Skip to content

模型输出的结构契约:语法、Schema 与领域规则是三道检查 ​

让模型从客服对话里提取订单参数,输出 JSON,用 Pydantic 解析后直接下单。测试时一切正常。上线后出现了一张「购买 -2 件」的订单,还有几张订单的价格是模型自己写的,和商品目录对不上。

把模型的输出交给程序处理时,常见的做法是在提示里要求「只输出 JSON」,再用一个数据模型解析。这能挡住一部分问题,但「能被解析」和「可以执行」之间至少隔着三层检查:文本是不是合法的 JSON,JSON 是不是符合 Schema,符合 Schema 的值在业务上是否成立。每一层挡住的东西不同,而且解析库的默认行为比想象中宽松。

本文用 Pydantic 2.14.1 和一组写死的「模型输出」样本,逐层验证。实验见文末。

模型输出语法层合法的 JSON 对象Schema 层strict、extra=forbid、范围领域层目录价、商品存在可执行代码块、前置说明截断、返回数组"3"、3.0、true额外字段、qty=-2价格 1 分未知商品 Z999每一层挡住的错误不同;宽松模式会把下面这些转换或丢弃,而不是拒绝修复后的输出三层都通过sku 却从 B200 变成 A100:对比应保持的字段后拒绝重试预算2 次用完返回明确失败,3 次时第 3 次成功
图 1 · 实测(Python 3.14.8、Pydantic 2.14.1):语法层挡住代码块、前置说明、截断与数组;严格模式的 Schema 层挡住字符串数字、true、额外字段与负数,默认宽松模式会放过它们;领域层挡住 Schema 合法的错误价格与未知商品。修复是又一次生成,三层都通过却可能换了商品;重试要有上限。

一、Pydantic 默认会替你转换类型 ​

python
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 不认识的意图,而程序完全不知道。

更严格的配置:

python
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: int

strict 拒绝类型转换,extra="forbid" 拒绝未声明的字段,Field 加上取值范围。开头那张 -2 件的订单,在这里就会被拒绝。

二、语法层:模型常见的几种「差一点」 ​

text
包在代码块里:JSON 语法错误:Expecting value(第 0 个字符)
前面有一句话:JSON 语法错误:Expecting value(第 0 个字符)
输出被截断:JSON 语法错误:Unterminated string starting at(第 26 个字符)
返回了数组:不是 JSON 对象,而是 list

模型经常把 JSON 包在 Markdown 代码块里、在前面加一句「好的」,或者因为输出长度上限被截断。常见的补救是用启发式提取:

text
「取第一个左括号到第一个右括号」的启发式,从带嵌套说明的文本里取到:'{"note": "见 {附件}'

启发式在简单情况下有效,一遇到嵌套或说明文字里的括号就取错,而且取错的结果可能恰好也能解析。更可靠的做法是让模型提供方用结构化输出(按 JSON Schema 约束解码)保证语法,或者要求输出只包含一个 JSON 对象、不合格就按失败处理,而不是猜测哪一段是想要的内容。截断要单独识别:它说明输出预算不够,重试同一个请求通常还会截断。

三、领域层:结构正确不等于业务正确 ​

text
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 + 领域规则 + 必要时向用户确认
权威数据(价格、库存、订单状态)应用查询不接受模型提供的值
动作(下单、退款)应用执行权限、幂等与确认,见工具调用循环

四、「修复」可能改变含义 ​

解析失败时,一个常见的做法是把错误信息和原输出交回模型,让它「修复」:

text
原始输出:拒绝:qty: int_type
修复后的输出:通过:{"sku":"A100","qty":2,"price_cents":1999};领域校验 通过
对比修复前后应保持不变的字段:['sku'] 被改动,拒绝这次修复

原始输出里商品是 B200,数量写成了「两件」;修复后数量对了,商品却变成了 A100,三层检查全部通过。修复是又一次生成,它没有义务保持原意。修复只应该被允许改变出错的部分:对比修复前后,规定哪些字段必须保持不变,变了就拒绝;更好的做法是让修复只处理格式(例如把「两件」规范化成 2),不处理含义。

五、重试要有预算 ​

text
最多尝试 2 次:语法错误 → Schema:qty: greater_than → 放弃,返回明确的失败
最多尝试 3 次:语法错误 → Schema:qty: greater_than → 成功

每次重试都是一次额外的调用和延迟。设定最多尝试几次,达到上限就返回一个明确的失败(让用户补充信息、转人工),而不是无限重试或者退回到宽松的解析。重试的次数和失败的类型都应该记录下来:如果某类请求总是要重试,问题在提示或 Schema 的设计上,而不是运气。

六、检查清单 ​

  • 解析模型输出时用严格模式:禁止类型转换与额外字段,给数值加范围。
  • 不用启发式从文本里猜 JSON;优先用约束解码,不合格就按失败处理,截断单独识别。
  • 权威数据由应用查询,不接受模型提供的价格、库存与状态。
  • 修复只允许改格式;对比修复前后,必须保持的字段变了就拒绝。
  • 重试有上限,失败时返回明确的结果,并记录重试原因。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权