Rosanci头像
关注
全栈工程师的结构化输出:为什么“让它返回 JSON“是 AI 功能最脆弱的一环封面图

全栈工程师的结构化输出:为什么“让它返回 JSON“是 AI 功能最脆弱的一环

严格 schema 也放过三成三、非必填字段被拼错取代时五条全静默、契约加一个可选字段反而让最严格的客户端硬失败、前缀占了单次输入的八成九、修复回合天生贵两成四 —— 为什么"让它返回 JSON"会同时交出"格式正确"和"内容错误",附 Python 3.13 + Node 22 五组本地实测

实测于 2026 年 10 月 6 日 | 约 10,100 字 | 面向全栈工程师、后端工程师与 AI 应用开发者

目录

一、结构化输出被严重低估了

二、先看清楚:结构化输出在整条链路里的位置

三、三个直觉,三条岔路

四、实验台:怎么做的,为什么可信

五、实验一:JSON Schema 到底拦得住什么

六、实验二:字段被拼错取代时,四道关全部放行

七、实验三:契约一变,20 格里 11 格是静默失败

八、实验四:失败率的真实成本

九、实验五:好心修复的代价

十、落到代码:一个能直接用的输出管线

十一、判据:把输出契约当成 API 契约来治

十二、上线前的检查清单

十三、把这篇的发现折算成钱

十四、和这个系列其它篇的关系

十五、常见问题

十六、小结

数据时效声明与版权成本

参考资料

工单智能分诊上线第三个月,运维群里出现一条很平静的消息:

  「上周有 17 条用水类的工单,派给了道路组。系统没有任何报错。」

我去翻了这批请求的日志。解析成功率 100%。每一条输出都是合法 JSON,每一个字段类型都对,schema 校验全绿,业务代码一次异常都没抛。contact 字段有值,priority 是 1 到 5 的整数,deadline_days 也都在合理区间。

可这批工单里,有 17 条的 category 填错了。

这就是结构化输出的真相:它能给你一个完美的"格式正确",同时把"内容是错的"这件事藏得严严实实。而这篇文章要讲的,是为什么——以及怎么把它从"看不见"变成"看得见"。

一、结构化输出被严重低估了

做 AI 功能时,大家最舍得花时间的地方通常是提示词、模型选型、检索质量。而"让它返回 JSON"这件事,往往被当成一行代码的事:

TypeScript

const result = JSON.parse(await callModel(prompt));

这句话里藏着三个默认假设:

假设一:只要能解析,就说明这次调用是成功的。 假设二:schema 写得越严格,系统就越安全。 假设三:解析失败就重试,或者自动修一下就好。

这篇文章会用五组可复现的本地实验,一条条拆掉这三个假设。所有实验都在你本机上跑得出来,不依赖任何 API,也不需要花一分钱——这是这个系列一贯的规矩:先把自己能验证的东西验证到底,再去谈别人的经验。

二、先看清楚:结构化输出在整条链路里的位置

一个 AI 功能大致分四层:

层

干什么

出错的典型表现

本系列哪一篇讲过

输入层

把用户的话 / 图 / 语音变成文本

语音识别同音错字、图片糊

第 9 、 10 篇

上下文层

决定给模型看什么

越聊越贵、越聊越傻

第 13 篇

调用层

决定用哪个模型、调什么工具

降级比不降级更贵

第 4 、 14 、 15 篇

输出层

把模型的话变成程序能用的数据

格式对但值错

本篇

前三层都有人认真写过。输出层没有。

原因也很容易理解:输出层看起来最没有技术含量。它不像检索有向量、不像路由有策略、不像工具调用有协议。它就是"把一段文本变成对象"。

但恰恰因为它简单,它成了唯一一层没有任何人盯着的地方。检索不准会有人投诉"答得不像",路由错了会有人发现账单涨了,而输出层错了——系统安静得像什么都没发生。

三、三个直觉,三条岔路

在开始实验之前,先把结论摆在前面。这三个结论都和直觉相反,而每一个我都有实测数据撑着。

直觉一:「只要 JSON 能解析就没事」。 实测:20 条坏输出里,只做 JSON.parse 能"成功"10 条;而这 10 条里,7 条的值是错的。解析成功率 50%,但既通过校验、值又正确的只有 2 条。

直觉二:「schema 写得越严格越安全」。 实测:60 条样本、6 类错误,用最严格的 schema(additionalProperties:false + 全部约束)仍然漏过 33.3%;而最常见的写法漏过 50.0%。更要命的是——最严格的 schema 会让"加一个可选字段"这种无害变更变成硬失败。

直觉三:「解析失败就重试或自动修」。 实测:修复回合天生比首轮贵 23.6%(你要把坏输出和报错再发一遍)。而越"宽容"的自动修复,越容易把「会被发现的解析失败」转化成「永远不会被发现的错值」。

后面五章,一章一个实验。

四、实验台:怎么做的,为什么可信

先把可信度交代清楚。这五组实验全部在本地跑,用的都是业界标准工具,不是我自己写的"看起来对"的模拟器:

工具

用途

为什么选它

jsonschema 4.26.0

校验输出是否符合 schema

Python 官方生态的 JSON Schema 参考实现, Draft 2020-12

gpt-tokenizer

统计真实 token 数

上一篇文章(成本账)用过的同一套计数器,口径一致

Node.js 22

跑真实下游管线

断言 " 静默 " 必须有真实运行时行为,不能靠推演

纯手写语料

60 条 + 20 条 + 25 条样本

场景沿用本系列的村务工单分诊,每一条都是真实会出现的输出

数据来源声明:本篇所有百分比、条数、token 数均来自 lab/ 目录下五组脚本的实跑输出(2026-10-06),可逐条复现。模型单价沿用本系列第 8 篇的口径。本篇不涉及任何线上 API 调用,因此不包含模型版本的时效性风险——测的是工具与协议的行为,不是某个模型某一天的脾气。

五、实验一:JSON Schema 到底拦得住什么

这是整篇的地基。如果这一步的结论是错的,后面所有的策略都会建立在错误的假设上。所以我先用 60 条样本,把 schema 的能力边界精确量出来——它拦得住什么、拦不住什么、以及那条分界线到底画在哪里。

