模型吐的 JSON 一半是坏的:我把校验挪进了解码过程

我在把一堆手抄的设备清单整理成台账。原始文本长这样,一行一台,分隔符全靠手抖:

PC-0417 联想ThinkCentre M760 2台  A栋3楼  2026/3/5  在库
NB-0233 Dell Latitude 5440,1台,B栋2楼,2026-03-05,借出
MON-0091:飞利浦 273V7,1台,A栋1楼,2026年3月5日,维修

写正则硬啃当然能行,但每来一种新格式我就得改一次。我改成让模型读文本、吐结构化 JSON,我这边只认 schema。然后我发现自己换了个麻烦:我拿到的 JSON 有一半是坏的。

不是"偶尔坏",是稳定地、以各种我想不到的方式坏。

先看看它到底坏在哪

我把遇到的问题攒了 15 条,全是我真见过的故障模式:代码围栏包裹、开头先唠两句、尾逗号、单引号、NaN、行注释、02 这种前导零、裸键、Python 味儿的 True、生成到一半被掐断、枚举值写飞了、必填字段漏了、数字写成 "2台"、全角冒号、以及最气人的 "location":"..."。

我写了个宽松扫描器(零依赖,daily/jsoncore.js),它对每个坏样本只回答两件事:合不合法,以及不合法的话,从第几个字符开始就已经没救了。第二个数字才是关键——它等于"我本来可以少生成多少"。

用例             长度     JSON.parse  前缀发现@      可省输出
----------------------------------------------------------------
代码围栏包裹       98     失败         0          100%
开头闲聊         99     失败         0          100%
尾逗号          57     失败         56          2%
单引号          56     失败         1          98%
NaN          58     失败         40          31%
行注释          63     失败         23          63%
前导零          57     失败         41          28%
裸键           54     失败         1          98%
Python 字面量   66     失败         61          8%
被截断          53     失败         53          0%
枚举外的值        56     通过         52          7%
缺必填          48     通过         47          2%
数字带单位        59     通过         40          32%
全角冒号         56     失败         11          80%
省略号占位        73     通过         73          0%
----------------------------------------------------------------
样本 15 条;前缀校验平均可省输出 43.3%(平均在全文 56.7% 处就能判死)

几个我读完这张表才想明白的事:

代码围栏和开头闲聊,第 0 个字符就能判死。这种我以前是等它说完再 JSON.parse 报错,白等一整段。

尾逗号只能省 2%,因为逗号本身合法,非得等到后面那个 } 才能确认。被截断和 "..." 是 0%——它们得等全文结束才知道。也就是说,早停不是万能的,有 3/15 条它一点忙都帮不上。

JSON.parse 拦不住的那 27%

我把这 15 条按"JSON.parse 能不能抓"分成两组,结果有点难看:

语法错 11 条:不带 schema 的前缀校验平均可省 55.2%
契约错  4 条(JSON.parse 全部放行):带 schema 后抓到 3/4 条
→ 只做 JSON.parse 会漏掉 4/15 = 27% 的样本,这些是"格式对、内容错"

"status":"待定"(枚举里没有)、qty 字段整个消失、"qty":"2台"、"location":"..."——这四条 JSON.parse 全部放行,因为它们在语法上挑不出毛病。我以前就是靠 JSON.parse 兜底的,等于我兜了个寂寞:格式错的那部分它抓得住,内容错的那部分一条都抓不住。

所以我给扫描器加了 schema:字段类型、必填、枚举。同一个扫描器,不传 schema 就退化成语法检查,传了才管契约。这一步做完,"枚举外的值"在第 52 个字符就被判死,"数字带单位"在第 40 个。

把校验挪进生成过程,值多少

知道最早发现位置之后,我就能算账了。用这 15 条的真实分布跑蒙特卡洛,每条失败就整条重来,最多 3 次:

做法                     最终可用率   平均吐出字符数   相对目标长度
--------------------------------------------------------------
① 生成完再 JSON.parse      34.0%        167           1.94×
② 边生成边校验(早停)       34.3%         87           1.01×
③ 约束解码(一次成型)      100.0%        86           1.00×
--------------------------------------------------------------
② 相对 ① 省 47.9% 的输出

②和①的可用率几乎一样(都在 34%),但输出量差了将近一倍。这才是早停真正的价值——它不是提高成功率,是让失败变得便宜。同样的 3 次机会,我少付了一半的钱。

干脆不让它错

早停治的是"错了快点知道"。再往前一步是"根本不让它错":每生成一个字符之前,先把词表里会让当前前缀变得不合法的全部 mask 掉。我的实现很笨——把每个候选字符试投一遍:

function maskOf(prefix, schema, cand) {
  const out = [];
  for (const c of cand) {
    if (scanLoose(prefix + c, schema).err === null) out.push(c);
  }
  return out;
}

笨是笨,但它是真的。把这 15 条坏输出灌进去,逐字符问"这个字符在 mask 里吗":

字符级 mask 拦下 13 条,另有 1 条靠"没写完不许停"拦下,合计 14/15 条
漏过去的:省略号占位

