用大模型抽 JSON 测试全过上线偶崩:一次 LLM 结构化输出不可靠的复盘

一个功能用大模型把用户自然语言日程解析成结构化 JSON 再建日历,开发机测几十句模型每次都吐回干净 JSON 完美,一上线就偶发 json.decoder.JSONDecodeError 功能崩,代码没改模型没换就是时不时崩一次。排查梳理:别盯 JSONDecodeError 堆栈它只说第几列非法,在 json.loads 之前把模型返回的原始文本完整记进日志,攒一批失败样本发现坏输出就那么几种固定形态外面包了 markdown 代码围栏、JSON 前面夹了客气话、JSON 被 max_tokens 截断只有半个、用了单引号带尾逗号;核心认知大模型不是 JSON 生成器它本质是文本概率生成器做的唯一一件事是预测下一个最可能的 token 生成的每个字都是概率采样是最可能不是必然,让它返回 JSON 它只是生成一段统计上最像 JSON 的文本出口处没有语法检查器把关绝大多数时候最像恰好合法但偶尔骰子掷出不合法形态就是偶发崩溃,它包代码围栏加客气话是因为训练数据里 JSON 大量伴随这些一起出现;治本一是防御性解析别裸 json.loads 统一剥掉代码围栏、从第一个左花括号到最后一个右花括号抠出 JSON 主体甩掉前后自然语言、正则去尾逗号容错,二是从源头少出错 prompt 明确只返回 JSON 不要解释给 few-shot 示例、优先用 API 的 JSON mode 保证返回合法 JSON、更稳的是 function calling 结构化输出把 JSON Schema 交给 API 强制约束字段类型、max_tokens 给足别截断;最后一道防线 json.loads 成功只代表是合法 JSON 不代表是你要的 JSON 必须再用 jsonschema 校验字段类型必填项,解析或校验失败别直接崩带着上次错在哪的具体反馈重试且限次数,重试仍失败必须有兜底友好提示或安全默认值绝不让异常裸奔到用户。正确做法是模型输出是概率性文本不是确定性 JSON,出口必须防御性解析加 schema 校验加失败兜底,以及一套 LLM 结构化输出排查纪律。

2024 年,一次"我用大模型把用户的自然语言解析成 JSON,测试时好好的,一上线就偶发崩溃"的事故,把我对"大模型输出"这件事的理解,从头到尾翻新了一遍。我们做了个功能:用户用大白话写一句日程,比如"明天下午三点和张总开个会",我们把这句话丢给大模型,让它返回一个结构化的 JSON:{"date": "...", "time": "...", "title": "...", "participants": [...]},然后我们拿这个 JSON 去建日历。我在开发机上测了几十句,模型每次都稳稳地吐回一个干净的 JSON,我用 json.loads() 一解析,字段齐整,完美。我很满意,上线。结果上线没几天,错误监控就开始零零星星地报:json.decoder.JSONDecodeError。不是每次都错,是【偶发】——同一个功能,大部分请求好好的,时不时崩一次,崩了用户那次日程就建不出来。我懵了。我的代码一个字没改,模型也没换。同样是"自然语言进、JSON 出"的活,为什么测试时一次没错,上线后却像抽风一样,时不时给我一个解析不了的东西?如果模型"会"输出 JSON——它在我测试时明明输出得好好的——那它上线后给我的那些解析失败的东西,到底【是什么】?它是不是,根本就没在"保证"给我 JSON?我一直以为我从模型那里拿到的是"一个 JSON",会不会,我拿到的从来只是"一段【看起来像】 JSON 的文本"?这件事逼着我把大模型到底是什么、它为什么不保证输出合法 JSON、防御性解析怎么做、function calling / JSON mode 是干什么的、结构化输出该怎么校验和兜底,彻底理清了。本文复盘这次实战。

问题背景

环境:一个功能,用大模型把用户自然语言解析成 JSON
事故现象:
- 测试环境:几十句输入,模型每次都返回干净 JSON,零失败
- ★ 上线后:偶发 json.decoder.JSONDecodeError,功能崩
- 代码没改、模型没换 —— 就是"时不时"崩一次

