侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 104 篇文章
  • 累计收到 7 条评论

JSON 能解析了,字符串里的逗号也没了

2026-9-22 / 0 评论 / 14 阅读

JSON 能解析了,字符串里的逗号也没了

模型返回的 JSON 不合法时,先修再解析几乎成了默认动作:去掉尾随逗号,把 Python 风格的 TrueNone 换成 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,} 在语法上本来就是合法内容,删掉之后语法依然合法,只是字段值短了一个字符。要发现它,只能把原文和修后的字段做一次比对,或者在修复函数里返回一份改动记录。落到工具调用上,坏处是参数被改短后照样能执行,返回看着正常,日志里的入参却和模型给的已经不一样了。

全局替换 TrueNone:字符串里的英文被改了

【危害 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、或者用工具调用而不是自由文本,都比在解析层打补丁划算。这一点本机没有用真实模型验证过,模型侧的行为这里不做断言。

    🤞 分享