Structured outputs: LLM-antwoorden waar je software op kan bouwen
Hoe je met structured outputs en een JSON-schema LLM-antwoorden krijgt die je software kan verwerken, en waarom je daarna nog steeds moet valideren.
Wie een taalmodel in een workflow zet, loopt vroeg of laat tegen hetzelfde probleem aan: het antwoord is tekst, en je software heeft data nodig. Een prompt als “antwoord alleen in JSON” werkt meestal. Meestal is niet genoeg voor een proces dat duizend keer per dag draait.
Het probleem met vrije tekst
Bij vrije tekst moet je code het antwoord ontleden. Dat gaat mis op voorspelbare manieren: een inleidende zin vóór de JSON, een ontbrekend veld, een getal als tekst, een extra komma. Elke fout betekent een retry, een uitzondering in je logs of erger: een half gevuld record dat stilletjes doorloopt naar het volgende systeem.
Structured outputs: het schema als contract
Met structured outputs geef je het JSON-schema mee in het verzoek. Het model genereert dan een antwoord dat aan dat schema voldoet: de velden die je verwacht, met de types die je verwacht. In TypeScript, met de Anthropic SDK en een Zod-schema:
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const Invoice = z.object({
supplier: z.string(),
invoiceNumber: z.string(),
invoiceDate: z.string(), // ISO 8601
totalExVat: z.number(),
vatAmount: z.number(),
currency: z.enum(["EUR", "USD", "GBP"]),
});
const client = new Anthropic();
const response = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 16000,
messages: [{ role: "user", content: `Lees deze factuur uit:\n\n${invoiceText}` }],
output_config: { format: zodOutputFormat(Invoice) },
});
if (response.stop_reason === "refusal" || response.parsed_output === null) {
return { status: "review", reason: "geen bruikbaar antwoord" };
}
const invoice = response.parsed_output;
Twee dingen vallen op. De parse-stap is weg: parsed_output is al een getypeerd object. En er is een expliciet pad voor wanneer er géén bruikbaar antwoord is. Dat pad hoort in elke productie-integratie.
Vorm is niet hetzelfde als waarheid
Een schema garandeert de vorm, niet de inhoud. Het model kan nog steeds een verkeerd bedrag lezen of een datum verwarren. Daarom volgt na de structuur een tweede laag: bedrijfsregels in gewone code.
function checkInvoice(inv: z.infer<typeof Invoice>): string[] {
const issues: string[] = [];
if (inv.totalExVat <= 0) issues.push("totaal is niet positief");
const vatRate = inv.vatAmount / inv.totalExVat;
if (![0, 0.09, 0.21].some((r) => Math.abs(vatRate - r) < 0.005)) issues.push("btw-percentage onbekend");
if (Number.isNaN(Date.parse(inv.invoiceDate))) issues.push("ongeldige datum");
return issues;
}
Faalt een controle, dan gaat het document naar een review-wachtrij in plaats van naar de boekhouding. Zo blijft de automatisering snel voor de gewone gevallen en veilig voor de rest.
Regels en model combineren
In SalesCtrl is die verdeling nog een stap verder doorgevoerd. Regelgebaseerde engines detecteren wat vastligt (vragen, bezwaren, afsluiters) en het model levert via een JSON-schema de interpretatie. De twee uitkomsten worden daarna samengevoegd, met per veld de bron. Deterministische code doet wat deterministisch kan; het model doet wat alleen een model kan.
Checklist voor productie
- Schema in het verzoek, niet alleen in de prompt.
- Een pad voor geen antwoord: weigering, lege of afgebroken output.
- Bedrijfsregels na het schema in gewone, testbare code.
- Een review-wachtrij voor alles wat niet door de controles komt.
- Logging per run: model, tokens, uitkomst van de controles. Zonder die gegevens weet je niet of de workflow beter of slechter wordt.
Structured outputs maken een LLM-stap niet foutloos. Ze maken fouten wel zichtbaar en afvangbaar, en dat is het verschil tussen een demo en een proces waar je op kunt bouwen.