Kamu minta LLM ekstrak objek JSON dari tiket support. Dia balikin sesuatu yang kayak JSON, hampir keparse, terus downstream code kamu meledak gara-gara koma yang ilang. Kamu tulis "return valid JSON" di system prompt, hasilnya jalan 80% dari waktu, dan itu nggak cukup buat production.
Setiap provider besar sekarang punya cara untuk menjamin model mengembalikan objek yang sesuai schema. Namanya beda-beda, mekanismenya beda, tapi tujuannya sama: berhenti parsing string, mulai konsumsi typed data.
Artikel ini membahas empat pendekatan yang bakal kamu pakai di production: response_format OpenAI, tool use Anthropic dengan Claude 4, responseSchema Gemini, dan parameter format Ollama untuk model lokal. Kode copy-paste, dengan jebakan ditunjukin di tempat dia bikin masalah.
Kenapa "tolong balikin JSON" nggak jalan
LLM itu token predictor. Dia nggak tahu JSON dengan cara yang sama seperti parser. String "{" di data training-nya diikuti banyak hal: object literal di JavaScript, matematika, prosa. Minta tolong cuma menggeser distribusi, bukan constrain.
Failure mode umum yang sering saya temui di sistem production:
- Trailing comma di array atau object
- Markdown code fence ngebungkus JSON (
json ...) - Komentar kayak
// field di bawah ini optional - Output terpotong waktu
max_tokensabis di tengah string - Prosa nyampur kayak "Berikut JSON-nya: { ... } Semoga membantu!"
- Tipe salah: balikin
"5"untuk field integer, atau"true"sebagai string
Structured output memecahkan semua ini dengan cara constrain sampling (Anthropic, OpenAI strict mode) atau validasi post-hoc terhadap schema (semua provider mendukung ini sebagai fallback).
Prerequisites
- Python 3.10+ dengan
pip install openai anthropic google-genai ollama pydantic - API key untuk cloud provider yang mau kamu tes (atau cukup jalur Ollama lokal)
- Untuk Ollama: install dari ollama.com dan pull model yang support structured output (
ollama pull llama3.1:8b)
Pola bersama: Pydantic sebagai sumber kebenaran
Sebagian besar project Python settle di Pydantic untuk definisi schema. Ekspresif, validasi bagus, dan kebanyakan SDK LLM punya jalur dari Pydantic ke format schema provider.
from pydantic import BaseModel, Field
from typing import Literal
class SupportTicket(BaseModel):
category: Literal["billing", "technical", "account", "other"]
priority: Literal["low", "medium", "high", "urgent"]
summary: str = Field(description="Ringkasan satu kalimat dari masalah")
needs_human: bool = Field(description="True kalau butuh follow up agen manusia")
Tiap provider ambil jalur berbeda dari model ini ke constraint. Begini bentuknya.
OpenAI: json_schema dengan strict mode
OpenAI kenalin structured outputs di Agustus 2024. Strict mode pakai constrained decoding: server API bangun finite state machine dari schema kamu dan paksa output model melaluinya token per token. Model secara harfiah nggak bisa produksi string yang nggak keparse.
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Ekstrak data terstruktur dari tiket support."},
{"role": "user", "content": "Invoice bulan lalu nunjukin $250 padahal saya cuma ditagih $180. Ini yang kedua kalinya. Tolong fix dan refund selisihnya."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "support_ticket",
"strict": True,
"schema": SupportTicket.model_json_schema(),
},
},
)
ticket = SupportTicket.model_validate_json(resp.choices[0].message.content)
print(ticket.category, ticket.priority)
Beberapa hal yang perlu diketahui:
- Flag
strict: trueadalah kuncinya. Tanpa itu, OpenAI fallback ke mode JSON-only yang kurang reliable dan masih sesekali produksi output jelek. additionalProperties: falsewajib. Pydantic emit ini secara default dimodel_json_schema(), tapi kalau kamu nulis schema manual, tambahin.- Semua field harus masuk
required. PakaiOptional[T] = Noneuntuk field nullable; jangan omit dari schema. - Model ke-pin ke snapshot yang support structured outputs. Per Juli 2026, ID yang relevan:
gpt-4o-2024-08-06dan setelahnya,gpt-4o-mini-2024-07-18dan setelahnya, plus keluarga o1. Snapshot lama ignore tipejson_schema.
Pydantic punya one-liner helper kalau kamu males ngetik wrapper-nya:
pip install pydantic-ai
Tapi untuk kode production, manggil OpenAI client secara langsung kasih kamu kontrol lebih buat retry, timeout, dan streaming.
Anthropic: tool use sebagai mekanisme structured output
Anthropic nggak punya parameter "response format" dalam arti yang sama. Sebagai gantinya, kamu definisiin tool, tandain sebagai required, dan baca argumennya. Model tetap harus manggil tool, tapi constraint schema bikin argumennya valid.
import anthropic
client = anthropic.Anthropic()
tool = {
"name": "extract_ticket",
"description": "Ekstrak field terstruktur dari tiket support",
"input_schema": SupportTicket.model_json_schema(),
}
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[tool],
tool_choice={"type": "tool", "name": "extract_ticket"},
messages=[
{"role": "user", "content": "Invoice bulan lalu nunjukin $250 padahal saya cuma ditagih $180. Ini yang kedua kalinya. Tolong fix dan refund selisihnya."},
],
)
# Cari tool use block
for block in resp.content:
if block.type == "tool_use" and block.name == "extract_ticket":
ticket = SupportTicket.model_validate(block.input)
print(ticket.category, ticket.priority)
break
Claude 4 juga support fitur bernama structured outputs (dalam beta) yang pakai constrained sampling kayak OpenAI. Per Juli 2026 beta header-nya structured-outputs-2025-09-15. Kalau kamu di model terbaru dan nggak butuh streaming, prefer yang itu daripada trik tool-use, karena kasih jaminan yang sama kayak OpenAI strict mode.
Pola tool-use tetap berguna karena:
- Jalan di semua model Claude, termasuk yang lama yang belum punya fitur beta
- Compose naturally dengan tool lain di agent loop
- Parameter
tool_choicebisa paksa model manggil tool spesifik, yang kamu mau untuk extraction
Google Gemini: responseSchema
Gemini ambil jalur yang sedikit beda. Kamu passing JSON Schema langsung lewat generation_config.response_schema dan set response_mime_type ke application/json.
from google import genai
client = genai.Client()
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents="Invoice bulan lalu nunjukin $250 padahal saya cuma ditagih $180. Ini yang kedua kalinya. Tolong fix dan refund selisihnya.",
config={
"response_mime_type": "application/json",
"response_schema": SupportTicket.model_json_schema(),
},
)
ticket = SupportTicket.model_validate_json(resp.text)
print(ticket.category, ticket.priority)
Gemini support constrained decoding di model 2.0 dan setelahnya. Di model 1.5 yang lebih lama, parameter yang sama jalan tapi dengan jaminan yang lebih lemah, jadi tetap validasi response-nya.
Quirk yang perlu diketahui: Gemini nggak handle tipe Literal dengan cara yang sama kayak OpenAI. Dia mau enum di JSON Schema, yang memang di-emit Pydantic dengan benar, tapi kalau kamu bangun schema manual, pakai "enum": [...] bukan "type": "literal".
Ollama: parameter format untuk model lokal
Model lokal adalah mata rantai terlemah di cerita ini. Nggak semuanya support constrained decoding, dan yang support mungkin nggak support tipe schema spesifik kamu.
Parameter format Ollama nerima nama schema ("json") atau objek JSON Schema lengkap. Waktu kamu passing schema, model yang support grammar-constrained sampling (Llama 3.1, Qwen 2.5, Mistral Nemo) akan disteerin buat produksi output valid.
import ollama
resp = ollama.chat(
model="llama3.1:8b",
messages=[
{"role": "user", "content": "Invoice bulan lalu nunjukin $250 padahal saya cuma ditagih $180. Ini yang kedua kalinya. Tolong fix dan refund selisihnya."},
],
format=SupportTicket.model_json_schema(),
)
ticket = SupportTicket.model_validate_json(resp["message"]["content"])
print(ticket.category, ticket.priority)
Jebakannya: format dengan JSON Schema cuma jalan di model yang shipped dengan GBNF grammar yang kompatibel. List lengkapnya ada di Ollama structured outputs docs. Untuk model yang nggak support, satu-satunya opsi adalah format="json", yang cuma nudge model buat balikin JSON tanpa enforce schema. Dalam kasus itu, kamu harus validasi response dan retry kalau gagal.
Untuk model yang support, output ke-constrained di level token. Untuk yang nggak, kamu dapet string JSON "best effort" yang masih butuh parsing dan validasi.
Defensive parsing: validasi semua, termasuk output yang sudah ke-constraint
Schema-constrained output bukan pengganti validasi. Model nggak bisa produksi JSON malformed, tapi model masih bisa produksi nilai yang nggak masuk akal untuk domain kamu (priority "super-urgent" di field free-text, summary yang kosong, category yang technically valid tapi salah secara semantik).
Selalu jalanin Pydantic validation di response, bahkan waktu provider jamin schema-nya:
from pydantic import ValidationError
def extract_ticket(text: str, max_retries: int = 2) -> SupportTicket:
for attempt in range(max_retries + 1):
try:
raw = call_provider(text) # fungsi provider-specific kamu
return SupportTicket.model_validate_json(raw)
except ValidationError as e:
if attempt == max_retries:
raise
# Feed error balik ke model dan retry
text = f"{text}\n\nPercobaan sebelumnya gagal: {e}. Coba lagi dengan nilai yang valid."
raise RuntimeError("unreachable")
Untuk jalur yang nggak ke-constraint (Ollama tanpa grammar support, model Gemini lama, atau provider dalam mode non-strict), bungkus call dalam retry loop dan feed validation error balik. Model biasanya benerin di percobaan kedua karena error message-nya bilang tepat apa yang salah.
Performa dan biaya
Structured output nggak gratis. Constrained decoding bangun state machine per request dan model harus melaluinya, yang nambah latency dan nurunin throughput.
Di tes saya di gpt-4o-mini:
- Plain JSON prompt: median latency ~0.6 detik
- Strict
json_schema: median latency ~0.9 detik
Itu 50% lebih lambat buat jaminan output valid. Untuk batch job high-throughput, worth it ngukur apakah biaya retry di plain JSON (rata-rata 5-10% failure rate) lebih murah dari latency hit di strict mode.
Untuk agent loop yang manggil model secara berurutan, latency biasanya nggak relevan. Retry path itu yang mahal. Strict mode nelan retry, yang mendominasi.
Kapan pakai apa
OpenAI strict mode kalau kamu all-in di OpenAI. Developer experience terbaik, iterasi paling cepat, dukungan model paling luas.
Anthropic tool use kalau kamu pake Claude dan mau pola yang jalan di semua versi model. Pindah ke beta structured outputs waktu kamu kontrol versi model dan nggak butuh streaming.
Gemini responseSchema kalau Gemini primary provider atau kamu butuh pricing/throughput-nya. Pasangan response_mime_type + response_schema adalah API paling bersih dari keempatnya.
Ollama format untuk development lokal, workload yang privacy-sensitive, atau batch processing zero-cost. Pastiin kamu pilih model dari list yang support; kalau nggak, kamu kembali ke "tolong balikin JSON" dan butuh retry loop.
Untuk setup multi-provider (OpenAI buat production, Ollama buat dev lokal), abstraksi paling bersih adalah wrapper kecil yang convert Pydantic model ke format tiap provider. Pydantic AI, Instructor, dan Outlines semua melakukan ini, dan untuk setup 2-3 provider, integration-nya sekitar 50 baris kode. Untuk lebih dari itu, ambil library.
Failure mode umum di production
Streaming dengan constrained output. OpenAI support streaming structured outputs, tapi response datang dalam delta, bukan objek lengkap. Kamu butuh partial JSON parser buat ngerakit. Library instructor handle ini; ngerjain manual itu footgun.
Nested optionals dan unions. Union[A, B] dan Optional[A] Pydantic produksi JSON Schema dengan oneOf dan anyOf. Keempat provider support ini, tapi constraint engine lebih lambat dan beberapa model sesekali gagal pilih branch yang tepat. Tes edge case secara eksplisit.
String panjang. Schema-constrained output jalan fine untuk teks pendek, tapi kalau kamu minta model ngegenerasi esai 10.000 token yang ke-constraint ke schema, sampling-nya bisa nyangkut. Untuk long-form generation, stream tanpa constraint dan validasi hasil akhir.
Schema drift. Waktu kamu ganti Pydantic model, kamu ganti kontraknya. OpenAI cache schema berdasarkan nama; kalau kamu ganti schema tanpa bump nama, yang lama mungkin disajiin. Selalu bump nama schema di breaking change, atau pake hash.
Implementasi referensi
Wrapper kecil yang jalan di keempat provider:
from typing import Type, TypeVar
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
def extract(provider: str, model: str, text: str, schema: Type[T]) -> T:
if provider == "openai":
return _openai_extract(model, text, schema)
elif provider == "anthropic":
return _anthropic_extract(model, text, schema)
elif provider == "gemini":
return _gemini_extract(model, text, schema)
elif provider == "ollama":
return _ollama_extract(model, text, schema)
else:
raise ValueError(f"Unknown provider: {provider}")
Tiap fungsi _provider_extract itu 10-20 baris: bangun request provider-specific, panggil API, validasi pakai Schema.model_validate_json (atau Schema.model_validate untuk tool input Anthropic), balikin typed object. Total surface area cukup kecil sampai ngejaga wrapper kustom lebih oke daripada berantem sama library yang mungkin atau mungkin nggak support campuran provider dan model kamu.
Pertanyaan arsitektur yang lebih besar adalah apakah kamu butuh abstraksi level library sama sekali. Untuk setup single-provider, kamu nggak butuh. Untuk multi-provider, trade-off-nya antara nulis 200 baris glue code atau nerima error handling, retry policy, dan streaming behavior yang opinionated dari library. Saya condong ke glue code untuk sistem production di mana failure mode harus keliatan, dan ke library untuk prototype di mana kecepatan iterasi yang penting.