Structured Output erzwingen: Wie LLMs zuverlässig sauberes JSON liefern

Structured Output erzwingen: Wie LLMs zuverlässig sauberes JSON liefern

Structured Output erzwingen: Wie LLMs zuverlässig sauberes JSON liefern

Artikel-Übersicht

In diesem Leitfaden zeige ich dir, wie du Large Language Models dazu bringst, verlässlich sauberes JSON auszugeben – also genau das, was du brauchst, sobald du ein LLM nicht mehr nur zum Plaudern, sondern als Baustein in einer echten Software-Pipeline einsetzt. Wir starten mit dem Kern des Problems: Warum liefern Modelle so oft kaputtes, halbgares oder in Prosa eingewickeltes JSON, das dein json.loads() zum Absturz bringt? Danach sortieren wir die vier großen Lösungsansätze – einfacher JSON-Mode, echte Structured Outputs mit erzwungenem JSON-Schema, Tool- bzw. Function-Calling und Constrained Decoding mit Grammatiken für lokale Modelle – und schauen uns an, wo die jeweiligen Stärken und Grenzen liegen. Im praktischen Teil bauen wir mit Pydantic als gemeinsamer Wahrheitsquelle vollständige Code-Beispiele für OpenAI und für Anthropic Claude. Zum Schluss geht es um die unbequemen Details: Wie zuverlässig ist das Ganze wirklich, wo sind die Grenzen, wie baust du saubere Fehlerbehandlung mit Retries, was kostet dich das an Performance – und welche Best Practices für Schemas, Beschreibungen und Enums machen den Unterschied zwischen „läuft" und „läuft auch nachts um drei in Produktion".

Einleitung: Warum „gib mir bitte JSON" nicht reicht

Heute beschäftigen wir uns mit einem Problem, das jeden trifft, der ernsthaft mit LLMs entwickelt: Du willst keine schöne Prosa, du willst Daten. Strukturierte, vorhersehbare, maschinenlesbare Daten, die du direkt in deine Datenbank schreiben, an die nächste Funktion weiterreichen oder in einem Frontend rendern kannst.

Der naive Ansatz sieht so aus: Du schreibst in den Prompt „Antworte ausschließlich mit gültigem JSON im folgenden Format ..." und hoffst auf das Beste. Und tatsächlich funktioniert das erstaunlich oft. Das Problem ist nur das Wort „oft". In einer Demo reicht eine Trefferquote von 95 Prozent. In Produktion, wo dein Service zehntausende Anfragen am Tag verarbeitet, bedeutet die fehlende eine von zwanzig Antworten einen kaputten Datensatz, einen abgestürzten Job oder eine Fehlermail im Posteingang.

Die gute Nachricht: Stand 2026 musst du dich auf diese Lotterie nicht mehr einlassen. Sowohl OpenAI als auch Anthropic bieten inzwischen Mechanismen, die JSON nicht erbitten, sondern auf Token-Ebene erzwingen – das Modell kann strukturell gar kein ungültiges JSON mehr produzieren. Und für lokale Modelle gibt es dasselbe Prinzip mit Open-Source-Werkzeugen. Schauen wir uns das im Detail an.

Das Problem: Warum LLMs so gern unsauberes JSON liefern

Um die Lösungen zu verstehen, lohnt es sich, kurz zu begreifen, warum das Problem überhaupt existiert. Ein LLM erzeugt seinen Text Token für Token. Bei jedem Schritt berechnet es eine Wahrscheinlichkeitsverteilung über alle möglichen nächsten Tokens und würfelt – mehr oder weniger zufällig – eines davon aus. Das Modell hat kein eingebautes Konzept von „dieser geschweiften Klammer muss irgendwann eine schließende folgen". Es schreibt JSON nur deshalb, weil es im Training Millionen JSON-Beispiele gesehen hat und das Muster nachahmt. Es ist ein Imitator, kein Parser.

