LLM 结构化输出总不稳定?我用三层防线兜住了 JSON 解析失败

写在前面

做 AI 应用,我踩过最反复的一个坑,不是模型”不会答”,而是模型”答得对、但格式不稳定”。

智能旅行规划助手这个项目里,前端要把行程渲染成一张张卡片——每日安排、景点、餐饮、酒店、交通、预算。这意味着后端必须给前端一段严格符合 Schema 的 JSON,而不是一段自然语言。

可现实是:同样的 Prompt,模型今天乖乖输出 JSON,明天就在前面加一句”好的,这是您的旅行计划:”;这次字段齐全,下次少了 daily_plans;甚至把”3 天”写成中文塞进了应该是数字的字段里。本地 Demo 跑得好好的,一上线就各种 500。

后来我想明白一件事:与其指望模型每次都输出完美 JSON,不如假设它一定会出错,然后在工程上建几层防线把错误兜住。 这篇就记录我最终落地的三层防线,以及一路踩过的坑。

问题:为什么 LLM 的”结构化输出”在生产里靠不住

把项目里真实遇到过的失败场景列一下,基本就是这几类:

  • 模型输出 Markdown 包裹的 JSON(```json ... ```),而不是纯 JSON
  • JSON 语法不合法:多了尾逗号、少了引号、键名用了单引号
  • 语法合法但业务结构不合法:少了 daily_plansattractions 为空
  • 类型错了:把数字 days 写成了 "三天",把日期写成 "下周一"
  • 嵌套结构漂移:酒店字段这次是对象、下次是字符串

关键在于:这些错误不能直接抛给用户。 用户不该看到一堆后端堆栈,更不该看到一个空白页面。

我的思路:把”解析—校验—兜底”拆成三层防线

我没有把所有希望押在 Prompt 上,而是按”出错概率从高到低、修复成本从低到高”排了三层:

1
2
3
4
5
模型原始响应
├─ 防线一:解析层 —— 把 JSON 从"人话"里抠出来,并做语法解析
├─ 防线二:校验层 —— Pydantic 结构校验;失败就带着错误信息自动重试
└─ 防线三:兜底层 —— 仍失败,就基于真实 POI 数据降级,返回结构稳定的备用计划
最终保证:前端永远拿到一份符合 TripPlan Schema 的结果

下面逐层说。

防线一:把 JSON 从”人话”里抠出来

第一层只解决一件事——语法。模型爱在 JSON 外面裹一层寒暄或 Markdown,所以不能上来就 json.loads(response),得先把 JSON 那段抠出来:

1
2
3
4
5
6
7
8
9
10
def extract_json(text: str) -> str:
text = text.strip()
if "```json" in text:
return text.split("```json")[1].split("```")[0].strip()
if "```" in text:
return text.split("```")[1].strip()
return text


data = json.loads(extract_json(raw_response))

如果你用的模型支持 JSON 模式(比如 response_format={"type": "json_object"}),一定要开——它能干掉绝大部分”裹 Markdown”的情况,让第一层基本不漏。但我仍然保留 extract_json,因为换模型、降级到不支持 JSON 模式的备用模型时,这层是最后的语法保险。

防线二:Pydantic 校验 + 带错误反馈的自动重试

语法合法 ≠ 业务结构合法。所以第二层用 Pydantic 把结构钉死。项目里的响应 Schema 大致是这样(完整建模见这篇):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Attraction(BaseModel):
name: str
address: str
duration: str
description: str

class DailyPlan(BaseModel):
day: int
date: date
summary: str
attractions: list[Attraction]
transportation: str

class TripPlan(BaseModel):
destination: str
days: int
daily_plans: list[DailyPlan]

校验就一行:

1
plan = TripPlan.model_validate(data)   # Pydantic v2

真正让稳定性上一个台阶的,是校验失败后不直接放弃,而是把错误信息喂回给模型让它自己改。模型其实很擅长”按报错修 JSON”,关键是要给它看到错在哪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def parse_with_retry(prompt: str, max_retries: int = 2) -> TripPlan:
messages = [{"role": "user", "content": prompt}]
last_error = None
for attempt in range(max_retries + 1):
raw = llm.chat(messages)
try:
data = json.loads(extract_json(raw))
return TripPlan.model_validate(data)
except (json.JSONDecodeError, ValidationError) as e:
last_error = e
# 把上一轮的输出和具体报错回灌,让模型针对性修正
messages.append({"role": "assistant", "content": raw})
messages.append({
"role": "user",
"content": f"上面的输出无法通过校验,错误是:{e}。"
f"请只返回修正后的、严格符合要求的 JSON,不要任何多余文字。",
})
raise last_error

