LLM 结构化输出总不稳定?我用三层防线兜住了 JSON 解析失败
写在前面
做 AI 应用,我踩过最反复的一个坑,不是模型”不会答”,而是模型”答得对、但格式不稳定”。
在智能旅行规划助手这个项目里,前端要把行程渲染成一张张卡片——每日安排、景点、餐饮、酒店、交通、预算。这意味着后端必须给前端一段严格符合 Schema 的 JSON,而不是一段自然语言。
可现实是:同样的 Prompt,模型今天乖乖输出 JSON,明天就在前面加一句”好的,这是您的旅行计划:”;这次字段齐全,下次少了 daily_plans;甚至把”3 天”写成中文塞进了应该是数字的字段里。本地 Demo 跑得好好的,一上线就各种 500。
后来我想明白一件事:与其指望模型每次都输出完美 JSON,不如假设它一定会出错,然后在工程上建几层防线把错误兜住。 这篇就记录我最终落地的三层防线,以及一路踩过的坑。
问题:为什么 LLM 的”结构化输出”在生产里靠不住
把项目里真实遇到过的失败场景列一下,基本就是这几类:
- 模型输出 Markdown 包裹的 JSON(
```json ... ```),而不是纯 JSON - JSON 语法不合法:多了尾逗号、少了引号、键名用了单引号
- 语法合法但业务结构不合法:少了
daily_plans、attractions为空 - 类型错了:把数字
days写成了"三天",把日期写成"下周一" - 嵌套结构漂移:酒店字段这次是对象、下次是字符串
关键在于:这些错误不能直接抛给用户。 用户不该看到一堆后端堆栈,更不该看到一个空白页面。
我的思路:把”解析—校验—兜底”拆成三层防线
我没有把所有希望押在 Prompt 上,而是按”出错概率从高到低、修复成本从低到高”排了三层:
1 | |
下面逐层说。
防线一:把 JSON 从”人话”里抠出来
第一层只解决一件事——语法。模型爱在 JSON 外面裹一层寒暄或 Markdown,所以不能上来就 json.loads(response),得先把 JSON 那段抠出来:
1 | |
如果你用的模型支持 JSON 模式(比如 response_format={"type": "json_object"}),一定要开——它能干掉绝大部分”裹 Markdown”的情况,让第一层基本不漏。但我仍然保留 extract_json,因为换模型、降级到不支持 JSON 模式的备用模型时,这层是最后的语法保险。
防线二:Pydantic 校验 + 带错误反馈的自动重试
语法合法 ≠ 业务结构合法。所以第二层用 Pydantic 把结构钉死。项目里的响应 Schema 大致是这样(完整建模见这篇):
1 | |
校验就一行:
1 | |
真正让稳定性上一个台阶的,是校验失败后不直接放弃,而是把错误信息喂回给模型让它自己改。模型其实很擅长”按报错修 JSON”,关键是要给它看到错在哪:
1 | |
两个细节是我用代价换来的:
- 一定要设上限(我设 2 次)。不设上限的重试,要么在模型反复犯同样的错时空转,要么把 token 成本和接口延迟拖到不可接受。
- 一定要回灌具体错误。只说”你错了重来”几乎没用;把
ValidationError的字段路径贴给它,命中率高得多。
防线三:仍然失败,就基于真实数据兜底
如果重试用完还是过不了校验,说明这次模型实在不配合。这时候兜底的目标不是生成完美计划,而是保证系统可用——返回一份结构绝对稳定、且数据真实的备用计划:
1 | |
它可能没有模型生成得那么”有灵气”,但有两个谁都拿不走的优点:数据是真的(POI 来自高德 API 工具增强,不是模型幻觉),结构是稳的。对线上应用来说,一个朴素但能用的结果,永远好过一个白屏。
串起来:接口层只做编排
三层防线在 FastAPI 接口层汇合,controller 不堆业务,只负责把流程串起来、把异常翻译成对用户友好的结果:
1 | |
注意这里不是把所有异常一吞了之:能兜底的(解析失败、工具超时)就降级,真正未知的异常该 500 还得 500,否则你会把真 bug 藏起来。
我踩过的坑
这部分是我觉得最值钱的——这些坑文档里不会写,是真在项目里栽过才知道的:
- 一开始真的直接
json.loads(response)。 然后被”好的,这是您的计划:”这句话稳定地干崩。模型的”礼貌”就是结构化输出的头号杀手。 - 重试一开始没设上限。 遇到模型反复犯同一个错,接口直接卡十几秒,token 也烧得肉疼。上限 + 错误回灌,缺一不可。
- 差点把兜底率当成”已经没问题了”。 后来加了日志统计兜底触发率才发现:兜底率高不是”防线很强”,而是”Prompt 或 Schema 没对齐”的报警信号。兜底是安全网,不该是常态——它高了要回去改 Prompt,而不是心安理得。
- Pydantic v1 → v2 的坑。
parse_obj换成model_validate,date字段对模型输出的"2026/6/17"这类格式不买账,得在 Prompt 里明确要求YYYY-MM-DD,或者给字段加自定义校验器。 - 模型把数字写成中文。
days偶尔会是"三天"。靠 Prompt 约束能压下大部分,剩下的交给防线二的重试兜。
上线后的效果
加这三层之前,我本地压一轮几十个请求,总有那么十几次因为格式问题直接抛错给前端;三层防线齐活之后,**前端再没收到过”解析失败”**——最差也是一份基于真实 POI 的兜底计划。同时靠兜底率这个指标,我能反过来持续优化 Prompt,让真正走到第三层的请求越来越少。
(具体数字和你的模型、Prompt 强相关,建议自己埋点统计”解析成功率 / 重试命中率 / 兜底触发率”这三个,比拍脑袋有用得多。)
小结
LLM 的结构化输出,本质上是个工程问题而不是 Prompt 问题。一次输出靠不靠谱,模型说了算;但一个系统稳不稳定,是你设计的防线说了算:
- 防线一管语法(抠 JSON + 解析)
- 防线二管结构(Pydantic + 带错误反馈的有限重试)
- 防线三管可用性(基于真实数据的降级兜底)
这套思路不止用于旅行规划,任何”让模型产出要被下游系统消费的结构化数据”的场景都适用。
相关阅读:FastAPI 接口与 Pydantic 数据建模、多 Agent 协作与 Prompt 设计、结构化输出解析与兜底机制(项目笔记版)。