5.1 怎么设计样本

我按"错误发生在哪一层"造了 6 类样本,每类 10 条,共 60 条:

类别

错误类型

举例

A

结构错误

缺必填字段、顶层是数组、 category 为 null

B

类型错误

priority 是字符串 "4" 、 need_onsite 是布尔以外的值

C

范围格式错误

priority=9 越界、电话格式不对、 summary 空串

D

业务语义非法

当天上门却说不用到场、爆管却给最低优先级

E

多余字段 / 拼错重名

结果里多带 reasoning 、多带 contect

F

枚举选错

category 填 " 道路 " ,但描述的是水管爆裂

然后用两版 schema 逐条校验:

  • 默认写法:老老实实写 properties、required、enum、pattern,不写 additionalProperties。
  • 最严格写法:在默认写法上补 additionalProperties: false。

5.2 结果

错误类型

默认写法漏过

最严格写法漏过

A 结构错误

0 / 10

0 / 10

B 类型错误

0 / 10

0 / 10

C 范围格式错误

0 / 10

0 / 10

D 业务语义非法

10 / 10

10 / 10

E 多余字段 / 拼错重名

10 / 10

0 / 10

F 枚举选错

10 / 10

10 / 10

合计漏过

30 / 60 = 50.0%

20 / 60 = 33.3%

schema 约束覆盖边界

5.3 这张表在说什么

能被拦下的只有 A、B、C 三类:结构、类型、范围格式。 全是"机器能判"的东西——不需要懂业务,只要对着规则比就行。

拦不住的是 D 和 F 两类:业务语义、枚举选错。 这两类有个共同点:它们的答案不在 schema 里。

看几条具体的:

JSON

{"category": "其他", "priority": 1, "summary": "水管爆了水淹到路上",

 "location": "村东头", "need_onsite": true, "contact": "13800138000",

 "deadline_days": 0}

这条 JSON:类型全对、范围全对、必填全齐、category 在枚举里、电话符合 pattern。两版 schema 都判它合法。

可它同时犯了三个业务错误:真正的问题是水管(该填"用水"不是"其他");爆管属于最紧急却给了最低优先级;说好当天上门但跨村维修当天到不了。

JSON

{"category": "道路", "priority": 4, "summary": "家里水管爆了流了一地",

 "location": "村东头", "need_onsite": true, "contact": "13800138000",

 "deadline_days": 2}

这条也一样。category 是"道路",是枚举里的合法值,schema 只能确认"它是个合法分类",没法确认"它是对的分类"。

这就是 D/F 两类拦不住的根因:schema 校验的是"值是否属于允许集合",不是"这个值是否描述了事实"。

实测输出

实测输出(exp1)

=======================================================

默认写法 漏过 30/60 = 50.0%

最严格写法 漏过 20/60 = 33.3%

其中 D 类(业务语义)与 F 类(枚举选错)合计 20 条

在两版 schema 下**全部漏过**,拦截率 0.0%

=======================================================

别急着抄:很多人看到这里的第一反应是"那就把所有能加的约束都加上,把 schema 写复杂"。先别。实验三会告诉你,把 schema 写严有它自己的代价,而且这个代价比你想的更贵——它会让无害的变更变成硬故障。另外还有一层更隐蔽的:

JSON

{"category": "用水", "priority": 4, "summary": "村东头水管漏水半天了",

 "location": "村东头", "need_onsite": true, "contact": "13800138000",

 "deadline_days": 2, "reasoning": "用户描述的是供水问题"}

这条数据本身完全正确。它只是多带了一个模型自己的思考过程。在默认写法下它通过;在 additionalProperties:false 下它被拦下——而它其实是无害的。严格和正确,是两件事。

5.4 那 D 类和 F 类怎么办:把它们从"语义"挪到"结构"

既然 schema 判不了业务语义,一个务实的做法是:把能量化的那部分语义,搬进 schema 里。

以 priority 为例。原来它是 1..5 的整数,这个取值范围什么都说明不了。但如果我们在 schema 里把"业务规则"显式写出来,模型就必须自证:

JSON

{

  "type": "object",

  "required": ["priority", "need_onsite", "deadline_days", "priority_basis"],

  "properties": {

    "priority": { "type": "integer", "minimum": 1, "maximum": 5 },

    "need_onsite": { "type": "boolean" },

    "deadline_days": { "type": ["integer", "null"], "minimum": 0, "maximum": 30 },

    "priority_basis": {

      "type": "string",

      "enum": ["爆管漏水停电等急件", "影响多人通行", "影响单户", "建议改进"]

    }

  }

}

加一个 priority_basis,逼模型把"它凭什么这么定级"说出来。这一步能自动发现的错误很具体:

原来看不见的错误

加了 priority_basis 之后

怎么发现

爆管却给最低优先级

模型写 " 爆管漏水停电等急件 " 却给 priority=1

两条规则冲突,可以写断言

说不用到场却要求现场

need_onsite=false 但 basis 是 " 急件 "

交叉校验

当天上门但跨村维修

deadline_days=0 且 basis 是 " 影响多人 "

规则拦截

关键不在这个字段本身,而在于它把"隐含的业务判断"变成了"必须显式说出的一小段文本"。 显式的文本能被规则匹配、能被抽样人工复核、能进评测集;而一个孤零零的 3 什么也做不了。

这也是 D 类和 F 类唯一务实的治理方向:不是让 schema 更严格,而是让"业务依据"变成输出的一部分。当然,这有代价——每次请求多几个字段,按实验四的口径会多花一点钱。所以要用在真正贵的那几个字段上,不是全量铺开。

六、实验二:字段被拼错取代时,四道关全部放行

上一个实验讲的是"值错了但结构没问题",这一个讲的是更土、也更常见的一类:结构本身就没对上,但校验器看不出来。它的危险不在于错得多,而在于整条链路上没有任何一层会喊。

6.1 这一节的问题更"土",但更常见

上一个实验讲的是"值错了"。这一个讲的是字段名错了。

模型把 contact 写成 contect、把 need_onsite 写成 needOnsite、把 deadline_days 写成 deadlineDays——这在真实场景里极其常见,尤其是模型被"顺手"换成另一个版本之后。

