跳到主要内容

Schema 优先的 AI 开发:在编写提示词之前先定义输出契约

· 阅读需 11 分钟
Tian Pan
Software Engineer

大多数团队发现 Schema 问题的方式都是错误的:下游服务开始返回乱码,仪表盘充斥着垃圾数据,经过 20 分钟的调试才发现,LLM 在三周前就开始悄悄地将其 JSON 包装在 Markdown 代码块中。没人注意到,因为应用程序没有崩溃 —— 它只是在静默地消耗格式错误的数据。

修复方法只是修改了一行提示词。但造成的损失是数周的错误分析和一次非常尴尬的复盘。

Schema-first 开发是防止这种情况发生的准则。这意味着在你编写任何提示词 Token 之前,先定义 LLM 输出必须遵循的确切结构。这并不是为了限制创造力;而是将输出格式视为下游系统可以依赖的契约,就像你在编写消费者端代码之前会先对 REST API 进行版本化一样。

你已经在支付的 15% 隐形税

原始的 JSON 提示词(告知模型“返回一个包含这些字段的 JSON 对象”)在生产环境中的失败率在 15% 到 20% 之间。失败并不总是显而易见的。它们包括:

  • Markdown 包装:模型添加了 json 代码块标识,导致大多数 JSON 解析器拒绝。
  • 尾部逗号:语法上无效的 JSON,严格的解析器会捕捉到,而宽松的解析器则会静默地生成畸形数据。
  • 幻觉字段:模型添加了 Schema 中不需要的“有用”额外键值,破坏了强类型反序列化。
  • 字段重命名:根据模型的训练分布,user_id 变成了 userIdid
  • 解释性文本:在左大括号之前出现了诸如“这是 JSON:”之类的开场白。

每一次这类失败都会触发重试。重试会让你的 Token 消耗翻倍甚至翻三倍。在大规模应用下, 15% 的失败率不仅是可靠性问题,更是成本问题。

解决方案不是更好的提示词,而是基础设施层的 Schema 强制执行。

Schema-First 究竟意味着什么

Schema-first 开发意味着你在设计提示词之前,先用正式的 Schema 语言指定输出契约。Schema 驱动下游的一切:验证逻辑、反序列化模型、下游消费者和错误处理。

这种工作流程逆转了典型的顺序。大多数团队先写提示词,观察输出,然后修补解析代码来处理模型选择的任何格式。Schema-first 团队则相反:他们定义 Schema,从中生成提示词结构,并将 Schema 视为单一事实来源。

在实践中,这表现为在编写任何关于输出格式的系统提示词指令之前,先定义 Pydantic 模型 (Python)、Zod schema (TypeScript) 或 JSON Schema 对象。Schema 捕捉了应用程序实际需要的:特定字段名、确切类型、枚举约束、必填 vs 可选字段。然后,该 Schema 会被直接传递给推理 API 或在生成时强制执行它的验证库。

行为上的差异是巨大的。没有 Schema,模型决定格式。有了 Schema,模型的 Token 生成被限制为有效的 Schema 实例 —— 从结构上就不可能产生格式错误的输出。

Schema 强制执行的三个层级

Schema 强制执行存在于一个光谱上。了解针对哪种工作负载使用哪一层是大多数团队犯错的地方。

提示词级别的 Schema 定义是最弱的形式。你在系统提示词中描述 Schema,并依赖模型来遵循它。这就是导致 15–20% 失败率的原因。仅将其用于由人工审查输出的低风险、非自动化流水线。

API 级别的结构化输出是中间层。OpenAI 的 response_format(配合 strict: true)、Anthropic 的结构化输出以及 Google Gemini 的 response_schema 都在模型 API 级别强制执行 Schema 合规性。OpenAI 的内部测试显示,他们的结构化输出将 Schema 违规率从(早期模型处理复杂 Schema 时的)近 60% 降到了 0.1% 以下。这是大多数生产工作负载的正确默认选择。你将 JSON Schema 直接传递给 API,不合规的输出在到达你的应用程序之前就会被拒绝。

受限解码 (Constrained decoding) 是最深的一层,适用于你控制推理基础设施的情况。像 vLLM 的引导解码(由 XGrammar 后端驱动)、Outlines 和 HuggingFace TGI 的引导生成等工具,在生成过程中直接修改 Token 的概率分布 —— 在每一步中,会违反 Schema 的 Token 都会被完全屏蔽。模型无法产生无效输出;在词汇表级别这在结构上就是不可能的。XGrammar 是目前该领域的先进引擎,在 JSON Schema 上的运行开销几乎为零,比基于有限状态机 (FSM) 的原始方法快 100 倍,并且在实际基准测试中仅增加了约 0–2% 的生成延迟。对于云端 API 结构化输出失败率仍然过高的自托管工作负载,受限解码完美地填补了这一空白。

Schema 设计即推理架构

这是一个大多数团队都会忽略的深刻见解:Schema 的结构直接影响模型的推理质量,而不仅仅是输出格式。

LLM 从左到右生成 Token。Schema 中字段的顺序就是模型确定值的顺序。这意味着字段排序是你推理架构的一部分。

