☰
大模型输出的畸形 JSON 怎么修:25 条样本 6 种策略实测,100% 能解析却有 24% 把值改坏了
2026/10/14 10:08:30 网站建设 项目流程

摘要

让大模型输出 JSON,再在下游解析——这是 Agent 工具调用、结构化抽取、分类路由里最普遍的一条链路。几乎所有教程都会教你加一层"容错解析":剥掉 markdown 围栏、去掉注释和尾逗号、括号不配平就补全。我把 25 条手工构造的畸形 JSON 样本(覆盖 9 类真实畸形形态)交给 6 种修复策略跑,分别统计解析成功率和语义正确率,结论和直觉相反:最能"修好"的那套组合管线做到了 100% 可解析,但其中 24% 的样本值被改坏或被发明;而单引号类样本被修复后直接返回空对象{}——业务代码看到的是一个合法但全空的记录,不会报错,只会静默丢数据。本文给出完整可运行的实验代码、实测数据、以及一条"哪些损坏连 Schema 校验都拦不住"的判定路径。

一、为什么"解析成功"是个错误的指标

先看一条真实的实验输出(本机 Windows + Python 3.11.5,下同):

输入: {'k': 'v', 'n': 1} # 模型用 Python 字面量风格输出,全单引号 策略: 回退式括号栈补全 输出: {} # 解析成功,不抛异常 期望: {'k': 'v', 'n': 1}

json.loads没报错,函数正常返回,下游拿到一个空字典。如果这段 JSON 是要写进数据库的一条工单,结果就是插入了一条空记录;如果它是 Agent 的工具参数,结果就是用默认值调了一次工具。整条链路上没有任何一处会报警。

这就是我为什么把实验指标拆成两个:

  • parse_ok:能不能 loads 开;
  • sem_ok:解析出来的对象是否等价于模型本想给出的那个值(每条样本我都写了expected,所以可判定)。

只看parse_ok,一个把数据全吞掉的修复器是满分选手。

二、实验设计

语料是 25 条本地构造的样本,覆盖 9 类畸形形态。必须说清口径:这些样本不是从真实模型输出里采集的,不代表真实错误分布;它们是按经验清单构造的"每类形态的最难那一档"。本文所有数字都来自这份固定语料,换语料结论的相对排序我认为不变,绝对百分比会变。

