LLM 吐的 JSON 十次坏三次,别急着骂模型——60 行容错解析器先兜住
结论:让 Agent 去调工具,模型给的 JSON 经常是不规整的,这很正常,第一反应不该是换模型,该是加一层容错解析。
我一开始也踩过这坑。Agent 跑着跑着突然崩,看日志一查,json.loads 抛异常了。盯半天以为模型坏了,后来把原始输出打印出来才发现,模型给的「JSON」长这样:
```json
{"city": "北京", "temp": 25}
```
外面套了层 markdown 代码围栏。你说它错吧,里面的 JSON 是对的;你说它对吧,json.loads 直接给你一个 JSONDecodeError。这种「内容对、外层报错」的情况,占了我遇到坏 JSON 的绝大多数。
一、模型吐的坏 JSON,常见就这几种
我把踩过的坑归了类,基本就六种:
- markdown 围栏:
```json ... ```包着,模型觉得这样「好看」。 - 尾部多余逗号:
{"a": 1,},JS 写法,JSON 不认。 - 单引号:
{'a': 1},Python 字面量习惯。 - Python 字面量:
True/False/None,没转成true/false/null。 - 键没加引号:
{city: "北京"},省了引号。 - 前后夹说明:
查询结果如下:{...} 以上供参考。,模型想跟你多说两句。
这六种,没有一种是模型「不会」,全是它「顺手」。它读了一堆带围栏、带注释的代码,写出来自然带味。你硬拿标准 json.loads 去卡它,等于拿文言文语法去卡口语,能对,但天天对不上。
二、代码:先试标准解析,崩了再修
思路很直接,别一上来就正则硬扛:
- 先走一遍标准
json.loads。它能过就别动,零开销,也避免误伤本来就规范的输出。 - 只有它崩了,才进修复流程,按从外到内的顺序收拾:剥围栏 → 抠出
{...}→ 转字面量 → 去多余逗号 → 补键引号 → 单引号转双引号。 - 修完再
json.loads一次。还崩,那就真不是格式问题,该抛就抛。
下面是完整代码,纯标准库,只用 json 和 re,不联网,直接跑:
"""LLM 吐的 JSON 总是坏的:一个容错解析器。
纯标准库,无第三方依赖。用法:python3 tolerant_json.py
"""
import json
import re
def _strip_fences(s: str) -> str:
"""剥掉 markdown 代码围栏(三个反引号包着的 json 块)。
源码里故意不直接写连续三个反引号,免得这段代码嵌进 markdown
代码块时把围栏提前闭合——用单反引号拼出 fence。
"""
fence = "`" * 3
m = re.search(fence + r"(?:json)?\s*(.*?)" + fence, s, re.S)
return m.group(1).strip() if m else s.strip()
def _extract_json(s: str) -> str:
"""从夹着说明文字的输出里,抠出最外层的 {...} 或 [...]。"""
for open_ch, close_ch in (("{", "}"), ("[", "]")):
start, end = s.find(open_ch), s.rfind(close_ch)
if start != -1 and end != -1 and end > start:
return s[start:end + 1]
return s
def _fix_literals(s: str) -> str:
"""Python 字面量转 JSON:True/False/None → true/false/null。"""
s = re.sub(r"\bTrue\b", "true", s)
s = re.sub(r"\bFalse\b", "false", s)
s = re.sub(r"\bNone\b", "null", s)
return s
def _drop_trailing_commas(s: str) -> str:
"""去掉 ] 或 } 前多余的逗号:{"a":1,} → {"a":1}。"""
return re.sub(r",\s*([\]}])", r"\1", s)
def _quote_keys(s: str) -> str:
"""给没加引号的键补上双引号:{name: 1} → {"name": 1}。"""
return re.sub(r"([{,]\s*)([A-Za-z_]\w*)\s*:", r'\1"\2":', s)
def _single_to_double_quotes(s: str) -> str:
"""把双引号外的单引号换成双引号(简单场景:值里不含撇号)。"""
out, in_dq = [], False
for ch in s:
if ch == '"':
in_dq = not in_dq
out.append(ch)
elif ch == "'" and not in_dq:
out.append('"')
else:
out.append(ch)
return "".join(out)
def repair(s: str) -> str:
"""按从外到内的顺序修:围栏 → 抠 JSON → 字面量 → 逗号 → 键 → 引号。"""
s = _strip_fences(s)
s = _extract_json(s)
s = _fix_literals(s)
s = _drop_trailing_commas(s)
s = _quote_keys(s)
s = _single_to_double_quotes(s)
return s
def loads(s: str):
"""容错解析:先走标准 json.loads,崩了就修复后再试。
返回 (解析结果, 是否动过修复)。修不好 → 抛 json.JSONDecodeError。
"""
try:
return json.loads(s), False
except (json.JSONDecodeError, ValueError):
return json.loads(repair(s)), True
我在本地跑了一遍(Python 3.12,无依赖),输入了上面那六种坏 JSON,实际输出:
==============================================================
容错 JSON 解析器 · 6 个坏 JSON 实测
==============================================================
[markdown 围栏]
原始 : ```json
{"city": "北京", "temp": 25}
```
✅ 修复后解析
结果 : {'city': '北京', 'temp': 25}
[尾部多余逗号]
原始 : {"city": "北京", "temp": 25,}
✅ 修复后解析
结果 : {'city': '北京', 'temp': 25}
[单引号]
原始 : {'city': '北京', 'temp': 25}
✅ 修复后解析
结果 : {'city': '北京', 'temp': 25}
[Python 字面量]
原始 : {"city": "北京", "is_rain": False, "note": None}
✅ 修复后解析
结果 : {'city': '北京', 'is_rain': False, 'note': None}
[键没加引号]
原始 : {city: "北京", temp: 25}
✅ 修复后解析
结果 : {'city': '北京', 'temp': 25}
[前后夹说明]
原始 : 查询结果如下:{"city": "北京", "temp": 25} 以上供参考。
✅ 修复后解析
结果 : {'city': '北京', 'temp': 25}
==============================================================
小结:6/6 个坏 JSON 都解析成功。
→ 先试标准解析(零开销),崩了再修,别一上来就正则硬扛。
→ 教学版只兜常见坑;生产里单引号带撇号、深层嵌套这些,
上 json5 / json-repair 这类专业库更稳。
==============================================================
6 个全过。
三、规则和步骤
先试标准解析这一步不能省。 有人图省事,上来就把所有输出过一遍正则修复。别。规范输出被你那几条正则来回揉,反而可能被误伤,比如一个值里本来就有 True 这个词,你一把替换成 true 就改了人家语义。先 json.loads 一次,能过就别碰,既省性能又安全。
单引号那步是最容易出错的,我做了详细注释。 我那个 _single_to_double_quotes 是逐字符扫、维护「在不在双引号里」的状态,双引号外的单引号一律当双引号。这对 {'a': 1} 这种简单场景没问题,但要是值里本身带撇号,比如单引号包着一个含撇号的值 {'note': 'it's fine'},就会误伤。所以我在代码注释里写明白了:教学版只兜常见坑,真生产里碰到单引号带撇号、深层嵌套这些,直接上 json5 或 json-repair 这类专业库,不能忽视。 。
这层该加在哪? 加在「模型输出」和「工具入参」之间。Agent 拿到模型输出,先过这层容错解析,再输入到工具。这样工具那边永远拿到干净的 dict,不用每个工具都自己防一遍问题 JSON。一层兜住,别把防御撒得到处都是。
别把容错当万事大吉。 容错是兜「格式问题」的,不是兜「内容错」的。模型给你 {"city": "北京"} 格式干干净净,但它答非所问、编了个不存在的城市,这层管不了,那是另一条线(参数校验、幻觉检测)的事。不能指望一个解析器解决所有问题。
四、我在做 AI 可靠性这条线
上面这 60 行是我做 AI 可靠性工具AIHCC时截出来的一小块。我把它当教学版重写的:思路一样,去掉工程细节。真跑在生产里的那版还接了 schema 校验,解析完的 dict 还得对一遍工具的入参定义,字段齐不齐、类型对不对,那是下一道需要检测的。
如果你也在把 Agent 接进生产(让它调工具、动数据),输出解析这道坎迟早碰上。上面这段先把常见的坑兜住,比每次崩了去翻日志强。
代码说明:示例基于 Python 3.10+,纯标准库(json / re)、无第三方依赖、不联网;文中输出为本地实测结果(Python 3.12.2)。容错解析兜的是「格式脏」(围栏/逗号/引号/字面量),不兜「内容错」(答非所问、编造值),后者需另加参数校验与幻觉检测。单引号处理为简单场景,值内含撇号或深层嵌套建议用 json5 / json-repair 专业库。
#Agent #大模型 #Python #工程实践 #可靠性