Building AI agents with raw LLM SDKs works fine for prototypes until you need structured outputs, testable code, and production reliability.
The gap shows up predictably. Your notebook code works, so you move it toward production and start patching: a try/except around json.loads, a helper to strip markdown fences, a few if statements to check field types, a retry loop, a dispatch function mapping tool names to callables. None of these are hard in isolation. Together, they become the majority of your codebase, and the actual agent logic disappears under the glue.
This article walks through six of those problems in the order you'd likely encounter them, showing how Pydantic AI—now a mature framework in 2026—solves each one with practical code examples:
- Unstructured outputs require brittle parsing. Your output schema lives in an English prompt string, disconnected from the dict your code expects. This often leads to malformed JSON, missing fields, and runtime crashes.
- Tool definitions are boilerplate-heavy. Approximately 70 lines of hand-written JSON schema and dispatch code are needed for three tools, with nothing keeping the schema in sync with your function signatures. This is error-prone and hard to maintain.
- No clean way to pass runtime context. Once the framework calls your tools, you can't hand them a database connection or a user ID without reaching for globals or closures. This complicates state management and hurts testability.
- Testing requires real LLM calls. Every test costs money, takes seconds, needs network access, and can be flaky. This slows down development and makes CI pipelines unreliable.
- Retry and validation logic is hand-rolled. You rewrite the same validate/re-prompt/retry pattern in every agent you build, leading to duplicated and inconsistent code.
- Switching models means rewriting integration code. Each provider has a different SDK shape, tool format, and response structure, so changing models becomes a significant refactor.
We'll use one running example throughout: a receipt analysis agent. It takes raw receipt text (e.g., from a photo-to-text scan), calls tools to look up merchant categories and exchange rates, and returns a typed summary—merchant, spending category, itemized breakdown, and a confidence score—that a budgeting dashboard or expense tool can consume directly.
1. Structured Outputs Without Brittle Parsing
When you use a raw LLM SDK, you often write a prompt like: "Return a JSON object with fields merchant, category, items, total, and confidence." The model might respond with markdown fences, extra text, or slightly different field names. Your parsing code then needs to strip fences, find the JSON block, and validate types—fragile and tedious.
Pydantic AI solves this by letting you define a Pydantic model for the output. The framework handles the prompt construction, parsing, and validation. If the model's output doesn't match the schema, Pydantic AI automatically retries with corrective feedback.
from pydantic import BaseModel, Field
class ReceiptSummary(BaseModel):
merchant: str
category: str
items: list[str]
total: float
confidence: float = Field(ge=0, le=1)
With this, you define your agent and run it, getting a ReceiptSummary instance directly—no manual parsing.
2. Declarative Tool Definitions
Hand-writing JSON schemas for tools is tedious and prone to drift from your actual functions. Pydantic AI lets you define tools as plain Python functions with type hints and docstrings. The framework generates the schema automatically, keeping everything in sync.
from pydantic_ai import Agent, RunContext
def lookup_merchant_category(merchant: str) -> str:
"""Look up the spending category for a merchant."""
# ...
def get_exchange_rate(base: str, target: str) -> float:
"""Get the current exchange rate between two currencies."""
# ...
agent = Agent('openai:gpt-4o', tools=[lookup_merchant_category, get_exchange_rate])
No separate JSON schema files. The framework introspects your functions and builds the tool specs for the LLM.
3. Passing Runtime Context Cleanly
In production, your tools often need access to a database session, a user ID, or an API client. Pydantic AI supports dependency injection via RunContext. You define a dependency type, and the framework passes it to your tools automatically.
from dataclasses import dataclass
from pydantic_ai import RunContext
@dataclass
class Deps:
db: DatabaseConnection
user_id: str
def lookup_merchant_category(ctx: RunContext[Deps], merchant: str) -> str:
# Access context: ctx.deps.db, ctx.deps.user_id
...
This eliminates globals and closures, making your code more modular and easier to test.
4. Testing Without Real LLM Calls
Pydantic AI provides a TestModel that lets you simulate LLM responses. You can test your agent's logic, including tool calls and output validation, without hitting the network or spending tokens.
from pydantic_ai import Agent
from pydantic_ai.testing import TestModel
agent = Agent('openai:gpt-4o', output_type=ReceiptSummary)
with agent.override(model=TestModel()):
result = agent.run('Receipt from Starbucks: $5.20')
assert isinstance(result.output, ReceiptSummary)
This makes tests fast, deterministic, and free. You can also simulate specific tool outputs to test error handling.
5. Built-in Retry and Validation
Instead of hand-rolling retry loops, Pydantic AI handles validation and retries out of the box. If the LLM produces invalid output, the framework automatically prompts the model to correct itself, up to a configurable limit. This reduces boilerplate and improves reliability in production.
@agent.output_validator
def validate_receipt(result: ReceiptSummary) -> bool:
return result.total > 0
You can also customize retry settings globally or per agent.
6. Model Agnosticism
Switching models in Pydantic AI is as simple as changing the model identifier—e.g., from 'openai:gpt-4o' to 'anthropic:claude-3-5-sonnet' or 'google-gla:gemini-1.5-pro'. The framework abstracts away provider-specific SDKs, tool formats, and response structures. This flexibility is crucial in 2026, where teams often compare models or run multi-provider strategies.
agent = Agent('anthropic:claude-3-5-sonnet', tools=[...])
You can also use a model registry in your config to switch without code changes.
Putting It All Together: The Receipt Agent
Here's a complete example combining everything:
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
class ReceiptSummary(BaseModel):
merchant: str
category: str
items: list[str]
total: float
confidence: float = Field(ge=0, le=1)
@dataclass
class Deps:
db: DatabaseConnection
user_id: str
def lookup_merchant_category(ctx: RunContext[Deps], merchant: str) -> str:
"""Get the spending category for a merchant."""
return ctx.deps.db.get_category(merchant)
def get_exchange_rate(ctx: RunContext[Deps], base: str, target: str) -> float:
"""Get exchange rate between two currencies."""
return ctx.deps.db.get_rate(base, target)
agent = Agent(
'openai:gpt-4o',
deps_type=Deps,
output_type=ReceiptSummary,
tools=[lookup_merchant_category, get_exchange_rate]
)
# In production
result = agent.run(
'Receipt from Starbucks: $5.20 for a latte and a muffin',
deps=Deps(db=db_conn, user_id='user_123')
)
# result.output is already a ReceiptSummary
This agent is concise, testable, and production-ready—no brittle parsing, no repetitive boilerplate, and easy model switching.
Conclusion
As AI agents move from notebooks to production, frameworks like Pydantic AI provide the necessary guardrails. By handling structured outputs, tool definitions, runtime context, testing, retries, and model abstraction, Pydantic AI lets you focus on the logic that differentiates your product. In 2026, it's a solid choice for building reliable, maintainable agents.
Have you used Pydantic AI in production? Share your experiences in the comments below.
via FreeCodeCamp
