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

python
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.

json
{
  "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 required staan. 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 in required.
  • additionalProperties moet false zijn 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 $ref of 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

python
# 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.

python
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.

python
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.

python
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']}")
Bouw je schema sneller: Gebruik de JSON Schema Generator om je schema visueel te maken en te verfijnen — plak een voorbeeld-JSON-object en hij genereert automatisch een startschema. Kopieer het resultaat direct in je OpenAI 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.

js
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.