关键在于被拼错的是不是必填字段:

  • 拼错必填字段 → 等于缺字段 → 结构校验会拦(甚至不用 additionalProperties)。
  • 拼错非必填字段 → 必填都还在 → 结构校验完全通过。

我造了 5 条这样的输出,然后把它送进一条真实的 Node 下游管线:

TypeScript

// 下游:把分诊结果写库 + 发短信通知

function toRow(o: any) {

  return {

    category: o.category,

    priority: o.priority,

    summary: String(o.summary || '').trim(),

    location: String(o.location || '').trim(),

    contact: String(o.contact || '').replace(/\D/g, ''),

    deadline_days: Number(o.deadline_days ?? 3),

  };

}

const row = JSON.parse(raw) as Triage; // 类型断言:运行时零校验

6.2 结果

样本

默认写法

最严格写法

contact 被 contect 取代

通过

拦下

location 被 loction 取代

通过

拦下

deadline_days 被 deadline 取代

通过

拦下

location 被 Location 取代

通过

拦下

deadline_days 被 deadlineDays 取代

通过

拦下

漏过合计

5 / 5

0 / 5

而下游的真实运行结果:

指标

数值

静默错误(无异常、无日志、值已错)

5 / 5

抛出异常(可见)

0

字段静默丢失(变空串 / 默认值)

5

静默链条

具体的样子:

实测输出

实测输出(exp2 · Node 实跑)

canonical → 期望 13800138000 实得 ""

location → 期望 村东头 实得 ""

deadline → 期望 2 实得 3

=======================================================

5 条输出 → 5 条拿到错值;抛异常 0、写日志 0

contact 变成空串的后果是:短信发不出去,而系统认为已经通知过了。deadline_days 被默认值 3 覆盖的后果是:一个本该 2 天的工单,系统显示 3 天。

真正危险的地方:注意这四道关的顺序——JSON.parse管语法,schema 管结构,TS 类型断言管编译期,业务代码管取值。四道关全都不检查"字段名的拼写是否语义正确",因为它们都只认"这个名字在不在允许集合里"。而contect 这个名字,在不写 additionalProperties的 schema 里属于"额外字段",默认是允许的。

这里要澄清一个常见误解:TypeScript 的类型断言 as Triage 在运行时什么都不做。它不是校验器,它是一句"相信我,这个类型是对的"。编译期类型和运行时数据之间那道缝,就是上面这 5 条错值掉下去的地方。

6.3 字段拼错是可以在 CI 里拦住的

这一类错误的可爱之处在于:它有一个完全不依赖模型的一致性判据。同一个字段名,在三个地方出现——schema、TS 类型、下游取值代码。只要这三处对齐,拼错就会被抓住。

一个几行就能跑的检查,比任何"提示词里再强调一遍字段名"都可靠:

TypeScript

// 从 schema 里取出所有属性名,与 TS 类型和取值代码做三方比对

const schemaFields = Object.keys(TRIAGE_SCHEMA.properties);

const typeFields = readTypeFields('src/types/triage.ts', 'Triage');

const usedFields = readAccessorFields('src/domain/triage.ts', 'toRow');

​

for (const f of schemaFields) {

  assert(typeFields.includes(f), `TS 类型漏了字段 ${f}`);

}

for (const f of usedFields) {

  assert(schemaFields.includes(f), `下游用了 schema 里没有的字段 ${f}`);

}

最后那一条断言才是重点。它能抓住两种真实事故:

  • 下游写错了字段名:代码里写 o.contect,而 schema 里是 contact。这类错在 TS 里会因为索引访问而静默通过(尤其是下游用了 any)。
  • 契约改了但下游没跟上:schema 里 summary 改名成 description,而下游还在读 o.summary —— 正是实验三 CH3 那一格。

这类检查的价值在于它不依赖运行时数据。 前面说的"静默错必须靠线上统计才能发现"是针对语义错的;而字段名这一层,完全可以在 CI 里用静态比对做到"一旦错就红"。这是整套方案里性价比最高的一道防线,建议先做这一条。

七、实验三:契约一变,20 格里 11 格是静默失败

前两个实验说的是"单次输出可能出错"。这一节说的是线上最难排查的那一类事故:代码没改错、模型没变笨、schema 也没写错,只是契约动了。下面这张矩阵,是这个系列写到第十六篇以来我最想让你看的一张表。

7.1 为什么这一节最重要

前两个实验讲的是"单次输出可能错"。这一节讲的是线上最常见、也最难排查的一类事故:契约变了。

场景你一定遇到过:产品说要在结果里加一个字段;或者要把 summary 改名成 description;或者分类要新增一个类别。改动本身可能只有三行,但老客户端还在线上跑。

我设计了 5 种典型变更 × 4 种常见客户端行为,一共 20 格,每一格判三种结果:正常 / 可见失败 / 静默失败。

四种客户端:

代号

行为

说明

C1a

严格 schema 校验

additionalProperties:false + 全约束,不合规就拒绝

C1b

宽松 schema 校验

只查 required 与 type ,不写 additionalProperties

C2

无校验 + 默认值兜底

JSON.parse 后直接取值,缺了给默认

C3

无校验 + 无兜底

undefined 直接往下传

7.2 结果

契约变更

C1a 严格

C1b 宽松

C2 无校验 + 默认

C3 无校验 + 无兜底

加一个必填字段

可见

静默

静默

静默

加一个可选字段

可见

正常

正常

正常

字段改名 summary → description

可见

可见

静默

静默

枚举新增一个值

可见

可见

静默

静默

值域收窄 1-5 档 → 1-3 档

静默

静默

静默

静默

合计(20 格)

可见 6 · 静默 11 · 正常 3

契约演进兼容矩阵

7.3 三个反直觉的发现

发现一:静默失败占 55%。 20 格里 11 格是"不报错、不告警,只是行为悄悄不对了"。

发现二:「加一个可选字段」这一行,四格里唯一失败的是最严格的那个客户端。

