跳到主要内容

语义验证层:为什么 JSON Schema 不足以应对生产环境中的 LLM 输出

· 阅读需 12 分钟
Tian Pan
Software Engineer

到 2025 年,每家主流 LLM 服务商都已推出结构化输出的受约束解码功能。OpenAI、Anthropic、Gemini、Mistral——它们都允许你向模型传入一个 JSON Schema,并保证返回结果在结构上完整无误。各个团队纷纷采用这一功能,长舒一口气:解析错误消失了,重试循环缩短了,监控面板一片绿色。

然后,微妙的故障开始出现。

一个情感分类器在两周内对每个输入——包括乱码——都锁定在 0.99 的置信度,无人察觉。一个信贷风险智能体返回了合法的 JSON,批准了一笔本应被拒绝的贷款申请,风险分数高出了五十分。一条金融数据管道将 "$500,000"(字符串,技术上符合 Schema)强制转换为整数字段中的零,破坏了六周的风险计算数据。这些故障全部通过了 Schema 验证。

教训是:结构有效性是必要条件,但并不充分。你需要一个语义验证层,而大多数团队并没有这一层。

结构与语义的鸿沟

受约束解码的工作原理是将你的 JSON Schema 编译成一个有限状态机,在生成时屏蔽无效的 token。模型在字面意义上无法生成违反 Schema 的输出。这是真正的工程成就,消除了整整一类故障——那种在凌晨三点以 JSONDecodeError 形式出现的错误。

但它无法消除语义上的错误。一个类型为 number、范围为 [0, 100] 的字段,其值始终会是 0 到 100 之间的数字。然而这个数字仍可能以任何类型检查器都无法检测到的方式出现错误:置信度分数冻结在 0.99,风险分数反映的是错误的风险画像,年龄限制服务中年龄字段包含 3。输出在形式上符合合约,只是其含义并不符合预期。

基准测试数据使这一点更加具体。对生产环境 LLM API 调用的研究发现,语义参数错误——结构有效但值违反业务语义——在前沿模型中高达 16.83%,在其他模型中超过 27%。这些故障在结构层面是不可见的。没有 Schema 验证器能捕获它们,这也意味着不会触发自动重试,它们在无声无息中不断积累。

还有一个额外的麻烦:受约束解码会带来"格式税"。最新基准测试显示,通过受约束解码强制输出 JSON 平均会使推理质量下降 3-9 个百分点,在难度较高的数学基准测试中甚至下降高达 12.7 个百分点。当你强制模型在语法约束内生成 token 时,它同时需要做到推理正确并满足 Schema 要求。这两个目标并不总是一致的,结构正确性可能以语义正确性为代价。

语义故障的分类

在构建防御措施之前,先给各种故障模式命名会有所帮助。

必填字段中的自信幻觉。 当 Schema 要求某个字段必须存在且非空时,一个没有底层知识的模型会编造一个看似合理的值,而不是表达不确定性。从形状上看,这个输出与正确输出无法区分。这是一起广为人知的法律事故中的故障模式——虚构的法庭案例引用被格式化得与真实引用一模一样。

分布冻结。 分类器或评分系统返回有效值,但这些值停止了变化。置信度分数在每个输入上都是 0.99,包括乱码。上游模型更新后,情感分类器把所有内容都标记为正面。Schema 验证通过;而分布监控本可发现这一问题。

跨字段逻辑不可能。 结束日期早于开始日期。items_count 字段报告 47,而 items 数组只有 1 个条目。入职日期晚于离职日期。0.01 美元订单上的运费却是 500 美元。每个字段单独看都是有效的,但它们的组合在语义上不连贯。

枚举漂移。 当你的枚举定义 "ORG" 时,模型返回 "ORGANIZATION"。当 Schema 要求 "Swedish" 时,返回 "swedish"。返回一个不在允许列表中的合理类别同义词。这些故障可能很微妙,开发者只在下游字符串比较开始返回意外结果时才会注意到。

貌似合理的空值。 一个感觉必填的可选字段返回 null 或空字符串。应用程序静默地使用默认值。数据不断积累,存在一个没有任何监控浮出水面的缺口,直到数周后依赖该字段的查询返回错误结果。

类型强制掩盖。 Pydantic 在默认模式下会静默地将 "4" 强制转换为 4。约束 Field(gt=0) 在强制转换前无法捕获作为字符串传入的 "0"。类型系统给你一种虚假的安全感,因为验证器从未以原始形式看到病理性的值。

双层架构

在生产环境中经得起考验的解决方案如下所示:

第一层:结构验证。 这是受约束解码和 JSON Schema 提供的内容。字段名称、类型、必填存在性、token 级别的枚举成员资格。这里的目标是格式合规——使输出可解析且结构可预测。对于开放权重模型,使用 OpenAI Structured Outputs、Outlines 或 Guidance;在应用层使用 Pydantic 类型提示或 Zod 进行强制执行。

第二层:语义验证。 这是大多数团队跳过的内容。值范围、跨字段一致性、时间顺序、领域合理性、针对实时数据的引用完整性、随时间推移的分布监控。这一层在结构验证之后、输出到达业务逻辑之前运行。运行成本低廉,能捕获危害最大的故障。