现场排查:
# 1. ★ 关键的一步:把"模型返回的原始文本"原样打出来
#    —— 别只看 json.loads 抛的异常,看它到底返回了啥
$ 在 json.loads 之前,把模型原始返回打到日志

# 2. ★★ 几次解析失败的案例,模型实际返回的是这些:

# 案例 A:外面包了一层 markdown 代码围栏
# ```json
# {"date": "2024-06-08", "title": "和张总开会"}
# ```
# ★ json.loads 解析不了开头那个 ```

# 案例 B:JSON 前面有一句"客气话"
# 好的,已为您解析,结果如下:
# {"date": "2024-06-08", "title": "开会"}
# ★ 前面那句中文,直接让解析炸了

# 案例 C:JSON 不完整 —— 被截断了
# {"date": "2024-06-08", "title": "和张总讨论下半年市场预算分配
# ★★ 后面没了!少了右引号、右括号 —— 输出被 max_tokens 截断

# 案例 D:用了单引号 / 带尾逗号
# {'date': '2024-06-08', 'title': '开会',}
# ★ 单引号、尾逗号 —— Python dict 像,但不是合法 JSON

# 3. ★ 为什么测试时没事
#    测试输入都短而规整,模型"心情好",输出也规整;
#    上线后输入五花八门(超长、口语、含特殊符号),
#    把模型各种"不规矩"的输出形态全触发出来了。

根因(后来想清楚的):
1. ★★ 大模型【不是 JSON 生成器】。它本质是一个
   "文本概率生成器" —— 它做的事是"根据上文,预测
   下一个最可能的字",它生成的是【最像 JSON 的文本】,
   而【不保证】是一个语法合法的 JSON。
2. ★ "输出 JSON"对它来说,只是"输出一种碰巧长这样
   的文本"。它没有一个语法检查器在出口处帮你把关。
3. ★ 所以它【时常】会:包代码围栏、加解释性的话、
   用单引号、留尾逗号;输出太长还会被 max_tokens
   【拦腰截断】,留下半个残缺 JSON。
4. ★ 我的错:我把它当成了一个"确定性的 JSON 接口",
   直接 json.loads。我没有给它的输出做任何【清洗、
   校验、容错】 —— 它一旦不规矩,我就崩。
真相:大模型的输出是【概率性】的文本,不是【确定性】
的 JSON。凡是拿模型输出当结构化数据用,出口处
必须有"防御性解析 + schema 校验 + 失败兜底"。

修复 1:JSON 解析偶发失败——先把"模型原始返回"打出来

# === ★ 解析失败,第一刀:看模型到底返回了"什么" ===

# === ★ 别盯着 JSONDecodeError 的堆栈,那没用 ===
# ★ json.loads 抛 JSONDecodeError,异常信息只会告诉你
#   "在第几行第几列有个非法字符" —— 它【不会告诉你】
#   模型返回的整段文本长什么样。
# ★ ★ 而定位这类问题,唯一有用的信息,就是【模型
#   返回的原始文本本身】。你得亲眼看见那段文本,
#   才知道它是多了个代码围栏、还是被截断了、还是
#   前面多了句话。

# === ★★ 决定性动作:解析前,先把原始返回完整记下来 ===
# ★ 在 json.loads(raw) 这一行【之前】,把 raw 这个
#   原始字符串,完整地打进日志(或在出错时打)。
# ★ ★ 这一步价值极高:它把"偶发、抓不住"的问题,
#   变成了"出错时,案发现场的文本就躺在日志里"。
#   本文那 4 个案例(代码围栏 / 客气话 / 截断 / 单引号),
#   全是这么一眼看出来的。

