2024 年我做一个简历解析功能:用户上传一段简历文本,我让大模型把里面的姓名、工作年限、技能列表抽取出来,变成一个结构化的对象,后面的代码再拿这个对象去做匹配、入库。怎么让模型把抽取结果交给我?这件事我没多想:让它输出 JSON 不就行了。第一版我做得很顺手:我在提示词里写一句"请以 JSON 格式输出结果",把模型返回的字符串直接 json.loads() 一下,拿到 dict,完事。本地拿几份简历一测——真不错:模型乖乖吐出 {"name": "张三", "years": 5, ...},json.loads 解得干干净净,字段一个不少。我心里很笃定:"我提示词里都写明了'输出 JSON',模型自然会给我一段能直接解析的纯 JSON。它都说自己输出 JSON 了,还能给我个别的东西不成?"可等这个功能真正上线、面对成千上万份五花八门的真实简历,一串问题冒了出来。第一种最先把我打懵:json.loads 在生产环境随机地抛异常,我把崩溃时的原始返回打出来一看——模型把 JSON 包在了一对 ```json 的 markdown 代码围栏里,围栏那几个反引号,json.loads 当然解不了。第二种最难缠:模型在 JSON 的前面后面加了解释性的话——"好的,根据您提供的简历,我抽取的结果如下:{...} 希望这个结果对您有帮助"——JSON 是对的,可它被一堆废话夹在中间。第三种最头疼:模型输出的 JSON 本身就不合法——结尾多一个逗号、字段名没加引号、用了中文的引号、字符串里有没转义的换行。第四种最莫名其妙:JSON 合法,解析也成功了,但内容是错的——该有的 skills 字段没了、years 本该是数字却给了个字符串 "五年"、我只允许三个取值的 level 字段它自己编了一个。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"我在提示词里要求输出 JSON,模型就会给我一段能直接 json.loads 的、合法的、符合我期望结构的纯 JSON"。这句话默认了模型有能力、也有义务,保证它的输出是一个合法的 JSON 对象。可它既没这个能力,也没这个义务。我脑子里,大模型是一个"理解了我的要求、然后按要求构造出一个 JSON 对象"的处理器。可它根本不是。大模型的本质,是一个文本生成器——它做的唯一一件事,是根据前面的上下文,一个 token 一个 token 地预测"接下来最像样的文字是什么"。当我要求它"输出 JSON"时,它生成的,是一段"在它见过的海量文本里、看起来很像 JSON 的文字"。注意,是"看起来像 JSON 的文字",不是"一个 JSON 对象"。这两者有天壤之别:一个 JSON 对象,它的合法性是被构造它的程序保证的;而一段"看起来像 JSON 的文字",它合不合法、完不完整、符不符合我的结构,完全是概率性的——大概率像,但没有任何机制担保它一定是。我在提示词里写的那句"请输出 JSON",在模型眼里,只是一条会影响它生成倾向的、软性的建议,而不是一条它必须遵守的、硬性的约束。它"倾向于"照做,但它随时可能因为这份简历的某个细节,而把 JSON 包进 markdown 围栏、在前面加上一句客套话、或者在某个该有逗号的地方漏掉逗号。模型从来没有向我承诺过"我的输出是合法 JSON",是我一厢情愿地以为它承诺了。要可靠地从大模型那里拿到结构化的数据,根上的办法只有一个:不能靠"请求"模型配合,而要在机制层面去强制——要么用模型厂商提供的受约束解码,在生成的每一个 token 上都强制它符合 JSON 语法;要么,就在拿到模型那段文本之后,用我自己的确定性代码去提取、去解析、去校验、去重试,把那段不确定的文字,亲手变成一个确定的、可信的结构。真正把大模型的结构化输出做扎实,核心不是"在提示词里把'输出 JSON'说得更用力",而是认清自然语言指令只是软建议、模型不保证输出合法 JSON,学会从模型的"话"里把 JSON 提取清洗出来,学会用 schema 校验把合法但错误的 JSON 也挡掉,学会用受约束解码从根上保证语法合法,学会用带错误反馈的重试和降级兜底,并把解析失败率、校验失败率这些信号接进监控。这篇文章就把 LLM 结构化输出这个坑梳理一遍:为什么"让模型输出 JSON"靠不住、怎么从模型的话里提取 JSON、怎么用 schema 校验内容、受约束解码怎么从根上解决、失败了怎么重试和降级,以及一些把它做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为"让模型输出 JSON、然后 json.loads"这个做法太自然、太顺手了——你需要结构化数据,模型能生成文本,JSON 又是程序和文本之间最通用的桥,把它们拼起来是任何人的第一直觉。它错得隐蔽,是因为面对简单、规整的输入,模型的输出确实大概率是干净的纯 JSON:你开发、测试、给同事演示,用的都是那么几份精心挑过的样例,跑几十次,json.loads 次次成功,你由此得出"模型输出 JSON 很稳"的错误结论。它只在真实流量的多样性面前才暴露——成千上万份风格迥异的简历里,总有一些会触发模型把 JSON 包进围栏、加上客套话、或写出非法语法,而那一刻,你那行没有任何防护的 json.loads,就成了生产环境里一个随机引爆的炸弹。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:
json.loads在生产环境随机抛异常;模型把 JSON 包进 markdown 围栏、前后加解释文字;输出的 JSON 语法非法;JSON 合法但字段缺失、类型错、枚举值乱编。 - 错误认知一:以为提示词里要求"输出 JSON"是一条硬约束。真相是它只是一条软建议,模型倾向于照做但不保证。
- 错误认知二:以为模型在"构造一个 JSON 对象"。真相是它在生成"看起来像 JSON 的文本",合法性是概率性的。
- 错误认知三:以为 JSON 能
json.loads成功就万事大吉。真相是合法的 JSON 不等于内容正确的 JSON,字段和类型还得单独校验。 - 真相:可靠的结构化输出靠机制强制,不靠请求配合——受约束解码从根上保证语法,提取清洗加 schema 校验加重试兜住其余。
一、为什么"让模型输出 JSON"靠不住
先把第一版那个写法摆出来。它就是字面意思——提示词里要一句 JSON,返回值直接 json.loads:
# 第一版:提示词要求输出 JSON,返回值直接 json.loads(反面教材)
import json
PROMPT = """请从下面的简历文本里抽取信息,以 JSON 格式输出,
包含 name(姓名)、years(工作年限)、skills(技能列表)三个字段。
简历文本:
{resume}"""
def parse_resume(resume_text: str) -> dict:
resp = llm.complete(PROMPT.format(resume=resume_text))
# 直接把模型返回的字符串当成合法 JSON 来解析
return json.loads(resp)
# 简单规整的简历,一切完美:
# 模型返回 '{"name": "张三", "years": 5, "skills": ["Python"]}'
# json.loads 解得干干净净
# 但只要模型这样返回(它随时可能这样):
# '好的,结果如下:\n```json\n{"name": "李四", ...}\n```'
# json.loads 当场抛 JSONDecodeError —— 生产环境随机崩溃
这段代码没有任何语法错误,面对规整输入它工作得很好。它唯一的问题是默认了模型返回的字符串一定是一段合法的纯 JSON。可模型返回的,只是一段文本。模型是个文本生成器,它在"续写"——它见过的训练语料里,JSON 经常是被包在 markdown 代码围栏里出现的,经常是前面有一句"结果如下"的,所以它"续写"出这些东西,对它而言再自然不过。把这段文本走到 json.loads 的几种可能结局画出来:
[mermaid]
flowchart TD
A[你在 prompt 里要求输出 JSON] --> B[模型生成一段文本]
B --> C{这段文本恰好是合法纯 JSON 吗}
C -->|是 运气好| D[json.loads 成功]
C -->|被包在 markdown 围栏里| E[json.loads 抛异常]
C -->|前后夹了解释性文字| E
C -->|JSON 语法本身非法| E
E --> F[生产环境随机崩溃]
D --> G[但内容仍可能不符合预期结构]
看懂这张图,"json.loads 随机崩溃"这个怪现象就有了答案:模型没有"出错",它每一次都在尽职尽责地做同一件事——生成它认为最自然的文本。只是"最自然的文本"有时候是纯 JSON、有时候是被围栏包着的 JSON、有时候是夹着客套话的 JSON。崩溃的随机性,正是这个生成过程概率本质的直接体现。
这里要建立的第一个、也是最重要的认知是:你必须分清两种截然不同的"要求"——一种是"软约束"(也叫建议、约定),一种是"硬约束"(也叫强制、保证)。我在提示词里写的那句"请输出 JSON",是一个软约束:它能影响、引导对方的行为,让对方"倾向于"照做,但它没有任何强制力,对方完全可以不照做,而你事先无法阻止。而我真正需要的,是一个硬约束:一个无论如何都不会被违反的保证。我第一版的全部失败,根子就在于我把一个软约束,当成硬约束来依赖了——我以为"我说了输出 JSON"就等于"输出一定是 JSON",可前者只是一句请求,后者才是一个保证,中间隔着的鸿沟,就是我生产环境里那些随机崩溃。想明白这一点,你看待整个软件世界的方式都会变,因为"软约束 vs 硬约束"这条线无处不在:代码注释里写一句"调用方传进来的 list 不要为空",是软约束,调用方完全可能传个空进来;而在函数开头写一行 if 判断、为空就抛异常,才是硬约束。文档里写一句"这个接口请勿并发调用",是软约束;在接口里加一把锁,才是硬约束。命名约定里规定"下划线开头的方法是私有的",是软约束;用编程语言真正的 private 关键字,才接近硬约束。团队约定"提交前要跑测试",是软约束;CI 流水线里红了就不准合并,才是硬约束。它们的区别是决定性的:软约束依赖对方的善意与配合,它在演示和理想情况下工作得很好,却会在压力、边界、恶意、或仅仅是概率不走运的时候失效;硬约束则把保证焊死在了机制里,对方想违反都没有办法。所以,每当你的系统里出现一个"我需要 X 一定成立"的关键点时,一定要停下来问自己:我现在保证 X 的方式,是软约束还是硬约束?如果只是软约束——只是一句注释、一句提示词、一个口头约定——那它迟早会在某个你没预料到的时刻被打破。把关键的正确性,从"请求别人配合"升级为"机制层面强制",是写出可靠系统的一条根本原则。后面第四节要讲的受约束解码,就是把"输出是合法 JSON"这件事,从软约束升级成硬约束。
二、提取与清洗:从模型的"话"里把 JSON 抠出来
认清了模型给的是"一段文本"而非"一个 JSON",第一道防线就清楚了:既然这段文本里可能夹着围栏、客套话,那我就别指望它纯净,而是主动去这段文本里,把那块真正的 JSON 抠出来。先看看模型实际会返回哪些"脏"形态:
模型实际会返回的几种"脏 JSON"形态:
形态一:包在 markdown 代码围栏里
```json
{"name": "张三", "years": 5}
```
形态二:JSON 前后夹着解释性文字
好的,根据简历,我抽取的结果如下:
{"name": "张三", "years": 5}
希望这个结果对您有帮助!
形态三:既有围栏、又有客套话(两种叠加)
当然可以,结果是:
```json
{"name": "张三", "years": 5}
```
形态四:JSON 语法本身非法(下一类问题,清洗解决不了)
{'name': '张三', years: 5,} 单引号 / 键无引号 / 尾逗号
规律:形态一到三是"纯净 JSON 被包了一层壳",剥壳就能救;
形态四是 JSON 本身坏了,得靠校验和重试,见后两节。
针对形态一到三,提取的思路很朴素:先尝试剥掉 markdown 围栏,再从剩下的文本里定位第一个 { 到与之匹配的最后一个 },把中间那段抠出来当 JSON 解析:
# 第一道防线:从模型返回的文本里,把 JSON 那块抠出来
import json
import re
def extract_json(text: str) -> dict:
s = text.strip()
# 第一步:剥掉 markdown 代码围栏 ```json ... ```
fence = re.search(r"```(?:json)?\s*(.+?)\s*```", s, re.DOTALL)
if fence:
s = fence.group(1).strip()
# 第二步:定位第一个 { 和最后一个 },截掉前后夹的客套话
start = s.find("{")
end = s.rfind("}")
if start == -1 or end == -1 or end < start:
raise ValueError(f"返回里找不到 JSON 对象: {text[:120]}")
candidate = s[start:end + 1]
# 第三步:尝试解析。这里只解析,不保证内容对(内容靠下一节)
return json.loads(candidate)
# 这道防线能救回形态一到三:不管模型把 JSON 包进围栏、
# 还是前后加客套话,只要那块 JSON 本身是合法的,就能被抠出来。
# 但它救不了形态四 —— 抠出来的那段 JSON 自己语法就是坏的
要清醒地看到这道防线的边界:它能对付的,是"合法 JSON 被包了一层壳"的情况;它对付不了"JSON 本身就是坏的"——单引号、尾逗号、键名没引号,这些抠出来照样 json.loads 失败。所以提取清洗只是第一层,不是终点。
这里要建立的认知是:这道提取防线背后,是一条在工程里极有分量的设计原则——健壮性原则,也叫 Postel 法则,它的经典表述是"对自己的输出要严格,对接收的输入要宽容"。我第一版的 json.loads,是"对输入极度严格"的代表:输入必须是分毫不差的纯 JSON,差一个字符就翻脸。而这道提取防线,做的就是"对输入宽容"——它承认上游(大模型)是个不那么靠谱、输出形态多变的来源,于是它不强求上游给出完美的输入,而是自己多做一些工作,主动地去理解、去容错、去把那个真正有用的部分从噪声里捞出来。这种"在系统边界上,主动吸收上游的不完美"的思路,是无数稳健系统的共同特征:一个解析用户日期输入的函数,会同时接受'2024-01-01'、'2024/1/1'、'今天',而不是只认一种格式;一个网络协议的实现,会容忍对端发来的、它不认识的额外字段而不是直接报错;一个 HTML 浏览器,面对满是错误的网页,依然努力把它渲染出来。它们都在边界上,替上游消化了一部分混乱。但是——这条原则有一个必须警惕的反面,你不能把"宽容"误解成"无底线地猜"。宽容的边界在于:你只是在"提取本就存在的、正确的信息",而绝不是在"脑补、伪造、修正本就错误的信息"。我这道提取防线,只做剥壳和截取,它从不去"修复"一段非法的 JSON——因为一旦你开始猜"这个少了的逗号大概该加在这里",你就是在用猜测污染数据,你救回来的可能是一个面目全非的错误结果,这比直接失败更危险。所以健壮性原则的完整、准确的姿势是:对输入的"形态"宽容(各种包装、各种格式都能接),但对输入的"内容"严格(内容只要有一丝不对,就明确地失败,绝不靠猜补全)。把"宽容地提取"和"严格地校验"分成两个清清楚楚的阶段——这一节宽容地把 JSON 抠出来,下一节严格地审查它对不对——而不是把它们和成一锅"边猜边修"的浆糊,这才是健壮性原则真正安全的用法。
三、Schema 校验:合法的 JSON 不等于对的 JSON
把 JSON 顺利抠出来、json.loads 也成功了,是不是就可以放心用了?远远不够。第一版还有一类最隐蔽的错误:JSON 语法完全合法,可它的内容不对——该有的字段没了、字段类型错了(years 给了字符串 "五年")、枚举字段的值是模型自己编的。这种错误 json.loads 根本发现不了,它会带着一身毛病,被你的代码当成正确数据用下去。所以第二道防线是:用一个明确的 schema,去校验解析出来的内容。Python 里最顺手的做法是用 pydantic:
# 第二道防线:定义 schema,校验"内容"对不对
from enum import Enum
from typing import List
from pydantic import BaseModel, ValidationError, field_validator
class Level(str, Enum):
# 枚举:level 字段只允许这三个值,模型编的别的值会被拒
junior = "junior"
middle = "middle"
senior = "senior"
class Resume(BaseModel):
name: str
years: int # 必须是整数,模型给 "五年" 会被拒
skills: List[str] # 必须是字符串列表
level: Level # 必须是上面三个枚举值之一
@field_validator("years")
@classmethod
def years_in_range(cls, v: int) -> int:
# 业务层校验:工作年限得在一个合理区间
if not (0 <= v <= 60):
raise ValueError(f"years 超出合理范围: {v}")
return v
def validate_resume(raw: dict) -> Resume:
# pydantic 会一次性检查:字段缺没缺、类型对不对、
# 枚举值合不合法、自定义规则过不过 —— 任一不满足就抛 ValidationError
return Resume(**raw)
# 关键:json.loads 成功只代表"语法合法",
# 而 validate_resume 通过,才代表"内容也符合我的预期结构"
有了这一层,数据要走到你的业务代码里,得连过两关:json.loads 那关保证它语法是合法的,validate_resume 这关保证它内容是符合预期的。任何一关没过,都在边界上就被明确地挡下,而不是带病往下游跑。
这里要建立的认知是:json.loads 成功、validate_resume 才通过,这两关的分工,要教给你一个贯穿数据处理始终的分层观念——"语法正确"和"语义正确"是两件完全不同的事,一个数据通过了语法检查,绝不意味着它通过了语义检查。语法正确,说的是这个数据"形式上是良构的"——JSON 的括号配对、引号闭合、逗号到位,它是一个合法的 JSON。语义正确,说的是这个数据"内容上是有意义、符合规则的"——该有的字段都在、每个字段的类型对、取值在允许的范围里、字段之间的关系也自洽。这两层检查,关注的东西根本不在一个维度上。我第一版栽的最隐蔽的那个跟头,就是只有语法检查(json.loads),完全没有语义检查,于是一个"语法合法但语义全错"的数据,畅通无阻地流进了我的业务逻辑。这个"语法层 / 语义层"的分野,在计算机世界里是一条根基性的线:一个编译器,先做词法和语法分析(你的代码括号配对吗、关键字拼对吗),通过了再做语义分析(你这个变量用之前声明了吗、类型匹配吗)——语法没错的程序,语义可以错得一塌糊涂。一个 HTTP 请求,报文格式合法(语法),不代表它带的参数就符合业务规则(语义)。一个数据库,字段类型约束是语法层面的,而外键、CHECK、业务规则是语义层面的。看清这条线,你做数据校验时就会自觉地分层:第一层,先确认形式良构(能不能解析);第二层,再确认内容合规(字段、类型、范围、枚举、关系)。永远不要因为第一层过了,就跳过第二层——形式上的良构,是内容正确的必要条件,而远远不是充分条件。一个"长得很像那么回事"的数据,和一个"真正对的"数据之间,隔着的就是语义校验这一整层,你必须亲手把它补上。
四、受约束解码:从根上保证输出合法
提取和校验,都是在模型已经生成完之后做的补救——模型先随性地吐出一段文本,我再费劲地去抠、去审。有没有办法,让模型在生成的过程中,就根本吐不出非法的 JSON?有,这就是受约束解码。它的原理是:模型每生成一个 token,本质是从词表里挑一个概率最高的;受约束解码在这一步动手脚——它根据 JSON 语法,算出"当前这个位置,语法上只允许出现哪些 token",然后把所有不合法的 token 概率强行清零,模型只能在合法的候选里挑。这样一来,生成出来的每一个 token 都符合 JSON 语法,最终结果不可能不合法。主流模型厂商把这个能力封装成了 JSON mode、function calling、structured outputs。用 OpenAI 的 structured outputs 大概是这样:
# 第三道防线:受约束解码 —— 让模型从根上吐不出非法 JSON
from openai import OpenAI
from pydantic import BaseModel
from typing import List
from enum import Enum
class Level(str, Enum):
junior = "junior"
middle = "middle"
senior = "senior"
class Resume(BaseModel):
name: str
years: int
skills: List[str]
level: Level
client = OpenAI()
def parse_resume_structured(resume_text: str) -> Resume:
# 把 Resume 这个 schema 直接交给 API。
# 模型在解码时会被强制约束:每个 token 都必须符合
# 这个 schema 推导出的语法 —— 它生成不出非法 JSON,
# 也生成不出缺字段、类型错、枚举越界的结构
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "从简历文本里抽取结构化信息。"},
{"role": "user", "content": resume_text},
],
response_format=Resume, # 关键:schema 直接驱动解码约束
)
# 拿到的 parsed 已经是一个校验过的 Resume 对象,不用再 json.loads
return completion.choices[0].message.parsed
# 受约束解码把"输出是合法且合 schema 的 JSON"这件事,
# 从一条软建议(提示词里求模型),变成了一条硬约束(解码层强制)
这是从根上解决问题的办法,应当优先使用。但要知道它也有边界:不是所有模型、所有部署方式都支持;它约束的是"语法和结构",管不了"语义对不对"——它能保证 years 是个整数,但保证不了这个整数是对的。所以即便用了受约束解码,上一节的 schema 业务校验、下一节的重试兜底,依然要留着。把几种方案摆在一起对比:
从模型拿结构化输出的几种办法,可靠性递增:
办法一:提示词里写"请输出 JSON"
本质:软建议。模型倾向照做,但毫无保证
可靠性:最低。本文第一版栽的就是这里
办法二:提示词 + 提取清洗 + schema 校验 + 重试
本质:事后补救。模型随性输出,你用代码兜
可靠性:中。模型不支持受约束解码时的主力方案
办法三:JSON mode
本质:解码层强制"输出是合法 JSON"
可靠性:较高。保证语法合法,但不保证符合你的 schema
办法四:function calling / structured outputs
本质:解码层强制"输出是合法、且符合指定 schema 的 JSON"
可靠性:最高。语法和结构都被焊死,优先用这个
共识:能用办法四就用办法四;它不可用时退到办法二;
永远不要只靠办法一。语义校验和重试,哪种办法都得留。
这里要建立的认知是:受约束解码这个方案,和前两节的提取、校验,有一个层次上的根本差别,这个差别值得你想透——提取和校验,是"事后检查":让坏东西先被造出来,再在下游把它筛掉;而受约束解码,是"事前杜绝":在源头就让坏东西根本没有机会被造出来。这两种思路的高下,在工程里是有定论的——只要做得到,"让错误状态根本无法被表示出来"永远优于"造出错误状态再去检查它"。前者是釜底抽薪,错误的可能性从根上就不存在了,你不需要在每一个下游都布防,也不会因为某个下游漏检而出事;后者是扬汤止沸,你必须保证每一处检查都不缺席、都无遗漏,而人总会有疏忽。这个"让非法状态无法被构造"的思想,是高质量系统设计里一条非常深的暗线:在类型系统里,你与其用一个普通字符串存邮箱、然后到处写校验,不如定义一个 Email 类型、让它的构造函数里就含校验,这样"一个不合法的 Email 对象"在整个程序里根本无法存在;在 API 设计里,你与其提供一个能进入非法状态的对象、再叮嘱调用方"别这么用",不如把接口设计成那种用法根本调不出来;数据库的非空约束、外键约束,也是同一个思想——让"违反约束的数据"压根写不进去。受约束解码,正是把这个思想用到了大模型上:它不是等模型吐出非法 JSON 再去清洗,而是让模型在解码的每一步,就被语法死死框住,非法的 token 连被选中的资格都没有。所以,当你面对任何一个"可能产生错误数据"的环节时,养成一个先于一切的追问:我能不能从机制上,让这个错误根本就产生不出来?如果能,就果断选这条路——它远比"在下游层层设卡去抓错误"来得干净、可靠。事后检查是不得已时的兜底,事前杜绝才是首选的上策。当然,正如这一节所说,事前杜绝也常有它管不到的地方(比如它管语法不管语义),那些地方,你再用事后检查老老实实补上——两者不是二选一,而是有先后、有主次的配合。
五、失败了怎么办:带反馈的重试与降级
前三道防线之后,还得回答最后一个问题:万一所有防线都没拦住——模型不支持受约束解码,吐出的 JSON 又抠不出来或校验不过——这一次请求,该怎么收场?最朴素的重试是"再调一次模型",但这样很傻:你不告诉模型上次错在哪,它很可能错得一模一样。聪明的重试,是把这次失败的具体原因,回喂给模型,让它针对性地改:
# 第四道防线:带错误反馈的重试 —— 把"错在哪"告诉模型让它改
import json
from pydantic import ValidationError
def extract_with_retry(resume_text: str, max_retry: int = 2) -> Resume:
messages = [
{"role": "system", "content": "从简历抽取信息,只输出 JSON。"},
{"role": "user", "content": resume_text},
]
for attempt in range(max_retry + 1):
resp = llm.chat(messages)
try:
raw = extract_json(resp) # 第二节:提取清洗
return validate_resume(raw) # 第三节:schema 校验
except (ValueError, json.JSONDecodeError, ValidationError) as e:
if attempt == max_retry:
raise # 重试用尽,交给上层降级
# 关键:把模型这次的输出 + 具体错误,一起回喂回去,
# 让下一次生成是"针对性修正",而不是"原地再赌一次"
messages.append({"role": "assistant", "content": resp})
messages.append({"role": "user", "content":
f"上面的输出有误:{e}。请修正后只输出合法 JSON。"})
log.warning(f"第 {attempt + 1} 次解析失败,带错误反馈重试: {e}")
raise RuntimeError("unreachable")
# 把错误信息喂回去,等于和模型组成了一个反馈闭环:
# 它做错 -> 我告诉它错在哪 -> 它针对性改 —— 比闷头重试有效得多
但重试不是无限的,次数总会用尽。次数用尽之后,绝不能让这个异常一路冒上去、把整个请求(乃至整个服务)拖垮。必须有一条降级的退路——根据业务的性质,选择一个"虽不完美、但可接受"的结局:
# 第四道防线之二:重试用尽后的降级 —— 给一个可接受的退路
def parse_resume_safe(resume_text: str) -> dict:
try:
return extract_with_retry(resume_text).model_dump()
except Exception as e:
# 重试彻底失败。绝不让异常拖垮整个请求,而是降级:
log.error(f"简历解析最终失败,转人工: {e}")
# 降级策略按业务定,常见几种:
# 1) 落一条"待人工处理"的记录,把这份简历转人工录入
# 2) 返回一个带 partial=True 标记的、字段尽量填的结果
# 3) 返回一组安全的默认值,让主流程能继续走下去
create_manual_review_task(resume_text)
return {"status": "manual_review", "partial": None}
# 要点:大模型是个概率性的、一定会有失败率的组件。
# 你的系统必须为"它就是失败了"这个必然事件,预先准备好退路
这里要建立的认知是:带错误反馈的重试、加上重试用尽后的降级,这一套组合,体现的是处理"不可靠组件"时两个缺一不可的工程素养——第一,重试要构成"反馈闭环",而不是"原地打转"。我最初能想到的重试,是"失败了就再调一次",这是一种没有反馈的重试:每一次尝试都和上一次一模一样,你只是在赌概率,赌这次模型的随机性恰好对你有利。可一个有效的重试,必须是带反馈的——它要观察到这次为什么失败,要把这个失败的信息,变成下一次尝试的输入,让下一次尝试比这一次"更可能成功"。把模型的错误输出和具体报错一起喂回去,就是在构建这个闭环:模型产出、我反馈误差、模型据此修正——这其实就是一切"逐步逼近正确"的过程的内核,无论是控制系统里的负反馈、还是人学习时的"犯错—被纠正—改进"。没有反馈的重试,重试一百次和一次没有本质区别;有反馈的重试,才是真的在向正确收敛。第二,你必须为"不可靠组件最终就是失败了"这个必然事件,预留一条降级的退路。大模型、远程接口、第三方服务,这些组件有一个共同点:它们的成功率永远不是 100%。只要不是 100%,那么"它失败"就不是一个意外,而是一个迟早必然会发生的事件——你不能把它当异常情况来对待,你要把它当成一条必然会被走到的正常分支来设计。降级,就是为这条分支预先铺好的路:它承认"这次我没能拿到完美的结果",然后给出一个"虽不完美、但系统能接受、能继续运转"的次优解——转人工、给默认值、返回部分结果。降级的精髓,是把一次"局部的失败",牢牢地控制在局部,绝不让它升级成"整个系统的崩溃"。把这两点合起来看,处理任何不可靠组件的成熟姿势就清晰了:先用带反馈的重试,尽最大努力去争取成功;同时清醒地知道重试可能耗尽,于是用降级,为最终的失败兜住底。一个健壮的系统,从来不是由一堆"永不失败的组件"搭起来的——那种组件不存在;它是由一堆"会失败的组件",加上一套"妥善处理失败"的机制,共同搭起来的。
六、工程里那些结构化输出的坑
四道防线的主线理顺了,落地时还有几个工程坑反复咬人。第一个,schema 别设计得太复杂。深层嵌套、几十个字段的庞大 schema,会显著拉低模型一次到位的成功率,也拉高 token 成本——能拆成几次简单抽取,就别强求一次抽一个巨型对象。第二个,给字段写清楚描述。在 schema 里给每个字段配上说明和示例(pydantic 的 Field(description=...)),模型填对的概率会明显提高,这比在主提示词里啰嗦一大段更有效。第三个,枚举字段一定要校验越界。模型最爱在枚举上自由发挥,编一个你没定义的值出来,Enum 校验必须把这种越界值挡死。第四个,重试要设上限和退避,别无限重试——一份模型怎么都解析不了的输入,会无限重试把延迟和成本拖垮,次数用尽就果断降级。第五个,受约束解码不是万能的,它保证语法和结构,但模型完全可能"一本正经地填错内容"——它会很合规地把 years 填成一个错误的数字,语义校验这一关任何时候都不能省。第六个,流式输出和结构化校验有冲突:JSON 没生成完时是不合法的,要等完整收完再校验,或用专门的流式 JSON 解析器。把这些信号都接进监控,你才有数据判断结构化输出健不健康:
LLM 结构化输出上线后必须盯死的几个指标:
json_parse_fail_rate 提取后 json.loads 仍失败的比例,反映语法问题
schema_valid_fail_rate schema 校验不通过的比例,反映结构 / 内容问题
retry_count_dist 重试次数分布,大量请求要重试 2 次以上是预警
retry_success_rate 重试最终成功的比例,偏低说明反馈重试没生效
fallback_rate 走到降级分支的请求占比,非 0 要持续关注
field_miss_topN 最常缺失 / 填错的是哪几个字段,指导优化 schema
output_token_p99 输出 token 的 p99,突增可能是模型在啰嗦或跑偏
constrained_unsupported 因模型不支持受约束解码而退到补救方案的比例
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"在系统里使用大模型"这件事的总体判断——你必须始终把大模型当成一个"能力很强、但本质上不确定、不可靠"的组件来对待,你工程上要做的全部努力,本质都是用一圈确定性的、可靠的代码,去把这个不确定的组件包裹起来、约束起来,让整个系统对外呈现出确定和可靠。我第一版最深层的错误,是一种"角色定位"上的错误:我下意识地把大模型当成了一段"和我写的其他代码一样可靠"的代码——我调用 json.loads 时不会怀疑它的返回,我便也不怀疑模型的返回。可大模型和你手写的确定性代码,有着本质区别:一段确定性代码,同样的输入永远给出同样的、可预期的输出;而大模型,同样的输入可能给出不同的输出,它有温度、有随机性、有概率,它的输出永远带着一层不确定的雾。这不是模型的缺陷,这是它的固有属性——正是这种"不确定",换来了它处理开放、模糊问题的惊人能力。所以你不能去消除它的不确定性,你要做的,是承认这份不确定,然后在它外面,亲手建一圈"确定性的外壳":提取清洗,是这圈外壳;schema 校验,是这圈外壳;受约束解码,是把约束加进模型自己;带反馈的重试和降级,还是这圈外壳;接进去的监控,是给这圈外壳装上仪表。这一圈东西合起来,做的是同一件事——把模型那团不确定的、概率性的输出,一层层地收敛、约束、校验、兜底,最终变成一个你的下游业务可以放心信任的、确定的结果。这个认知,可以推广到你在系统里引入的一切"不确定组件":一个会超时、会抖动的远程服务,你要用超时控制、重试、熔断、降级这一圈确定性机制把它包起来;一个可能返回脏数据的外部数据源,你要用校验、清洗、隔离把它包起来。它们和大模型是同一类东西——能力或价值是真的,不确定也是真的。成熟的工程,从不天真地期待这些组件"变得可靠",而是默认它们不可靠,然后用自己可控的、确定性的代码,为它们织一张可靠的网。你交付给用户的可靠性,从来不是某个组件天生就有的,而是你用工程手段,一层一层亲手建立起来的。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 软约束 | 提示词里要求输出 JSON 这类建议 | 模型倾向照做但毫无保证 |
| 硬约束 | 机制层面强制保证的约束 | 对方想违反也违反不了 |
| 文本生成器 | 模型生成看起来像 JSON 的文本 | 不是构造 JSON 对象 合法性是概率的 |
| 提取清洗 | 从模型返回里把 JSON 块抠出来 | 剥围栏定位花括号 只提取不脑补修复 |
| 语法正确 | JSON 形式良构能被解析 | json.loads 通过只代表这一层 |
| 语义正确 | 字段类型枚举范围都符合预期 | 要靠 schema 校验单独保证 |
| schema 校验 | 用 pydantic 校验内容结构 | 合法 JSON 不等于对的 JSON |
| 受约束解码 | 解码层强制每个 token 合语法 | 从根上杜绝非法输出 优先使用 |
| 带反馈重试 | 把错误信息回喂给模型再生成 | 构成反馈闭环 比闷头重试有效 |
| 降级 | 重试用尽后的可接受退路 | 把局部失败控制在局部 |
避坑清单
- 别把提示词里的"输出 JSON"当硬约束,它只是软建议,模型随时可能不照做。
- 别直接 json.loads 模型返回值,先剥 markdown 围栏、定位花括号把 JSON 抠出来。
- 提取只剥壳不修复,对形态宽容对内容严格,绝不靠猜补全非法 JSON。
- json.loads 成功不代表内容对,必须再用 schema 校验字段、类型、枚举、范围。
- 枚举字段务必校验越界,模型最爱自己编一个你没定义的值。
- 能用受约束解码就优先用,structured outputs 把合法性从软约束升级成硬约束。
- 受约束解码也别省语义校验,它保证语法结构,保证不了内容填得对。
- 重试要带错误反馈,把上次错在哪喂回去,别原地闷头再赌一次。
- 重试要设上限并降级,次数用尽果断走转人工或默认值,别无限重试拖垮系统。
- schema 别太复杂、字段配描述,巨型嵌套 schema 会拉低成功率、抬高成本。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为在提示词里要求"输出 JSON",模型就会给我一段能直接 json.loads 的、合法的、符合我期望结构的纯 JSON。可大模型本质是个文本生成器,它生成的是"看起来像 JSON 的文本",不是"一个 JSON 对象"——它会很自然地把 JSON 包进 markdown 围栏、在前面加句客套话、或在某处漏掉一个逗号。我提示词里那句"请输出 JSON",在模型那里只是一条影响生成倾向的软建议,从来不是一条它必须遵守的硬约束。所以 json.loads 在生产环境随机崩溃,不是模型出了 bug,是它概率本质的正常体现。
真正把大模型的结构化输出做扎实,工作量不在"把'输出 JSON'在提示词里说得更用力",而在一次思路的转变:承认模型不保证输出合法 JSON,转而用一圈确定性的代码把它包起来。一旦接受这一点,该做的事就都浮现出来了——优先用受约束解码从解码层强制语法合法,拿到文本后先提取清洗把 JSON 抠出来,再用 schema 校验把语义错误也挡掉,失败了用带错误反馈的重试去针对性修正,重试用尽用降级兜住底,把解析失败率和降级率接进监控。每一步都不复杂,难的是先承认:你手里的不是一段返回值永远可信的代码,而是一个概率性的、需要你在它外面层层设防的组件。
我后来常拿"和一个口才极好但做事毛糙的助理共事"来想这件事。这个助理能力很强,你让他从一堆资料里整理出一份表格,他整理的内容八成是对的——可他递给你的方式,永远不让你省心:有时把表格抄在一张便签上、外面还裹着一层"老板您看这是结果"的客套话;有时表格里某一栏的数字,他写成了汉字;有时一个本该从下拉选项里选的字段,他自己手写了一个选项进去。你不能因为他"能力强",就闭着眼睛把他递来的东西直接归档——你得养成习惯:先把便签从客套话里抽出来(提取清洗),再逐栏核对格式和取值对不对(schema 校验),发现错了就明确告诉他"第三栏数字写成汉字了,改过来"(带反馈重试),他几次都改不对的,你就自己补录或退回(降级)。最好的办法,是一开始就给他一张印好格子、标好每格该填什么、选项都列死的表格让他填(受约束解码),他能发挥的空间小了,出格的可能也就小了。你管理的不是他的能力,是他能力之外那份天生的毛糙。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你测试时用的,是那么几份你精心挑过的、规整的输入,模型面对它们,大概率就吐出干干净净的纯 JSON,你跑几十次,json.loads 次次成功,那行毫无防护的解析代码看起来稳如磐石。它只在真实流量的汪洋里才暴露——成千上万份风格各异的输入中,总有一些会让模型把 JSON 包进围栏、加上客套话、写出非法语法,而这些失败没有一个会在功能测试里喊疼,它只是在生产环境里,随机地、毫无规律地让某一次请求崩掉。所以别等线上开始零星报 JSONDecodeError、等用户反馈"结果时好时坏",才想起去补防护:接入大模型、写下第一行 json.loads 的那一刻,就该把"模型给我的不是一个 JSON,而是一段可能很脏的文本"当成和写对业务逻辑同等重要的事来设计——提取、校验、受约束解码、重试降级,不该是"出了线上事故再补"的补丁,而该是你设计 LLM 功能时,和功能本身一起摆上桌的另一半。把"模型不保证输出合法 JSON、可靠性要靠自己的代码亲手建立"这件事在一开始就认下来,你才算真正跳出了那个把模型输出当成可信返回值、出了事还在反复修改提示词措辞的坑。
—— 别看了 · 2026