LLM Output Validation: Schema-First Prompt Engineering Patterns
We invalidate ~6% of LLM outputs before they reach a downstream system. Here's how we structure prompts and validators to catch malformed responses early.
On this page
Of every 1,000 LLM completions our pipelines emit, approximately 63 are syntactically or semantically invalid — broken JSON, missing fields, fields filled with nonsense values that look right but aren't. We catch all of them before they reach a downstream system. Here's the pattern, the schema-first prompt structures, and the retry logic that gets us to four nines of correct outputs.
The Workload
- ~190k LLM calls/day across pipelines
- Use cases: classification (highest volume), structured extraction, tool selection
- Models:
gpt-4o-minifor high volume,gpt-4ofor complex extractions - Output format: JSON only. We never use free-form prose where a structure could be enforced
Layer 1: Prompt Structure That Anchors The Output
The prompt explicitly shows the schema the model should produce. Three patterns we use:
Pattern A: Inline JSON Schema
SCHEMA = {
"type": "object",
"required": ["category", "confidence", "reasoning"],
"properties": {
"category": {"enum": ["billing", "technical", "account", "other"]},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"reasoning": {"type": "string", "minLength": 10, "maxLength": 200}
}
}
PROMPT = f"""Classify the customer message below.
Output JSON matching this schema EXACTLY. No prose before or after the JSON.
Schema:
{json.dumps(SCHEMA, indent=2)}
Message:
{user_message}
"""
This is the highest-quality pattern. Not because the model can't be unfaithful, but because review and validation become trivial when the spec is visible alongside the output.
Pattern B: Examples Over Description
For non-trivial extractions, examples beat descriptions:
PROMPT = """Extract product mentions from text. Output JSON.
Example:
Input: "I love my MacBook Pro M4 and the iPhone 16 case I bought."
Output: {"products": [
{"name": "MacBook Pro", "version": "M4", "type": "laptop"},
{"name": "iPhone 16 case", "version": null, "type": "accessory"}
]}
Example:
Input: "Visited the store, didn't buy anything."
Output: {"products": []}
Now extract from:
""" + user_text
Two examples cover most edge cases. We rarely use more than three.
Pattern C: Provider's Structured Output API
OpenAI's response_format={"type": "json_schema", ...} constrains the decoder to produce valid JSON matching a schema. We use this for the highest-volume routes.
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "classification", "strict": True, "schema": SCHEMA},
},
)
This eliminates syntactic failures (broken JSON) entirely. Semantic failures still happen — the model writes valid JSON with wrong content — so validation downstream is still required.
Layer 2: Pydantic Validators (Or Equivalent)
Every LLM output passes through a strict Pydantic model before any downstream code touches it.
from pydantic import BaseModel, Field, field_validator
from enum import Enum
class Category(str, Enum):
BILLING = "billing"
TECHNICAL = "technical"
ACCOUNT = "account"
OTHER = "other"
class Classification(BaseModel):
category: Category
confidence: float = Field(ge=0.0, le=1.0)
reasoning: str = Field(min_length=10, max_length=200)
@field_validator("reasoning")
@classmethod
def reasoning_must_be_meaningful(cls, v: str) -> str:
# Catches the failure mode "I will classify this message"
bad_patterns = ["I will", "Let me", "Sure,"]
for p in bad_patterns:
if v.startswith(p):
raise ValueError(f"reasoning starts with non-substantive phrase: {p}")
return v
The custom validator on reasoning is the kind of thing that comes from production experience. The model occasionally produces "Let me think about this..." in fields that should contain conclusions. The validator catches it.
Layer 3: Retry With Targeted Repair
When validation fails, we don't just retry. We send the error back to the model:
class LLMValidationError(Exception):
pass
def call_with_validation(prompt: str, schema: type[BaseModel], max_retries: int = 2) -> BaseModel:
messages = [{"role": "user", "content": prompt}]
for attempt in range(max_retries + 1):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
response_format={"type": "json_object"},
)
raw = response.choices[0].message.content
try:
return schema.model_validate_json(raw)
except (ValueError, ValidationError) as e:
if attempt >= max_retries:
raise LLMValidationError(f"failed after {max_retries+1} attempts: {e}")
# Targeted repair prompt
messages.append({"role": "assistant", "content": raw})
messages.append({
"role": "user",
"content": f"Your output failed validation: {e}. Fix and reply with corrected JSON only.",
})
Showing the model its own previous output plus the specific error dramatically improves the recovery rate compared to a fresh retry.
In our metrics:
- First-attempt success: 94%
- After one retry: 99.2%
- After two retries: 99.7%
- Hard failure: 0.3% (we treat these as actual application errors)
Layer 4: Fail-Open vs Fail-Closed
When all retries fail, what happens?
Fail-closed (we use for): classification, routing decisions, anything that gates user experience. The pipeline returns an error; user sees a generic "we couldn't categorize this — please pick from the list" UI.
Fail-open (we use for): enrichment, optional metadata, anything that's nice-to-have. The pipeline returns null; downstream code handles the absence.
The choice depends on what's worse: a confident-but-wrong categorization, or no categorization. For us, the former is much worse.
Validators That Catch Real Bugs
Beyond schema validation, we have semantic validators that have caught real production issues:
Bounded numeric outputs
class PricingDecision(BaseModel):
discount_percent: float = Field(ge=0, le=50) # never give > 50% off
Caught a model that emitted discount_percent: 95 for an edge-case prompt. Without the validator, that would have been a billing incident.
Internal-consistency
class ExtractedAddress(BaseModel):
street: str
city: str
state: str
country: str
@model_validator(mode="after")
def state_required_if_us(self):
if self.country == "US" and not self.state:
raise ValueError("US addresses must have state")
return self
Catches partial extractions. The pipeline retries with a corrective prompt.
Reference data check
KNOWN_PRODUCTS = set(load_product_catalog())
class ProductMention(BaseModel):
name: str
sku: str
@field_validator("sku")
@classmethod
def sku_must_exist(cls, v: str) -> str:
if v not in KNOWN_PRODUCTS:
raise ValueError(f"unknown SKU: {v}")
return v
Caught the model occasionally inventing plausible-looking SKUs. Validator forces the model to stick to the catalog.
Cost of Validation
Validation isn't free. Each failed-and-retried call costs roughly the same as a successful call. Our cost overhead from retries is ~6% above raw success-only call volume.
That's far cheaper than the alternative — corrupted downstream state — and 6% is a budget worth paying.
What Surprises People
1. Model errors cluster.
When validation fails, it usually fails on the same kinds of inputs. We log every validation failure with the input. Quarterly review of these logs has produced three big improvements:
- A reworded prompt example
- A field renamed because the model kept generating the legacy name
- A fallback path for inputs that legitimately don't fit the schema
2. Smaller models fail more on validation than on quality.
gpt-4o-mini produces roughly equally accurate classifications to gpt-4o for our task. But mini produces invalid JSON about 3× more often. Strict structured outputs help.
3. Streaming complicates validation.
If you stream tokens to the user UI, you can't validate before the user sees text. We render tentatively, then revalidate at end-of-stream and post a correction if needed. Honest, but ugly. We avoid streaming for any pipeline where final-output correctness matters.
The Pattern, In Order
For any new LLM pipeline:
- Define the output schema as a Pydantic model.
- Show the model that schema (or use the structured-output API).
- Provide 1–3 examples of input → output.
- Validate the response with the Pydantic model + custom semantic validators.
- On validation failure, retry with the error message in context.
- Choose fail-open vs fail-closed deliberately.
- Log every validation failure for quarterly review.
Best Practices Summary
- Schema-first prompts beat schema-after prompts. Anchoring to a schema in the prompt is the single biggest quality lever.
- Use the provider's structured output mode when available. Eliminates syntactic failures.
- Always validate semantics, not just syntax. Valid JSON with wrong content is the failure mode that matters.
- Send errors back into the model. Targeted repair beats blind retries.
- Cap retries at 2. Beyond that, you're wasting money on a query the model can't answer.
- Log validation failures. Patterns emerge over weeks; act on them.
When You Don't Need This
- One-off prompts in a notebook? Skip it.
- Pure prose responses where structure isn't applicable? Skip it.
- Internal tools with humans in the loop catching errors? You can probably skip it.
For any production system that consumes LLM output programmatically, schema-first validation is the difference between four-nines reliability and a steady stream of small downstream corruptions you'll spend years explaining.
Argo Rollouts: Canary Deployments That Caught a $40k Bug
A two-line config change to an Argo Rollouts analysis template caught a regression that would have cost ~$40k in API spend before we noticed. Here's the pattern.
eBPF for SREs: Three Real Diagnoses That Saved Hours
We started using eBPF tooling for ad-hoc production debugging six months ago. Three real incidents where it cut investigation time from hours to minutes.
More from AI
Explore more articles in this category
Meta and Sierra's Personal Agent Protocol, Explained
It is OAuth for letting your AI agent act on your behalf with a business, not another MCP, and it is nowhere near ready to build on.
Mistral Large 4 'Le Chonk': Self-Host or Use the API?
A 1T-parameter MoE model with open weights coming doesn't make self-hosting the right call for most teams.
Claude Haiku 5.5: When to Route Down From the Frontier Tier
Haiku 5.5 is cheap enough to stop rationing subagent calls, but the 75% savings claim only holds if your prompts are short.
You might have missed
Evergreen posts worth revisiting.