这一条值得单独说。加一个可选字段,在语义上是完全向后兼容的变更——老客户端不认识它,忽略就好。C1b / C2 / C3 都正常。可 C1a 报错了,因为 additionalProperties:false 把"出现了一个我没声明过的字段"本身判成非法。

于是现实中的剧本是这样的:

实测输出

第 1 步:为了"安全",给所有 schema 都加上 additionalProperties:false

第 2 步:产品加了个字段,严格客户端全线报错,告警刷屏

第 3 步:为了止血,把 additionalProperties 关掉

第 4 步:回到实验一的"默认写法",漏过率从 33.3% 变回 50.0%

第 5 步:E 类(多余字段/拼错重名)从 0 漏过变回 10 漏过

越严越不兼容,最后大家都去关它。 这是"严格 schema"这条路最真实的结局——不是被绕过,是被自己人关掉。

发现三:「值域收窄」这一行,四个客户端全部静默。

这一行是我在设计实验时最没想到的。变更内容是:priority 的语义从"1-5 五档"收窄成"1-3 三档",其中 3 代表最紧急 (SLA 0 天)。

而老客户端的调度表是按五档写的:priority=3 在老表里是"中等,SLA 3 天"。

这一格里,没有任何一层会报错:

  • schema 校验通过——因为 3 在 1 到 5 之间,完全合法。
  • 类型校验通过——整数。
  • 业务代码不抛异常——它按老表查到了"3 天",返回了一个合法的数字。

结果是:最紧急的工单,被排到了 3 天后。

实测输出

实测输出(exp3 · 语义漂移)

变更:priority 值域 1-5 档 → 1-3 档(3 = 最紧急)

老客户端:priority=3 在老表里是「中等」,SLA = 3 天

新契约: priority=3 是「最紧急」,SLA = 0 天

四个客户端(严格/宽松/无校验/无兜底)全部通过校验

零报错、零告警 —— 差异只体现在「几天后到场」上

=======================================================

这一条最容易被忽略:前面几个变更(加字段、改名、加枚举值)都会在数据形状上留下痕迹,你至少有机会用 diff 发现。而"值域收窄"只改了同一个数字的含义——数据形状一模一样,schema 一模一样,类型一模一样。你唯一能发现它的方式,是有人告诉你"上周有三条爆管工单排到了三天后"。

给这条变更的处置建议:凡是会改变"同一个值含义"的契约变更,都不要复用原来的字段。要么换字段名(priority → urgency_level),要么换成字符串枚举("P0" / "P1" / "P2")。让语义变化在数据形状上可见,这是最便宜的一道保险。

八、实验四:失败率的真实成本

前面三节讲的是"会不会错、错在哪一层"。这一节换一个角度:错了以后要花多少钱。结论会有点反直觉——真正的成本大头从来不是那几次重试,而是每次请求都要重发一遍的那段固定前缀。

8.1 先看清楚一次请求的钱花在哪

用 gpt-tokenizer 对真实的请求体做计数(场景同本系列,村务工单分诊):

部件

tokens

system prompt

111

小 schema ( 7 个字段)

249

大 schema ( 32 个字段)

884

用户真正的输入

42

修复回合:坏输出

45

修复回合:报错文本

29

修复回合:指令

21

算一下就很难受了:

实测输出

实测输出(exp4 · 真实 token 计数)

小 schema 前缀 = 111 + 249 = 360 tokens

单次请求输入 = 360 + 42 = 402 tokens

前缀占比 = 360 / 402 = 89.6%

​

大 schema 前缀 = 111 + 884 = 995 tokens

单次请求输入 = 995 + 42 = 1037 tokens

前缀占比 = 995 / 1037 = 95.9%

=======================================================

一次请求里,90% 的钱花在「把你的要求再说一遍」上

只有 10.4% 花在真实内容上(42 tokens 的用户输入)

=======================================================

用户真正说的话只有 42 tokens,比 schema 本身小 5.9 倍。

8.2 失败一次的代价

这里有个很朴素但常被忽略的事实:要让模型改错,你必须把"错的东西"和"为什么错"再发一遍。

回合

输入构成

tokens

首轮

前缀 + 用户输入

402

修复回合

前缀 + 用户输入 + 坏输出 + 报错 + 指令

497

放大倍数

497 / 402

1.236 倍

所以重试不是"免费再来一次",它天生比第一次贵 23.6%。

8.3 成本放大曲线

设失败率 p,期望调用次数是 1/(1-p);如果每次失败都触发一个修复回合,放大倍率是:

实测输出

放大 = 1 + p/(1-p) × (修复回合成本 / 首轮成本)

     = 1 + p/(1-p) × 1.236

失败率 p

只重试一次 1/(1-p)

含修复回合

1%

+1.0%

+1.2%

2%

+2.0%

+2.5%

5%

+5.3%

+6.5%

10%

+11.1%

+13.7%

15%

+17.6%

+21.8%

20%

+25.0%

+30.9%

30%

+42.9%

+53.0%

失败率的成本放大

记住这一句:失败率 10% 听起来是个"很小"的数,但成本要多花 13.7%;失败率 30% 时要多花 53.0%。而这里的放大是乘法意义上的——它不是在原成本上加一条重试记录,而是把占 89.6% 的固定前缀重花一遍。

这也解释了为什么"把 schema 写小"很值钱: 大 schema 的前缀是 995 tokens,是小的 2.77 倍(995 / 360)。而每次失败触发的修复回合,都要把这份更大的前缀重发一次。失败率不变的情况下,大 schema 的每一次失败都更贵。

8.4 大 schema 的另一重代价:约束点

我顺手数了一下两个 schema 的"约束点"(每一个 type / enum / minimum / maxLength / pattern / required 条目都算一个):

指标

小 schema

大 schema

倍数

属性数

7

32

4.57x

约束点总数

21

66

3.14x

schema tokens

249

884

3.55x

单次请求输入 tokens

402

1037

2.58x

每多一个约束点,就多一个"模型可能违反"的机会。 而每一次违反,都要按上一节的倍率重花一遍前缀的钱。所以"把 30 个字段一次全塞进一个 schema"这种写法,是同时在两个方向上变贵:一次更贵,而且更容易失败。

8.5 前缀缓存能救回多少

