我们有个功能,用大模型把用户的一段自然语言描述,抽取成结构化的 JSON——比如把"我想订下周三下午两点去北京的高铁"变成 {"date":"...","time":"14:00","destination":"北京"},后端再拿这个 JSON 去走下单流程。开发时我测了几十条,模型乖乖吐 JSON,解析、入库一气呵成,皆大欢喜。可上线后,监控里开始零星冒出解析报错:Unexpected token in JSON,而每一次报错,都意味着一个用户的请求在最后一步崩掉了。
我把那些失败的原始响应捞出来一看,真相哭笑不得。模型大部分时候确实返回纯 JSON,可总有一些时候,它"自作主张"地加了料:有时把 JSON 裹在 ```json ... ``` 的 Markdown 代码块里;有时在 JSON 前面客气地补一句"好的,这是为您解析的结果:";有时在结尾画蛇添足地加上"如果还需要调整请告诉我";偶尔还会因为字段值里有特殊字符,吐出一段格式微微破损的 JSON。我的代码呢,老老实实地直接把整个响应丢给 JSON.parse——它当然一口被这些"杂质"噎住,抛出异常。
这就是 LLM 应用工程化里一个极其普遍、又极易被 demo 顺利所掩盖的坑:大模型的输出是"概率性"的、不稳定的,你不能像对待一个确定性 API 那样,假设它每次都返回格式完美的结果。它本质是在"生成最可能的文本",而不是在"严格执行一个返回 JSON 的契约"。这篇文章,就从这次"LLM 返回的 JSON 偶发解析失败"的事故出发,把如何让大模型稳定输出结构化数据、以及如何稳健地消费它,一次讲透。
先摆几个关于 LLM 输出的想当然
动手复盘前,先把我自己曾经深信、后来被解析报错教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "我让它返回 JSON,它就会只返回 JSON" | 它是概率生成, 总有一定比例夹带代码块、解释文字 |
| "demo 测了几十条都对,上线就稳了" | 真实输入千奇百怪, 长尾 case 会逼出各种格式跑偏 |
| "把响应直接 JSON.parse 就行" | 裹在 ``` 里或带前后缀时, 直接 parse 必然抛异常 |
| "模型够强就不会输出错格式" | 再强的模型也有概率跑偏, 强弱只是概率高低之别 |
| "解析失败就让它报错呗" | 一个用户请求就此崩掉, 该有兜底/重试而非直接挂 |
这些念头的共同病根,是把大模型当成了一个确定性的、契约严格的程序接口来对待,而忘了它的本质是一个概率性的文本生成器。它"倾向于"按你的要求返回 JSON,但这只是一个高概率事件,不是百分之百的保证。要稳健地用好它,得先认清这个根本差异。
第一件事:认清大模型输出的"概率"本质
传统的 API 是确定性的:你调一个返回 JSON 的接口,它要么返回符合约定的 JSON,要么返回一个明确的错误码——它的输出空间是被代码严格框定的。可大模型完全不同:它的工作方式,是根据你给的提示,一个 token 一个 token 地预测"接下来最可能出现什么",最终拼成一段文本。当你要求它输出 JSON 时,它确实学到了"这种情况下大概率该吐 JSON",但"大概率"不等于"必然"——在某些输入下,它也可能"觉得"先说句客套话、或者用代码块包裹一下"更自然",于是就跑偏了。
这意味着一个根本的心态转变:对待大模型的输出,你不能"信任并直接使用",而要"验证并稳健处理"。它更像一个聪明但偶尔不严谨的实习生交上来的东西,而不是一个编译器输出的确定结果。下面这张图,把"确定性 API"和"概率性 LLM"在消费方式上的差异画出来:
看懂这张图,我那次事故的根就清楚了:我用对待左边"确定性 API"的方式(直接 parse),去消费右边"概率性 LLM"的输出,两者的预期一旦错位,偶发的崩溃就在所难免。大模型给你的不是一份保证合规的数据,而是一份大概率合规、但需要你亲自把关的草稿。接下来,我们就分两头说:一头是怎么让它尽量少跑偏(提高合规概率),一头是怎么稳健地接住它(兜住漏网的)。
第二件事:从源头降低跑偏——用 JSON 模式/结构化输出
降低模型跑偏概率,最有效的一招,是使用模型厂商提供的"结构化输出"能力,而不是仅仅在提示词里"请你返回 JSON"地拜托它。现在主流的大模型 API,大多提供了专门的机制:有的叫 JSON Mode(强制输出合法 JSON),更强的叫 Structured Output / 函数调用(让你传一个 JSON Schema,模型保证按这个 schema 输出)。这些机制在模型解码层面做了约束,能把"输出格式正确"的概率从"大概率"显著提升。
# 反例:只在 prompt 里恳求, 全凭模型自觉, 偶发夹带代码块/解释
resp = client.chat.completions.create(
model="...",
messages=[{"role": "user", "content": "请返回 JSON: " + text}],
)
data = json.loads(resp.choices[0].message.content) # 偶发抛异常
# 正解:开启 JSON 模式, 让 API 在解码层保证输出是合法 JSON
resp = client.chat.completions.create(
model="...",
messages=[
{"role": "system", "content": "你是解析器, 只输出 JSON, 不要任何额外文字。"},
{"role": "user", "content": text},
],
response_format={"type": "json_object"}, # 关键:强制 JSON 输出
)
data = json.loads(resp.choices[0].message.content) # 合规率大幅提升
更进一步,如果模型支持传 JSON Schema 的结构化输出,那是最佳选择——你把期望的字段、类型、是否必填都用 schema 描述清楚,模型不仅保证输出是合法 JSON,还保证符合你定义的结构,连"字段名拼错""该是数字却给了字符串"这类问题都能大幅减少。
# 最佳:传 schema, 模型保证输出既是合法 JSON、又符合这个结构
schema = {
"type": "object",
"properties": {
"date": {"type": "string"},
"time": {"type": "string"},
"destination": {"type": "string"},
},
"required": ["date", "time", "destination"],
}
resp = client.chat.completions.create(
model="...",
messages=[...],
response_format={"type": "json_schema",
"json_schema": {"name": "booking", "schema": schema}},
)
# 输出严格符合 schema, 下游消费的确定性大大增强
同时,提示词里也要把要求写得明确无歧义:"只输出 JSON,不要 Markdown 代码块,不要任何解释性文字,不要前后缀"——把那些它"容易自作主张加的料"逐个明令禁止。提示词约束 + JSON 模式/Schema,双管齐下,能把跑偏概率压到很低。但请注意,是"压到很低",不是"压到零"——这就引出了下一件同样重要的事。
第三件事:消费端必须稳健——提取、校验、兜底
无论上游怎么约束,你都不能假设输出 100% 合规,消费端必须自己长出一套"防御性消费"的能力。第一层是稳健提取:别直接把整个响应丢给 parser,而是先把可能的代码块标记、前后缀文字剥掉,从中"抠"出真正的 JSON 部分再 parse。
import json, re
def robust_parse(raw: str):
# 1. 先剥掉 markdown 代码块标记(```json ... ``` 或 ``` ... ```)
text = raw.strip()
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:
text = text[start:end + 1]
# 3. 再尝试解析
return json.loads(text)
# 这套"剥壳 + 截取"能救回绝大多数"夹了料"的响应
第二层是结构校验:就算 parse 成功了,也要验证拿到的对象字段全不全、类型对不对(用上一篇 TypeScript 提过的思路,或 Python 的 pydantic),别让一个缺字段的 JSON 流到下游再炸。第三层是失败兜底与重试:如果提取和校验都没救回来,不要直接让整个请求崩,而是带着"你上次输出格式不对,请严格只返回 JSON"的提示重试一次;重试仍失败,则走优雅降级(返回友好错误、转人工、用默认值),而不是抛一个 500 给用户。
def parse_with_retry(text, max_retry=2):
for i in range(max_retry):
raw = call_llm(text, stricter=(i > 0)) # 重试时用更严格的提示
try:
data = robust_parse(raw)
validate_schema(data) # 校验字段/类型
return data
except (json.JSONDecodeError, ValidationError):
continue # 解析/校验失败, 重试
return fallback_result() # 都失败, 优雅降级而非崩溃
这三层——稳健提取、结构校验、失败兜底——构成了消费 LLM 输出的标准防御姿势。它的核心理念是:把"模型可能不靠谱"当成默认前提,在消费端做好接住各种意外的准备。有了这套防御,即便上游偶尔跑偏,用户也几乎无感,而不是动不动就吃一个崩溃。
第四件事:调低 temperature,让输出更"听话"
有一个常被忽略、却很有效的小旋钮:temperature(温度)。它控制模型输出的"随机性"——温度越高,输出越多样、越有创造性,但也越容易跑偏;温度越低,输出越确定、越保守、越贴着最高概率走。对于"抽取结构化数据"这种追求准确和稳定、而非创造性的任务,应该把温度调到很低(0 或接近 0),让模型尽量走那条"最规矩"的输出路径。
# 结构化抽取这类任务, 不需要创造性, 把 temperature 压低求稳
resp = client.chat.completions.create(
model="...",
messages=[...],
response_format={"type": "json_object"},
temperature=0, # 关键:追求确定性时, 温度调到最低
# 还可配合 top_p 等参数进一步收紧输出分布
)
# 温度=0 时, 模型每次倾向于走同一条最高概率路径, 格式更稳定
这里要建立一个对应关系:需要发散、创意、多样性的任务(写文案、头脑风暴)用高温度;需要严谨、稳定、可复现的任务(抽取、分类、判断)用低温度。抽取 JSON 显然属于后者。把温度调低,不仅让格式更稳定,连抽取的内容准确性往往也更高——因为它不会"自由发挥"地脑补不存在的信息。这个小参数,常常能立竿见影地降低一截跑偏率。
第五件事:别忘了校验"内容",不只是"格式"
前面解决的主要是"格式合不合法"(能不能 parse、字段全不全),但还有一层更隐蔽的坑:格式完全正确,内容却是错的——模型把字段值"幻觉"了出来。比如用户说"去北京",它却抽出 "destination": "上海";或者用户没说时间,它自作主张填了个 "time": "09:00"。这种 JSON 能完美解析、字段也齐全,可内容是凭空捏造的,流到下单流程就是一笔错误的订单。
# 格式对 ≠ 内容对。要对抽取出的值做业务层校验
def validate_business(data, original_text):
# 1. 枚举类字段: 值必须在合法集合内, 防止幻觉出不存在的选项
if data["destination"] not in KNOWN_CITIES:
raise InvalidValue("目的地不在已知城市列表中")
# 2. 必填信息缺失时, 模型不该"脑补", 而该标记为空让上层追问
if "时间" not in original_text and data.get("time"):
# 用户根本没提时间, 模型却填了值 → 可疑, 置空并回问用户
data["time"] = None
# 3. 数值/日期做范围与合理性校验(如日期不能是过去)
if parse_date(data["date"]) < today():
raise InvalidValue("解析出的日期已过期")
return data
这层校验的思路是:把模型抽取的结果,当成"待核实的线索"而非"可信的事实",用确定性的业务规则去验证它。枚举值要在白名单内、数值要在合理范围、用户没提供的信息模型不该擅自填充。尤其要警惕第三点——让模型学会输出"我不知道/为空",而不是逼它对缺失信息硬编一个值。在提示词里明确"信息不足的字段请返回 null,不要猜测",能大幅减少这种内容幻觉。格式校验防的是"读不进来",内容校验防的是"读进来的是假的",两者缺一不可。
第六件事:把"合规率"当成一个要监控的指标
最后,这类问题要能被量化和监控,而不是等用户报错。我后来给这个功能加了一个关键指标:结构化输出的"一次成功率"——有多少比例的响应,无需重试就能直接提取、校验通过。把它接入监控,你就能持续看到模型输出的健康度:这个数字突然下跌,可能是模型版本变了、可能是来了某类新的刁钻输入,都该引起警觉。
import logging, json
def log_parse_result(ok, retried, raw, parsed):
# 结构化记录每次解析结果, 用于统计合规率、回放失败 case
logging.info(json.dumps({
"parse_ok": ok, # 最终是否成功
"needed_retry": retried, # 是否经过重试才成功
"raw_len": len(raw),
"had_codeblock": "```" in raw, # 统计"夹代码块"的比例
}, ensure_ascii=False))
# 监控面板上盯三个数:
# 一次成功率(越高越好)、重试成功率、彻底失败率(触发兜底的)
# 失败的原始响应要留样, 它们是优化提示词/schema 的最好素材
更重要的是,要把那些解析失败的原始响应保存下来。它们是你优化系统的金矿:看模型到底以什么方式跑偏了——是总爱加代码块?是某类输入下爱说客套话?顺着这些真实的失败样本,你能针对性地改进提示词、调整 schema、补强提取逻辑。LLM 应用是一个需要持续观测、持续迭代的系统,而"合规率"和"失败样本",就是驱动这个迭代的关键数据。到这儿,这次事故的方方面面就齐了。我把应对思路收成一张决策图:
把这套体系建起来,LLM 的结构化输出就能从"偶发崩溃的隐患"变成"稳定可靠的能力"。最后,拧成几条可直接照做的铁律:
- 把 LLM 输出当概率性草稿, 而非确定性 API,永远"验证并稳健处理",绝不"信任并直接用"。
- 从源头用 JSON 模式/Schema 约束,别只在提示词里恳求,从解码层提升合规率。
- 消费端三层防御:稳健提取、结构校验、失败兜底,接住一切跑偏,别让一次崩坏整条流程。
- 抽取类任务把 temperature 调到最低,要稳定不要创造性。
- 不只校验格式, 更要校验内容,防字段幻觉, 让模型对缺失信息返回 null 而非硬编。
- 把"一次成功率"当指标监控,留存失败样本驱动提示词与 schema 的持续迭代。
- 失败要优雅降级,重试 + 兜底 + 友好提示, 而不是抛个 500 给用户。
一张 LLM 结构化输出避坑速查表
把常见的失败形态、根因和对策汇成一张表,做 LLM 结构化输出时对照着配。
| 失败形态 | 根因 | 对策 |
|---|---|---|
| 裹在 ```json 代码块里 | 模型"觉得"代码块更规范 | JSON 模式 + 提取时剥壳 |
| 前后夹客套话/解释 | 模型倾向"礼貌完整"地回答 | 提示词明令无前后缀 + 截取主体 |
| 缺字段/字段名拼错 | 无 schema 约束 | 传 JSON Schema + 结构校验 |
| 格式对但值是幻觉 | 模型脑补了缺失信息 | 内容校验 + 让缺失项返回 null |
| 偶发格式微破损 | 概率生成的固有抖动 | 低 temperature + 重试兜底 |
| 升级模型后突然变差 | 新版本输出习惯变了 | 监控合规率, 留样回归测试 |
延伸一句:稳定性还能靠"缓存"和"少调用"来加固
除了让单次调用更可靠,从系统层面还有两个加固稳定性的思路值得一提。其一是缓存:大模型调用又慢又贵,而很多输入是重复或高度相似的。对相同(或语义相同)的输入缓存其结构化结果,既能省钱省时延,也能让"已经验证过格式没问题"的结果被复用,等于绕开了那部分输入的跑偏风险。其二是能不用模型就别用模型:如果某些抽取用简单的规则、正则、关键词就能高准确地搞定,那就别动用大模型——把模型的能力,精准地用在那些确实需要语言理解的复杂、模糊输入上。
这其实和前面聊 Agent 时那个"能写死的流程别交给模型"的思路一脉相承:大模型是一种强大但昂贵、且不确定的资源,好的系统懂得在恰当的地方用它、在不必要的地方避开它。把确定性能解决的交给确定性代码,把真正需要"理解"的才交给模型,系统整体的稳定性、成本、速度都会更好。这种"克制地使用 AI"的工程判断力,往往比单纯追求"用上最强的模型"更能决定一个 AI 产品的成败。
最后还有一个常被忽略的稳定性维度:模型版本的变化。当你依赖的模型升级了版本,它的输出习惯、对提示词的反应,都可能微妙地改变——昨天合规率 99% 的提示词,换个模型版本可能就降到 95%。所以把模型版本当成一个需要纳入回归测试的"依赖项",每次切换模型,都用你留存的那批样本跑一遍,确认合规率没退化。在 AI 应用里,模型不是一个一成不变的底座,而是一个会演进、需要被持续验证的依赖。
写在最后
这次"LLM 返回的 JSON 偶发解析失败"的事故,看似只是个小小的格式问题,内里却藏着一个用好大模型的根本心法。我犯的错,本质是用了一套"确定性世界"的思维定式,去对待一个"概率性世界"的新事物。在传统软件里,一个返回 JSON 的接口就该返回合规 JSON,这是天经地义的契约;可大模型不是接口,它是一个概率生成器,它"倾向于"满足你的要求,但永远保留着一丝跑偏的可能。把它当成一个永远靠谱的程序去信任,就是这类问题的认知根源。
所以和大模型打交道,需要一种新的工程心态:既要善用它的强大,又要时刻为它的不确定性兜底。这有点像和一个绝顶聪明、但偶尔粗心的天才合作——你欣赏并依赖它的才华,但你不会把它交上来的东西不加检查就直接发出去。从约束源头(JSON 模式、低温度、清晰提示),到稳健消费(提取、校验、兜底),再到持续观测(监控合规率、留样迭代),这一整套,都是围绕"如何与一个不确定的智能体可靠协作"展开的。这也是 AI 工程区别于传统软件工程最迷人、也最具挑战的地方:我们不再是在编排一堆确定的指令,而是在驾驭一种强大却不羁的概率之力,既要放手让它发挥,又要稳稳地为它系上安全带。愿你我在拥抱大模型澎湃能力的同时,都能修炼出这份"信任但要核实"的从容,让 AI 的不确定性,在我们稳健的工程之手中,沉淀为产品的确定可靠。
如果你手上也有用大模型做结构化输出的功能,不妨今天就花二十分钟做三件小事。第一,检查调用代码有没有开启厂商提供的 JSON 模式或 Schema 约束,没开的赶紧加上,这是性价比最高的一步。第二,看看消费端是不是还在裸 JSON.parse/json.loads,给它包上"剥壳提取 + 结构校验 + 失败重试兜底"这三层防御。第三,加一个"一次解析成功率"的监控指标,并把失败的原始响应留样——这两样会成为你日后优化的眼睛和素材。这三步走下来,你就能把一个偶发崩溃、靠用户投诉才暴露的隐患,变成一个可观测、可兜底、可持续改进的稳健能力。
—— 别看了 · 2026