线上 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 drift | owner 变成 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 条,分布如下:
| 样本类型 | 数量 |
|---|---|
| clean | 238 |
| fenced | 177 |
| single_quote | 161 |
| prefix_suffix | 139 |
| trailing_comma | 135 |
| missing_quote_key | 123 |
| python_literals | 96 |
| truncated | 70 |
| schema_drift | 61 |
我对比了三条链路:
parse_only:直接json.loads(raw)。extract_repair:先抽取 JSON 区域,再做便宜修复。extract_repair_validate:修复后再做 schema validation。
结果很有意思:
| 链路 | 成功数 | 成功率 | p50 | p95 | 适合场景 |
|---|---|---|---|---|---|
| parse_only | 299 / 1200 | 24.92% | 1.96us | 4.46us | 只接强约束 structured output |
| extract_repair | 1131 / 1200 | 94.25% | 5.71us | 19.02us | 草稿、离线任务、弱业务约束 |
| repair + validate | 974 / 1200 | 81.17% | 5.88us | 15.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_total | schema 校验失败 | 字段契约 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 报错时,只剩下一招:重试。