大模型返回了 JSON,为什么程序还是不敢直接用?

1 -> 引言
让模型从一张单据中提取金额,最初的实现往往很简单:在提示词末尾加一句“只返回 JSON,不要解释”,然后调用 json.loads。演示时看起来没有问题,接入真实数据后,错误才逐渐出现。模型可能在 JSON 外面包一层代码围栏,可能把金额写成字符串,也可能非常规整地返回一个错误数字。
前两类错误会让程序明显失败,最后一类反而更难发现。程序能够解析,字段名称也正确,下游却使用了一个没有证据支持的值。结构化输出的价值,正是在于减少其中一部分不确定性;它的边界,则是不能把所有正确性问题都压缩成“是不是合法 JSON”。
假设原单据金额是 100 元,模型返回 120 元。无论 JSON 格式多整齐、字段定义多严格,只要 120 仍在允许范围内,前两道检查都可能放行。金额正确与否,得回到单据本身核对。下面就围绕这张单据,拆开看各层检查的职责。
2 -> 同一个金额,可能在不同层出错
JSON 语法关心括号、引号、逗号以及基本数据类型。{"amount":120} 可以是合法 JSON,{"amount":"120"} 也可以。语法解析器不会替我们判断金额应该用整数分、十进制字符串还是其他表示方式,更不会知道原始单据上写的是不是 120。
Schema 描述的是双方约定的数据形状。例如要求存在 amount、currency 和 source_id,金额必须是非负整数,币种只能从允许集合中选择。它适合表达类型、枚举、长度、嵌套对象等约束,使错误尽量在进入业务代码之前暴露。
JSON Schema 中,写在 properties 里的字段不自动成为必填字段,必填性由 required 指定;不允许额外字段时,也需要相应约束。null 与缺少字段是两种状态,不能因为业务代码都把它们叫作“空”,就默认协议层含义相同。JSON Schema 对象规范说明
事实校验又是另一回事。一个金额类型正确、范围正常,但与原始记录不一致,Schema 通常无法自己发现。它需要可靠的外部依据,例如受信任的数据库记录、可定位的原文、经过确认的计算结果。即使给 Schema 加上许多业务限制,也不能凭空得到源材料中没有的信息。

