返回探索
指南VibeFix 编辑部更新于 2026年10月17日

别再 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();

两个实战经验:

  1. 用 z.coerce 对付模型的"类型随意":模型把数字写成字符串是家常便饭,z.coerce.number() 比 z.number() 的生产存活率高一个数量级。但 coerce 不是万能——"很贵" coerce 出来是 NaN,照样报错,这正是你想要的:让离谱值在校验层死掉,而不是流到下游。
  2. 错误信息是第三层的弹药: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 人工贵在人力,默认值贵在错误扩散,跳过贵在数据缺失。把这笔账算清楚,降级方案就不会拍脑袋。以及:降级触发本身要告警——降级是安全气囊,不是日常座椅,弹出来一次就要有人看一眼。

可观测:失败率指标与失败样本留存

四层防线建完,还差最后一块:你怎么知道它在工作?两个指标、一个习惯:

  1. 结构化失败率:zod 校验失败次数 / 总调用次数,按天看趋势。这是整个链路的健康度仪表盘,突增意味着上游变了。
  2. 修复重试触发率:触发重试的次数 / 总调用次数。健康值通常 <5%;持续走高说明第一层(模型原生结构化输出)的配置或模型版本需要 review。
  3. 失败样本留存(脱敏后):把"模型到底返回了什么妖魔鬼怪"存下来(注意先脱敏,别把用户原文存进去)。这些样本是迭代 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 在凌晨三点裸奔了。

阅读 1评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

实拍:工业控制面板上的红色急停按钮
指南
上线不靠祈祷:vibe 项目的功能开关与应急关停实战

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

部署上线后端工程项目创作
实拍:伏案写作的人,笔记本旁放着电脑
指南
用户写了 2000 字然后丢了:vibe 项目的草稿自动保存实战

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

项目创作设计体验独立开发
协同编辑器示意图:同一文档上多个带名字标签(Sophia、Charlie)的彩色光标同时编辑
指南
两个人同时改同一行:vibe 项目的多人实时协同编辑实战

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

开发工作流数据工作流团队协作