跳到主要内容

Schema 问题:在生产环境中驯服 LLM 输出

· 阅读需 11 分钟
Tian Pan
Software Engineer

你上线了一个功能,使用 LLM 从用户文本中提取结构化数据。你进行了彻底的测试。它工作正常。三个月后,模型提供商悄悄更新了权重,在没有修改任何代码的情况下,你的下游流水线开始静默丢弃记录。没有抛出异常。没有触发报警。只是错误的数据在系统中流动。

这就是 Schema 问题。尽管结构化输出 API 已经改进了多年,它仍然是 LLM 驱动的系统中最少被讨论的故障模式之一。

令人不安的现实是,LLM 的输出契约默认是隐性的。当你要求模型“返回带有这些字段的 JSON”时,你不是在签署合同 —— 而是在提出一个请求,模型在不同运行、版本和提供商之间可能会不一致地履行该请求。GPT-4 在实际工作负载的复杂提取任务中表现出 11.97% 的无效响应率。这不是测试中的偶然现象;这是生产环境中的行为。而且故障模式会叠加:缺失字段、类型不匹配、幻觉属性和枚举违规不仅会导致解析错误 —— 当你的代码使用备选方案和默认值而不是硬性失败来处理它们时,它们还会悄悄地破坏你的数据。

为什么结构化输出看起来已经解决了,但实际上并没有

工具已经得到了显著改进。OpenAI 在 2024 年 8 月推出了带有严格模式(Strict Mode)的结构化输出(Structured Outputs),由语法受限解码(grammar-constrained decoding)支持,保证了在 token 级别的 Schema 合规性。Anthropic 随后也推出了类似的语法编译。两者都消除了最明显的故障 —— 畸形的 JSON。但合规的 JSON 并不等同于语义正确的数据。

考虑一个真实的输出契约需要什么:

  • 字段语义"status": "complete" 是指任务结束了,还是文档处理完了?
  • 可空性规则:哪些字段可以为 null,当它们为 null 时,下游代码应该假设什么?
  • 枚举边界:允许的值究竟是什么?当模型返回一个看起来合理但无效的变体(如用 "in-progress" 代替 "in_progress")时会发生什么?
  • 新鲜度预期:这些数据是按原样从文档中提取的,还是模型推断/总结出来的?

结构化输出 API 强制执行形状。它们对含义闭口不谈。而且,形状有效但语义错误的数据通常比解析错误更难捕捉 —— 它会绕过你所有的防护并进入数据库。

更难的问题是漂移。每当提供商更新模型权重、调整安全过滤器或更改解码参数时,你的输出分布就会发生偏移。你的 Schema 保持不变;但模型对其的解释却变了。一个之前返回简洁状态字符串的字段可能会开始返回解释性文字。一个模型以前能够可靠填充的可选字段可能会开始返回 null。在宽松的 Schema 下,这些都不会表现为校验失败。

Schema 故障的四种类型

在设计校验策略之前,准确命名这些故障模式会很有帮助:

结构故障 (Structural failures) 是最容易捕获的。缺失必填字段、错误类型、多余属性、畸形 JSON。这些会立即且响亮地破坏解析器。现代结构化输出 API 在生成层几乎消除了所有此类故障。

语义故障 (Semantic failures) 更难处理。JSON 是有效的。Schema 得到了满足。但值是错误的 —— 模型在推断意图而不是提取意图,或者返回了一个不在你许可列表中的、听起来合理的枚举值。这些需要评估逻辑,而不仅仅是 Schema 校验。

漂移故障 (Drift failures) 是最隐蔽的。你的校验通过了。你的测试通过了。但在数周和数月内,输出分布发生了偏移,而不会触发任何单个检查。平均置信度分数悄悄下降。可选字段为 null 的比例上升。原本总是 10-20 个字符的字段变成了 50-100 个。当你注意到时,存储中的数千条记录已经损坏。

跨版本故障 (Cross-version failures) 会在你升级模型或提供商时发生。一个在 gpt-4-turbo-2024-04-09 下提取完美的提示词,在下一个版本下开始产生细微差异的输出。Schema 是相同的,但模型对它的理解改变了。

构建分层校验栈

正确的应对方案是深度防御。每一层捕捉不同的故障模式;没有哪一层是万能的。

第 1 层:约束生成 (Constrained generation)。只要可用,就使用结构化输出 API。OpenAI 的严格模式、Anthropic 的语法编译,以及像 Outlines 和 XGrammar 这样的开源替代方案,在 token 生成期间强制执行 Schema 合规性 —— 在输出到达你的代码之前。这几乎完全消除了结构故障,且对于典型 Schema 而言,增加的延迟微乎其微。

第 2 层:带有自动重试的库级校验 (Library-level validation with automatic retry)。Instructor 库(每月下载量超过 300 万次)在任何 LLM 调用中包装了 Pydantic 模型,并实现了带有错误反馈的自动重试。当校验失败时,错误信息会被嵌入到下一个提示词中,给模型一个自我纠正的机会。在实践中,1-3 次重试即可解决绝大多数校验失败,无需人工干预。这是处理语义故障的正确模式:校验、描述错误、让模型修复。

在 Python 中使用 Pydantic:

import instructor
from pydantic import BaseModel, field_validator
from openai import OpenAI
from typing import Literal

class ExtractionResult(BaseModel):
status: Literal["approved", "rejected", "pending"]
confidence: float

