LLM Output Validation — Schema-Constrained Generation in Production
Parsing model output with a regex and a prayer doesn't survive contact with traffic. The validation layers that keep structured LLM output reliable — constrained decoding, schema validation, and the repair loop.
Key takeaways
- Parsing model output with a regex and a prayer doesn't survive contact with traffic.
- The validation layers that keep structured LLM output reliable — constrained decoding, schema validation, and the repair loop.
LLM Output Validation — Schema-Constrained Generation in Production#
The demo works: you ask the model for JSON, it returns JSON, you JSON.parse it. Then real traffic arrives and 2% of responses come back with a trailing comment, a markdown code fence, a hallucinated extra field, or a number where you expected a string. At a million requests a day, 2% is twenty thousand broken responses. Here's the layered approach that got our structured-output reliability from "mostly" to "we stopped getting paged."
Stop asking nicely; constrain the output#
The single biggest win was switching from "please return JSON" in the prompt to schema-constrained generation at the API layer. Most providers now support forcing the model to emit output matching a JSON schema — the decoder is masked so only tokens that keep the output valid against the schema are sampleable.
schema = {
"type": "object",
"properties": {
"intent": {"type": "string", "enum": ["refund", "question", "complaint"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"entities": {"type": "array", "items": {"type": "string"}},
},
"required": ["intent", "confidence"],
"additionalProperties": False,
}
response = client.messages.create(
model="claude-...",
tools=[{"name": "classify", "input_schema": schema}],
tool_choice={"type": "tool", "name": "classify"},
messages=[{"role": "user", "content": text}],
)
result = response.content[0].input # already schema-valid
Forcing a tool call with a strict input_schema means the structural classes of failure — fences, prose preamble, wrong types, missing required fields — largely disappear because the model literally cannot emit them. This is the difference between validating output and making invalid output unrepresentable.
Validate anyway — constraints aren't semantics#
Constrained decoding guarantees the output is shaped right. It does not guarantee it's correct. confidence: 0.99 on a wrong classification is still schema-valid. So the second layer is semantic validation in your own code:
from pydantic import BaseModel, field_validator
class Classification(BaseModel):
intent: str
confidence: float
entities: list[str] = []
@field_validator("entities")
@classmethod
def entities_must_appear_in_source(cls, v, info):
# reject hallucinated entities not present in the input
source = info.context["source_text"].lower()
return [e for e in v if e.lower() in source]
We drop entities the model invented that don't appear in the source text. Schema validation can't catch that — it's a business rule, and business rules live in your code, not the model's prompt.
The repair loop, bounded#
When validation still fails — usually a semantic constraint, occasionally a provider that doesn't support strict schemas — feed the error back and retry once or twice, not forever:
def generate_validated(text, max_attempts=2):
messages = [{"role": "user", "content": text}]
for attempt in range(max_attempts):
raw = call_model(messages)
try:
return Classification.model_validate(raw, context={"source_text": text})
except ValidationError as e:
messages.append({"role": "assistant", "content": str(raw)})
messages.append({"role": "user",
"content": f"That failed validation: {e}. Fix and resend."})
raise OutputValidationError("exhausted repair attempts")
Two rules learned the hard way:
- Bound the retries. An unbounded repair loop on a request the model fundamentally can't satisfy is a cost and latency bomb. Cap it, then fall back.
- Feed the specific error. "That was invalid" gets you another invalid response. "Field
confidencewas 1.5, must be ≤ 1" gets you a fix.
Define the fallback before you need it#
When repair is exhausted, you need a defined behavior that isn't a 500. Depending on the call site:
- Route to a human queue (classification, moderation)
- Return a safe default with a
degraded: trueflag - Use the last partially-valid response with missing fields nulled
The worst outcome is an exception bubbling to the user because nobody decided what "the model couldn't comply" should do.
Where to draw the trust boundary#
Treat model output exactly like user input: untrusted until validated. Never let it flow directly into a SQL query, a shell command, a file path, or an API call without passing your schema and semantic checks first. Constrained decoding makes the happy path clean; the validation layer is what keeps a bad day from becoming an incident.
What moved the needle#
- Strict schema-forced tool calls: structural failures ~2% → ~0.05%
- Semantic validators: caught hallucinated entities the schema couldn't
- Bounded repair loop: recovered ~70% of the remaining failures within one retry
- Defined fallbacks: zero unhandled exceptions reaching users
Reliable structured output isn't one trick. It's constraining what the model can emit, validating what it means, repairing what's fixable, and having a plan for what isn't.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
CI Pipeline Caching That Actually Pays Off
Most CI caches either miss constantly or restore stale junk. The cache-key discipline, scope boundaries, and measurements that turned our pipeline cache from theatre into real minutes saved.
Edge Caching with Stale-While-Revalidate — Fast and Fresh at the CDN
The cache-control header most teams under-use. How stale-while-revalidate and stale-if-error turned our CDN from a freshness liability into a latency and resilience win — with the gotchas.
More from AI
Explore more articles in this category
AI Agents and Kubernetes Remediation: Write Access Is the Easy Part
Handing an agent kubectl is a five-minute job. Proving the fix worked and did no harm is the real work, and it belongs in the wrapper, not the prompt.
Plugin4Shell: A Pinned Plugin Is Not a Verified Plugin
A SHA pin that nobody checks is a label, not a control. Plugin4Shell showed that four major coding agents never checked.
Copilot's September Bill Cliff: Included AI Credits Just Dropped
The June to August promotion ended on September 1. Included Copilot credits fell 37% on Business and 44% on Enterprise while seat prices stayed flat. Here is the arithmetic and the controls.
You might have missed
Evergreen posts worth revisiting.