结构化输出与自愈
让模型输出"一段 JSON"和让模型输出"符合约定的 JSON",是两件事。前者在演示里够用,在生产里迟早会出事——少一个字段、多一层嵌套、数字写成字符串,任何一条都能让下游崩掉。
结构化输出解决的是这个问题:把输出形状变成一份可校验的契约。
1. 为什么不能只靠提示词
在提示词里写"请输出 JSON,格式如下"是第一步,但它只是引导,不是保证。模型仍然可能:
- 在 JSON 前后加一段说明文字
- 把字段名写错(尤其是 camelCase 和 snake_case 混用)
- 输出合法 JSON 但不符合你的字段约定
- 输出被截断的 JSON(长度超了)
前三条靠约束解码能解决大部分,最后一条只能靠校验发现。所以完整的方案是两层:约束生成 + 强制校验。
2. 第一层:约束生成
各家都提供了严格模式:给一份 Schema,模型输出被约束在其中。
以 OpenAI 的 strict: true 为例,开启后 Schema 必须满足两条基本约束:
- 每个对象都要
additionalProperties: false - 所有字段都要进
required(想表达"可选",用["string", "null"]这种联合类型)
除此之外还有一组容易踩的硬限制:对象属性总数不超过 5000、嵌套不超过 10 层、所有属性名/定义名/枚举值的字符总数有上限、枚举值总数不超过 1000;递归要用 $ref/$defs 表达。约束类关键字(字符串的 minLength/pattern,数字的 minimum/maximum,数组的 minItems/maxItems)在现行普通模型上已经支持,可以直接写进 Schema 让模型遵守;但微调模型例外——这类约束在微调模型上不被支持,想让模型遵守长度、范围约束仍要靠校验层兜底。跨厂商复用 Schema 时,以最保守的那一家为准。
不满足的话请求会被直接拒绝,并给出缺失的约束说明——这是好事,问题暴露在定义阶段,而不是上线之后。
还要知道各家的"严格"机制并不等价:OpenAI 是约束解码(按 Schema 生成语法);Anthropic 走的是 strict tool use 那套 grammar 管线;Gemini 是 responseSchema + responseMimeType。同样一份 Schema 换一家可能被拒,也可能只是"尽力而为"。跨模型复用 Schema 时,以最严格的那家为准。
在 AI SDK 里,这件事被统一成 Output.object({ schema }),Schema 可以用 Zod 写:
import { generateText, Output } from 'ai'
import { z } from 'zod'
const ReceiptSchema = z.object({
merchant: z.string(),
amount: z.number(),
currency: z.enum(['CNY', 'USD', 'EUR']),
items: z.array(z.object({ name: z.string(), price: z.number() })),
note: z.string().nullable(), // 可选字段:联合类型 + null
})
const { output } = await generateText({
model: 'openai/gpt-5.1',
output: Output.object({ schema: ReceiptSchema }),
prompt: '从这段文字里提取票据信息:' + text,
})
Schema 同时用于约束生成和校验结果,一份定义两处生效。这也意味着 Schema 应该放在前后端共享的位置,避免两份定义各自漂移。
3. 第二层:自己再校验一遍
无论有没有开严格模式,都要在自己的代码里再校验一次。
理由不是"不信任模型",而是输出要经过网络、解析、可能的流式拼接——每一环都有出错的可能。把模型输出当成外部输入处理,这条假设在安全上是基本要求(和《工具调用的四段循环》里对工具参数的做法一致)。
const parsed = ReceiptSchema.safeParse(raw)
if (!parsed.success) {
// 不要直接抛给调用方
return { ok: false, reason: parsed.error.issues.map((i) => i.path.join('.') + ' ' + i.message) }
}
4. 校验失败怎么办:自愈一次
校验失败不一定意味着这次调用废了。常见做法是把失败原因回传给模型,让它改一次:
// lastError 要真的传进提示词,否则「自愈」这一步等于没做
async function extract(text, retries = 1, lastError) {
const r = await generateText({
model,
output: Output.object({ schema: Schema }),
prompt: buildPrompt(text, lastError),
})
const parsed = Schema.safeParse(r.output)
if (parsed.success) return parsed.data
if (retries > 0) {
return extract(text, retries - 1, formatIssues(parsed.error))
}
throw new Error('结构化输出校验失败:' + formatIssues(parsed.error))
}
三条纪律:
只允许一次。 连续两次失败,通常说明是 Schema 本身有问题(约束太复杂、字段语义不清),继续重试只是在烧 token。
回传的错误要具体。 "格式不对"没用;"items[2].price 应该是数字,收到的是字符串 '12 元'"才有用。
失败要能被看见。 记下失败率和常见的失败字段——失败率上升往往意味着上游输入分布变了,而不是模型突然变差。
5. 流式结构化:partial 的问题
结构化输出也能流式返回(字段逐个填充,界面边收边显示)。但它有一个必须处理的特点:中间状态是不完整的,因此不能按完整 Schema 校验。
const { object, submit, isLoading, stop } = useObject({
api: '/api/extract',
schema: ReceiptSchema,
})
// 渲染时每个字段都可能还没到,必须容错
return <div>{object?.merchant ?? '识别中…'}</div>
实践里有两条:
前端渲染一律用可选链。 这是流式结构化的固定成本,换来的是"不用等 30 秒才看到第一个字段"。
最终落地时再校验一次。 流中途不校验,但流结束拿到完整对象之后要校验——这才是要拿去用的那份数据。
6. Schema 本身怎么设计
几条实测有效的规则:
字段描述要写进 Schema。 .describe('金额,单位是元,不含货币符号') 这类说明会随 Schema 一起送给模型,对准确率的提升很明显。
用枚举而不是自由文本。 可枚举的字段一律 z.enum([...]),把"可能的输出"限定住。
别嵌套太深。 三层以上的嵌套,出错率明显上升。能拍平就拍平。
可选字段显式表达。 用 nullable 联合类型,不要靠"有时不出现"来隐式表达。
金额、日期这类用字符串还是数字要定死。 混用是下游解析错误最常见的来源。
7. Schema 会演进,要按"契约"来管
Schema 一旦上线就不是一次性的了。下游在用它、提示词在引用它、评测集在断言它——改一个字段名的影响面和改一个接口的影响面是一样的。
三条纪律:
加字段容易,改语义难。 新增可选字段通常是安全的;把 amount 从"元"改成"分"这种语义变更是破坏性的,必须走新版本而不是就地改描述。
版本要随请求一起记录。 日志里带上 schema 版本,否则事后无法把"某个字段开始填错"和"Schema 改过"关联起来。这一点和《灰度与 A/B 怎么分》里要求的提示词、模型、检索配置版本化是同一套做法。
Schema 改动要走灰度。 描述文字变了,模型的填写行为也会变,而这类变化在离线评测集上经常看不出来。小流量放出去,盯校验失败率和关键字段的准确率。
还有一处耦合:Schema 进入系统提示或工具定义时,它参与前缀哈希。改一次 Schema 等于给所有请求重新预热一次提示缓存,成本影响在高频场景里很实在(见《提示缓存怎么才命中》)。
8. 结构化输出和采样参数要一起设
抽取、分类这类用结构化输出的场景,通常同时要求结果稳定。这时温度取 0(或很低)+ 结构化输出是标准组合:前者压掉采样方差,后者保证形状。
但要注意,结构化输出保证的是形状合法,不是内容正确,也不是每次一样。字段类型对了、必填项齐了,值仍然可能每次不同。所以:
- 形状断言用代码做,这一步是确定的
- 值的稳定性要跑多次看分布,不能跑一次通过就认为稳定
- 需要可复现的场景(对账、审计、回归测试)还要额外固定模型版本和随机种子——为什么这些步骤一个都不能省,见《输出为什么每次不一样》
9. 什么时候不该用结构化输出
两种场景硬用会适得其反:
要的是创造性长文本。 让模型写一段分析或一封邮件,套进 Schema 只会让它写得僵化,还多花 token。
输出结构本来就不固定。 比如"根据内容自行组织要点",强行固定字段会让模型为了填满字段而编内容——这正好是《RAG 评测指标怎么选》里要防的那类问题。
用在这两类之外的场景——信息抽取、分类、表单填充、接口返回规范化——结构化输出几乎是必选项。它把"下游要处理的意外情况"从几十种降到一种:校验失败。