两个细节是我用代价换来的:

  • 一定要设上限(我设 2 次)。不设上限的重试,要么在模型反复犯同样的错时空转,要么把 token 成本和接口延迟拖到不可接受。
  • 一定要回灌具体错误。只说”你错了重来”几乎没用;把 ValidationError 的字段路径贴给它,命中率高得多。

防线三:仍然失败,就基于真实数据兜底

如果重试用完还是过不了校验,说明这次模型实在不配合。这时候兜底的目标不是生成完美计划,而是保证系统可用——返回一份结构绝对稳定、且数据真实的备用计划:

1
2
3
4
5
真实 POI 数据(来自高德地图 API)
-> 按天数分组
-> 每天安排 2-3 个景点
-> 填充基础交通与餐饮建议
-> 组装成符合 TripPlan Schema 的结果

它可能没有模型生成得那么”有灵气”,但有两个谁都拿不走的优点:数据是真的(POI 来自高德 API 工具增强,不是模型幻觉),结构是稳的。对线上应用来说,一个朴素但能用的结果,永远好过一个白屏。

串起来:接口层只做编排

三层防线在 FastAPI 接口层汇合,controller 不堆业务,只负责把流程串起来、把异常翻译成对用户友好的结果:

1
2
3
4
5
6
7
8
9
10
@router.post("/api/trip/plan", response_model=TripPlan)
async def plan_trip(request: TripPlanRequest):
try:
return await planner.generate(request) # 内部含防线一、二
except (ValidationError, json.JSONDecodeError):
return fallback_plan(request) # 防线三:降级不报错
except ToolAPIError:
return fallback_plan(request) # 工具 API 挂了也兜底
except Exception:
raise HTTPException(status_code=500, detail="旅行计划生成失败")

注意这里不是把所有异常一吞了之:能兜底的(解析失败、工具超时)就降级,真正未知的异常该 500 还得 500,否则你会把真 bug 藏起来。

我踩过的坑

这部分是我觉得最值钱的——这些坑文档里不会写,是真在项目里栽过才知道的:

  1. 一开始真的直接 json.loads(response) 然后被”好的,这是您的计划:”这句话稳定地干崩。模型的”礼貌”就是结构化输出的头号杀手。
  2. 重试一开始没设上限。 遇到模型反复犯同一个错,接口直接卡十几秒,token 也烧得肉疼。上限 + 错误回灌,缺一不可。
  3. 差点把兜底率当成”已经没问题了”。 后来加了日志统计兜底触发率才发现:兜底率高不是”防线很强”,而是”Prompt 或 Schema 没对齐”的报警信号。兜底是安全网,不该是常态——它高了要回去改 Prompt,而不是心安理得。
  4. Pydantic v1 → v2 的坑。 parse_obj 换成 model_validatedate 字段对模型输出的 "2026/6/17" 这类格式不买账,得在 Prompt 里明确要求 YYYY-MM-DD,或者给字段加自定义校验器。
  5. 模型把数字写成中文。 days 偶尔会是 "三天"。靠 Prompt 约束能压下大部分,剩下的交给防线二的重试兜。

上线后的效果

加这三层之前,我本地压一轮几十个请求,总有那么十几次因为格式问题直接抛错给前端;三层防线齐活之后,**前端再没收到过”解析失败”**——最差也是一份基于真实 POI 的兜底计划。同时靠兜底率这个指标,我能反过来持续优化 Prompt,让真正走到第三层的请求越来越少。

(具体数字和你的模型、Prompt 强相关,建议自己埋点统计”解析成功率 / 重试命中率 / 兜底触发率”这三个,比拍脑袋有用得多。)

小结

LLM 的结构化输出,本质上是个工程问题而不是 Prompt 问题。一次输出靠不靠谱,模型说了算;但一个系统稳不稳定,是你设计的防线说了算:

  • 防线一管语法(抠 JSON + 解析)
  • 防线二管结构(Pydantic + 带错误反馈的有限重试)
  • 防线三管可用性(基于真实数据的降级兜底)

这套思路不止用于旅行规划,任何”让模型产出要被下游系统消费的结构化数据”的场景都适用。

相关阅读:FastAPI 接口与 Pydantic 数据建模多 Agent 协作与 Prompt 设计结构化输出解析与兜底机制(项目笔记版)


LLM 结构化输出总不稳定?我用三层防线兜住了 JSON 解析失败
https://zxyblog.top/2026/06/17/LLM结构化输出三层防线-Pydantic校验与兜底重试/
作者
zxy
发布于
2026年6月17日
许可协议