devShakib

Structured Output From LLMs: A Retry-Repair Loop Your Parser Never Sees Through

Reliable structured output from LLMs needs three layers: constrained decoding, JSON Schema validation you own, and a bounded retry repair loop. Here's the pattern.

The first time I wired an LLM into a real product feature at Shpper, I did the naive thing: prompt the model to "return JSON", jsonDecode the response, move on. It worked in the demo. Then it hit real traffic and I started getting FormatException at 2am because the model wrapped its JSON in a ``` `json ``` fence, or added a cheerful "Here's the data you asked for!" preamble, or trailed a comma before the closing brace. A model that's right 97% of the time is still wrong on thousands of requests a day. Reliable structured output isn't a prompting trick — it's a small pipeline, and the last stage is a repair loop your parser never sees through.

This is the pattern I reach for every time I need an LLM to hand back a typed object instead of prose: contact extraction, invoice parsing, classification against a fixed label set, turning a messy paragraph into a database row. The shape is identical every time, and once you internalize it you stop firefighting malformed JSON for good.

Why "return JSON" fails in production

The failure modes are boring and relentless, which is exactly why they're worth naming. Prompt-only JSON breaks in a handful of predictable ways:

None of these are exotic. They're the median Tuesday. The mistake is treating them as bugs to squash one by one instead of a class of failures to absorb architecturally.

The three layers, from strongest to weakest

You have three tools to force structure, and you should reach for them in this order — strongest guarantee first.

Here's what people miss: even the strong layers don't free you from validation. JSON mode guarantees syntactic validity — that you can jsonDecode it. It does not guarantee the model filled in the field you needed, respected your enum, or didn't drop a null where you require a string. Schema-enforced modes are much better, but a schema can't express every business rule: this date must be after that date; this array must be non-empty when type == "premium"; this email must belong to a domain you support. So the architecture is always the same three moves: generate as constrained as the provider allows, then validate against your own source of truth, then repair.

Validate against a schema you own

Do not hand-roll if (json['name'] == null) checks scattered across your codebase. Define the contract once and validate against it. In the Dart-heavy world I live in that means a real model class with a strict parser; on a Node or Python backend I'll use JSON Schema directly, or a Zod / Pydantic-style validator that doubles as the schema I send to the provider.

The parser has one job: turn any input into either a valid typed object or a structured error that describes exactly what's wrong — because that error is the input to the repair step. A parser that throws a generic "invalid" is useless here; the specificity of the error determines the quality of the repair.

class SchemaError implements Exception {  final List<String> messages;  SchemaError(this.messages);  @override  String toString() => 'SchemaError: ${messages.join('; ')}';}class ContactRecord {  final String name;  final String email;  final String? company;  ContactRecord({required this.name, required this.email, this.company});  /// Returns the record, or throws SchemaError with a machine-useful message list.  factory ContactRecord.parse(Map<String, dynamic> json) {    final errors = <String>[];    final name = json['name'];    final email = json['email'];    if (name is! String || name.trim().isEmpty) {      errors.add('"name" must be a non-empty string');    }    if (email is! String || !email.contains('@')) {      errors.add('"email" must be a valid email address containing "@"');    }    if (errors.isNotEmpty) {      throw SchemaError(errors); // carries the list to the repair loop    }    return ContactRecord(      name: name as String,      email: email as String,      company: json['company'] as String?,    );  }}

The load-bearing detail is that SchemaError carries a specific, actionable list — not just "invalid JSON". Vague errors produce vague repairs. "email" must contain "@" gets fixed on the next turn; "validation failed" gets you the same broken output again.

A useful discipline: the validator you run in your code and the schema you hand the provider should be derived from the same definition. When they drift, you get output that passes the provider's schema check but fails yours — the worst kind of silent mismatch. With Zod or Pydantic you literally generate the JSON Schema from the validator, so they can't disagree.

The retry-repair loop

When validation fails, you don't throw the whole request away. You hand the model back its own broken output plus the exact validation errors and ask it to fix them. Models are remarkably good at this — repairing a nearly-correct object is a far easier task than generating one from scratch, so the second attempt succeeds the overwhelming majority of the time.

A few things separate a production-grade loop from a foot-gun:

/// Defensive: pull the first balanced JSON object out of a raw string,/// so fences and preamble ("Here's your JSON:") don't blow up jsonDecode.Map<String, dynamic> extractJson(String raw) {  final start = raw.indexOf('{');  final end = raw.lastIndexOf('}');  if (start == -1 || end == -1 || end < start) {    throw const FormatException('no JSON object found');  }  return jsonDecode(raw.substring(start, end + 1)) as Map<String, dynamic>;}Future<ContactRecord> extractContact(String source) async {  String rawText = await callModel(    prompt: buildPrompt(source),    jsonMode: true, // strongest mode the provider offers  );  for (var attempt = 0; attempt < 3; attempt++) {    try {      return ContactRecord.parse(extractJson(rawText));    } on SchemaError catch (e) {      log.warning('repair attempt $attempt: ${e.messages}\nraw: $rawText');      if (attempt == 2) rethrow; // give up -> caller handles fallback      rawText = await callModel(        prompt: repairPrompt(previous: rawText, errors: e.messages),        jsonMode: true,        // On the last shot, spend more: lower temp / stronger model.        temperature: attempt == 1 ? 0.0 : 0.2,      );    } on FormatException catch (e) {      if (attempt == 2) rethrow;      rawText = await callModel(        prompt: repairPrompt(previous: rawText, errors: [e.message]),        jsonMode: true,        temperature: 0.0,      );    }  }  throw StateError('unreachable');}

The repairPrompt is boring but load-bearing. Something like: "The previous response failed validation. The errors were: [list]. Return only the corrected JSON object with no explanation, no markdown, and no code fences." Boring, deterministic, effective. Resist the urge to make it clever — the whole point is to reduce the model's freedom to the single act of patching the fields you named.

Note that I catch FormatException (couldn't even parse it) separately from SchemaError (parsed but wrong). Both feed the repair loop, but distinguishing them in your logs tells you whether the model is failing at syntax (a weak-mode problem) or semantics (a schema-clarity problem) — and the fixes are different.

Practical notes from production

A few opinions I've formed after shipping this pattern more than once:

Key takeaways

Wrap-up

Reliable structured output is not one thing you turn on — it's a layered contract. Constrain the model as hard as the provider lets you, validate against a schema you own rather than the model's promises, and wrap it in a bounded repair loop that feeds specific errors back to the model. Get those three layers right and your parser genuinely never sees malformed JSON — the loop absorbs it upstream. The whole thing is maybe forty lines of code, and it's the difference between a demo that impresses and a feature that survives Monday morning traffic.