Du hast ein Feature ausgeliefert, das GPT-4 aufruft, um strukturierte Daten aus von Nutzern eingereichten Rechnungen zu extrahieren.
In der Entwicklung funktioniert es perfekt — das Modell gibt jedes Mal ein sauberes JSON-Objekt zurück. Dann in Produktion,
um 2 Uhr nachts, kommt ein Sentry-Alert: JSON.parse: unexpected token. Das Modell hat beschlossen, seine
Antwort mit "Klar! Hier ist das JSON, das du wolltest:" vor dem eigentlichen Payload einzuleiten. Eine Woche später, gleiches Feature,
anderer Bug: das Modell gibt totalAmount statt total_amount zurück, und dein
downstream Datenbank-Write verliert das Feld stillschweigend. Wenn du bisher mit Prompt Engineering um die
LLM-Output-Zuverlässigkeit herumgemanövriert bist,
OpenAI Structured Outputs
ist die Lösung, auf die du gewartet hast.
Structured Outputs, von OpenAI im August 2024 veröffentlicht, erlaubt dir, ein
JSON Schema
über den response_format-Parameter zu liefern und eine garantiert gültige Antwort zu bekommen, die
genau diesem Schema entspricht. Das unterscheidet sich vom älteren JSON-Mode ({"type": "json_object"}),
der nur sicherstellte, dass der Output valides JSON war — nicht, dass er einer bestimmten Form entsprach. Es ist auch zu unterscheiden
von Function Calling, das den Output des Modells in einen Tool-Call routet, aber seine eigene Schicht Zeremoniell hinzufügt.
Structured Outputs ist der sauberste Weg: beschreib die Form, die du willst, und bekomm genau diese Form zurück, jedes Mal.
Unter der Haube nutzt OpenAI Constrained Decoding — das Token-Sampling des Modells wird durch dein Schema geleitet, sodass
es buchstäblich keine ungültige Antwort produzieren kann.
Dein erster 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}Drei Dinge zu beachten hier. Erstens: das Modell ist gpt-4o-2024-08-06 — Structured Outputs
verlangt ein Modell, das es explizit unterstützt (der -2024-08-06-Snapshot oder später für GPT-4o, oder
gpt-4o-mini). Zweitens: response_format.type ist "json_schema", nicht
"json_object". Drittens: "strict": True ist das, was dir die Garantie gibt —
ohne das bist du wieder im Best-Effort-Gebiet. Das name-Feld ist ein Label, das das Modell sieht;
es hat keinen Einfluss auf das Parsing, macht aber deine API-Logs lesbar.
Das JSON Schema entwerfen
Hier ist ein realistischeres Schema für eine Produktkatalog-Extraktionsaufgabe — die Art, die du nutzen würdest, um strukturierte Daten aus unstrukturierten Produktbeschreibungen, E-Commerce-Listings oder PDF-Datenblättern zu ziehen. Nutze den JSON Schema Generator, um dein Schema visuell zu bauen und zu validieren, bevor du es in deine API-Aufrufe verdrahtest.
{
"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 müssen im Strict-Modus in
requiredstehen. Du kannst keine optionalen Felder haben. Wenn ein Feld in den Ausgangsdaten möglicherweise nicht existiert, nutz einen Union-Typ:{"type": ["string", "null"]}und nimm es immer inrequiredauf. additionalPropertiesmuss auf jeder Objekt-Ebenefalsesein. Das gilt rekursiv — deine verschachtelten Objekte brauchen es auch, nicht nur die Wurzel.- Unterstützte Typen im Strict-Modus:
string,number,integer,boolean,null,array,object. Typ-Unions (["string", "null"]) sind erlaubt. - Kein
$refoder rekursive Schemas im Strict-Modus. Alles muss inline sein. Wenn du eine wiederverwendbare Definition brauchst, kopier sie. - Füge
description-Felder großzügig hinzu. Das Modell liest sie. "Preis in US-Dollar, nur numerisch — keine Währungssymbole" gibt dir saubereren Output, als darauf zu hoffen, dass das Modell richtig rät. - Enums funktionieren.
{"type": "string", "enum": ["pending", "shipped", "delivered"]}wird voll unterstützt, und das Modell wird nur einen dieser drei Werte emittieren.
Strict-Modus vs Nicht-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
}
}Mit strict: True verarbeitet OpenAI dein Schema bei der ersten Nutzung vor
und cached den Constrained Decoder. Der erste Call mit einem neuen Schema dauert etwas länger; nachfolgende
Calls mit demselben Schema sind schnell. Was du im Gegenzug bekommst: der Model-Output ist strukturell
garantiert — du kannst json.loads() aufrufen und dann direkt auf Felder zugreifen, ohne
defensive Checks. Was du aufgibst: $ref, anyOf über strukturelle Varianten
hinweg, und rekursive Schemas werden nicht unterstützt. Nicht-Strict-Mode akzeptiert eine breitere Palette
von JSON Schema-Features,
fällt aber auf Best-Effort zurück — das Modell versucht, dem Schema zu folgen, wird aber auf Token-Ebene nicht
eingeschränkt. Für Produktions-Extraktions-Pipelines immer Strict-Modus nutzen. Die Schema-Einschränkungen
sind handhabbar, sobald du sie verstehst.
Verschachtelte Objekte und Arrays
Verschachtelte Strukturen funktionieren gut, aber jedes verschachtelte Objekt braucht sein eigenes "additionalProperties": false
und sein eigenes "required"-Array, das alle Properties auflistet. Ein häufiger Fehler ist, Strict-Regeln
auf das Root-Objekt anzuwenden und die Kinder zu vergessen — OpenAI lehnt das Schema dann mit einem Validierungsfehler ab.
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 behandeln
Auch mit Structured Outputs kann das Modell sich weigern zu antworten — typischerweise, wenn der Prompt
eine Content-Policy auslöst (wenn du es bittest, Daten aus etwas Schädlichem zu extrahieren). Wenn das passiert,
ist finish_reason "stop", aber message.content ist null
und message.refusal enthält den Refusal-Text. Wenn du das nicht prüfst, bekommst du einen
AttributeError, wenn du versuchst, json.loads(None) aufzurufen. Pass auch auf
finish_reason == "length" auf — wenn die Antwort wegen max_tokens abgeschnitten wurde,
ist das JSON unvollständig und nicht parsebar, unabhängig von 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"])Dasselbe Schema zur Validierung nutzen
Ein unterschätztes Pattern: nutz dasselbe JSON Schema,
das du an OpenAI übergibst, auch, um Daten zu validieren, die aus anderen Quellen reinkommen — Webhooks, Datei-Uploads,
Drittanbieter-APIs. Das gibt dir eine Single Source of Truth für dein Datenformat. In Python nutz die
jsonschema-Library. In Node.js nutz Ajv.
Du kannst dein Schema auch in den JSON Validator pasten, um einen schnellen
manuellen Sanity-Check zu machen, ohne Code zu schreiben.
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-Version
Das OpenAI Node.js SDK
spiegelt die Python-API fast exakt. Der Hauptunterschied ist, dass strict im
json_schema-Objekt auf dieselbe Art sitzt, und du die Antwort mit JSON.parse() parst.
Validierung mit Ajv ist das Node.js-Äquivalent
zu Pythons jsonschema-Library — sie ist schneller und hat exzellenten TypeScript-Support.
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}`)
);Fazit
Structured Outputs eliminiert eine ganze Kategorie von Produktions-Bugs — die Art, bei der das Modell
fast-richtiges JSON zurückgibt, das deinen Parser um 2 Uhr nachts kaputt macht. Der Workflow ist unkompliziert: entwirf dein
Schema sorgfältig (jedes verschachtelte Objekt braucht additionalProperties: false und ein vollständiges
required-Array), setz strict: true, und behandle Refusals und Truncation explizit.
Sobald das Schema steht, kannst du es über deinen ganzen Stack wiederverwenden — im OpenAI-Call, in Webhook-Validierung,
in deinen Test-Fixtures — mit Libraries wie jsonschema (Python) oder
Ajv (Node.js). Wenn du ein Schema von Grund auf startest,
ist der JSON Schema Generator der schnellste Weg
zu einem funktionierenden Basis-Schema aus einem Beispiel-Payload. Die Tage des Prompt-Engineering um verlässlichen
JSON-Output sind vorbei — nutz das Tool, das genau dafür gebaut wurde.