Output Parsing and Repair
LLM output parsing when structured outputs are unavailable: strip fences, repair JSON, re-ask with validator errors, then give up.
- parsing
- json
- reliability
Trailing commas and ```json fences will outlive every model version you pin. LLM output parsing is the boring layer between a sampled string and a typed object your UI can trust. Prefer native structured outputs. When the provider cannot constrain decoding, or you are stuck on a model that loves markdown, parse then repair, with a cap.
Unbounded “fix the JSON” loops are how a Tuesday typo becomes a token bill. Two repairs. Then fail closed.
Key takeaways
- Constrained decoding is the default. A repair loop is a fallback, not an architecture.
- Strip fences, then
JSON.parse, then Zod (or JSON Schema). Valid JSON can still be a wrong invoice.- Feed validator errors back as a follow-up turn. Do not regex the prose forever.
- Cap repairs (two is plenty). After that, 422 the client with a stable error code.
- Never
JSON.parsea partial stream. Wait for the object to close, or use a streaming object parser that expects incompleteness.
LLM output parsing as a bounded pipeline
Numbered on purpose. Skip a step and you will invent a third JSON library.
- Ask the provider for a schema (
response_format, Anthropic structured outputs, AI SDKgenerateObject). If it holds, stop. Structured output with the AI SDK. - Strip markdown fences, leading “Sure, here is”, trailing commentary.
- Parse with
JSON.parse. If it throws, optionally run a mechanical repair (trailing commas, smart quotes). - Validate with Zod. Schema-valid is not business-valid:
amountCents > paidCentsstill fails. - Re-ask at most twice, putting the validator message in the next user or tool turn. Then give up.
import { z, type ZodType } from 'zod';
const Invoice = z.object({
invoiceId: z.string().min(1),
currency: z.enum(['USD', 'EUR', 'GBP']),
amountCents: z.number().int().nonnegative(),
});
type Invoice = z.infer<typeof Invoice>;
function stripFence(raw: string): string {
const trimmed = raw.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
return (fenced?.[1] ?? trimmed).trim();
}
function tryJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
const loosened = text
.replace(/,\s*([}\]])/g, '$1')
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, '"');
return JSON.parse(loosened);
}
}
export async function parseWithRepair<T>(
generate: (hint: string) => Promise<string>,
schema: ZodType<T>,
maxRepairs = 2,
): Promise<T> {
let hint = 'Return a single JSON object. No markdown.';
let lastErr = 'parse_exhausted';
for (let attempt = 0; attempt <= maxRepairs; attempt++) {
const raw = await generate(hint);
try {
const value = tryJson(stripFence(raw));
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
lastErr = parsed.error.message;
} catch (err) {
lastErr = err instanceof Error ? err.message : 'json_parse';
}
hint = `Your previous output failed: ${lastErr}. Return JSON only, no fences.`;
}
throw new Error(lastErr);
}
tryJson’s second pass is the “jsonrepair as last resort” move: mechanical, local, no extra model call. If that still throws, you spend a repair turn. A dedicated repair library is fine on step 3. It is not a substitute for step 1.
After the schema succeeds, run your rules:
function assertPayable(inv: Invoice, paidCents: number): Invoice {
if (inv.amountCents > paidCents) {
throw new Error('amount_exceeds_paid');
}
return inv;
}
The model can emit amountCents: 999999999 in perfect JSON. That is not a parse bug.
What you should repair vs refuse
| Symptom | Repair? | Why |
|---|---|---|
```json wrapper |
Yes, strip | Models will keep doing this |
| Trailing comma | Yes, mechanical | Not worth a round trip |
Smart quotes, True instead of true |
Maybe mechanical | One pass, then re-ask |
| Extra keys | Ignore or strip via Zod .strict() |
Decide if unknown keys are an attack |
| Missing required field | Re-ask with the Zod path | The model omitted work |
| Valid JSON, illegal business state | Do not repair. 422 | Repair would invent money |
Partial stream ({"id": "a") |
Wait, do not parse | You will throw every token |
| HTML / essay with no object | Give up after cap | Another try will essay harder |
| Tool call args | Same pipeline on args, then auth | Parse success is not permission |
Streaming is the usual footgun. The UI can paint tokens. Your parser cannot. Buffer the structured payload, or use a parser built for incremental objects. Streaming.
Re-ask as tool feedback, not as prompt poetry
A second generation should see the validator, not “please be careful.” If the provider supports tool results or a user turn, send:
const repairTurn = {
role: 'user' as const,
content: `Validation error:\n${lastErr}\nResend the object only.`,
};
Keep temperature low on extraction. A repair pass at 0.8 will invent a prettier, still-wrong field. Pin the same schema on the retry. Do not widen the type “so it passes.”
Retries here are semantic. They are not HTTP 429 retries. Do not stack them with a network retry that also re-fires a side-effecting tool. Retries, timeouts, fallbacks.
Prefer not to write this
Native structured outputs constrain tokens to the schema. You still validate business rules after. The AI SDK’s generateObject is the TypeScript-shaped version of that: Zod in, object out, and you choose what happens on failure instead of hoping.
Write a repair helper when:
- the only model that can do the task does not expose constrained decoding
- you are reading an older completion you cannot regenerate
- a vendor outage forced you onto a fallback model that ignores
response_format
Do not write it because a blog post parsed ChatGPT with regex in 2023. That code still fails on a Tuesday, just with more stars in the fence.
Interview answer (out loud)
“I prefer schema-constrained decoding. If I cannot have it, I strip fences, JSON.parse, then Zod, then at most two re-asks that include the validator error. Mechanical repair for trailing commas is allowed. I do not parse partial streams, and I do not let a valid object skip business rules. After the cap I fail closed.”
Further reading (primary sources)
Related on this site
- Structured outputs and JSON Schema
- Structured output with the AI SDK
- Retries, timeouts, and fallbacks
Related guides
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.
- Retries, Timeouts, and FallbacksLLM APIs flake. Retry with jitter on 429/5xx, bound the wait, fall back to a smaller model, and never retry a successful side-effecting tool.
- Structured Outputs and JSON SchemaForce the model into a schema instead of parsing its vibes. When constrained decoding helps, and when the model still fills fields with junk.
- Agent HandoffsA handoff passes the conversation to another agent with a slimmer prompt and different tools. What to copy, what to drop, and how to avoid ping-pong.
- Agent MemoryAgents forget when the context window ends. Working memory, session summaries, and long-term stores are three different systems. Do not mix them.