用 PydanticAI 写一个类型安全的 AI Agent——依赖注入、工具注册到流式输出全过程
上个月我在项目里接了个需求:做一个客服 Agent,能查订单、查物流、判断是不是要转人工。用 LangChain 写了一版,跑是能跑,但改起来很痛苦。工具函数的参数类型全靠 docstring 描述,返回值没有校验,运行时报错的位置和实际出问题的位置差着十万八千里。IDE 的自动补全基本是摆设。
后来同事推荐了 PydanticAI,试了两天,把整个 Agent 迁移过来了。最直观的感受:写 AI Agent 终于有了写 FastAPI 的感觉——类型约束、依赖注入、结构化输出,这些在 Web 开发里早就标配的东西,在 Agent 开发里也该有了。
这篇文章把我这次迁移的过程和踩过的坑写出来,代码都能跑。
PydanticAI 是什么
PydanticAI 是 Pydantic 团队(就是写 Pydantic 那帮人)做的 AI Agent 框架。Pydantic 本身是 OpenAI SDK、Anthropic SDK、LangChain、LlamaIndex 这些库的底层验证层,所以 PydanticAI 等于是"上游亲自下场"。
安装很简单:
pip install pydantic-ai
装完大概 20MB 出头。如果要用 OpenAI 以外的模型,装对应的 extra 就行:
pip install "pydantic-ai[anthropic]" # Anthropic
pip install "pydantic-ai[google]" # Gemini
最简单的 Agent:5 行代码
先看最小可运行的例子:
from pydantic_ai import Agent
agent = Agent(
'openai:gpt-4o',
instructions='你是一个Python专家,回答尽量简洁。',
)
result = agent.run_sync('Python 3.12 的 type 语句是干嘛的?')
print(result.output)
run_sync 是同步调用,内部其实是 asyncio.run。如果你的项目本来就是 async 的,直接用 await agent.run() 就行。
到这一步跟其他框架没什么区别。PydanticAI 真正好用的地方在后面。
结构化输出:output_type
大多数场景下,你不是要 Agent 返回一段自由文本,而是要一个结构化的结果。PydanticAI 直接用 Pydantic 的 BaseModel 做输出类型约束:
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class SentimentResult(BaseModel):
sentiment: str = Field(description='positive / negative / neutral')
confidence: float = Field(description='置信度,0到1之间', ge=0, le=1)
keywords: list[str] = Field(description='关键词列表,最多5个')
agent = Agent(
'openai:gpt-4o',
output_type=SentimentResult,
instructions='分析用户输入的情感倾向。',
)
result = agent.run_sync('这个产品太垃圾了,客服态度还差')
print(result.output)
# sentiment='negative' confidence=0.95 keywords=['垃圾', '客服', '态度差']
print(type(result.output))
# <class 'SentimentResult'>
result.output 直接就是 SentimentResult 的实例,不需要你自己 json.loads 再 parse。字段校验也是 Pydantic 管的——confidence 如果不在 0-1 之间,框架会自动让模型重试。
LangChain 也有类似的功能(with_structured_output),但 PydanticAI 的类型推导在 IDE 里是完全可用的。你写 result.output. 的时候,VS Code 能提示出 sentiment、confidence、keywords 这些属性。
依赖注入:deps_type 和 RunContext
依赖注入是 PydanticAI 跟其他框架拉开差距最大的地方。
在 LangChain 里,如果你的工具函数需要数据库连接、API Key 之类的外部依赖,通常的做法是用全局变量或者闭包。代码一多就很难测试,mock 起来也麻烦。
PydanticAI 用 deps_type 声明依赖类型,运行时通过 RunContext 注入。写法跟 FastAPI 的 Depends 很像:
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class OrderDeps:
db_url: str
api_key: str
user_id: int
agent = Agent(
'openai:gpt-4o',
deps_type=OrderDeps,
instructions='你是一个订单查询助手。',
)
@agent.instructions
async def dynamic_instructions(ctx: RunContext[OrderDeps]) -> str:
return f'当前用户ID是 {ctx.deps.user_id},请根据用户身份回答问题。'
注意 deps_type=OrderDeps 传的是类型,不是实例。实例在 run 的时候才传进去:
deps = OrderDeps(db_url='postgresql://...', api_key='sk-xxx', user_id=42)
result = await agent.run('我最近的订单到哪了?', deps=deps)
RunContext[OrderDeps] 这个泛型参数不是装饰用的——如果你把 ctx.deps.user_id 写成 ctx.deps.userid(少了下划线),mypy 和 pyright 都会报错。类型检查能在你写代码的时候就拦住一批拼写错误,而不是等到运行时才崩。
工具注册:@agent.tool
工具函数是 Agent 跟外部世界交互的接口。PydanticAI 有两种注册方式:
import httpx
from pydantic_ai import Agent, RunContext
@dataclass
class ServiceDeps:
http_client: httpx.AsyncClient
logistics_api: str
agent = Agent('openai:gpt-4o', deps_type=ServiceDeps)
# 方式1:需要上下文的工具
@agent.tool
async def query_logistics(ctx: RunContext[ServiceDeps], tracking_no: str) -> str:
"""根据运单号查询物流状态"""
resp = await ctx.deps.http_client.get(
f'{ctx.deps.logistics_api}/track/{tracking_no}'
)
resp.raise_for_status()
data = resp.json()
return f"运单 {tracking_no}:{data['status']},当前位置 {data['location']}"
# 方式2:不需要上下文的工具
@agent.tool_plain
def calculate_shipping_fee(weight_kg: float, distance_km: float) -> float:
"""计算运费(元),按重量和距离"""
base = 8.0
return base + weight_kg * 1.2 + distance_km * 0.05
@agent.tool 和 @agent.tool_plain 的区别就是第一个参数要不要 RunContext。工具的参数类型和 docstring 会被自动转成 JSON Schema 发给模型,所以类型标注和文档字符串都不能省。
一个容易忽略的细节:工具函数的参数名也很重要。模型会根据参数名来理解应该传什么值。tracking_no 比 s 好理解得多。
实战:订单查询 Agent
把前面的概念串起来,写一个完整的订单查询 Agent。这是我从实际项目简化出来的版本:
import asyncio
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
# ---- 模拟数据库 ----
ORDERS_DB = {
'ORD001': {'item': 'MacBook Pro 14寸', 'status': '已发货', 'tracking': 'SF1234567890', 'price': 14999},
'ORD002': {'item': 'AirPods Pro 2', 'status': '待发货', 'tracking': None, 'price': 1799},
'ORD003': {'item': '妙控键盘', 'status': '已签收', 'tracking': 'YT9876543210', 'price': 699},
}
LOGISTICS_DB = {
'SF1234567890': {'location': '杭州转运中心', 'eta': '预计明天到'},
'YT9876543210': {'location': '已签收', 'eta': '已完成'},
}
# ---- 依赖 ----
@dataclass
class CustomerDeps:
customer_id: str
order_ids: list[str]
# ---- 输出结构 ----
class QueryResult(BaseModel):
answer: str = Field(description='回答用户问题的文本')
needs_human: bool = Field(description='是否需要转人工客服', default=False)
mentioned_orders: list[str] = Field(description='涉及的订单号列表', default_factory=list)
# ---- Agent ----
support_agent = Agent(
'openai:gpt-4o',
deps_type=CustomerDeps,
output_type=QueryResult,
instructions='你是一个订单查询客服。根据用户问题查询订单和物流信息,给出简洁回答。如果问题超出你的能力范围,标记需要转人工。',
)
@support_agent.tool
async def get_order_info(ctx: RunContext[CustomerDeps], order_id: str) -> str:
"""查询订单信息。返回订单的商品、状态、价格等。"""
if order_id not in ctx.deps.order_ids:
return f'订单 {order_id} 不属于当前用户'
order = ORDERS_DB.get(order_id)
if not order:
return f'订单 {order_id} 不存在'
return f"订单号: {order_id}, 商品: {order['item']}, 状态: {order['status']}, 金额: {order['price']}元"
@support_agent.tool
async def get_logistics(ctx: RunContext[CustomerDeps], tracking_no: str) -> str:
"""查询物流信息。需要运单号。"""
info = LOGISTICS_DB.get(tracking_no)
if not info:
return f'运单号 {tracking_no} 没有查到物流信息'
return f"运单号: {tracking_no}, 当前位置: {info['location']}, 预计: {info['eta']}"
@support_agent.tool
async def list_my_orders(ctx: RunContext[CustomerDeps]) -> str:
"""列出当前用户的所有订单"""
results = []
for oid in ctx.deps.order_ids:
order = ORDERS_DB.get(oid, {})
results.append(f" {oid}: {order.get('item', '未知')} - {order.get('status', '未知')}")
return '\n'.join(results) if results else '没有找到订单'
async def main():
deps = CustomerDeps(
customer_id='user_42',
order_ids=['ORD001', 'ORD002', 'ORD003']
)
# 查物流
r1 = await support_agent.run('我的 MacBook 到哪了?', deps=deps)
print(r1.output)
# answer='您的MacBook Pro 14寸(订单ORD001)已发货...' needs_human=False mentioned_orders=['ORD001']
# 超出范围的问题
r2 = await support_agent.run('我想退货,怎么操作?', deps=deps)
print(r2.output.needs_human) # True
asyncio.run(main())
这段代码能直接跑(把模型换成你有 API Key 的就行)。几个值得注意的点:
-
权限控制在依赖里。
ctx.deps.order_ids限制了当前用户只能查自己的订单。模型调用get_order_info时如果传了别人的订单号,工具函数直接拒绝。这比在 prompt 里写"不要查其他用户的订单"靠谱得多。 -
输出结构化。
QueryResult里的needs_human字段让后续逻辑可以自动分流,不用你再去解析自然语言判断要不要转人工。 -
工具函数就是普通 Python 函数。可以单独写单元测试,mock 数据库就行,完全不需要启动模型。
流式输出
如果你要做实时聊天界面,流式输出很重要。PydanticAI 的流式 API 长这样:
from pydantic_ai import Agent
agent = Agent('openai:gpt-4o', instructions='用中文回答')
async def stream_demo():
async with agent.run_stream('Python 3.13 有哪些新特性?') as response:
async for chunk in response.stream_text():
print(chunk, end='', flush=True)
print() # 换行
结构化输出也能流式返回,但有个前提条件:output_type 里的字段需要按顺序填充。实际体验下来,模型在流式生成 JSON 的时候偶尔会出格式错误,PydanticAI 会在最后做一次完整校验。如果校验失败,框架会自动把错误信息发回给模型让它重新生成。
测试:用 TestModel 不花钱跑测试
PydanticAI 内置了 TestModel,写测试的时候不用调真实模型:
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
agent = Agent(
'openai:gpt-4o',
instructions='你是Python专家',
)
# 用 TestModel 替换真实模型
result = agent.run_sync(
'什么是装饰器?',
model=TestModel()
)
# TestModel 不调 API,直接返回固定格式的输出
print(result.output)
# 验证工具是否被正确调用
print(result.all_messages())
TestModel 会返回固定格式的文本或者按 output_type 生成符合 schema 的默认值。对于工具调用,它会按顺序调一遍所有注册的工具。
对 CI/CD 很实用。测试管线不需要 API Key,也不会因为模型返回不稳定导致测试随机失败。
但有个限制:TestModel 不做任何语义理解。如果你想测试 Agent 在特定输入下的行为逻辑(比如"问退货问题时是否正确标记 needs_human"),还是得用真实模型跑集成测试。
5 个踩坑记录
1. deps_type 必须用 dataclass 或者有明确类型的类
一开始图省事用了 dict 当 deps_type,结果 RunContext[dict] 里 ctx.deps 的类型推导全废了,IDE 什么都提示不出来。换成 dataclass 之后才正常。
2. 工具函数的返回值只能是 str
这个限制让我困惑了一会。工具函数不管你返回什么类型,PydanticAI 最终都会把它转成字符串发给模型。如果你返回一个 dict,它会自动 json.dumps,但不如你自己控制格式化。建议统一返回 str,自己把数据格式化好。
3. 动态 instructions 和静态 instructions 可以共存
Agent 构造函数里的 instructions 和用 @agent.instructions 装饰器注册的函数可以同时存在,两者会拼接。静态的在前,动态的在后。知道这个之后就不用在动态函数里重复写通用指令了。
4. 模型切换时 tool calling 格式不同
OpenAI 和 Anthropic 的 tool calling 协议有差异。PydanticAI 做了适配,但如果你在 tool 的参数里用了比较复杂的嵌套类型(比如 list[dict[str, Any]]),切换模型的时候可能会遇到 schema 不兼容的问题。我的经验是工具参数尽量用基本类型:str、int、float、bool、list[str]。
5. run_sync 不能在已有 event loop 的环境里调用
如果你的代码运行在 Jupyter Notebook 或者已经有 async 环境(比如 FastAPI 的请求处理函数里),调 run_sync 会报 RuntimeError: This event loop is already running。这种场景必须用 await agent.run()。在 Jupyter 里可以直接 await,FastAPI 里也是 async 函数所以也没问题。
PydanticAI 和 LangChain 怎么选
两句话说完:
- 如果你的项目需要大量预置的 Chain 和 Retriever,数据源多且杂,LangChain 的生态更成熟。
- 如果你更看重类型安全、代码可维护性、测试友好性,或者你本来就在用 Pydantic + FastAPI 那套技术栈,PydanticAI 用起来会舒服很多。
两者不冲突。我现在的项目里 LangChain 管 RAG 的部分,PydanticAI 管 Agent 的部分,各干各的。
小结
PydanticAI 让我觉得 Agent 开发终于跟正常的软件工程接轨了。类型约束让 IDE 能帮上忙,依赖注入让测试变得正常,结构化输出让下游代码不用猜 Agent 返回了什么。
代码仓库在 github.com/pydantic/py…,文档在 ai.pydantic.dev。建议从官方的 bank_support 例子开始看,那个例子把依赖注入、工具、结构化输出全串起来了,跟我这篇文章里的订单查询 Agent 思路一样。
如果你之前用 LangChain 或者裸调 API 写 Agent 觉得不顺手,试试 PydanticAI。不一定要全部迁移,挑一个新的 Agent 用它写就行,体会一下"如果类型标注写对了,代码大概率能跑"的感觉。