Die Demo funktioniert jedes Mal: Das Modell um JSON gemäß einem Schema bitten, json.loads() auf die Antwort anwenden und weitermachen. Dann wird es deployed, und drei Wochen später findet jemand einen Stack Trace, bei dem das Modell sein JSON in einen Markdown-Code-Block eingewickelt hat, oder einen freundlichen „Hier sind die extrahierten Daten:"-Satz vor dem eigentlichen Objekt hinzugefügt hat, oder – schlimmer – syntaktisch valides JSON zurückgegeben hat, das ein Feld fehlt, von dem der nachgelagerte Code annahm, es wäre immer vorhanden. Nichts davon ist das Modell, das auf irgendeine mysteriöse Weise unzuverlässig ist. Es ist das vorhersehbare Ergebnis davon, „nett um JSON bitten" als gleichwertig mit „ein validiertes JSON-Objekt bekommen" zu behandeln, wenn das zwei verschiedene Garantieniveaus sind, die in einer Happy-Path-Demo zufällig gleich aussehen.
Der Fix ist kein cleverer Prompt. Es ist eine Pipeline, die davon ausgeht, dass das Modell gelegentlich genau das tut, was du nicht verlangt hast, und einen definierten, getesteten Pfad dafür hat, was passiert, wenn es das tut – ein Schema, das tatsächlich durchgesetzt statt nur beschrieben wird, eine Retry-Schleife, die dem Modell seinen eigenen Validierungsfehler übergibst, und ein Fallback für den Fall, dass all das nicht funktioniert und du trotzdem Prosa bekommst. Dieser Beitrag baut diese Pipeline in Python.
Du wirst lernen:
- Warum das Prompten für JSON und das Validieren von JSON verschiedene Probleme mit verschiedenen Fehlermodi sind
- Wie man einen LLM-Output-Vertrag mit Pydantic definiert und direkt eine validierte Instanz erhält
- Den echten Unterschied zwischen Constrained Decoding und „Bitte antworten Sie in JSON"-Prompting
- Wie man eine Retry-Schleife baut, die Validierungsfehler zurück an das Modell schickt, statt blind erneut zu versuchen
- Was zu tun ist, wenn das Modell trotz aller Sicherheitsnets Prosa zurückgibt
- Wie man gestreamtes, partielles JSON verarbeitet, ohne dass der Parser an einem unvollständigen Objekt erstickt
- Eine Produktions-Checkliste für eine strukturierte Output-Pipeline, der man tatsächlich vertrauen kann
Inhaltsverzeichnis
- Zwei verschiedene Probleme unter einem Namen
- Pydantic-Schemas als Vertrag
- Constrained Decoding vs. Gepromptetes JSON
- Retry bei Validierungsfehler
- Wenn das Modell trotzdem Prosa zurückgibt
- Partielles und gestreamtes JSON verarbeiten
- Verschachtelte und Union-Schemas
- Strukturierte Ausgaben in einer Agenten-Schleife
- Produktions-Checkliste
- Fazit
Zwei verschiedene Probleme unter einem Namen
„Strukturierte Ausgabe von einem LLM bekommen" ist meist eine Abkürzung für zwei separate Garantien, die nicht automatisch zusammenkommen. Die erste ist Form: Ist die Antwort überhaupt syntaktisch valides JSON, im Gegensatz zu Prosa, einem Code-Block oder JSON mit einem angehängten Satz? Die zweite ist Schema: Hat valides JSON tatsächlich die Felder, die du brauchst, mit den richtigen Typen, die die Einschränkungen erfüllen, die dein nachgelagerter Code voraussetzt?
Ein Prompt, der nur „antworte in JSON" sagt, behandelt weder Garantie zuverlässig – es ist eine Anfrage, keine Einschränkung, und das Modell ist frei, ein paar Tokens von dem entfernt zu sein, was du verlangt hast. Die Techniken in diesem Beitrag behandeln jede Garantie auf einer anderen Ebene: Constrained Decoding (oder ein schema-bewusster API-Modus) behandelt die Form strukturell, so dass das Format buchstäblich nicht falsch herauskommen kann; Pydantic-Validierung behandelt das Schema und fängt eine Ausgabe auf, die valides JSON, aber das falsche JSON ist – ein String, wo du einen Int brauchtest, ein fehlendes Pflichtfeld, ein Enum-Wert, der nicht existiert. Diese als ein Problem zu behandeln ist der Weg, auf dem Teams bei einem einzigen try/except json.JSONDecodeError landen, das still ein perfekt parseables Objekt durchlässt, dem die Hälfte der Felder fehlt, von denen der Rest des Codes abhängt.
Pydantic-Schemas als Vertrag
Definiere die Ausgabeform einmal als Pydantic-Modell und lass es drei Zwecke gleichzeitig erfüllen – das Schema, das die Dokumentation sonst beschreiben würde, der Validator, der bei jeder Antwort läuft, und der Typ, mit dem dein nachgelagerter Code tatsächlich arbeitet.
from pydantic import BaseModel, Field
from typing import Literal
class TicketClassification(BaseModel):
category: Literal["billing", "technical", "account", "other"]
urgency: Literal["low", "medium", "high"]
summary: str = Field(description="One-sentence summary of the customer's issue")
requires_escalation: bool
Mit dem Anthropic SDK nimmt client.messages.parse() dieses Modell direkt und gibt eine validierte Instanz zurück statt eines Content-Blocks, den du selbst parsen müsstest:
import anthropic
client = anthropic.Anthropic()
response = client.messages.parse(
model="claude-opus-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": f"Classify this support ticket:\n\n{ticket_text}",
}],
output_format=TicketClassification,
)
ticket = response.parsed_output
print(ticket.category, ticket.urgency) # typed, validated attribute access — not dict lookups
response.parsed_output ist eine echte TicketClassification-Instanz, kein dict, von dem du hoffst, dass es die richtigen Schlüssel hat – ein Tippfehler wie ticket.catgory schlägt zur Entwicklungszeit mit einem normalen Python AttributeError fehl, statt zur Laufzeit drei Ebenen tiefer in deinem Call Stack mit einem KeyError still zu scheitern. Das ist eine kleine Sache, die sich summiert: Das Schema hört auf, Dokumentation zu sein, von der du hoffst, dass sie aktuell geblieben ist, und wird zu etwas, das dein Typprüfer und deine IDE tatsächlich durchsetzen.
Für Fälle, in denen du das rohe JSON-Schema statt einer von Pydantic getriebenen Parse brauchst – wenn du mit einem System sprichst, das dir ein Schema dynamisch übergibt, zum Beispiel – ist dieselbe Garantie auf Request-Ebene über output_config verfügbar:
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Classify this ticket:\n\n{ticket_text}"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "account", "other"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"requires_escalation": {"type": "boolean"},
},
"required": ["category", "urgency", "summary", "requires_escalation"],
"additionalProperties": False,
},
}
},
)
output_config.format garantiert, dass der Antwortinhalt Text ist, der valides JSON enthält, das diesem Schema entspricht – die Form-Garantie – aber beachte, dass es an diesem Punkt das rohe dict ist; du würdest immer noch selbst TicketClassification.model_validate_json(...) aufrufen müssen, um die Schema-Garantie (Typkoerzion, Enum-Prüfung) zu bekommen, die .parse() automatisch gibt. In der Praxis bevorzuge .parse() mit einem Pydantic-Modell, wann immer du das Schema im Code kontrollierst – es ist streng genommen weniger zu pflegen als dieselben Einschränkungen zweimal zu definieren, einmal als Pydantic-Modell und einmal als rohem JSON-Schema-Dict.
Constrained Decoding vs. Gepromptetes JSON
Es lohnt sich, genau zu sein über das, was sich hier gegenüber älteren Ansätzen geändert hat, weil „das Modell bitten, JSON im System-Prompt zurückzugeben" immer noch verbreiteter Rat ist und bedeutungsweise schwächer ist als ein schema-bewusster Output-Modus. Gepromptetes JSON ist eine Anfrage, die das Modell frei ist, unvollkommen zu erfüllen – es kann eine Präambel hinzufügen, unter ungewöhnlichen Samplingbedingungen eine abschließende geschweifte Klammer vergessen oder einen plausibel klingenden Schlüssel verwenden, den du nicht angegeben hast, statt dem, den du angegeben hast. Es funktioniert meistens, genau bis die Eingabe ungewöhnlich genug ist, dass es das nicht tut.
output_config.format (und der darauf aufbauende .parse()-Helper) schränkt die Antwort zur Generierungszeit ein, so dass die Form-Garantie strukturell statt angefragt ist – das Modell wird daran gehindert, Inhalt außerhalb des Schemas auszusenden, nicht nur freundlich gebeten, darin zu bleiben. Das ist der eigentliche Grund für einen schema-bewussten API-Modus statt einem gut formulierten Prompt: Es wandelt „das Modell kriegt das meistens richtig hin" in „das Modell kann die Form nicht falsch machen" um, was eine kategorisch andere Zuverlässigkeitsgarantie ist, auch wenn es immer noch die Schema-Level-Validierung (die zweite Garantie aus dem obigen Abschnitt) als etwas lässt, das dein eigener Code prüfen muss.
Beachte eine Interaktion, die es wert ist zu wissen: output_config.format ist nicht kompatibel mit Dokument-Zitierungen (citations: {enabled: true}) – wenn deine Pipeline sowohl strukturierte Ausgabe als auch Zitierungsverfolgung gegen ein Quelldokument benötigt, ist das eine echte Designeinschränkung, die man berücksichtigen muss, kein Edge Case, den man in der Produktion entdeckt.
Retry bei Validierungsfehler
Selbst mit einer schemabeschränkten Antwort kann die Pydantic-Validierung noch fehlschlagen – ein enum-naher Wert, den das Schema als generischen String erlaubt hat, aber dein Literal-Typ nicht akzeptiert, ein numerisches Feld, das das Modell mit einem Wert außerhalb eines Bereichs befüllt hat, den du in einem benutzerdefinierten Validator durchsetzt. Das Einzige effektivste, was du hier tun kannst, ist nicht „nochmal mit demselben Prompt versuchen", sondern „nochmal mit der eigenen Fehlermeldung des Modells versuchen", weil ein Validierungsfehler spezifisches, handlungsfähiges Feedback ist, auf das das Modell meist direkt reagieren kann.
from pydantic import ValidationError
def classify_with_retry(ticket_text: str, max_attempts: int = 3) -> TicketClassification:
messages = [{"role": "user", "content": f"Classify this support ticket:\n\n{ticket_text}"}]
for attempt in range(max_attempts):
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
messages=messages,
output_config={"format": {"type": "json_schema", "schema": TicketClassification.model_json_schema()}},
)
text = next(b.text for b in response.content if b.type == "text")
try:
return TicketClassification.model_validate_json(text)
except ValidationError as e:
if attempt == max_attempts - 1:
raise
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": f"That response failed validation with this error:\n{e}\n"
f"Return corrected JSON matching the schema exactly.",
})
raise RuntimeError("unreachable")
Zwei Details machen diese Schleife tatsächlich effektiv statt nur langsamer. Erstens gehen der fehlgeschlagene Versuch und der Fehler beide zurück in das Gespräch – das Modell sieht exakt, was falsch war, nicht ein generisches „versuche es nochmal." Zweitens ist max_attempts eine echte Obergrenze mit einer echten Exception am Ende, kein unendlicher Retry, der aus einer schlechten Anfrage einen stillen Kostenmultiplikator macht; drei Versuche fangen die überwältigende Mehrheit der Validierungsfehler in der Praxis auf, und eine Anfrage, die nach drei immer noch fehlschlägt, sagt dir etwas über die Eingabe oder das Schema, nicht dass sie nur einen weiteren Versuch bräuchte.
Wenn das Modell trotzdem Prosa zurückgibt
Selbst mit einem schemabeschränkten Modus plane dafür, dass die Antwort gelegentlich nicht das ist, was du erwartest – eine Ablehnung, ein ungewöhnlicher stop_reason, ein Edge Case der Client-Bibliothek oder ein Modell, das entschieden hat, die ehrliche Antwort auf „extrahiere diese Daten" ist „es gibt keine Daten zu extrahieren" und das in einem Satz gesagt hat. Defensives Parsen hier ist günstige Versicherung gegen eine Klasse von Produktionsvorfällen, die sonst wie ein zufälliger, nicht reproduzierbarer Absturz aussieht.
import json
import re
def extract_json_defensively(text: str) -> dict | None:
# Try a direct parse first — the common case.
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Strip a markdown code fence if the model wrapped its output in one.
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fence_match:
try:
return json.loads(fence_match.group(1))
except json.JSONDecodeError:
pass
# Last resort: grab the first {...} block and hope it's balanced.
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
if brace_match:
try:
return json.loads(brace_match.group(0))
except json.JSONDecodeError:
pass
return None
Verdrahte das als Fallback hinter dem Constrained-Mode-Pfad, nicht als Ersatz dafür – greife nur dann auf Regex-Extraktion zurück, wenn der schema-bewusste Aufruf vollständig fehlschlägt (ein stop_reason, den du nicht erwartet hast, ein API-Fehler), statt jede Antwort standardmäßig durch Best-Effort-String-Chirurgie zu leiten. Und was auch immer das zurückgibt, schick es immer noch durch TicketClassification.model_validate(...), bevor du ihm vertraust; valides JSON aus einem unordentlichen String zu erholen ist nicht dasselbe wie JSON zu erholen, das dein Schema erfüllt; das sind dieselben zwei Garantien vom Anfang dieses Beitrags, und das Zweite hier zu überspringen macht den gesamten Sinn der Übung zunichte.
Partielles und gestreamtes JSON verarbeiten
Eine strukturierte Antwort zu streamen führt zu einer Version des Form-Problems, die man bei einem einzelnen blockierenden Aufruf nicht bekommt: Zu jedem Zeitpunkt mitten im Stream ist der angesammelte Text per Definition unvollständiges JSON, und es zu parsen, bevor das Objekt geschlossen ist, wird bei jedem einzelnen Chunk werfen, bis zum letzten. Parse nicht jeden Chunk – puffere und parse defensiv, oder verwende einen Streaming-JSON-Parser, der genau dafür gebaut ist, wenn du partielle Ergebnisse inkrementell gerendert brauchst (ein progressiv ausfüllendes Formular in einer UI zum Beispiel).
def stream_classification(ticket_text: str):
buffer = ""
with client.messages.stream(
model="claude-opus-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Classify this ticket:\n\n{ticket_text}"}],
output_config={"format": {"type": "json_schema", "schema": TicketClassification.model_json_schema()}},
) as stream:
for text_delta in stream.text_stream:
buffer += text_delta
# Only attempt a full parse once the stream is done, unless you have
# a genuine incremental-rendering need that justifies a partial-JSON parser.
return TicketClassification.model_validate_json(buffer)
Wenn du inkrementelles Rendering tatsächlich benötigst, behandle partielle Parses als streng als Best-Effort-Anzeigezustand – validiere niemals ein partielles Objekt gegen dein volles Pydantic-Schema, da Pflichtfelder, die noch nicht gestreamt wurden, die Validierung auf eine Weise zum Fehlschlagen bringen, die nichts damit zu tun hat, dass das Modell etwas falsch macht.
Verschachtelte und Union-Schemas
Echte Extraktionsaufgaben sind selten ein flaches Objekt. Pydantics verschachtelte Modelle und Unions funktionieren durch denselben output_format-/output_config-Pfad ohne zusätzliches Plumbing, was eines der stärkeren Argumente ist, Schemas in Pydantic statt handgeschriebenen JSON-Schema-Dicts zu definieren, wenn die Komplexität wächst.
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
class RefundRequest(BaseModel):
order_id: str
items: list[LineItem]
reason: Literal["damaged", "wrong_item", "changed_mind", "other"]
response = client.messages.parse(
model="claude-opus-5", max_tokens=1024,
messages=[{"role": "user", "content": f"Extract the refund request:\n\n{email_text}"}],
output_format=RefundRequest,
)
refund = response.parsed_output
total = sum(item.quantity * item.unit_price for item in refund.items)
Der Fehlermodus, der es lohnt, bewusst zu testen, ist eine leere oder einelementige Liste, wo dein Prompt implizierte, es gäbe normalerweise mehrere – items: [] ist gegen das obige Schema gültig, ist aber sehr wahrscheinlich ein Zeichen, dass das Modell keine Einzelposten gefunden hat, statt dass die E-Mail genuinlich null hatte, und diese Ambiguität ist genau die Art von Sache, die ein Schema nicht für dich unterscheiden kann. Füge eine explizite min_length=1-Einschränkung hinzu, wenn eine leere Liste für deinen Anwendungsfall niemals tatsächlich gültig ist, damit diese Ambiguität zu einem Validierungsfehler wird, den deine Retry-Schleife fangen kann, statt stillschweigend 0 nach downstream fließen zu lassen.
Strukturierte Ausgaben in einer Agenten-Schleife
Alles oben geht von einem einzelnen isolierten Aufruf aus, aber die Validierung strukturierter Ausgaben ist noch wichtiger, sobald sie in einem Knoten eines größeren Agenten sitzt statt alleine – eine schlechte Klassifizierung dort gibt nicht nur eine falsche Antwort zurück, sie leitet die restliche Ausführung des Agenten auf den falschen Pfad. Wenn du einen Agenten baust, der komplex genug ist, um explizites Verzweigen und Zyklen statt einem einzigen linearen Aufruf zu benötigen, ist das der Punkt, an dem eine einfache Chain nicht mehr ausreicht und etwas wie LangGraph seine Komplexität verdient – und ein TicketClassification-artiger Knoten ist genau die Art von Ding, das am Ende eine bedingte Edge bedingt.
def classify_node(state: AgentState) -> dict:
response = client.messages.parse(
model="claude-opus-5", max_tokens=1024,
messages=[{"role": "user", "content": f"Classify this ticket:\n\n{state['ticket_text']}"}],
output_format=TicketClassification,
)
return {"classification": response.parsed_output}
def route_on_category(state: AgentState) -> str:
return "escalate" if state["classification"].requires_escalation else "auto_respond"
Die Retry-on-Validation-Failure-Schleife von früher gehört inside classify_node, nicht um den gesamten Graph-Aufruf herum – ein Validierungsfehler bei der Ausgabe eines Knotens sollte nicht das erneute Abspielen jedes Knotens erzwingen, der bereits erfolgreich ausgeführt wurde. Den Retry lokal auf den Knoten zu halten, der die strukturierte Ausgabe produziert, ist das, was das sauber mit dem Rest der Fehlerbehandlung eines Graphen zusammensetzt, statt dagegen zu kämpfen.
Produktions-Checkliste
- Verwende einen schema-bewussten Output-Modus (
.parse()oderoutput_config.format), keine rein prompt-basierte Anfrage für JSON, für alles, von dem nachgelagerter Code abhängt, dass er erfolgreich parsed. - Validiere durch Pydantic, auch wenn die API Form garantiert – Form und Schema sind verschiedene Garantien, und nur eine davon wird zur Generierungszeit durchgesetzt.
- Retry mit dem enthaltenen Validierungsfehler, begrenzt auf eine kleine, explizite Versuchsanzahl mit einer echten Exception am Ende.
- Halte einen defensiven Extraktionsfallback für den Fall vor, dass ein beschränkter Aufruf immer noch nicht wie erwartet zurückkommt – aber validiere, was auch immer es erholt, bevor du ihm vertraust.
- Validiere niemals einen partiellen gestreamten Buffer gegen das volle Schema – behandle partiellen Zustand als nur für die Anzeige.
- Füge explizite Einschränkungen (
min_length, Wertebereiche) für alles hinzu, das ein lediglich-gültiger-aber-leerer oder lediglich-gültiger-aber-unplausibler Wert downstream brechen würde. - Logge Validierungsfehler mit der rohen Modellausgabe anhängt – ein wachsender Eimer von Retry-Fehlern auf demselben Feld ist ein Signal, dass dein Schema oder Prompt geändert werden muss, nicht nur dass Retries das Problem absorbieren.
Ein verwandtes Detail, das es wert ist für jeden zu flaggen, der aus einer älteren Codebasis kommt: Einige SDKs stellen immer noch einen Top-Level-output_format-Parameter direkt auf messages.create() aus der Zeit vor dem aktuellen .parse()-Helper und der output_config.format-Form bereit. Dieser Parameter ist veraltet – neuer Code sollte output_config: {format: {...}} auf messages.create() für den rohen Schema-Pfad verwenden, oder .parse() mit einem Pydantic-Modell für den typisierten Pfad, der in diesem Beitrag gezeigt wird. Wenn du eine bestehende Integration pflegst und den alten Parameternamen siehst, ist das ein Zeichen, dass der Code vor der aktuellen strukturierten Output-Oberfläche entstammt und es wert ist, migriert zu werden statt es so zu lassen, da veraltete Parameter genau die Art von Sache sind, die in einer zukünftigen SDK-Version still aufhört unterstützt zu werden ohne Warnung zur Aufrufzeit.
Fazit
Die Lücke zwischen einem Structured-Output-Demo und einer Structured-Output-Pipeline, der du in der Produktion vertrauen kannst, liegt vollständig darin, wie jede Schicht damit umgeht, dass das Modell nicht exakt das tut, was du verlangt hast. Ein Pydantic-Modell gibt dir einen echten, durchsetzbaren Vertrag statt eines Kommentars, der einen beschreibt; ein schema-bewusster Output-Modus macht die Form der Antwort strukturell statt angefragt; eine Retry-Schleife, die dem Modell seinen eigenen Validierungsfehler übergibt, erholt sich von den Schema-Fehlern, die trotzdem durchkommen; und ein defensiver Fallback fängt den Rest auf, ohne so zu tun, als wäre erholterer Text automatisch gültig. Keines davon ist einzeln exotisches Engineering – die Zuverlässigkeit kommt davon, alle vier zusammen zu verwenden, bewusst, statt in der Produktion zu entdecken, welchen du übersprungen hast.

