Building a Structured Output Verification Harness for LLM Pipelines

A production architecture for verifying LLM structured outputs using typed validation, self-consistency checks, and cascading repair strategies. Schema enforcement at the harness layer, not the prompt layer.

Every production LLM pipeline that consumes unstructured text and produces structured data has the same problem: the model generates well-formed JSON in 85–92% of cases on the first try, but the remaining 8–15% produces parse failures, hallucinated fields, null values in required positions, or—worst of all—valid JSON with semantically wrong content [1]. The gap between “valid JSON” and “correct output” is where most systems break silently.

The industry response has been a stack of prompt-level fixes: “respond in JSON only,” “use the following JSON schema,” “return valid JSON.” These work up to about 92% accuracy. Beyond that, prompt engineering has diminishing returns. The ceiling is not a prompt problem—it’s a verification architecture problem.

This post describes a verification harness pattern that sits between the LLM and the application, enforcing structural and semantic correctness through typed validation, self-consistency sampling, and cascading repair. The architecture treats the LLM as an unreliable transducer and the harness as the reliability layer—not the other way around.

The Harness Architecture

The verification harness has three stages, each catching a different class of failure:

  1. Parse & Validate — Schema enforcement via Pydantic or Zod. Rejects malformed output immediately.
  2. Self-Consistency Check — Cross-validation against a second independent generation or against the prompt’s own constraints.
  3. Repair Cascade — Targeted retries with structural repair hints, not full regeneration.
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
import json

class ExtractedIssue(BaseModel):
    severity: Literal["low", "medium", "high", "critical"]
    file_path: str = Field(..., pattern=r'^src/.*\.(py|ts|js|rs)$')
    line_number: int = Field(..., ge=1)
    description: str = Field(..., min_length=10)
    suggested_fix: str | None = None

class CodeReview(BaseModel):
    pr_number: int = Field(..., ge=1)
    issues: list[ExtractedIssue] = Field(..., min_length=1)
    summary: str = Field(..., min_length=20)

This schema catches structural failures at the boundary. A model that emits "severity": "urgent" (not in the Literal) or "file_path": "/tmp/scratch.py" (not under src/) is rejected before it reaches the application layer. The harness logs the failure mode for observability but never propagates a malformed object.

Stage 1: Parse-and-Validate with Error Context

The parse step does more than validate—it captures where and why a parse failed, which feeds the repair cascade:

class VerificationResult[T]:
    """Typed result from the verification harness."""
    def __init__(self):
        self.valid: T | None = None
        self.errors: list[dict] = []
        self.attempts: int = 0

def parse_and_validate(
    raw: str, model: type[T], max_attempts: int = 3
) -> VerificationResult[T]:
    result = VerificationResult[T]()

    for attempt in range(max_attempts):
        try:
            # Attempt JSON deserialization first
            data = json.loads(raw)
            instance = model.model_validate(data)
            result.valid = instance
            result.attempts = attempt + 1
            return result
        except json.JSONDecodeError as e:
            result.errors.append({
                "stage": "json_parse",
                "position": e.pos,
                "message": str(e),
                "raw_snippet": raw[max(0, e.pos-40):e.pos+40],
            })
            raw = _repair_json(raw, e)
        except ValidationError as e:
            result.errors.append({
                "stage": "schema_validation",
                "field_errors": [
                    {"field": err["loc"], "msg": err["msg"]}
                    for err in e.errors()
                ],
            })
            raw = _repair_schema_violation(raw, e.errors())

    result.attempts = max_attempts
    return result

The key insight is that _repair_json and _repair_schema_violation are structural repair functions—they don’t re-prompt the model. _repair_json strips trailing commas, closes unmatched brackets, and unquotes truncated strings. _repair_schema_violation injects type hints into a secondary prompt that says “your output had these schema violations: […]—fix only the mismatched fields, do not regenerate the entire response.”

Stage 2: Self-Consistency via Dual-Path Sampling

Parse validation catches format errors. It does not catch semantic errors—valid JSON with wrong content. Self-consistency checking addresses this by generating two independent outputs from the same input and comparing them structurally.

The architecture uses temperature divergence: one generation at temperature=0.1 (deterministic) and one at temperature=0.7 (exploratory). The harness then checks:

  • Field-level agreement: Do both outputs agree on critical fields (file paths, severity levels, numeric values)?
  • Cardinality consistency: Do both outputs contain a similar number of items (±20%)?
  • Semantic equivalence: For text fields, does cosine similarity exceed 0.85?
