Frontier Engineering
← Structured Output & Extraction

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.

8 min read

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.

The quality of the repair is bounded by the quality of the error message.

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)))
warning

"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
tip

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.