# === ★ 把失败样本归归类 ===
# ★ 攒上十几二十个失败的原始返回,你会发现它们不是
#   乱七八糟,而是【几种固定的"坏形态"】反复出现:
#  - 形态①:被 ```json ... ``` 代码围栏包住;
#  - 形态②:JSON 前后有解释性的自然语言;
#  - 形态③:JSON 被截断,后半截没了;
#  - 形态④:不合 JSON 规范(单引号、尾逗号、注释)。
# ★ 认清这几种形态,后面的"防御性解析"才有的放矢。

# === 认知 ===
# ★ JSON 解析偶发失败,别盯着 JSONDecodeError 的堆栈
#   (它只说第几列非法,不告诉你全貌)。★★ 决定性
#   动作:在 json.loads 之前把【模型返回的原始文本】
#   完整记进日志 —— 把"偶发抓不住"变成"案发现场就在
#   日志里"。攒一批失败样本会发现坏输出就那么几种固定
#   形态:代码围栏、前后夹自然语言、被截断、单引号尾
#   逗号 —— 认清形态,防御性解析才有的放矢。
import json, logging

def parse_llm_json(raw: str):
    """解析大模型返回的 JSON —— 出错时,把原始返回留证"""
    try:
        return json.loads(raw)
    except json.JSONDecodeError as e:
        # ★★ 关键:把模型返回的原始文本,完整记下来
        logging.error("LLM JSON 解析失败 | err=%s | 原始返回=%r", e, raw)
        raise

# ★ 上线后翻这条日志,4 类"坏形态"一眼看清:
#   原始返回='```json\n{"date":"..."}\n```'    <- 代码围栏
#   原始返回='好的,结果如下:\n{"date":"..."}'  <- 前面夹了话
#   原始返回='{"date":"2024-06-08","title":"和张总  <- 被截断
#   原始返回="{'date':'2024-06-08',}"            <- 单引号+尾逗号

修复 2:核心认知——大模型是"概率文本生成器",不是"JSON 生成器"

# === ★ 这一节是全文的认知核心:模型到底在"生成"什么 ===

# === ★ 大模型做的事,只有一件:预测下一个 token ===
# ★ 剥到最本质,一个大语言模型做的事【只有一件】:
#   给定前面已有的文本,预测【下一个 token 最可能
#   是什么】,然后吐出来;再把它接上,预测再下一个。
# ★ ★ 它是一个"文本接龙"机器。它生成的每一个字,
#   都是一次【概率采样】的结果 —— 是"最可能",而
#   【不是"必然"】。

# === ★★ 于是,关于"输出 JSON",真相是残酷的 ===
# ★ 当你让它"返回 JSON",它【并不理解】"JSON 必须
#   语法合法"这条硬约束。它做的,只是因为训练数据里
#   见过海量 JSON,所以它会生成一段【在统计上非常
#   像 JSON 的文本】。
# ★ ★★ 关键:它的出口处,【没有一个 JSON 语法检查器】
#   在把关。它不会在吐出 } 之前回头数一下括号配没
#   配对。它只是"觉得"这里大概率该是个 } 就吐了。
# ★ ★ 所以它生成的,是"最像 JSON 的文本",不是
#   "保证合法的 JSON"。绝大多数时候,"最像"恰好
#   就是"合法";但【偶尔】,概率的骰子掷出了一个
#   不合法的形态 —— 这就是你的"偶发崩溃"。

# === ★ 为什么会包代码围栏、加客气话 ===
# ★ 这恰恰是"概率"的体现:模型的训练数据里,JSON
#   大量出现在 markdown 代码块 ```json 里、出现在
#   "这是结果:"这类话后面。所以它生成 JSON 时,
#   【顺手】把这些它"见惯了的伴生物"也一起生成了 ——
#   在它的统计世界里,这很"自然"。

# === ★ 为什么会被"截断" ===
# ★ 这是另一回事,但同样致命:你设了 max_tokens 上限
#   (该设,见前文成本篇)。模型生成到一半,token
#   数撞上上限,就【被强行喊停】 —— 不管这个 JSON
#   有没有写完。于是你拿到半个 JSON,必崩。