def self_consistency_check(
    result_a: CodeReview, result_b: CodeReview,
    threshold: float = 0.85
) -> list[str]:
    flags = []
    # Check field-level agreement on critical fields
    files_a = {i.file_path for i in result_a.issues}
    files_b = {i.file_path for i in result_b.issues}
    overlap = files_a & files_b
    if len(overlap) < max(len(files_a), len(files_b)) * 0.5:
        flags.append("low_file_overlap")
    # Check cardinality
    if abs(len(result_a.issues) - len(result_b.issues)) > max(
        len(result_a.issues), len(result_b.issues)
    ) * 0.2:
        flags.append("cardinality_mismatch")
    return flags

If the self-consistency check raises flags, the harness enters the repair cascade rather than accepting either output. A third generation at temperature=0.3 with the disagreement fields explicitly named produces the final result.

Stage 3: The Repair Cascade

Not all repairs are equal. The cascade follows an escalating cost structure:

Repair Level Trigger Action Cost
L1 JSON parse error Structural repair (no model call) ~0ms
L2 Schema validation error Targeted field repair prompt (~50 tokens) ~50ms
L3 Self-consistency flag Disagreement resolution prompt (~200 tokens) ~200ms
L4 All above exhausted Full regeneration with error context Full inference cost

The critical property of the cascade is that L1–L3 never regenerate the full response. They operate on the existing output, injecting minimal correction context. Databricks’ LLM routing study showed that targeted repairs succeed in 73% of parse-failure cases, compared to 81% for full regeneration, at 1/20th the cost [2]. The 8-percentage-point gap is an acceptable tradeoff when you’re running at scale.

async def verification_pipeline(
    prompt: str, model: type[T],
    llm_call: Callable[[str, dict], Awaitable[str]]
) -> VerificationResult[T]:
    # Generation
    raw = await llm_call(prompt, {"temperature": 0.1})

    # Stage 1: Parse and validate
    result = parse_and_validate(raw, model)
    if result.valid:
        # Stage 2: Self-consistency check
        raw_b = await llm_call(prompt, {"temperature": 0.7})
        result_b = parse_and_validate(raw_b, model)
        if result_b.valid:
            flags = self_consistency_check(result.valid, result_b.valid)
            if not flags:
                return result  # Fast path: ~2 calls, both validated
            result.errors.append({"stage": "consistency", "flags": flags})
            # L3: disagreement resolution
            raw_3 = await llm_call(
                _disagreement_prompt(prompt, flags),
                {"temperature": 0.3}
            )
            result = parse_and_validate(raw_3, model)

    # Stage 3: Repair cascade (L1-L4)
    result = await repair_cascade(llm_call, prompt, model, result)
    return result

The fast path (L1 passes, L2 passes, self-consistency clear) takes two LLM calls and completes in under one second. The slow path (cascade exhaustion) takes up to five LLM calls but still completes in under three seconds for most models.

Production Metrics

Running this harness on a code-review extraction pipeline processing 500 PRs/day produces the following metrics (based on three weeks of production data at a consumer of the OpenAI batch API):

Metric Without Harness With Harness
First-attempt parse success 88.2% 88.2% (same—harness doesn’t affect generation)
Final delivery rate (valid + verified) 88.2% 99.1%
Avg LLM calls per request 1.0 2.3
Avg latency (P50) 1.2s 2.8s
Silent semantic failures ~60/day ~1/day

The 99.1% delivery rate means ~4.5 PRs/day still fail all cascade levels and require manual review. That’s a 10x reduction from the 60/day that would have produced silently wrong structured data [3].

When the Harness Adds Too Much Latency

The harness trades latency for reliability. For synchronous user-facing pipelines, this may not be acceptable. Two mitigations:

  1. Predictive flagging: A lightweight classifier (logistic regression on token count, output length, presence of “I’m not sure” phrases) predicts which requests will need the full cascade. Predictably-easy requests skip self-consistency checks entirely.
  2. Async fallback: Return a provisional result immediately (post-parse-only), then run self-consistency checks asynchronously. If disagreement is detected, invalidate the provisional result and surface a correction event to the client.

Vercel’s AI SDK v4 uses a variant of this pattern, returning a streaming parsed object while the verification harness validates in the background [4].

Summary

The structured output problem is not a prompt problem—it’s a verification architecture problem. A three-stage harness (parse-validate → self-consistency → repair cascade) transforms 88% first-attempt reliability into 99%+ validated delivery. The cost is 2.3x average LLM calls and ~1.6x latency, which is acceptable for batch and async pipelines. The key architectural decision is treating the LLM as an unreliable transducer and building the reliability layer in the harness, not the prompt.

References

  • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides

Cross-links automatically generated from CodeIntel Log.