LLM agents that emit JSON are only as reliable as the code that consumes them. To validate LLM JSON output before it hits production, you need a pipeline that checks structure, types, and business constraints the moment a response leaves the model—not after it crashes a downstream service.
Step 1: Define a strict schema before you write the prompt
The first move to validate LLM JSON output is to treat the JSON contract as an API specification, not an afterthought. If you let the model invent keys, you will spend forever writing defensive dict access. Write a JSON Schema that describes exactly what the agent must return.
For a weather alert agent, the contract might look like this:
{
"type": "object",
"properties": {
"alerts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"region": { "type": "string" },
"severity": { "type": "string", "enum": ["low", "moderate", "high", "extreme"] },
"valid_until": { "type": "integer", "minimum": 0 }
},
"required": ["region", "severity", "valid_until"],
"additionalProperties": false
}
}
},
"required": ["alerts"],
"additionalProperties": false
}
Locking additionalProperties to false forces the model to stick to the shape. You can still evolve the schema later, but every change becomes a deliberate deployment. Version the schema with an $id such as https://example.com/schemas/weather/v1.json so frontends and workers agree on the same contract.
Do not embed the schema only in the prompt. The prompt is a suggestion; the schema file is the source of truth your validation code loads at runtime.
Step 2: Force JSON mode and pass the schema hint
Most inference providers now support a JSON response format. With the OpenAI client, set response_format to json_schema and supply the schema. The model then constrains its decoding to valid JSON that matches the structure.
from openai import OpenAI
client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Return weather alerts as JSON."},
{"role": "user", "content": "Any alerts for the Pacific Northwest?"}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "weather_alerts",
"schema": { ... } # the schema from Step 1
}
}
)
When you point the same client at an OpenAI-compatible endpoint such as n4n.ai, which fronts 240+ models with automatic fallback, you keep the identical code and avoid 429-driven validation gaps when a primary provider degrades. The gateway honors your routing directives and forwards cache-control hints, so repeated schema-constrained calls can hit cached completions.
JSON mode is not a validator. It reduces syntax errors; it does not guarantee your enum values or integer ranges. Some providers only support json_object mode (valid JSON, no schema). In that case, shift the structural enforcement entirely to Steps 3–5.
Step 3: Validate the raw string before parsing
Never call json.loads directly on a model response in production. Strip markdown fences, check for truncation, and confirm the body is non-empty. A partial stream that died mid-token will parse as a string but fail later.
import json
import re
def safe_extract(text: str) -> str:
if not text or not text.strip():
raise ValueError("Empty model response")
# remove ```json ... ``` wrappers if present
fenced = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if fenced:
text = fenced.group(1)
try:
json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON syntax: {e}") from e
return text
If you consume streaming responses, accumulate the full text before validation. A truncated array [{"region": "OR" will pass a regex check but blow up Pydantic. Run safe_extract inside a try/except at the edge of your ingestion. If it raises, the response never reaches the parser.
Step 4: Bind to a typed model with Pydantic
Python dicts are not contracts. Use Pydantic v2 to turn the validated string into an object with types and required fields. This catches type mismatches (e.g., "severity": 3) that JSON Schema alone might miss if your schema is loose.
from pydantic import BaseModel, ConfigDict, ValidationError
from typing import Literal
class Alert(BaseModel):
model_config = ConfigDict(extra="forbid")
region: str
severity: Literal["low", "moderate", "high", "extreme"]
valid_until: int
class WeatherResponse(BaseModel):
alerts: list[Alert]
raw = safe_extract(resp.choices[0].message.content)
data = WeatherResponse.model_validate_json(raw)
extra="forbid" mirrors additionalProperties: false. model_validate_json runs the JSON parse and validation in one step and raises ValidationError with precise paths like alerts->0->severity. That path is exactly what you feed back to the model in Step 6.
Step 5: Enforce business constraints schemas can’t express
JSON Schema handles types and ranges, but not cross-field logic. Pydantic validators let you reject semantically impossible data: an alert valid_until in the past, a severity of “extreme” with empty region, or a list longer than your UI supports.
import time
class Alert(BaseModel):
model_config = ConfigDict(extra="forbid")
region: str
severity: Literal["low", "moderate", "high", "extreme"]
valid_until: int
@field_validator("valid_until")
@classmethod
def not_expired(cls, v: int) -> int:
now = int(time.time())
if v < now:
raise ValueError("alert already expired")
if v > now + 86400 * 7:
raise ValueError("alert valid window too long")
return v
@field_validator("region")
@classmethod
def region_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("region must not be blank")
return v.strip()
@field_validator("severity")
@classmethod
def extreme_requires_region(cls, v: str) -> str:
# example cross-field rule handled per-field with class access
return v
These checks are where you encode domain knowledge. They are the difference between “valid JSON” and “useful JSON”. If a rule is purely structural, keep it in the schema; if it depends on current time or external state, put it in the validator.
Step 6: Feed validation errors back to the model
A single failure should not drop the request. Wrap the call in a bounded retry loop that appends the ValidationError to the conversation so the model can self-correct. Cap at two retries; beyond that, fail loudly.
MAX_RETRIES = 2
def get_valid_alerts(user_msg: str) -> WeatherResponse:
messages = [
{"role": "system", "content": "Return weather alerts as JSON."},
{"role": "user", "content": user_msg}
]
last_raw = ""
for attempt in range(MAX_RETRIES + 1):
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
response_format={"type": "json_schema", "json_schema": {...}}
)
last_raw = resp.choices[0].message.content
raw = safe_extract(last_raw)
return WeatherResponse.model_validate_json(raw)
except (ValueError, ValidationError) as e:
if attempt == MAX_RETRIES:
raise
messages.append({"role": "assistant", "content": last_raw})
messages.append({"role": "user", "content": f"Fix validation error: {e}. Return conforming JSON."})
raise RuntimeError("unreachable")
This loop turns transient schema drift into recoverable events. It also surfaces prompt weaknesses: if the model repeatedly fails, your schema or instructions are ambiguous. Do not retry on business-rule violations that are unlikely to change with the same context—log and escalate instead.
Step 7: Instrument validation failures in production
Validation is a telemetry source, not just a gate. Count parse failures, schema violations, and business-rule rejections separately. If you use a gateway that provides per-token usage metering, correlate validation drops with token spend to spot loops that burn budget.
Log the offending raw string (truncated) with an error type. Do not log full PII-laden content in plaintext. A minimal metric emission:
from prometheus_client import Counter
PARSE_FAIL = Counter("llm_json_parse_fail", "Raw JSON failed to parse")
SCHEMA_FAIL = Counter("llm_json_schema_fail", "Pydantic validation failed")
BIZ_FAIL = Counter("llm_json_business_fail", "Business rule rejected")
# in except blocks:
PARSE_FAIL.inc()
Alert when SCHEMA_FAIL exceeds 5% of requests for a given model. That threshold means the prompt or model version regressed. Route persistent failures to a dead-letter queue where an engineer can inspect the raw model output without blocking the live path.
How to verify success
Before shipping, prove the pipeline rejects bad data and accepts good data. Write a pytest suite:
def test_rejects_malformed():
bad = '{"alerts": [{"severity": "critical"}]}' # missing region, bad enum
with pytest.raises(ValidationError):
WeatherResponse.model_validate_json(bad)
def test_accepts_good():
good = '{"alerts": [{"region": "OR", "severity": "high", "valid_until": 1735689600}]}'
assert WeatherResponse.model_validate_json(good).alerts[0].region == "OR"
Run it in CI on every schema change. In staging, send a canned malformed response through the full get_valid_alerts path and confirm it either retries or raises without poisoning downstream consumers.
If both tests pass and your metrics show zero llm_json_parse_fail on golden inputs, your pipeline to validate LLM JSON output is production-ready. The moment a model returns something off-contract, it gets caught at the boundary, not in your billing system or customer-facing API.