漏掉的那条值得单说。"location":"..." 在语法上完美、在 schema 上也完美,它是一个完全合法的字符串,只是内容是我不要的。约束解码保证的是"合法",不是"对"——这条边界我在自己跑之前是含糊的。

另外那 1 条靠"没写完不许停"拦下的,是被截断。字符集管不住模型什么时候停,得把 EOS 也纳入约束。OpenAI 官方文档里那句限定条件说的正是这事:只有在响应没有 refusal、且没被 finish_reason 提前中断的情况下,才保证输出合规(信息源:OpenAI Structured Outputs 指南,核对日期 2026-10-20)。

它不报错,它悄悄改

这是约束解码让我后背发凉的一面。我让模型写 status 为"待定",一个枚举外的值:

模型原本想写 : {"asset_id":"PC-0417","name":"联想","qty":2,"status":"待定"}
约束解码写出 : {"asset_id":"PC-0417","name":"联想","qty":2,"status":"在库"}
→ JSON.parse 通过,status = "在库"(我原本要的是"待定")
→ 中途被 mask 拒了 2 次

自由生成时,"待定"会老老实实写出来,然后被我的 schema 校验打回——我能看见。约束解码下,它永远写不出"待定",mask 逼着它在合法字符里重选,最后交给我一个漂漂亮亮的 "在库"。

错误从显式变成了隐式。 我拿到的是一条格式完美、内容错误的记录,而且日志里干干净净。唯一会说话的是那个"被拒次数",我现在把它和记录一起存下来,超过阈值就挂起待人工。不记这个数,我永远不知道自己被改过。

落到台账脚本上

最后说回开头那堆清单。我现在的管线是三层,各管一件事:

const STATUS_MAP = { '在库':'在库', '闲置':'在库', '借出':'借出', '维修':'维修', '报废':'报废' };
// ① 清洗层:三种日期格式、带单位的数量、全角分隔符
// ② 契约层:scanLoose + schema(类型/必填/枚举)
// ③ 常识层:schema 之外的值域判断
const bizRule = (r) => (r.qty !== null && r.qty > 0 && r.qty <= 50) || '数量越界';

8 行样本跑完:入库 5 行,挂起 3 行。挂起的原因分别是"待定"没法映射(清洗层没兜住,被契约层抓住)、数量缺失、以及这个:

挂起 NB-0233:数量越界 → {"asset_id":"NB-0233","name":"Dell Latitude 5440","qty":5440,...}

Dell Latitude 5440 里的 5440 被我当成数量抽出来了。这是我自己跑出来才发现的 bug,格式校验和 schema 校验都抓不到它——5440 是个合法的 integer。只有"一台笔记本不可能有 5440 台"这种常识能抓住。所以第三层不能省。

我为什么还自己写了一遍

这些轮子现成的都有:OpenAI 那边开 response_format: {type:"json_schema", strict:true},官方的说法是模型不会漏必填字段、也不会幻觉出枚举外的值(信息源:OpenAI Structured Outputs 指南与 2024-08 的发布公告,核对日期 2026-10-20);本地 vLLM 用 --guided-decoding-backend 指定后端,请求里带 guided_json / guided_choice / guided_regex / guided_grammar,其中 xgrammar 是拿下推自动机做批量约束解码的那个(信息源:vLLM-Ascend 结构化输出指南,核对日期 2026-10-20;OpenAI 官方那两页我这台机器取回来是 403,只按搜索缓存的官方正文引用,不挂链接)。

我照样自己写了一遍,两个原因。一是我这边的调用链不一定走这两家,本地脚本换引擎时我不想改业务代码;二是我想知道 mask 到底拦得住什么,而这件事只有自己跑一遍才答得出来——比如"漏网的只有合法但内容不对那一类",我读文档是读不出这个边界的,是我拿 15 条样本撞出来的。

顺便说一句,我不建议为了拿结构化输出就换掉手上的引擎。省下来的那 48% 输出,只在"失败率高、重试次数多"的场景里才明显;本来一次就过的活,加了约束解码也省不出东西。

我的判断

约束解码值得开,但它不是免死金牌,而是把风险挪了个位置:从"我看见它错了"挪到"它错了但我看不见"。所以我现在是三件事一起上——能约束就约束(省掉 48% 的无效输出),约束完仍然记 schema 校验(防实现差异),并且把"被 mask 拒绝的次数"当成一等公民记进日志。

至于那个漏网的 "...",我不打算再加规则了。合法但内容不对这类问题,加规则是加不完的,我改成入库后按比例抽查。剩下的精力应该花在让模型少想一点格式、多想一点内容上。

下次再碰到模型吐结构化数据,我还是会先花半小时把坏样本攒成一张表再动手。我那 15 条故障模式比后面所有的优化都值钱:它直接告诉我该在哪一层加拦截,而不是凭感觉把三层的规则都堆上去。

最后修改:2026 年 10 月 20 日
如果觉得我的文章对你有用,请随意赞赏