Validate and Repair Loop
Also known as: self-correction, retry with error feedback, reflexion on validation
When output fails validation, hand the model the specific errors and let it fix them instead of retrying blind.
The problem
Validation failures get handled in one of two bad ways. Blind retry re-runs the same prompt and usually reproduces the same error, since nothing about the situation changed. Hard failure throws away work that was 95% correct because one field was malformed. Neither uses the information you already have: you know exactly what was wrong, in machine-readable form, and the model can act on that.
The pattern
Treat validation errors as feedback. On failure, send the model its previous output plus the specific validation errors — field paths, expected types, failed constraints — and ask for a corrected version. Bound the loop tightly, escalate the strategy if repair does not converge, and distinguish repairable violations from semantic errors no amount of reformatting will fix.
Use when
- Structured output must satisfy constraints beyond shape — cross-field rules, business logic
- Constrained decoding is unavailable, or your validation exceeds what a schema can express
- Output is expensive to regenerate from scratch and usually nearly right
- You have deterministic validators: a JSON schema, a SQL parser, a type checker, a test suite
Avoid when
- The validator cannot say what is wrong — a boolean pass/fail gives the model nothing
- Failures indicate a misunderstood task rather than a malformed answer; fix the prompt
- Latency budget cannot absorb a second and third model call
Tradeoffs
GainRecovers most near-miss outputs, often turning a 90% pass rate into 99%
CostFailed attempts cost a full extra generation each
GainWorks with any deterministic validator, including compilers and test runs
CostRepair prompts grow the context, and long error lists can confuse more than help
GainRepair failure rates reveal exactly which constraints the model cannot satisfy
CostRepair can mask a bad prompt — you fix symptoms while the root cause persists
The design rule: the quality of the repair is bounded by the quality of the error message. A validator that says "invalid" is useless here; one that says which field, what was expected, and what was received usually gets a correct answer on the first repair.
Format errors for a model, not for a log
Raw validator output is written for developers and is often terse to the point of uselessness out of context. Translate it: field path, what was expected, what was received, and where possible a hint. Cap the list — a model handed forty errors fixes the first few and drifts on the rest.
MAX_ERRORS_SHOWN = 8
def format_errors(e: ValidationError) -> str:
lines = []
for err in e.errors()[:MAX_ERRORS_SHOWN]:
path = ".".join(str(p) for p in err["loc"]) or "(root)"
got = repr(err.get("input"))[:80]
lines.append(f"- {path}: {err['msg']} (received {got})")
if len(e.errors()) > MAX_ERRORS_SHOWN:
lines.append(f"- ...and {len(e.errors()) - MAX_ERRORS_SHOWN} more")
return "\n".join(lines)The bounded loop
Two repair attempts is the right default. Track whether the error set is shrinking: if attempt two produces the same errors as attempt one, the model cannot satisfy the constraint by reformatting and further attempts are waste. Escalate instead — a stronger model, a simplified schema, or a clean failure.
REPAIR = """Your previous output failed validation.
Your output:
{previous}
Errors:
{errors}
Return the corrected output. Fix only what the errors identify — keep every other
field exactly as it was. If a field cannot be determined from the source, use null
rather than inventing a value."""
def generate_validated(prompt: str, schema: type[T], max_repairs: int = 2) -> T:
raw = call_model(prompt)
previous_errors: set[str] = set()
for attempt in range(max_repairs + 1):
try:
return schema.model_validate_json(raw)
except ValidationError as e:
current = {f"{'.'.join(map(str, x['loc']))}:{x['type']}" for x in e.errors()}
if attempt == max_repairs:
raise UnrepairableOutput(raw, e)
if current == previous_errors:
# Same failures twice: reformatting will not fix this.
raise UnrepairableOutput(raw, e, reason="not_converging")
previous_errors = current
metrics.increment("repair.attempt", tags={"attempt": attempt + 1})
raw = call_model(REPAIR.format(previous=raw, errors=format_errors(e)))"Fix only what the errors identify" matters more than it looks. Without it, models rewrite the whole object during repair, and fields that were correct come back subtly different — a silent data-corruption bug that validation cannot catch.
Validators beyond schemas
The loop generalizes to any deterministic checker that produces actionable errors. Generated SQL goes through a parser and an EXPLAIN; generated code goes through a type checker and a test run; generated configuration goes through the tool's own validator. The pattern is identical — run the checker, feed its errors back, bound the loop — and it is what makes model-generated code usable in a pipeline.
def generate_sql(question: str, schema_ddl: str, max_repairs: int = 2) -> str:
sql = call_model(sql_prompt(question, schema_ddl))
for _ in range(max_repairs):
problems = (
syntax_errors(sql)
or unknown_identifiers(sql, schema_ddl) # catches invented columns
or explain_errors(sql) # planner rejects it
or forbidden_operations(sql) # writes in a read-only path
)
if not problems:
return sql
sql = call_model(REPAIR.format(previous=sql, errors=bullets(problems)))
raise UnrepairableOutput(sql)- Error messages must name the field, the expectation, and the received value
- Cap the number of errors shown; long lists degrade repair quality
- Instruct the model to change only what failed, or correct fields will drift
- Detect non-convergence by comparing error sets between attempts
- Track repair rate per constraint — a constraint repaired constantly is a prompt bug
Log every repair with its before and after. Those pairs are the best possible dataset for improving the original prompt — and a constraint that appears in that log every day should be fixed upstream, not repaired downstream forever.