Streaming und strukturierte Outputs: JSON-Schema und Tool-Calls sauber verarbeiten
Wie Token-fuer-Token-Streaming die UX verbessert, wie JSON-Schema garantierte Struktur erzwingt und wie du Tool-Call-Ausgaben robust parst und Fehler abfaengst.
Zwei Probleme, die beim Bauen mit LLM-APIs fast immer gleichzeitig auftauchen
Sobald du ein Sprachmodell nicht nur im Chat-Fenster nutzt, sondern per API in eine eigene Anwendung einbaust, stoesst du unweigerlich auf zwei Fragen: Wie zeige ich die Antwort an, ohne dass Nutzer:innen zehn Sekunden auf einen leeren Bildschirm starren? Und wie stelle ich sicher, dass die Antwort in einer Form zurueckkommt, die mein Code zuverlaessig weiterverarbeiten kann, statt Freitext mit hoffentlich passendem JSON drin?
Die erste Frage beantwortet Streaming: Statt auf die komplette Antwort zu warten, bekommst du sie haeppchenweise, sobald das Modell sie generiert. Die zweite beantworten strukturierte Outputs: JSON-Schema-Constraints, die das Modell zwingen, exakt in der Form zu antworten, die du vorgibst - inklusive der Sonderform Tool-Calls, bei denen das Modell keine Antwort an den Nutzer formuliert, sondern einen strukturierten Aufruf an deinen eigenen Code zurueckgibt (Details zum Konzept dahinter in KI-Agents und Tool-Use).
Beide Themen haengen enger zusammen, als es zunaechst wirkt: Sobald du streamst, kommt auch strukturierte Ausgabe nur haeppchenweise an - ein JSON-Objekt oder ein Tool-Aufruf trudelt in Bruchstuecken ein, die du erst zusammensetzen musst, bevor du sie parsen kannst. Genau da entstehen die meisten Bugs in der Praxis.
Streaming: Token fuer Token statt Warten auf die volle Antwort
Ohne Streaming schickst du eine Anfrage und wartest, bis das Modell die komplette Antwort fertig generiert hat - bei einer laengeren Analyse koennen das mehrere zehn Sekunden sein, in denen deine Anwendung nichts anzeigen kann. Mit Streaming bekommst du die Antwort als fortlaufenden Strom kleiner Ereignisse, typischerweise ueber Server-Sent Events (SSE): Die Verbindung bleibt offen, und der Server schickt einzelne Textschnipsel (Deltas), sobald das Modell sie generiert hat.
Der Effekt fuer die UX ist gross: Aus “Ladebalken, dann alles auf einmal” wird “Text erscheint fluessig, Wort fuer Wort”, wie man es aus Chat-Oberflaechen kennt. Technisch misst man das oft als Time to First Token (TTFT) - die Zeit bis zum ersten sichtbaren Wortschnipsel ist meist deutlich kuerzer als die Zeit bis zur kompletten Antwort, selbst wenn die Gesamtdauer gleich bleibt.
import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{"role": "user", "content": "Erklaere mir DNS in drei Saetzen."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
finale_antwort = stream.get_final_message()
print("\n\nToken verbraucht:", finale_antwort.usage.output_tokens)
Bei einem rohen HTTP-Request (ohne SDK) siehst du das SSE-Format direkt: einzelne event:- und data:-Zeilen, die du selbst parsen musst.
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","model":"claude-opus-4-8"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"DNS "}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"uebersetzt "}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":42}}
event: message_stop
data: {"type":"message_stop"}
Die genauen Event-Namen unterscheiden sich zwischen Anbietern (Anthropic nennt sie content_block_delta, OpenAI verwendet ein aehnliches, aber nicht identisches Chunk-Format), das Grundprinzip - offene Verbindung, fortlaufende Deltas, ein Abschlussereignis mit Grund und Token-Zaehlung - ist bei allen grossen Anbietern praktisch gleich (Stand: 2026-07).
Wichtig fuer die Praxis: Auch wenn du live streamst, willst du am Ende meist trotzdem die vollstaendige, zusammengesetzte Antwort als ein Objekt - etwa um sie in einer Datenbank zu speichern oder die finale Token-Nutzung auszuwerten. Die meisten SDKs bieten dafuer einen Helfer wie get_final_message() (Python) oder finalMessage() (TypeScript), der die Deltas fuer dich zusammensetzt, statt dass du das selbst per String-Konkatenation machen musst.
Strukturierte Outputs: JSON-Schema statt Try-and-Parse
Frueher lief “strukturierte Ausgabe” so: Du hast das Modell im Prompt hoeflich gebeten, “bitte nur gueltiges JSON, ohne Erklaerungstext drumherum” zu liefern - und trotzdem kam gelegentlich ein einleitender Satz, ein Markdown-Codeblock drumherum oder ein fehlendes Komma zurueck. Moderne APIs loesen das strukturell: Du gibst ein JSON-Schema vor, und der Anbieter garantiert (oder erzwingt technisch), dass die Antwort exakt diesem Schema entspricht.
Bei Anthropic heisst der Parameter output_config.format, bei OpenAI response_format mit type: "json_schema" - die Idee ist bei beiden praktisch identisch: ein Name, eine Beschreibung und ein JSON-Schema-Objekt, das Pflichtfelder, Typen und erlaubte Werte (enum) definiert.
{
"output_config": {
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"kategorie": {
"type": "string",
"enum": ["hardware", "software", "netzwerk", "konto"]
},
"prioritaet": { "type": "string", "enum": ["niedrig", "mittel", "hoch"] },
"zusammenfassung": { "type": "string" },
"eskalieren": { "type": "boolean" }
},
"required": ["kategorie", "prioritaet", "zusammenfassung", "eskalieren"],
"additionalProperties": false
}
}
}
}
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "Nutzer meldet: Drucker im 2. Stock druckt nicht mehr."}],
output_config={"format": {"type": "json_schema", "schema": TICKET_SCHEMA}},
)
ticket = response.parsed_output # bereits validiertes Python-Objekt, kein manuelles json.loads noetig
Die meisten SDKs bieten dafuer eine .parse()-Methode (statt .create()), die die Antwort direkt gegen dein Schema validiert und dir ein fertiges Objekt zurueckgibt - inklusive Fehler, falls die Validierung scheitert, statt dass du selbst raten musst, ob das JSON gueltig war.
Eng verwandt ist Strict Tool Use: Du markierst eine Tool-Definition zusaetzlich als strict: true, und der Anbieter garantiert, dass die Eingabeparameter des Tool-Aufrufs exakt dem Schema entsprechen - kein fehlendes Pflichtfeld, kein falscher Typ, keine zusaetzlichen, nicht deklarierten Felder. Das ist besonders wertvoll, wenn ein Tool-Aufruf direkt eine Aktion mit Seiteneffekten ausloest (siehe KI-Agents und Tool-Use) und du dich nicht auf nachtraegliche Validierung verlassen willst.
Tool-Calls: strukturierte Ausgabe fuer Aktionen statt fuer Antworten
Ein Tool-Call ist im Grunde ein Spezialfall von strukturierter Ausgabe: Statt Freitext oder einem einzelnen JSON-Objekt gibt das Modell einen Block zurueck, der Werkzeugname und passend befuellte Parameter enthaelt - dein Code fuehrt die eigentliche Aktion aus, das Modell selbst tut nichts. Beim Streaming kommt dieser Block genauso haeppchenweise an wie normaler Text: Der Werkzeugname steht meist frueh fest, die Parameter werden als JSON-Fragment nach und nach aufgebaut (“fine-grained tool streaming”).
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 2048,
tools: [ticketTool],
messages: [{ role: "user", content: "Server 3 antwortet nicht mehr auf Ping." }],
});
let toolInputJson = "";
for await (const event of stream) {
if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
console.log("Werkzeug aufgerufen:", event.content_block.name);
}
if (event.type === "content_block_delta" && event.delta.type === "input_json_delta") {
toolInputJson += event.delta.partial_json; // Fragmente sammeln, NICHT einzeln parsen
}
if (event.type === "content_block_stop") {
const input = JSON.parse(toolInputJson); // erst nach content_block_stop ist das JSON vollstaendig
console.log("Vollstaendige Parameter:", input);
}
}
Parsing: robust mit dem umgehen, was tatsaechlich zurueckkommt
Selbst mit striktem JSON-Schema oder Strict Tool Use bleiben Faelle, in denen die Antwort nicht das ist, was dein Code erwartet - abgeschnittene Ausgaben, Refusals, oder schlicht Netzwerkfehler mitten im Stream. Ein robuster Parsing-Layer prueft deshalb immer zuerst den Grund, warum das Modell aufgehoert hat, bevor er ueberhaupt versucht, den Inhalt zu lesen.
stop_reason / Signal | Bedeutung | Was dein Code tun sollte |
|---|---|---|
end_turn | Normales Ende, Antwort vollstaendig | Inhalt normal parsen und verarbeiten |
max_tokens | Output-Limit erreicht, Antwort moeglicherweise abgeschnitten | Nicht blind parsen - pruefen, ob JSON/Tool-Input vollstaendig ist; sonst max_tokens erhoehen und neu anfragen |
tool_use | Modell moechte ein Werkzeug aufrufen | Tool-Input erst nach vollstaendigem Content-Block parsen, Ergebnis zurueckschicken |
refusal | Modell hat aus Sicherheitsgruenden abgelehnt | Inhalt ist leer oder unvollstaendig - nicht als Nutzdaten behandeln, eigene Fehlermeldung anzeigen |
| Verbindungsabbruch mitten im Stream | Netzwerkproblem, Timeout, Proxy-Fehler | Bereits empfangene Teilantwort verwerfen oder klar als unvollstaendig markieren, kontrolliert erneut anfragen |
try:
response = client.messages.create(model="claude-opus-4-8", max_tokens=1024, messages=messages)
except anthropic.RateLimitError:
# mit Backoff erneut versuchen
...
except anthropic.APIConnectionError:
# Netzwerkfehler, meist retry-faehig
...
else:
if response.stop_reason == "refusal":
zeige_fehlermeldung("Anfrage wurde abgelehnt.")
elif response.stop_reason == "max_tokens":
zeige_warnung("Antwort moeglicherweise unvollstaendig - max_tokens erhoehen.")
else:
daten = json.loads(response.content[0].text) # erst jetzt parsen
Fuer die eigentliche Validierung nach dem Parsen lohnt sich ein Schema-Validator wie Pydantic (Python) oder Zod (TypeScript/JavaScript) - selbst wenn die API-seitige Strict-Mode-Garantie meistens haelt, ist eine zweite, unabhaengige Pruefung auf deiner Seite eine guenstige Versicherung gegen API-Versionsaenderungen, Edge Cases oder schlicht Bugs beim Anbieter.
from pydantic import BaseModel
class Ticket(BaseModel):
kategorie: str
prioritaet: str
zusammenfassung: str
eskalieren: bool
ticket = Ticket.model_validate_json(response.content[0].text) # wirft bei Abweichung sofort einen klaren Fehler
Fehlerbehandlung: Entscheidungsbaum fuer den Ernstfall
Antwort erhalten (Stream abgeschlossen oder Non-Streaming-Response)
│
├─ HTTP-Fehler (4xx/5xx) beim Request selbst?
│ ├─ 429 / 5xx / Netzwerkfehler → retry-faehig, mit Backoff erneut versuchen
│ └─ 400 / 401 / 403 → nicht retry-faehig, Request oder Auth pruefen
│
├─ Response erhalten, aber stop_reason == "refusal"?
│ → kein Nutzdaten-Inhalt vorhanden, eigene Fehlermeldung statt Parsing-Versuch
│
├─ stop_reason == "max_tokens"?
│ → Inhalt moeglicherweise abgeschnitten, vor dem Parsen auf Vollstaendigkeit pruefen
│
├─ Stream bricht mitten in einem content_block ab (Verbindungsfehler)?
│ → Teilinhalt verwerfen, kontrolliert neu anfragen, nicht stillschweigend mit halbem JSON weiterarbeiten
│
└─ stop_reason == "end_turn" oder "tool_use", Inhalt vollstaendig?
→ normal parsen, gegen Schema validieren (Pydantic/Zod), dann verarbeiten
Ein Punkt, der in der Praxis oft unterschaetzt wird: Retries muessen idempotent gedacht werden, besonders bei Tool-Calls mit Seiteneffekten. Wenn ein Tool-Aufruf (“Ticket anlegen”) bereits ausgefuehrt wurde, bevor die Verbindung abbrach, aber die Bestaetigung nie ankam, darf ein automatischer Retry nicht einfach ein zweites Ticket anlegen. Fuer Aktionen mit echten Konsequenzen lohnt sich ein eigener Idempotenz-Schluessel oder eine Pruefung “existiert das schon?”, bevor eine Aktion wiederholt wird.
Praxis-Checkliste
- Streame grundsaetzlich bei laengeren Antworten oder hohen
max_tokens-Werten, nicht nur wenn du live Text anzeigen willst. - Sammle JSON-Fragmente aus dem Stream immer als String und parse erst nach dem Abschluss-Event, nie fragmentweise.
- Nutze JSON-Schema (
output_config.formatbzw.response_format) oder Strict Tool Use, statt das Modell im Prompt nur hoeflich um sauberes JSON zu bitten. - Pruefe
stop_reasonimmer zuerst, bevor du auf den Inhalt zugreifst - besondersrefusalundmax_tokens. - Validiere geparste Daten zusaetzlich mit einem eigenen Schema-Validator (Pydantic, Zod), auch wenn die API bereits Struktur garantiert.
- Denke Retries idempotent, sobald ein Tool-Call echte Seiteneffekte ausloest.
- Logge Rohantworten mit Request-ID, damit Parsing-Fehler im Nachhinein nachvollziehbar bleiben.
Zusammenfassung
Streaming und strukturierte Outputs loesen zwei unterschiedliche Probleme - Antwortzeit-UX auf der einen, verlaessliche Maschinenlesbarkeit auf der anderen Seite -, treffen aber in der Praxis fast immer aufeinander, sobald du Tool-Calls oder JSON-Antworten live verarbeitest. Der Schluessel liegt darin, Fragmente korrekt zusammenzusetzen, bevor du sie parst, den Abbruchgrund (stop_reason) immer vor dem Inhalt zu pruefen, und trotz API-seitiger Struktur-Garantien eine eigene Validierungsschicht zu behalten. Wer diese Reihenfolge diszipliniert einhaelt, spart sich die meisten der Bugs, die bei LLM-Integrationen typischerweise erst in Produktion auffallen.
Weiterlernen
- Anthropic - Streaming Messages
- Anthropic - Structured Outputs
- Anthropic - Tool Use Overview
- OpenAI - Structured Outputs Guide
- OpenAI - Streaming API Responses
- JSON Schema - offizielle Spezifikation
Verwandte Themen: LLM-APIs nutzen: Anthropic, OpenAI und OpenRouter · KI-Agents und Tool-Use · System-Prompts und Prompt-Engineering · Tokens und Kontextfenster
Kommentare
Frage, Verbesserungsvorschlag oder eigene Erfahrung zu diesem Artikel? Schreib einen Kommentar. Neue Beiträge erscheinen nach kurzer Moderation.
- Lade Kommentare …