| 失败类型 | 常见表现 | 合适的处理位置 |
|---|---|---|
| 语法失败 | 缺少引号、截断、夹带说明 | 严格解析与传输完整性检查 |
| 契约失败 | 缺少字段、类型错误、出现未知字段 | Schema 验证 |
| 语义失败 | 金额抄错、对象关联错误 | 证据与业务规则验证 |
| 权限失败 | 数据合法,但当前调用者无权执行 | 身份、授权与动作审批 |
最后一行尤其容易被漏掉。一个 JSON 请求符合格式,并不意味着它可以获得操作权限。模型生成“删除记录”的结构,与系统允许删除记录,是完全不同的决定。工具调用也应遵循这个区分,不能把模型输出的整洁程度当成授权依据。
3 -> 约束解码把一部分检查提前到了生成过程中
在提示词里强调格式,是希望模型按照要求回答;约束解码则是在选择下一个 token 时,限制哪些候选可以继续构成合法结果。前者依赖模型遵从要求,后者把部分规则落实到了生成机制中。
可以把它理解为一个随输出前缀移动的语法状态。已经生成对象的开头之后,系统知道接下来允许出现哪些结构;字段类型被限定为数字时,也会排除不符合当前约束的续写。真实实现必须处理分词器:一个 token 可能覆盖多个字符,不能简单把“下一个 token”理解成“下一个标点”。
这种方法能显著减少可由形式规则界定的错误,但有几个条件不能省略。生成引擎需要支持相应约束;传入的 Schema 必须在引擎支持范围内;输出还必须完整结束。服务返回拒绝、网络中断、达到输出上限,都应该成为应用显式处理的状态,而不是拼接几个括号之后假装已经拿到完整答案。
约束也可能改变生成路径。原本自然语言中可以表达“不知道”的位置,如果被设计成一个必填数字,模型就可能被迫选出一个形式合法的数。解决办法不是认为约束解码失败了,而是重新设计协议:允许缺失证据时进入 unknown 或待复核状态,并让下游理解这种状态。
结构越复杂,也不必然越安全。一个巨大 Schema 混合很多可选分支,可能增加维护难度,使错误更难定位。更稳妥的思路往往是按任务拆分契约:单据提取只产出提取结果,业务判定单独进行,动作执行继续使用独立的授权接口。
举个更具体的协议设计问题:来源里没写交货日期,应该返回空字符串、零日期、null,还是省略字段?没有一种答案天然正确,关键是上下游必须约定一致。空字符串有时会被当成已填写,默认日期又可能被后续程序当作真实承诺。与其在事后猜测,不如明确区分“已找到”“来源未提供”“存在冲突”这些状态,并让状态与字段值之间的关系接受校验。
引用证据也一样。一个 source_id 字段只证明数据里放了一个字符串;它是否属于当前用户可以访问的材料、是否指向真实存在的段落,还需要系统核对。更好的流程是由应用先提供允许引用的来源范围,模型在范围内提出候选,应用再验证对应关系。不要允许模型临时编出一个路径,程序便据此读取任意文件。
2026 年的 StructureBench 将结构合法性和语义正确性分开评估。它在所测试的小模型与任务中发现,约束解码可以保证结构层面的要求,却不能稳定地带来语义准确率提升。这个结论的价值不在于否定结构化输出,而在于提醒我们不要把两项指标合成一个“成功率”。StructureBench 论文
4 -> 用一段代码看清验证边界
下面的例子不连接任何模型,而是把几种可能的模型返回值送入同一条验证流程。这样更容易观察每一层到底拦住了什么。依赖只有 Python 标准库和 jsonschema,可通过 python -m pip install jsonschema 安装后运行。
为了避免二进制浮点数直接参与金额比较,示例使用整数分。来源记录是代码中预先给定的受信任样本,不是让模型自己再生成一份“证据”。在实际系统中,应由数据访问层取得相应记录,并检查访问权限。
import json
from jsonschema import Draft202012Validator
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"amount_cents": {"type": "integer", "minimum": 0},
"currency": {"enum": ["CNY", "USD"]},
"source_id": {"type": "string", "minLength": 1},
},
"required": ["amount_cents", "currency", "source_id"],
"additionalProperties": False,
}
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
sources = {"receipt-01": {"amount_cents": 10000, "currency": "CNY"}}
def reject_constant(value):
raise ValueError("Non-standard JSON constant: " + value)
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate key: " + key)
result[key] = value
return result
def inspect(raw):
try:
data = json.loads(raw, parse_constant=reject_constant,
object_pairs_hook=unique_object)
except ValueError:
return "parse_error"
if not validator.is_valid(data):
return "schema_error"
source = sources.get(data["source_id"])
if source is None:
return "unknown_source"
if any(data[key] != source[key] for key in ("amount_cents", "currency")):
return "evidence_mismatch"
return "accepted"
cases = [
('{"amount_cents":10000,"currency":"CNY","source_id":"receipt-01"}',
"accepted"),
('{"amount_cents":12000,"currency":"CNY","source_id":"receipt-01"}',
"evidence_mismatch"),
('{"amount_cents":10000,"currency":"USD","source_id":"receipt-01"}',
"evidence_mismatch"),
('{"amount_cents":10000,"currency":"CNY","source_id":"missing"}',
"unknown_source"),
('{"amount_cents":"10000","currency":"CNY","source_id":"receipt-01"}',
"schema_error"),
('{"amount_cents":null,"currency":"CNY","source_id":"receipt-01"}',
"schema_error"),
('{"amount_cents":10000,"amount_cents":12000}', "parse_error"),
('{"amount_cents":', "parse_error"),
]
for raw, expected in cases:
actual = inspect(raw)
assert actual == expected, (actual, expected)
print(actual)
示例还主动拒绝了重复键和非标准数值常量。原因不是“所有解析器都会这样做”,而是实际解析库可能有宽松行为,协议边界应由应用明确决定。相同字段出现两次时,静默采用最后一个值,可能隐藏上游错误。若不同服务的解析策略又不一致,问题会更加难查。
这里使用成熟的 Schema 验证库,而不是手写一串类型判断。库本身也有需要了解的行为,例如 format 是否启用实际检查,要看验证器配置。date-time 这样的标记不能只写进 Schema 就想当然地认为所有实现都会验证。jsonschema 验证文档
八个测试里,12000 分被证据层拦下,字符串 "10000" 和 null 则在 Schema 层停止。金额不变、只把币种换成 USD,依然会出现证据冲突;把来源换成一个不存在的 ID,会得到单独的 unknown_source。这些区别决定了下一步怎么处理,不只是让错误消息更详细。
这份 Schema 有意把金额限定为必填整数,所以 null 无法通过。若业务允许“单据没写金额”,就要扩展协议,而不是让解析器偷偷把 null 转成 0。零金额是一个明确的业务值,缺少金额是一种信息状态;把两者合并,可能让需要人工复核的单据被自动当作零元单据处理。示例覆盖的是契约检查,并没有执行任何支付或其他业务动作。
5 -> 系统接入时,失败路径比正常样本更值得设计
测试结构化输出时,正常样本往往最容易准备,边界样本却决定系统是否可靠。原文没有金额时该返回什么?同时有含税价和未税价时选择哪个?识别结果出现两个币种时,是保留两项候选,还是触发复核?这些都是任务定义,不是给模型多加一句“认真一点”能解决的问题。
重试也需要分类。网络中断可以有限重试;Schema 不符合约定可以带明确错误反馈重新生成;证据冲突则不能只反复请求直到得到一个通过验证的数字。否则重试机制会逐渐变成“寻找验证器喜欢的答案”,而不是解决事实问题。
对于不确定数据,建议保留来源位置和原始片段的引用,而不是只保存模型给出的最终字段。来源必须能够回到可信原文,不能把模型随口填写的页码当作证据。必要时把“模型候选值”和“已确认值”分开存储,避免未经确认的推断进入事实表。
流式输出同样需要谨慎。界面可以展示逐渐形成的对象,但业务动作应等待完整响应及最终验证。一个前缀当前看起来合法,不代表后续不会改变其含义,更不代表已经满足必填约束。解析中间片段与接受最终结果,应该是两个不同事件。
协议还会随着业务变化。新增一个字段,看起来不影响旧逻辑,但旧消费者如果严格禁止未知字段,就会拒绝新结果;旧版模型若仍然返回旧结构,新消费者也可能失败。因此,应给契约保留版本,明确兼容策略,并把几个真实的旧版响应加入回归测试。模型接口与普通软件接口一样,也需要管理变化。
上线前可以建立一份很小但固定的失败样本集:截断对象、重复键、未知枚举、缺少证据、来源冲突、超长数组,以及模型明确拒绝的响应。每次更换模型、生成引擎或 Schema 都运行它。相比临时询问几道正常问题,这种测试更容易发现集成层的回归,也能避免把所有失败都归咎于模型能力。
监控也应沿用这些分类。解析失败突然增加,先查截断、传输和生成配置;Schema 失败增加,先查协议版本;格式全过但证据冲突增加,再检查原文识别和字段含义。把它们都合成“调用成功率”,会让排查绕回原点。
对于这张单据,最终可接受的状态不只有“提取成功”,还可以是“来源缺失”或“需要复核”。应用把这几个出口设计清楚以后,结构化输出才能成为稳定接口:正确结果继续流转,有问题的结果留下依据,而不是为了凑齐一个对象,把所有空白都填成看似可信的值。
更多推荐

所有评论(0)