Daraus ergeben sich die klassischen Fehlerbilder, die jeder kennt:

  • Geschwätz drumherum: „Klar, hier ist dein JSON:" gefolgt vom eigentlichen Objekt und einem freundlichen „Lass mich wissen, wenn du noch etwas brauchst!". Beides bringt json.loads() zu Fall.
  • Markdown-Verpackung: Das JSON kommt in einem ```json-Codeblock, den du erst mühsam herausschneiden musst.
  • Syntaxfehler: Ein fehlendes Komma, ein nachgestelltes Komma (in JSON verboten), nicht geschlossene Klammern, einfache statt doppelter Anführungszeichen.
  • Halluzinierte Struktur: Das Modell erfindet Felder, die es im Schema nicht gibt, lässt Pflichtfelder weg oder verschachtelt anders als gewünscht.
  • Falsche Typen: Eine Zahl kommt als String "42", ein Boolean als "ja", ein Datum in einem kreativen Format.
  • Abgeschnittene Ausgaben: Das Token-Limit ist erreicht, mitten im Objekt – du bekommst valides JSON-Fragment, aber kein vollständiges.

Die Analogie, die ich am hilfreichsten finde: Ein LLM ohne erzwungene Struktur ist wie ein brillanter, aber zerstreuter Mitarbeiter, dem du ein Formular gibst und sagst „Füll das bitte aus." Meistens macht er es richtig. Manchmal schreibt er aber eine Anekdote an den Rand, vergisst ein Feld oder kritzelt außerhalb der Kästchen. Structured Output bedeutet, ihm das Formular digital zu geben, bei dem die Felder physisch nur die richtige Eingabe zulassen.

Die vier Lösungsansätze im Überblick

Bevor wir in den Code gehen, hier die Landkarte. Es gibt im Wesentlichen vier Stufen, von „nett gefragt" bis „auf Token-Ebene erzwungen":

Ansatz Garantie Wo verfügbar Typischer Einsatz
Prompting allein keine überall schnelle Prototypen, unkritische Fälle
JSON-Mode valide JSON-Syntax, aber kein Schema OpenAI, viele andere wenn du nur garantiert parsebares JSON brauchst
Structured Outputs (JSON-Schema, strict) Syntax und Schema garantiert OpenAI, Anthropic Claude Datenextraktion, Produktions-Pipelines
Tool / Function Calling Schema garantiert (bei strict) OpenAI, Anthropic Claude Agenten, Werkzeugaufrufe, mehrere mögliche Aktionen
Constrained Decoding / Grammatiken Syntax und Schema garantiert lokale Modelle (Outlines, llama.cpp, vLLM, XGrammar) self-hosted, Datenschutz, volle Kontrolle

Der entscheidende konzeptionelle Sprung liegt zwischen JSON-Mode und Structured Outputs. JSON-Mode sorgt dafür, dass irgendein gültiges JSON herauskommt – aber ob es deinem Schema entspricht, bleibt offen. Echte Structured Outputs garantieren beides. Und der gemeinsame technische Trick hinter Structured Outputs und Constrained Decoding ist derselbe: Constrained Decoding. Dein JSON-Schema wird in eine formale Grammatik übersetzt, und bei jedem Generierungsschritt werden alle Tokens, die die Grammatik verletzen würden, schlicht ausmaskiert – ihre Wahrscheinlichkeit wird auf null gesetzt. Das Modell kann gar nicht falsch abbiegen.

Ansatz 1: JSON-Mode – der einfache Einstieg

Der JSON-Mode war historisch der erste Schritt weg vom reinen Prompting. Bei OpenAI aktivierst du ihn über response_format={"type": "json_object"}. Das Modell garantiert dann, syntaktisch korrektes JSON zu produzieren – kein Geschwätz drumherum, keine fehlenden Klammern, kein Markdown.

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-5.5",
    messages=[
        {"role": "system", "content": "Du extrahierst Daten und antwortest als JSON-Objekt mit den Feldern name, alter, stadt."},
        {"role": "user", "content": "Anna ist 34 und wohnt in Köln."},
    ],
    response_format={"type": "json_object"},
)

print(response.choices[0].message.content)
# {"name": "Anna", "alter": 34, "stadt": "Köln"}

Wichtig zu verstehen: Der JSON-Mode garantiert nur die Syntax, nicht die Struktur. Das Modell könnte dir genauso {"vorname": "Anna", "jahre": 34} liefern – valides JSON, aber mit den falschen Feldnamen. Außerdem verlangt OpenAI, dass das Wort „JSON" irgendwo im Prompt vorkommt, sonst wird die Anfrage abgelehnt. Stand 2026 gilt der JSON-Mode deshalb als Legacy: Du benutzt ihn nur noch, wenn dir reine Syntax-Garantie genügt und du das Schema selbst per Validierung prüfst. Für alles andere gibt es den nächsten Ansatz.

Ansatz 2: Echte Structured Outputs mit JSON-Schema (OpenAI strict mode)

Das ist seit 2024/2025 der Goldstandard bei OpenAI und inzwischen der Produktions-Default. Statt nur „gib mir JSON" sagst du „gib mir JSON, das exakt diesem Schema entspricht" – und das wird per Constrained Decoding erzwungen. Der entscheidende Schalter ist strict: true. Damit kompiliert OpenAI dein JSON-Schema in eine kontextfreie Grammatik und beschränkt die Token-Generierung darauf. Das Modell kann das Schema strukturell nicht mehr verletzen.

Der eleganteste Weg in Python führt über Pydantic. Du definierst dein Schema einmal als Pydantic-Modell, und das SDK übersetzt es automatisch in das passende JSON-Schema. Die Helper-Methode client.chat.completions.parse() (bzw. client.responses.parse() in der neueren Responses-API) gibt dir sogar direkt eine fertige, validierte Python-Instanz zurück:

from openai import OpenAI
from pydantic import BaseModel, Field
from enum import Enum

class Prioritaet(str, Enum):
    niedrig = "niedrig"
    mittel = "mittel"
    hoch = "hoch"

class TicketAnalyse(BaseModel):
    titel: str = Field(description="Kurze, prägnante Zusammenfassung des Anliegens")
    kategorie: str = Field(description="z. B. 'Rechnung', 'Technik', 'Vertrieb'")
    prioritaet: Prioritaet
    kundenname: str | None = Field(description="Name des Kunden, falls genannt, sonst null")
    benoetigt_rueckruf: bool

client = OpenAI()

completion = client.chat.completions.parse(
    model="gpt-5.5",
    messages=[
        {"role": "system", "content": "Analysiere die Support-Anfrage und extrahiere die Felder."},
        {"role": "user", "content": "Hallo, hier spricht Herr Müller. Eure Software stürzt seit dem Update ständig ab, das ist dringend! Bitte ruft mich zurück."},
    ],
    response_format=TicketAnalyse,
)

analyse = completion.choices[0].message.parsed
print(analyse.prioritaet)        # Prioritaet.hoch
print(analyse.benoetigt_rueckruf) # True
print(type(analyse))             # <class '__main__.TicketAnalyse'>

Das ist wunderschön: analyse ist ein echtes, typisiertes Python-Objekt. Kein manuelles json.loads(), kein Prüfen einzelner Felder. Wenn du die rohe Schnittstelle ohne SDK-Helfer nutzt, sieht das response_format so aus:

{
  "type": "json_schema",
  "json_schema": {
    "name": "ticket_analyse",
    "strict": true,
    "schema": {
      "type": "object",
      "properties": {
        "titel": {"type": "string"},
        "prioritaet": {"type": "string", "enum": ["niedrig", "mittel", "hoch"]}
      },
      "required": ["titel", "prioritaet"],
      "additionalProperties": false
    }
  }
}

Zwei Dinge sind im strict mode bei OpenAI Pflicht: additionalProperties: false (keine erfundenen Zusatzfelder) und alle Properties müssen in required stehen. Optionale Felder modellierst du, indem du den Typ als Union mit null deklarierst – in Pydantic also str | None. Genau das macht das SDK automatisch für dich.

Ansatz 3: Tool- und Function-Calling für strukturierte Daten

Der dritte Ansatz nähert sich der Sache von einer anderen Seite. Function Calling (OpenAI) bzw. Tool Use (Anthropic) wurde ursprünglich dafür gebaut, dass ein Modell Werkzeuge aufruft – etwa eine Wetter-API oder eine Datenbankabfrage. Dazu beschreibst du dem Modell jedes Werkzeug samt seiner Parameter als JSON-Schema. Und genau diese Parameter sind: strukturiertes JSON.

Das kannst du dir zunutze machen, auch wenn du gar kein echtes Tool ausführen willst. Du definierst einfach ein „Werkzeug" namens speichere_ticket, dessen Parameter exakt deinem gewünschten Datenschema entsprechen, und zwingst das Modell, es aufzurufen. Die „Argumente" des Aufrufs sind dann deine Daten.

Wann nimmst du Tool Calling statt direkter Structured Outputs?

  • Wenn das Modell zwischen mehreren Aktionen wählen soll – etwa bei einem Agenten, der mal eine Suche, mal einen Datenbankeintrag und mal eine Berechnung anstoßen kann. Jedes Werkzeug hat sein eigenes Schema.
  • Wenn du parallele Aufrufe (mehrere Werkzeuge in einer Antwort) brauchst.
  • Wenn das Modell auch entscheiden darf, kein Werkzeug aufzurufen und stattdessen frei zu antworten.

Faustregel: Geht es dir um eine feste Datenstruktur als Endergebnis, nimm direkte Structured Outputs. Geht es um eine Auswahl aus mehreren möglichen Aktionen, nimm Tool Calling. Wichtig ist: Auch beim Tool Calling solltest du heute den strict mode einschalten, damit die Parameter garantiert schemakonform sind – ohne ihn sind Tool-Argumente nur „best effort" und können dieselben Fehler wie freies JSON aufweisen.

Strukturierte Ausgaben bei Anthropic Claude

Lange war Claude hier der Sonderfall: Es gab keinen nativen JSON-Schema-Modus, und der etablierte Trick war, Tool Use zu „missbrauchen", um JSON zu erzwingen. Das hat sich geändert. Anthropic hat Structured Outputs im November 2025 zunächst als Public Beta eingeführt und inzwischen allgemein verfügbar gemacht. Damit hat Claude jetzt zwei sauber getrennte Mechanismen.

1. JSON Outputs über das output_config-Format – das Pendant zu OpenAIs response_format. Auch hier ist der bequemste Weg die messages.parse()-Methode mit einem Pydantic-Modell:

from anthropic import Anthropic
from pydantic import BaseModel, Field

class Rechnung(BaseModel):
    rechnungsnummer: str
    betrag_eur: float = Field(description="Gesamtbetrag in Euro")
    faellig_am: str = Field(description="Fälligkeitsdatum im Format YYYY-MM-DD")
    bezahlt: bool

client = Anthropic()

response = client.messages.parse(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[
        {"role": "user", "content": "Rechnung R-2026-0815 über 1.249,90 €, zahlbar bis 30.07.2026, noch offen."},
    ],
    output_format=Rechnung,
)

rechnung = response.parsed_output
print(rechnung.betrag_eur)  # 1249.9
print(rechnung.bezahlt)     # False

Ein angenehmer Nebeneffekt der Vereinheitlichung: Der früher nötige Beta-Header anthropic-beta: structured-outputs-2025-11-13 ist seit der allgemeinen Verfügbarkeit nicht mehr erforderlich. Strukturierte Ausgaben werden derzeit von den aktuellen Modellen der Familien Opus, Sonnet und Haiku 4.5 unterstützt. Möchtest du das rohe Schema statt Pydantic nutzen, gibst du es über output_config={"format": {"type": "json_schema", "schema": {...}}} an; das Ergebnis landet als garantiert valider String in response.content[0].text.

2. Strict Tool Use – das ist Claudes Antwort für Werkzeugaufrufe. Du fügst deiner Tool-Definition einfach strict: true hinzu, und Claude garantiert, dass die übergebenen Parameter exakt dem input_schema entsprechen:

from anthropic import Anthropic

client = Anthropic()

tools = [{
    "name": "speichere_kontakt",
    "description": "Speichert einen extrahierten Kontakt in der Datenbank.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "email": {"type": "string"},
            "firma": {"type": "string"},
        },
        "required": ["name", "email", "firma"],
        "additionalProperties": False,
    },
}]

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "speichere_kontakt"},
    messages=[
        {"role": "user", "content": "Schreib das in die DB: Lisa Wagner, lisa@beispiel.de, von der Firma Nordlicht GmbH."},
    ],
)

for block in response.content:
    if block.type == "tool_use":
        print(block.input)
        # {'name': 'Lisa Wagner', 'email': 'lisa@beispiel.de', 'firma': 'Nordlicht GmbH'}

Mit tool_choice zwingst du Claude, genau dieses Werkzeug aufzurufen, statt frei zu antworten. Das ist der saubere Weg, wenn du am Ende garantiert ein strukturiertes Objekt willst. Und das Schönste: Du kannst beide Mechanismen kombinieren – Claude kann Werkzeuge mit garantiert validen Parametern aufrufen und am Ende eine strukturierte JSON-Antwort liefern, was für Agenten-Workflows ideal ist.

Ansatz 4: Constrained Decoding und Grammatiken für lokale Modelle

Was, wenn du gar keine Cloud-API nutzt, sondern ein lokales Modell betreibst – aus Datenschutzgründen, wegen der Kosten oder weil du volle Kontrolle willst? Dann hast du keinen strict-Schalter eines Anbieters. Aber du hast etwas Besseres: vollen Zugriff auf den Decoding-Prozess. Und genau dort setzt Constrained Decoding an – dasselbe Prinzip, das auch hinter den kommerziellen Structured Outputs steckt, nur dass du es selbst in der Hand hast.

Die populärste Bibliothek hierfür ist Outlines (von dottxt-ai). Du übergibst ihr ein Pydantic-Modell oder ein JSON-Schema, und sie kompiliert daraus im Hintergrund eine Grammatik und maskiert während der Generierung alle Tokens aus, die die Struktur verletzen würden. Outlines arbeitet mit Hugging Face Transformers, vLLM und llama-cpp-python zusammen:

import outlines
from transformers import AutoModelForCausalLM, AutoTokenizer
from pydantic import BaseModel

class Produkt(BaseModel):
    name: str
    preis: float
    auf_lager: bool

model_name = "meta-llama/Llama-3.1-8B-Instruct"
model = outlines.from_transformers(
    AutoModelForCausalLM.from_pretrained(model_name),
    AutoTokenizer.from_pretrained(model_name),
)

generator = outlines.Generator(model, Produkt)
ergebnis = generator("Extrahiere: Das Wireless-Headset kostet 89,99 € und ist verfügbar.")
print(ergebnis)  # garantiert valides JSON, das dem Produkt-Schema entspricht

Auf einer noch tieferen Ebene arbeitet llama.cpp mit GBNF (GGML BNF) – einem Format zur Definition formaler Grammatiken. Du kannst eine Grammatik direkt in BNF-ähnlicher Syntax schreiben oder ein JSON-Schema automatisch in GBNF umwandeln lassen. Eine minimale Grammatik, die nur JSON-Objekte mit den richtigen Feldern erlaubt, sieht im Kern so aus:

root   ::= "{" ws "\"name\":" ws string "," ws "\"preis\":" ws number "}"
string ::= "\"" [^"]* "\""
number ::= [0-9]+ ("." [0-9]+)?
ws     ::= [ \t\n]*

In der Praxis lässt du dir diese Grammatik aber aus dem JSON-Schema generieren, statt sie von Hand zu pflegen. Neben Outlines und llama.cpp gibt es weitere ausgereifte Frameworks wie XGrammar und Guidance, und auch Inferenz-Server wie vLLM bringen Constrained Decoding direkt mit. Der große Vorteil lokal: Die Garantie ist genauso hart wie bei den großen Anbietern, du bleibst aber Herr deiner Daten und deiner Hardware.

Pydantic als Single Source of Truth – Schema-Design

Dir ist sicher aufgefallen, dass Pydantic in fast jedem Beispiel auftaucht. Das ist kein Zufall, sondern die wichtigste praktische Empfehlung dieses Artikels. Pydantic ist deine einzige Wahrheitsquelle: Aus demselben Modell wird das JSON-Schema für die API generiert und die Laufzeit-Validierung durchgeführt. Du beschreibst die Datenstruktur exakt einmal – der Prompt-Vertrag und die Validierung bleiben automatisch synchron.

Ein paar Prinzipien machen den Unterschied zwischen einem Schema, das das Modell versteht, und einem, an dem es scheitert:

  • Sprechende Feldnamen. lieferdatum ist besser als feld3. Der Name ist Teil des Prompts – das Modell liest ihn mit.
  • description für jedes nicht-triviale Feld. Über Field(description=...) erklärst du dem Modell, was hineingehört. Das ist der Hebel mit der größten Wirkung auf die Qualität. Schreib dort hinein, was du in einer Prompt-Anweisung schreiben würdest – inklusive Format-Hinweisen wie „Datum im Format YYYY-MM-DD".
  • Enums statt Freitext, wo es geht. Wenn ein Feld nur bestimmte Werte annehmen darf, modelliere es als Enum. Das Modell kann dann buchstäblich nichts anderes wählen, und du sparst dir nachgelagerte Bereinigung.
  • Richtige Typen. int, float, bool, datetime – je präziser der Typ, desto weniger Mehrdeutigkeit. So bekommst du 42 statt "42".
  • Verschachtelung gezielt einsetzen. Tief verschachtelte Schemas sind möglich, aber fehleranfälliger und langsamer. Halte die Struktur so flach wie sinnvoll.
from pydantic import BaseModel, Field
from enum import Enum
from datetime import date

class Stimmung(str, Enum):
    positiv = "positiv"
    neutral = "neutral"
    negativ = "negativ"

class Bewertung(BaseModel):
    sterne: int = Field(ge=1, le=5, description="Anzahl Sterne von 1 bis 5")
    stimmung: Stimmung = Field(description="Gesamtstimmung der Rezension")
    schlagworte: list[str] = Field(description="2 bis 5 prägnante Schlagworte")
    datum: date | None = Field(default=None, description="Datum der Bewertung, falls erkennbar")

Ein wichtiger Hinweis: Nicht jeder Pydantic- bzw. JSON-Schema-Constraint wird von jedem Anbieter im strict mode erzwungen. Numerische Grenzen wie ge=1, le=5 oder Längenbeschränkungen werden teils ignoriert (mehr dazu gleich). Du solltest sie trotzdem deklarieren – als Dokumentation, als Prompt-Hinweis und für deine eigene Validierung nach dem Empfang.

Zuverlässigkeit, Grenzen und was strict mode NICHT kann

Jetzt der ehrliche Teil. Structured Outputs sind ein gewaltiger Fortschritt, aber kein Allheilmittel. Es gibt eine wichtige Unterscheidung: garantiert wird die Form, nicht der Inhalt.

Das heißt konkret: Der strict mode garantiert, dass das JSON syntaktisch korrekt ist und deinem Schema entspricht – die richtigen Felder, die richtigen Typen, die erlaubten Enum-Werte. Er garantiert nicht, dass der Inhalt korrekt ist. Das Modell kann immer noch eine Telefonnummer halluzinieren, ein falsches Datum extrahieren oder die Stimmung einer Rezension falsch einschätzen. Die Struktur ist wasserdicht, die Wahrheit nicht.

Dazu kommen die handfesten technischen Grenzen, die du kennen solltest:

  • Nicht alle JSON-Schema-Features werden unterstützt. Bei Anthropic etwa werden minimum/maximum, minLength/maxLength, multipleOf, die meisten Array-Längen-Constraints, rekursive Schemas und externe $ref-URLs im strict mode nicht erzwungen. additionalProperties muss false sein. Bei OpenAI gelten ähnliche Einschränkungen.
  • Komplexitätslimits. Anthropic begrenzt etwa auf 20 strict-Werkzeuge pro Anfrage, eine begrenzte Zahl optionaler Parameter und Union-Typen. Sehr große, tief verschachtelte Schemas stoßen an Grenzen.
  • Feldreihenfolge. Bei Anthropic erscheinen Pflichtfelder zuerst, dann optionale – unabhängig von deiner Schema-Reihenfolge. Verlass dich nicht auf eine bestimmte Reihenfolge.
  • Refusals. Wenn das Modell eine Anfrage aus Sicherheitsgründen ablehnt, bekommst du kein schemakonformes Ergebnis. OpenAI liefert dann ein refusal-Feld, Anthropic einen stop_reason: "refusal". Das musst du explizit abfangen – und du wirst trotzdem für die Tokens berechnet.
  • Abgeschnittene Ausgaben. Erreicht die Antwort das Token-Limit (finish_reason: "length"), kann das JSON unvollständig sein. Setze max_tokens großzügig genug für dein größtes erwartbares Objekt.

Fehlerbehandlung, Retries und Performance

Auch mit erzwungenem Schema brauchst du robusten Code – schon allein wegen Refusals, Netzwerkfehlern und Token-Limits. Ein solides Muster kombiniert die Helper-Methode mit expliziter Refusal-Prüfung und einem Retry mit exponentiellem Backoff:

import time
from openai import OpenAI
from pydantic import ValidationError

client = OpenAI()

def extrahiere(text: str, schema, max_versuche: int = 3):
    for versuch in range(max_versuche):
        try:
            completion = client.chat.completions.parse(
                model="gpt-5.5",
                messages=[
                    {"role": "system", "content": "Extrahiere die Daten gemäß Schema."},
                    {"role": "user", "content": text},
                ],
                response_format=schema,
            )
            nachricht = completion.choices[0].message

            # 1. Refusal abfangen
            if nachricht.refusal:
                raise ValueError(f"Modell hat abgelehnt: {nachricht.refusal}")

            # 2. Abgeschnittene Antwort abfangen
            if completion.choices[0].finish_reason == "length":
                raise ValueError("Antwort wurde abgeschnitten (max_tokens erhöhen).")

            # 3. parsed ist bereits validiert
            return nachricht.parsed

        except (ValidationError, ValueError) as e:
            if versuch == max_versuche - 1:
                raise
            time.sleep(2 ** versuch)  # 1s, 2s, 4s ...

Drei Prinzipien für robuste Strukturextraktion:

  1. Vertraue, aber prüfe. Auch wenn strict mode das Schema garantiert: Lass Pydantic die Antwort validieren. Bei den SDK-Helfern (.parse()) passiert das automatisch. Nutzt du rohes JSON, ruf Schema.model_validate_json(text) selbst auf.
  2. Retries mit Backoff für transiente Fehler (Rate Limits, Timeouts). Bei inhaltlichen Problemen kann ein Retry mit nachgeschärftem Prompt helfen, der den Fehler beschreibt.
  3. Refusals und Abschnitte als eigene Fehlerklassen behandeln – nicht stillschweigend als „leeres Ergebnis" verbuchen.

Zur Performance: Constrained Decoding ist nicht gratis, aber günstig. Bei OpenAI verursacht die erste Anfrage mit einem neuen Schema eine kleine zusätzliche Latenz, weil das Schema in eine Grammatik kompiliert wird; nachfolgende Anfragen mit demselben Schema nutzen den Cache und sind nicht langsamer. Halte dein Schema also stabil, statt es bei jedem Request dynamisch umzubauen. Lokal mit Outlines oder llama.cpp gilt Ähnliches: Die Grammatik-Kompilierung passiert einmal. Interessanterweise kann Constrained Decoding die Generierung sogar beschleunigen, weil bei stark eingeschränkten Positionen (etwa festen Feldnamen) nur ein einziges Token möglich ist und übersprungen werden kann („Coalescence"). Unterm Strich: Der Overhead ist in fast allen Fällen vernachlässigbar gegenüber dem Gewinn an Zuverlässigkeit.

Welcher Ansatz für welchen Fall?

Damit du nicht den Überblick verlierst, hier meine pragmatische Entscheidungshilfe:

  • Du nutzt OpenAI und willst eine feste Datenstruktur als Ergebnis? ? Structured Outputs mit response_format=DeinPydanticModell und .parse(). Das ist der Default für 2026.
  • Du nutzt Claude und willst dasselbe? ? client.messages.parse() mit output_format. Native Structured Outputs sind da und brauchen keinen Beta-Header mehr.
  • Du baust einen Agenten, der zwischen mehreren Aktionen wählt? ? Tool/Function Calling mit strict: true, bei Claude ggf. mit tool_choice, um einen Aufruf zu erzwingen.
  • Du betreibst ein lokales Modell? ? Constrained Decoding mit Outlines (komfortabel) oder llama.cpp/GBNF (low-level), beides aus deinem Pydantic-Schema oder JSON-Schema gespeist.
  • Du brauchst nur garantiert parsebares JSON ohne festes Schema? ? Der schlichte JSON-Mode reicht (aber das ist 2026 selten die beste Wahl).

Zusammenfassung und praktische Tipps

Strukturierte Ausgaben haben sich von einem Bastel-Thema („bitte, bitte gib mir JSON") zu einer harten technischen Garantie entwickelt. Der gemeinsame Kern ist immer Constrained Decoding: Dein Schema wird in eine Grammatik übersetzt, und das Modell kann auf Token-Ebene gar nichts Ungültiges mehr erzeugen. Ob du das über OpenAIs strict mode, Claudes native Structured Outputs oder lokal mit Outlines machst – das Prinzip ist dasselbe.

Die wichtigsten Erkenntnisse zum Mitnehmen:

  • Pydantic ist dein bester Freund. Definiere dein Schema einmal als Pydantic-Modell und nutze die .parse()-Helfer beider Anbieter. Eine Wahrheitsquelle für Prompt-Vertrag und Validierung.
  • JSON-Mode garantiert nur Syntax, Structured Outputs garantieren Syntax und Schema. Nimm im Zweifel Structured Outputs.
  • Schema-Design schlägt Prompt-Engineering. Sprechende Feldnamen, gute description-Texte und enum-Werte bringen mehr als jede noch so geschickte Anweisung im Prompt.
  • Garantiert wird die Form, nicht der Inhalt. Halluzinationen im Wert bleiben möglich – plane Validierung und ggf. menschliche Prüfung für kritische Daten ein.
  • Baue Fehlerbehandlung ein: Refusals abfangen, abgeschnittene Antworten erkennen, Retries mit Backoff. Auch eine harte Garantie ersetzt keinen robusten Code.
  • Performance ist selten ein Problem. Halte dein Schema stabil, dann amortisiert sich die einmalige Grammatik-Kompilierung sofort.

Wenn du diese Punkte beherzigst, verwandelst du das LLM von einem zerstreuten Genie in einen verlässlichen Datenlieferanten – und genau das brauchst du, sobald aus deinem Prototyp echte Software wird.

Ressourcen