先说个真实场景。上一周我在处理项目里的大模型报表配置,提示词里白纸黑字写了“只输出JSON,不要加任何解释”,结果模型还是把JSON塞进了Markdown代码块里,前后各带一句“好的,这是你要的配置”。我把整段返回当作json.loads直接喂进去,第一秒就抛了JSONDecodeError。这类问题在接AI输出的时候几乎人人都会撞上,不是偶发,是常态。
本文要聊的就是怎么用一个可复用的代码片段,把AI返回的“脏JSON”修复成能直接 json.loads 的干净数据。我会把整个处理思路、每一层修复的原理、完整可运行的实现代码、以及我踩过的坑全部拆开讲。适合正在接大模型API、做AI Agent、写自动化和爬虫脚本的开发者,新手看完也能直接抄走用。
1. 先搞明白AI是怎么把JSON写坏的
1.1 问题根源不在语法,在“夹带”
我拆解过几百条AI返回的JSON,发现真正的问题很少是传统JSON语法层面的错误,更多是AI“太爱说话”造成的结构污染。最典型的是Markdown围栏——模型习惯把JSON包在三引号加json标注的代码块里,你还要专门剥掉一层。其次是周围附带的自然语言,比如“根据您的输入,我生成了如下内容:”这种引导语,直接让整个字符串不再是合法JSON。
更麻烦的是嵌套场景。比如你让AI生成一段配置,它内部有一个数组,模型可能因为中间某条字符串太长而把JSON截断了,后面缺了半个结构;也可能在数组最后一项后面多打一个逗号,因为人类写代码时习惯了尾逗号,模型也跟着有样学样。还有键名不加引号、字符串用了单引号、布尔值写成True这种Python风格……这些都是高频故障。
所以我的第一个判断是:修复AI JSON不能靠一条正则梭哈,必须做成“多阶段、可降级”的清洗管道。每一个阶段只解决一类问题,而且每修一步就用json.loads试一次,能过就直接返回,避免过度改写。
1.2 修复思路的核心理念:分层剥离与渐次宽松
我采用的策略是“先提取、再修补、后兜底”的三段式。先提取是指从AI返回的原始文本里把可能存在的JSON块抠出来,去掉前后自然语言和代码块围栏。再修补才是修引号、键名、尾逗号、截断这些结构问题。后兜底是在标准解析彻底失败时,再尝试ast.literal_eval、json5这类宽容解析手段。
这里有个关键设计意图:每一层修复做完都要立刻尝试json.loads,而不是把所有修复步骤全部跑完再解析。因为AI每次出错的情况不一样,提前return可以省掉很多不必要的文本改动,降低误伤正常JSON的风险。修复操作不是越多越好,恰到好处的提前终止反而是最优解。
分层还有一个好处,就是方便排查。哪一步报错,你能精确知道是提取没找着、引号修坏、还是截断导致的不完整。比一上来就抛一个晦涩的JSONDecodeError直观得多。
2. 整体方案设计与工具选型
2.1 五阶段修复管道设计
我把清洗流程拆成五个阶段,每一个阶段对应一类典型问题:
- 阶段一:块提取。定位候选JSON区间,处理Markdown围栏与首尾噪声。
- 阶段二:引号修复。把键名和字符串值的单引号替换为双引号,并补全没加引号的键名。
- 阶段三:键名规范化。处理Python风格True/False/None,以及不带引号的裸键。
- 阶段四:尾逗号清理。删除数组和对象末尾的多余逗号。
- 阶段五:截断补齐。尝试最大合法前缀,自动补闭合括号。
每个阶段函数都是纯字符串变换,输入输出都只是str,方便单独测试和扩展。我在设计时强制要求每个函数都幂等——就是同一输入跑两遍和跑一遍结果一致,这样可以安全地串联调用,不会因为二次处理产生新问题。
2.2 自己写还是用json_repair
开源里有一个json_repair库,可以直接pip install,很多场景下它做得比我这个方案更暴力也更全面。但我在生产环境里还是选择自己维护一套轻量实现,原因有三。第一,json_repair对超大JSON的去格式化重构会改变原有排版,而我有时需要保留原始文本结构能力;第二,团队需要审计每一层修复逻辑,黑盒库不透明;第三,大多数AI输出问题集中在“围栏剥离+前缀截断+尾逗号”三个点,根本不需要完整重写一个解析器。
所以我建议的选型是:如果你只要一次性处理几个文件,直接上json_repair最省事;如果你要把这个能力嵌入到Agent系统、批量处理管道里,并且希望对失败路径做精细控制,那自研一个20行左右的核心修复函数更合适。工具不在多,在于你能掌控它的边界行为。
3. 核心代码实现与逐段拆解
3.1 数据提取:从噪声文本中挖出JSON本体
先看一下块提取。我定义了一个extract_json_block方法,它先检查有没有Markdown围栏,有就按围栏切出中间内容;没有围栏就采用“首大括号到末大括号”的粗切逻辑。粗切逻辑有个常见坑——AI如果在一个字符串里包含了一个},简单用find和rfind切出来的区间会缺一块或超出边界。
所以我加了一步括号配对校验。我扫一遍候选文本,记录{}的深度变化,如果深度在最后不等于0,说明粗切区间不对,这时候我会退回到逐字符寻找第一个让深度归零的位置。这里的意图是让提取逻辑不依赖“最后一个大括号”这种脆弱假设,而是真正匹配结构。
import re def extract_json_block(text: str) -> str: # 第1步:尝试提取markdown代码块内的json fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) if fence_match: return fence_match.group(1).strip() # 第2步:找第一个{和最后一个},用括号深度验证 first_brace = text.find("{") if first_brace == -1: return text depth = 0 end = -1 for i in range(first_brace, len(text)): ch = text[i] if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: end = i break if end == -1: end = text.rfind("}") return text[first_brace:end + 1]注意第2步用的是双条件:先尝试配平括号找结束位置,配不平时再退到最后一个大括号。这个设计是我实际测试后确定的,因为AI截断场景下rfind到的位置虽然不完整,但至少能拿到最多数据,后续截断修复阶段还能再补救。如果一上来就放弃,就会丢掉整个可用的前半段。
3.2 引号与键名统一:别一刀切改单引号
修复引号是风险最高的一步,因为JSON字符串内部的单引号是合法内容,比如英文里的 don't。我最初就是正则一把梭把单引号全替换成双引号,结果把 don't 变成了 don"t,数据直接损坏。后来我改成“先保护字符串内容,再替换结构引号”。
具体思路:先遍历文本,记录所有被双引号包裹的字符串片段,把它们临时替换成占位符,比如\x00__STR_1__\x00;然后对剩余文本再把裸单引号替换成双引号;最后把占位符还原。这样字符串内部内容完全不受影响,只修结构层的引号。
def unify_quotes(text: str) -> str: placeholders = [] def stash(match): placeholders.append(match.group(0)) return f"\x00__PLACEHOLDER_{len(placeholders)-1}__\x00" protected = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', stash, text) # 把没加双引号的键名和值里的单引号统一处理 protected = re.sub(r"(?<=[\[,{:\s])'([^']*?)'(?=[\],}:,\s])", r'"\1"', protected) for i, ph in enumerate(placeholders): protected = protected.replace(f"\x00__PLACEHOLDER_{i}__\x00", ph) return protected保护正则里那个(?:\\.[^"\\]*)*是处理转义字符用的,它能识别内嵌的\",不至于在字符串里遇到转义引号就提前结束匹配。这是很多人写正则容易漏的点。实测下来,这版对“AI返回了{'name': 'tom'}”这类Python风格字典的改造效果很好,同时不会误伤真正在双引号字符串里的撇号。
3.3 尾逗号与截断补齐:两个高频狱友
尾逗号的处理比较直接,正则把,\s*[}\]]里的逗号去掉就行。但要配合负向条件,避免误删JSON里数组元素之间的正常逗号。我的实现是只清理“逗号后面紧跟着右括号且中间只有空白”的情况,这样匹配面很精准。
截断补齐是更高级的一层。AI返回超长JSON时经常在中间某处被截断,导致末尾缺}或]。我用一个循环策略:从末尾逐步尝试补}、]、",每补一个字符就做一次json.loads,成功后立即返回。如果全部失败,就启用最长合法前缀算法——从最后一个可能出现完整结构的位置分割,尝试用逐层截到前一个逗号或括号处重新解析。
def fix_truncation(text: str): for closing in ("}", "]", "}", "'"): candidate = text + closing try: return json.loads(candidate), "补全右括号" except Exception: pass # 最长合法前缀:逐步回退 segments = [m.start() for m in re.finditer(r"[,}\]]", text)] for pos in reversed(segments): candidate = text[:pos + 1] try: return json.loads(candidate), "截取最大合法前缀" except Exception: continue raise ValueError("无法解析截断JSON")这里我特意把"也放进了闭补候选,因为某些截断会把字符串最后的一个双引号切丢。但注意回退算法的顺序:优先做后缀补齐,次选最长合法前缀。因为前缀截断虽然稳定,但会丢掉后半段数据,能补全的尽量补全。这也是我在一次生成报表配置时踩出来的经验——一次性输出几百行的JSON被截断,直接前缀截取后少了三个数组项,差点把下游图表饿死。
3.4 完整可运行代码与使用姿势
把上面所有阶段串起来,我封装了一个repair_ai_json类,内部按管道执行,每一步都尝试标准解析,提前成功就返回。判断是否需要走修复管道,我建议先直接 json.loads 一次,如果成功就根本不要进修复逻辑。这个细节很重要,能保证处理正常AI响应时零损耗。
import json import re from typing import Any class RepairAiJson: def __init__(self): self.steps = [] def repair(self, raw: str) -> Any: candidates = [raw] try: return json.loads(raw) except Exception: pass # 提取 block = extract_json_block(raw) candidates.append(block) try: return json.loads(block) except Exception: pass # 引号修复 unified = unify_quotes(block) candidates.append(unified) try: return json.loads(unified) except Exception: pass # 键名规范化:True/False/None + 无引号键 key_fixed = re.sub(r"\b(True|False|None)\b", lambda m: {"True": "true", "False": "false", "None": "null"}[m.group(1)], unified) key_fixed = re.sub(r"([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)", r'\1"\2"\3', key_fixed) candidates.append(key_fixed) try: return json.loads(key_fixed) except Exception: pass # 尾逗号 no_trailing = re.sub(r",\s*(?=[}\]])", "", key_fixed) candidates.append(no_trailing) try: return json.loads(no_trailing) except Exception: pass # 截断补齐 fixed_trunc, hint = fix_truncation(no_trailing) self.steps.append(hint) return fixed_trunc使用姿势很简单:
raw_output = "这是AI返回的内容 ```json\n{\"name\": \"张三\", \"items\": [1, 2, 3,],}\n``` 供您参考" handler = RepairAiJson() data = handler.repair(raw_output) print(data) # {'name': '张三', 'items': [1, 2, 3]}设计上我没有把候选列表全部串进递归,而是显式写每个阶段,目的是让每一步出错时能定位到具体是哪一层修坏的。如果生产里要更自动化,你可以把阶段函数注册进列表循环调用,再把成功路径记录下来写日志。我目前这条实现已经跑在几个Agent配置解析脚本里,稳定性不错。
4. 多语言落地与工程化接入
4.1 从Python迁移到JavaScript的要点
如果你的AI功能跑在Node.js环境,核心思路能搬,但正则细节要调。JavaScript的字符串处理没有Python那么顺滑,尤其是占位符保护那一步,建议改用状态机遍历。我写过一个精简版,用setString标记位替代正则保护,遍历每个字符,只在JSON结构层做引号替换。
function repairBlock(text) { let inString = false; let out = ""; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '\\' && inString) { out += ch + (text[i+1] || ''); i++; continue; } if (ch === '"' && !inString) { inString = true; out += ch; continue; } if (ch === '"' && inString) { inString = false; out += ch; continue; } if (ch === "'" && !inString) { out += '"'; continue; } out += ch; } return out; }这段代码的思想是手动跳过转义符,把结构层的单引号换成双引号,同时保证字符串内部单引号不动。虽然比Python版啰嗦,但可读性更强,团队里新人也能快速改。
4.2 批量处理与自动化管道
真实场景里你大概率不是手敲一条条响应,而是把修复函数接到API返回后处理函数里。我通常的姿势是做一个通用入口函数,输入可以是字符串、字节流或已经解析到一半的dict,输出统一成dict。如果修复失败就抛一个自定义异常,异常里带上原始文本和最后一步解析的错误信息,方便排查。
批量处理的场景更注意性能。修复管道里的json.loads每一步都在尝试完整解析,对大JSON来说成本不低。我实测过一条100KB的JSON,直接从标准解析跳过修复能在5ms内完成,但如果进入完整修复管道,每一步都要重新解析一遍,可能会到50ms以上。所以优化手段是:先尝试标准解析,只有失败才进入管道;并且尽量把最省力的修复步骤(去围栏、去尾逗号)放在前面,让大多数“轻病号”在前面就康复出院,避免走到昂贵的截断补齐阶段。
5. 高频坑位全景表与实战排查心得
5.1 高频坑位全景表
我把处理AI JSON时最常遇到的情况整理成了一张速查表,方便排查时对照:
| 症状 | 根因 | 修复策略 | 是否常见 |
|---|---|---|---|
| json.loads抛JSONDecodeError,提示Expecting value | 返回内容被自然语言包围 | 先提取块再解析 | 极高 |
| 提示Expecting property name enclosed in double quotes | 键名没加双引号 | 键名规范化正则 | 高 |
| 提示Expecting , delimiter | 数据结构错位,可能是截断 | 后缀补齐或最长合法前缀 | 中 |
| 字符串内部引号错乱 | 转义或单双引号混用 | 先保护字符串再修结构引号 | 中 |
| True/False/None出现 | Python风格布尔值 | 替换为JSON小写值 | 中 |
| 尾逗号导致解析失败 | AI模仿编程语言风格 | 正则清理尾逗号 | 高 |
| 中文字符被转成\uXXXX | 模型做了转义 | 标准解析后无需修复 | 低 |
其中“Expecting property name”这条最迷惑,因为行号经常指到莫名其妙的位置。我排查过很多次都是键名少了双引号,结果错误信息却指向下一行,让人误以为是数据结构坏掉。这条经验我建议写进团队的排查文档。
5.2 实战中总结的三条避坑经验
第一条:永远不要只调一次解析就放弃。很多新手看到JSONDecodeError就以为是模型返回烂了,实际上90%的情况只要剥掉Markdown围栏就能过。先做最小提取,再考虑修复,最后才考虑重试生成。
第二条:AI返回的长度越长,越要留意截断问题。大模型输出有token上限,一次返回长JSON时经常被硬切断。如果你发现解析失败且错误信息出现在文本中部,那大概率是截断。这时候不要反复重新生成(费钱费时),直接用最长合法前缀截取,往往能拿到80%有效数据。
第三条:给下游留好容错。JSON修复永远不是百分百可靠,我宁可让修复函数抛异常返回可读的错误信息,也不要在下游静默吞掉异常。否则配置错误可能藏到几层之后才爆发,排查成本十倍往上。
6. 项目扩展与私有化部署思路
现在这个修复片段已经不只是脚本层工具了,我把它做成了一个轻量的独立服务接口,内部走HTTP POST,输入原始AI输出,输出清洗后的JSON。因为有状态记录steps,还能在响应里返回修复了哪几个阶段,便于监控AI输出质量趋势。
如果你打算在自己系统里复刻这套东西,可以给它扩展三个能力:一是接入数据校验层,比如JSON Schema,清洗完再校验结构和必填字段;二是加一层规则引擎,针对你自己的AI模型输出习惯定制修复规则;三是把修复过程和token消耗一起打进日志系统,用于评估不同模型参数的输出质量。这套思路我放在一个内部AI网关里跑了一个月,让下游配置解析成功率从88%提到了97%以上,剩下的3%基本是模型彻底跑题给出非结构化文本,那就只能靠重试或人工处理了。
说回到开头的那个报表配置场景,我后来把我这套函数接进了异常重试逻辑里——第一次解析失败先修复,修复完再失败才触发重试请求。结果重试率降了一大截,每千次调用能省下不少token费用。所以哪怕只有一个场景用得上,这个代码片段也值回票价了。