做 LLM 应用最头疼的事,不是 prompt 写不好,而是模型回答怎么变成程序能用的数据。以前我都是叫它直接返回 JSON,然后json.loads()一把梭,看起来挺顺,实际上暗坑无数:输出里多了个 markdown 代码块、字段名换个风格、多了一个逗号、返回的是空对象、偶尔某几个字段直接消失……每次上线前都得写一堆防御代码,异常分支比业务逻辑还多。
后来我把 PydanticAI 接进项目,情况完全变了。它把“LLM 输出”和“可信赖的 Python 对象”之间那道又脆又乱的桥,换成了一条带校验、带契约、带自动修复的通路:你定义一个 Pydantic 模型,它就按这个模型去约束模型生成,输出直接就是一个模型实例,类型不对、字段缺失还会自动让模型重新生成一次。这篇文章我会从设计思路讲到可落地的订单提取案例,再讲验证失败恢复、工具调用,以及我在生产环境里踩过的那些坑,适合所有正在用 LLM 做数据提取、结构化输出的开发者。
1. 为什么 LLM 输出需要“可信赖的 Python 对象”
1.1 直接解析 JSON 的痛点
先说一个根本问题:大语言模型不是数据库,它不会执行“return JSON”这个指令。你看到的 JSON,只是模型根据 prompt 里的指引,把“看起来像 JSON”的 token 一个个生成出来的结果。这个过程中没有任何东西保证它合法。
我实际遇到过的情况有这些:
- 模型在 JSON 外面包了一层
```json ... ```代码块,json.loads()直接炸。 - 返回的内容被截断,整个 JSON 只生成了一半,括号不闭合。
- 字段名不完全遵守约定。我要求
order_id,它给我orderId;我要求customer_name,它给我name。 - 值类型不稳。我要求
quantity是整数,它输出"3"或者3.0。 - 数组字段有时候是
[],有时候直接不返回,导致取字段时抛KeyError。
你可以在 prompt 里写“不要输出任何多余内容”,但模型的注意力有限,写得越复杂,它越容易顾此失彼。与其依赖口头约束,不如在工程层面做硬约束。
打个生活化的比方:你让三个人各自记录一场会议纪要,A 写流水账,B 用表格,C 只写结论。你想要的是一张统一结构的表,但是如果你不给他们统一模板,结果必然五花八门。Pydantic 模型就是那个模板,而且是带校验引擎的模板——不是“建议你按模板来”,而是“不符合就直接重填”。
1.2 “可信赖”到底指什么
一个可信赖的结构化输出,至少应该满足三层要求:
第一层,类型正确。quantity必须是int,unit_price必须是Decimal,created_at必须是datetime。这一层用 Pydantic 的字段类型注解就能解决。
第二层,字段完整。模型返回的对象里,该有的字段一个都不能少,required字段缺失就是不合格。
第三层,语义合理。光有类型还不够。比如订单总金额,类型是Decimal,但数值跟明细相加对不上;比如商品数量为 0;比如发票类型传了一个枚举里不存在的值。这些属于业务规则,可以通过 Pydantic 的校验器拦截。
PydanticAI 的思路就是把这三层校验全部前移。它不是在模型输出完以后再做事后解析,而是把这个 schema 当作生成约束,模型在出结果的时候就照着这个 schema 写。出了错,框架还能带着错误信息让模型自己改。
1.3 相比“手动解析”的方案优势
以前我用传统方式写一个 LLM 数据提取函数,流程大概是:
- 调模型接口,传 prompt。
- 收到字符串。
- 清洗字符串(去掉代码块标记、处理截断)。
json.loads()。- 如果失败,再拼一个“请你修复 JSON”的 prompt,重试一次。
- 成功之后,手写一个转换函数,把 dict 映射到某个 dataclass。
- 手写各字段类型转换和校验。
- 写一整套测试覆盖各种异常输入。
这套流程不是不能用,但每个项目都要重写一遍,而且重试逻辑、错误信息传递、prompt 拼接这些环节特别容易出 bug。PydanticAI 把这些都封装好了:你声明一个Agent,指定output_type,它内部会自动把 schema 信息注入到模型请求里;收到模型输出后,自动做 Pydantic 校验;校验失败时,它会生成一条包含错误详情的消息发回给模型,让模型重新生成。这套闭环是手动方案很难做到的——你原本需要自己管对话历史、拼接错误信息、控制重试次数,现在框架替你管了。
2. PydanticAI 的核心设计思路
2.1 Agent 是最小的调度单元
PydanticAI 里最核心的概念就是Agent。它不像 LangChain 那样把 chain、memory、llm 拆成一堆组件,它就是一个整体:一个 agent 绑定一个模型、一套 system prompt、一组工具、一个输出类型。
它的使用方式非常像“封装了一个带人格的函数调用”:
from pydantic_ai import Agent agent = Agent( 'openai:gpt-4o', system_prompt='你是一个订单信息提取助手。只根据用户提供的文本提取信息,不要猜测缺失字段。', output_type=OrderInfo, ) result = agent.run_sync( '订单号 A2024001,客户张三,电话 13800138000,' '地址是北京市朝阳区某街道 1 号院,购买商品:SKU1001 无线鼠标 2 个,单价 89.5 元;' 'SKU1002 机械键盘 1 个,单价 399 元,需要开具发票。' ) print(result.output)agent.run_sync()是同步入口,输出对象的类型就是OrderInfo,不是一个 dict,也不是一段字符串。这带来的好处是:你在业务代码里可以直接result.output.order_id,IDE 能自动补全字段名,类型错误在开发阶段就能暴露。
result对象里还带对话消息和 token 统计,调试的时候看result.all_messages()可以了解到模型完整思考过程。这个对排查“为什么提取错”特别有用。
2.2 输出类型即契约
output_type是整个 PydanticAI 设计的灵魂。
当你指定output_type=OrderInfo时,PydanticAI 会把这个 Pydantic 模型的 JSON Schema 注入到模型的系统提示词中。模型的生成过程会尽量参照这个 Schema,这比在 prompt 里写“请输出 JSON,字段如下:……”要规范得多。
更关键的是,你改 Schema 就等于改契约。想增加一个字段、修改一个枚举值、把某个字段变成必填,只需要改 Pydantic 模型定义,不需要去翻 prompt 里那一大段描述。Model 定义既是 prompt 的一部分,又是运行时校验规则的一部分,两者永远同步。
如果你不加output_type,PydanticAI 默认会返回一段 markdown 文本,所以项目里一定要显式声明输出类型。这是最容易踩的一个“隐性默认”坑。
2.3 验证失败后的自动重试闭环
PydanticAI 最打动我的设计是它的重试机制。
假设模型返回了一个不符合OrderInfo结构的结果,传统方案里你已经准备抛异常了,但 PydanticAI 会做这样一个流程:
- 模型返回原始文本或结构化输出。
- 框架把它转换成 Pydantic 模型,执行字段校验。
- 校验失败时,框架不会直接放弃,而是把校验错误(包括哪个字段错误、为什么错误、期望什么格式)组织成一条消息,发给模型。
- 模型看到错误信息后,修正自己的输出,再校验一次。
- 重复这个过程,直到通过校验或达到最大重试次数。
换句话说,模型有了“自我纠错”的机会。这非常管用,因为模型犯的很多错误其实不是不会,而是马虎——它把格式写错了、把字段名拼错了、多写了个逗号。告诉它哪儿错了,它马上就能改对。
你可以用max_retries控制这个闭环的上限:
agent = Agent( 'openai:gpt-4o', output_type=OrderInfo, system_prompt='...', max_retries=3, )这个参数不能设太大。模型收到重试消息会多消耗 token,还会增加响应延迟,我一般控制在 2 到 3 次之间。
3. 完整案例:订单信息提取助手
3.1 安装和准备
PydanticAI 的安装很简单:
pip install pydantic-ai如果你不需要所有模型服务的 SDK 依赖,可以装精简版:
pip install pydantic-ai-slim然后根据你要用的模型服务类型装扩展依赖。以常见的大模型接口为例:
pip install 'pydantic-ai-slim[openai]'我这里说的模型名只是一个服务标识串,替换成你实际用的服务商和模型即可。
3.2 定义订单信息模型
我用一个真实业务里最常见的场景来演示:从一段用户聊天文本中提取订单信息。
首先定义商品明细和订单两个模型:
from datetime import datetime from decimal import Decimal from enum import Enum from pydantic import BaseModel, Field, field_validator class ReceiptType(str, Enum): no_invoice = "no_invoice" personal = "personal" company = "company" class OrderItem(BaseModel): sku: str = Field(description="商品唯一编码") name: str = Field(description="商品名称") quantity: int = Field(description="商品数量,整数") unit_price: Decimal = Field(description="商品单价,单位元") subtotal: Decimal = Field(description="小计金额,等于数量乘以单价") @field_validator("subtotal") @classmethod def check_subtotal(cls, v, info): quantity = info.data.get("quantity") unit_price = info.data.get("unit_price") if quantity is not None and unit_price is not None: expected = Decimal(quantity) * unit_price if abs(v - expected) > Decimal("0.01"): raise ValueError( f"小计金额应为 {expected},但实际是 {v}" ) return v class OrderInfo(BaseModel): order_id: str = Field(description="订单号,例如 A2024001") customer_name: str = Field(description="客户姓名") phone: str = Field(description="联系电话") shipping_address: str = Field(description="收货地址,尽量完整") items: list[OrderItem] = Field(description="购买商品列表") total_amount: Decimal = Field(description="订单总金额,单位元") receipt_type: ReceiptType = Field(description="发票类型") note: str | None = Field(default=None, description="订单备注,没有则填 None") created_at: datetime = Field(description="订单创建时间") @field_validator("total_amount") @classmethod def check_total(cls, v, info): items = info.data.get("items") if items: expected = sum((i.subtotal for i in items), Decimal("0")) if abs(v - expected) > Decimal("0.01"): raise ValueError( f"订单总金额应为 {expected},但实际是 {v}" ) return v这里有几个细节值得说。
subtotal字段的校验器用到了info.data,它拿到的是同一个模型里已经完成解析的字段值。因为 Pydantic 默认按字段声明顺序解析,sku、quantity、unit_price都在subtotal之前处理完,所以校验器能拿到quantity和unit_price的正确值。这个顺序依赖是代码里看不见的隐性要求,一旦字段顺序变动就可能导致校验器拿不到数据。因此我不建议在模型里乱调字段顺序,如果确实需要,把这类跨字段校验放在model_validator(mode="after")里更稳妥。
Decimal类型用在这里非常合适。金额计算最怕浮点误差,89.5 * 1在 float 里是89.5,但0.1 * 3可能是0.30000000000000004。用 Decimal 能消除这类误差。
created_at直接声明成datetime,Pydantic 会自动把字符串转成时间对象。但这里有个坑:如果 LLM 输出的时间不是 ISO 8601 格式,比如“2024年5月1日”,校验会直接失败。这种情况下更稳妥的做法是声明为str,在业务层再解析,或者给字段加上一个宽松的field_validator。
3.3 创建 Agent 并运行
定义好模型后,创建一个 agent:
from pydantic_ai import Agent agent = Agent( 'openai:gpt-4o', system_prompt=( "你是订单信息提取助手。请从用户输入的文本中提取订单信息。" "注意:电话必须保留完整号码;地址要完整;" "如果文本中没有提到某个可选字段,请将该项设为 null,不要凭空编造。" ), output_type=OrderInfo, max_retries=2, ) user_input = ( "订单号 A2024001,客户张三,电话 13800138000," "地址是北京市朝阳区某街道 1 号院。购买商品:" "SKU1001 无线鼠标 2 个,单价 89.5 元;" "SKU1002 机械键盘 1 个,单价 399 元。需要开具个人发票。" ) result = agent.run_sync(user_input) order = result.output print(order.order_id) # A2024001 print(order.customer_name) # 张三 print(order.total_amount) # 578.0 print(order.items[0].name) # 无线鼠标 print(type(order)) # <class '__main__.OrderInfo'>运行这段代码,你拿到的order就是一个合法、经过校验的 Python 对象。它不是字符串,不是 dict,是一个完整的 Pydantic 模型实例。
细心的读者会发现,模型定义里有一个created_at必填字段,但用户输入里根本没有时间。LLM 能做的只有两种:编一个时间,或者让校验失败。我在 system prompt 里写了“不要凭空编造”,但 LLM 可能还是会尝试填一个当前时间。这种敏感场景,更好的做法是把这个字段设为可选,或者用另一个渠道单独传入时间戳,而不是让模型猜。
3.4 嵌套结构的价值
OrderInfo.items是一个list[OrderItem]。嵌套 Pydantic 模型在提取场景里非常实用:模型可以一次性把“订单 + 商品明细”整个结构生成出来,不用你写两轮 prompt。
如果不用嵌套模型,你大概率需要先提取订单头,再根据订单号提取商品明细,最后做关联。PydanticAI 的嵌套模型让模型自己完成这个组装过程。商品数量多、种类杂的时候,嵌套模型尤其好用。
当然,这也对模型本身的结构化输出能力要求更高。实测下来,较新的大模型对嵌套对象支持都不错,老的模型容易在嵌套层级深的时候出现字段丢失,这时候可以适当减少嵌套深度,把复杂结构拆成两个 agent 分步提取。
4. 验证失败和自动重试机制
4.1 校验失败时,框架到底做了什么
理解 PydanticAI 的验证闭环,关键要看它的消息流。
当模型第一次输出不符合 schema 的内容,比如它给quantity传了一个字符串"2",框架会执行 Pydantic 校验,捕获ValidationError,然后把这条错误信息组织成一条新消息(通常是system或user角色)发回给模型,里面包含类似这样的内容:
This response does not match the expected schema. The validation error was: 1. ValidationError: items.0.quantity Input should be a valid integer, got '2'模型收到这条消息后,往往会回答“哦好的,我修正一下”,然后重新生成新的结构化输出。这个过程对业务代码完全透明,你根本看不到中间发生的修正。
我觉得这个机制的设计价值在于:它把“容错”从代码层移到了模型推理层。代码层的容错需要你写大量的分支逻辑,模型推理层的容错则是给模型补齐上下文,让它自己意识到错误。前者是防御,后者是修复,两件事都有意义,但后者明显更省心。
4.2 用业务规则校验触发重试
模型类型对不对只是最低要求,业务规则校验才有真正的价值。
我在OrderInfo模型里写的check_total就是一个例子。假设模型在提取时把某个商品价格写错了,导致小计和总金额对不上,Pydantic 的field_validator会抛出ValueError。这个异常会转化成校验错误,同样反馈给模型重新生成。
这里有几个注意事项:
- 校验器抛出的错误信息要写得足够明确,最好包含期望值,模型才知道怎么改。比如上面代码里的“小计金额应为 578.0,但实际是 570.0”,模型拿到这个值就能重新计算。
field_validator尽量少依赖info.data里的字段,因为跨字段取值受字段顺序影响。跨字段规则优先用model_validator(mode="after"),等所有字段都解析好再统一校验。- 不要把业务规则写得过于苛刻。有时候模型提取出的地址有细微差异,但核心信息完全正确。业务规则应该验证“必须对”的硬约束,比如金额一致性,而不是验证“格式完全符合某个固定模板”的软约束。
4.3 模型一直不能修正时的兜底策略
不管重试机制多完善,总有模型反复出错的情况。可能输入文本信息本身模糊,可能模型的上下文长度不足导致输出截断,可能你定义的 schema 和模型能力不匹配。
PydanticAI 设置了max_retries上限,超过之后会抛出异常。生产环境一定要做兜底:
from pydantic_ai.exceptions import AgentError try: result = agent.run_sync(user_input) except AgentError as exc: # 记录原始输入和异常信息,方便人工介入或走降级流程 log.error("订单提取失败: %s", exc) raise异常路径往往意味着模型输出质量不可靠,这时候与其继续重试,不如把原始输入交给人工审核,或者返回给用户重新整理信息。我见过不少团队在重试机制上无限加深,结果延迟涨了一倍,成功率却没有明显改善。
我个人的心得是:重试次数提升的边际效益递减非常快。第一次重试能救回 90% 的格式错误,第二次重试救回的就只剩少数情况了。设 2 到 3 次,把剩余失败交给人工处理,是性价比最高的配比。
5. 从结构化输出去往工具调用和多步编排
5.1 在 Agent 里挂业务工具
PydanticAI 不只做结构化输出,它还能在同一个 Agent 里注册工具,让 LLM 调用。这解决了另一个常见需求:模型提取出的数据需要校验真伪或补充实时信息。
拿订单提取场景来说,提取到商品 SKU 后,模型可以调用一个库存查询工具,验证这个 SKU 是否存在、库存是否充足。代码可以这样写:
from pydantic_ai import Agent class StockResult(BaseModel): sku: str in_stock: bool stock_count: int agent = Agent( 'openai:gpt-4o', system_prompt='你是订单助手,提取订单信息,并检查每个商品是否有库存。', output_type=OrderInfo, ) @agent.tool async def check_stock(ctx, sku: str) -> StockResult: """查询商品库存状态。""" # 实际项目里这里查数据库或库存服务,我这里用模拟数据 stock_map = { "SKU1001": 15, "SKU1002": 0, "SKU1003": 5, } count = stock_map.get(sku, 0) return StockResult( sku=sku, in_stock=count > 0, stock_count=count, )模型在生成OrderInfo之前,会先调用check_stock("SKU1002"),拿到库存结果,然后决定在输出里如何呈现。你可以要求模型把缺货状态写进note字段,或者直接生成一个包含库存信息的另一种结果模型。
agent.tool的函数签名里,ctx是运行上下文,后面参数由 LLM 根据函数描述推断生成。工具函数的 docstring 非常重要,模型根据它决定传什么参数。写清楚参数含义和格式,提取成功率会高很多。
5.2 多 Agent 协作:任务拆分
当一个任务涉及多个领域时,我会把单一 Agent 拆成多个子 Agent,再让一个父 Agent 做分发。
比如电商客服系统里,用户的输入可能同时包含“咨询订单状态”和“申请退货”两个意图。拆成order_agent(订单查询)、return_agent(退货流程)、support_agent(通用问答)三个子 Agent,每个 Agent 负责一个职责。父 Agent 根据用户输入判断应该调用哪个子 Agent,拿到结果后再汇总。
这种多 Agent 编排比塞一个超级 Agent 更可控。每个子 Agent 的 prompt 更短,模型不容易被无关信息干扰;工具列表更精简,模型选择工具的失误率更低;每个子 Agent 的输出类型各自独立,校验规则也更清晰。
如果场景更复杂,可以考虑先把用户请求分类,再用if/elif路由到不同子 Agent,而不是把所有 Agent 的调用权都交给父 Agent 动态决定。显式路由调试起来更直观,也更容易写测试用例。
5.3 依赖注入让工具保持干净
工具函数里直接查数据库还是注入外部服务,两者差别很大。PydanticAI 支持deps_type,可以把手里的客户端对象注入到工具函数里:
from dataclasses import dataclass @dataclass class OrderDeps: stock_service: object db_client: object agent_with_deps = Agent( 'openai:gpt-4o', system_prompt='...', output_type=OrderInfo, deps_type=OrderDeps, ) @agent_with_deps.tool async def query_stock(ctx, sku: str) -> StockResult: svc = ctx.deps.stock_service return await svc.query(sku) result = agent_with_deps.run_sync( user_input, deps=OrderDeps(stock_service=my_svc, db_client=db), )这样的好处是工具函数里不用写死全局连接或者环境变量,测试的时候传一个 mock 的stock_service就能跑通。我写的项目基本都走这个模式,避免工具函数里直接 import 数据库客户端。
另外,工具函数接收的ctx类型需要和deps_type对应,PydanticAI 会根据deps_type自动推导出RunContext[OrderDeps]。如果你手动标注ctx: RunContext又不带类型参数,部分场景下类型检查会报错。
6. 生产环境踩坑清单
6.1 常见问题速查表
我把实践里遇到过的典型问题整理成了一个速查表:
| 问题现象 | 可能原因 | 推荐解法 |
|---|---|---|
| 模型输出偶尔带 markdown 代码块 | 模型服务对返回格式约束不严 | 调高max_retries;优先选用支持结构化输出的模型接口 |
| 字段名总被改写成驼峰风格 | 模型对下划线字段名不敏感 | 在 Field 里用validation_alias声明别名;或者在 prompt 中显式声明字段名 |
| 数字字段偶尔变成字符串 | 模型对类型感知弱 | 提高重试次数;检查是否有strict=True与模型能力冲突 |
| 时间类字段解析失败 | LLM 输出了非 ISO 格式 | 先用str接收,再用独立解析器处理;或在 Field 里写宽松校验器 |
| 校验失败后重试次数始终偏高 | schema 过于复杂,嵌套太深 | 拆成多个 Agent 分步提取;简化嵌套层级 |
| 工具调用参数频繁出错 | 工具函数参数说明不清晰 | 重写 docstring,给参数标注枚举和示例值 |
| 结构化输出耗时比普通输出长很多 | 模型需要结合 schema 生成,外加重试消耗 | 控制max_retries;使用更小的模型;对实时性要求高的接口预编译 prompt |
| 字段缺失时不报错反而被模型填了假值 | prompt 给了模型自由发挥的空间 | 显式说明“缺失字段填 null,禁止编造”;用可选字段 + 校验器拦截明显假值 |
6.2 一个真实的调试案例
之前做一个模拟客服工单项目,A 同学负责用 PydanticAI 提取工单信息。他定义了一个字段updated_at,测试时发现模型经常把这个字段填成当前时间,而不是工单文本里提到的最近更新时间。
我让他查看result.all_messages(),发现一个有意思的模式:模型第一次输出里没有updated_at字段,校验失败后,模型在修正时没有去原文里找时间,而是直接填了当前时间凑数。这是典型的重试机制副作用——模型想尽快让输出合法,于是“造”了一个值。
解决办法分两步。第一,把这类的字段改成可选,避免模型为了补齐 schema 编造内容。第二,在 system prompt 里加一句硬约束:“如果原文未提及时间,置 null,不要使用当前时间。”
这个案例说明一个常见误解:重试机制不等于增强模型理解能力。模型在重试时只是修正格式,并不意味着它能重新正确理解语义。对于语义敏感性高的字段,务必靠字段类型设计和 prompt 约束双管齐下。
6.3 关于性能的取舍
结构化输出整体比普通对话慢,这是很多人没说透的点。
一方面,模型生成时需要把 JSON Schema 结构融入输出,约束本身会增加推理负担;另一方面,校验失败重试是额外的完整模型调用,一次重试就是一次新的生成过程。
实测下来,一个简单的订单提取任务,不加结构化约束大概 1 到 2 秒出结果,加上 PydanticAI 和必填校验之后,可能要 2 到 4 秒。如果模型第一次就出错,再来一次重试,耗时直接翻倍。
对此我的建议是:在线实时接口,max_retries控制在 1 到 2;对耗时要求高的场景,可以先把output_type定义得宽松一些(比如核心字段必填,外围字段可选),宁可后续在业务层做二次校验,也不能让模型在生成阶段反复失败;如果业务允许异步处理,把提取任务丢到消息队列里,重试次数可以放宽到 3。
6.4 别把模型当前端
最后想提醒的是 PydanticAI 的定位。它解决的是“模型输出到程序对象”这一段问题,但不解决“模型答非所问”的问题。
如果模型本身能力不够,或者 prompt 写得含糊,再强的 schema 校验也只是把错误从“格式错误”变成“校验失败”。工具函数里的业务逻辑、校验器里的业务规则、多 Agent 的编排流程,这些才是一个 LLM 应用真正的护城河。
我现在写新项目的习惯是:先把 Pydantic 模型定义好,再做 Agent 编排。模型定义不通过,绝不动 Agent 逻辑。这样项目结构从一开始就是类型安全的,UI 层、业务层、存储层全部围绕同一个模型对象转,协作的时候也不会出现“A 说字段叫 phone,B 说字段叫 mobile”这种低级扯皮。
PydanticAI 不是银弹,但它确实把 LLM 输出从“一个不稳定的字符串”变成了“一个有类型的、验证过的、可调试的模型实例”。对这方面有深刻体会的开发者,应该能理解这件事的价值有多大。