模型输出的结构契约:语法、Schema 与领域规则是三道检查
让模型从客服对话里提取订单参数,输出 JSON,用 Pydantic 解析后直接下单。测试时一切正常。上线后出现了一张「购买 -2 件」的订单,还有几张订单的价格是模型自己写的,和商品目录对不上。
把模型的输出交给程序处理时,常见的做法是在提示里要求「只输出 JSON」,再用一个数据模型解析。这能挡住一部分问题,但「能被解析」和「可以执行」之间至少隔着三层检查:文本是不是合法的 JSON,JSON 是不是符合 Schema,符合 Schema 的值在业务上是否成立。每一层挡住的东西不同,而且解析库的默认行为比想象中宽松。
本文用 Pydantic 2.14.1 和一组写死的「模型输出」样本,逐层验证。实验见文末。
一、Pydantic 默认会替你转换类型
class LineLax(BaseModel):
sku: str
qty: int
price_cents: int| 输入 | 默认(宽松)模式 | strict=True, extra="forbid" 加范围 |
|---|---|---|
qty 是字符串 "3" | 通过,得到 3 | 拒绝 |
| qty 是 3.0 | 通过,得到 3 | 拒绝 |
| qty 是 3.5 | 拒绝 | 拒绝 |
| qty 是 true | 通过,得到 1 | 拒绝 |
多一个 discount_pct: 90 | 通过,额外字段被丢弃 | 拒绝 |
| qty 是 -2 | 通过 | 拒绝 |
默认模式的设计目标是方便地接收外部数据,所以会把 "3"、3.0 甚至 true 转成整数,并且静默丢弃未声明的字段。对模型输出来说,这些转换掩盖了模型「没按要求输出」的信号:今天输出的是 "3",明天可能是 "三";多出来的 discount_pct 被丢掉了,说明模型在试图表达一个你的 Schema 不认识的意图,而程序完全不知道。
更严格的配置:
class LineStrict(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
sku: str = Field(pattern=r"^[A-Z]\d{3}$")
qty: int = Field(gt=0, le=100)
price_cents: intstrict 拒绝类型转换,extra="forbid" 拒绝未声明的字段,Field 加上取值范围。开头那张 -2 件的订单,在这里就会被拒绝。
二、语法层:模型常见的几种「差一点」
包在代码块里:JSON 语法错误:Expecting value(第 0 个字符)
前面有一句话:JSON 语法错误:Expecting value(第 0 个字符)
输出被截断:JSON 语法错误:Unterminated string starting at(第 26 个字符)
返回了数组:不是 JSON 对象,而是 list模型经常把 JSON 包在 Markdown 代码块里、在前面加一句「好的」,或者因为输出长度上限被截断。常见的补救是用启发式提取:
「取第一个左括号到第一个右括号」的启发式,从带嵌套说明的文本里取到:'{"note": "见 {附件}'启发式在简单情况下有效,一遇到嵌套或说明文字里的括号就取错,而且取错的结果可能恰好也能解析。更可靠的做法是让模型提供方用结构化输出(按 JSON Schema 约束解码)保证语法,或者要求输出只包含一个 JSON 对象、不合格就按失败处理,而不是猜测哪一段是想要的内容。截断要单独识别:它说明输出预算不够,重试同一个请求通常还会截断。
三、领域层:结构正确不等于业务正确
Schema 通过的 {"sku": "A100", "qty": 2, "price_cents": 1999}:领域校验 通过
Schema 通过的 {"sku": "A100", "qty": 2, "price_cents": 1}:领域校验 价格 1 与目录价 1999 不符
Schema 通过的 {"sku": "Z999", "qty": 2, "price_cents": 1999}:领域校验 未知商品 Z999价格 1 分是一个合法的整数,Schema 挑不出毛病。问题出在职责划分上:价格不应该由模型决定。模型的任务是从对话里理解用户要买什么、买几件;价格、库存、优惠资格这些权威数据由应用从自己的系统里查。开头那几张价格对不上的订单,根源就是让模型输出了一个本该由应用查询的字段。
一个有用的划分是看字段的来源:
| 字段 | 谁决定 | 怎样验证 |
|---|---|---|
| 用户意图(商品、数量、地址) | 模型提出候选 | Schema + 领域规则 + 必要时向用户确认 |
| 权威数据(价格、库存、订单状态) | 应用查询 | 不接受模型提供的值 |
| 动作(下单、退款) | 应用执行 | 权限、幂等与确认,见工具调用循环 |
四、「修复」可能改变含义
解析失败时,一个常见的做法是把错误信息和原输出交回模型,让它「修复」:
原始输出:拒绝:qty: int_type
修复后的输出:通过:{"sku":"A100","qty":2,"price_cents":1999};领域校验 通过
对比修复前后应保持不变的字段:['sku'] 被改动,拒绝这次修复原始输出里商品是 B200,数量写成了「两件」;修复后数量对了,商品却变成了 A100,三层检查全部通过。修复是又一次生成,它没有义务保持原意。修复只应该被允许改变出错的部分:对比修复前后,规定哪些字段必须保持不变,变了就拒绝;更好的做法是让修复只处理格式(例如把「两件」规范化成 2),不处理含义。
五、重试要有预算
最多尝试 2 次:语法错误 → Schema:qty: greater_than → 放弃,返回明确的失败
最多尝试 3 次:语法错误 → Schema:qty: greater_than → 成功每次重试都是一次额外的调用和延迟。设定最多尝试几次,达到上限就返回一个明确的失败(让用户补充信息、转人工),而不是无限重试或者退回到宽松的解析。重试的次数和失败的类型都应该记录下来:如果某类请求总是要重试,问题在提示或 Schema 的设计上,而不是运气。
六、检查清单
- 解析模型输出时用严格模式:禁止类型转换与额外字段,给数值加范围。
- 不用启发式从文本里猜 JSON;优先用约束解码,不合格就按失败处理,截断单独识别。
- 权威数据由应用查询,不接受模型提供的价格、库存与状态。
- 修复只允许改格式;对比修复前后,必须保持的字段变了就拒绝。
- 重试有上限,失败时返回明确的结果,并记录重试原因。
配套实验
- codesphere-labs/ai/structured-output-contract:Python 3.14.8、Pydantic 2.14.1:宽松与严格模式、语法层的常见形态、括号启发式、领域校验、修复改变含义、重试预算(验证记录)
参考资料