Je hebt een feature uitgerold die GPT-4 aanroept om gestructureerde data uit door gebruikers ingediende facturen te halen.
In development werkt het perfect — het model geeft elke keer een nette JSON-object terug. Dan in productie,
om 02:00, krijg je een Sentry-alert: JSON.parse: unexpected token. Het model besloot zijn
antwoord vooraf te laten gaan door "Natuurlijk! Hier is de JSON die je vroeg:" vóór de echte payload. Een week later, dezelfde feature,
andere bug: het model geeft totalAmount terug in plaats van total_amount, en je
downstream database-write dropt het veld stilletjes. Als je om de betrouwbaarheid van LLM-output heen hebt lopen prompten,
is OpenAI Structured Outputs
de fix waar je op hebt gewacht.
Structured Outputs, door OpenAI uitgebracht in augustus 2024, laat je een
JSON Schema
meegeven via de response_format-parameter en een gegarandeerd geldige response ontvangen die
exact overeenkomt met dat schema. Dit is anders dan de oudere JSON-modus ({"type": "json_object"}),
die alleen garandeerde dat de output geldige JSON was — niet dat hij een bepaalde vorm had. Het is ook anders
dan function calling, dat de output van het model naar een tool-call routeert maar zijn eigen laag ceremonie toevoegt.
Structured Outputs is het schoonste pad: beschrijf de vorm die je wilt, krijg exact die vorm terug, elke keer.
Onder de motorkap gebruikt OpenAI constrained decoding — het samplen van tokens door het model wordt gestuurd door je schema, zodat het
letterlijk geen ongeldige response kan produceren.
Je eerste Structured Output
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": "Extract the vendor name, invoice number, and total amount from this text: "
"Invoice #INV-2024-0892 from Acme Supplies Ltd. Total due: $1,450.00"
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
}
}
)
data = json.loads(response.choices[0].message.content)
print(data)
# {"vendor_name": "Acme Supplies Ltd", "invoice_number": "INV-2024-0892", "total_amount": 1450.0}Drie dingen om hier op te merken. Ten eerste, het model is gpt-4o-2024-08-06 — Structured Outputs
vereist een model dat het expliciet ondersteunt (de -2024-08-06-snapshot of later voor GPT-4o, of
gpt-4o-mini). Ten tweede is response_format.type "json_schema", niet
"json_object". Ten derde, "strict": True is wat je de garantie geeft —
zonder ben je terug in best-effort-territorium. Het name-veld is een label dat het model ziet;
het heeft geen effect op het parsen, maar maakt je API-logs leesbaar.
Het JSON Schema ontwerpen
Hier is een realistischer schema voor een product-catalog-extractietaak — van het soort dat je zou gebruiken om gestructureerde data uit ongestructureerde productbeschrijvingen, e-commerce-listings of PDF-datasheets te halen. Gebruik de JSON Schema Generator om je schema visueel op te bouwen en te valideren voor je het in je API-calls inbouwt.
{
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "The full commercial name of the product"
},
"sku": {
"type": "string",
"description": "Stock keeping unit identifier"
},
"price_usd": {
"type": "number",
"description": "Price in US dollars, numeric only"
},
"in_stock": {
"type": "boolean"
},
"categories": {
"type": "array",
"items": { "type": "string" }
},
"dimensions": {
"type": "object",
"properties": {
"width_cm": { "type": "number" },
"height_cm": { "type": "number" },
"depth_cm": { "type": "number" }
},
"required": ["width_cm", "height_cm", "depth_cm"],
"additionalProperties": false
}
},
"required": [
"product_name", "sku", "price_usd",
"in_stock", "categories", "dimensions"
],
"additionalProperties": false
}- Alle properties moeten in strict mode in
requiredstaan. Je kunt geen optionele velden hebben. Als een veld mogelijk niet in de brondata bestaat, gebruik dan een union-type:{"type": ["string", "null"]}en neem het altijd op inrequired. additionalPropertiesmoetfalsezijn op elk object-niveau. Dit geldt recursief — je geneste objects hebben het ook nodig, niet alleen de root.- Ondersteunde types in strict mode:
string,number,integer,boolean,null,array,object. Type-unions (["string", "null"]) zijn toegestaan. - Geen
$refof recursieve schema's in strict mode. Alles moet inlined zijn. Als je een herbruikbare definitie nodig hebt, kopieer hem dan. - Voeg royaal
description-velden toe. Het model leest ze. "Prijs in Amerikaanse dollars, alleen numeriek — neem geen valuta-symbolen mee" geeft je schonere output dan hopen dat het model het goed gokt. - Enums werken.
{"type": "string", "enum": ["pending", "shipped", "delivered"]}wordt volledig ondersteund en het model zal alleen ooit een van die drie waarden emitteren.
Strict mode vs non-strict
# Strict mode — guaranteed conformance, tighter schema rules
response_format_strict = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": True, # <-- the key flag
"schema": product_schema
}
}
# Non-strict — more schema flexibility, best-effort conformance
response_format_lenient = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": False,
"schema": product_schema
}
}Met strict: True pre-processeert OpenAI je schema de eerste keer dat het wordt gebruikt
en cachet hij de constrained decoder. De eerste call met een nieuw schema duurt iets langer; opvolgende
calls met hetzelfde schema zijn snel. Wat je ervoor terugkrijgt: de output van het model is structureel
gegarandeerd — je kunt json.loads() aanroepen en dan direct velden benaderen zonder
defensieve checks. Wat je opgeeft: $ref, anyOf over structurele varianten,
en recursieve schema's worden niet ondersteund. Non-strict mode accepteert een breder scala aan
JSON Schema-
features maar valt terug op best-effort — het model probeert het schema te volgen, maar wordt niet beperkt
op tokenniveau. Gebruik voor productie-extractie-pipelines altijd strict mode. De schema-beperkingen
zijn te behappen zodra je ze begrijpt.
Geneste objects en arrays
Geneste structuren werken goed, maar elk genest object heeft zijn eigen "additionalProperties": false
en eigen "required"-array nodig met alle properties erin. Een veelvoorkomende fout is strict rules toepassen
op het root-object en de children vergeten — OpenAI weigert het schema dan met een validatiefout.
from openai import OpenAI
import json
client = OpenAI()
order_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"customer": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": False # required on nested objects too
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False # required on array item schemas too
}
},
"total_usd": {"type": "number"}
},
"required": ["order_id", "customer", "line_items", "total_usd"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{
"role": "user",
"content": (
"Parse this order: Order #ORD-5531 for Jane Smith ([email protected]). "
"2x Wireless Keyboard at $49.99 each, 1x USB Hub at $29.99. Total: $129.97"
)
}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "order_extract",
"strict": True,
"schema": order_schema
}
}
)
order = json.loads(response.choices[0].message.content)
for item in order["line_items"]:
print(f"${item['unit_price']:.2f} x{item['quantity']} {item['description']}")Refusals afhandelen
Zelfs met Structured Outputs kan het model weigeren te antwoorden — doorgaans wanneer de prompt
een content policy triggert (hem vragen data uit iets schadelijks te halen). Als dat gebeurt,
is finish_reason "stop" maar is message.content null
en bevat message.refusal de weiger-tekst. Als je hier niet op checkt, krijg je
een AttributeError wanneer je json.loads(None) probeert aan te roepen. Let ook op
finish_reason == "length" — als de response werd afgekapt vanwege max_tokens,
is de JSON incompleet en niet parseerbaar, ongeacht Structured Outputs.
import json
from openai import OpenAI
client = OpenAI()
def extract_invoice(raw_text: str) -> dict | None:
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": f"Extract invoice fields: {raw_text}"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": invoice_schema
}
},
max_tokens=1024
)
choice = response.choices[0]
if choice.finish_reason == "length":
raise ValueError("Response truncated — increase max_tokens or simplify your schema")
if choice.message.refusal:
# Model refused to answer — log and return None rather than crashing
print(f"Model refused: {choice.message.refusal}")
return None
return json.loads(choice.message.content)
result = extract_invoice("Invoice #2024-441 from BuildRight Inc., due $3,200 by Dec 15")
if result:
print(result["vendor_name"], result["total_amount"])Hetzelfde schema gebruiken voor validatie
Een onderbenut patroon: gebruik hetzelfde JSON Schema
dat je aan OpenAI doorgeeft ook om data te valideren die binnenkomt uit andere bronnen — webhooks, file-uploads,
third-party API's. Dat geeft je één single source of truth voor je data-vorm. Gebruik in Python de
jsonschema-library. Gebruik in Node.js Ajv.
Je kunt je schema ook in de JSON Validator plakken voor een snelle
handmatige sanity-check zonder code te schrijven.
import json
import jsonschema
from jsonschema import validate, ValidationError
# The same schema used in your OpenAI call
invoice_schema = {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
def validate_invoice(data: dict) -> bool:
try:
validate(instance=data, schema=invoice_schema)
return True
except ValidationError as e:
print(f"Validation failed: {e.message}")
print(f" Path: {' -> '.join(str(p) for p in e.path)}")
return False
# Validate a payload from a webhook — same schema, zero extra work
webhook_payload = json.loads(request_body)
if not validate_invoice(webhook_payload):
return HTTPResponse(status=400, body="Invalid invoice payload")
# Validate the OpenAI output too, for belt-and-suspenders safety
llm_output = json.loads(openai_response.choices[0].message.content)
assert validate_invoice(llm_output), "LLM output failed schema validation — check schema definition"
print(f"Processing invoice {llm_output['invoice_number']} for ${llm_output['total_amount']}")response_format.JavaScript / Node.js-versie
De OpenAI Node.js SDK
spiegelt de Python-API bijna exact. Het belangrijkste verschil is dat strict op dezelfde manier in het
json_schema-object zit, en dat je de response parset met JSON.parse().
Validatie met Ajv is het Node.js-
equivalent van Pythons jsonschema-library — het is sneller en heeft uitstekende TypeScript-ondersteuning.
import OpenAI from "openai";
import Ajv from "ajv";
const client = new OpenAI();
const ajv = new Ajv();
const invoiceSchema = {
type: "object",
properties: {
vendor_name: { type: "string" },
invoice_number: { type: "string" },
total_amount: { type: "number" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
amount: { type: "number" }
},
required: ["description", "amount"],
additionalProperties: false
}
}
},
required: ["vendor_name", "invoice_number", "total_amount", "line_items"],
additionalProperties: false
};
const validateInvoice = ajv.compile(invoiceSchema);
async function extractInvoice(rawText) {
const response = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [{ role: "user", content: `Extract invoice fields: ${rawText}` }],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_extract",
strict: true,
schema: invoiceSchema
}
}
});
const choice = response.choices[0];
if (choice.message.refusal) {
throw new Error(`Model refused: ${choice.message.refusal}`);
}
const data = JSON.parse(choice.message.content);
// Validate even though strict mode guarantees structure —
// useful for catching schema drift between environments
if (!validateInvoice(data)) {
console.error("Schema validation errors:", validateInvoice.errors);
throw new Error("Output failed schema validation");
}
return data;
}
const invoice = await extractInvoice(
"Invoice #INV-881 from Nordic Parts AS. " +
"3x Brake Pads at $28.50 each. Total: $85.50"
);
console.log(`${invoice.vendor_name} — Invoice ${invoice.invoice_number}`);
invoice.line_items.forEach(item =>
console.log(` ${item.description}: $${item.amount}`)
);Afronding
Structured Outputs elimineert een hele categorie productiebugs — het soort waarbij het model
bijna-goede JSON teruggeeft die je parser om 02:00 breekt. De workflow is rechttoe rechtaan: ontwerp je
schema zorgvuldig (elk genest object heeft additionalProperties: false en een volledige
required-array nodig), zet strict: true, en handel refusals en truncatie expliciet af.
Zodra het schema er staat, kun je het hergebruiken in je hele stack — in de OpenAI-call, in webhook-validatie,
in je test-fixtures — met libraries als jsonschema (Python) of
Ajv (Node.js). Als je from scratch begint
met een schema, is de JSON Schema Generator de snelste manier
om een werkend basis-schema uit een voorbeeld-payload te krijgen. De dagen van prompt-engineering om tot betrouwbare
JSON-output te komen zijn voorbij — gebruik de tool die daarvoor is gebouwd.