Building a Pydantic AI Agent for Production Workloads
A pydantic ai agent combines the type-safety of Pydantic’s data validation with the flexibility of large language model tool-calling, giving developers a way to build AI agents whose inputs and outputs are structurally guaranteed rather than loosely typed strings. This article walks through what a pydantic ai agent actually is, how to structure one, how to deploy it with Docker, and where it fits into a broader DevOps pipeline.
What Is a Pydantic AI Agent
PydanticAI is a Python agent framework built by the team behind Pydantic, the widely used data-validation library. The core idea behind a pydantic ai agent is straightforward: instead of parsing raw text output from a language model and hoping it matches an expected shape, you define a Pydantic model describing exactly what the agent should return, and the framework enforces that structure at runtime.
This matters because production systems rarely tolerate unpredictable output. A typical LLM call returns free-form text, and extracting reliable structured data from that text (dates, IDs, nested objects) is a recurring source of bugs. A pydantic ai agent addresses this by making structured output a first-class citizen of the request/response cycle, not an afterthought handled by regex or manual JSON parsing.
Core Components
Every pydantic ai agent is built from a small set of primitives:
Why Type Safety Matters Here
Type safety in a pydantic ai agent isn’t just a developer-experience nicety. When an agent’s output feeds into a database write, an API call, or a downstream automated workflow (as it often does in n8n-based pipelines), a malformed field can propagate silently and corrupt state further down the chain. Validating at the boundary — right when the model returns its answer — catches these problems immediately instead of hours later during a failed database insert.
Setting Up a Pydantic AI Agent Project
Getting a working pydantic ai agent running locally takes only a few steps. The framework is distributed as a standard Python package, so it integrates cleanly with existing virtual environment and dependency-management tooling.
python3 -m venv venv
source venv/bin/activate
pip install pydantic-ai
export OPENAI_API_KEY="sk-your-key-here"
A minimal agent definition looks like this:
from pydantic import BaseModel
from pydantic_ai import Agent
class CityInfo(BaseModel):
city: str
country: str
population_estimate: int
agent = Agent(
"openai:gpt-4o",
result_type=CityInfo,
system_prompt="Extract structured city information from user queries.",
)
result = agent.run_sync("Tell me about Lisbon, Portugal.")
print(result.data)
Notice that result.data is guaranteed to be an instance of CityInfo, not a raw string you need to parse. This is the central value proposition of a pydantic ai agent: the contract between the model and your application code is enforced by the type system, not by hope.
Choosing a Model Provider
PydanticAI is model-agnostic by design — you can point the same agent definition at OpenAI, Anthropic, Google, or a self-hosted model server, changing only the model string. This is useful if you’re comparing providers on cost or latency, or if you want a fallback path when a primary provider has an outage. If you’re evaluating API costs across providers, it’s worth reviewing pricing documentation directly, such as the guidance in this site’s OpenAI API pricing breakdown, before committing to a single model for a production pydantic ai agent.
Adding Tools to a Pydantic AI Agent
Tools are what turn a pydantic ai agent from a glorified text-completion wrapper into something that can actually take action — querying a database, calling an internal API, or performing a calculation the model itself isn’t reliable at.
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
@dataclass
class Deps:
api_token: str
agent = Agent("openai:gpt-4o", deps_type=Deps)
@agent.tool
async def get_weather(ctx: RunContext[Deps], city: str) -> str:
"""Fetch current weather conditions for a given city."""
# real implementation would call a weather API using ctx.deps.api_token
return f"Weather data for {city} retrieved using token {ctx.deps.api_token[:4]}..."
The RunContext object gives the tool access to the typed dependencies you defined, which keeps configuration and secrets out of global state and out of the prompt itself. This pattern scales well: as a pydantic ai agent grows to include a dozen tools, having each tool’s signature validated by Pydantic prevents a whole category of “the model called the tool with the wrong argument type” failures.
Handling Tool Errors Gracefully
Tools can and do fail — an external API times out, a database connection drops, or the model requests a parameter combination that doesn’t make sense. PydanticAI supports raising a ModelRetry exception inside a tool, which tells the agent to ask the model to retry with corrected arguments rather than crashing the whole run. This retry loop is one of the more valuable behaviors baked into the framework, since it mirrors the kind of automated self-correction you’d otherwise have to build by hand.
Validating Nested Tool Output
For tools that return complex data, define a Pydantic model for the tool’s return type as well, not just its arguments. This keeps validation symmetric on both sides of the tool call and makes it much easier to reason about what a pydantic ai agent actually has access to at any point in a multi-step run.
Deploying a Pydantic AI Agent with Docker
Once an agent works locally, the next step is packaging it for consistent deployment. A pydantic ai agent has no unusual runtime requirements beyond a standard Python environment and network access to whichever model provider you’re using, which makes it a natural fit for a container.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]
For anything beyond a single throwaway script, running the agent alongside supporting services (a database for logging results, a queue for incoming requests) makes more sense as a Compose stack:
services:
agent:
build: .
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- agent_data:/var/lib/postgresql/data
volumes:
agent_data:
If you’re new to Compose file structure, this site’s guide on PostgreSQL Docker Compose setup covers the database side of a stack like this in more depth, and the Docker Compose environment variables guide is worth reviewing before hardcoding any secrets into your YAML.
Running the Agent Behind a Queue
For workloads where requests arrive asynchronously — webhook triggers, scheduled jobs, or user-submitted forms — it’s common to put a pydantic ai agent behind a message queue rather than exposing it directly over HTTP. This decouples the agent’s (sometimes slow, LLM-bound) response time from the caller, and makes retries and backpressure much easier to manage than with a synchronous request/response API.
Persisting Agent Runs for Auditing
Because a pydantic ai agent’s output is already a validated Pydantic object, logging every run to a database is close to free — you’re writing structured data, not scraping unstructured text after the fact. This is valuable for debugging and for compliance in regulated domains, where you may need to reconstruct exactly what an agent decided and why.
Orchestrating a Pydantic AI Agent with n8n
Many teams don’t want to build a full custom service around every agent — they want to trigger a pydantic ai agent from an existing automation pipeline. n8n is a common choice here, since it can call an HTTP endpoint wrapping the agent and route the structured result into a spreadsheet, a CRM, or a Slack notification without additional custom glue code.
A typical pattern:
If you’re setting up this kind of self-hosted automation layer for the first time, this site’s n8n self-hosted installation guide and the broader guide to building AI agents with n8n both cover the orchestration side of this pattern in detail. For teams comparing orchestration tools generally, the n8n vs Make comparison is a useful reference point when deciding where a pydantic ai agent should sit in the stack.
Wrapping the Agent in a Minimal API
A thin FastAPI layer is usually enough to expose a pydantic ai agent to external callers like n8n:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Query(BaseModel):
text: str
@app.post("/run-agent")
async def run_agent(query: Query):
result = await agent.run(query.text)
return result.data
Because both the request and the agent’s own result type are Pydantic models, FastAPI’s automatic request validation and the agent’s internal validation reinforce each other end to end.
Testing and Observability for Pydantic AI Agents
Testing a pydantic ai agent differs from testing conventional deterministic code, since the underlying model’s output can vary between runs even with an identical prompt. A practical testing strategy separates concerns:
Logging Model Calls for Debugging
PydanticAI exposes hooks for inspecting the full message history of a run, including every tool call and its arguments. Persisting this history — even just to a log file initially — makes it much easier to diagnose why an agent produced an unexpected result, since you can see the exact sequence of tool calls rather than only the final answer.
FAQ
Is PydanticAI tied to OpenAI models only?
No. A pydantic ai agent can be configured against multiple model providers, including Anthropic, Google, and self-hosted or local model servers, by changing the model identifier passed to the Agent constructor. The validation and tool-calling logic stays the same regardless of provider.
Do I need Pydantic v2 to use PydanticAI?
Yes, PydanticAI is built on top of Pydantic v2’s validation engine. If your existing codebase is still on Pydantic v1, you’ll need to plan a migration before adopting the framework, since the two major versions have different internals and some breaking API changes.
How is a pydantic ai agent different from just prompting an LLM directly?
Prompting an LLM directly returns unstructured text that your application must parse manually. A pydantic ai agent enforces a schema on the output (and on tool arguments) at the framework level, so malformed responses trigger a validation error or an automatic retry instead of silently corrupting downstream data.
Can a pydantic ai agent call external tools like databases or APIs?
Yes. Tools are ordinary Python functions decorated and registered with the agent, and their arguments are validated using the same Pydantic mechanisms as the agent’s final result. This makes it straightforward to give an agent controlled, typed access to internal systems.
Conclusion
A pydantic ai agent gives you a disciplined way to build LLM-powered systems where output structure is guaranteed rather than assumed. By combining Pydantic’s validation with a tool-calling agent loop, you get automatic retries on malformed output, typed dependency injection for tools, and a result object your application code can trust immediately — no ad hoc text parsing required. Packaging that agent in a container, wiring it into an orchestration layer like n8n, and logging every run for auditability turns a promising prototype into something you can actually operate in production. For further framework details and API references, consult the official Pydantic documentation and the Python documentation for language-level features used throughout these examples.
Leave a Reply