别再 JSON.parse 裸奔:AI 结构化输出的校验与修复实战
prompt 是建议,不是约束。AI 结构化输出可靠性的四层防线:模型原生结构化输出(OpenAI vs Anthropic,官方文档核实)、zod 校验的严格/宽松取舍、把报错原文喂回的修复重试、按成本选的降级兜底。

本文只讲一件事:让 AI 的结构化输出稳定变成可用 JSON 的工程链路。先划清边界:
- vs 《AI 评估指南》:那篇讲"怎么评测模型输出质量";本文讲"怎么让输出稳定变成可用 JSON",是生产可靠性,不是评测方法。
- vs 《AI 流式输出 UX》:那篇讲打字机效果等展示层;本文讲输出内容的结构可靠性,不讲展示。
- vs 《Agent 失败模式》:那篇是失败模式全景;本文只深挖"格式非法"这一种失败的工程解法。
场景你一定遇到过:你让 agent 调大模型做"从用户评论里提取结构化信息",prompt 写得明明白白"只返回 JSON",上线第一周好好的,某天凌晨三点,JSON.parse 炸了——模型在 JSON 前面加了一句"好的,这是提取结果:"。你的服务 500 了,用户在骂,而你只想问:我明明说了只返回 JSON,为什么它不听?
答案是:prompt 是建议,不是约束。想要结构可靠,得建四层防线。
四层防线总览
| 层 | 拦什么 | 成本 |
|---|---|---|
| 1. 模型原生结构化输出 | JSON 语法错误、缺字段、枚举瞎编 | 低(调参数) |
| 2. schema 校验(zod) | 语义错误:类型对但值离谱、必填缺失 | 低(写 schema) |
| 3. 修复重试 | 偶发的格式/语义错误 | 中(多一次调用) |
| 4. 降级兜底 | 连续失败 | 按业务定 |
记住这个顺序:每层只拦自己该拦的,不越俎代庖。第一层保证"长得像 JSON",第二层保证"是对的 JSON",第三层处理"偶尔抽风",第四层处理"彻底摆烂"。很多项目的bug在于只做了第一层(甚至只做了"prompt 里写只返回 JSON"),然后对着凌晨三点的 500 干瞪眼。
第一层:模型原生结构化输出(以及它保证不了什么)
2026 年的主流模型都有原生结构化输出,但用法各异(OpenAI 口径经官方文档亲核;Anthropic 口径经多份 2026 年技术文档交叉核实,2026-10-17):
- OpenAI:Responses API 里
text.format = { type: "json_schema", strict: true, schema },或用client.responses.parse()直接传 zod/pydantic schema。注意官方文档明确写了:JSON mode 只保证"输出合法 JSON",只有 Structured Outputs 才保证"符合 schema"。而且官方也承认:遇到安全拒绝或 max_tokens 截断时,照样可能返回不符合 schema 的内容——结构化输出不是银弹,这是官方自己说的。 - Anthropic:
messages.create()的output_config: { format: { type: "json_schema", schema } }(旧的output_format参数已废弃)。注意新模型(Fable 5.1 起)移除了强制工具调用tool_choice: {type: "tool"},想拿 JSON 请走 structured outputs 或给 tool 加strict: true。
第一层能保证的是语法层面:括号配对、引号闭合、枚举值在范围内。它保证不了的是语义层面:"price": "很贵"(字符串但你期望数字)、"date": "明天"(合法字符串但下游要 ISO 日期)。语义问题必须第二层来。
第二层:zod 校验——宽松还是严格,是个取舍
import { z } from "zod";
const ReviewExtraction = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
// 金额:模型可能返回 "99.9" 字符串,也可能返回 99.9 数字
amount: z.coerce.number().min(0),
// 日期:先收字符串,再用 refine 做语义校验
date: z.string().refine((s) => !isNaN(Date.parse(s)), {
message: "date 必须是可解析的日期",
}),
tags: z.array(z.string()).max(5).default([]),
});
// 关键取舍:.strict() vs .passthrough()
// .strict():多余字段直接报错——适合你完全掌控 schema 的场景
// .passthrough():多余字段保留透传——适合模型偶尔"加戏"但你不想炸的场景
const StrictExtraction = ReviewExtraction.strict();
两个实战经验:
- 用
z.coerce对付模型的"类型随意":模型把数字写成字符串是家常便饭,z.coerce.number()比z.number()的生产存活率高一个数量级。但 coerce 不是万能——"很贵"coerce 出来是NaN,照样报错,这正是你想要的:让离谱值在校验层死掉,而不是流到下游。 - 错误信息是第三层的弹药:zod 的报错信息(如
Expected number, received string at "amount")不要只记日志,要原样喂给重试 prompt。模型看到自己错在哪,修复成功率远高于只看到"格式错误,重试"。
第三层:修复重试——把报错原文拼进 prompt
async function extractWithRepair(input: string, maxRetries = 2) {
let lastError = "";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const raw = await callModel(buildPrompt(input, lastError));
const parsed = ReviewExtraction.safeParse(tryParseJson(raw));
if (parsed.success) return parsed.data;
// 关键:把 zod 报错原文喂回去,而不是只说"错了重试"
lastError = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
// 成本熔断:重试预算
if (attempt === maxRetries) break;
}
return fallback(input); // 第四层
}
function tryParseJson(raw: string) {
// 先处理"前面加了一句话"的经典 case:提取第一个 { 到最后一个 }
const m = raw.match(/\{[\s\S]*\}/);
return JSON.parse(m ? m[0] : raw); // 仍可能抛,由 safeParse 接住
}
重试设计的三个要点:
- 重试次数上限 2 次:经验值。第一次失败后带报错重试,成功率显著回升;第二次还失败,基本是 prompt 或 schema 本身有问题,再试就是烧钱。把
maxRetries写成配置,别写死。 - 每次重试都要换"信息",不换"运气":无脑重试同一 prompt 是烧钱。有效的重试必须带上新的信息——zod 报错原文、失败的原始输出、甚至"上次你错在 amount 字段"这样的指征。
- 重试预算进可观测:记录"修复重试触发率"。如果某天这个指标从 2% 飙到 20%,说明上游模型行为变了(换了版本、改了默认参数),要去看而不是加重试次数。
第四层:降级兜底——连续失败后怎么办
三层都拦不住时(模型持续抽风、上游 API 故障),必须有个"体面认输"的方案。按业务容忍度三选一:
| 降级方式 | 做法 | 适用场景 |
|---|---|---|
| 转人工 | 把原始输入推进人工审核队列,UI 显示"处理中" | 金融、医疗等高风险场景;错了比慢了更贵 |
| 默认值 | 返回 schema 的安全默认值(如 sentiment: "neutral"、tags: []) | 推荐、打标等低风险场景;错了可接受 |
| 跳过 | 整条流水线跳过该样本,记日志后续补 | 批量处理场景;单条失败不影响整体 |
选型的唯一标准:"错了"和"没有",哪个更贵。 turning 人工贵在人力,默认值贵在错误扩散,跳过贵在数据缺失。把这笔账算清楚,降级方案就不会拍脑袋。以及:降级触发本身要告警——降级是安全气囊,不是日常座椅,弹出来一次就要有人看一眼。
可观测:失败率指标与失败样本留存
四层防线建完,还差最后一块:你怎么知道它在工作?两个指标、一个习惯:
- 结构化失败率:
zod 校验失败次数 / 总调用次数,按天看趋势。这是整个链路的健康度仪表盘,突增意味着上游变了。 - 修复重试触发率:
触发重试的次数 / 总调用次数。健康值通常 <5%;持续走高说明第一层(模型原生结构化输出)的配置或模型版本需要 review。 - 失败样本留存(脱敏后):把"模型到底返回了什么妖魔鬼怪"存下来(注意先脱敏,别把用户原文存进去)。这些样本是迭代 prompt 的金矿,也是将来做 AI 评估 时现成的 bad case 集。
Solo 上线检查清单
- ☐ 第一层:模型原生结构化输出已开(OpenAI 用 structured outputs 而非 JSON mode;Anthropic 用 output_config.format)
- ☐ 第二层:zod schema 已写,
.strict()vs.passthrough()已按场景选定,z.coerce用在模型"类型随意"的字段上 - ☐ 第三层:重试把 zod 报错原文喂回 prompt,
maxRetries ≤ 2且是配置项 - ☐ 第四层:降级方案已按"错了 vs 没有哪个更贵"选定,降级触发有告警
- ☐ 可观测:结构化失败率、重试触发率两个指标在看板上;失败样本脱敏留存
- ☐ schema 有版本管理(schema 改了,老数据/老 prompt 不会悄悄对不上)
记住这个链条:prompt 是建议,参数是约束,校验是法律,降级是保险。别再让 JSON.parse 在凌晨三点裸奔了。
评论 (0)
相关文章

功能开关把部署和发布解耦:代码先上,功能后开。四种开关类型与选型矩阵、一张表的最小实现、兜底铁律(新功能默认关、止血开关冻结)、开关腐烂治理,以及开关与灰度发布如何配合。

用户写的 2000 字,要扛住浏览器崩溃、断网和误关标签页。三层防线——输入防抖、localStorage 兜底、带版本号的服务端持久化,外加版本号乐观锁的冲突 UX(保留两份让用户选)、离线重放与草稿生命周期。

两个人同时改同一行是"一致性"问题,不是"实时"问题——"WebSocket 广播 JSON"的天真方案撑不过三个人(同行冲突、光标打架、断线丢数据)。本指南先与站内 realtime-features(单向推送)、offline-sync(单用户)两篇划界,再深入:OT vs CRDT 一句话讲透+选型对照表(结论先行:一人团队直接选 Yjs)、Y.Doc/Awareness/Provider 三件套(30 行客户端+40 行带鉴权的 y-websocket 服务端)、房间级 ACL 与快照兜底防恶意客户端、生产坑(大文档快照/分片、Y.UndoManager 语义、按席位计费),最后附 5 项上线验收清单。