Schema-first AI 输出:让模型结果能被系统消费
我做第一个 AI 功能的时候,Prompt 写了几十轮,GPT 返回的结果也越来越「像样」。上线第三天,下游的数据管道挂了两小时——模型偶尔把 confidence 字段返回成字符串 "high",而不是浮点数 0.92。代码里写着 if result.confidence < 0.5,字符串和数字比较在 TypeScript 里直接抛异常,整批数据全部进了死信队列。
这件事让我重新审视了整个开发顺序。之前的做法是先写 Prompt,跑通几个 case,看到输出「差不多对」就开始接下游。问题在于,「差不多对」和「系统能消费」之间有一条很大的鸿沟。模型输出的是自然语言的一种变体,而系统需要的是可解析、可校验、可路由的结构化数据。
Schema-first 的核心思路很简单:先定义下游系统能消费的契约,再反过来设计 Prompt 和校验逻辑。不是先问「模型能输出什么」,而是先问「系统需要什么」。
从文本解析到契约设计
传统的 LLM 集成方式是:发 Prompt,拿到一段文本,用正则或 JSON.parse 试着提取信息。这种方式在小规模原型里没问题,到了生产环境就会暴露三个根本性缺陷。
第一,格式不稳定。同一个 Prompt 在不同模型版本、不同 temperature、甚至同一个请求的不同运行里,输出格式都可能不同。今天返回 { "status": "ok" },明天可能返回 { "status": "success", "code": 200 }。下游代码永远在追赶模型的变化。
第二,失败路径不可见。模型可能拒答、可能信息不足、可能输出低置信度的猜测。如果没有在 schema 层面定义这些失败状态,下游只能靠猜——拿到一个空字符串算失败?拿到 null 算失败?拿到 "I'm not sure" 这样的自然语言又算失败?每种情况都需要不同的处理策略,但如果没有契约,这些策略全靠 if-else 堆砌,迟早漏掉某种边界情况。
第三,校验成本后置。等到数据进入数据库、进入前端展示、进入下游 API 调用时才发现问题,修复代价远高于在入口处拦截。
Collin Wilkins 在 2026 年初的一篇工程博客里把这个问题拆成了两层:语法正确性(输出是否是合法 JSON 且符合 schema 结构)和语义正确性(字段值是否在合理范围内、枚举是否合法、置信度是否在 0-1 之间)1。语法正确性可以通过约束解码(constrained decoding)在推理阶段保证;语义正确性仍然需要后处理校验。两层都做到位,才能说输出「能被系统消费」。
约束解码:从源头消除格式错误
约束解码是近几年 LLM 推理引擎最重要的工程进展之一。核心思路是在 token 采样阶段就施加 JSON Schema 约束——如果当前已生成的前缀不可能产出合法 JSON,对应的 token 就会被从采样空间中移除。这意味着语法错误在构造层面变得不可能。
OpenAI 在 2024 年 8 月发布了 Structured Outputs API,把约束解码做成了原生能力2。开发者只需要提供 JSON Schema(或通过 Pydantic / Zod 定义模型后自动生成 Schema),API 就能保证输出严格匹配。它的实现细节没有完全公开,但论文和工程社区的共识是,这类约束解码通常基于 grammar-based decoding——在每一步维护一个 JSON grammar 的状态机,只允许产生合法转移的 token。
有趣的是,约束解码在多数情况下反而比无约束生成更快。因为 schema 约束裁剪了搜索空间,模型不需要在「下一个 token 是 " 还是 } 还是 \n」这类决策上浪费算力。OpenAI 的文档也提到,首次使用某个 schema 时会有额外的编译延迟(需要构建 grammar),但后续请求会复用编译结果。
arXiv 上 2025 年 5 月发表的 SLOT 论文3把约束解码推向了另一个方向:用一个经过 SFT 训练的小型语言模型作为后处理层,配合 XGrammar 约束解码,将任意自由文本转换为结构化 JSON。Mistral-7B + SFT + XGrammar 的组合达到了 99.5% 的 schema 准确率和 94.0% 的内容相似度,超过了 Claude-3.5-Sonnet(分别高出 25 和 20 个百分点)。这个结论说明,结构化输出不一定依赖最大的模型——针对性训练加约束解码的组合,在小模型上也能达到生产可用的水平。
约束解码解决了语法层的问题,但它不保证语义。一个 schema 要求 confidence 是 0 到 1 之间的浮点数,约束解码能保证你拿到的是浮点数,但不保证模型不会返回 1.5 或 -0.3。这就是为什么后处理校验仍然不可或缺。
三个真实踩坑案例
案例一:只设计了成功路径
我在做一个内容分类功能时,schema 是这样定义的:
// ❌ 只描述了成功情况
const ClassificationSchema = {
type: 'object',
properties: {
category: { type: 'string', enum: ['tech', 'finance', 'health'] },
confidence: { type: 'number', minimum: 0, maximum: 1 },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['category', 'confidence', 'tags']
}上线后我观察到几种 schema 没覆盖的情况:文章涉及多个领域但 schema 只能选一个分类,模型返回了 category: null 表示「无法判断」;输入内容太短模型拒绝分类,返回了一段自然语言解释;模型对结果不确定,confidence 只有 0.12。
下游系统对这些情况毫无准备。null 分类导致路由崩溃,自然语言解释导致 JSON parse 失败,低置信度结果被当作高质量分类入了库。
修复方式是重新设计 schema,把所有失败状态显式编码进去:
// ✅ 成功和失败路径都在 schema 里
const ClassificationResult = z.object({
status: z.enum(['success', 'uncertain', 'insufficient_input', 'refused']),
// 只有 success 和 uncertain 时有分类结果
category: z.enum(['tech', 'finance', 'health']).nullable(),
confidence: z.number().min(0).max(1).nullable(),
tags: z.array(z.string()).default([]),
// 失败时给下游一个可操作的信号
reason: z.string().optional(),
// 是否需要人工复核
needsReview: z.boolean().default(false)
})这个改动看起来简单,但它改变了整个系统的错误处理方式。下游不再需要猜测「为什么这个字段是空的」,而是可以直接根据 status 路由:success 走自动入库,uncertain 走人工复核队列,insufficient_input 走追问流程,refused 记录安全日志。
案例二:字段改名引发的连锁故障
schema 在迭代中必然会变化。我犯过一个常见错误:把 tags 改名为 keywords,因为后者「更准确」。改完之后,本地测试全部通过——因为 Prompt 输出的字段名确实变了。但下游有三个消费者在读取 tags 字段:搜索索引服务、推荐系统、数据看板。它们全部在 schema 变更上线后开始丢失数据。
这次教训让我建立了一条规则:schema 一旦发布,就是 API。新增字段用可选字段,改名走版本迁移,删除字段先标 deprecated 再在下一个大版本移除。
// ❌ 直接改名,下游全部断裂
// v1: { tags: string[] }
// v2: { keywords: string[] } // tags 没了
// ✅ 渐进式迁移
// v2: { tags: string[], keywords: string[] } // 两个都有
// v3: { tags?: string[], keywords: string[] } // tags 标 deprecated
// v4: { keywords: string[] } // 大版本移除 tagsCollin Wilkins 的建议是在每条输出事件里记录 schema_version 和 prompt_version1。这样当下游发现数据异常时,可以回溯到具体的 schema 和 Prompt 版本来排查。这个做法成本很低,但在排查问题时价值巨大。
案例三:跨提供商的 schema 差异
同一个 JSON Schema,在不同模型提供商那里的行为可能完全不同。我在做模型切换评估时,用同一份 schema 测试了 OpenAI、Anthropic 和 Gemini,发现了几个令人意外的差异。
OpenAI 的 Structured Outputs 在 strict: true 模式下要求所有属性都出现在 required 数组里,不支持 required: false——可选字段必须用 type: ["string", "null"] 这种 union with null 的方式来表达。Anthropic 的约束更严格:不支持递归 schema、不支持 minimum / maximum 数值约束、不支持字符串长度限制,每次请求最多 20 个 strict tools。Gemini 的问题更隐蔽——不支持的 schema 关键字会被静默忽略,不报错、不警告,你定义了一个 minLength: 10,它直接当没看见,输出了 3 个字符的字符串1。
这些差异在本地开发时不会暴露,因为通常只测一个提供商。到了生产环境做 fallback 或 A/B 测试时才会炸。
// ❌ 假设所有提供商都支持相同的 JSON Schema 特性
const schema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
score: { type: 'number', minimum: 0, maximum: 1 }
}
}
// ✅ 取各提供商的交集,或在后处理中补充约束
const portableSchema = {
type: 'object',
properties: {
// minLength/maxLength 在约束解码阶段不跨提供商保证
// 放到 Zod/Pydantic 后处理里校验
name: { type: 'string' },
// minimum/maximum 同样,后处理兜底
score: { type: 'number' }
},
required: ['name', 'score'],
additionalProperties: false
}Schema-first 输出管道
把上述经验整合成一个完整的管道,从 schema 定义到输出消费,每个环节都有明确的职责:
这个管道的关键设计点:
Schema 是源头,不是附属品。 先写 schema,再写 prompt。prompt 的任务是引导模型产出符合 schema 的内容,而不是先让模型自由发挥再试着解析。
校验分两层。 约束解码保证语法正确(一定是合法 JSON、一定符合 schema 结构),后处理校验保证语义正确(枚举值合法、数值在范围内、字符串长度合规)。两层缺一不可。
失败路径和成功路径同等重要。 schema 里要显式编码失败状态(不确定、信息不足、拒答),管道里要有重试、降级、人工复核的分支。
每一次输出都是可追溯的事件。 记录 schema 版本、prompt 版本、模型 ID、校验结果、重试次数、耗时。这些数据在排查问题和迭代 prompt 时是唯一的真相来源。
方案对比
| 维度 | 先 Prompt 后解析 | Schema-first |
|---|---|---|
| 开发顺序 | 写 Prompt → 看输出 → 写解析逻辑 | 定义 Schema → 写 Prompt → 配置校验 |
| 格式保证 | 依赖模型自觉 + 重试 | 约束解码保证语法 |
| 失败处理 | if-else 猜空值、猜异常格式 | schema 里显式编码失败状态 |
| 跨模型迁移 | Prompt 微调即可,解析逻辑可能要大改 | Schema 不变,切换提供商只需适配约束差异 |
| 迭代成本 | 改 Prompt 后可能需要同步改解析代码 | 改 Schema 有版本迁移成本,但解析代码不需要动 |
| 适用阶段 | 原型验证、一次性脚本 | 生产系统、多人协作、长期维护 |
| 校验层 | 约束解码(constrained decoding) | 后处理校验(Zod/Pydantic) |
|---|---|---|
| 保证范围 | 语法正确:合法 JSON、字段齐全、类型匹配 | 语义正确:枚举值合法、数值范围、字符串模式 |
| 执行阶段 | 推理引擎内,token 采样时 | 推理完成后,应用代码中 |
| 性能影响 | 首次编译有延迟,后续请求通常更快 | 增加毫秒级解析开销 |
| 跨提供商一致性 | 各实现差异大(Gemini 静默忽略不支持的关键字) | 一致,用同一套 Zod/Pydantic 规则 |
| 能捕获的错误 | 缺字段、类型错、非法 JSON | 超范围数值、非法枚举值、不匹配的字符串模式 |
| 工具/库 | 语言 | 核心能力 | 适用场景 |
|---|---|---|---|
| OpenAI Structured Outputs | 跨语言 | 原生约束解码,strict: true 保证 schema 匹配 | 只用 OpenAI 模型的项目 |
| Instructor | Python/TS | 多提供商适配,自动路由到原生 API 或降级到 tool calling | 需要跨提供商统一接口 |
| Pydantic (Python) | Python | Schema 定义 + 运行时校验,model_json_schema() 自动生成 JSON Schema | Python 项目的后处理校验 |
| Zod (TypeScript) | TypeScript | Schema 定义 + 运行时校验,类型推导 | TypeScript/Node.js 项目 |
| Pydantic AI | Python | 专为 LLM 输出设计的 Pydantic 扩展,内置重试和校验 | Python 项目,需要 LLM 特化的校验流程 |
| vLLM + XGrammar | 自部署 | 本地推理引擎的约束解码,支持多种后端 | 自建推理基础设施 |
| 策略 | 描述 | 优点 | 风险 |
|---|---|---|---|
| 只靠 Prompt 约束格式 | 在 Prompt 里写「请返回 JSON」 | 零工程成本,灵活 | 格式不稳定,生产不可靠 |
JSON Mode(json_object) | 保证输出合法 JSON,不保证符合 schema | 简单,模型支持广 | 可能缺字段、多字段、类型错 |
| Structured Outputs | 约束解码保证严格匹配 schema | 语法零错误,无需重试 | 首次编译延迟,schema 限制较多 |
| 约束解码 + 后处理校验 | 推理端约束 + 应用端 Zod/Pydantic 校验 | 语法和语义两层保证 | 工程复杂度略高 |
| SFT 小模型 + 约束解码 | 训练专用小模型做格式转换 | 成本低、延迟低、准确率高 | 需要训练数据和基础设施 |
关键代码对比
1. Schema 设计:是否包含失败状态
// ❌ 只有成功路径,遇到异常下游只能猜
const ArticleSchema = z.object({
title: z.string(),
summary: z.string(),
category: z.string()
})
// 模型拒答时返回 "I cannot classify this article"
// 下游 JSON.parse 直接报错,没有兜底逻辑
// ✅ 显式编码所有可能的输出状态
const ArticleResult = z.object({
status: z.enum(['success', 'refused', 'insufficient_input']),
title: z.string().nullable(),
summary: z.string().nullable(),
category: z.string().nullable(),
// 失败时给下游可操作的解释
reason: z.string().optional(),
// 标记是否需要人工介入
needsReview: z.boolean().default(false)
})
// 下游直接 switch(result.status) 路由
// 每种状态有明确的处理策略,不需要猜差异在于:左边把异常处理的责任推给了下游的 try-catch 和启发式判断;右边把异常处理内化到了 schema 本身,下游的路由逻辑是确定性的。
2. 推理调用:是否启用约束解码
// ❌ 普通 chat completion,靠 Prompt 要求 JSON 格式
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '请返回 JSON 格式,包含 title 和 category 字段' },
{ role: 'user', content: articleText }
]
// 模型可能返回 markdown 包裹的 JSON
// 可能漏掉某个字段
// 可能在 JSON 后面加一段解释文字
})
const data = JSON.parse(response.choices[0].message.content)
// 以上每一步都可能出错
// ✅ 使用 Structured Outputs,约束解码保证格式
const completion = await openai.chat.completions.parse({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '按照 schema 提取文章信息' },
{ role: 'user', content: articleText }
],
response_format: ArticleResult, // 直接传入 Zod/Pydantic 模型
// 约束解码保证:
// 1. 输出一定是合法 JSON
// 2. 一定包含 schema 定义的所有字段
// 3. 字段类型一定匹配
})
const data = completion.choices[0].message.parsed
// 跳过 JSON.parse,直接拿到类型安全的对象差异在于:左边把格式保证完全交给了模型的「自觉性」,右边通过约束解码把格式保证从概率性的变成了确定性的。
3. 后处理校验:是否兜底语义错误
// ❌ 只相信约束解码,不做后处理
function processResult(data: ArticleResult) {
// 直接用,假设一切正确
await db.articles.create({
category: data.category,
confidence: data.confidence
})
// 问题:confidence 可能返回 1.5 或 -0.3
// 约束解码保证它是 number,但不保证它在 0-1 之间
// 因为某些提供商的约束解码不完全支持 minimum/maximum
}
// ✅ 约束解码 + Zod 后处理双重校验
function processResult(raw: unknown): ArticleResult {
const result = ArticleResult.safeParse(raw)
if (!result.success) {
// 语义校验失败,记录错误并进入重试或降级
logger.warn('Schema validation failed', {
errors: result.error.issues,
raw
})
throw new ValidationError(result.error.issues)
}
// 额外业务规则校验(超出 JSON Schema 表达能力)
if (result.data.status === 'success' && result.data.confidence! < 0.3) {
return { ...result.data, needsReview: true }
}
return result.data
}差异在于:左边的校验只有一层(约束解码),语法正确但语义可能有问题;右边有两层(约束解码 + Zod 校验 + 业务规则),每层各有分工。
4. 重试策略:是否有上限和错误反馈
// ❌ 无限重试,或者不重试
async function classify(text: string): Promise<ArticleResult> {
const result = await callLLM(text)
if (!isValid(result)) {
return classify(text) // 无限递归,没有退出条件
// 或者完全不重试,直接返回可能错误的结果
}
return result
}
// ✅ 有限重试,把错误信息反馈给模型
async function classify(text: string, maxRetries = 2): Promise<ArticleResult> {
let lastError: string | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const messages = buildMessages(text, lastError)
const result = await callLLM(messages)
const validation = ArticleResult.safeParse(result)
if (validation.success) {
return validation.data
}
// 把校验错误反馈给模型,让它修正
lastError = formatValidationErrors(validation.error.issues)
logger.info('Validation failed, retrying', {
attempt: attempt + 1,
errors: lastError
})
}
// 超过重试上限,走降级路径
return {
status: 'insufficient_input',
title: null,
summary: null,
category: null,
reason: `Failed after ${maxRetries} retries`,
needsReview: true
}
}差异在于:左边没有退出条件,要么无限循环要么完全放弃;右边有明确的重试上限,每次重试都把错误信息反馈给模型,超过上限走降级路径,保证调用方一定能拿到结果。
Prompt 围绕 Schema 来写
Schema 定义好之后,Prompt 的写法也会随之改变。核心原则是:Prompt 的任务是帮助模型理解 schema 中每个字段的含义和约束,而不是靠自然语言描述格式要求。
几个有效的实践:
字段描述用 schema 的 description 而非 Prompt 正文。 当使用 OpenAI 的 Structured Outputs 时,JSON Schema 的 description 会被模型直接看到。把字段含义写在 schema 里比写在 Prompt 里更不容易遗漏。
推理字段放在输出字段前面。 如果你需要模型先分析再给结论,schema 里要把 reasoning 字段放在 answer 前面。模型是按 schema 定义的字段顺序生成的,如果 answer 在前面,模型会在没充分分析的情况下就给出结论,后面的 reasoning 变成了事后合理化。
枚举值在 schema 里定义,不在 Prompt 里列举。 Prompt 里写「category 可以是 tech、finance 或 health」,模型不一定遵守。在 schema 里用 enum: ["tech", "finance", "health"],约束解码会直接阻止模型输出其他值。
// ❌ 格式要求全靠 Prompt 描述
const prompt = `
请分析以下文章并返回 JSON,格式如下:
{
"category": 分类,必须是 tech/finance/health 之一,
"confidence": 置信度,0到1之间,
"reasoning": 分析过程,
"tags": 标签列表
}
注意:必须返回合法 JSON,不要包含其他内容。
`
// 即使写了这么多要求,模型仍然可能输出 markdown 包裹的 JSON
// 或者把 confidence 写成 "high" 而不是数字
// ✅ 格式由 schema 保证,Prompt 只负责语义引导
const schema = z.object({
reasoning: z.string().describe('先分析文章内容和关键信号,再给出分类结论'),
category: z.enum(['tech', 'finance', 'health']).describe('文章的主分类,只能选一个'),
confidence: z.number().min(0).max(1).describe('分类置信度,低于 0.5 表示不确定'),
tags: z.array(z.string()).max(5).describe('最多 5 个关键词标签')
})
const prompt = '分析以下文章,按照 schema 提取分类信息。'
// 约束解码保证格式,Prompt 只引导语义Schema 兼容性管理
schema 被下游系统消费之后,它就具有了 API 的属性。改动 schema 和改动 API 端点一样,需要考虑兼容性。
我总结的兼容性规则:
| 变更类型 | 兼容性 | 处理方式 |
|---|---|---|
| 新增可选字段 | ✅ 向后兼容 | 直接加,旧消费者忽略新字段 |
| 新增枚举值 | ⚠️ 可能不兼容 | 旧消费者如果没有 default 分支会崩溃 |
| 改名字段名 | ❌ 破坏性变更 | 走版本迁移,新旧字段并存一段时间 |
| 删除字段 | ❌ 破坏性变更 | 先标 deprecated,下个版本再移除 |
| 修改字段类型 | ❌ 破坏性变更 | 新增字段替代旧字段,不要改原字段 |
| 把必填改为可选 | ⚠️ 可能不兼容 | 消费者如果假设字段一定存在会出问题 |
一个实用的做法是在输出里带上 schema_version。消费者可以根据版本号选择解析逻辑,迁移工具可以按版本做数据转换。这和在 API 响应里带 api-version header 是同一个思路。
生产环境检查清单
Schema 设计阶段
- ✅ 先写 schema,再写 prompt——不要反过来
- ✅ schema 中包含所有失败状态(refused、uncertain、insufficient_input),不只是成功路径
- ✅ 关键字段使用
Literal或enum类型,不使用自由字符串 - ✅ 需要路由或分支的字段使用确定性类型(枚举、布尔),不使用模型自由生成的字符串
- ✅ 推理字段(reasoning / explanation)放在结论字段前面
- ✅ 所有字段都有
description,写清楚含义、约束和示例值
校验与重试阶段
- ✅ 启用约束解码(Structured Outputs / strict mode),不要把格式正确性交给模型自觉
- ✅ 在约束解码之后加 Zod / Pydantic 后处理校验,兜底语义错误
- ✅ 重试有上限(建议 2-3 次),每次重试把校验错误反馈给模型
- ✅ 超过重试上限有降级路径(人工复核、默认值、缓存旧结果)
兼容性与运维阶段
- ✅ 输出事件里记录
schema_version、prompt_version、model_id - ✅ schema 变更遵循版本迁移规则:新增可选字段 > 新增枚举值 > 改名 > 删除
- ✅ 监控校验失败率的重试率——如果持续上升,说明 prompt 或 schema 需要调整
- ✅ 跨提供商部署时,schema 取各提供商约束解码支持的特性交集
- ✅ 安全拒答(refusal)作为独立状态处理,不和业务失败混在一起
参考资料
Footnotes
-
Collin Wilkins. LLM Structured Outputs: Schema Validation for Real Pipelines (2026). 2026. 覆盖 OpenAI / Claude / Gemini / vLLM 的 schema 校验实践和跨提供商差异。 ↩ ↩2 ↩3
-
OpenAI. Structured model outputs | OpenAI API. OpenAI 官方文档。详细介绍了 Structured Outputs 的 API 参数、SDK 集成、支持模型和限制。 ↩
-
Liu et al. SLOT: Structuring the Output of Large Language Models. arXiv, 2025. 提出了用小模型 + SFT + 约束解码实现高准确率结构化输出的方法。 ↩