上一节的数据其实指向一个好消息:前缀占 89.6%,而前缀是固定的。固定前缀是所有请求里逐字节相同的一段,天生就是缓存(prompt cache)的理想对象。理论上,命中缓存的输入按更低的单价计费,能把"重发一遍前缀"这件事的伤害压下去。

但这里有两个必须认清的边界:

第一,缓存是按「模型 + 前缀」隔离的。 本系列第 15 篇(多模型路由)实测过一个反直觉的结论:把一个模型换成价格完全相同的另一个模型,总成本仍然贵 24.3%——因为前缀全部未命中,缓存资产直接归零。所以路由策略和缓存策略是耦合的,不能分开设计。

第二,修复回合的前半段仍然可以命中缓存。 修复回合的输入结构是「前缀 + 用户输入 + 坏输出 + 报错 + 指令」,它的开头还是那段一模一样的前缀。所以只要缓存粒度覆盖到前缀,修复回合真正按全价计费的就只有后面那段尾巴(用户输入 42 + 坏输出 45 + 报错 29 + 指令 21 = 137 tokens),而不是整个 497。

实测输出

实测数据(exp4 · 修复回合的两段结构)

修复回合输入 = 497 tokens

  ├─ 固定前缀 360 tokens ← 与首轮逐字节相同,可命中缓存

  └─ 新增尾巴 137 tokens ← 只有这段按全价计费

=======================================================

所以第八节的放大倍率 1.236 是**没有缓存**时的上限;

有缓存时,修复回合的实际增幅会显著低于这个数

这一节的实际行动项:如果你的网关或框架支持 prompt cache,那么"把 system prompt 和 schema 放在最前面、且保证逐字节稳定",是一件立刻能省钱的事。反过来说——任何会让前缀抖动的改动(换模型、改 schema 措辞、调 system prompt 的标点),都会把缓存打掉。

九、实验五:好心修复的代价

最后一个实验,也是最能直接影响你明天写代码方式的一个。"解析失败就修一下"几乎是所有团队都会做的事,问题只在于修到哪一层为止。我造了 20 条真实的坏输出,用 5 种典型策略分别处理,然后把结果按四个互不相同的口径分别统计——你会发现"修得更宽容"和"错得更安静"经常是同一件事。

9.1 现实里最常见的五种处置

解析失败之后,工程上一般有五种做法。我造了 20 条真实的坏输出(尾随逗号、代码块包裹、单引号、裸键名、JS 注释、URL、截断、全角引号、NaN、外层套数组……),然后分别用五种策略处理。

这里最关键的是把口径分开——很多人把三个不同的指标混成一个:

  1. 拿到对象:解析成功了(说明不了任何事)
  2. 通过校验:拿到的是对象且符合严格 schema
  3. 值正确:与真实意图一致
  4. 静默错:下游接受了它,但值与真实意图不符 ← 这一列才是要命的

9.2 结果

策略

拿到对象

通过严格校验

值正确

静默错

可见拒绝

A1 只解析(拿到就用)

10

3

3

7

10

A2 解析 + 严格校验(拒绝)

3

3

2

1

17

B 宽容修语法

18

13

12

6

2

C 宽容 + 类型补齐 + 补默认

18

16

15

3

2

D 宽容 + 严格校验 + 不补值

13

13

12

1

7

五种处置策略对比

9.3 逐个读这张表

A1「只解析」是最会骗人的策略。

实测输出

实测输出(exp5 · A1 只解析)

拿到对象 10 / 20 —— 看起来解析成功率 50%,还不错

其中值正确的只有 3 条

既通过校验、值又正确的只有 2 条

另外 7 条:不报错、不告警、下游照常用

=======================================================

它是现状里最常见的写法(JSON.parse 加一句 as Triage)。而它的真实产出率是 2 / 20。

A2「解析 + 严格校验」把静默错从 7 压到 1,代价是拒绝率飙到 85%。 20 条里拒 17 条——因为真实模型输出里"尾随逗号""被代码块包住"这种事太常见了,全部让人重试,体验会很差。

B「宽容修语法」是对的方向,但不够。 拒绝从 17 降到 2,静默错 6 条——因为它不管类型:priority 还是字符串 "4",need_onsite 还是字符串 "true",deadline_days 干脆不见了。

C「宽容 + 类型补齐 + 补默认」——这一层的风险变了性质。

C 的静默错降到 3 条,看起来最好。但这 3 条的性质和前面的都不一样:

实测输出

实测输出(exp5 · C 策略的静默错,逐条)

① deadline_days 缺失 → 补默认 3;而真值是 2

② priority = NaN → 归零;而真值是 4

③ 截断在字符串中间 → 兜底抽取,把残缺的 summary 当完整的

=======================================================

这 3 条不是「没修好」,是「工具替模型编造了数据」

第一条最典型。deadline_days 缺失时,代码里写着 o.deadline_days ?? 3——一个看起来非常贴心、非常"健壮"的默认值。可模型没给这个字段,说明它不知道。用一个默认值把它填上,等于让系统假装"我知道,是 3 天"。

D「宽容修语法 + 严格校验 + 绝不补值」是这五条路里的正解。

静默错 1 条、可见拒绝 7 条。拒绝数比 A2 的 17 条少了 59%——因为宽容修语法把"本来不该让人重试的"(尾随逗号、代码块、单引号)救回来了;而静默错几乎归零——因为不替模型补任何值。

9.4 D 还剩的那 1 条静默错,暴露了最后一个坑

D 唯一剩下的静默错是这一条:

实测输出

整个 deadline_days 缺失 → 通过严格 schema → 静默错

为什么?因为 deadline_days 没写进 required。schema 说"这个字段随便,有就有、没有就没有",于是"没有"是合法的,下游只好用"没有"。

这条给出的行动项非常具体:凡是下游会用到、缺失就会出错的字段,不管业务上是不是"可选",都要写进 required——哪怕它允许显式 null。

JSON

{

  "required": ["category", "priority", "summary", "location",

               "need_onsite", "contact", "deadline_days"],

  "properties": {

    "location": { "type": ["string", "null"] },

    "deadline_days": { "type": ["integer", "null"], "minimum": 0, "maximum": 30 }

  }

}

