Free lesson · GenAI Agent Engineering
Parse LLM output into Pydantic models
You can extract JSON from a markdown-fenced LLM response, validate it into a Pydantic model, and combine the right Pydantic features (strict mode, validators, error reporting) so malformed LLM output fails loudly rather than silently passing through.
Course: GenAI Agent Engineering · Chapter 4 · The Data Validator
Free to read — no subscription required.
Introduction
When you build an agent that picks a tool or updates state based on the model's reply, raw text isn't enough — you need structured data the rest of your code can branch on. LLMs happily wrap JSON in markdown fences, drop trailing commas, or stream half-finished objects, and a single unhandled parse error cascades into a crashed turn, a stuck workflow, or silent garbage flowing into downstream systems. By the end of this lesson you'll be able to extract validated structured output from messy LLM responses using layered parsing strategies that degrade gracefully instead of failing hard.
Key Terminology
- Pydantic model — a typed schema class that validates parsed JSON against expected fields and types; the validation layer between raw parsing and your business logic.
- Markdown fence extraction — pulling JSON out of
```json ... ```blocks the model emits even when told not to; the single most common cleanup step in production. - Recovery prompt — re-sending failed output plus the schema back to the LLM with a "fix this" instruction; the last line of defence before raising.
- Partial parse — completing an incomplete JSON string by tracking open braces and brackets so streaming UIs can render progressively before the full response arrives.
Concepts
Why structured parsing is layered
A single json.loads works only when the model behaves perfectly. Real responses arrive with markdown wrappers, trailing commas, unquoted keys, or mid-stream truncation. Robust parsing chains cheap-first strategies — direct parse, fence extraction, regex cleanup, LLM-assisted retry — so the common case stays fast and the edge cases still resolve (see Code Walkthrough).
Parsing versus validation
Parsing turns bytes into Python objects; validation turns objects into trusted, typed data. Keep them distinct: json.JSONDecodeError means the syntax was bad, while pydantic.ValidationError means the shape was wrong. Different errors deserve different recovery — re-prompting fixes the second far more reliably than the first.
Recovery via the model itself
When deterministic cleanup fails, the model that produced the broken JSON is usually best positioned to fix it. A recovery prompt includes the malformed text, the error message, and the schema; the model returns corrected JSON. Cap retries (1–2 is typical) so transient bugs don't turn into runaway loops.
Partial parsing for streaming output
Streaming agents want to render structured fragments before the full response lands. Tracking the stack of unclosed {, [, and " lets you synthesise a temporarily-valid suffix, parse it, and return a "complete=false" partial. The flow below shows how the layers compose.
Code Walkthrough
The snippet below combines three of the concepts above — fence extraction, deterministic cleanup, and LLM-assisted recovery — into a single layered parser keyed to a Pydantic model. The strategies are tried cheapest-first so happy-path calls cost one json.loads.
Code snippet python
1import json 2import re 3from typing import Type, TypeVar 4from pydantic import BaseModel, ValidationError 5 6T = TypeVar("T", bound=BaseModel) 7 8def _extract_from_markdown(text: str) -> str: 9 for pattern in (r"```json\s*(.*?)\s*```", r"```\s*(.*?)\s*```"): 10 m = re.search(pattern, text, re.DOTALL) 11 if m: 12 return m.group(1).strip() 13 return text.strip() 14 15def _clean(text: str) -> str: 16 text = re.sub(r",\s*([}\]])", r"\1", text) # trailing commas 17 text = re.sub(r"//.*?\n", "\n", text) # // comments 18 text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) 19 return text.strip() 20 21def _try(text: str, model: Type[T]) -> T | None: 22 try: 23 return model.model_validate(json.loads(text)) 24 except (json.JSONDecodeError, ValidationError): 25 return None 26 27async def parse_structured( 28 text: str, 29 model: Type[T], 30 llm_client, 31 max_retries: int = 1, 32) -> T: 33 """Layered parse: direct -> fence -> cleanup -> LLM recovery.""" 34 for candidate in (text, _extract_from_markdown(text), _clean(_extract_from_markdown(text))): 35 result = _try(candidate, model) 36 if result is not None: 37 return result 38 39 schema = json.dumps(model.model_json_schema(), indent=2) 40 current = text 41 for _ in range(max_retries): 42 fix_prompt = ( 43 "The following output is not valid JSON for the given schema.\n\n" 44 f"Output:\n{current}\n\nSchema:\n{schema}\n\n" 45 "Return ONLY corrected JSON, no prose, no markdown." 46 ) 47 current = (await llm_client.generate(fix_prompt)).text 48 result = _try(_clean(_extract_from_markdown(current)), model) 49 if result is not None: 50 return result 51 52 raise ValueError(f"Could not parse output into {model.__name__} after recovery")
- Lines 8-13:
_extract_from_markdownpeels JSON out of```jsonor plain```fences the model often emits despite instructions; if no fence is found it returns the original text untouched so the direct-parse path still works. - Lines 15-19:
_cleanstrips the three most common LLM JSON sins — trailing commas,//comments, and/* ... */comments — using regex substitutions; production code typically extends this with unquoted-key fixes. - Lines 21-25:
_tryis the single parse-plus-validate step shared by every strategy; it returnsNonefor either decode or validation failure so the caller can branch uniformly without exception juggling. - Lines 27-37:
parse_structuredwalks the cheapest-first ladder — raw text, fence-extracted text, cleaned-fence-extracted text — and returns on the first success. - Lines 39-52: On exhausting deterministic strategies, it sends a recovery prompt containing the bad output, the JSON schema, and a "JSON only" instruction back to the LLM; each retry feeds the model's reply through the same cleanup-and-parse pipeline before declaring defeat with a
ValueError.
You'll know it works when a call returns a typed Pydantic instance for well-formed input, recovers a markdown-wrapped response without invoking the LLM, and raises ValueError only after at least one recovery attempt on truly broken output.
Do's and Don'ts
Having walked through parsing LLM output above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do parse and validate in separate stages — distinguish
JSONDecodeErrorfromValidationErrorso recovery prompts can target the actual failure. - ✓Do cap recovery retries at 1-2 — more attempts rarely help and can mask deterministic bugs while burning tokens.
- ✓Do try the cheapest strategy first — direct
json.loadsfor well-behaved responses keeps the hot path fast.
Don'ts
- ✗Don't trust raw LLM text as structured data — always parse through a typed model before branching on field values.
- ✗Don't strip markdown fences with ad-hoc string slicing — anchored regex on
```json/```blocks survives whitespace and language hints that manual trims miss. - ✗Don't recurse on parse failures without a budget — bounded retries plus a final raise keep agents from hanging on unrecoverable output.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
More free lessons in GenAI Agent Engineering
- Ch 1Isolate your agent's Python environment
- Ch 1Secure API key management
- Ch 4Write custom Pydantic validators
- Ch 4Parse LLM output into Pydantic modelsYou are here
- Ch 4Configure agents with Pydantic Settings
- Ch 11Practical use cases — security, parameters, observability
- Ch 15Use Pydantic for tool schemas