Claude API Structured Output: Drei Muster für garantiertes JSON
Wer aus dem OpenAI-SDK kommt, kennt response_format: { type: "json_object" } oder strict JSON-Schema-Mode. Sie übergeben ein Schema, OpenAI erzwingt es auf Decoder-Ebene, Sie bekommen parsebares JSON oder einen Fehler. Simpel.
Claude hat das nicht. Kein response_format-Flag, kein strict-Schema-Decoder, kein JSON-Mode-Toggle. Wenn Sie Claude im Prompt höflich um JSON bitten, liefert es meist. “Meist” ist kein Wort, das ich in Produktion haben will. Ich fahre zehn KI-Agenten als Cron-Skripte auf einem Debian-VPS. Jeder parsed Claude-Output downstream in typisierte Objekte. Ein nicht escaptes Quote im String-Feld killt die Pipeline um 06:30, während ich schlafe.
Der Fix ist nicht eine Technik, sondern drei, sortiert nach Zuverlässigkeit. Tool Use, wenn Sie strukturelle Garantien brauchen. Assistant-Prefill, wenn der Output flach ist und Sie Tokens sparen wollen. Prompt-only, wenn Sie prototypen und nicht stört, wenn es bricht. Dieser Post geht alle drei mit echtem TypeScript-Code und den exakten Tradeoffs, die ich in Produktion sehe.
Warum Claudes JSON-Story anders ist
Anthropics Position: Tool Use löst bereits Structured Output, ein separater JSON-Mode wäre redundant. Weitgehend richtig. Ein Tool-Input-Schema IST ein JSON-Schema. Wenn Sie Claude zwingen, ein spezifisches Tool aufzurufen, ist der Output auf dieses Schema gezwungen. Sie bekommen dieselben Garantien wie mit OpenAIs Strict-Mode — nur durch eine andere Tür.
Der Haken: Tool Use trägt Schema-Overhead auf jedem Call (grob 20 bis 40 Tokens, je nach Schema-Größe) und liefert den Output im tool_use-Content-Block statt als Top-Level-Message. Für flache Extraktion fühlt sich das übertrieben an. Hier kommt Prefill rein.
Prefill nutzt eine einfache Tatsache aus: Claudes API erlaubt es, die Assistant-Response für Claude zu starten. Wenn die letzte Message in der Liste role: "assistant" und content "{" hat, muss Claude von der offenen Klammer weiterlaufen. Es kann nicht mit “Sure, here is the JSON” anfangen. Der nächste Token muss valider JSON-Content sein. Das Pattern nutze ich in den meisten Cron-Skripten — billig, schnell, deckt 90 % ab.
Prompt-only ist der “bitte JSON zurückgeben”-Ansatz. Funktioniert bis es nicht mehr funktioniert, und wenn es bricht, bricht es auf Arten, die schwer mit einfacher Validierung zu fangen sind. Nutze ich nur für Einmal-Skripte.
Muster 1: Tool Use als Schema-Vertrag
Das zuverlässigste Pattern — und wo ich zuerst hingreife, wenn die Output-Form zählt. Der Trick: ein Tool definieren, dessen einziger Zweck es ist, Ihre strukturierten Daten zu empfangen, und Claude zwingen, es aufzurufen.
Ein End-to-End-Beispiel. Ich will Kontaktinfo aus einem rohen E-Mail-Body extrahieren: Absendername, E-Mail-Adresse, Intent (einer aus einem fixen Enum) und Priority-Score.
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
const client = new Anthropic();
// 1. Define the schema in zod for runtime validation.
const ContactSchema = z.object({
name: z.string(),
email: z.string().email(),
intent: z.enum(["sales", "support", "spam", "other"]),
priority: z.number().int().min(1).max(5),
});
type Contact = z.infer<typeof ContactSchema>;
// 2. Define the tool. The input_schema is what Claude fills in.
const extractContactTool = {
name: "extract_contact",
description: "Extract contact details from an email body.",
input_schema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Sender's full name" },
email: { type: "string", description: "Sender's email address" },
intent: {
type: "string",
enum: ["sales", "support", "spam", "other"],
description: "Primary reason for the email",
},
priority: {
type: "integer",
minimum: 1,
maximum: 5,
description: "1 = low, 5 = urgent",
},
},
required: ["name", "email", "intent", "priority"],
},
};
async function extractContact(emailBody: string): Promise<Contact> {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 512,
tools: [extractContactTool],
tool_choice: { type: "tool", name: "extract_contact" },
messages: [
{
role: "user",
content: `Extract contact details from this email:\n\n${emailBody}`,
},
],
});
// 3. Pull the tool_use block out of the response.
const toolUse = response.content.find((b) => b.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") {
throw new Error("Claude did not call the tool");
}
// 4. Validate. Tool use is reliable but not infallible for enums.
return ContactSchema.parse(toolUse.input);
}
Zwei Dinge machen das kugelsicher. Erstens: tool_choice: { type: "tool", name: "extract_contact" } zwingt Claude, genau dieses Tool aufzurufen. Kein Prosa-Fallback möglich. Zweitens: das input_schema zwingt die Output-Form auf Decoder-Ebene. Required-Felder sind da. Typen stimmen.
Die zod-Parse am Ende ist Gurt-und-Hosenträger. Ich habe gesehen, dass Claude gelegentlich Enum-Werte fuzzt ("SALES" statt "sales" oder einen Wert synthetisiert, der nicht in der Liste ist), wenn der E-Mail-Inhalt mehrdeutig ist. Validierung fängt das ab und erlaubt Retry oder Fallback.
Wer aus dem OpenAI-Ökosystem kommt: das Migrations-Pattern ist straightforward. Vollständiges Mapping in Von OpenAI-Structured-Output zu Claude migrieren und tieferer Blick in Claude-Tool-Use-Patterns.
Muster 2: Assistant-Prefill
Wenn der Output ein flaches Objekt mit zwei oder drei Feldern ist, fühlt sich Tool Use schwer an. Prefill gibt 90 % der Zuverlässigkeit bei 1 Token Overhead statt 20 bis 40.
async function extractContactPrefill(emailBody: string): Promise<Contact> {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 256,
messages: [
{
role: "user",
content: `Extract contact details from this email as JSON with keys name, email, intent (one of: sales, support, spam, other), priority (1-5 integer).\n\nEmail:\n${emailBody}\n\nReturn ONLY the JSON object.`,
},
{
role: "assistant",
content: "{",
},
],
});
const text = response.content[0];
if (text.type !== "text") throw new Error("Unexpected content type");
// Claude's output starts AFTER the prefill, so we prepend the "{".
const raw = "{" + text.text;
// Sometimes Claude closes with a trailing explanation. Strip it.
const jsonEnd = raw.lastIndexOf("}");
const cleaned = raw.slice(0, jsonEnd + 1);
return ContactSchema.parse(JSON.parse(cleaned));
}
Das Prefill "{" ist non-negotiable als nächster Token. Claude kann kein “Here is the JSON:” davorsetzen. Es muss valides JSON weiterschreiben.
Das ist das Pattern, das ich produktiv für mein Morning-Briefing und Agenda-Follow-up-Cron-Skripte einsetze. Das Skript ruft claude -p mit JSON-Instruktion und Prefill, pipet den Output durch jq, postet das Ergebnis nach Telegram. Prefill plus Post-Parse-Validierung hat über hunderte tägliche Runs zuverlässig gehalten. Wenn es scheitert, scheitert es beim JSON.parse — und das fange ich ab und logge.
Prefills Schwachstelle sind geschachtelte Strukturen. Bei flachen Objekten schließt Claude die Klammer und stoppt. Bei tief geschachtelten Schemata mit Optional-Feldern schreibt es manchmal trailing commas oder lässt Closing-Brackets unter Druck weg. Bei mehr als einer Ebene Schachtelung auf Tool Use umsteigen.
Muster 3: Prompt-only (und warum nicht)
Der naive Ansatz. Im Prompt um JSON bitten, hoffen.
// DO NOT use this in production.
async function extractContactBad(emailBody: string): Promise<Contact> {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 512,
messages: [
{
role: "user",
content: `Return a JSON object with name, email, intent, priority from this email:\n${emailBody}`,
},
],
});
const text = (response.content[0] as any).text;
return ContactSchema.parse(JSON.parse(text));
}
Funktioniert in vielleicht 85 % der Fälle. Die anderen 15 % bekommen Sie:
"Sure, here is the extracted contact:\n\n{...}"(Präambel bricht Parse)- Markdown-Code-Fences:
```json\n{...}\n```(Fences brechen Parse) - Trailing-Erklärung:
{...}\n\nNote that the priority is 5 because... - Single-Quotes statt Double-Quotes
- Nicht escapte Newlines in String-Werten
Sie können eine Regex-Pipeline bauen, um das meiste zu retten. Zu dem Zeitpunkt haben Sie eine schlechtere Version von Prefill neu erfunden. Schritt überspringen. Muster 1 oder 2 nehmen.
Den Output validieren
Egal welches Muster — den geparsten Output validieren, bevor er den Rest der Pipeline berührt. Tool Use gibt strukturelle Garantien. Es schützt nicht vor semantischen Fehlern wie einer halluzinierten E-Mail-Adresse, die syntaktisch valide, aber fiktiv ist.
Mein produktives Pattern: zweistufiger Fallback.
async function extractWithFallback(emailBody: string): Promise<Contact | null> {
try {
return await extractContact(emailBody);
} catch (err) {
console.error("Tool use failed, retrying with prefill", err);
try {
return await extractContactPrefill(emailBody);
} catch (err2) {
console.error("Both patterns failed, skipping", err2);
return null;
}
}
}
Einmal mit anderem Pattern retryen, dann aufgeben und loggen. Die Pipeline wegen einer einzelnen schlechten E-Mail zu crashen ist schlimmer, als einen Datensatz zu verlieren.
Bei Read-heavy-Workloads, bei denen derselbe System-Prompt oder dasselbe Tool-Schema sich über Calls wiederholt, können Sie Claudes Prompt-Caching darüberlegen, um die Schema-Tokens auf den meisten Calls zu sparen. Der Schema-Overhead fällt von 20 bis 40 Tokens pro Call auf rund 2 gecachte Tokens, sobald der Cache warm ist.
Edge Cases, die Claude stolpern lassen
Einige Patterns brauchen im Vergleich zu OpenAI extra Sorgfalt.
Enums. Claude respektiert Enum-Constraints in Tool-Schemata meistens, synthetisiert aber gelegentlich Werte bei Mehrdeutigkeit. Enums immer in zod oder pydantic validieren. Für kritische Enums die erlaubten Werte zusätzlich in der Tool-Description nennen, nicht nur in der enum-Property. Die Redundanz hilft.
Optional-Felder. Claude nimmt Optional-Felder manchmal mit null-Werten auf, lässt sie manchmal weg. Ihr Schema braucht .optional().nullable() oder das Äquivalent. Entscheiden, was Sie wollen — und explizit sein.
Tief geschachtelte Objekte. Über zwei Schachtelungsebenen fängt Claude an, Struktur zu halluzinieren. Wenn möglich flach halten. Wenn Sie tief schachteln müssen, in mehrere Tool-Calls splitten oder Extended Thinking einsetzen, damit das Modell den Output plant, bevor es ihn schreibt.
Integer vs. Float. Ein als integer typisiertes Feld kommt manchmal als 3.0 zurück. Im Validator casten.
Welches Muster wann
- Tool Use, wenn: das Schema geschachtelt ist, Enums zählen, das Objekt mehr als drei Felder hat oder der Output ein typisiertes Downstream-System speist. Default für alles in Produktion.
- Prefill, wenn: der Output ein flaches Objekt mit zwei oder drei Feldern ist, Sie den Call tausendfach pro Tag fahren und Token-Kosten zählen, oder Sie die Response als Plain Text für einfacheres Logging wollen. Das nutze ich in den meisten Cron-Skripten.
- Prompt-only, wenn: Sie in einem Jupyter-Notebook explorieren oder der Output buchstäblich ein Feld ist, das Sie per Regex rausholen können. Nie in Produktion.
Das Pattern, zu dem ich zuerst greife, ist Tool Use mit zod-Validierungs-Fallback. Es kostet ein paar Tokens mehr als Prefill — und bringt mir Schlaf. Wenn 06:30 kommt und der Cron feuert, will ich, dass das JSON auch JSON ist.
Weiterführendes
- Claude-API-Prompt-Caching: Schema-Overhead auf wiederholten Calls senken
- Claude-API-Tool-Use-Leitfaden: tiefere Tool-Use-Patterns
- Claude API vs. OpenAI für Business-Automatisierung: vollständige Migrations-Story