Di suatu tempat di codebase kamu ada fungsi yang minta JSON ke LLM, lalu berdoa. Prompt-nya nulis "return JSON, no markdown", tapi yang balik adalah code block dengan kalimat pembuka, koma di akhir, dan satu key yang ganti nama. json.loads() lempar error, kamu retry. Retry-nya balikin JSON yang beda, dengan key lain yang ganti nama.
Retry loop kayak gini itu buang-buang. Tiap percobaan makan token, makan waktu, dan makan kesabaran. Dan fix-nya bukan prompt yang lebih bagus.
OpenAI dan Anthropic sama-sama punya structured outputs. API-nya ngompilasi JSON Schema kamu jadi grammar dan ngiket proses generate token, jadi model secara fisik nggak bisa ngeluarin output di luar schema. JSON valid dengan tipe yang bener dan semua key wajib ada, dijamin pas token-nya lagi dipilih, bukan ditambal setelah kejadian. Pandangan jujur saya: JSON andalan prompt doang itu cuma layak buat demo, bukan buat yang lain.
Prerequisites
- Python 3.10+
pip install openai anthropic pydantic- API key:
OPENAI_API_KEYdanANTHROPIC_API_KEY - Sekitar 10 menit
Kenapa cuma nyuruh prompt nggak cukup
Model bahasa itu generate token, bukan data structure. Nggak ada yang nge-stop dia nulis code fence, nambah prosa, ganti nama key, atau ngarang nilai enum. Prompt yang kuat nurunin kemungkinan gagalnya, tapi nggak ngilangin, dan mode gagalnya persis yang paling nyakitin:
- Syntax nggak valid: fence, koma di akhir, output kepotong
- Key wajib hilang atau ganti nama
- Tipe salah, kayak
"42"padahal harusnya42 - Key tambahan yang bikin parser kamu error atau diam-diam diabaikan
Masing-masing itu mahal: satu retry, bayar token dan latency lagi, dan hasil retry-nya bisa gagal dengan cara yang beda. Structured outputs ngilangin satu kategori bug ini. Dengan constrained decoding, schema ditegakkan pas token-nya lagi disampling, jadi json.loads nggak lagi jadi judi.
OpenAI: response_format dan chat.completions.parse
OpenAI punya structured outputs sejak GPT-4o. Buat project baru, dokumentasinya nyaranin gpt-5.6. Ada dua level: API mentah dan helper SDK.
JSON Schema mentah
Endpoint chat completions nerima response_format dengan type: "json_schema":
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "How do I solve 8x + 7 = -23?"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "math_reasoning",
"strict": True,
"schema": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"explanation": {"type": "string"},
"output": {"type": "string"},
},
"required": ["explanation", "output"],
"additionalProperties": False,
},
},
"final_answer": {"type": "string"},
},
"required": ["steps", "final_answer"],
"additionalProperties": False,
},
},
},
)
Dua hal yang penting di sini. strict: true ngaktifin penegakan schema, dan schema-nya harus lengkap: semua property didaftarin di required, additionalProperties: false, dan object di dalamnya juga diperlakukan sama. Helper SDK ngurusin itu semua otomatis.
Pydantic, cara yang praktis
Nulis JSON Schema mentah manual itu cepet bikin capek. SDK Python nerima model Pydantic langsung:
from pydantic import BaseModel
from openai import OpenAI
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
client = OpenAI()
completion = client.chat.completions.parse(
model="gpt-5.6",
messages=[
{"role": "system", "content": "Extract the event information."},
{"role": "user", "content": "Alice and Bob are going to a science fair on Friday."},
],
response_format=CalendarEvent,
)
event = completion.choices[0].message.parsed
print(event.name, event.participants)
message.parsed udah berupa instance CalendarEvent. Nggak ada json.loads, nggak ada ritual validasi. Dua sisi yang tetep jadi urusan kamu: penolakan karena safety balik di message.refusal, dan kalau kena batas max_tokens responsnya nggak lengkap. Cek dua-duanya:
message = completion.choices[0].message
if message.refusal:
print("refused:", message.refusal)
elif message.content:
print(message.content)
else:
raise Exception("No response content")
Lebih suka Responses API? Helper yang sama ada di sana: client.responses.parse(model=..., input=..., text_format=CalendarEvent), hasil parse-nya ada di response.output_parsed.
Anthropic: output_config.format dan messages.parse
Structured outputs di Anthropic cara kerjanya sama, parameternya di output_config.format dengan type: "json_schema". Model yang didukung antara lain claude-opus-5, claude-sonnet-5, dan keluarga claude-haiku-4-5.
JSON Schema mentah
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": (
"Extract the key information from this email: John Smith "
"([email protected]) is interested in our Enterprise plan and "
"wants to schedule a demo for next Tuesday at 2pm."
),
}
],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan_interest": {"type": "string"},
"demo_requested": {"type": "boolean"},
},
"required": ["name", "email", "plan_interest", "demo_requested"],
"additionalProperties": False,
},
}
},
)
print(next(block.text for block in response.content if block.type == "text"))
Outputnya JSON valid di text content block. Disiplinnya sama kayak OpenAI: required ditulis lengkap, additionalProperties: false.
Pydantic dengan messages.parse
messages.parse di SDK Python nerima model langsung. output_format itu parameter kemudahan yang diterjemahin SDK jadi output_config.format di belakang layar:
from pydantic import BaseModel
from anthropic import Anthropic
class ContactInfo(BaseModel):
name: str
email: str
plan_interest: str
demo_requested: bool
client = Anthropic()
response = client.messages.parse(
model="claude-opus-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": (
"Extract the key information from this email: John Smith "
"([email protected]) is interested in our Enterprise plan and "
"wants to schedule a demo for next Tuesday at 2pm."
),
}
],
output_format=ContactInfo,
)
print(response.parsed_output)
response.parsed_output udah berupa instance ContactInfo yang tervalidasi.
Mini project: invoice extractor
Contoh satu object doang udah cukup. Ini pola yang bakal beneran kamu pake: schema bersarang dengan line items dan enum, digerakin satu model Pydantic di dua provider.
from enum import Enum
from pydantic import BaseModel, Field
class Currency(str, Enum):
USD = "USD"
EUR = "EUR"
IDR = "IDR"
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
amount: float
class Invoice(BaseModel):
vendor: str = Field(description="Company that issued the invoice")
invoice_number: str
currency: Currency
line_items: list[LineItem]
total: float
due_date: str = Field(description="ISO 8601 date, for example 2026-09-01")
Dua catatan soal model di atas. Field(description=...) itu bukan hiasan: satu-satunya tempat model ngeliat maksud kamu soal due_date atau currency, jadi tulis kayak lagi ngasih instruksi. Dan jaga nilai enum tetap primitif. Dua provider cuma ngizinin enum berupa string, angka, boolean, dan null.
Extractor-nya, satu per provider, dua-duanya balikin Invoice yang sama:
def extract_with_openai(text: str) -> Invoice:
from openai import OpenAI
client = OpenAI()
completion = client.chat.completions.parse(
model="gpt-5.6",
messages=[
{
"role": "system",
"content": "Extract invoice data. Copy amounts as written, do not recalculate them.",
},
{"role": "user", "content": text},
],
response_format=Invoice,
)
return completion.choices[0].message.parsed
def extract_with_anthropic(text: str) -> Invoice:
from anthropic import Anthropic
client = Anthropic()
response = client.messages.parse(
model="claude-opus-5",
max_tokens=2048,
messages=[{"role": "user", "content": text}],
output_format=Invoice,
)
return response.parsed_output
Jalanin:
raw_invoice = """INVOICE INV-2026-0041
PT Maju Bersama, Jakarta
2x Server rack rails @ 450000 = 900000
1x KVM console @ 2800000 = 2800000
Total: IDR 3,700,000
Due date: 2026-09-01"""
invoice = extract_with_openai(raw_invoice)
print(invoice.vendor) # PT Maju Bersama
print(invoice.currency.value) # IDR
print(invoice.total) # 3700000.0
for item in invoice.line_items:
print(f"- {item.description}: {item.amount}")
Ganti ke extract_with_anthropic dan model yang sama tetep jalan. Itu intinya definisiin schema sekali di Pydantic: tetep portable lintas provider, dan JSON Schema yang dia generate jadi acuan validasi tiap API.
Gotcha yang bakal ngejegal kamu
Schema-nya subset dari JSON Schema. Dua provider cuma mendukung sebagian spec. Anthropic ngedokumentasiin limitnya secara eksplisit dan balikin error 400 buat fitur yang nggak didukung: nggak ada constraint numerik kayak minimum, nggak ada constraint panjang string kayak minLength, dan minItems array cuma 0 atau 1. Strict mode OpenAI nuntut bentuk yang sama-sama lengkap, jadi jaga schema tetap di primitif, enum, object bersarang, dan array dari itu semua. Kalau butuh nilai antara 1 sampai 100, taruh itu di description dan validasi di kode.
Schema rekursif. OpenAI mendukung: definisikan model dengan referensi diri lalu panggil model_rebuild(). Anthropic nggak mendukung schema rekursif sama sekali. Buat struktur bersarang kayak layout UI, OpenAI jalurnya lebih gampang.
Kapitalisasi enum. Anthropic nggak njamin kapitalisasi nilai string enum. Schema yang nulis "Conversation topic 3" bisa balik jadi "Conversation Topic 3". Bandingin case-insensitive, dan jangan pernah bikin nilai enum yang cuma beda kapital.
Latensi kompilasi grammar. Anthropic ngompilasi schema kamu jadi grammar. Request pertama dengan schema baru keliatan lebih lambat, grammar yang udah dikompilasi di-cache 24 jam sejak pemakaian terakhir, dan cache-nya invalid kalau struktur schema atau daftar tools berubah.
Biaya token. Anthropic nyuntikin system prompt yang ngejelasin format output. Itu makan token kayak system prompt lain, dan ganti output_config.format bakal nge-invalidate prompt cache di thread itu.
Refusal dan output kepotong tetap urusan kamu. Structured outputs njamin bentuk jawabannya, bukan njamin modelnya jawab. Tangani message.refusal di OpenAI, cek stop_reason di Anthropic, dan anggap respons yang deket max_tokens itu nggak lengkap.
Kapan pake structured outputs vs function calling
Dua provider narik garis yang sama. Kalau model harus memicu sesuatu di sistem kamu, kayak query database atau panggilan tool, pake function calling. OpenAI nyediain lewat tools; Anthropic nambahin strict: true di tool buat validasi input_schema-nya. Kalau balasan model ke user yang harus terstruktur, payload UI atau record hasil ekstraksi, pake structured response format.
JSON mode polos masih ada di OpenAI (json_object) buat model lama. Nggak ada alasan milih itu buat kerjaan baru: dia cuma njamin JSON valid, bukan bentuknya.
Langkah selanjutnya
- Bungkus extractor dengan retry yang cuma jalan pas refusal atau output kepotong, jangan pernah pas pelanggaran schema, karena itu udah nggak seharusnya terjadi
- Tetep pasang lapisan validasi. Structured outputs matiin sebagian besar bug parsing, tapi dokumen sumber yang jelek tetep bisa ngasilin ekstraksi yang jelek
- Jalanin model Pydantic yang sama di dua provider pake eval set kecil. Schema-nya identik; bedanya keliatan di kasus pinggir kayak kapitalisasi enum