类别条数典型样本
fenced(markdown 围栏)3` ```json\n{…}\n````
chatter(前后缀客套话)3好的,下面是结果:{...}
trailing_comma(尾逗号)3{"a": 1, "b": 2,}
single_quote(单引号)2{'k': 'v'}
comment(注释)3{"a": 1, // 说明
value_trap(值内含易误伤字符)2{"note": "items: a, b, }"}
truncated(max_tokens 截断)3{"title": "x", "author":
raw_control(值内裸换行/制表符)2{"desc": "第一行+ 裸换行
fullwidth(全角标点)4{“a”:1,“b”:2}

6 种策略:

defs1_direct(s):# S1 什么都不做returnjson.loads(s)defs2_balanced(s):# S2 括号配平截取:字符串感知地frag=balanced_extract(s)# 找第一个 { 到它的配对 },切出来再 loadsiffragisNone:raiseValueError("no balanced fragment")returnjson.loads(frag)defs3_clean(s):# S3 正则清洗:剥围栏 -> 截取 -> 去注释 -> 去尾逗号t=drop_trailing_commas(strip_comments(clean_slice(s)))returnjson.loads(t)defs4_stack(s):# S4 回退式栈补全:从末尾逐字符回删(最多 48 步),returnstack_repair(s)# 每删一次就扫描括号栈补上 " ] },取第一个能 loads 的前缀defs5_pipeline(s):# S5 组合管线:S3 的各阶段逐个尝试 loads,...# 仍失败才降级到 S4,最后一步才做全角->半角映射defs6_naive_fullwidth(s):# S6 全局全角归一:网传"先统一替换标点再解析"returnjson.loads(to_ascii_punct(s))

S4 的"发明 null"这一步是关键设计,也是后面所有麻烦的来源:

defclose_open(body):"""给一个残缺前缀补出可解析的候选串。"""in_str,stack=_scan(body)ifin_str:body+='"'# 裸字符串没闭合 -> 补右引号body=body.rstrip()m=DANGLING_KEY_RE.search(body)# 结尾是个残缺键名 "tag -> 整个丢掉这个键ifm:body=body[:m.start(1)+1]whilebody.endswith(","):body=body[:-1].rstrip()ifbody.endswith(":"):body+=" null"# 键在、值被截断 -> 补一个 null 占位tail=["}"ifch=="{"else"]"forchinreversed(stack)]returnbody+"".join(tail)

每策略跑 5 轮,耗时取 min~max。

三、实测结果

策略解析成功率语义正确率静默损坏单样本耗时
S1 直接 loads20%20%0%2.1~3.5 µs
S2 括号配平截取40%40%0%3.2~4.1 µs
S3 正则清洗56%48%8%3.8~5.3 µs
S4 回退栈补全80%44%36%45.4~62.2 µs
S5 组合管线100%76%24%24.9~27.3 µs
S6 全局全角归一24%20%4%3.7~4.5 µs

三个反直觉的点:

  1. 解析成功率和语义正确率的差,只在"强修复"策略上出现。S1/S2 不做任何值改写,所以两个数字完全相等;S4 把差距拉到 36 个百分点——它"修好"的 36% 样本里,值是被它自己编出来的。
  2. S5 比 S4 更快(24.9~27.3 µs vs 45.4~62.2 µs)。因为组合管线把最贵的回退式补全放在最后,多数样本在前面的廉价阶段就返回了。整条 S5 管线在全语料上的单样本分布是 min 5.3 / 中位 12.2 / max 134.8 µs——最坏情况出现在客套话里嵌套花括号那条样本,它把回退补全的 48 步全部耗尽。
  3. S5 在本语料上 0/25 解析失败,看起来"完美",但它的代价藏在第二列:76% 语义正确率。

四、静默损坏长什么样

完整清单(5 轮结果完全一致,这是确定性过程):

[S3 正则清洗] 值内含逗号+右花括号 期望: {'note': 'items: a, b, }', 'n': 1} 得到: {'note': 'items: a, b}', 'n': 1} # 正则 ,\s*([}\]]) 吃进了字符串内部 [S3 正则清洗] 值内含逗号+右方括号 期望: {'arr_hint': 'seen [1, 2, ]'} 得到: {'arr_hint': 'seen [1, 2]'} [S4 回退栈补全] 块注释 期望: {'a': 1} 得到: {'a': None} # 值被发明成 null [S4 回退栈补全] 值中间截断 期望: {'title': '开源推理框架', 'author': '张三'} 得到: {'title': '开源推理框架', 'author': None} # 字段在,值没了 [S4 回退栈补全] 值内裸换行 期望: {'desc': '第一行\n第二行'} 得到: {'desc': '第一行'} # 后半段内容直接蒸发 [S4/S5] 全单引号、全角标点包键 得到: {} # 最危险的一种:合法空对象 [S6 全局全角归一] 合法 JSON 但值内含全角逗号 期望: {'steps': '第一步,第二步,第三步'} 得到: {'steps': '第一步,第二步,第三步'} # 本来就是合法 JSON,被"修"坏了

最后一条 S6 值得单独说:输入本身是完全合法的 JSON,S1 到 S5 全部原样正确解析。只有那个"先把全角标点统一换成半角"的建议,把一个正确结果改错了。任何"全局字符替换"都必须限定作用域在字符串字面量之外,否则它对合法输入是纯破坏。

五、加上字段校验能不能兜住

我用三层递增校验重测了这 18 条损坏样本:V1 只查键是否齐全;V2 查键齐全 + 类型一致;V3 再加值契约(字符串非空、URL 形状、整数范围)。

策略损坏条数V1 漏V2 漏V3 漏
S3 正则清洗2222
S4 回退栈补全9422
S5 组合管线6100
S6 全局全角归一1111
合计18855

分两种情况看,这张表才有意义:

  • 损坏形态是"缺字段 / 空对象 / null 发明"时,校验很有效:S5 的 6 条损坏被 V2 全数拦下(漏 0)。
  • 损坏形态是"字符串值被删了字符 / 被换了标点"时,三道校验全漏:S3 的 2 条和 S6 的 1 条,键在、类型是 str、长度非空、URL 形状也还是 URL——V3 比 V2 一条都没多拦住(合计 5 vs 5)。

所以"加个 Schema 校验就安全"这句话,成立条件是修复器的破坏方式是删字段。它要是删字符,你的校验就是摆设。

六、我建议的判定顺序

是

否

是

否

单引号/全角包键

截断/裸控制字符

是

否

模型原文 raw

json.loads 直接成功?

进入字段校验

剥围栏 + 配平截取
只删字符串外噪声

loads 成功?

失败原因可判别?

重试或降级:
不做字符替换

补全只用于展示类字段
禁止写库/禁止执行工具调用

键齐全 + 类型一致 +
枚举/长度/正则值契约?

放行

重试一次并把校验错误回传模型

关键约束是 H 那条:补全出来的结果不进写路径。截断样本补全后一定缺信息,区别只在于你让它缺得可见(报错)还是缺得无声(author: None落库)。

七、避坑指南

  1. 不要用正则去尾逗号。re.sub(r",\s*([}\]])", r"\1", s)是全网流传的写法,它不看字符串边界,实测把"items: a, b, }"吃成"items: a, b}",且解析照样成功。要删尾逗号必须走括号栈/字符串态感知。
  2. 不要用正则删注释。同一条管线在处理{"img": "https://cdn.example.com/a.png"}时,//被当注释起点,整条样本解析失败(实测 S3 在该样本 0/1)。失败还算好的,值短一点就是静默截断。
  3. 不要做全局全角→半角替换。实测它让一条完全合法的 JSON 值被改坏(第四节 S6 例子)。要做也只对字符串字面量之外的字符做。
  4. 补全器返回{}必须当失败处理。判据很简单:解析出的对象为空、而期望结构非空。修复器永远"想要"解析成功,它会把你最想保留的字段先丢掉。
  5. null和"字段缺失"在数据库里是两回事。截断补全出的author: None会覆盖掉一份已有的正确作者名。补全后应把可疑字段单独打标记,别让它参与 upsert。
  6. 别把"能解析"当成验收指标。这条链路的正确验收指标是"解析后语义等价"或"校验通过",本项目一开始就踩过:只看 parse_ok 的话 S4 是 80 分的策略,看 sem_ok 它只有 44 分。
  7. 重试预算要按"损坏形状"估,不能按解析失败率估。S5 在本语料里解析失败 0/25,看起来一次重试都不用;但按校验口径,它的 6 条损坏里只做键检查只能发现 5 条,第 6 条(author被发明成null、键却齐全)要靠类型校验才揪出来。而 S3/S6 那 3 条"改值型"损坏,键、类型、值契约三道全漏——按解析失败率配重试预算,会配出 0 次重试。
  8. 源头约束优先于事后修复。用 JSON Schema 在解码阶段做硬约束、或直接让分类任务不输出文本,是把这条路绕开的唯一办法。这一条我没有实测数据(本机没有可跑的约束解码推理服务),只是给结论定位;但本文所有修复层数字都在说明同一件事:事后修复改不掉"值本身错了"的问题。

八、结论

  • 事后修复层能把解析成功率从20% 抬到 100%(同一份 25 条语料,本机实测),这是它真实存在的价值。
  • 代价是24% 的样本值被改坏或被发明,其中"空对象{}"和"null发明"这两类会被字段校验拦下,而"字符串被删字符/被换标点"这一类三道校验合计漏 5 条、一条没拦住。
  • 因此:修复层只该用于展示类降级,不该出现在写库和工具调用的路径上;写路径要么靠解码阶段的 Schema 硬约束,要么解析失败就直接重试,不要在中间加"聪明"的正则。
  • 若要加校验,类型一致的键校验是必须的(实测拦掉 S5 全部 6 条损坏),但别指望值契约有额外收益(本语料里 V3 相对 V2 零增量)。

复现方式:demos/json_repair_strategies.py,PYTHONIOENCODING=utf-8 python json_repair_strategies.py,运行留档demos/json_repair_strategies.transcript.txt(Windows + Python 3.11.5)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询