Pydantic Turns 'Return JSON' From a Prompt Suggestion Into a Schema Check
Malformed model output that parses cleanly can sit for months in a database before anyone notices, and neither prompting nor structured-output frameworks can fully prevent it. A schema check at the boundary turns silent corruption into an immediate error, and it keeps working no matter which model provider sits upstream.
LLM responses to 'return JSON' arrive wrapped in prose, code fences, and unvalidated strings. json.loads() only proves the text is syntactically valid; it does not catch a missing skills key, a phone number stored as '138-0013-8000', or a salary string '15k左右' that Python happily multiplies by 12 and drops into a database column. Failures that throw are easy to find if late; failures that do not throw are worse.
The fix separates extraction from validation. One step strips markdown fences and grabs the outermost curly-brace object; the next runs it through a Pydantic BaseModel whose field declarations carry types, ranges, and required constraints. Custom before-validators normalize uniquely convertible input, so '138-0013-8000' becomes 13800138000 and '15k左右' becomes 15000, while ambiguous input like '五年' fails. Nested models and cross-field validators catch data that is internally inconsistent, such as 9 years of experience when the listed jobs sum to 7.
Retries feed the previous ValidationError back into the model prompt, with different messages for JSON syntax failures vs schema failures, a retry cap, and a fallback return of None. Setting temperature 0 keeps retries stable. LangChain and LlamaIndex offer structured output helpers, but those enforce structure, not business rules, so the final Pydantic check stays as a vendor-neutral boundary. A runnable mock demo requires only pip install pydantic[email].
The costliest LLM output failure is a valid-looking value that is wrong; Python string multiplication lets '15k左右' pass silently into a salary field, and validation is what makes that class of bug fail loudly at the boundary instead of months later in a report.
Retrying with error messages as context only becomes a reliable loop when the model is deterministic; temperature 0 is the setting that turns the retry strategy from roulette into a convergent process.
Pydantic's field-ordering dependency is an easy trap: cross-field validation only sees earlier fields, so a schema that reads naturally can silently skip a check until the validator runs on incomplete data.
Structured-output frameworks narrow the problem to 'valid JSON' but cannot encode a business's consistency rules; the final schema layer is what survives a model-provider swap.
The extraction-and-retry pipeline draws a blunt rejection as too clumsy. Its retry logic also contains a concrete error-handling hole: the no-JSON branch raises a bare ValueError, while only ValidationError and JSONDecodeError are caught, so a model's prose-only answer exits the loop instead of triggering another attempt. That gap is fixable by catching the parsing-level ValueError, but the outright dismissal implies the method may not be worth patching.
Doing it this way is just too dumb.
The no-JSON branch throws an ordinary ValueError, but the retry example only catches ValidationError and JSONDecodeError, so in that case it exits the loop immediately. Catching the parsing layer's ValueError as well is necessary to cover the situation where the model only replies with an explanation.