news 2026/11/5 3:02:55

AI输出JSON格式错误修复指南:从正则清洗到括号配平实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI输出JSON格式错误修复指南:从正则清洗到括号配平实战

1. 先说说这事的起因:AI输出JSON,到底有多不靠谱

做后端和数据处理的朋友,这两年大概都有一个共同感受:让大模型返回结构化数据,省心的时候是真省心,糟心的时候也是真糟心。我最近在做一个内部工具,功能不复杂——让模型从一段文本里提取字段、整理成配置项,然后程序直接消费它的输出。理想状态下,模型应该返回一坨干净利落的JSON,程序拿过去json.loads()一下,皆大欢喜。

可现实是,我拿到手的输出经常长这样:

{ name: "张三", "age": 25, "city": "北京", "hobby": ["读书", "跑步",], "remark": "他说:"明天见"" }

你要是把这个字符串丢给Python的json.loads(),它会当场给你抛一个JSONDecodeError。注意,这还算好的,至少报错了你知道有问题。更麻烦的是那种"一半能用、一半瞎改"的情况——有些字段被单引号包着,有些键没加引号,有些字符串里混着转义符,你根本不知道哪一行会突然炸掉。

这个问题的本质是:大模型做的是"概率式文本生成",它并没有内建一个"严格符合JSON语法"的编译器。它对JSON的理解来自训练语料里的统计分布,所以经常会出现"形似JSON但不符合规范"的输出。我们作为工程师,不能指望它在短期内变得完美,更靠谱的做法是在程序侧加一道"清洗和修复"的工序。

这篇文章就记录一下我实测过的一整套修复思路和代码片段。核心目标很明确:在不依赖外部API、不引入重型依赖库的前提下,把常见的JSON格式错误尽可能修复掉,让程序能够继续走下去。

2. 先给问题分分类:我收集到的七类典型病状

做修复之前,我建议你先做一件事——把自己遇到过的"坏JSON"全部收集起来,分类存放。没有这一步,你写的修复逻辑就是在瞎猜,改完这个坏那个,永远打地鼠。

我把自己项目里积累的几十条异常样本归类,发现大多数问题可以归结为以下七类:

序号问题类型典型表现严重程度
1键名缺少双引号{name: "张三"}高
2字符串使用了单引号{'name': '张三'}高
3多余的尾逗号[1, 2, 3,]或{"a": 1,}中
4字符串内未转义的双引号"他说:"你好""高
5Markdown代码块包裹输出带有```json前缀和后缀中
6注释混入// 这是注释或/* ... */中
7内容截断导致括号不匹配{"a": 1, "b": [2, 3高

其中,第1、2、4类出现频率最高,是修复的重点;第3类不致命但会影响解析器心情;第5类完全可以在外层剥离;第6类看场景;第7类最麻烦,因为它不是纯粹的格式问题,而是数据完整性受损,后面我会单独说一层处理策略。

有一点要提前说明:JSON规范本身极其简单,真正写起来就是"大括号小括号中括号、冒号逗号、双引号字符串、数字布尔空值"。所以修复逻辑也不需要做成一个完整的Parser,我们只需要找到"哪几个地方不符合规范",然后做针对性的修补,这就够了。

3. 修复前的预处理:剥掉外壳,看清里子

我实际处理的时候,第一步永远不是直接碰JSON字符串,而是先做"去外壳"处理。这一步能解决掉一大部分看着莫名其妙的问题。

3.1 剥离Markdown代码块标记

很多模型在收到"请返回JSON"的指令后,会习惯性把结果包在代码块里。比如:

{ "name": "张三" }

这种输出如果直接拿去解析,必挂。剥离逻辑非常简单:

import re def strip_code_fence(text: str) -> str: # 去掉 ```json 和 ``` 包裹 text = text.strip() # 匹配以 ``` 开头,可能带语言标识,到另一个 ``` 结束 fence_pattern = re.compile(r'^```[a-zA-Z]*\s*\n(.*?)\n```$', re.DOTALL) match = fence_pattern.search(text) if match: return match.group(1).strip() return text

我见过有些实现还会处理"只剥掉开头标记,忘了剥结尾标记"的情况,所以在剥离之后,最好再检查一下:如果字符串里仍然存在```这个片段,就手动截断到最近的一个作为结束。

