Module 10: Agents in Production and Alternatives
7. Pydantic AI: An Honest Comparison
Overview
You spent 9 modules learning LangChain and LangGraph. You built ReAct agents, state machines, multi-agent systems, planning, reflection, memory, MCP, and testing. It would be easy to close the guide by saying "LangChain is the best, use that." But that wouldn't be honest — and this capsule exists precisely to be honest.
Pydantic AI is a framework created by Samuel Colvin, the same creator of Pydantic — the validation library you already use in FastAPI, in your models, and probably in half your Python stack. Pydantic AI's premise is direct: if you can build agents with real type safety, dependency injection, and no unnecessary layers of abstraction, why not do it? And in many cases, it has a point.
This capsule isn't propaganda for any framework. You're going to implement the same agent — a simplified research agent — in LangChain/LangGraph and in Pydantic AI. You'll see the code side by side, measure real trade-offs, and come away with the judgment to choose. The goal isn't for you to abandon LangChain or adopt Pydantic AI. The goal is for you to know when each is the best tool for the problem.
Pydantic AI: Philosophy and Approach
The manifesto: less magic, more Python
Pydantic AI comes out of a concrete frustration: building agents with existing frameworks requires learning the framework's own abstractions before you can write business logic. LCEL, runnables, RunnablePassthrough, RunnableLambda, StateGraph, MessageGraph — they're powerful concepts, but they're LangChain concepts, not Python concepts.
Pydantic AI proposes the opposite: an agent is a Python function with types. Tools are Python functions. Dependencies get injected. Outputs are validated with Pydantic models. If you know Python and Pydantic, you already know 80% of Pydantic AI.
Type safety as design, not as a feature
In LangChain, you can define an agent that returns dict[str, Any]. It works, but you have no guarantee what's in that dict. In Pydantic AI, you define the return type with a Pydantic model — and if the LLM produces something that doesn't validate against that model, you get an explicit error, not silent garbage.
from pydantic import BaseModel
from pydantic_ai import Agent
class ResearchResult(BaseModel):
topic: str
summary: str
confidence: float
sources: list[str]
agent = Agent(
"openai:gpt-4o-mini",
result_type=ResearchResult,
system_prompt="You are a researcher. Answer with structured data.",
)
result = agent.run_sync("What is the current state of AI agents in production?")
# result.output is a ResearchResult — not a dict, not a str, not Any
print(result.output.topic) # a guaranteed str
print(result.output.confidence) # a guaranteed float
print(result.output.sources) # a guaranteed list[str]
If the LLM responds with JSON that lacks confidence or that has sources as a string instead of a list, Pydantic AI retries automatically (with the validation error as feedback). You don't need to parse manually or handle format edge cases.
Dependency injection: no more global variables
One of Pydantic AI's cleanest patterns is dependency injection. Instead of your tools reaching for the database or the API client as global variables or imported modules, the dependencies are declared and injected at runtime.
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class ResearchDeps:
db_connection: any
api_client: any
max_results: int = 10
agent = Agent(
"openai:gpt-4o-mini",
deps_type=ResearchDeps,
system_prompt="You are a researcher with database access.",
)
@agent.tool
def search_database(ctx: RunContext[ResearchDeps], query: str) -> str:
"""Searches the internal database."""
db = ctx.deps.db_connection
limit = ctx.deps.max_results
results = db.search(query, limit=limit)
return f"Found {len(results)} results for '{query}'"
result = agent.run_sync(
"Search for papers about RAG",
deps=ResearchDeps(
db_connection=my_db,
api_client=my_api,
max_results=5,
),
)
Why does it matter? Because in testing you can inject a mock db_connection and a fake api_client without patching globals. The tools are pure functions with respect to their dependencies — they receive what they need via ctx.deps, they don't import it from the module.
@agent.tool vs @agent.tool_plain
Pydantic AI distinguishes two types of tools:
@agent.tool— Receives aRunContextas its first argument. It has access to the injected dependencies. Use it when your tool needs external resources.@agent.tool_plain— A pure Python function. It receives no context. Use it for tools with no dependencies (calculators, formatting, transformations).
The distinction makes the code more readable: at a glance you know which tools have side effects and which are pure.
The Same Agent in Both Frameworks
Here's where the comparison stops being theoretical. You're going to see a simplified research agent — with tools to search, analyze, and generate a report — implemented in both frameworks. The same problem, the same functionality, a different philosophy.
The Research Agent in LangChain/LangGraph
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import SystemMessage
from langgraph.prebuilt import create_react_agent
@tool
def search_papers(query: str, max_results: int = 5) -> str:
"""Searches for academic papers about a topic."""
papers = [
{"title": f"Paper about {query} #{i}", "year": 2025, "citations": i * 10}
for i in range(1, max_results + 1)
]
return str(papers)
@tool
def analyze_trends(topic: str) -> str:
"""Analyzes industry trends for a topic."""
return (
f"Trends for '{topic}': "
f"40% YoY growth, rising enterprise adoption, "
f"main players: OpenAI, Anthropic, Google. "
f"Main challenge: reliability in production."
)
@tool
def generate_summary(content: str, style: str = "executive") -> str:
"""Generates a summary of the provided content."""
if style == "executive":
return f"EXECUTIVE SUMMARY: {content[:200]}..."
return f"DETAILED SUMMARY: {content[:500]}..."
model = init_chat_model("openai:gpt-4.1-mini")
system_message = SystemMessage(content=(
"You are a research agent. Your process: "
"1) Search for relevant papers, "
"2) Analyze trends, "
"3) Generate an executive summary. "
"Use the tools in that order."
))
agent = create_react_agent(model, [search_papers, analyze_trends, generate_summary])
result = agent.invoke({
"messages": [
system_message,
("user", "Research the current state of AI agents in production"),
]
})
final_response = result["messages"][-1].content
print(final_response)
Lines of code: ~45
Dependencies: langchain, langchain-openai, langgraph
Output: An untyped string — result["messages"][-1].content is str | list
Testing: You need to mock the model or use LangSmith
External dependencies (DB, API): Global variables or closures
The Research Agent in Pydantic AI
from dotenv import load_dotenv
load_dotenv()
from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
class ResearchReport(BaseModel):
topic: str
key_findings: list[str]
trend_analysis: str
executive_summary: str
papers_reviewed: int
confidence_score: float
@dataclass
class ResearchDeps:
search_api_key: str
max_papers: int = 5
agent = Agent(
"openai:gpt-4o-mini",
deps_type=ResearchDeps,
result_type=ResearchReport,
system_prompt=(
"You are a research agent. Your process: "
"1) Search for relevant papers, "
"2) Analyze trends, "
"3) Generate a structured report. "
"Use the tools in that order."
),
)
@agent.tool
def search_papers(ctx: RunContext[ResearchDeps], query: str) -> str:
"""Searches for academic papers about a topic."""
max_results = ctx.deps.max_papers
papers = [
{"title": f"Paper about {query} #{i}", "year": 2025, "citations": i * 10}
for i in range(1, max_results + 1)
]
return str(papers)
@agent.tool_plain
def analyze_trends(topic: str) -> str:
"""Analyzes industry trends for a topic."""
return (
f"Trends for '{topic}': "
f"40% YoY growth, rising enterprise adoption, "
f"main players: OpenAI, Anthropic, Google. "
f"Main challenge: reliability in production."
)
@agent.tool_plain
def generate_summary(content: str, style: str = "executive") -> str:
"""Generates a summary of the provided content."""
if style == "executive":
return f"EXECUTIVE SUMMARY: {content[:200]}..."
return f"DETAILED SUMMARY: {content[:500]}..."
result = agent.run_sync(
"Research the current state of AI agents in production",
deps=ResearchDeps(search_api_key="test-key", max_papers=5),
)
report: ResearchReport = result.output
print(f"Topic: {report.topic}")
print(f"Findings: {report.key_findings}")
print(f"Confidence: {report.confidence_score}")
print(f"Papers reviewed: {report.papers_reviewed}")
Lines of code: ~55
Dependencies: pydantic-ai
Output: A typed, validated ResearchReport — every field has a guaranteed type
Testing: You inject ResearchDeps with mocks, no patching globals
External dependencies: Via ResearchDeps (explicit injection)
What the code reveals
The difference isn't 10 lines more or less. The difference is what happens when things fail:
In LangChain, if the LLM responds with an unexpected format, result["messages"][-1].content gives you a string and you decide what to do. It could be JSON, it could be free text, it could be a list of ToolMessages. Your downstream code does if isinstance(...) or json.loads(...) with try/except.
In Pydantic AI, if the LLM doesn't produce a valid ResearchReport, Pydantic AI retries automatically, passing the LLM the validation error as feedback. After N retries, it fails with a typed exception. There's no "badly formatted output propagating silently."
The internal flow: (1) the LLM responds with key_findings as a string, (2) Pydantic validates and fails, (3) Pydantic AI sends it back to the LLM with the error as feedback, (4) the LLM corrects it with a list, (5) validation OK.
Head-to-Head Comparison
The complete table
| Aspect | LangChain/LangGraph | Pydantic AI |
|---|---|---|
| Lines of code (a simple agent) | ~12-15 | ~12-15 |
| Lines of code (an agent with tools + deps) | ~45 | ~55 |
| Output type safety | Low — dict[str, Any], manual parsing | High — validated BaseModel, auto retries |
| Tool type safety | Medium — @tool infers types from docstrings | High — explicit types, mypy/pyright compatible |
| Dependency injection | Not native — globals, closures, or RunnableConfig | Native — deps_type + RunContext |
| Unit testing | Mock the model + the tools, patch imports | Inject mock deps, no patching |
Testing with TestModel | Not native (LangSmith for eval) | TestModel and FunctionModel built in |
| Integration ecosystem | 100+ official (models, vector stores, tools) | Small but growing |
| Multi-agent orchestration | Mature — StateGraph, subgraphs, supervisors | Limited — agents as tools of other agents |
| State management | Strong — checkpointing, durable execution | Basic — no native checkpointing |
| Observability | LangSmith (integrated, mature) | Logfire (from the same team, younger) |
| MCP support | Official and mature | In development |
| Learning curve | Steep — LCEL, runnables, graphs, state | Gentle — standard Python + Pydantic |
| API stability | Stabilizing (v0.3+, LangGraph v0.3+) | Pre-1.0, possible breaking changes |
| Streaming | Yes — tokens, events, custom streams | Yes — native streaming |
| Async support | Yes — ainvoke, astream | Yes — agent.run() is async by default |
| Structured output | Via with_structured_output() (added on) | Core — result_type=BaseModel |
| Community & support | Very large — Stack Overflow, Discord, GitHub | Growing — GitHub, official documentation |
| Performance overhead | Medium — layers of abstraction | Low — fewer layers between your code and the LLM |
The honest read
LangChain/LangGraph wins on ecosystem, multi-agent, observability, and production maturity. If your system needs complex orchestration with checkpointing, conditional routing, and integrated tracing — LangGraph has no equivalent in Pydantic AI.
Pydantic AI wins on type safety, dependency injection, testing, and code simplicity. If your system is one agent (or a few) that needs typed outputs, clean testing, and code that reads like standard Python — Pydantic AI is more ergonomic.
Neither wins at everything. That isn't a flaw in the comparison — it's the reality of the landscape.
When to Choose Each
A decision framework
How many agents do you need to orchestrate?
│
├── 1 agent with tools
│ ├── Is type safety critical? → Pydantic AI
│ └── Do you need LangSmith/MCP? → LangChain/LangGraph
│
├── 2-3 coordinated agents
│ ├── A fixed flow (a pipeline)? → Either works
│ └── A dynamic flow (conditional routing)? → LangGraph
│
└── 4+ agents with shared state
└── LangGraph (state machines, checkpointing, subgraphs)
Scenarios where Pydantic AI shines
- APIs with structured outputs —
result_type=BaseModeleliminates manual parsing and validation - Pydantic + FastAPI teams — an almost zero learning curve, the same dependency injection philosophy
- Testing as a priority —
TestModelwith no LLM, injected deps withoutmock.patch, deterministic tests - Simple agents in production — 3-5 tools, no multi-agent, less overhead
Scenarios where LangChain/LangGraph shines
- Complex multi-agent —
StateGraph, subgraphs, conditional edges, checkpointing - Observability in production — LangSmith with tracing, evaluation datasets, dashboards
- The integration ecosystem — 100+ official (Pinecone, Chroma, Tavily, etc.)
- MCP as a protocol — a mature official integration
- The team already knows LangChain — switching costs are real; migrating for "cleaner code" rarely justifies it
Framework Selection Guide
The decision isn't only LangChain vs Pydantic AI. The landscape includes alternatives that cover specific niches. Here's a quick guide to each — not so you learn them all, but so you know when to dig deeper.
CrewAI
Philosophy: Multi-agent as a metaphor for a human team — roles, tasks, delegation.
When yes: Fast prototypes of multi-agent systems, non-technical stakeholders, fixed and clear roles. When no: Fine-grained flow control (conditional edges), production at scale with observability.
AutoGen (Microsoft)
Philosophy: Agents as participants in conversations — GroupChat, speaker selection, human-in-the-loop.
When yes: Agents that debate or collaborate in free conversation, conversational patterns for research. When no: A stable API for production (v0.4 rewrote the API), a broad integration ecosystem.
Semantic Kernel (Microsoft)
Philosophy: An enterprise multi-language SDK — C#, Python, Java with consistent APIs.
When yes: An Azure stack (Azure OpenAI, Azure AI Search), real multi-language support, enterprise with governance. When no: Pure Python without Azure, when you need a large Python community (the strong one is in C#).
OpenAI Agents SDK
Philosophy: OpenAI's framework with handoffs between agents, guardrails, and built-in tracing.
When yes: OpenAI models only, a minimalist API, the handoffs pattern. When no: Vendor neutrality, mature production (released March 2025), complex state machines.
Summary table of alternatives
| Framework | Best for | Worst for | Maturity |
|---|---|---|---|
| CrewAI | Fast multi-agent prototypes | Fine-grained control, production at scale | Medium |
| AutoGen | Research, multi-agent conversations | A stable API, enterprise production | Medium (unstable API) |
| Semantic Kernel | Enterprise Azure/C# | Pure Python, the Python community | High (v1.0) |
| OpenAI Agents SDK | Simple OpenAI-only agents | Multi-provider, complex systems | Low (2025) |
The meta-advice
If you don't have a strong reason for another framework, LangChain/LangGraph is still the safest default for ecosystem, community, and maturity. Pydantic AI is the most serious alternative if you value type safety and simplicity. The rest are legitimate niches for specific contexts — not defaults.
Connection to the Project
In the Research Agent you built throughout this guide:
-
The current architecture (LangGraph) uses a
StateGraphwith a supervisor, researchers, and a writer. This multi-agent orchestration is where LangGraph justifies its complexity — coordinating 3+ agents with shared state, conditional routing, and checkpointing. -
If it were a single agent — a researcher with no multi-agent orchestration — Pydantic AI would be cleaner. The typed output (
ResearchReport), the dependency injection (API keys, DB connections), and testing without LLM mocks would make the code more maintainable. -
The design lesson: It isn't "migrate to Pydantic AI" or "stick with LangGraph." It's: the framework's complexity must match the problem's complexity. A simple agent in LangGraph carries unnecessary overhead. A multi-agent system in Pydantic AI has real limitations.
-
For deployment: The tools you built as pure Python functions (as we recommended in Module 01's capsule 06) are portable between frameworks. If in 6 months Pydantic AI has mature multi-agent orchestration and you decide to migrate, your tools get re-decorated in a day. The business logic doesn't change.
Troubleshooting
Problem 1: "I installed pydantic-ai but I get import errors"
Symptom: ModuleNotFoundError: No module named 'pydantic_ai' or compatibility errors with Pydantic v1.
Cause: Pydantic AI requires Pydantic v2+. If your project uses Pydantic v1 (common in legacy projects), there's a conflict.
Solution: Run pip install pydantic-ai && pip show pydantic — verify that Pydantic is v2.x. If you have v1, you need to migrate first — evaluate whether it justifies the effort.
Problem 2: "The output doesn't validate and the agent goes into a retry loop"
Symptom: Pydantic AI retries the same question 3+ times and finally fails with UnexpectedModelBehavior.
Cause: Your result_type has a schema that's too complex or ambiguous for the LLM to produce correctly. Fields with very specific types (Literal["A", "B", "C"], conint(ge=0, le=100)) can confuse less capable models.
Solution:
Start with simple schemas (score: float, category: str) and add constraints gradually (Literal[...], Field(ge=0)). Use more capable models (GPT-4o, Claude Sonnet) for complex schemas.
Problem 3: "How do I do multi-agent with Pydantic AI?"
Symptom: You need one agent to delegate to another, but Pydantic AI has no StateGraph or supervisor pattern.
Cause: Pydantic AI wasn't designed for complex multi-agent orchestration.
Solution: The idiomatic way is to use one agent as a tool of another:
detail_agent = Agent("openai:gpt-4o-mini", result_type=str)
main_agent = Agent("openai:gpt-4o-mini", result_type=str)
@main_agent.tool_plain
async def get_detailed_analysis(topic: str) -> str:
"""Delegates the detailed analysis to a specialized agent."""
result = await detail_agent.run(f"Analyze in detail: {topic}")
return result.output
It works for 2-3 agents with a linear flow. For complex orchestration (conditional routing, checkpointing), LangGraph is still the better option.
Problem 4: "I don't know how to test my Pydantic AI agent without calling the LLM"
Symptom: Every test makes a real LLM call — slow, expensive, and non-deterministic.
Cause: You aren't using TestModel, Pydantic AI's built-in testing tool.
Solution:
from pydantic_ai.models.test import TestModel
# TestModel responds with data that validates against result_type
with agent.override(model=TestModel()):
result = agent.run_sync(
"Research AI agents",
deps=ResearchDeps(search_api_key="test", max_papers=3),
)
assert isinstance(result.output, ResearchReport)
# TestModel generates valid values for every field
TestModel doesn't call any LLM. It generates responses that validate against your result_type. For more controlled testing, use FunctionModel, which lets you define exactly what it responds:
from pydantic_ai.models.function import FunctionModel
def mock_response(messages, info):
return "A controlled response for testing"
with agent.override(model=FunctionModel(mock_response)):
result = agent.run_sync("test query", deps=test_deps)
Problem 5: "I want to migrate from LangChain to Pydantic AI but I have 30 tools"
Symptom: The migration looks like a weeks-long project.
Cause: The tools are coupled to the framework — they use LangChain's @tool, BaseTool, or depend on RunnableConfig.
Solution: Migrate in layers: (1) extract the logic into pure functions, (2) create @agent.tool / @agent.tool_plain wrappers, (3) migrate the orchestration, (4) migrate observability. Don't try to migrate everything at once — one tool at a time, verifying it works.
Exercises
Exercise 1: Typed vs untyped output (Easy)
You have this LangChain agent that analyzes sentiment. Rewrite it in Pydantic AI with a result_type that guarantees the output's structure.
# The current LangChain version
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [])
result = agent.invoke({
"messages": [("user", "Analyze the sentiment of: 'The product is excellent'")]
})
# result["messages"][-1].content is a str with no guaranteed structure
See solution
from pydantic import BaseModel
from pydantic_ai import Agent
class SentimentResult(BaseModel):
text: str
sentiment: str # "positive", "negative", "neutral"
confidence: float # 0.0 to 1.0
reasoning: str
agent = Agent(
"openai:gpt-4o-mini",
result_type=SentimentResult,
system_prompt="Analyze sentiment. Answer with the requested structure.",
)
result = agent.run_sync("Analyze the sentiment of: 'The product is excellent'")
print(result.output.sentiment) # "positive" — a guaranteed str
print(result.output.confidence) # 0.95 — a guaranteed float
print(result.output.reasoning) # a str with the explanation
The key advantage: If the LLM responds with "sentiment": 123 (an int instead of a str), Pydantic AI detects it and retries. With LangChain, that error propagates silently until your downstream code breaks.
Exercise 2: Dependency injection for testing (Easy)
Write a Pydantic AI agent that uses an API client as an injected dependency. Then write a test that uses a mock of the API client without patching imports.
See solution
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
@dataclass
class WeatherDeps:
api_client: any
agent = Agent(
"openai:gpt-4o-mini",
deps_type=WeatherDeps,
system_prompt="You are a weather assistant.",
)
@agent.tool
def get_weather(ctx: RunContext[WeatherDeps], city: str) -> str:
"""Gets the weather for a city."""
return ctx.deps.api_client.get_weather(city)
# --- In production ---
# result = agent.run_sync("Weather in Madrid?", deps=WeatherDeps(api_client=real_api))
# --- In testing ---
class MockWeatherAPI:
def get_weather(self, city: str) -> str:
return f"Mock: 22°C in {city}"
with agent.override(model=TestModel()):
result = agent.run_sync(
"Weather in Madrid?",
deps=WeatherDeps(api_client=MockWeatherAPI()),
)
# No LLM or real API was called
# The injected mock was used in the tool
The comparison: In LangChain, you'd need unittest.mock.patch("module.api_client") to achieve the same thing. With Pydantic AI, the mock is passed as an argument — no patches, no side effects.
Exercise 3: Compare the testing experience (Medium)
Implement the same tool (a calculator with history) in both frameworks and write a unit test for each. Compare the amount of setup required.
See solution
# === PYDANTIC AI ===
from dataclasses import dataclass, field
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
@dataclass
class CalcDeps:
history: list[str] = field(default_factory=list)
agent_pai = Agent("openai:gpt-4o-mini", deps_type=CalcDeps)
@agent_pai.tool
def calculate(ctx: RunContext[CalcDeps], expression: str) -> str:
"""Calculates an expression and saves it to the history."""
result = str(eval(expression))
ctx.deps.history.append(f"{expression} = {result}")
return result
# The Pydantic AI test — 4 lines of setup
def test_calculate_pydantic_ai():
deps = CalcDeps()
with agent_pai.override(model=TestModel()):
agent_pai.run_sync("Calculate 2 + 3", deps=deps)
assert len(deps.history) >= 1
assert "=" in deps.history[0]
# === LANGCHAIN ===
from langchain_core.tools import tool
calc_history: list[str] = [] # A global variable — it needs a reset in tests
@tool
def calculate_lc(expression: str) -> str:
"""Calculates an expression and saves it to the history."""
result = str(eval(expression))
calc_history.append(f"{expression} = {result}")
return result
# The LangChain test — you have to manage the global
def test_calculate_langchain():
global calc_history
calc_history = [] # A manual reset
output = calculate_lc.invoke("2 + 3")
assert output == "5"
assert len(calc_history) == 1
calc_history = [] # A manual cleanup
The verdict: Pydantic AI — injected deps, no globals, no manual cleanup. LangChain — it works, but the history as a global is a code smell that scales badly (race conditions in async, cleanup in tests, not thread-safe).
Exercise 4: A real decision matrix (Medium)
Your team (3 Python devs, a FastAPI + PostgreSQL stack, no Azure) needs to build a customer support agent with: 8 tools, strict JSON outputs for the frontend, CI/CD testing, and no multi-agent (a single agent). Which framework do you recommend? Justify it with this capsule's comparison table.
See solution
Recommendation: Pydantic AI.
| Requirement | LangChain/LangGraph | Pydantic AI | Verdict |
|---|---|---|---|
| 8 tools | ✅ @tool | ✅ @agent.tool | A tie |
| Strict JSON | ⚠️ with_structured_output | ✅ Native result_type=Model | Pydantic AI |
| CI/CD testing | ⚠️ Mock the model, heavy setup | ✅ TestModel, native DI | Pydantic AI |
| A single agent | ⚠️ Unnecessary LangGraph overhead | ✅ Designed for this | Pydantic AI |
| A FastAPI stack | ✅ Compatible | ✅ The same Pydantic philosophy | Pydantic AI |
| Observability | ✅ Mature LangSmith | ⚠️ Logfire is younger | LangChain |
Score: Pydantic AI 4, LangChain 1, Tie 1.
The case is strong for Pydantic AI: a Python team with FastAPI (they already know Pydantic), a single agent (no need for LangGraph), strict JSON outputs (Pydantic AI's core), and testing as a priority (native DI). LangChain's only advantage here is observability with LangSmith — but Logfire + custom logging can cover that.
The risk: Pydantic AI is pre-1.0. The mitigation: the tools are pure Python functions, migratable in 1 day if there are breaking changes.
Exercise 5: A partial migration (Hard)
You have this LangChain agent with 3 framework-coupled tools. Refactor so the business logic is framework-agnostic, then implement wrappers for both frameworks.
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
@tool
def fetch_user(user_id: int) -> str:
"""Looks up a user by ID."""
users = {1: "Ana García", 2: "Carlos López", 3: "María Rodríguez"}
return users.get(user_id, "User not found")
@tool
def check_subscription(user_id: int) -> str:
"""Checks a user's subscription."""
subs = {1: "premium", 2: "free", 3: "premium"}
plan = subs.get(user_id, "none")
return f"Plan: {plan}"
@tool
def generate_invoice(user_id: int, amount: float) -> str:
"""Generates an invoice for a user."""
return f"Invoice #{user_id * 1000}: ${amount:.2f} for user {user_id}"
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [fetch_user, check_subscription, generate_invoice])
See solution
# === LAYER 1: Business logic (framework-agnostic) ===
USERS = {1: "Ana García", 2: "Carlos López", 3: "María Rodríguez"}
SUBSCRIPTIONS = {1: "premium", 2: "free", 3: "premium"}
def fetch_user_logic(user_id: int) -> str:
return USERS.get(user_id, "User not found")
def check_subscription_logic(user_id: int) -> str:
plan = SUBSCRIPTIONS.get(user_id, "none")
return f"Plan: {plan}"
def generate_invoice_logic(user_id: int, amount: float) -> str:
return f"Invoice #{user_id * 1000}: ${amount:.2f} for user {user_id}"
# === LAYER 2A: LangChain wrappers ===
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
@tool
def fetch_user(user_id: int) -> str:
"""Looks up a user by ID."""
return fetch_user_logic(user_id)
@tool
def check_subscription(user_id: int) -> str:
"""Checks a user's subscription."""
return check_subscription_logic(user_id)
@tool
def generate_invoice(user_id: int, amount: float) -> str:
"""Generates an invoice for a user."""
return generate_invoice_logic(user_id, amount)
model = init_chat_model("openai:gpt-4.1-mini")
lc_agent = create_react_agent(model, [fetch_user, check_subscription, generate_invoice])
# === LAYER 2B: Pydantic AI wrappers ===
from pydantic_ai import Agent
pai_agent = Agent("openai:gpt-4o-mini", system_prompt="A billing assistant.")
@pai_agent.tool_plain
def fetch_user_pai(user_id: int) -> str:
"""Looks up a user by ID."""
return fetch_user_logic(user_id)
@pai_agent.tool_plain
def check_subscription_pai(user_id: int) -> str:
"""Checks a user's subscription."""
return check_subscription_logic(user_id)
@pai_agent.tool_plain
def generate_invoice_pai(user_id: int, amount: float) -> str:
"""Generates an invoice for a user."""
return generate_invoice_logic(user_id, amount)
# === LAYER 3: Framework-agnostic tests ===
assert fetch_user_logic(1) == "Ana García"
assert fetch_user_logic(99) == "User not found"
assert "premium" in check_subscription_logic(1)
assert "$100.00" in generate_invoice_logic(1, 100.0)
print("All the business logic tests pass")
The result: 3 separate layers. The tests validate the logic without instantiating any framework. If "Framework Z" comes out tomorrow, you create a layer 2C with wrappers — the logic (layer 1) and the tests (layer 3) don't change. With this pattern, migrating 30 tools takes ~90 minutes (re-decorating). Without it, ~15 hours of refactoring.
Summary
In this capsule you learned:
- Pydantic AI has a clear philosophy: type safety, dependency injection, and code that reads like standard Python — not like a framework-specific DSL
- The same agent implemented in both frameworks reveals real differences: LangChain has more setup but more ecosystem; Pydantic AI has typed outputs and cleaner testing
- Type safety in outputs (
result_type=BaseModel) eliminates an entire category of bugs where the LLM responds with an unexpected format - Dependency injection (
deps_type+RunContext) makes tools testable without patching globals - LangChain/LangGraph wins on ecosystem, multi-agent orchestration, observability (LangSmith), and production maturity
- Pydantic AI wins on type safety, testing experience, learning curve, and simplicity for simple agents
- Beyond these two: CrewAI for fast multi-agent prototypes, AutoGen for multi-agent conversations, Semantic Kernel for enterprise Azure, OpenAI Agents SDK for simplicity with OpenAI models
- The framework decision is contextual: it depends on the system's complexity, observability needs, the existing stack, and the team's size
- Design for portability: business logic as pure Python functions, thin framework wrappers — migrating is re-decorating, not rewriting
Additional Resources
- Pydantic AI Documentation — Official documentation with tutorials, API reference, and examples
- Pydantic AI vs LangChain FAQ — The official comparison from the Pydantic AI team
- Pydantic AI GitHub — Source code, issues, and community examples
- Logfire — The Pydantic team's observability platform
- LangGraph Documentation — A reference for comparing orchestration patterns
- CrewAI Documentation — A multi-agent framework with roles and teams
- OpenAI Agents SDK — OpenAI's framework with handoffs and tracing