Prompt 输出 Schema 设计:先定契约再让模型发挥
没有契约的输出很难集成
我把一个分类任务的 Prompt 交给模型,它在回复里写了「这段文本偏负面」,下游代码却找不到「负面」这个值——模型写的是「Negative sentiment with some uncertainty」,而我期望的是一个枚举字段 "sentiment": "negative"。
这就是没有输出契约时常见的场面。模型擅长自然语言,但应用系统需要稳定的字段名、固定的枚举值和明确的条件分支。事后用正则去「捞」字段,或者在代码里做各种兜底判断,本质上都是在为缺失的契约补账。
生产场景下,先设计输出 Schema,再写提示词,比事后修正格式稳定得多。Thoughtworks 在 2026 年 4 月的 Technology Radar 中已经将「Structured Output from LLMs」提升到 Adopt 级别,理由很直接:它把不可预测的文本转成了确定性的数据契约1。
契约式设计背后的原理
什么是「输出 Schema」
输出 Schema 是模型返回数据的结构定义——字段名、类型、是否必填、允许值范围、缺失时的表示方式。它和你写 API 响应体时定义的 JSON Schema 是同一个概念,只不过这里的「服务端」变成了 LLM。
核心思路来自一条工程经验:接口的消费方决定接口形状。你的下游代码需要一个 confidence 字段做阈值判断,Schema 里就得有 confidence;你的数据库需要一个 category 枚举字段做索引,Prompt 就必须约束模型从这个枚举中选。
为什么不能事后修格式
事后修格式有三层成本:
| 成本类型 | 具体表现 | 典型后果 |
|---|---|---|
| 解析成本 | 用正则、字符串分割处理自由文本 | 边界 case 频繁崩溃 |
| 防御成本 | 下游到处写 if field exists 兜底 | 代码膨胀、逻辑分散 |
| 修复成本 | 线上发现格式不对再补补丁 | 反复发版、数据不一致 |
这三层成本叠加起来,远比在设计阶段花两小时定义 Schema 要高。Collin Wilkins 在 2026 年初的一篇分析中把这个过程类比为 API 设计——「先定义数据结构,再教模型往里填」,而不是「让模型随便输出,再想办法适配」2。
Schema 约束的三个层级
不是所有约束手段都一样强。我按执行时机把 Schema 约束分成三层:
| 约束层级 | 执行时机 | 代表工具 | 适用场景 |
|---|---|---|---|
| Prompt 约束 | 模型生成前 | 在 System Prompt 中嵌入 Schema 定义 | 快速原型、不支持原生约束的平台 |
| JSON Mode | 模型生成后 | OpenAI response_format: { type: "json_object" } | 需要合法 JSON 但不需要严格字段校验 |
| 约束解码 | 模型生成中 | Outlines、XGrammar、vLLM structured output | 字段必须严格匹配 Schema,不容许格式错误 |
arXiv 上 2025 年发表的 SLOT 论文给出了一个有意思的对比数据:直接用 Prompt 让 Claude Sonnet 3.5 输出结构化 JSON,Schema 准确率平均 74.7%;用 7B 模型配合 XGrammar 做约束解码,准确率提升到 99.5%3。这说明约束机制的选择直接决定输出可靠性,模型大小反而不是决定性因素。
三个场景的 Schema 设计实践
场景一:文本分类任务
分类任务的核心约束是枚举。模型不能自由发挥——它只能从预定义的值中选。
// ❌ 没有约束的分类输出
// 模型返回: "这段文本表达了负面情绪,但不是很确定"
// 下游解析: 正则匹配? 关键词提取? 每次都可能出新花样
// ✅ 定义 Schema 约束
const classificationSchema = {
type: "object",
properties: {
category: {
type: "string",
enum: ["positive", "negative", "neutral", "mixed"]
},
confidence: {
type: "string",
enum: ["high", "medium", "low"]
},
reason: {
type: "string",
description: "一句话说明分类依据,不超过 30 字"
}
},
required: ["category", "confidence"]
}这里有两个设计决策值得展开说:
第一,confidence 用枚举而不是浮点数。让模型输出 0.87 这种数值看似精确,实际上模型对数字的「自我认知」非常不可靠——它会给出看起来合理但没有校准过的值。用 high/medium/low 三档反而更诚实,下游也更容易做阈值判断。
第二,reason 是可选字段。不是所有分类都需要解释,但当 confidence 是 low 时,下游可能需要看 reason 来决定是否转人工审核。Schema 里通过 required 控制这一点。
场景二:信息抽取任务
信息抽取最怕的是「字段缺失」。用户问「帮我从这段合同里提取甲方、乙方和签约日期」,但合同里可能根本没有签约日期。Schema 必须处理这种情况。
// ❌ 缺失字段处理不当
// 模型可能返回: {"partyA": "某某公司", "partyB": ""}
// 空字符串? null? 还是直接不返回这个字段? 三种情况下游代码都得处理
// ✅ 显式定义缺失语义
const extractionSchema = {
type: "object",
properties: {
partyA: {
type: ["string", "null"],
description: "甲方名称,未找到时为 null"
},
partyB: {
type: ["string", "null"],
description: "乙方名称,未找到时为 null"
},
signingDate: {
type: ["string", "null"],
description: "签约日期,格式 YYYY-MM-DD,未找到时为 null"
},
missingFields: {
type: "array",
items: { type: "string" },
description: "原文中未找到的字段列表"
}
},
required: ["partyA", "partyB", "signingDate", "missingFields"]
}关键区别在于:null 和「字段不存在」是两种不同的语义。我选择让字段始终存在、值为 null 表示缺失,这样下游代码只需要 if (result.partyA === null) 一个判断路径,不需要同时处理 undefined、字段不存在、空字符串三种情况。
missingFields 数组是一个辅助字段。它让下游能一眼看出哪些信息没提取到,而不需要逐个字段检查 null。对批量处理场景特别有用——你可以直接按 missingFields.length 排序,优先处理缺失最多的记录。
场景三:多步生成任务
当任务需要模型先推理再给结论时,Schema 设计需要考虑字段顺序。SLOT 论文和多项实践都指向同一个结论:让模型先输出推理过程,再输出最终结论。
// ❌ 结论在前,推理在后
// 模型先写了 conclusion,再补 reasoning
// 结果:模型「过早承诺」了一个答案,推理只是在为已有结论找理由
// ✅ 推理在前,结论在后
const analysisSchema = {
type: "object",
properties: {
evidence: {
type: "array",
items: {
type: "object",
properties: {
quote: { type: "string" },
source: { type: "string" }
}
},
description: "先列出从原文中提取的证据"
},
reasoning: {
type: "string",
description: "基于证据的推理过程"
},
conclusion: {
type: "string",
description: "最终结论,放在最后"
}
},
required: ["evidence", "reasoning", "conclusion"]
}这不是格式偏好,而是利用了 Transformer 自回归生成的特性——前面的 token 会影响后面的 token。把 evidence 和 reasoning 放在 conclusion 前面,模型在生成结论时已经「看过」自己列出的证据和推理,结论质量会明显提升。Collin Wilkins 把这个原则总结为「Order fields logically — reasoning before conclusion」2。
错误状态也要写进 Schema
多数人在设计输出 Schema 时只考虑「正常情况」——分类对了、信息提取到了、分析完成了。但生产系统里,错误路径往往比正常路径更复杂。
不要在 Prompt 里写「无法回答时说明原因」就结束了。这种自然语言指示有两个问题:第一,模型不一定遵守;第二,下游代码没法结构化地判断「模型是不是在说它无法回答」。
我的做法是把错误状态显式编码进 Schema:
const robustSchema = {
type: "object",
properties: {
ok: {
type: "boolean",
description: "任务是否成功完成"
},
data: {
type: ["object", "null"],
description: "成功时的结果数据,失败时为 null"
},
error: {
type: ["object", "null"],
properties: {
code: {
type: "string",
enum: ["insufficient_input", "ambiguous_input", "out_of_scope", "model_uncertain"]
},
message: { type: "string" },
missingFields: {
type: "array",
items: { type: "string" }
}
},
description: "失败时的结构化错误信息"
},
confidence: {
type: "string",
enum: ["high", "medium", "low"]
}
},
required: ["ok", "error", "confidence"]
}这个设计的好处是下游处理逻辑可以非常清晰:
function handleResult(result: SchemaOutput) {
if (!result.ok) {
switch (result.error?.code) {
case "insufficient_input":
return askUserForMoreInfo(result.error.missingFields)
case "ambiguous_input":
return askUserToClarify(result.error.message)
case "out_of_scope":
return fallbackToRuleEngine()
case "model_uncertain":
return queueForHumanReview()
}
}
return processSuccess(result.data, result.confidence)
}每种错误码对应一个明确的下游动作。没有结构化错误时,这些判断只能靠解析自然语言——而自然语言解析本身就是不可靠的。
从 Prompt 到落地的完整流程
把 Schema 设计融入实际开发流程时,我通常按这个顺序走:
这个流程有几个关键节点值得注意:
第一,从下游场景出发,不是从模型能力出发。先问「我的代码需要什么字段」,再问「模型能不能填这些字段」。
第二,约束层级不是越高越好。Prompt 约束最灵活,适合快速验证;约束解码最严格,但有「格式税」——SLOT 论文指出,过大或过深的 Schema 在约束解码下可能导致推理质量下降,甚至解码失败3。我的经验是:Schema 字段超过 15 个或嵌套超过 3 层时,优先考虑用 API 级 JSON Mode 而不是约束解码。
第三,95% 不是一刀切的阈值。分类任务可能要求 99% 以上,创意生成任务可能 80% 就够用。关键是根据你的业务容错度来定。
Schema 设计的核心对比
约束方式对比
| 维度 | Prompt 内嵌 Schema | API response_format | 约束解码 |
|---|---|---|---|
| 可靠性 | 中,模型可能不完全遵守 | 高,保证合法 JSON | 最高,token 级强制 |
| 灵活性 | 最高,随时改 Prompt | 中,受 API 支持限制 | 低,Schema 变更需重新配置 |
| 性能影响 | 无 | 极小 | 有,复杂 Schema 可能降低生成速度 |
| 平台依赖 | 无,任何模型都能用 | 依赖特定平台 API | 依赖推理框架(vLLM、Outlines) |
| Schema 复杂度上限 | 高 | 中 | 低,深嵌套容易失败 |
| 适用阶段 | 原型验证 | 大多数生产场景 | 金融、医疗等不容错场景 |
字段命名对比
| 坏命名 | 好命名 | 问题 |
|---|---|---|
result | extractedEntities | result 不说明内容 |
info | customerProfile | info 过于模糊 |
type | sentimentCategory | type 在嵌套结构中容易歧义 |
data | analysisReport | data 是最差的字段名之一 |
val | confidenceLevel | 不要缩写 |
flag | requiresHumanReview | flag 不说真假含义 |
字段命名的核心原则是「boring is better」。一个好的字段名应该让没看过 Schema 的开发者也能猜出它的含义和用途。
缺失值处理对比
| 策略 | 表示方式 | 下游判断 | 适用场景 |
|---|---|---|---|
| 字段不存在 | delete result.field | "field" in result | 可选的附加信息 |
null | "field": null | result.field === null | 明确「没有找到」 |
| 空字符串 | "field": "" | result.field === "" | 不推荐,和「空值」难区分 |
| 默认值 | "field": "unknown" | result.field === "unknown" | 枚举型字段,有明确的「未知」选项 |
| 空数组 | "field": [] | result.field.length === 0 | 列表型字段,「没有匹配项」 |
我倾向的策略是:标量字段用 null,列表字段用空数组,枚举字段在 enum 中加入 "unknown" 选项。这三种策略覆盖了绝大多数场景,而且下游判断逻辑统一。
错误处理模式对比
| 模式 | 结构 | 优点 | 缺点 |
|---|---|---|---|
| 布尔 + 数据 | { ok, data, error } | 判断简单 | error 结构需要额外约定 |
| Result/Either | { success: { value }, failure: { code, message } } | 类型安全,互斥 | 嵌套深一层 |
| 状态枚举 | { status: "ok" | "error" | "partial" } | 支持部分成功 | 需要为每种 status 定义字段 |
| HTTP 风格 | { statusCode, body, headers } | 和 API 网关对齐 | 过度工程化 |
对于大多数 Prompt 输出场景,我推荐第一种「布尔 + 数据」模式。它够简单,模型容易理解,下游处理也直接。只有在你需要严格类型安全(比如用 TypeScript 的 discriminated union)时,才需要上第二种模式。
版本兼容:Schema 变了的怎么办
Schema 一旦被客户代码、任务队列或数据库消费,变更就变成了接口变更。这和传统 API 版本管理是同一个问题。
几条我实际执行过的规则:
新增字段永远比改名安全。下游代码遇到没见过的字段会忽略它,但如果字段突然消失或改名,下游就会崩溃。新增字段时,在 Schema 的 description 里注明「Added in v1.2」方便追踪。
枚举值扩展要同步下游。新增一个枚举值之前,先确认所有消费这个字段的代码都能处理新值。否则模型返回一个新枚举值时,下游的 switch-case 会掉进 default 分支。
废弃字段不要立即删除。先标记为 deprecated,保留至少一个版本周期,确认没有下游在使用后再移除。
Schema 版本和 Prompt 版本同步更新。我的做法是在代码仓库里把 Schema 定义和对应的 Prompt 模板放在一起,同一个 PR 里更新。这样 review 的时候能同时看到两边的变化,降低不一致的风险。
生产上线前的检查清单
我把这些检查项整理成了一个清单,每次有新的 Prompt 输出 Schema 要上线时都会过一遍:
结构设计
- 每个字段都有明确的下游消费方——没有「以防万一」加的字段
- 字段名具有自解释性,不需要看 description 也能猜出含义
- 枚举值覆盖所有合法选项,包含
unknown或other兜底值 - 必填字段和可选字段的划分经过确认,不会让模型被迫填它不知道的字段
缺失与错误
- 每个可能缺失的字段都有明确的 null 语义定义
- 错误状态编码进了 Schema,不只是「请在失败时说明原因」
- 错误码有枚举约束,不是自由文本
- 下游对每种错误码都有对应的处理逻辑
可靠性
- 用至少 20 个测试用例跑过 Schema 校验通过率
- 通过率在约束层级下达到业务要求(通常 95% 以上)
- 推理字段在结论字段之前输出
- Schema 字段数量不超过 15 个,嵌套不超过 3 层
兼容与运维
- Schema 变更走 code review,不是只改 Prompt 文档
- 新 Schema 和旧 Schema 的 diff 已经检查过向后兼容性
- 上线后配置了字段级失败率监控
- Schema 和 Prompt 模板在同一目录下版本管理
常见陷阱与应对
陷阱一:Schema 太贪心。一次想提取 30 个字段,模型要么漏填,要么乱填。拆解成多个小任务,每个任务的 Schema 控制在 5-8 个字段内,然后用代码合并结果。
陷阱二:用约束解码处理复杂 Schema。约束解码在 Schema 复杂度过高时会失败或严重拖慢生成速度。复杂 Schema 优先用 API 级 JSON Mode + 后校验,把约束解码留给关键字段。
陷阱三:忽略 retry 机制。再好的 Schema 设计也不能保证 100% 通过。生产系统必须有 retry 逻辑:校验失败时把错误信息喂回模型,让它修正。通常 2-3 次 retry 就够了,超过 3 次说明 Schema 或 Prompt 本身有问题。
陷阱四:过度依赖模型自觉。「请严格按照以下格式输出」这种指令在模型能力不够时形同虚设。可靠的约束来自工具链,不来自 Prompt 里的措辞。
陷阱五:监控盲区。只看总体通过率不够。某个字段通过率 99%,另一个只有 70%,总体看起来还行但那个 70% 的字段可能已经在制造脏数据。按字段粒度监控失败率是必要的。
参考资料
Footnotes
-
Thoughtworks, "Structured Output from LLMs", Technology Radar, April 2026. 将 LLM 结构化输出提升到 Adopt 级别,认为它已成为构建可靠 AI 应用的默认策略。 ↩
-
Collin Wilkins, "LLM Structured Outputs: Schema Validation for Real Pipelines", January 2026. 提出 Schema-first 设计方法论,强调「先定义数据结构,再教模型填数据」,以及 validate-repair-retry 循环。 ↩ ↩2
-
SLOT: Structuring the Output of Large Language Models, arXiv:2505.04016, 2025. 学术基准测试对比了 Prompt、JSON Mode 和约束解码在结构化输出任务上的表现,证明小模型 + 约束解码可以超越大模型 + Prompt。 ↩ ↩2