@field_validator("confidence")
def confidence_range(cls, v):
assert 0.0 <= v <= 1.0, "confidence must be between 0 and 1"
return v

client = instructor.from_openai(OpenAI())
result = client.chat.completions.create(
model="gpt-4o",
response_model=ExtractionResult,
max_retries=3,
messages=[{"role": "user", "content": document_text}]
)

第 3 层:带有合理默认值的平滑降级 (Graceful degradation with sensible defaults)。并非每个字段的缺失都是灾难。在设计输出 Schema 时,明确哪些字段是任务关键型的(硬失败),哪些是信息型的(使用默认值)。对于关键字段,抛出异常并报警。对于信息型字段,记录缺失并使用默认值以保持流水线运行。关键在于在代码中明确做出这一决定,而不是让它通过 Python 的 dict.get() 以及未记录的备选方案偶然发生。

第 4 层:下游语义校验 (Downstream semantic validation)。解析后,验证提取的值在上下文中是否有意义。模型提取的日期是否早于文档日期?数值提取是否超出了任何合理的范围?这是 Schema 校验无法表达的特定于应用程序的逻辑,也是许多生产流水线存在漏洞的地方。

为输出契约(Output Contracts)制定版本管理

将 LLM 的输出 Schema 视为服务之间的 API:建立明确的版本管理、记录变更并设置弃用窗口。

最起码的做法是在每条提取的记录旁记录 schema_versionmodel_version。这听起来显而易见,但大多数团队并没有这样做。如果没有这些元数据,当 Schema 发生变化或模型表现出差异时,你将无法查询“哪些记录是基于旧契约生成的?”,也无法进行干净的回滚。

一套实用的版本管理原则:

  1. 为每个 Schema 标记版本标识符 —— 不是模型名称,而是你的 Schema 版本。模型名称会变;而你的 Schema 是独立演进的。
  2. 将字段新增视为次要变更(Minor changes);将删除和类型变更视为重大变更(Major changes)。 破坏性变更需要新版本、迁移路径,以及对旧版本的限时支持。
  3. 在升级之前,针对当前 Schema 测试新模型版本。 使用新模型进行影子部署(Shadow deployment),逐字段比较输出分布,在它们进入生产环境之前观察分布偏移。
  4. 在记录 Schema 版本的同时记录完整的 Prompt 版本。 Schema 的变更和 Prompt 的变更会产生相互影响;了解两者对于调试至关重要。

像 Langfuse 这样的工具让 Prompt 版本追踪变得非常简单 —— 每个 Prompt 都有一个版本 ID,输出会被标记上该 ID,你可以通过 Prompt 版本进行查询,以调查行为上的变化。

监控漂移(Drift)

Schema 的有效性检查只是针对某个时间点的。漂移则是一个时间序列模式。如果没有监控手段,你无法捕捉到它。

值得追踪的指标包括:

  • 每个模型/Prompt 版本的 Schema 有效率 —— 突然下降预示着模型更新或 Prompt 回归
  • 字段级完成率 —— 追踪包含每个可选字段的响应比例;完成率下降意味着模型对 Schema 的理解发生了变化
  • 字段值的分布 —— 尤其是对于枚举(Enums)和数值范围;观察是否有新值出现或分布发生偏移
  • 重试率 —— 如果验证重试次数在爬升,说明模型的第一遍输出可靠性正在下降
  • 解析器(Parser)错误率 —— 这是一个滞后指标,但对于捕捉灾难性故障非常有用

针对变化率进行报警,而不仅仅是绝对阈值。0.1% 的解析错误率孤立来看可能没问题,但在 24 小时内翻了一倍的解析错误率即使绝对值很小,也值得去调查。

Schema 严格程度的决策框架

并非每个 LLM 输出都需要相同的处理方式。正确的严格程度取决于下游的需求。

在以下情况使用严格 Schema 和约束生成(Constrained Generation):

  • 输出进入事务性系统(支付、数据库、带有契约的 API)
  • Schema 变更频率低且经过协同
  • 你可以容忍重试带来的延迟和成本
  • 正确性失效的代价高于可用性失效

在以下情况使用宽松解析和优雅降级:

  • Schema 正在积极演进中
  • 字段缺失或错误会降低质量,但不会破坏功能
  • 你处于产品早期迭代阶段,Schema 下周还会改动
  • 面向用户的输出在生效前会经过人工审核

使用混合模式(大多数生产系统): 严格生成 + 库校验 + 非关键字段的优雅降级 + 监控。目标是在关键事务上“大声报错(Fail loudly)”,在非关键事务上继续运行,并具备足够的观测能力来区分它们。

契约是你的责任

供应商的 API 在强制执行结构合规性方面已经变得非常出色。语法约束解码(Grammar-constrained decoding)是一项真正的进步。但它只解决了 Schema 问题中简单的部分。

难的部分 —— 语义正确性、漂移检测、Schema 版本管理、优雅降级 —— 需要你亲自构建。模型不知道你的业务规则。它不知道在你的领域中,"status": "done""status": "complete" 意味着不同的东西。它不会追踪上周二的输出分布是否与今天不同。当你切换供应商时,它也不会维持向后兼容性。

你的代码可以做到这一点。或者它做不到,最终你会在数据中察觉到问题。

能够规避这类问题的团队不会将 LLM 输出视为受信任的数据。他们将其视为需要验证、版本管理和监控的外部输入 —— 就像他们对待任何无法控制的第三方 API 那样。因为从功能上看,它确实就是这样。

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