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

仙人之下我无敌,
仙人之上一换一。

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

模型吐出来的 JSON 全是毛病,解析层该怎么兜

2026-9-15 / 0 评论 / 16 阅读

模型吐出来的 JSON 全是毛病,解析层该怎么兜

拿真实模型输出去解析,报错长这么一行:Expecting property name enclosed in double quotes: line 1 column 20。定位过去是一个多出来的尾随逗号,改提示词、加重试,三次回来的还是那个逗号,因为 temperature 一样的请求拿到的输出也一样。

之后把 RFC 8259 和 Python 的 json 文档翻出来,把能跟模型输出对上的条目抄成一张清单,一条条拿实测去撞。脏样本一共 17 条,脚本在 tools/verify-llm-json.py

条目一 · RFC 8259 §9:解析器 MAY 接受非 JSON 形式的扩展

RFC 给解析器留的原文里有一句「A JSON parser MAY accept non-JSON forms or extensions.」,容错解析照着它把这口子开着。口子开多大,17 条样本挨个撞一遍,判定标准只有一条,能不能切出一个合法的 JSON 对象:

共 17 条
  json.loads 直接上      过 3 条
  只去 markdown 围栏     过 5 条
  贪婪正则 {.*}          过 9 条
  ast.literal_eval      过 8 条
  parse_model_json()    过 15 条

挂掉的 2 条:单引号那条、被 max_tokens 截断那条

只去围栏能救回两条,是因为模型爱加 markdown 围栏,这是最便宜的一招。贪婪正则那 9 条有点侥幸,它的失败方式比成功率重要,那类单独占一节。ast.literal_eval 过 8 条,但它恰好能吃下单引号那条,值得留着当兜底。

条目二 · Python json 文档:JSONDecodeError 自带位置

文档对 json.loads 的承诺很短,只说非法 JSON 会抛 JSONDecodeError,好用的是异常自带的 msgposlinenocolno。一开始每遇到解析失败就把整段输出贴出来人眼找;后来发现报错里带字符位置,这就够了:

前面有废话或 BOM      → Expecting value: line 1 column 1 (char 0)
字符串里带裸换行       → Invalid control character at: line 1 column 54 (char 53)
全角冒号              → Expecting ':' delimiter: line 1 column 8 (char 7)
被 max_tokens 截断    → Unterminated string starting at: line 1 column 35 (char 34)
BOM                  → Unexpected UTF-8 BOM (decode using utf-8-sig)

char 0 基本就是前面有废话或者开头有个 BOM,跟内容无关。给出具体列号的,把那个位置的字符打出来看一眼,立刻就知道是全角冒号还是少了个引号(Expecting ',' delimiter 基本都是引号问题)。这一招把排查从几分钟压到几秒。

条目三 · RFC 8259 §4:逗号只做 value-separator

对象那节的 ABNF 是 object = begin-object [ member *( value-separator member ) ] end-object,逗号只出现在成员之间,整份语法里没有尾随逗号这个产生式。re.search(r'{.*}') 不是在解析,是在猜:

原文            : {"tool": "a", "args": {}}{"tool": "b", "args": {"expr": "1+1"}}
贪婪正则抓到的  : 两个粘在一起,json.loads 报 Extra data: line 1 column 26 (char 25)
只切第一个      : {"tool": "a", "args": {}}   ← 能解析,但第二个调用被静默丢掉
切出全部 2 个   : [{'tool': 'a', 'args': {}}, {'tool': 'b', 'args': {'expr': '1+1'}}]
带引号花括号的  : ['{"note": "他说 { 还没写完"}', '{"tool": "b"}']

贪婪正则配上 indexOf 取第一个,能解析但会吃掉第二个调用,而模型不会说明它其实给了两个。正确做法是扫一遍把顶层对象全切出来,同时跳过字符串内部的括号。

条目四 · RFC 8259 §2:literal names MUST be lowercase

结构那节列了六个结构字符,name-separator 就是 %x3A;同一节还有一句「The literal names MUST be lowercase. No other literal names are allowed.」。模型不认这套,三个常犯错误都留了样本:

【尾随逗号】原文: {"note": "表格里这行是 a,}", "keep": 1}
  全局 re.sub(r",(\s*[}\]])")  → {"note": "表格里这行是 a}", "keep": 1}
  解析结果                    → {'note': '表格里这行是 a}', 'keep': 1}
  note 字段本来是 'a,}',现在逗号没了,而且不报错

【True/None 归一】原文: {"note": "结果是 None,不是 True", "dry": True}
  全局 replace               → {"note": "结果是 null,不是 true", "dry": true}
  字符串内容被改了

【全角标点】原文: {"tool":"calc","args":{"expr":"1+1"}}
  全角: 和 , 归一           → 解析成功
  同一份数据里字符串内的标点  → 原样保留,一个字没动

去 BOM、去围栏、按配平切段这三件事无脑做,它们不改变数据的语义;上面三行里有两个是会静默改坏数据的。全局正则去尾随逗号这条最容易出事:出来的 JSON 合法,解析也成功,拿到手的是一个被悄悄改过的字符串值,它后来被拼进 SQL 或者写进文件,错在哪一步根本看不出来。

条目五 · Python ast 文档:literal_eval 只碰字面量

文档对 ast.literal_eval 的描述是「Safely evaluate an expression node or a string containing a Python literal」,能接受的只有字面量,函数调用和运算符不在此列。网上常见做法是把 ' 全换成 ",实测下来它会毁掉合法数据:

原文(合法 JSON,值是 it's fine): {"tool": "calc", "args": {"note": "it's fine"}}
无脑替换 ' 后的结果              : {"tool": "calc", "args": {"note": "it"s fine"}}
解析                             : JSONDecodeError: Expecting ',' delimiter: line 1 column 39

值里带撇号或者带英文缩写的场景一点都不少见,所以我不做这个替换。真的遇到单引号输出,兜底交给 ast.literal_eval,17 条里过 8 条,能把单引号那条救回来。它是 Python 专属,只处理字面量不执行代码,这一点是照文档用的,没做安全性审计。

条目六 · 文档没写的部分:截断和重试

补括号能救回截断那条:{"tool": "calc", "args": {"expr": "1+1 补成 {'tool': 'calc', 'args': {'expr': '1+1'}}。它能成是因为截断正好落在字符串外面;落点在字符串中间时,补出来的值可能是错的,而一个错的值比解析失败更危险,这一类不猜。

自己加的那条规则不在任何条目里:文档只规定合法的 JSON 长什么样,没规定修的时候不许碰字符串里面。改写先判断是否在字符串内部,在里面就一个字都不动,裸换行转成 \n 也按这条走,§7 说 U+0000 到 U+001F 必须转义,跟这条一致。

条目七 · 所有批注收口成一个函数

整条流水线收口成一个函数,规则还是那条:改写只在字符串外面做。跑 17 条过 15 条,挂掉的两条正是上面说选择不修的那两类。

def parse_model_json(text):
    """把模型输出里能救的 JSON 都解析出来,返回 (对象列表, 用到的修复)。

    规则只有一条:所有改写只在字符串外面做,字符串里的内容一个字都不动。
    """
    WIDTH = str.maketrans({"{": "{", "}": "}", "[": "[", "]": "]", ":": ":", ",": ","})
    LITERALS = {"True": "true", "False": "false", "None": "null"}
    out, notes = [], set()
    in_str, esc, i = False, False, 0
    text = text.lstrip("\ufeff")
    while i < len(text):
        ch = text[i]
        if esc:
            out.append(ch); esc = False; i += 1; continue
        if ch == "\\":
            out.append(ch); esc = True; i += 1; continue
        if ch == '"':
            out.append(ch); in_str = not in_str; i += 1; continue
        if in_str:
            if ch == "\r":
                i += 1; continue
            if ch == "\n":
                out.append("\\n"); notes.add("裸换行转义"); i += 1; continue
            out.append(ch); i += 1; continue
        if ch in "{}[]:,":
            out.append(ch.translate(WIDTH)); notes.add("全角标点归一"); i += 1; continue
        replaced = False
        for w, r in LITERALS.items():
            tail = text[i + len(w):i + len(w) + 1]
            if text.startswith(w, i) and not tail.isalnum():
                out.append(r); notes.add(f"{w} 归一"); i += len(w); replaced = True
                break
        if replaced:
            continue
        if ch == ",":
            j = i + 1
            while j < len(text) and text[j] in " \t\r\n":
                j += 1
            if j < len(text) and text[j] in "}]":
                notes.add("去尾随逗号"); i += 1; continue
        out.append(ch); i += 1

    fixed = "".join(out)
    objs, depth, start, in_str, esc = [], 0, None, False, False
    for idx, ch in enumerate(fixed):
        if in_str:
            if esc:
                esc = False
            elif ch == "\\":
                esc = True
            elif ch == '"':
                in_str = False
            continue
        if ch == '"':
            in_str = True
        elif ch == "{":
            if depth == 0:
                start = idx
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0 and start is not None:
                objs.append(json.loads(fixed[start:idx + 1]))
                start = None
    return objs, sorted(notes)

它的输出带一份修复记录,方便在日志里看到底动了什么:

✅ 尾随逗号                切出 1 个对象 / 修复动作: ['去尾随逗号']
✅ 全角冒号和逗号           切出 1 个对象 / 修复动作: ['全角标点归一']
✅ 全角标点出现在字符串里      切出 1 个对象 / 修复动作: ['无']   ← 没动它
✅ Python 风格的 True      切出 1 个对象 / 修复动作: ['True 归一']
✅ 两个 JSON 连着写         切出 2 个对象 / 修复动作: ['无']
✅ 被 max_tokens 截断      切出 0 个对象 / 修复动作: ['无']
❌ 单引号                  JSONDecodeError: Expecting property name enclosed in double quotes

有个细节要提:截断那条没有抛异常,返回的是空列表。调用方必须把「切出的对象数为 0」当成失败来处理,否则会得到一种很别扭的静默失败,循环照跑,模型收到一个空结果然后基于它继续编。用起来的顺序是:先直接 json.loads,失败再走这个函数;还是失败就重试一次请求,并在提示词里明确要求只输出 JSON。

如果手上的模型支持厂商保证的结构化输出,优先用那个,这篇文章说的事情能省掉一大半;解析层兜的是模型不支持结构化输出、提示词得自己拼,以及结构化输出偶尔也吐不出合法 JSON 的情况。

最后一条批注,也是唯一一条跟文档无关的:别拿重试代替解析容错。同一个 temperature 下重试三次,回来的是同一个逗号,开头那段就是这么来的。

    🤞 分享