Prompt 输出 Schema 设计:先定契约再让模型发挥

0阅读10分钟

没有契约的输出很难集成

我把一个分类任务的 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。把 evidencereasoning 放在 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 设计融入实际开发流程时,我通常按这个顺序走:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程有几个关键节点值得注意:

第一,从下游场景出发,不是从模型能力出发。先问「我的代码需要什么字段」,再问「模型能不能填这些字段」。

第二,约束层级不是越高越好。Prompt 约束最灵活,适合快速验证;约束解码最严格,但有「格式税」——SLOT 论文指出,过大或过深的 Schema 在约束解码下可能导致推理质量下降,甚至解码失败3。我的经验是:Schema 字段超过 15 个或嵌套超过 3 层时,优先考虑用 API 级 JSON Mode 而不是约束解码。

第三,95% 不是一刀切的阈值。分类任务可能要求 99% 以上,创意生成任务可能 80% 就够用。关键是根据你的业务容错度来定。

Schema 设计的核心对比

约束方式对比

维度Prompt 内嵌 SchemaAPI response_format约束解码
可靠性中,模型可能不完全遵守高,保证合法 JSON最高,token 级强制
灵活性最高,随时改 Prompt中,受 API 支持限制低,Schema 变更需重新配置
性能影响极小有,复杂 Schema 可能降低生成速度
平台依赖无,任何模型都能用依赖特定平台 API依赖推理框架(vLLM、Outlines)
Schema 复杂度上限低,深嵌套容易失败
适用阶段原型验证大多数生产场景金融、医疗等不容错场景

字段命名对比

坏命名好命名问题
resultextractedEntitiesresult 不说明内容
infocustomerProfileinfo 过于模糊
typesentimentCategorytype 在嵌套结构中容易歧义
dataanalysisReportdata 是最差的字段名之一
valconfidenceLevel不要缩写
flagrequiresHumanReviewflag 不说真假含义

字段命名的核心原则是「boring is better」。一个好的字段名应该让没看过 Schema 的开发者也能猜出它的含义和用途。

缺失值处理对比

策略表示方式下游判断适用场景
字段不存在delete result.field"field" in result可选的附加信息
null"field": nullresult.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 也能猜出含义
  • 枚举值覆盖所有合法选项,包含 unknownother 兜底值
  • 必填字段和可选字段的划分经过确认,不会让模型被迫填它不知道的字段

缺失与错误

  • 每个可能缺失的字段都有明确的 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

  1. Thoughtworks, "Structured Output from LLMs", Technology Radar, April 2026. 将 LLM 结构化输出提升到 Adopt 级别,认为它已成为构建可靠 AI 应用的默认策略。

  2. Collin Wilkins, "LLM Structured Outputs: Schema Validation for Real Pipelines", January 2026. 提出 Schema-first 设计方法论,强调「先定义数据结构,再教模型填数据」,以及 validate-repair-retry 循环。 2

  3. SLOT: Structuring the Output of Large Language Models, arXiv:2505.04016, 2025. 学术基准测试对比了 Prompt、JSON Mode 和约束解码在结构化输出任务上的表现,证明小模型 + 约束解码可以超越大模型 + Prompt。 2

评论 0

0 / 1000