区别在这里:"type": ["string", "null"] 要求模型显式表态——要么给值,要么明确说"我不知道"。而"字段直接消失"是一种沉默,沉默是无法被校验的。把沉默变成显式表态,这一层就补上了。

9.5 一张"该不该修"的判据表

"修复"这个词太笼统了。实际写代码时,你面对的是一个个具体决定。把上面的结论落实成一张可以贴在工位上的判据表:

你看到的情况

修不修

为什么

被 Markdown 代码块包住

修

这是模型排版的习惯,与数据无关

首尾多了 " 好的,结果如下 "

修

同上,纯粹是包装

尾随逗号、单引号、裸键名

修

语法层问题,改完值不变

全角引号 “”

修

输入法问题,改完值不变

结尾少一个 }

修

语法层补全,不影响已写出的字段

priority 是字符串 "4"

先别修,交给校验

转成 4 看起来没损失,但你放弃了 " 模型没遵守契约 " 这个信号

deadline_days 整个缺失

绝不修

补默认值 = 替模型编造数据

值是 null

绝不修

null 是明确表态 " 我不知道 " ,不该被默认值覆盖

值是 NaN

绝不修

归零会从一个错值变成另一个错值

文本在字符串中间被截断

绝不修

残缺内容被当完整的用,是最典型的静默错

规律很清楚:"修"只应该发生在语法层和包装层,"值"这一层一个字都不要碰。

一个便于记忆的边界:修完之后,如果这条数据在你眼里变得"更像一个正确结果"了,那就不该修。 修复的目标是让它"能被程序读",不是让它"看起来对"。

十、落到代码:一个能直接用的输出管线

把上面五个实验的结论合起来,一条可用的管线应该长这样:宽容修语法 → 严格校验 → 绝不补值 → 错误分型埋点。

TypeScript

import Ajv from 'ajv';

import addFormats from 'ajv-formats';

​

const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });

addFormats(ajv);

​

/** 第 1 层:宽容修语法 —— 只动语法,不动语义 */

export function repairSyntax(raw: string): string {

  let s = raw.trim();

  // 去掉 Markdown 代码块围栏

  s = s.replace(/^```[a-zA-Z]*\s*/m, '').replace(/```\s*$/m, '');

  // 剥掉首尾的解释文字:取第一个 { 到最后一个 }

  const i = s.indexOf('{');

  const j = s.lastIndexOf('}');

  if (i >= 0 && j > i) s = s.slice(i, j + 1);

  // 去尾随逗号

  s = s.replace(/,(\s*[}\]])/g, '$1');

  // 全角引号与单引号定界符

  s = s.replace(/[\u201c\u201d]/g, '"').replace(/'/g, '"');

  return s;

}

​

/** 第 2 层:解析 —— 失败就是失败,不猜 */

export function parseStrict(raw: string): unknown {

  const s = repairSyntax(raw);

  return JSON.parse(s); // 失败 -> 抛错 -> 走可见路径

}

​

/** 第 3 层:严格校验 —— additionalProperties:false 只在这里生效 */

export const validate = ajv.compile(TRIAGE_SCHEMA);

​

export type Outcome =

  | { kind: 'ok'; data: Triage }

  | { kind: 'parse_error'; raw: string; detail: string }

  | { kind: 'schema_error'; raw: string; errors: string[] };

​

export function normalizeOutput(raw: string): Outcome {

  let parsed: unknown;

  try {

    parsed = parseStrict(raw);

  } catch (e) {

    return { kind: 'parse_error', raw, detail: String(e) };

  }

  if (!validate(parsed)) {

    return {

      kind: 'schema_error', raw,

      errors: (validate.errors ?? []).map(

        (x) => `${x.instancePath} ${x.message}`),

    };

  }

  return { kind: 'ok', data: parsed as Triage }; // 绝不补值

}

三个刻意的设计:

第一,repairSyntax 只做语法替换,绝不做类型转换。 它不去改 priority 的值,不加字段,不删字段。语法层和语义层的边界必须守住——实验五里 C 策略踩的就是这条线。

第二,normalizeOutput 不返回"补好默认值"的对象。 校验不过就返回 schema_error,把"缺了什么"原样报上去。宁可让这一条工单走人工,也不要让系统用一个编造的默认值假装成功。

第三,错误分型要按类型埋点。 这是把"静默"变"可见"的关键一步:

TypeScript

/** 第 4 层:错误分型埋点 —— 让静默错有机会被看见 */

function record(outcome: Outcome, ctx: { taskId: string }) {

  const base = { taskId: ctx.taskId, at: Date.now() };

  switch (outcome.kind) {

    case 'ok':

      metrics.inc('struct.ok');

      break;

    case 'parse_error':

      metrics.inc('struct.parse_error');

      logger.warn('output.parse_error', { ...base, detail: outcome.detail });

      break;

    case 'schema_error':

      metrics.inc('struct.schema_error');

      // 关键:把错误路径打散,才能看出是哪一类问题在涨

      for (const e of outcome.errors) {

        metrics.inc('struct.field_error', { path: e.split(' ')[0] });

      }

      break;

  }

}

落到代码上:很多人只统计一个 struct_fail_rate。那样你只能知道"失败变多了",不知道"哪一类失败变多了"。把schema_error 按字段路径打散(/deadline_days 涨了,还是 /contact涨了),你才能在看板上看出"这个模型最近开始漏deadline_days"这种事。

十一、判据:把输出契约当成 API 契约来治

五个实验的结论可以压成五条硬规矩:

规矩

具体做法

为什么

① 校验必须在运行时

别用 as 断言顶替校验

TS 断言在运行时什么都不做

② 语法宽容,语义严格

修语法可以,改值不行

实验五: C 策略的错全是 " 替模型编造 "

③ 不补默认值

缺字段就报缺字段

默认值会把 " 不知道 " 伪装成 " 知道 "

④ 必用字段全写进 required

可空的写成 ["string","null"]

实验五: D 唯一残留的静默错来自这里

⑤ 语义变化要改字段名

收窄值域时换新字段或换字符串枚举

实验三:值域收窄四格全静默