实际实现取决于你的技术栈。在 Python 中,Pydantic 的 @field_validator 处理单字段检查——置信度分数必须在 [0, 1] 范围内,年龄必须对用例合理。@model_validator(mode='after') 装饰器获取完全填充的模型,支持跨字段断言:如果 end_datestart_date 都存在,则要求 end_date >= start_date。在 TypeScript 中,Zod 的 .refine() 应用带有可读错误消息的单一规则,而 .superRefine() 提供对完整对象的访问,用于复杂的跨字段逻辑。

Instructor 库专门为 LLM 管道扩展了这一模式。当 Pydantic 验证器抛出 ValidationError 时,Instructor 将错误消息作为修正上下文发送回模型并自动重试。这个重试循环受 max_retries 约束,在验证通过时停止。对于无法用代码表达的语义规则——"这个摘要是否真实反映了源文档?"——Instructor 的 llm_validator 运行对较小模型的子调用。

验证级联按成本从低到高运行:结构 Schema 验证,然后基于代码的语义规则,最后针对需要上下文推理的规则使用 LLM 作为判断者。每一层只在前一层通过后才触发。这使成本与复杂性成正比,并保持热路径的快速响应。

构建此层时遇到的故障模式

修复级联问题。 当你将验证错误发送回模型进行修正时,模型倾向于"修复"被标记的字段,同时无意中更改了之前正确的字段。如果你的重试提示说"risk_score 字段无效",模型可能在修正 risk_score 的同时更改 recommendation——而后者之前是正确的。明确的措辞很重要:"仅修正被标记的字段;不要修改任何其他字段。"

服务商 Schema 不匹配。 OpenAI Structured Outputs 静默地拒绝某些 Pydantic 约束注解(ge=0, le=100),并将它们移至描述文本中。它们不再在生成时强制执行——而是变成了提示。通过 @field_validator 在应用层的强制执行成为实际的执行层。这是依赖服务商端验证会给你带来虚假覆盖感的地方。

分布冻结检测。 基于代码的验证器检查单个响应。除非你还监控滚动窗口内的字段值分布,否则无法捕获一个始终返回单一值的分类器。如果评分字段的方差降至接近零,将其视为监控警报,而不是验证通过。实现方式是在结构化输出日志上运行一个小型聚合作业——每小时或每天计算评分字段的标准差,并设置警报阈值。

跨服务商枚举同义词不匹配。 模型可能使用了语义正确但与你的枚举不完全匹配的术语。在严格枚举检查触发之前,使用模糊匹配的 BeforeValidator——将模型的输出与带有阈值的枚举值进行比较——可以对这些进行规范化。当模糊匹配低于阈值时,回退到 "OTHER""UNKNOWN" 类别,而不是硬错误。

受约束解码质量权衡。 对于复杂推理任务——风险评估、摘要质量、多步骤分析——格式税可能大到值得关注。考虑两轮方法:先自由形式生成,然后在第二轮使用受约束解码重新格式化输出。基准测试显示,与单轮受约束生成相比,这可以恢复大约 6-9 个百分点的准确性,代价是额外的延迟和 token 消耗。

构建语义层:实践优先级

在向现有系统添加语义验证时,按优先级排序的最高杠杆添加项:

首先添加跨字段一致性检查。 时间顺序、必须与数组长度匹配的计数、必须相对彼此有序的值。这些实现成本低廉,能捕获结构验证在架构上无法捕获的故障模式。

在添加更多规则之前监控评分和分类分布。 从第一天起就部署带有字段级分布跟踪的结构化输出日志。分布冻结故障——评分字段停止变化——没有分布数据是不可见的。捕获它不需要任何模型更改或提示工程;它需要一个聚合查询。

使可选字段显式化并强制有意义的填充。 不要从下游代码推断必要性,而是使用 @model_validator 断言,当某个条件成立时,一组字段中至少有一个必须被填充。在代码中记录规则,而不是注释中。

选择性地应用语义验证器。 并非每个字段都需要语义约束。优先考虑下游业务逻辑用于决策的字段。信贷决策中的 recommendation 字段、门控人工审查的 confidence 字段、路由到不同管道的 category 字段——这些值得设置语义规则。仅出现在 UI 显示中的 description 字段则不需要。

对于 LLM 作为判断者的验证器,积极缓存并并行运行。 用于语义验证的子 LLM 调用会使触发它的路径的推理成本翻倍。基于内容哈希的缓存在具有重复输入的工作负载中可将此成本降低 60-70%。基于 ThreadPoolExecutor 的并行验证在多个字段需要独立 LLM 判断时保持延迟平稳。

这对系统设计意味着什么

添加语义验证层会迫使一些上游选择。Schema 设计成为安全态势的一部分,而不仅仅是接口契约。需要语义验证的字段是模型可能合理地产生有效但错误值的字段——这是一个信号,表明该字段可能承担了过多职责,或者上游提示的预期语义说明不足。有时正确的修复不是验证器,而是使预期语义明确的提示更改。

重试架构很重要。指令遵循重试循环——将验证错误发送回模型——对于直接的修正效果良好,具有良好的实证成功率。但它们在复杂的多字段约束上可能失败,并会增加延迟和成本。对于语义正确性至关重要的高风险输出,在验证边界处进行人工审查仍然是比自动修复更好的答案。

结构验证解决了解析问题。语义验证层解决了含义问题。在生产环境中,两者都不是可选的,但它们需要不同的工具、不同的运行时位置和不同的监控。将受约束解码之后的结构化输出视为已解决问题的团队,会在损害已经深入管道六周之后才发现第二个问题。

形状是对的。值不是。这才是剩余的工作。

References:Let's stay in touch and Follow me for more thoughts and updates