如果你先放 category(类别)再放 reasoning(推理),模型会先选择一个类别,然后为其寻找合理化的理由。如果你先放 reasoning 再放 category,模型会在确定分类之前先分析问题。在处理复杂任务时,第二种顺序的表现可靠地优于第一种 —— 你已经将思维链(Chain-of-thought)植入了 Schema 本身。

以下是一些具体的 Schema 设计规则,它们能比单纯的强制执行更有效地降低失败率:

保持嵌套浅层。 两到三层嵌套是实际的上限。更深的嵌套会增加每一层的错误率,并减慢语法编译速度。一个拥有 12 个字段的扁平化 Schema 通常优于一个具有相同逻辑结构但有三层嵌套的 Schema。

优先使用枚举(Enums)而非自由字符串。 受限的值集可以消除各种类型的幻觉。如果一个字段只能是 "low""medium""high",那么在 Schema 中使用枚举可以防止模型返回 "medium-high""MEDIUM"

将字段描述编写为隐式提示词。 JSON Schema 中的描述字段不仅仅是文档 —— 它是提示词上下文。“用户表达的情绪基调,非推断出的。仅使用消息中明确出现的内容。” 这种描述比埋藏在系统提示词中的相同指令更能可靠地塑造模型行为。

尽量减少可选字段。 每一个可选字段都是解析逻辑中的一个分支。除非你有充分的理由需要可选性,否则请将字段设为必填;在大多数反序列化管道中,显式的 null 比处理缺失字段要好。

验证-重试循环(以及何时不使用它)

即使使用 API 级别的结构化输出或受限解码,你仍然需要进行 Schema 一致性之外的验证。Schema 一致性保证了结构,但不能保证语义的正确性。模型可能会返回一个完美的 JSON 对象,但其中的 start_dateend_date 之后,或者在一个模型显然不该自信的问题上给出 0.99 的 confidence_score,又或者一个必填的引用 ID 指向了一个不存在的实体。

标准模式是:

  1. 使用 Schema 强制执行生成输出(通过 API 或受限解码)
  2. 根据你的应用层业务规则进行解析和验证
  3. 失败时,将错误消息注入上下文并重试 —— 明确告诉模型哪里出了错
  4. 在固定的重试预算(通常为 2-3 次)后,转入人工审核队列或抛出异常

Instructor 库已成为 Python 中该模式的事实标准(每月 300 万次以上下载),它能自动处理这个循环。你定义一个 Pydantic 模型,将其传递给 Instructor 的 client.chat.completions.create,该库就会负责 Schema 强制执行、解析、验证和重试。模型在每次重试时都会收到其失败的输出以及具体的验证错误,与盲目重试相比,这显著提高了纠错率。

这里关键的失败模式是预算耗尽。没有断路器的天真重试逻辑会在几毫秒内因病态输入而耗尽你的 Token 预算。请设置硬性的重试上限,添加带有抖动的指数退避,并将验证失败率作为监控栈中的领先指标 —— 验证失败率的上升可以在模型退化影响到用户投诉之前预警。

为什么团队会跳过这一步(并在以后为此付出代价)

对 Schema 优先开发最常见的反对意见是速度:预先定义 Schema 会减慢原型设计循环。这确实是真的,值得承认。一个能在三小时内返回混乱 JSON 的工作原型,确实比一个需要五小时才能符合 Schema 的版本开发得更快。

问题在于,团队在原型运行成功后很少会重新审视 Schema。混乱的 JSON 变成了生产格式。处理 Markdown 围栏和尾随逗号的解析代码逐渐演变成技术债务。每一次模型升级都有可能带来细微的格式偏移,而现有的补丁代码无法处理这些偏移。

Schema 优先带来的实际速度优势体现在第二周,而不是第一周。预先定义输出契约的团队会将迭代时间花在输出质量上 —— 模型是否提取了正确的信息?—— 而不是花在输出格式上 —— 模型这次返回的是有效的 JSON 吗?第一个问题是有意义的,而第二个问题纯属浪费。

Schema 优先开发的核心是将消费者驱动的契约设计(Consumer-driven contract design)应用于 LLM 接口。在 API 设计中,你在实现提供者之前先定义消费者所依赖的契约。对于 LLM 输出,你的解析代码、下游代理和数据库就是消费者。它们依赖于一个稳定的契约。请先定义它。

从哪里开始

如果你正在构建一个新的 LLM 功能,起点很简单:在编写系统提示词之前,先写好 Pydantic 模型或 Zod schema。你添加到 schema 中的每个字段,都是关于该功能实际需求的一个决策。这会强制产生一种清晰度,而“返回包含用户请求信息的 JSON”这类描述永远无法做到这一点。

对于使用朴素 JSON 提示词的现有功能,迁移路径如下:

  1. 审计当前的解析代码 —— 每一个变通方案都是你隐式模式中的一个字段
  2. 显式地将该模式正式化(JSON Schema、Pydantic、Zod)
  3. 启用带严格模式的 response_format,或者切换到 Instructor
  4. 删除解析相关的变通代码
  5. 在监控仪表盘中添加校验失败率指标

对于运行自托管推理的团队,请在 vLLM 或 TGI 中启用以 XGrammar 为后端的引导式解码(guided decoding)。其开销微乎其微,而失败率的降低则是彻底的。

更广泛的原则是分布式系统工程师多年前就学到的经验:将你的接口视为合约(contracts),而非约定(conventions)。约定会发生偏离,而合约不会。

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