# === ★ 结论:认知必须转过来 ===
# ★ ★ 别再把大模型的输出,当成一个"确定性 API 的
#   返回值"。它是一段【概率性生成的文本】。
# ★ 凡是要把它当"结构化数据"用,你【必须】在它和
#   你的代码之间,架一道"防线":清洗 + 校验 + 兜底。
#   把"它大概率会对"这件事,和"我的代码必须不崩"
#   这件事,彻底解耦。

# === 认知 ===
# ★★ 大模型本质是"文本概率生成器",它做的唯一一件事
#   是"预测下一个最可能的 token",生成的每个字都是
#   概率采样的结果 —— 是"最可能"不是"必然"。让它
#   "返回 JSON",它只是生成一段【统计上最像 JSON 的
#   文本】,出口处【没有语法检查器把关】。绝大多数
#   时候"最像"恰好"合法",但偶尔骰子掷出不合法形态
#   就是你的偶发崩溃。★ 认知必须转过来:模型输出是
#   概率性文本不是确定性 JSON,当结构化数据用就必须
#   在出口架"清洗+校验+兜底"的防线。

修复 3:防御性解析——别直接 json.loads,先清洗再容错

# === ★ 既然模型输出"不规矩",解析就得"防着它" ===

# === ★ 第一步:剥掉 markdown 代码围栏 ===
# ★ 针对"坏形态①":返回被 ```json ... ``` 包着。
#   解析前,先把开头的 ```json / ``` 和结尾的 ```
#   这几个标记,统统去掉。

# === ★ 第二步:从一堆文本里,"抠"出那个 JSON ===
# ★ 针对"坏形态②":JSON 前后夹着自然语言。
# ★ ★ 一个稳的做法:在整段文本里,找【第一个 {】
#   和【最后一个 }】,把它俩之间的内容截出来 —— 前后
#   那些客气话,自然就被甩掉了。
# ★ (如果要的是 JSON 数组,就找第一个 [ 和最后一个 ]。)

# === ★ 第三步:对常见的"不规范"做容错修补 ===
# ★ 针对"坏形态④":单引号、尾逗号。
#  - 尾逗号:把 ",}" 替换成 "}"、",]" 替换成 "]";
#  - 单引号:谨慎地替换成双引号(★ 这步有风险 ——
#    内容里本身带引号会被误伤,能用 prompt 让模型
#    别犯就别靠这步硬修)。
# ★ ★ 容错修补是"亡羊补牢",能修一部分,但别指望
#   它修好一切 —— 真正的解法在下一节(让模型少犯错)。

# === ★ 第四步:截断的,基本救不回来 ===
# ★ 针对"坏形态③":被 max_tokens 截断的半个 JSON。
# ★ ★ 这种【基本没法靠解析救】—— 数据本身就缺了。
#   正解是:① 把 max_tokens 调大到足够;② 让输出
#   更紧凑(少返回无关字段);③ 解析失败后重试。

# === ★ 把这几步,封装成一个"防御性解析"函数 ===
# ★ 别在业务代码里到处 json.loads。统一收口到一个
#   函数里:剥围栏 -> 抠 JSON -> 容错修补 -> 解析,
#   失败了再走重试 / 兜底。出口干净,业务才干净。

# === 认知 ===
# ★ 模型输出不规矩,解析就得"防着它",做防御性解析:
#   ① 剥掉 markdown ```json 代码围栏;②★ 从文本里找
#   第一个 { 和最后一个 } 把 JSON"抠"出来,甩掉前后
#   的自然语言;③ 对尾逗号、单引号等做容错修补(单
#   引号替换有误伤风险,能靠 prompt 避免就别硬修);
#   ④ 被 max_tokens 截断的半个 JSON 基本救不回来,要
#   靠调大 max_tokens / 紧凑输出 / 重试。把这几步收口
#   成一个统一的防御性解析函数,别在业务代码到处 loads。
import json, re