再加一条关于 additionalProperties 的实操建议——它是整个实验里最纠结的一个开关:

不要全局开 additionalProperties:false,而是按"模型输出"和"内部调用"分开对待。

场景

建议

理由

模型输出(不可信来源)

开 false

你确实希望 " 多带思考字段 " 被拦下来,逼团队显式处理

服务间调用(可信来源)

不开

上游加字段不应该让下游硬失败

对外 API 响应

不开

加字段是兼容变更,不该当故障

11.1 契约变更需要一个流程,哪怕只有三行

实验三那张矩阵的根因不是技术问题,是流程问题:契约改了,但没人把"老客户端会怎样"过一遍。所以除了上面五条技术规矩,还需要一条很轻的流程——任何 schema 变更,先归到五类里,再决定要不要评审:

变更类别

兼容性

需要的动作

加可选字段

兼容

无(但如果开了 additionalProperties:false ,就必须先灰客户端)

加必填字段

不兼容

必评审:老客户端要能容忍这个字段缺失

改字段名

不兼容

必评审:改名 = 删旧 + 加新,等价于两次不兼容

枚举加值

兼容(数据层)

必评审:下游的 switch 有没有 default 分支

收窄值域 / 改语义

不兼容且不可见

强制换字段名 ,不允许复用

最后一行是需要重点盯的。前四类变更多少会在数据形状上留痕迹,而"收窄值域"只改了同一个数字的含义。处理方式很简单:不允许原地改语义,要么换字段名,要么换成字符串枚举。 让语义变化在数据形状上可见,成本几乎为零,收益是省掉一整类无法排查的线上事故。

十二、上线前的检查清单

结构化输出相关的检查,可以按这张清单逐条过:

  1. 每个 AI 功能的输出,都有独立的 schema 文件吗? 而不是散在代码里的 TS interface。
  2. schema 里 required 是否覆盖了所有下游会用到的字段? 包括业务上"可选"的那些。
  3. 有没有一个测试用例,专门验证"多一个未声明字段"的行为? 这个行为必须是你选择过的,不是默认碰巧如此的。
  4. 解析失败的分类埋点里,有没有按字段路径打散?
  5. 修复逻辑里有没有 ?? 默认值 这种写法? 有的话,逐条问:"如果模型没给这个字段,我凭什么能替它填?"
  6. schema 变更有没有对应的兼容性评审? 特别标记出"值域收窄""语义变更"这两类。
  7. 失败的输出有没有落盘留样? 不复现的错修不了;留样是唯一能让"静默错"变成"可分析"的手段。
  8. 线上有没有在算"值正确率"? 如果只有解析成功率和 schema 通过率,那 55% 的静默失败你一个都看不见。

这八条里,第 5 条和第 8 条是最容易被跳过的,也是回报最高的两条。

第 5 条的理由在实验五里写得很清楚:?? 默认值 这种写法在代码评审时几乎不会被质疑——它看起来是"防御性编程",是"健壮"。但它恰好是"工具替模型编造数据"的主要入口。每一次写默认值,都应该能回答"如果模型没给这个字段,我凭什么能替它填"这个问题。 如果答不上来,那就不是防御,是伪装。

第 8 条是整篇的落脚点。结构化输出的失败模式里,能被 try/catch 抓住的只是少数;大多数失败是"下游收到了一个格式完美、内容错误的对象"。能反映这一切的只有一个指标:在你有把握判断的那部分请求上,算一下"值正确率"是多少。 这个指标不需要全量算——抽样几百条,人工标一遍,就能得到一个稳定得多的信号,比盯着解析成功率有用得多。

十三、把这篇的发现折算成钱

沿用本系列一贯口径,按 1 万次分诊请求估算。假设小 schema 前缀 360 tokens、用户输入 42 tokens:

项

计算

结果

基准输入量

10000 × 402

4,020,000 tokens

失败率 10% 、只重试

4.02M × 0.111

+446,220 tokens

失败率 10% 、含修复回合

4.02M × 0.137

+550,740 tokens

换成大 schema (前缀 995 )

10000 × 1037

10,370,000 tokens

大 schema 的输入增幅

10.37M / 4.02M

+157.9%

最贵的一条不是重试,是把 schema 写大。 大 schema 让输入量涨 157.9%——而这笔钱花在"把 32 个字段的定义每次都说一遍"上。

再加一句关于"看不见"的成本:实验五里,C 策略(宽容+补默认)看起来最"健壮",静默错只有 3 条。但如果这 3 条落在 deadline_days 这种字段上,代价就不是 token 了——一个爆管工单被排到 3 天后,这个成本不在账单上。

所以这一节的表只能算一半的账。另一半是:

静默错的落点

看得见的成本

看不见的成本

summary 被截断

无

归档关键词缺失,检索命中率下降

location 变空串

无

派单靠电话确认,人工成本上升

category 选错

无

派错班组,一次往返的人力与时间

deadline_days 变默认值

无

SLA 失真,急件被排队

contact 被拼错

无

通知发不出去,而系统认为已通知

这张表的每一行,成本都不在 token 账单上。 这是结构化输出最容易被低估的地方:它的失败不会让你多花钱,它会让你以为一切正常。

十四、和这个系列其它篇的关系

本篇不是孤立的。结构化输出这一层,和前后几篇都有直接的接口——尤其是工具调用那一篇,工具参数的 schema 校验和这里的输出 schema 校验,本质是同一件事。

篇

讲的是

和本篇的接口

第 8 篇 成本账

单价没变账单却涨了十倍

本篇第八节沿用它的大 schema 与缓存口径

第 13 篇 上下文工程

越聊越贵、越聊越傻

输出契约是上下文里最容易被污染的一段

第 14 篇 工具设计

Agent 选错工具、填错参数

工具参数就是结构化输出的特例 —— 本篇的五个实验全部适用于工具调用

第 15 篇 多模型路由

换模型兜底比不兜底更贵

换模型最容易踩的坑就是本篇的实验二 :字段名悄悄变了

本篇 结构化输出

格式对但值错

输出契约层

如果你正在做工具调用(第 14 篇),本篇的实验二和实验五可以直接照搬——工具参数的 schema 校验和输出 schema 校验是同一件事。

