
模型返回的 JSON 不合法时,先修再解析几乎成了默认动作:去掉尾随逗号,把 Python 风格的 True 和 None 换成 JSON 的形式,再切出里面的对象。脚本 tools/verify-json-repair-hazards.py 把这三种修法各自跑了一遍,危险的地方在于它们全都不报错,改完照样能解析,数据已经不一样了。
全局替换 ,}:字符串里的逗号一起被吃掉
【危害 1】全局正则去尾随逗号,会不会误伤字符串里的 ,}
原文 : {"note": "表格里这行是 a,}", "keep": 1}
全局 re.sub : {"note": "表格里这行是 a}", "keep": 1}
解析结果 : {'note': '表格里这行是 a}', 'keep': 1}
note 字段 : '表格里这行是 a}' ← 逗号没了,数据被改了,而且不报错
re.sub(r',(\s*[}\]])', ...) 这类写法只看逗号后面跟的是什么,不管这个逗号在不在字符串里。原文里那个 a,} 明明是文本内容,被当成尾随逗号删掉了。解析成功,字段里少了一个字符,调用方完全看不出来。
正确做法是扫描一遍,记住当前是否在字符串内(以及有没有被反斜杠转义),只在字符串外删。
只在字符串外修: {"note": "表格里这行是 a,}", "keep": 1}
note 字段 : '表格里这行是 a,}' ← 逗号还在
真的尾随逗号 : {"tool": "calc", "args": {"expr": "1+1",},}
修完 : {"tool": "calc", "args": {"expr": "1+1"}}
解析 : {'tool': 'calc', 'args': {'expr': '1+1'}}
同一个函数,对真尾随逗号仍然有效,对字符串里的逗号不再动手。
这类改动静悄悄发生的原因在于解析器不参与判断:字符串里的 a,} 在语法上本来就是合法内容,删掉之后语法依然合法,只是字段值短了一个字符。要发现它,只能把原文和修后的字段做一次比对,或者在修复函数里返回一份改动记录。落到工具调用上,坏处是参数被改短后照样能执行,返回看着正常,日志里的入参却和模型给的已经不一样了。
全局替换 True 和 None:字符串里的英文被改了
【危害 2】把 True/None 归一,会不会误伤字符串里的英文单词
原文 : {"note": "结果是 None,不是 True", "dry": True}
全局 replace: {"note": "结果是 null,不是 true", "dry": true}
解析结果 : {'note': '结果是 null,不是 true', 'dry': True} ← 字符串被改了
只在字符串外修: {"note": "结果是 None,不是 True", "dry": true}
解析结果 : {'note': '结果是 None,不是 True', 'dry': True}
str.replace('True', 'true') 的误伤更隐蔽,因为替换后的结果仍然是合法 JSON,解析器不会有任何意见。字符串被改这件事只有比对原文才能发现。要在字符串外做替换,另外还得注意词边界:TrueValue 这样的标识符不该被切一半。这类字段在工具参数里并不少见,比如一个名叫 dryRun 的字段值写成 True,旁边的说明文字里提到同一件事,一次全局替换就把说明文字也改了。
贪婪正则切对象:两个调用粘在一起,或者第二个被静默丢掉
【危害 3】一次切出所有顶层对象,而不是只切第一个
原文 : {"tool": "a", "args": {}}{"tool": "b", "args": {"expr": "1+1"}}
贪婪正则抓到的 : '{"tool": "a", "args": {}}{"tool": "b", "args": {"expr": "1+1"}}' ← 两个粘一起,loads 直接报错
JSONDecodeError: Extra data: line 1 column 26 (char 25)
只切第一个 : '{"tool": "a", "args": {}}' ← 能解析,但第二个调用被静默丢掉
切出全部 2 个 : [{'tool': 'a', 'args': {}}, {'tool': 'b', 'args': {'expr': '1+1'}}]
带引号花括号的 : ['{"note": "他说 { 还没写完"}', '{"tool": "b"}']
这一条对应模型一口气给出多个工具调用的情形。re.search(r'{.*}') 会把两个对象连中间的空白一起抓走,解析直接失败。改成非贪婪 {.*?} 能解析,但只拿到第一个,第二个调用没有任何提示就丢了,表现是「模型说要调用两个工具,实际只跑了一个」。
按大括号配对切是能同时解决这两个问题的写法,引号状态要一起跟踪,最后一行那个带花括号的字符串就是检验点。
修之前先问能不能不修
这几个动作的风险都不在「修错了会不会崩」,而在「修对了但改了内容」。可用的顺序是:先按原样解析,解析不过再决定修不修。真要修,只做字符串外的替换,切对象按配对做,并且把原文和修后的结果都留着,事后能比对。
这几条里我唯一愿意留下的是按配对切对象这个写法,它同时解决了粘连和丢调用两个问题,代价是一段几十行的状态机,比正则长得多。另外两条在字符串外做替换就能修好,麻烦的是要维护「当前是否在字符串内」这个状态,代码量上一个量级,换来的收益也更明确:不会静默改数据。
另外,需要修的频率本身是个信号。同一个提示词下模型反复吐不合法 JSON,说明约束没给够:把输出格式写进系统提示、给一个 schema、或者用工具调用而不是自由文本,都比在解析层打补丁划算。这一点本机没有用真实模型验证过,模型侧的行为这里不做断言。