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

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}

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.

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 müssen im Strict-Modus in required stehen. 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 in required auf.
  • additionalProperties muss auf jeder Objekt-Ebene false sein. 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 $ref oder 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

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
    }
}

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.

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

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"])

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.

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']}")
Bau dein Schema schneller: nutz den JSON Schema Generator, um dein Schema visuell zu erstellen und zu verfeinern — paste ein JSON-Beispielobjekt rein und er generiert automatisch ein Start-Schema. Kopier das Ergebnis direkt in dein OpenAI-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.

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}`)
);

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.