一个直接把大模型返回当 JSON 来解析的接口,在线上偶发地解析崩溃——因为模型有时会"贴心"地多说几句话:一次 LLM 结构化输出的深度复盘
那个 bug 偶发得让我抓狂:我们有个功能,让大模型把一段文本抽取成结构化的 JSON(比如把简历抽成 {"name":..., "skills":[...]}),后端拿到模型的回复直接 json.loads 解析、入库。测试时跑了几十条都好好的,可一上线,就时不时蹦出 JSONDecodeError,概率不高、但稳定地出现,把那一批请求搞挂。我把出错时模型的原始返回打出来一看,哭笑不得——模型确实返回了 JSON,但它有时会这样返回:```json\n{"name":"张三"...}\n```(裹了一层 markdown 代码围栏),有时会在前面"贴心"地加一句 好的,这是抽取的结果:,有时JSON 里还带了个多余的尾逗号或把双引号写成了中文引号。我盯着这些"几乎是 JSON、但又不完全是"的返回,才终于想明白这个坑的根本,后背发凉:大模型的输出本质是"生成看起来合理的文本",它不是一个保证返回严格合法 JSON 的函数。我让它"返回 JSON",它大部分时候会给我合法 JSON,但它本质上是概率性的——它随时可能"自由发挥"地多加一句解释、裹个代码块、或写出一个差一点点的、不合法的 JSON。我却把它当成了一个"一定返回严格 JSON"的可靠接口,直接 json.loads,自然就会偶发崩溃。这篇就把这次"LLM 结构化输出不可靠"的坑,从头到尾复盘一遍。
故障现场:一个直接 json.loads 模型返回的接口
问题代码,是一个把大模型当"严格 JSON 接口"来用的写法:
# ✗ 出问题的代码: 直接把模型返回当合法JSON解析
def extract_resume(text: str) -> dict:
resp = llm.chat([
{"role": "system", "content": "把简历抽取成JSON, 包含name和skills字段"},
{"role": "user", "content": text},
])
content = resp.choices[0].message.content
return json.loads(content) # ✗ 直接解析! 模型返回不规范时就 JSONDecodeError
# 模型【大部分时候】返回合法JSON:
# {"name": "张三", "skills": ["Java", "Python"]} → json.loads 成功
# 但它【偶尔】会这样返回(都会导致 json.loads 崩溃):
# 情况A: ```json\n{"name":"张三",...}\n``` → 裹了markdown代码围栏
# 情况B: 好的,这是抽取结果:\n{"name":"张三",...} → 前面加了一句解释
# 情况C: {"name":"张三", "skills":["Java",]} → 多了个尾逗号(非法JSON)
# 情况D: {"name":"张三", "age": 28,} → 中文引号/多余逗号/缺引号等
# 情况E: 这段文本里没有明确的技能信息, 所以... → 干脆没返回JSON, 返回了解释
# 为什么:
# - LLM的输出是"概率性地生成文本", 不是"确定性地返回结构化数据";
# - 你在prompt里"要求"它返回JSON, 它会"尽量"照做, 但【不保证】每次都严格合法;
# - 它可能受训练习惯影响加上markdown围栏、解释性文字, 或生成出差一点的JSON。
# 关键: LLM不是"一定返回严格JSON的函数"; 把它的自由文本输出直接当合法JSON解析, 必偶发崩溃。
第一次看到这些"花式不合法"的返回时,我又好气又无奈:"我明明在 prompt 里写了'返回 JSON',它怎么还自由发挥?"这个坑最磨人的地方在于它的偶发性:大模型大部分时候(可能 95%+)都返回得好好的,测试时大概率测不出;它只在某些输入、某些时候"发挥"一下,导致 bug 低频、随机、难复现。更根本的是,它源于一个认知错位:我们带着"调用一个确定性 API"的心智(输入确定、输出格式确定、可靠),去对待一个"概率性生成模型"(输出是采样出来的、有随机性、不保证格式)。下面就来拆解,该怎么和这种"不那么听话"的输出打交道。
第一件事:搞懂 LLM 输出为什么不可靠,以及怎么提高可靠性
我认真梳理了 LLM 输出的特性,才理解该如何"驯服"它的结构化输出。
LLM 的结构化输出为什么不可靠? 怎么提高可靠性?
【核心: LLM是概率性生成文本的, 不保证格式; 要靠"约束生成"+"容错解析"+"校验重试"来保证】
1. 为什么不可靠:
- LLM本质是"根据概率, 一个token一个token地生成看起来合理的文本";
- 它没有"我必须输出严格合法JSON"的硬性保证——格式正确是"学来的倾向", 不是"机制保证";
- 受温度(随机性)、输入、训练习惯影响, 它可能加围栏、加解释、生成出微小格式错误。
2. 提高可靠性的几个层次(从源头到兜底):
① 用"结构化输出"功能从源头约束(最有效):
- 很多API提供 JSON mode / response_format=json / function calling / tool use,
它们会【强制】模型输出合法JSON(甚至符合你给的schema)——优先用这个!
② prompt里明确要求 + 给例子(few-shot):
- 明确说"只返回JSON, 不要任何解释、不要markdown围栏";
- 给一两个输入输出的例子, 让它照着格式来。
③ 降低温度: 结构化抽取这类任务, 把temperature调低(如0), 减少"自由发挥"。
④ 容错解析: 解析前先"清洗"——去掉markdown围栏、提取第一个{到最后一个}之间的内容。
⑤ schema校验 + 重试: 解析出来后用schema校验字段; 不合法就带着错误信息让模型重试。
3. 核心心态转变:
- 不要把LLM当成"确定可靠的函数", 而要当成"聪明但有时不靠谱的助手";
- 对它的输出, 要像对待"外部不可信输入"一样: 不信任、要校验、要兜底。
一句话: LLM概率性生成、不保证输出格式; 提高可靠性靠"用JSON mode/function calling从源头约束
+ 明确prompt + 低温度 + 容错解析 + schema校验重试"; 把它的输出当不可信输入来防御。
这套认知,是整个坑的根。为什么不可靠:LLM 本质是"根据概率一个 token 一个 token 生成看起来合理的文本",它没有"必须输出严格合法 JSON"的硬性保证——格式正确是"学来的倾向"、不是"机制保证",受温度/输入/训练习惯影响可能加围栏、加解释、生成微小格式错误。提高可靠性分层次:①用结构化输出功能从源头约束(最有效)——JSON mode/response_format/function calling 会强制模型输出合法 JSON(甚至符合 schema),优先用;②prompt 明确要求+给例子;③降低温度(抽取任务 temperature 调低减少自由发挥);④容错解析(去 markdown 围栏、提取第一个 { 到最后一个 });⑤schema 校验+重试(不合法带错误让模型重试)。核心是心态转变:别把 LLM 当确定可靠的函数,而当"聪明但有时不靠谱的助手";对它的输出像对待外部不可信输入一样不信任、要校验、要兜底。一句话:LLM 概率性生成、不保证格式;提高可靠性靠"JSON mode/function calling 从源头约束+明确 prompt+低温度+容错解析+schema 校验重试";把它的输出当不可信输入来防御。
第二件事:正解——用 JSON mode/function calling 从源头约束,加容错解析与校验重试
搞懂了原理,正解就清晰了:优先用 API 的结构化输出能力(JSON mode/function calling)从源头强制合法;再加容错解析(清洗围栏)、schema 校验、失败重试,层层兜底。
# ====== 正解一(最有效): 用 JSON mode / 结构化输出, 从源头强制合法JSON ======
from pydantic import BaseModel
import json, re
class Resume(BaseModel): # 用 schema 定义期望结构
name: str
skills: list[str]
def extract_resume(text: str) -> Resume:
resp = llm.chat(
messages=[
{"role": "system", "content": "把简历抽取成JSON, 只返回JSON, 不要任何解释或markdown"},
{"role": "user", "content": text},
],
response_format={"type": "json_object"}, # ★ JSON mode: 强制输出合法JSON
temperature=0, # ★ 抽取任务用低温度, 减少自由发挥
)
content = resp.choices[0].message.content
data = json.loads(content) # JSON mode下基本能保证合法
return Resume(**data) # ★ 用schema校验字段(缺字段/类型错会报错)
# → JSON mode强制合法JSON + schema校验结构, 从源头解决了大部分问题。
# (更强的是 function calling / structured outputs, 能直接保证符合你给的schema)
# ====== 正解二: 容错解析 + 校验 + 重试(兜底, 即使没有JSON mode也能用) ======
def parse_llm_json(content: str) -> dict:
# 1. 清洗: 去掉markdown代码围栏
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip())
# 2. 提取: 从第一个 { 到最后一个 } 之间的内容(去掉前后的解释性文字)
start, end = content.find("{"), content.rfind("}")
if start != -1 and end != -1:
content = content[start:end + 1]
return json.loads(content)
def extract_with_retry(text: str, max_retry=2) -> Resume:
messages = [
{"role": "system", "content": "把简历抽取成JSON, 只返回JSON, 不要解释、不要markdown"},
{"role": "user", "content": text},
]
for attempt in range(max_retry + 1):
content = llm.chat(messages, temperature=0).choices[0].message.content
try:
data = parse_llm_json(content) # 容错解析
return Resume(**data) # schema校验
except (json.JSONDecodeError, ValueError) as e:
if attempt == max_retry:
raise # 重试用尽, 抛出(或走降级)
# ★ 把"你上次的输出解析失败了"告诉模型, 让它修正后重试
messages.append({"role": "assistant", "content": content})
messages.append({"role": "user",
"content": f"上面的输出不是合法JSON({e}), 请只返回合法的JSON, 不要任何其他内容"})
raise RuntimeError("unreachable")
# 核心: 优先用JSON mode/function calling从源头强制合法JSON; 配低温度+明确prompt;
# 再加容错解析(清围栏/提取{})、schema校验、失败带错误信息重试——层层兜底, 把偶发崩溃挡住。
修复的核心,是"从源头约束 + 层层兜底,别裸信模型的输出"。正解一(最有效):用 JSON mode/结构化输出从源头强制合法——response_format={"type":"json_object"} 强制输出合法 JSON,配 temperature=0 减少自由发挥,再用 pydantic 等 schema 校验字段(缺字段/类型错会报错);更强的 function calling/structured outputs 能直接保证符合你给的 schema。正解二:容错解析+校验+重试(兜底)——解析前清洗(去 markdown 围栏、提取第一个 { 到最后一个 })、schema 校验、失败就把"你上次输出解析失败了"告诉模型让它修正重试。归根结底:优先用 JSON mode/function calling 从源头强制合法 JSON,配低温度+明确 prompt;再加容错解析、schema 校验、失败带错误重试——层层兜底挡住偶发崩溃。
第三件事:LLM 工程化的其他常见坑
排查后我把 LLM 应用工程化相关的其他常见坑也系统梳理了一遍。
LLM 工程化的其他常见坑
# 1. 直接解析模型输出(本文): 输出不保证格式。→ JSON mode+容错解析+校验重试。
# 2. 把LLM当确定性函数: 同样输入可能不同输出(温度>0), 别假设可缓存/可复现。→ 低温度/接受随机性。
# 3. 完全信任输出(幻觉): 模型会"自信地编造"事实。→ 关键信息要核实/给来源/人工复核。
# 4. 没控制上下文长度: 输入太长超上下文窗口被截断/报错, 或费用暴涨。→ 裁剪/摘要/控制长度。
# 5. 没处理API失败/限流: 模型API会超时、限流、报错。→ 重试+退避+降级(同外部依赖)。
# 6. 没控成本: token按量计费, 失控的调用/超长上下文烧钱。→ 预算/监控/缓存可缓存的。
# 7. prompt硬编码散落: prompt难维护、难迭代、难评测。→ 集中管理prompt、做版本和评测。
# 8. 没有评测集: 改了prompt/换了模型, 不知道效果变好还是变坏。→ 建评测集量化质量。
# 共同根源: 把"概率性的、不确定的、可能出错的"大模型, 当成"确定的、可靠的、稳定的"组件来用;
# 而LLM是个"能力强但不稳定"的概率系统, 必须按"它会以各种方式不靠谱"来做工程防御。
# 核心: 把LLM当"强大但不可全信的概率组件": 结构化输出从源头约束、输出校验兜底、控制上下文和成本、
# 处理API失败、管理prompt、建评测集; 用确定性的工程手段, 包裹住不确定的模型。
排查让我把 LLM 工程化的其他坑也梳理清了。一、直接解析模型输出(本文)。二、把 LLM 当确定性函数(输出有随机性)。三、完全信任输出(幻觉)(关键信息要核实)。四、没控上下文长度(超窗口/费用暴涨)。五、没处理 API 失败/限流。六、没控成本。七、prompt 硬编码散落。八、没有评测集。它们的共同根源是:把"概率性的、不确定的、可能出错的"大模型,当成"确定的、可靠的、稳定的"组件来用;而 LLM 是"能力强但不稳定"的概率系统,必须按"它会以各种方式不靠谱"来做工程防御。核心是:把 LLM 当"强大但不可全信的概率组件":结构化输出从源头约束、输出校验兜底、控制上下文和成本、处理 API 失败、管理 prompt、建评测集;用确定性的工程手段包裹住不确定的模型。下面这张图,是这次 LLM 输出解析坑的成因与解法:
第四件事:保证 LLM 结构化输出的手段速查表
这次踩坑后,我把"让 LLM 输出可靠结构化数据"的手段按有效性整理成一张表。
| 手段 | 作用 | 有效性 |
|---|---|---|
| function calling/structured output | 强制符合给定schema | 最强(首选) |
| JSON mode | 强制合法JSON | 很强 |
| 明确prompt+给例子 | 引导格式 | 中(基础) |
| 低温度temperature=0 | 减少自由发挥 | 中(辅助) |
| 容错解析(清围栏/提取) | 兜住小瑕疵 | 兜底 |
| schema校验+重试 | 挡住不合规+给修正机会 | 兜底(必备) |
这张表把保证手段钉清了。核心是:让 LLM 输出可靠结构化数据,要"源头约束 + 兜底防御"双管齐下——源头上优先用 function calling/JSON mode 强制格式(从机制上保证)、配明确 prompt 和低温度;兜底上加容错解析和 schema 校验重试(挡住漏网的);越靠源头的手段越有效,但兜底也不能少。它给我的最大启发是:处理"不可靠的输入/输出"时,"预防(从源头减少出错)"和"兜底(出错了也能处理)"是两条都要有的防线——只靠预防(JSON mode)可能还有漏网、只靠兜底(容错解析)则把本可避免的问题都揽到下游;"从源头尽量保证 + 在末端兜底防御",是处理一切不确定性的稳妥组合。这其实是一个通用的可靠性思维:面对不可控的因素(LLM 输出、用户输入、网络、外部依赖),不要指望单一手段"一劳永逸",而要建立"纵深防御(defense in depth)"——多道防线层层设防,每道挡住一部分,即使某道失效,后面还有兜底;"源头约束 + 过程校验 + 末端兜底",比"把宝全押在一道防线上"可靠得多。用源头约束加末端兜底的纵深防御处理 LLM 输出——是这个坑带给我的可靠性认知。
第五件事:LLM 是"概率系统",和传统组件本质不同
这次事故让我深刻意识到,把 LLM 接进系统,需要换一套思维。我把 LLM 和传统组件做了对比。
| 维度 | 传统组件(函数/API) | LLM |
|---|---|---|
| 输出确定性 | 确定(同输入同输出) | 概率性(可能不同) |
| 格式保证 | 严格按约定 | 倾向性, 不保证 |
| 会不会"编" | 不会 | 会(幻觉) |
| 错误方式 | 报错/异常 | 静默给出错误/不合规结果 |
| 该怎么对待 | 信任契约 | 不信任+校验+兜底 |
| 测试 | 断言精确相等 | 评测集+容忍区间 |
这张表道出了一个根本性的差异。核心是:LLM 和传统组件本质不同——传统组件是确定性的(同输入同输出、严格按契约、错了会报错),LLM 是概率性的(输出可能变、格式不保证、会编造、且常常静默地给出错误结果);用对待传统组件的方式(信任契约、断言精确)去对待 LLM,必然踩坑。它给我的深刻启发是:把 LLM 引入软件系统,带来的不只是一个新 API,而是一种"新的组件范式"——我们过去几十年的工程经验,大多建立在"组件是确定、可靠、行为可预测"的假设上;而 LLM 打破了这个假设,它是一个"强大、灵活,但不确定、会出错、需要被'管教'"的概率组件;要用好它,需要调整我们的工程心智:从"依赖确定性"转向"拥抱并管理不确定性"。这给了我一种构建 AI 系统的核心心态:用"确定性的工程"去包裹"不确定性的模型"——在 LLM 这个不确定的核心外面,套上一层层确定性的"护具":结构化输出约束、输出校验、重试兜底、人工复核、评测监控;让"不可靠的智能",在"可靠的工程框架"里,发挥出可控、可用的价值。认清 LLM 是概率组件、用确定性工程包裹不确定模型——是这个坑带给我的、关于构建 AI 系统的范式认知。
第六件事:让 LLM 输出结构化数据时,我现在的做法
现在每当我要让大模型输出结构化数据,我都会按这张图先想清楚:
这张图的精髓,是"源头强制格式、低温度、容错解析、校验、失败重试或降级"。优先用 function calling/JSON mode 从源头约束;配低温度和明确 prompt;解析前容错清洗、用 schema 校验;校验失败就带错误重试、用尽则降级告警(别让脏数据进系统)。这套习惯,让我从"裸 json.loads 模型返回"变成了"从源头约束+层层兜底地拿模型的结构化输出"——核心始终是:LLM 输出不保证格式,要从源头约束、并把它的输出当不可信输入来校验和兜底。
我立下的几条规矩
这场"直接解析 LLM 输出偶发崩溃"的事故,换来了我做 LLM 应用时,刻进骨子里的几条铁律:
- LLM 是概率性生成,不保证输出格式。它"倾向"给合法 JSON,但不"保证"。
- 别裸 json.loads 模型返回。它会加围栏、加解释、生成微小格式错误。
- 优先用 function calling/JSON mode 从源头约束。这是最有效的手段。
- 结构化抽取用低温度。temperature=0 减少自由发挥。
- 解析要容错+schema 校验+失败重试。清围栏、提取、校验字段、带错重试。
- 把 LLM 输出当不可信输入。不信任、要校验、要兜底、要降级。
- 用确定性工程包裹不确定模型。源头约束+末端兜底的纵深防御。
写在最后
回头看,这场由"直接解析大模型返回"引发的、偶发崩溃的事故,真正教给我的,远不止"LLM 输出要用 JSON mode 和容错解析"这一个技巧。它让我对"把一个'概率性、不确定'的东西,接入一个'要求确定、可靠'的系统时,二者交界处需要一层专门的'转换/防御'",有了一次刻骨的体会。我栽跟头,根源在于我用对待"确定性世界"的方式,去对待一个"概率性的东西"。在我熟悉的传统编程世界里,一个函数/接口的输出是确定、可预测、有契约保证的——我调用它、拿到返回、直接用,天经地义。我把大模型也下意识地放进了这个"确定性"的框框里:我"要求"它返回 JSON,就以为它"一定会"返回严格的 JSON,像调用一个普通函数那样直接 json.loads。可大模型不属于这个确定性的世界——它是一个概率系统,它的输出是"采样"出来的、带着不确定性、不受严格契约约束;我把"概率世界的产物",直接喂给了"要求确定的代码",这两个世界的交界处,没有任何缓冲,自然会在不确定性"发作"时崩裂。这让我领悟到一个面向 AI 时代的核心认知:当"确定性的传统软件"要和"概率性的 AI 模型"协作时,二者的"交界处"是一个需要被精心设计的、特殊的地带——在这里,你要把模型那"自由、不确定、可能出错"的输出,"翻译/约束/校验"成下游确定性系统能安全消费的、可靠的数据;这层"不确定 → 确定"的转换与防御,正是 AI 工程区别于传统工程的、最关键也最容易被忽视的一环。这给了我一种构建 AI 应用的清醒定位:做 AI 工程,很大一部分功夫不在"调模型"本身,而在"如何在模型的不确定性,和系统的确定性需求之间,架起一座可靠的桥"——用结构化输出、校验、重试、降级、评测,把不可靠的智能,稳稳地接入可靠的系统;"驯服不确定性、为它兜底",是 AI 时代工程师的一项核心新技能。认清确定性系统与概率性模型的交界需要精心设计、做好"不确定到确定"的转换防御——这,是我用一次 LLM 输出崩溃的事故,换来的、关于 AI、也关于如何在 AI 时代做工程的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次让大模型返回 JSON 时,不再直接 json.loads,而是先想"用 JSON mode 约束、再校验兜底",那我对着那偶发的 JSONDecodeError 排查的这段时间,就值了。
—— 别看了 · 2026