PydanticAI 实战:给 AI Agent 套上类型系统,少踩 80% 的坑
用 OpenAI SDK 写过 Agent 的人都知道一个感受:tool calling 的参数校验得自己写,返回值格式得自己 parse,多轮对话的 message 列表管理全靠手动拼。写到第三个 Agent 的时候,你会发现一半代码在处理"胶水逻辑",而不是业务本身。
PydanticAI 就是来解决这个问题的。它是 Pydantic 团队官方出品的 Agent 框架,核心思路很直接------把 Python 的类型系统用到 Agent 开发的每个环节:参数校验、返回值约束、依赖注入、流式输出,全部走类型标注。
这篇文章不是框架对比,不做横评。我从一个实际项目中抽取了几个典型场景,用代码说明 PydanticAI 到底解决了哪些问题、怎么用、以及我踩过哪些坑。
先看一个最小 Agent
装好依赖:
bash
pip install pydantic-ai
写一个最基本的 Agent:
python
from pydantic_ai import Agent
agent = Agent('openai:gpt-4o', system_prompt='你是一个翻译助手,把用户输入翻译成英文。')
result = agent.run_sync('今天天气不错')
print(result.output)
# Today's weather is nice.
到这一步,看起来跟直接调 OpenAI SDK 区别不大。真正的价值在后面。
结构化输出:让返回值有明确类型
用 raw SDK 做结构化输出,一般是这样的:在 prompt 里写"请返回 JSON 格式",然后 json.loads() 解析,再自己校验字段。字段缺了?类型不对?运行时才炸。
PydanticAI 的做法是直接用 Pydantic Model 当输出类型:
python
from pydantic import BaseModel
from pydantic_ai import Agent
class MovieReview(BaseModel):
title: str
year: int
rating: float # 1-10
summary: str
tags: list[str]
agent = Agent(
'openai:gpt-4o',
output_type=MovieReview,
system_prompt='从用户的影评文本中提取结构化信息。',
)
result = agent.run_sync('''
昨晚看了《奥本海默》,诺兰拍得真狠。三个小时没觉得长,
cillian murphy演得太好了,那种内心煎熬全在眼神里。
音效炸裂,IMAX必看。我给9分。
''')
review = result.output
print(f'{review.title} ({review.year}) - {review.rating}/10')
print(f'标签: {", ".join(review.tags)}')
# 奥本海默 (2023) - 9.0/10
# 标签: 诺兰, 传记, IMAX, 历史
output_type=MovieReview 这一行干了三件事:
- 让 LLM 知道该返回什么结构(PydanticAI 自动把 schema 塞进请求)
- 自动 parse 返回值
- 用 Pydantic 做校验------
year不是整数?直接报ValidationError,不用等到下游业务逻辑出 bug 才发现
IDE 里 result.output 的类型推断也是 MovieReview,自动补全能直接点出 .title、.year 这些字段。
依赖注入:让 Tool 能拿到外部资源
PydanticAI 里我觉得最值得说的功能是依赖注入(Dependency Injection)。
场景:你写了个 Agent,它需要调工具查数据库。数据库连接从哪来?硬编码在 tool 函数里?那测试的时候换个 mock 连接怎么办?
PydanticAI 的方案是通过 deps_type 声明依赖类型,运行时注入:
python
from dataclasses import dataclass
import httpx
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
api_client: httpx.AsyncClient
api_key: str
agent = Agent(
'openai:gpt-4o',
deps_type=Deps,
system_prompt='你是一个天气查询助手。',
)
@agent.tool
async def get_weather(ctx: RunContext[Deps], city: str) -> str:
"""查询指定城市的天气"""
client = ctx.deps.api_client
resp = await client.get(
'https://api.weatherapi.com/v1/current.json',
params={'key': ctx.deps.api_key, 'q': city}
)
data = resp.json()
current = data['current']
return f"{city}: {current['temp_c']}°C, {current['condition']['text']}"
# 运行
async def main():
async with httpx.AsyncClient() as client:
deps = Deps(api_client=client, api_key='your-key-here')
result = await agent.run('北京今天多少度?', deps=deps)
print(result.output)
几个要点:
RunContext[Deps]的类型参数Deps必须跟 Agent 的deps_type一致,不匹配的话 mypy/pyright 会报错------在写代码阶段就能发现,不是运行时- 测试时只要传一个 mock 的
Deps就行,不用 monkey patch 任何东西 - 一个 Agent 的所有 tool 共享同一个 deps 实例,天然避免了资源重复创建
跟 FastAPI 的 Depends() 是一脉相承的思路。如果你用过 FastAPI,上手零成本。
Tool 注册:声明式定义,自动校验参数
PydanticAI 的 tool 定义方式很干净。用装饰器注册,参数自动从函数签名提取 schema:
python
from pydantic_ai import Agent, RunContext
agent = Agent('openai:gpt-4o', deps_type=str)
@agent.tool
async def search_docs(ctx: RunContext[str], query: str, max_results: int = 5) -> str:
"""在文档库中搜索相关内容
Args:
query: 搜索关键词
max_results: 最大返回结果数,默认5
"""
# ctx.deps 是项目名称
project = ctx.deps
# 实际搜索逻辑
return f"在 {project} 中搜索 '{query}',找到 {max_results} 条结果..."
函数签名里的类型标注直接变成 tool 的参数 schema。query: str 就是必填字符串参数,max_results: int = 5 就是可选整型参数。函数的 docstring 变成 tool 的描述。
不用再手写一大坨 JSON schema 了。
多种输出类型:Union type 的妙用
一个实际场景:用户问问题,Agent 可能给出文本回答,也可能判断需要升级到人工客服。两种情况的返回结构完全不同。
python
from pydantic import BaseModel
from pydantic_ai import Agent
class TextAnswer(BaseModel):
answer: str
confidence: float
class Escalation(BaseModel):
reason: str
department: str
priority: int # 1-5
agent = Agent(
'openai:gpt-4o',
output_type=TextAnswer | Escalation, # Union type
system_prompt='''你是客服助手。能回答的直接回答,
遇到投诉、退款、法律问题等敏感话题,升级到人工。''',
)
result = agent.run_sync('我买的手机屏幕碎了,想退货')
output = result.output
if isinstance(output, Escalation):
print(f'转人工: {output.department} (优先级{output.priority})')
print(f'原因: {output.reason}')
elif isinstance(output, TextAnswer):
print(f'回答: {output.answer} (置信度{output.confidence})')
output_type=TextAnswer | Escalation 告诉 LLM:你返回的数据必须是这两种之一。PydanticAI 会自动尝试校验,匹配上哪个就返回哪个。下游代码用 isinstance 做分支,类型完全安全。
流式输出:边生成边校验
长文本生成场景下,等 LLM 全部生成完再返回太慢了。PydanticAI 支持流式输出:
python
from pydantic_ai import Agent
agent = Agent('openai:gpt-4o')
async def stream_demo():
async with agent.run_stream('写一首关于 Python 的打油诗') as response:
async for chunk in response.stream_text(delta=True):
print(chunk, end='', flush=True)
# 流结束后,还能拿到完整的结构化结果
print(f'\n\n用量: {response.usage()}')
结构化输出也能流式:
python
from pydantic import BaseModel
from pydantic_ai import Agent
class StoryOutline(BaseModel):
title: str
chapters: list[str]
estimated_words: int
agent = Agent('openai:gpt-4o', output_type=StoryOutline)
async def stream_structured():
async with agent.run_stream('帮我规划一本关于 Python 设计模式的技术书') as response:
async for partial in response.stream_output(debounce_by=0.1):
# partial 是部分解析的 StoryOutline
# 字段逐步填充,可以做进度展示
print(f'已解析: title={partial.title}')
stream_output() 会在数据到达时尝试部分解析。标题先出来了就能先展示,不用等整个结构生成完。debounce_by=0.1 控制更新频率,避免过于频繁触发 UI 重渲染。
对话历史管理
多轮对话的 message 管理,用 raw SDK 得自己维护一个 list,每轮追加 user/assistant/tool message。PydanticAI 把这个流程做到了 API 层面:
python
from pydantic_ai import Agent
agent = Agent('openai:gpt-4o', system_prompt='你是编程助手。')
# 第一轮
result1 = agent.run_sync('Python 的 GIL 是什么?')
print(result1.output)
# 第二轮,传入上一轮的 message_history
result2 = agent.run_sync(
'那它对多线程有什么影响?',
message_history=result1.all_messages(),
)
print(result2.output)
# 第三轮
result3 = agent.run_sync(
'有什么绕过方案?',
message_history=result2.all_messages(),
)
print(result3.output)
result.all_messages() 返回整个对话历史,包括 system prompt、tool calls、tool results。传给下一轮就能保持上下文。
也可以做持久化------all_messages() 返回的是可序列化的 dataclass 列表,直接 json dump 存数据库:
python
import json
from pydantic_ai.messages import ModelMessagesTypeAdapter
# 序列化
messages = result.all_messages()
json_bytes = ModelMessagesTypeAdapter.dump_json(messages)
# 反序列化
loaded = ModelMessagesTypeAdapter.validate_json(json_bytes)
result_next = agent.run_sync('继续', message_history=loaded)
多模型切换
同一个 Agent 逻辑,换个模型试试效果?创建 Agent 时不指定模型,运行时传入:
python
from pydantic_ai import Agent
agent = Agent(system_prompt='你是代码审查专家。')
# 用 GPT-4o 跑一遍
result_gpt = agent.run_sync(
'审查这段代码: def add(a, b): return a + b',
model='openai:gpt-4o'
)
# 换 Claude 跑一遍
result_claude = agent.run_sync(
'审查这段代码: def add(a, b): return a + b',
model='anthropic:claude-sonnet-4-20250514'
)
# 用 DeepSeek 跑一遍
result_ds = agent.run_sync(
'审查这段代码: def add(a, b): return a + b',
model='deepseek:deepseek-chat'
)
Agent 定义、tool 定义、输出类型全不用改。模型只是一个运行参数。做 A/B 测试、模型效果对比的时候特别方便。
踩坑记录
用了一个月,踩过的几个坑记下来。
坑 1:output_type 用复杂嵌套模型时,小模型经常校验失败
我用 gpt-4o-mini 搭配一个三层嵌套的 Pydantic Model 做输出,失败率大概 30%。原因是小模型生成的 JSON 结构容易出错------漏字段、类型不对、数组里混了 null。
解法:对小模型用扁平的输出结构,嵌套不超过两层。或者用 output_type 的 strict=True 模式(需要模型支持 structured outputs)。
坑 2:tool 函数的 docstring 格式影响调用准确率
Google 风格的 docstring(带 Args: 段落)对 tool 参数描述的效果比较好。我一开始偷懒没写 docstring,LLM 对参数的理解全靠参数名猜测,结果经常传错值。
加上详细的 docstring 后,调用准确率从大概 70% 提升到 95% 以上。
坑 3:deps 对象不要放不可序列化的东西
deps 对象会被 PydanticAI 内部传递,如果里面放了数据库连接池、文件句柄这类不可序列化的资源,在某些场景(比如 Logfire 追踪)下会出问题。
好的做法是 deps 里放"配置"(URL、key、config),需要的时候在 tool 内部创建连接。或者用 @dataclass 包装,只存轻量级的引用。
坑 4:流式输出 + 结构化输出的兼容性
不是所有模型都支持 structured output streaming。我用 deepseek-chat 试过 stream_output(),拿到的 partial 对象很多字段是 None,直到整个响应结束才能拿到完整数据。gpt-4o 和 claude-sonnet 的流式结构化表现好得多。
用之前先确认你的模型是否支持这个特性。
和 raw SDK 对比
用一个具体例子说明差异。需求:让 LLM 查询数据库,返回结构化分析结果。
raw OpenAI SDK 写法(大约 60 行):
python
# 手动定义 tool schema
tools = [{
"type": "function",
"function": {
"name": "query_db",
"description": "查询数据库",
"parameters": {
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SQL语句"}
},
"required": ["sql"]
}
}
}]
# 手动处理 tool calling 循环
messages = [{"role": "system", "content": "你是数据分析助手"}]
messages.append({"role": "user", "content": "查一下上月销售额"})
while True:
response = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools
)
msg = response.choices[0].message
if msg.tool_calls:
for call in msg.tool_calls:
# 手动解析参数
args = json.loads(call.function.arguments)
result = execute_sql(args['sql'])
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result)
})
else:
# 手动 parse 返回的 JSON
try:
analysis = json.loads(msg.content)
except json.JSONDecodeError:
analysis = {"error": "解析失败"}
break
PydanticAI 写法(大约 25 行):
python
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
class SalesAnalysis(BaseModel):
total_revenue: float
top_product: str
growth_rate: float
agent = Agent('openai:gpt-4o', deps_type=str, output_type=SalesAnalysis,
system_prompt='你是数据分析助手。')
@agent.tool
async def query_db(ctx: RunContext[str], sql: str) -> str:
"""执行SQL查询数据库
Args:
sql: 要执行的SQL语句
"""
return execute_sql(sql)
result = agent.run_sync('查一下上月销售额', deps='sales_db')
analysis = result.output # 类型是 SalesAnalysis,IDE 自动补全可用
print(f'总营收: {analysis.total_revenue}')
代码量少了一半多,tool calling 循环、参数解析、结果校验全由框架处理。改字段?改 SalesAnalysis 的定义就行,上下游自动同步。
适用场景和不适用场景
适合用 PydanticAI 的场景:
- 需要结构化输出的数据提取/分析任务
- 需要 tool calling 的 Agent
- 需要类型安全和 IDE 支持的生产项目
- 需要依赖注入做测试的场景
- 多模型切换和效果对比
不太适合的场景:
- 纯聊天机器人(没结构化输出需求,用 raw SDK 更轻量)
- 已经深度集成 LangChain 的项目(迁移成本高)
- 需要复杂的多 Agent 编排(LangGraph 在图编排上更成熟)
小结
PydanticAI 做的事情不复杂------把 Python 类型系统和 Pydantic 验证引入 Agent 开发。但这个看起来简单的决定,在实际项目中省了大量胶水代码和运行时 bug。
跟 FastAPI 改变 Web 开发体验的思路一样:不是发明新概念,而是用好 Python 已有的东西,让开发体验回到"写代码"而不是"拼 JSON"。
安装:pip install pydantic-ai,文档:ai.pydantic.dev。建议从一个小的数据提取任务开始试,体验一下类型驱动的 Agent 开发是什么感觉。