3.2 去掉BOM和不可见字符

这个坑比较阴间。某些模型在输出开头会带上一个零宽空格(\u200b)或者BOM(\ufeff),你用肉眼完全看不出来,但程序解析的时候就会报错。稳妥做法是直接在开头做一次清理:

def clean_invisible(text: str) -> str: # 去掉BOM和零宽字符 text = text.replace('\ufeff', '').replace('\u200b', '') # 去掉ASCII控制字符(除了换行和制表符) text = ''.join(ch for ch in text if ch == '\n' or ch == '\t' or ord(ch) >= 32) return text.strip()

3.3 提前取出第一个JSON对象区域

还有一种常见场景:模型在返回JSON之后,又追加了一段解释性文字,比如:

{"name": "张三"} 这个人的年龄是25岁。

我的建议是:不要幻想正则能从混合文本里完美抠出JSON——直接找第一个{或[到最后一个对应的}或]之间的内容。因为大多数模型的输出都以结构化数据开头,尾部可能是补充说明。但要注意,如果JSON内部包含花括号字符串(例如备注字段里有{"x": 1}这种内容),这个策略就可能失效。所以我的顺序是:先尝试直接解析,失败之后再做截取、再做修复。每一步都做"无损尝试",不要上来就把原始数据切坏了。

这也是我想强调的第一个经验:修复管道要设计成"逐级降级"模式,而不是一股脑全修一遍。每次都先试试当前字符串能不能直接json.loads(),能过就直接用,不能过才进入下一步修复,这样能最大程度保留原始信息的完整度。

4. 修复函数的核心设计:状态机配合正则

现在进入正题——写一个repair_json_text()函数。整体设计思路我拆成四层,每一层解决不同粒度的问题。

4.1 第一层:正则修复明显病状

这一层处理的是模式明确、替换规则稳定的问题,用正则来效率最高、也最不容易误伤。

def regex_fix(text: str) -> str: # 1. 去掉尾逗号:把 , 后面紧跟 } 或 ] 的情况修正 # 注意要处理空白字符,如 , ] 或 ,} text = re.sub(r',\s*([\]}])', r'\1', text) # 2. 单引号替换为双引号 —— 但只处理那些"像JSON字符串边界"的单引号 # 这一步要非常小心,避免把字符串内部的自然单引号(如 don't)替换掉 # 下面这一条只做简单的全局替换,后续状态机再逐字符校正 text = re.sub(r"(?<!\w)'([^'\\]*(?:\\.[^'\\]*)*)'(?!\w)", r'"\1"', text) # 3. 键名补引号:匹配 键名: 的模式 # 键名允许字母、数字、下划线、中划线、点 text = re.sub(r'([\{,]\s*)([A-Za-z_][A-Za-z0-9_\-\.]*)(\s*:)', r'\1"\2"\3', text) # 4. 把用单引号包裹的键名也补成双引号 text = re.sub(r"([\{,]\s*)'([^']+)'(\s*:)", r'\1"\2"\3', text) # 5. 识别 NaN、Infinity 等非标准值,替换为 null text = re.sub(r'\bNaN\b', 'null', text) text = re.sub(r'\bInfinity\b', 'null', text) text = re.sub(r'\bundefined\b', 'null', text) return text

这里面,尾逗号那条正则最安全,因为它只针对,和紧接着的}/]的组合,字符串内部的这样的情况极少出现。键名补引号那条正则风险稍高——如果字符串值里恰好有{some_text:value}这种内容,就会被误伤。要降低误伤率,可以把正则改成只在"行首位置({或,后紧跟非引号字符)"才触发,并且配合状态机在下一层做复核。

4.2 第二层:状态机逐字符校正

正则处理解决不了所有问题,尤其是字符串内部引号纠缠的情况。所以第二层我写了一个轻量的逐字符扫描器,用途有三:识别字符串边界、判断引号是否闭合、在必要处补上转义。

这个状态机的核心逻辑是维护一个in_string布尔值,以及一个stack用来记录嵌套括号。扫描时对每个字符分类处理:

def scan_fix(text: str) -> str: chars = list(text) i = 0 n = len(chars) in_string = False stack = [] quote_marks = [] # 记录字符串起始位置 while i < n: ch = chars[i] if in_string: if ch == '\\': # 跳过转义字符的下一个字符 if i + 1 < n: i += 2 continue elif ch == '"': in_string = False # 如果是单引号出现在字符串内部,且前一个字符不是转义符,暂时不管 # 字符串内部的普通字符直接跳过 else: if ch == '"': in_string = True quote_marks.append(i) elif ch == "'": # 单引号存在两种情况: # 1. 作为字符串边界,需要替换为双引号 # 2. 作为字符串内容的一部分,比如 don't,不应处理 # 这里采用一个启发式规则: # 如果单引号两边都是字母/数字/下划线,说明它是单词内部撇号,跳过 prev_char = chars[i - 1] if i > 0 else '' next_char = chars[i + 1] if i + 1 < n else '' if prev_char.isalnum() and next_char.isalnum(): # 单词内撇号,保持原样 pass else: # 视为字符串边界,替换为双引号 chars[i] = '"' in_string = True quote_marks.append(i) elif ch in '[{(': stack.append(ch) elif ch in ']})': if stack: stack.pop() i += 1 # 如果扫描结束后 in_string 仍为 True,说明字符串没有闭合 # 处理方式:在末尾补上闭合引号 if in_string: chars.append('"') return ''.join(chars)

状态机的优势在于它"知道"当前到底在不在字符串里,所以遇到:,,,{,}时能判断它是语法符号还是字符串内容。这也是一个合格的JSON修复器的最低要求——没有状态机,你的修复逻辑就是在碰运气。

4.3 第三层:字符串内非法引号的修复

这一层是最难处理、也是我最想详细说的一层。模型生成的JSON里,字符串值经常包含未转义的双引号,比如:

{"remark": "他说:"明天见""}

这个字符串在语法上是致命的:解析器把"他说:"当作完整字符串,接下来遇到明天见会直接语法错乱。我的修复思路是:如果一个字符串内部出现了一个裸的双引号,且这个双引号后面不是,、}、]、:或空白符,那它大概率是"字符串内容的起点/终点"而非语法符号,把它转义成\"。

用正则实现不了精确判断,还得靠状态机。我在4.2的基础上加一条规则:当处于in_string状态时,遇到双引号,先看后面一个字符:

  • 如果后面是,、}、]、空白符、行尾——说明它是字符串正常结束的边界,保持原样。
  • 否则,说明它是内容的一部分,在前面加一个反斜杠转义。

同理,字符串内部的反斜杠也可能需要补转义。有些模型会把路径C:\Users\...原样输出,JSON语法里\U不是一个合法转义序列,遇到这种情况,在\前面再加一个\。这条规则要在4.2里同时处理。

我把这段逻辑扩展一下:在扫描到\时,如果后面跟着的字符不是 JSON 合法转义("\\/"bfnrtu以及\u开头的四位数),就在\前面再补一个反斜杠。

def fix_string_content(text: str) -> str: chars = list(text) i = 0 n = len(chars) in_string = False result = [] valid_escapes = set('"\\/bfnrtu') while i < n: ch = chars[i] if in_string: if ch == '\\': # 判断下一个字符是否是合法转义 if i + 1 < n and chars[i + 1] in valid_escapes: # 合法转义,原样保留 result.append(ch) result.append(chars[i + 1]) i += 2 continue else: # 非法转义,补一个反斜杠 result.append('\\\\') result.append(ch) i += 1 continue elif ch == '"': # 双引号:看后一个字符决定是否转义 if i + 1 < n and chars[i + 1] not in ',}] \t\n\r:': # 后面还有内容且不是语法分隔符,说明这个双引号是字符串内容 result.append('\\"') i += 1 continue else: in_string = False result.append(ch) i += 1 continue else: result.append(ch) i += 1 else: if ch == '"': in_string = True result.append(ch) i += 1 else: result.append(ch) i += 1 return ''.join(result)

这段代码的判定标准是"后续字符类型",不是"绝对正确",但实测下来对大多数模型输出是有效的。要注意的是,如果字符串内容恰好以双引号收尾(即"他说:\"你好\""已经是正确转义的),这段逻辑会不会把第二个\"误判?不会——因为后面跟的是",不在,}]这个集合里,会再补一个\,变成\\\"。这确实会多转义一层。不过这种输入已经是合法JSON了,正常的json.loads()在进入修复流程之前就应该通过,不会走到这一层,所以这个误判实际影响很小。

4.4 第四层:括号配平

走到这一层,说明前面修完之后还是解析失败。常见原因就是模型输出被截断,或者多了一个括号。我先写一个简单版本的括号配平逻辑:

def balance_brackets(text: str) -> str: stack = [] pairs = {')': '(', ']': '[', '}': '{' } # 先扫描一遍,找到未匹配的结束括号(多余)和未闭合的起始括号(缺失) chars = list(text) i = 0 n = len(chars) in_string = False result = [] # 第一遍:删除多余的结束括号 for ch in text: if ch == '"' and not in_string: in_string = True result.append(ch) continue elif ch == '"' and in_string: in_string = False result.append(ch) continue if not in_string: if ch in '}])': if stack and stack[-1] == pairs[ch]: stack.pop() result.append(ch) else: # 多余的结束括号,直接丢弃 continue else: if ch in '[{(': stack.append(ch) result.append(ch) else: result.append(ch) # 第二遍:给未闭合的起始括号补上结束括号 closers = {'(': ')', '[': ']', '{': '}'} while stack: result.append(closers[stack.pop()]) return ''.join(result)

这个方法的思路很简单:从左扫到右,维护栈;遇到结束括号就看栈顶匹配不匹配,不匹配就丢掉这个结束括号;扫完整个字符串,如果栈里还有没闭合的起始括号,就在末尾补上对应的结束括号。

这个方案的局限很明显:如果缺失发生在中间(比如整块对象丢了),补在末尾毫无意义;如果多余的是中间的某个括号,直接删除也可能改变结构。但话说回来,如果真的缺数据,你本来也无从知道"缺失"是不是被补上的这一段,所以这个配平逻辑的目标不是保证语义正确,而是让json.loads()能跑通,拿到一个可用的近似结果,同时用日志标记"这里可能不完整",把风险交给上层业务去判断。

5. 把各层修复串联起来:完整的处理管道

我来说说这些函数是怎么串成一个完整管道的。完整处理流程分四个阶段:预备、试解析、逐级修复、最终校验。

5.1 主函数:repair_json_text

import json def repair_json_text(raw_text: str): """ 尝试解析AI返回的JSON文本,失败则逐级修复。 返回字典/列表类型。如果实在修复不了,返回None。 """ if not raw_text: return None text = raw_text.strip() # 阶段0:清理不可见字符 text = clean_invisible(text) # 阶段1:尝试直接解析 try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 阶段2:剥离markdown代码块后再试 text = strip_code_fence(text) try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 阶段3:剥离解释性文字(从第一个{或[开始截取) # 注意:这里只截取到最后一个对应的}]为止 extracted = extract_json_area(text) if extracted != text: text = extracted try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 阶段4:正则修复 text = regex_fix(text) try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 阶段5:状态机修复字符串和引号 text = scan_fix(text) text = fix_string_content(text) try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 阶段6:括号配平 text = balance_brackets(text) try: obj = json.loads(text) return obj except json.JSONDecodeError: pass # 最后,尝试用更宽松的方式提取多个对象区域 objects = extract_objects_loose(text) if objects: return objects return None

看到这里,你应该注意到一个设计原则:每一步修复之后都立刻尝试json.loads(),通过了就直接返回。这比"把所有修复都做完再解析"要安全得多。因为修复本身是有副作用的(比如可能误转义内容),能少改就少改。

这也是我在实际写代码时踩过的最重要的坑之一。一开始我把所有正则和状态机更新一股脑全跑完,结果把一个原本可以正确解析的字符串修坏了,后来又花了半天定位。所以现在无论写什么工具函数,我都养成了"每步验证、快速失败"的习惯。

5.2 辅助函数:截取JSON区域

extract_json_area()的作用是在混合文本里找到结构化数据区域。实现如下:

def extract_json_area(text: str) -> str: """ 从文本中提取第一个完整的JSON对象或数组区域。 如果找不到启始括号,返回原文本。 """ # 找到第一个 { 或 [ start_pos = -1 start_char = None for i, ch in enumerate(text): if ch in '{[': start_pos = i start_char = ch break if start_pos == -1: return text end_char = '}' if start_char == '{' else ']' # 从start_pos开始扫描,维护栈 stack = [] in_string = False for i in range(start_pos, len(text)): ch = text[i] if in_string: if ch == '\\': # 跳过转义字符 i += 1 continue if ch == '"': in_string = False continue if ch == '"': in_string = True elif ch in '{[': stack.append(ch) elif ch in '}]': if stack: stack.pop() if not stack: # 找到了匹配的结束括号 return text[start_pos:i+1] return text[start_pos:]

这里有个细节:如果找到了起始括号但扫描完整个字符串栈都没清空,说明这个JSON区域不完整,那就从起始位置一直截到末尾,交给括号配平处理。

6. 特殊场景处理:不同模型、不同出错的应对策略

写到这里,我猜很多朋友都会问:这个修复器是不是所有模型都能通用?理论上说,模型都是概率生成,出错的类型总体类似,但不同场景下修复策略的优先级确实不太一样。我挑几个典型场景展开说说。

6.1 追求结构完整 vs 追求字段可用

我自己的项目里,纯文本提取类的模型调用,对字段完整度要求没那么高——修完能拿到可用字段就行;但配置生成类的调用,结构完整性要求很高,宁可返回None让上层重试,也不要返回一个残缺的配置项。

所以repair_json_text()这个函数我设置了两个降级模式:默认模式是"能修就修",遇到实在修不了的返回None;严格模式下,一旦检测到括号配平阶段补上了括号——也就是数据存在截断——就直接返回None,让调用方决定是重试还是告警。

def repair_json_strict(raw_text: str): obj = repair_json_text(raw_text) if obj is not None: # 确认是否经过括号配平 text = raw_text.strip() text = clean_invisible(text) text = strip_code_fence(text) # 如果提取区域不完整,判定为失败 if detect_truncation(text): return None return obj

判断截断的方法很简单:把文本从头扫到尾,数一下左右括号总数。如果左括号数明显大于右括号数,大概率是截断了;或者raw_text末尾不是以}、]、"结尾,也有截断风险。

6.2 嵌套对象拼接问题

还有一类更隐蔽的问题:模型同时返回两个JSON对象拼接在一起,比如:

{"name": "张三"} {"age": 25}

第一个对象能正常解析,但程序可能拿到的是{"name": "张三"},后续{"age": 25}被丢弃。如果你希望拿到合并的结果,就得在修复管道的最后加一个"松弛模式":把字符串里所有}{、][等连接处补一个逗号并加一层包裹。

def merge_json_blocks(text: str) -> str: # 将 {"a":1} {"b":2} 合并为 [{"a":1},{"b":2}] text = text.strip() # 如果整体不是数组包裹,但中间存在多个对象 if not (text.startswith('[') and text.endswith(']')): text = '[' + re.sub(r'\}\s*\{', '},{', text) + ']' text = re.sub(r'\]\s*\[', '],[', text) return text

这个函数一般放在所有修复逻辑的最后,并且默认关闭,只在特定任务里手动开启。因为不是所有场景都需要合并多个块,如果默认开启,反而会把一些本应是字符串内容的} {给误操作了。

6.3 AI参数里的 response_format 能解决一部分问题

如果你的模型API支持response_format参数,例如直接限定输出为JSON对象格式,建议优先开这个。我在测试中发现,开启该参数后,坏JSON的占比能从大概30%降到5%左右。但5%依然存在,而且这5%往往是更复杂的问题——多语言混排、极端嵌套、字符串里的引号乱飞。所以response_format是"降低修复压力",不是"替代修复器"。

另外,有些模型支持json_schema约束(结构化输出),这个比单纯的response_format效果更好,能强制字段类型。但这类约束也会限制模型的灵活性,对"自由命名键"的任务就不太适用。

7. 代码实测效果和一批真实样本

为了验证修复器的效果,我从真实项目里抽了20条模型输出做测试,这里贴几条典型的,直接给大家看修复前后的状态。

样本一(键名无引号 + 尾逗号):

输入:

{name: "李四", age: 30, interest: ["篮球", "足球",]}

输出:

{"name": "李四", "age": 30, "interest": ["篮球", "足球"]}

处理链路:正则修复后直接解析成功,一次通过。

样本二(markdown包裹 + 注释):

输入:

```json // 用户信息 {"name": "王五", "age": 28}
输出: ```json {"name": "王五", "age": 28}

处理链路:剥离代码块后解析成功。如果解析失败,正则层会顺带把注释清掉。

样本三(字符串内部双引号):

输入:

{"remark": "他说"你好",然后走了"}

输出:

{"remark": "他说\"你好\",然后走了"}

处理链中fix_string_content()起了主要作用。注意,这类输入在regex_fix阶段有概率被误伤,所以我特意把regex_fix里的单引号处理写得保守,宁可多留一点给状态机阶段处理。

样本四(截断+括号不闭合):

输入:

{"user": {"name": "赵六", "age": 35, "tags": ["a", "b"

输出:

{"user": {"name": "赵六", "age": 35, "tags": ["a", "b"]}}

处理链路:进入括号配平阶段,补两个括号,解析成功,但调用方需要意识到"数据可能不完整"。

从实测结果看,20条样本有17条能通过管道最终解析成功,其中3条需要括号配平,剩余3条彻底修复失败。失败的那几条基本都是语义级错误——比如数组里面混入了不是原文本的额外说明——已经不是格式问题,再修下去就是把语义往错里猜,所以我选择直接让上层重试。

8. 单元测试与回归测试:别让修复器变成破坏器

这个项目里我还做了一个不算大的测试集,把历史上所有出过问题的坏JSON样本全部放进去,每次改修复逻辑就跑一遍,确保修好一种错误的同时没有引入新的误伤。这件事特别重要,尤其是你用的正则越写越复杂的时候。

我的单元测试分三层:

  • 第一层:合法JSON必须原样通过,修复器不能破坏任何合法输入。
  • 第二层:已知坏样本必须被修好,且正确解析出的字段要和预期一致。
  • 第三层:字符串内部含JSON样式的文本(比如备注里有一串{"a":1})不能被误修复。

第三层的测试经常暴露问题。举个例子,如果有人的备注字段是:

{"note": "配置项: {\"debug\": true}"}

这个字符串里的{"debug": true}是字符串的一部分,任何正则只要没考虑字符串边界,就可能把里面的键名和引号再修一遍,导致输出变成:

{"note": "配置项: {\"debug\": true}"}

一次修复,原本合法的字符串变成了非法字符串。所以我在regex_fix的所有正则里,都尽量通过"前置必须是{或,"来缩窄匹配范围,从源头上避免误伤字符串内容。

测试代码大概长这样:

import unittest class TestRepairJson(unittest.TestCase): def test_legal_json_untouched(self): raw = '{"name": "张三", "age": 25}' result = repair_json_text(raw) self.assertEqual(result["name"], "张三") def test_invalid_key(self): raw = '{name: "张三"}' result = repair_json_text(raw) self.assertEqual(result["name"], "张三") def test_single_quote_string(self): raw = "{'name': '张三'}" result = repair_json_text(raw) self.assertEqual(result["name"], "张三") def test_inner_quote_string(self): raw = '{"remark": "他说"你好""}' result = repair_json_text(raw) self.assertEqual(result["remark"], '他说"你好"') def test_string_contains_json(self): raw = '{"note": "配置: {\\"debug\\": true}"}' result = repair_json_text(raw) self.assertEqual(result["note"], '配置: {"debug": true}') if __name__ == '__main__': unittest.main()

实际维护过程中,这个测试集大概扩充到了80多个用例。每次更新修复逻辑,我都会把这些用例全跑一遍,速度挺快(毫秒级),但省下的定位时间是实打实的。

9. 在实际业务里怎么接这个修复器

说了这么多代码层面的东西,最后再聊聊在业务里接入时应该注意的事情。技术方案做出来了,接入方式不对,照样会在生产环境里翻车。

9.1 调用方应该拿到的不是"字符串",而是"解析结果+元信息"

我的建议是:修复器不要只返回解析后的对象,最好同时返回一个元信息结构,标记它经过了多少层修复、是否启用了括号配平、是否存在截断风险。这样上层拿到数据后,可以根据业务容忍度决定下一步动作。

def repair_json_with_meta(raw_text: str): obj = repair_json_text(raw_text) meta = { "raw": raw_text, "repaired": obj is not None, "truncated": detect_truncation(raw_text), "used_balance": "balance_brackets" in trace_call() } return obj, meta

业务上用得比较多的是truncated字段——它表示"这一段数据可能不完整"。比如我那个配置生成工具,字段缺失一个没关系,但如果整体结构被截断,就要触发重新生成一次。

9.2 不要把修复器当保险箱,还是得做schema校验

有些团队会把repair_json_text()当成"万能兜底"——反正出错它会修,修不了就重试。这个思路很危险。修复器能解决的问题是语法层面的,而字段类型、必填项、非法组合这些逻辑,它一概管不了。

正确姿势是:修复器之后再加一道validate_schema()检查。参考实现:

def validate_schema(data, required_fields=None): if not isinstance(data, dict): return False, "expected object" if required_fields: missing = [f for f in required_fields if f not in data] if missing: return False, f"missing fields: {missing}" return True, ""

修复器和校验器各管一段,调试问题的时候也能快速定位到底是"修复不彻底"还是"业务逻辑不对"。

9.3 给重试留余地,修复失败不是终点

最后一条经验:修复失败时,别只记个日志就完事。结合上下文来看,很多修复失败的根因是模型没理解任务要求,而不是JSON格式本身的问题。所以我的流程是:

  1. 修复失败后,把原始输出存进日志系统。
  2. 根据任务类型选择:
    • 允许模糊结果的任务:去掉会话中最近的几轮干扰信息,重试一次。
    • 不允许模糊结果的任务:直接向用户报错,附带原始输出片段,让用户决定是否需要人工干预。
  3. 定期统计"修复失败率",如果超过某个阈值(比如5%),就要考虑是不是提示词该调整了。

提供一个简单的重试函数示例:

def call_with_json_retry(prompt_func, max_retries=2): for attempt in range(max_retries): raw = prompt_func() obj, meta = repair_json_with_meta(raw) if obj is not None and not meta["truncated"]: return obj # 失败后,提示词里追加一句“请确保输出是完整JSON,不要截断” # 具体实现取决于你的业务封装 return None

从我这边的实践来看,重试一次的成功率能提升不少,第二次生成时模型往往会收敛到更规范的输出。不过别无限重试,成本和时间都耗不起,两三次就够。

10. 几个从血泪中换来的工程经验

代码细节讲完了,说点更接近工程认知层面的东西。这些东西很难在文档里看到,但实际写修复器时绕不开。

第一,不要把修复逻辑堆在解析前处理里。我曾经看到过有人写了一个超级长的preprocess_json,里面几十条正则,跑的比解析本身还慢,而且互相干扰。更好的是像我上面那样做"分层修复,每层都是可跳过的",调试时只需要看哪一层生效了,不用全链路猜。

第二,尽量不在字符串内部做全局替换。JSON修复里90%的误伤,都发生在"没搞清当前是否在字符串内部"的情况下。所以一切有风险的替换,都用状态机配合,而不是简单的正则全局替换。如果你非要用正则,那就牢记:匹配条件里必须有\{或,等语法前置符号作为锚点。

第三,修复器是有状态的,测试必须持续维护。模型的更新换代很快,上个月这个模型不犯的错误,下个月可能就犯了。我的习惯是每换一个参数集或模型版本,就把历史坏样本重新跑一遍,确认没有退化。如果你没有时间维护测试集,那至少在每次发版前手动跑几条典型坏样本,心里有个底。

第四,多语言场景下的引号问题要特别留意。中英文混排的内容,经常会出现中文引号(“”)和英文引号(")混用的现象。JSON规范只认英文双引号,所以中文引号如果不能成对匹配,可能会被当成普通字符;但如果混着英文引号,状态机就很容易误判。我的处理方式是:在clean_invisible阶段就把中文引号统一替换成英文引号,或者直接保留原样(因为中文引号不是JSON语法符号),具体看你的任务场景。

我选择的是保留原样,让它们在字符串里安安稳稳当内容。如果你用全局替换,要小心不要把字符串里的"中文引号作为内容"的情况给改了。

第五,修复器要能讲清楚自己做了什么。在生产环境里,一个数据异常可能导致连锁反应。如果你的日志系统里只有 "json parse failed, retrying...",那你没法回答"为什么失败""修复了几层""修完数据还缺什么"。我的做法是给修复管道的每一层加一个标记(如auto_fixed: regex_tail_comma或auto_fixed: internal_quote),挂到日志结构化字段里。这样一旦出问题,能快速回溯到是哪一层改坏了,或者哪一类样本覆盖没到位。

11. 这个修复方案的适用边界与备选方向

最后我坦白说一句:这个方案不是万能钥匙。如果你的项目要大规模消费模型生成的JSON,我建议你在用这种"手写修复器"的同时,也考虑下面几条技术路线:

  • 升级到支持结构化输出的模型接口。凡是平台承诺"JSON Schema约束"的地方,优先用平台能力,比自己修复靠谱得多。
  • 在提示词层面做约束。比如在系统提示里写明"不要输出注释,不要使用单引号,不要在字符串内使用未转义的双引号",实测能显著降低坏样本比例。
  • 对于高价值数据,可以考虑让模型先输出一个"中间表示"(比如逐字段的纯文本),再由程序组装成JSON。缺点是多一次推理成本,但可控性会好很多。

手写修复器更多是"最后一公里"的兜底工程,不是核心方案。做技术选型时,先想清楚你主要依赖哪一层来保证数据质量,修复器只承担"异常兜底"的职责就行。

回到我那个内部工具本身——现在每次调用模型之后,数据都会走一遍repair_json_text,通过率稳定在九成五以上。剩下的那些,有的重试一次就好,有的确实是模型压根没理解需求,得回到提示词层面去解决。

如果你也在跟AI返回的JSON较劲,希望这篇记录能给你节约几天的排坑时间。最后再顺手提一句:修复器里那些正则,建议每次模型升级后都重新验证一下,模型变了,坏法也会变,我们的修复逻辑也得跟着长进。

把这个片段丢进自己的工具库里,跑几个刚才提到过的坏样本试试,应该能直观感受到它解决的是什么问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 7:00:05

RocketMQ核心机制与生产实践:从消息队列原理到高可用部署

先说明一点&#xff0c;这个标题看着简单&#xff0c;但"学习总结"四个字最容易写成流水账。我这边梳理了完整的学习路径和实战心得&#xff0c;重点放在那些官方文档里不会写的"为什么"和"怎么做"上&#xff0c;希望对正在啃RocketMQ的朋友有点…

作者头像 李华
网站建设 2026/10/23 8:34:45

UEFI Shell加载EFI应用程序的三种方式与实战踩坑记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 15:45:30

从NIC到epoll:百万并发下的内核收包链路与事件通知机制剖析

1. 前半程&#xff1a;数据包怎么从网卡落到 socket 队列聊高并发之前得先搞清楚一件事&#xff1a;我们口中常说的 epoll 百万并发&#xff0c;其实只是"事件通知"这一层的功夫。真正决定一个服务能扛多少连接的第一道关卡&#xff0c;在网卡和协议栈&#xff0c;在…

作者头像 李华
网站建设 2026/10/23 9:46:32

Word截图粘贴到内网OA编辑器丢失?完整解决方案与代码实现

凡是做过军工、涉密单位这类内网OA系统的朋友&#xff0c;十有八九都碰到过同一个需求&#xff1a;用户从Word文档里复制一篇带截图的材料&#xff0c;粘到系统集成的编辑器里&#xff0c;结果图片不见了&#xff0c;或者变成一串看不懂的本地路径&#xff0c;再或者整段内容被…

作者头像 李华
网站建设 2026/10/23 13:39:03

拧紧工艺数字化:制造业转型中不可忽视的隐形支柱

拧紧工艺这玩意儿&#xff0c;在制造业现场太容易被忽略了。大家一提到数字化转型&#xff0c;想到的都是MES、ERP、工业互联网、大数据平台&#xff0c;却很少有人专门去聊一个工位上每天重复几千次的动作——把螺栓拧到规定扭矩。可偏偏就是这“拧一下”的动作&#xff0c;直…

作者头像 李华