大模型返回的 JSON 字段名飘忽不定、类型随机、偶尔还塞一段自然语言当值——这是每个在生产环境调用 LLM 的开发者都踩过的坑。Pydantic AI 的核心思路很简单:用 Pydantic 模型把"LLM 应该返回什么"钉死在类型系统里,再配合工具声明和依赖注入,让 Agent 从"能跑的脚本"升级为"可维护的系统"。
下面拆解它的三个关键机制,最后用一个可运行的完整示例把它们串起来。
结构化输出:让 LLM 说话有规矩
裸调用 LLM,你拿到的是一段字符串,接下来自己写正则、写 JSON 解析、写 fallback——本质上是在用脆弱的字符串匹配对抗一个概率模型。Pydantic AI 的做法是:把期望的输出结构声明为 Pydantic 模型,框架在调用 LLM 时把模型 schema 嵌进 prompt,并在收到响应后做 Pydantic 校验。校验失败会触发重试(可配置次数),而不是把半成品丢给下游。
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class Diagnosis(BaseModel):
disease: str = Field(description="最可能的疾病名称")
confidence: float = Field(description="置信度,0-1之间")
symptoms_matched: list[str] = Field(description="匹配到的症状列表")
doctor_agent = Agent(
model="openai:gpt-4o",
result_type=Diagnosis, # 关键:声明输出类型
system_prompt="你是一个医学诊断助手,根据症状给出结构化诊断。",
)
result = doctor_agent.run_sync("患者发热3天,咳嗽,乏力")
# result.data 是一个 Diagnosis 实例,字段类型已校验
print(result.data.disease) # str
print(result.data.confidence) # float,保证在 0-1
print(result.data.symptoms_matched) # list[str],不会出现 None 或字符串
几点值得注意:
Field(description=...)不是给开发者看的注释,而是会被注入 prompt 的约束说明,LLM 会据此调整输出格式。- 如果 LLM 返回的
confidence是"高",Pydantic 校验会失败,框架自动重试,不需要你手写异常处理。 result_type支持嵌套模型和Union,复杂业务场景也能覆盖。
工具声明:给 Agent 一双可调用的手
Agent 只有"嘴"没有"手"时,回答永远停留在"建议你查一下数据库"。Pydantic AI 的工具机制让 Agent 在推理过程中主动调用你注册的函数,拿到真实数据再继续推理。
from pydantic_ai import Agent, RunContext
agent = Agent("openai:gpt-4o", system_prompt="你是一个客服助手,可以查询订单状态。")
@agent.tool
async def order_status(ctx: RunContext[None], order_id: str) -> str:
"""查询订单状态。order_id 为订单编号。"""
# 实际场景中这里会查数据库或调用内部 API
mock_db = {
"ORD-001": "已发货,预计明天送达",
"ORD-002": "仓库备货中",
}
return mock_db.get(order_id, "未找到该订单")
result = agent.run_sync("我的订单 ORD-001 到哪了?")
# Agent 会自动调用 order_status("ORD-001"),然后把结果融入回答
print(result.data)
工具函数的几个设计要点:
- 类型签名即契约——参数
order_id: str和返回-> str会被框架提取成工具描述,LLM 据此决定何时调用、传什么值。 - docstring 是自然语言接口——
"""查询订单状态。order_id 为订单编号。"""会作为工具的 description 发给 LLM,写清楚比写短更重要。 RunContext是依赖注入的入口——第一个参数ctx携带运行时依赖,下一节展开。
依赖注入:把状态和连接从工具参数里剥离出来
工具函数如果直接接收数据库连接、API key 等参数,签名会变得臃肿,而且测试时你得构造真实依赖。Pydantic AI 的依赖注入把这类"环境依赖"放进 RunContext,工具函数只声明需要什么类型,框架在运行时注入。
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
db_conn: str # 模拟数据库连接
api_key: str # 外部服务认证
agent = Agent(
"openai:gpt-4o",
deps_type=Deps,
system_prompt="你是一个财务助手,可以查询账户余额。",
)
@agent.tool
async def balance(ctx: RunContext[Deps], account: str) -> str:
"""查询账户余额。account 为账户名。"""
# ctx.deps 携带注入的依赖
print(f"使用 db_conn={ctx.deps.db_conn} 查询")
return f"账户 {account} 余额:¥12,350"
# 运行时传入依赖实例
deps = Deps(db_conn="postgres://prod-db:5432", api_key="sk-live-xxx")
result = agent.run_sync("查一下张三的余额", deps=deps)
print(result.data)
这样做的好处:
- 工具签名干净——
balance(ctx, account)只保留业务参数,不混入基础设施细节。 - 测试友好——跑单测时传入
Deps(db_conn="mock", api_key="test-key"),不需要启动真实数据库。 - 多环境切换——开发、预发、生产只需换
deps实例,Agent 代码零修改。
把三件事串起来:一个完整的类型安全 Agent
下面是一个可直接运行(需安装 pydantic-ai 和配置 OpenAI API key)的示例,把结构化输出、工具、依赖注入组合在一起:
import asyncio
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
# ---- 1. 结构化输出模型 ----
class TravelPlan(BaseModel):
destination: str = Field(description="推荐目的地城市")
budget_estimate: float = Field(description="预估人均费用,单位元")
activities: list[str] = Field(description="推荐活动列表,至少3项")
risk_note: str = Field(description="需要注意的风险或限制")
# ---- 2. 依赖声明 ----
@dataclass
class Deps:
weather_api: str # 天气服务端点(模拟)
user_locale: str # 用户所在城市
# ---- 3. Agent + 工具 ----
travel_agent = Agent(
model="openai:gpt-4o",
deps_type=Deps,
result_type=TravelPlan,
system_prompt=(
"你是一个旅行规划助手。根据用户偏好和实时天气,"
"给出结构化的旅行方案。务必调用天气工具确认目的地天气。"
),
)
@travel_agent.tool
async def get_weather(ctx: RunContext[Deps], city: str) -> str:
"""查询指定城市的当前天气。city 为城市名。"""
# 模拟调用——实际替换为真实天气 API
mock = {
"三亚": "晴天,32°C,适合海滩活动",
"成都": "多云,22°C,适合室内和美食",
"哈尔滨": "小雪,-8°C,注意保暖",
}
return mock.get(city, "暂无该城市天气数据")
# ---- 4. 运行 ----
async def main():
deps = Deps(weather_api="https://api.weather.example", user_locale="北京")
result = await travel_agent.run("想去暖和的地方度假,3天左右", deps=deps)
plan = result.data # 类型:TravelPlan,字段已校验
print(f"目的地:{plan.destination}")
print(f"预算:¥{plan.budget_estimate:.0f}")
print(f"活动:{', '.join(plan.activities)}")
print(f"风险提示:{plan.risk_note}")
asyncio.run(main())
运行前需要:
pip install pydantic-ai
export OPENAI_API_KEY=sk-your-key
这个示例里,LLM 会先调用 get_weather 查询候选城市的天气,再把结果融入推理,最终返回一个经过 Pydantic 校验的 TravelPlan 实例。如果 LLM 返回的 budget_estimate 是字符串 "约3000",校验失败后框架会自动重试,直到拿到合法的 float。
上手前的几条务实建议
| 决策点 | 建议 |
|---|---|
| 什么时候引入 result_type | 只要下游代码要消费 LLM 输出的具体字段,就该用。纯对话场景(聊天机器人)可以不加。 |
| 工具数量上限 | 单个 Agent 注册 5-8 个工具效果最好;超过 15 个 LLM 容易选错工具,考虑拆成多个子 Agent。 |
| 依赖注入的粒度 | 把"可替换的"放 deps(数据库、API key),把"不可替换的"写进工具逻辑(业务规则)。 |
| 重试策略 | 默认重试 1 次,关键场景调到 2-3 次,但要在日志里监控重试率——高重试率说明 prompt 或 schema 需要优化。 |
| 模型选择 | 结构化输出对模型能力有要求;gpt-4o / claude-3.5 表现稳定,小模型容易在 schema 约束下"偷懒"输出空值。 |
Pydantic AI 不是唯一选择——OpenAI 的 Structured Outputs、Instructor、Marvin 都在做类似的事。它的差异化在于:把类型安全从"输出校验"延伸到了"工具签名"和"依赖管理"三个层面,让 Agent 的输入、输出、执行环境都有类型约束。如果你的 Agent 正在从 demo 走向生产,这套约束值得认真评估。