def safe_parse_json(raw: str):
    """防御性解析:剥围栏 -> 抠 JSON -> 容错修补 -> 解析"""
    text = raw.strip()

    # ★ 1. 剥掉 markdown 代码围栏 ```json ... ```
    text = re.sub(r'^```(?:json)?\s*', '', text)
    text = re.sub(r'\s*```$', '', text)

    # ★ 2. 从第一个 { 到最后一个 },抠出 JSON 主体
    start, end = text.find('{'), text.rfind('}')
    if start != -1 and end != -1 and end > start:
        text = text[start:end + 1]

    # ★ 3. 容错:去掉对象/数组里的尾逗号
    text = re.sub(r',\s*([}\]])', r'\1', text)

    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return None          # ★ 修不好就返回 None,交给上层重试/兜底

# ★ 业务代码统一调 safe_parse_json,不再到处裸 json.loads

修复 4:治本——让模型"更可能"输出对(JSON mode / function calling)

# === ★ 防御性解析是"接住错",这一节是"少出错" ===

# === ★ 手段 1:prompt 里把要求"钉死" ===
# ★ 别只说"返回 JSON"。要明确、强硬地约束:
#  - "只返回 JSON 本身,不要任何解释、不要 markdown
#    代码块、不要 ``` 标记";
#  - 把你要的【字段名、类型、枚举值】一条条列清楚;
#  - ★ 给一两个完整的"输入 -> 输出"示例(few-shot)
#    —— 模型照着例子的样子学,比你干讲规则管用得多。

# === ★★ 手段 2:用"JSON mode" —— 让 API 替你保证合法 ===
# ★ ★ 现在主流大模型 API,大多提供一个【JSON mode】
#   (response_format 设成 json_object 之类)。
# ★ ★★ 开了它,API 会【保证】返回给你的是一个语法
#   合法的 JSON —— 它在生成时受约束,不会再给你
#   代码围栏、客气话、尾逗号。这一招直接干掉"坏
#   形态①②④"。

# === ★★ 手段 3:function calling / 结构化输出 —— 连字段都锁死 ===
# ★ JSON mode 只保证"是个合法 JSON",不保证"字段
#   符合你的要求"。再进一步:
# ★ ★ function calling(工具调用)/ structured output:
#   你把想要的结构,写成一个 JSON Schema 交给 API。
#   API 会【强制】模型的输出严格贴合这个 Schema ——
#   字段名、类型、必填项、枚举值,全锁死。
# ★ ★★ 这是目前拿大模型做"结构化数据抽取"最稳的
#   方式 —— 把"输出对不对"这件事,从"求模型自觉",
#   变成了"API 强制保证"。优先用它。

# === ★ 手段 4:max_tokens 给够,别让 JSON 被截断 ===
# ★ 针对"坏形态③"截断:预估你这个 JSON 最长会有多少
#   token,把 max_tokens 设得【足够大】,留出余量。
# ★ 同时让输出尽量紧凑(别让模型返回一堆你不要的
#   字段、别让它在 JSON 里写长篇大论)。

