← Kembali ke Blog

Structured Outputs: Bikin LLM Balikin JSON Valid, Tanpa Retry Loop

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_KEY dan ANTHROPIC_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 harusnya 42
  • 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

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis