别只会重试:LLM JSON 失败 5 类修复法

0 阅读12分钟

线上 Agent 最烦人的故障,不是模型完全答错,而是它答了一个「差一点能用」的 JSON。本文用 1200 条本地脏样本跑了一遍:只 json.loads 成功率 24.92%,加修复链路能到 94.25%,但真正可上线的有效率只有 81.17%。差出来的 13 个点,就是很多系统半夜报警的来源。

你应该见过这种日志:

JSONDecodeError: Expecting property name enclosed in double quotes
raw="好的,结果如下:\n```json\n{'severity':'P1','owner':'api',}\n```"

第一反应通常是:再调一次模型。

这招短期能救火,长期会把系统拖进泥里。因为重试没有回答三个问题:这次到底是哪类失败?能不能本地修?修完是不是符合业务 schema?如果修不了,应该丢进死信队列,还是带着错误上下文定向重试?

结构化输出不是「让模型输出 JSON」这么简单。更准确一点说,生产里的结构化输出至少有四层契约:语法合法、Schema 合法、语义可接受、过程可追踪。少一层都可能出事。

这篇只拆一个窄问题:LLM JSON 解析失败该怎么工程化处理。没有玄学提示词,没有「把 prompt 写严谨一点」这种废话,直接看失败类型、修复链路、代码和数据。

先别急着怪模型:5 类失败不该用同一招

我把线上最常见的脏 JSON 分成 5 类。

类型典型样子本地能修吗应不应该重试风险
包装类```json fence、前后解释文字误截取多个 JSON
语法类单引号、尾逗号、key 没引号中高过度修复改变含义
类型类None、字符串数字、布尔大小写业务字段变脏
Schema driftowner 变成 team、字段缺失静默写入错误字段
截断/传输类少半个对象、流式中断补括号会制造假数据

这里有个反直觉点:能解析成功,不代表能用。

比如模型把 owner 写成 team

{
  "ticket_id": "INC-0042",
  "severity": "P1",
  "team": "api",
  "summary": "支付回调超时",
  "confidence": 0.81,
  "tags": ["timeout", "payment"]
}

这段是合法 JSON。json.loads 会非常开心。但你的工单分派服务要的是 owner,不是 team。如果后面代码默认 owner 为空就走 unknown,那这次事故看上去像「模型置信度不够」,实际是 schema drift 没被拦住。

所以我不太喜欢把所有问题都归因到「模型不稳定」。很多时候,是我们自己的接入层太乐观。

我跑了一个本地实验:1200 条脏 JSON

实验脚本放在文章目录的 experiments/json_repair_bench.py。它不调用远程模型,也不假装测了某个供应商。它测的是一件更稳定的事:面对常见脏输出形态,本地 parser / repair 链路能恢复多少。

样本固定 seed:7110803,共 1200 条,分布如下:

样本类型数量
clean238
fenced177
single_quote161
prefix_suffix139
trailing_comma135
missing_quote_key123
python_literals96
truncated70
schema_drift61

我对比了三条链路:

  1. parse_only:直接 json.loads(raw)
  2. extract_repair:先抽取 JSON 区域,再做便宜修复。
  3. extract_repair_validate:修复后再做 schema validation。

结果很有意思:

链路成功数成功率p50p95适合场景
parse_only299 / 120024.92%1.96us4.46us只接强约束 structured output
extract_repair1131 / 120094.25%5.71us19.02us草稿、离线任务、弱业务约束
repair + validate974 / 120081.17%5.88us15.54us线上业务写入、工单、订单、审计

只看成功率,extract_repair 很诱人:94.25%。但我会选第三条。

原因很简单:第二条把 schema_drift 也算成功。它把 JSON 解析出来了,却没有发现字段契约坏了。第三条多花不了多少 CPU,却能把 61 条 schema drift 和 96 条 None 类型问题拦下来。

这就是本文最重要的结论:生产链路不要追求最高解析成功率,要追求最高「可安全落库」成功率。

修复链路长什么样

我建议把 LLM JSON 处理拆成 6 个阶段:

raw_output
  -> extract_json_region
  -> cheap_syntax_repair
  -> json_parse
  -> schema_validate
  -> targeted_retry_or_dead_letter
  -> business_write

注意顺序。

不要一失败就重试。先做本地便宜操作:去 fence、截取 {...}、去尾逗号、补 key 引号。这些操作微秒级,远比再发一次模型请求便宜。

也不要修完就写库。schema validation 必须在业务写入前面。

Python 版本:先做便宜修复

下面这段是实验里的核心逻辑,精简后可以直接复制跑:

import json
import re


def extract_json_region(raw: str) -> str:
    s = raw.strip()

    # 去掉 ```json fence
    if s.startswith("```"):
        s = re.sub(r"^```(?:json)?\s*", "", s)
        s = re.sub(r"\s*```$", "", s)

    # 截取第一个 { 到最后一个 }
    first = s.find("{")
    last = s.rfind("}")
    if first >= 0 and last > first:
        s = s[first:last + 1]

    return s


def cheap_repair(raw: str) -> dict:
    s = extract_json_region(raw)

    # 单引号字符串:{'a':'b'} -> {"a":"b"}
    # 注意:这不是通用 Python literal parser,只处理简单场景
    s = re.sub(
        r"'([^']*)'",
        lambda m: json.dumps(m.group(1), ensure_ascii=False),
        s,
    )

    # 尾逗号:{"a":1,} -> {"a":1}
    s = re.sub(r",\s*([}\]])", r"\1", s)

    # 未加引号的 key:{severity:"P1"} -> {"severity":"P1"}
    s = re.sub(
        r"([\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:",
        r'\1"\2":',
        s,
    )

    # Python literal
    s = s.replace(": None", ": null")
    s = s.replace(": True", ": true")
    s = s.replace(": False", ": false")

    return json.loads(s)

这段代码故意没有做「智能补全」。

比如截断输出:

{"ticket_id":"INC-0999","severity":"P1","summary":"支付

你当然可以补一个 "} 让它变成合法 JSON。但这个 summary 已经丢信息了。对工单分类来说,宁可重试,也不要把半截内容写进库里。

便宜修复的边界要保守。它只处理「格式包装」和「低风险语法噪声」,不负责猜业务含义。

Schema validation:把「能解析」变成「能落库」

Python 里可以用 Pydantic,Node 里可以用 Zod。这里先给 Python 版本:

from typing import Literal
from pydantic import BaseModel, Field, ValidationError


class TicketRoute(BaseModel):
    ticket_id: str = Field(pattern=r"^INC-\d{4,}$")
    severity: Literal["P0", "P1", "P2", "P3"]
    owner: Literal["api", "frontend", "infra", "ai-platform", "unknown"]
    summary: str = Field(min_length=6, max_length=200)
    confidence: float = Field(ge=0, le=1)
    tags: list[str] = Field(min_length=1, max_length=5)


def parse_ticket(raw: str) -> TicketRoute:
    obj = cheap_repair(raw)
    return TicketRoute.model_validate(obj)


raw = """
好的,结果如下:
```json
{'ticket_id':'INC-1024','severity':'P1','owner':'api',
 'summary':'支付回调超时,需要排查网关日志','confidence':0.82,
 'tags':['timeout','payment'],}

"""

try: ticket = parse_ticket(raw) print(ticket.model_dump()) except (json.JSONDecodeError, ValidationError) as e: print("不可安全落库:", str(e))


如果模型输出 `team` 而不是 `owner`,这段会失败。失败是好事。

很多事故不是因为系统不知道错了,而是系统把错数据当成对数据继续跑。schema validation 的价值,就是尽早把「坏但看起来能用」的数据拦住。

### Node / Zod 版本:前端和工作流系统更常用

不少 Agent 工作流跑在 Node 侧,给一个等价版本:

```ts
import { z } from "zod";

const TicketRoute = z.object({
  ticket_id: z.string().regex(/^INC-\d{4,}$/),
  severity: z.enum(["P0", "P1", "P2", "P3"]),
  owner: z.enum(["api", "frontend", "infra", "ai-platform", "unknown"]),
  summary: z.string().min(6).max(200),
  confidence: z.number().min(0).max(1),
  tags: z.array(z.string()).min(1).max(5),
});

function extractJsonRegion(raw: string): string {
  let s = raw.trim();
  s = s.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");

  const first = s.indexOf("{");
  const last = s.lastIndexOf("}");
  if (first >= 0 && last > first) s = s.slice(first, last + 1);

  return s;
}

function cheapRepair(raw: string): unknown {
  let s = extractJsonRegion(raw);
  s = s.replace(/,\s*([}\]])/g, "$1");
  s = s.replace(/([\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
  s = s.replace(/: None/g, ": null").replace(/: True/g, ": true").replace(/: False/g, ": false");
  return JSON.parse(s);
}

export function parseTicket(raw: string) {
  const obj = cheapRepair(raw);
  return TicketRoute.parse(obj);
}

Node 版我没有处理单引号字符串,因为 JavaScript 正则处理嵌套引号很容易写出坑。工程上我更倾向于两种做法:要么让模型端强约束输出双引号 JSON,要么把单引号修复放到一个更严格的 tokenizer 里,不要在一行正则里赌运气。

定向重试:别再发一句「请输出合法 JSON」

重试不是不能用。问题是很多重试太笼统。

坏例子:

请重新输出合法 JSON,不要包含多余文字。

这句话没有告诉模型错在哪。更好的重试,是把 validation error 精确带回去:

from pydantic import ValidationError


def build_retry_prompt(raw: str, error: Exception) -> str:
    return f"""
你上一次输出不能被系统接收。请只返回一个 JSON 对象,不要 markdown,不要解释。

必须满足字段:
- ticket_id: 形如 INC-0001
- severity: P0/P1/P2/P3
- owner: api/frontend/infra/ai-platform/unknown
- summary: 6 到 200 字
- confidence: 0 到 1 的数字
- tags: 1 到 5 个字符串

校验错误:
{str(error)[:1200]}

上一次原始输出:
{raw[:2000]}
""".strip()


try:
    ticket = parse_ticket(raw)
except ValidationError as e:
    prompt = build_retry_prompt(raw, e)
    # 这里再发模型请求,而不是无脑重试原 prompt

定向重试的收益在于,它把「重新生成」变成「修正具体错误」。尤其是 schema drift、字段缺失、类型错误,模型通常能根据错误信息修回来。

但有一类不要自动重试太多:业务语义冲突。

比如 summary 里写「用户要求退款」,severity 却是 P3;或者 confidence=0.97,但 tags 为空。它可能不是格式问题,而是模型判断不稳。这里应该进入人工抽样、低置信度队列,或者换更强模型,不该靠 5 次重试碰运气。

可观测性:没有指标,修复链路会变成黑盒

修复链路上线后,至少打 6 个指标:

指标含义异常信号
json_parse_success_rate原始 JSON 直接解析成功率prompt 或模型输出风格变化
repair_applied_total本地修复次数上游开始夹带说明文字
repair_failed_total修复后仍失败截断、复杂脏格式增加
schema_validation_failed_totalschema 校验失败字段契约 drift 或业务提示词变了
retry_recovered_total定向重试恢复次数模型可修但首次输出不稳
dead_letter_total最终不可处理数量需要人工介入或降级

一个简单的埋点可以这样写:

import time
from collections import Counter

metrics = Counter()


def handle_model_output(raw: str):
    started = time.perf_counter()
    try:
        try:
            obj = json.loads(raw)
            metrics["json_parse_success_total"] += 1
        except json.JSONDecodeError:
            metrics["repair_applied_total"] += 1
            obj = cheap_repair(raw)

        ticket = TicketRoute.model_validate(obj)
        metrics["schema_validation_success_total"] += 1
        return ticket

    except json.JSONDecodeError:
        metrics["repair_failed_total"] += 1
        raise
    except ValidationError:
        metrics["schema_validation_failed_total"] += 1
        raise
    finally:
        cost_ms = (time.perf_counter() - started) * 1000
        # 接入 Prometheus / OpenTelemetry 时,把 cost_ms 记录成 histogram
        metrics["parser_latency_ms_sum"] += int(cost_ms)

这些指标最有价值的地方,不是证明系统很稳定,而是帮你定位变化来自哪里。

如果 json_parse_success_rate 突然从 80% 掉到 40%,但 schema validation 没变,通常是模型开始加解释文字或 fence。修 extract 就行。

如果 parse 成功率没变,schema_validation_failed_total 突然升高,问题更像是 prompt、schema 或上游字段含义变了。此时继续加强正则修复,只会把坏数据包装得更像好数据。

什么时候该用强约束输出,什么时候该上修复链路

现在很多模型和网关都提供 structured output、JSON schema、grammar decoding 之类能力。能用当然要用。它们能把很多语法类问题挡在生成阶段。

但我不建议因此删掉本地修复链路。

原因有三个。

第一,不同供应商、网关、SDK 对 schema 参数的支持并不完全一致。有的会透传,有的会降级,有的会在适配层改写嵌套 schema。你不能假设「我传了 schema」就等于「模型严格按 schema 生成」。

第二,强约束主要解决 syntax 和部分 schema 问题,不解决全部业务语义问题。severity=P0 合法,不代表它判断得对。

第三,生产系统需要可观测性。即使强约束把错误率降到 1%,你也要知道那 1% 是哪类错误,否则排障时只能翻原始日志。

我自己的决策框架是:

场景推荐链路
内部草稿、总结、低风险自动化structured output + extract repair
工单分派、告警分类、运营配置structured output + repair + schema validation
订单、支付、权限、审计schema validation + 低置信度人工队列,不自动猜字段
长文本抽取、流式输出分块 schema + checkpoint + dead letter
多模型网关切换每个模型记录 parse / repair / validate 指标

说白了,强约束输出是第一道门,不是保险柜。

别把「修复」写成安全漏洞

修复器最容易踩的坑,是过度热心。

我见过有人写这种逻辑:如果少字段,就给默认值;如果少右括号,就全部补齐;如果 owner 不在枚举里,就写 unknown;如果 confidence 不是数字,就写 0.5。

这看起来让成功率变高了,实际是在吞错误。

默认值只应该用于业务明确允许的字段。比如 tags 为空可以给 [],但 owner 不能随便给 unknown,因为这会影响分派。confidence 也不该自动写 0.5,因为它会污染后续评估数据。

我的建议:

  • 包装类问题可以自动修。
  • 简单语法类问题可以自动修,但要记录 repair_applied_total
  • 类型类问题只修低风险形式,比如 "0.82"0.82,并记录。
  • schema drift 不自动修字段名,进入定向重试。
  • 截断不补内容,优先重试或进 dead letter。

这套规则会让表面成功率低一点,但系统更诚实。

一个可落地的最小版本

如果你现在有一个 Agent 项目,最小改造不用很大。

第一步,把模型原始输出完整保存下来。不要只保存解析后的对象。

{
  "trace_id": "tr_20260711_0803_001",
  "model": "your-model-name",
  "raw_output": "...",
  "parser_stage": "schema_validation_failed",
  "validation_error": "owner field required",
  "retry_count": 1,
  "accepted": false
}

第二步,把 parser 包成独立模块。不要散落在每个业务 handler 里。

第三步,上 3 个最关键指标:直接解析成功率、schema 失败数、死信数。

第四步,只对 validation error 做定向重试,不对所有错误无脑重试。

第五步,每周抽样看 dead letter。dead letter 不是垃圾桶,它是你发现 prompt/schema/模型变化的入口。

如果你用了统一 API 网关或多模型路由,也要把模型名打进指标里。我自己日常会把不同模型的 parse、repair、validate 指标放在同一张面板里看;类似 TheRouter、LiteLLM、OneAPI 这种网关都能做统一入口,关键是别只看调用成功率,要看结构化输出的有效成功率。

常见问题

Q:有 JSON mode 了,还需要本地 repair 吗?

A:需要。JSON mode 主要降低语法错误,不能覆盖前后缀包装、schema drift、业务语义冲突、传输截断。生产里本地 repair 更像安全网,不是替代模型能力。

Q:修复失败后应该重试几次?

A:我一般把格式问题控制在 1 次定向重试,schema drift 最多 2 次。超过 2 次还失败,通常不是偶发格式问题,而是 prompt、schema 或模型能力不匹配。继续重试只是在烧钱。

Q:为什么不直接让模型重新格式化旧输出?

A:可以,但它应该排在本地便宜修复之后。本地修复是微秒级,远程重试是百毫秒到秒级,还要花 token。先本地、再定向重试,成本和延迟都更稳。

Q:这套链路会不会太重?

A:不会。实验里 repair + validation 的 p95 只有 15.54us 量级,真正贵的是远程模型调用。把解析链路做扎实,反而能减少无效重试。

最后给一个判断标准

如果你的系统现在只记录「模型调用成功 / 失败」,那还不够。

对 LLM JSON 来说,更该记录这四个数:原始 parse 成功率、修复后 parse 成功率、schema validation 成功率、最终业务接收率。

这四个数一拆开,你会立刻知道问题在哪一层。

也只有拆开以后,你才不会在半夜看到 JSON 报错时,只剩下一招:重试。