十五、常见问题

Q1:用了约束解码(structured outputs / grammar)是不是就万无一失了?

不。约束解码保证的是语法与结构——它能让输出一定是合法 JSON、一定符合 schema。它保证不了"值是业务上正确的"。这正是实验一里 D 类和 F 类漏过率 100% 的原因:这两类错误在结构上完全合法。约束解码能帮你省掉实验五里"修语法"这件事,但省不掉校验和语义核对。

Q2:那 D 类和 F 类到底该怎么治?

schema 治不了,得靠其它层:

  • 交叉校验:category 与 summary 的一致性单独判一次(比如用关键词规则或第二个模型)。
  • 置信度显式化:让模型输出 confidence,低于阈值就转人工。
  • 影子比对:新旧逻辑并跑,只比对不生效,等差异率稳定再切。
  • 可回滚:分诊结果允许人工改派,且改派动作要有记录——这是把静默错变成训练数据的唯一途径。

Q3:为什么不干脆全部人工兜底?

成本。本系列第 15 篇算过:静态规则能省 62% 的模型钱,但误判 9 条之后,含人工兜底的总成本是纯模型的 58.6 倍。人工兜底必须只用在静默错概率最高的那一小撮请求上——所以你需要置信度,而不是全量兜底。

Q4:实验里的样本只有几十条,可信吗?

规模确实小,但这是类型学验证不是统计估计。60 条样本的作用是穷举"错误可能发生在哪一层",而不是估计"线上失败率是多少"。真正该被记住的是分层的结论(哪一层拦得住、哪一层拦不住),这个结论不会因为样本量翻十倍而改变。至于你自己的线上失败率——那必须用你自己的数据测,本篇给的是测量方法。

Q5:additionalProperties:false 到底开不开?

见第十一章的表。一句话:模型输出开,服务间调用和对外响应不开。 而且如果你开了它,就必须接受"加字段 = 硬失败"这个代价,并为此准备一个契约变更流程(加字段时先灰度客户端)。

Q6:这套东西对已经很便宜的模型还有必要吗?

更必要。模型越便宜,你就越会把更多业务交给它;而静默错的代价与单价无关——它取决于这个字段被用在哪里。一条被误分类的工单,代价是派人跑错地方,不是那 402 个 token。

十六、小结

把这篇最值钱的三句话留下:

第一句:「解析成功率」是最会骗人的指标。 20 条坏输出里,只解析能"成功" 10 条,但既通过校验、值又正确的只有 2 条。如果你的看板上只有解析成功率,那你看到的是一片祥和。

第二句:最严格的 schema 不是最安全的,它只是最先崩的。 20 格兼容矩阵里,静默失败占 11 格;而唯一"加一个可选字段"的无害变更,恰恰是最严格的客户端硬失败。现实里的结局是——为了止血把它关掉,然后漏过率从 33.3% 回到 50.0%。

第三句:你真正在选的不是"对不对",而是"错在看得见的地方还是看不见的地方"。 五种处置策略的静默错是 7 / 1 / 6 / 3 / 1,可见拒绝是 10 / 17 / 2 / 2 / 7。最有价值的组合是 D:宽容修语法、严格校验、绝不补值——把拒绝数降到 A2 的 41%,同时把静默错压到 1 条。

最后一个提醒。这个系列写了十五篇,反复出现的主题是同一件事:AI 功能的故障,绝大多数不是"报错",而是"安静地做错了"。结构化输出之所以是其中最脆弱的一环,是因为它处在"必须给程序一个确定的答案"和"模型只会给一个概率性的答案"这两件事的接缝上。

接缝处不需要更聪明的模型,需要更严格的契约。

数据时效声明与版权成本

按这个系列的规矩,把数据来源、时效性、成本口径和版权情况一次交代清楚,方便你判断这些结论能用到什么时候。

  • 实验数据:本篇所有数字来自 2026-10-06 在本地实跑的五组实验(lab/exp1.py ~ lab/exp5.py),校验器为 jsonschema 4.26.0(Draft 2020-12 参考实现),token 计数使用 gpt-tokenizer。脚本与原始输出(exp*_out.txt / exp*_out.json)随文保存,可逐条复现。
  • 时效性说明:本篇测的是工具与协议的行为(JSON Schema 语义、OAuth 式的契约兼容规则、tokenizer 计数),不是某个模型在某个版本上的表现。因此这批数字的有效期远长于"模型跑分"类数据。若 JSON Schema 规范或 tokenizer 版本发生变更,需重新跑一遍。
  • 成本口径:token 单价沿用本系列第 8 篇《AI 成本账》的口径。本篇的"成本放大"是比例性结论(放大倍率、前缀占比),不依赖具体单价,因此单价变动不影响结论。
  • 版权与引用:本篇所有样本、代码、图表均为原创,未引用第三方图表或代码。文中提到的 jsonschema、gpt-tokenizer、Ajv 均为开源项目,按其各自许可证使用。
  • 写作成本:正文配图 5 张全部由本地 matplotlib 生成,无外部图片生成费用;本篇未使用 AI 生成封面以外的图像资源。

参考资料

以下资料是这篇里所有技术判断的依据。前四条是规范与官方工具,后三条是本系列中与本篇直接相关的三篇。

  1. JSON Schema 官方规范(Draft 2020-12):applications 一节中 additionalProperties 的默认值为 true。
  2. JSON Schema 官方规范:required 与 properties 的语义边界——required 只声明"键必须存在",不声明"值必须非空"。
  3. Python jsonschema 4.26.0(Draft 2020-12 参考实现),本篇全部校验结果的产出工具。
  4. OpenAI 系列文档中关于 Structured Outputs 的说明:约束解码保证 schema 遵从,不保证值在业务上的正确性。
  5. 本系列第 8 篇《全栈工程师的 AI 成本账》:token 计数方法与单价口径。
  6. 本系列第 14 篇《全栈工程师的工具设计》:工具参数即结构化输出的特例。
  7. 本系列第 15 篇《全栈工程师的多模型路由》:换模型会让输出契约静默漂移。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Rosanci/article/details/167221913

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--