# === 认知 ===
# ★ 防御性解析是"接住错",这一节是"从源头少出错":
#   ① prompt 把要求钉死 —— 明说只返回 JSON 不要解释
#   不要代码块,列清字段名/类型/枚举,给 few-shot 示例;
#   ②★★ 用 API 的 JSON mode,它保证返回语法合法的
#   JSON,直接干掉代码围栏/客气话/尾逗号;③★★ 更进
#   一步用 function calling / 结构化输出,把 JSON Schema
#   交给 API 强制模型输出贴合 schema,字段类型枚举全
#   锁死 —— 这是结构化抽取最稳的方式,优先用;④ max_tokens
#   给足,别让 JSON 被拦腰截断。
# ★ 用 function calling / 结构化输出:把 schema 交给 API 强制约束
schema = {
    "type": "object",
    "properties": {
        "date":  {"type": "string"},
        "time":  {"type": "string"},
        "title": {"type": "string"},
        "participants": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["date", "title"],          # ★ 必填字段
    "additionalProperties": False,           # ★ 不许多塞字段
}

resp = client.chat.completions.create(
    model="...",
    messages=[
        {"role": "system", "content": "把用户日程解析成 JSON。只返回 JSON。"},
        {"role": "user", "content": "明天下午三点和张总开个会"},
    ],
    response_format={                        # ★★ 让 API 强制贴合 schema
        "type": "json_schema",
        "json_schema": {"name": "schedule", "schema": schema},
    },
    max_tokens=500,                          # ★ 给足,别截断
)
# ★ 这样拿到的输出,字段名/类型/必填项,API 已替你保证

修复 5:出口校验与失败兜底——把"概率"挡在业务之外

# === ★ 最后一道防线:校验 + 重试 + 兜底 ===

# === ★ 第一关:就算解析成功,也要做 schema 校验 ===
# ★ json.loads 成功,只代表"它是个合法 JSON",【不
#   代表】"它是你要的那个 JSON"。模型可能:少了
#   必填字段、字段类型不对(date 给成了数字)、枚举
#   值飘了、还幻觉多塞了字段。
# ★ ★ 所以解析成功后,必须再用一个【JSON Schema】
#   校验一遍:字段全不全、类型对不对、必填项有没有。
#   用 jsonschema 这类库,几行代码的事。

# === ★★ 第二关:校验不过,带着错误信息去重试 ===
# ★ 解析失败、或校验不过 —— 别直接崩,★ 重试。
# ★ ★ 重试有个技巧:别原样再问一遍。把"模型上次
#   返回的错误内容 + 具体哪里不对",一起塞回 prompt:
#   "你上次返回的 JSON 缺少 date 字段,请修正后重新
#   只返回 JSON" —— 带着反馈重试,成功率高得多。
# ★ 重试要有【次数上限】(如 2~3 次),别无限试。

# === ★ 第三关:重试还是不行,走"兜底" ===
# ★ 试了 N 次仍拿不到合法结果 —— 这时【一定要有
#   一个兜底】,绝不能让异常裸奔到用户面前:
#  - 给用户一个友好提示("没太理解,请换个说法");
#  - 或退回一条人工处理的链路;
#  - 或返回一个安全的默认值。
# ★ ★ 核心原则:模型那一侧的"概率不确定性",必须
#   【在你的出口处被完全消化掉】 —— 流到业务逻辑、
#   流到用户面前的,只能是确定的、合法的数据。

# === ★ 把整条链路串起来 ===
# ★ 调模型 -> 防御性解析 -> schema 校验 -> 不过则带
#   错误重试(限次)-> 仍不过走兜底。这一整条,就是
#   把一个"概率性的文本源",改造成一个"对业务确定
#   可靠的数据源"的全部工序。

# === 认知 ===
# ★ 最后一道防线:① json.loads 成功只代表"是合法
#   JSON",不代表"是你要的 JSON" —— 必须再用 JSON
#   Schema 校验字段全不全、类型对不对、必填项在不在
#   (jsonschema 库);②★ 解析失败或校验不过别直接崩,
#   带着"上次错在哪"的具体反馈重试,成功率远高于原样
#   重问,重试要限次;③ 重试仍失败必须有兜底(友好
#   提示/人工链路/安全默认值),绝不让异常裸奔到用户。
#   ★★ 核心原则:模型侧的概率不确定性必须在你的出口处
#   被完全消化,流进业务的只能是确定合法的数据。
from jsonschema import validate, ValidationError

def get_structured(user_input, max_retry=3):
    """完整链路:解析 -> 校验 -> 带反馈重试 -> 兜底"""
    err_feedback = ""
    for attempt in range(max_retry):
        raw = call_llm(user_input, extra=err_feedback)   # 调模型
        data = safe_parse_json(raw)                       # ★ 防御性解析
        if data is None:
            err_feedback = "你上次的返回不是合法 JSON,请只返回 JSON"
            continue
        try:
            validate(instance=data, schema=schema)        # ★ schema 校验
            return data                                   # ★ 全部通过
        except ValidationError as e:
            # ★ 带着"具体哪里不对"去重试,比原样重问强得多
            err_feedback = f"你上次的 JSON 不合规:{e.message},请修正"

    # ★★ 重试用尽 —— 兜底,绝不让异常裸奔到用户
    logging.error("LLM 结构化输出多次失败 | input=%r", user_input)
    return {"_fallback": True, "msg": "没太理解,请换个说法试试"}

修复 6:LLM 结构化输出排查纪律

# === 这次事故暴露的认知盲区,定几条纪律 ===

# === 1. ★ JSON 解析偶发失败,先把"模型原始返回"完整打进日志 ===

# === 2. ★★ 大模型是概率文本生成器,不保证输出合法 JSON ===

# === 3. ★ 失败样本归类:代码围栏 / 夹自然语言 / 被截断 / 单引号尾逗号 ===

# === 4. ★ 别裸 json.loads,统一走防御性解析:剥围栏 + 抠 JSON + 容错 ===

# === 5. ★★ 优先用 API 的 JSON mode / function calling,让 API 强制保证 ===

# === 6. ★ prompt 明确"只返回 JSON 不要解释",给 few-shot 示例 ===

# === 7. ★ max_tokens 给足,别让 JSON 被拦腰截断 ===

# === 8. ★★ 解析成功 ≠ 数据正确,必须再用 JSON Schema 校验字段和类型 ===

# === 9. ★ 失败带错误信息重试(限次),仍失败必须有兜底 ===

# === 10. 把模型输出改造成可靠数据源的工序链 ===
$ 调模型              # ① 优先开 JSON mode / 结构化输出
$ 防御性解析          # ② 剥围栏、抠 JSON、容错修补
$ schema 校验         # ③ 字段、类型、必填项
$ 带反馈重试(限次)  # ④ 把"哪里错了"塞回 prompt
$ 兜底                # ⑤ 仍失败,给用户安全的默认结果

命令速查

需求                          做法
=============================================================
定位解析失败                  json.loads 前把模型原始返回完整记进日志
剥 markdown 代码围栏          去掉开头 ```json 和结尾 ```
从杂文本里抠 JSON             截取第一个 { 到最后一个 } 之间
容错尾逗号                    正则把 ",}" "]" 前的逗号去掉
保证返回合法 JSON             开 API 的 JSON mode(response_format)
锁死字段和类型                function calling / json_schema 结构化输出
防止 JSON 被截断              max_tokens 给足余量 + 输出紧凑
校验结构正确                  jsonschema.validate 按 schema 校验
解析/校验失败                 带"哪里错了"的反馈重试,限次数
重试仍失败                    兜底:友好提示 / 人工链路 / 安全默认值

口诀:大模型是概率文本生成器 不保证输出合法 JSON
      出口必须 防御性解析 + schema 校验 + 失败兜底

避坑清单

  1. JSON 解析偶发失败先把模型返回的原始文本完整打进日志,异常堆栈本身定位不了问题
  2. 大模型是概率文本生成器不是 JSON 生成器,它生成最像 JSON 的文本但不保证语法合法
  3. 模型坏输出就那么几种固定形态,包代码围栏、前后夹自然语言、被截断、单引号尾逗号
  4. 别在业务代码里裸 json.loads,统一走防御性解析,剥围栏、抠出 JSON 主体、容错修补
  5. 被 max_tokens 截断的半个 JSON 基本救不回来,要靠调大 max_tokens 和让输出更紧凑
  6. 优先用 API 的 JSON mode,它能保证返回的是语法合法的 JSON,直接干掉大半坏形态
  7. 更稳的是 function calling 结构化输出,把 JSON Schema 交给 API 强制约束字段和类型
  8. prompt 要明确只返回 JSON 不要任何解释和代码块,并给一两个输入输出的 few-shot 示例
  9. json.loads 成功只代表是合法 JSON 不代表是你要的 JSON,必须再用 schema 校验字段类型
  10. 解析或校验失败要带着具体错误信息重试且限次数,重试仍失败必须有兜底不让异常裸奔

总结

这次"大模型解析 JSON,测试全过、上线偶崩"的事故,纠正了我一个关于"接口"的、藏得极深的错觉。在我过去的脑子里,我调用的任何一个东西,只要它叫"接口"、它"返回 JSON",它就属于一个我无比熟悉的世界:一个【确定性】的世界。在这个世界里,一个接口说它返回 JSON,它就【永远】返回合法的 JSON;同样的输入,它就【永远】给同样的输出;它对了一万次,我就有理由相信它第一万零一次也对。我这十几年写代码,调的全是这样的接口——它们像齿轮一样精确、像数学一样可靠。所以当大模型这个"接口"也对我说"我返回 JSON",我想都没想,就把它归进了那个确定性的世界,我对它,用的是我对一个普通函数的那种【完全的信任】:它返回什么,我就 json.loads 什么,中间不设一道防。它在测试时对了几十次,我那条"它会一直对"的直觉,就被彻底坐实了。直到上线后那一次次"偶发"的崩溃,我才如梦初醒地看清:我把一个【概率性】的东西,错放进了【确定性】的格子里。大模型这个"接口",它和我熟悉的那些接口,长着同一张脸——同样的函数签名、同样的"传入文本、返回 JSON"——可它们的【内核】是两个物种。普通接口的内核是【逻辑】:它执行一段确定的代码,结果是被推导出来的,所以它确定。大模型的内核是【概率】:它的每一个字都是一次采样,结果是被"掷"出来的,所以它,天生就【不确定】。它给我的从来不是"一个 JSON",而是"一段大概率长得像 JSON 的文本"——"大概率"这三个字,一直都在,只是测试时它运气好,没让我看见。复盘到最深,我意识到这件事真正教给我的,是世界上的"组件",其实分两种:一种是确定性的,你可以信任它的【每一次】;另一种是概率性的,你只能信任它的【绝大多数次】。而这两种东西,最危险的地方在于,它们在表面上、在"绝大多数次"里,长得一模一样——概率性组件在 99% 的情况下,表现得和确定性组件【完全没有区别】,温顺、可靠、规规矩矩。它把那 1% 的狰狞,藏在了你测试覆盖不到的角落里。你一旦因为那 99% 而给了它确定性组件的待遇——直接信任、不设防线——那 1% 就会在你最没防备的某个上线后的深夜,准时降临。这个教训,我后来到处都看见它的影子:一个"几乎总是成功"的网络请求,一个"基本不会为空"的缓存,一个"正常情况下有序"的消息队列,一个"通常很快"的下游服务——它们全都是概率性的,而我们太容易因为那个"几乎、基本、通常、正常情况下",就把它们当成"永远"来依赖。这次最大的收获,是我给自己立了一条新规矩:每当我要依赖一个外部的东西,我都会先给它分类——它,是确定性的,还是概率性的?如果是概率性的,那我就【必须】在它和我的核心逻辑之间,亲手砌一道墙:校验它、容错它、给它的失败准备好退路。我不再问"它会不会出错",我默认它【一定会】,我只问"它出错的时候,我接得住吗"。大模型那段时灵时不灵的 JSON 教给我的,不是一个解析技巧,而是一种更彻底的清醒:真正可靠的系统,不是用一堆"永远可靠"的零件搭起来的——那种零件根本不存在;它是在你【承认每个零件都可能失手】之后,靠你在零件与零件之间,亲手砌起的那一道道墙,才得以可靠的。你信任的不该是零件,你信任的,只能是你自己砌的那道墙。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

后台改 40 万条数据把前台下单也搞挂了:一次 MySQL 大事务与锁等待的复盘

2026-5-21 11:23:29

技术教程

一个热点 key 过期瞬间打挂数据库:一次 Redis 缓存击穿与雪崩穿透的复盘

2026-5-21 11:36:37

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索