2024 年我做一个功能:让大模型从一段用户输入的文本里,提取出结构化信息(姓名、金额、日期之类),再交给后面的程序去用。第一版我做得很省事:在 prompt 里写一句"请以 JSON 格式返回",拿到模型的回复,直接 json.loads。本地测了几条——真不错:模型乖乖回了 JSON,我也顺利解析出来了。我心里很踏实:"结构化输出嘛,不就是在 prompt 里说一句'返回 JSON',然后 json.loads 一下。"可等它真正上线、跑在五花八门的真实输入上,一串问题冒了出来。第一种最先把我打懵:json.loads 频繁抛异常、整个流程崩掉——模型把 JSON 包在了 Markdown 代码围栏里,或者在 JSON 前面加了一句"好的,这是提取结果:"。第二种:偶尔 json.loads 成功了,可字段对不上——我要的 age 字段它没给,或者 age 给了个字符串 "二十八"。第三种:模型还爱自作主张——我没要的字段它给加上,枚举值我只允许三个、它给了第四个。第四种:遇到长文本,JSON 直接被截断成半截,缺一个右括号。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"结构化输出就是在 prompt 里说一句返回 JSON,然后 json.loads"。这句话把"让模型返回 JSON"和"拿到我能可靠使用的结构化数据"当成了一回事。可它不是。大模型是一个概率生成器,prompt 里那句"返回 JSON"只是一条建议,不是一道保证——它随时可能包围栏、夹解释、缺字段、错类型、被截断。真正用好结构化输出,核心不是"在 prompt 里说一句",而是理解模型的输出天然不可靠,并为此搭一整套从约束、解析、校验到重试的防线。这篇文章就把大模型结构化输出梳理一遍:为什么"说一句返回 JSON"不算结构化输出、怎么用 JSON mode 从源头约束、解析为什么必须容错、schema 校验该怎么做、解析失败怎么带错误重试,以及 few-shot 示例、截断检测、枚举约束这些把结构化输出真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:在 prompt 里要求模型返回 JSON、再直接 json.loads 之后,上线冒出一串问题:json.loads 频繁抛异常(JSON 被包进 Markdown 围栏、或前面夹着解释文字);偶尔 parse 成功了字段却对不上(缺必填字段、类型不对);模型自作主张加字段、给超出枚举范围的值;长文本下 JSON 被截断成半截。
我当时的错误认知:"结构化输出就是在 prompt 里说一句返回 JSON,然后 json.loads 一下就行了。"
真相:大模型本质是一个按概率逐字生成文本的模型。你在 prompt 里写"返回 JSON",对它来说只是一个倾向、一条建议,不是一道能保证的契约。它很可能:习惯性地把代码包进 Markdown 围栏、礼貌性地加一句开场白、漏掉某个字段、把数字写成中文、在 token 上限处被硬生生截断。可靠的结构化输出,是一整套防线:用 JSON mode 从 API 层约束语法、解析时容错剥离、用 schema 校验字段是否符合要求、失败时把错误反馈给模型重试、用 few-shot 示范格式、检测截断。让模型"返回 JSON"只是开头,把这套防线搭齐才是关键。
要把结构化输出做对,需要几块认知:
- 为什么"说一句返回 JSON"不等于结构化输出——模型的输出天然不确定;
- 源头约束——JSON mode / response_format 能保证什么、不能保证什么;
- 容错解析——剥离围栏、抠出 JSON 片段;
- schema 校验——能 parse 不等于字段对,要校验缺失、类型、枚举;
- 失败重试、few-shot、截断检测这些工程坑怎么处理。
一、为什么"说一句返回 JSON"不等于结构化输出
先把这件最根本的事钉死:大模型不是一个"执行你指令"的程序,它是一个"根据上文,一个字一个字往下猜最可能的下一个字"的概率模型。你在 prompt 里写"返回 JSON",并没有在它身上装一个"只能吐 JSON"的开关——你只是让"接下来吐出 JSON"这件事的概率,变高了一些。但"概率高"不等于"必然"。它在训练时见过海量"把代码包在 Markdown 围栏里"的样本,也见过海量"回答前先礼貌寒暄一句"的样本——所以它很自然地、按概率地,就会这么干。你只在 prompt 里说一句,等于把整个解析的成败,赌在了一个概率事件上。
下面这段代码,就是我那个"上线就频繁崩"的第一版:
import json
from openai import OpenAI
client = OpenAI()
# 反面教材:以为 prompt 里说一句"返回 JSON",拿到的就是干净 JSON
def naive_extract(text):
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": f"请从下面这段话提取姓名和年龄,返回 JSON:\n{text}",
}],
)
raw = resp.choices[0].message.content
# 破绽:直接 json.loads,赌模型回的就是一段纯 JSON
return json.loads(raw)
# 破绽一:模型常把 JSON 包在 markdown 代码围栏里,json.loads 直接崩。
# 破绽二:模型爱在 JSON 前后加一句"好的,这是结果:"之类的解释。
# 破绽三:就算 parse 成功了,字段缺了、类型错了,也照样往下传。
这段代码在本地测试时能跑通,它的问题不在代码本身,而在一个被忽略的前提:它默认"模型一定会、且只会,吐出我要的那段纯 JSON"。可它把一个概率事件,当成了确定事件。于是那串问题就有了解释:json.loads 崩溃,是因为它没料到模型会加围栏、夹解释;字段对不上,是因为它以为 parse 成功就等于数据正确;截断,是因为它没考虑输出长度会撞上 token 上限。问题的根子清楚了:结构化输出的工程量,全在"承认模型输出不可靠"之后——你不为这份不可靠搭防线,它就出问题。先从第一道防线说起。
二、用 JSON mode 从源头约束
第一道防线,是从 API 层面就把"语法"约束住。现在主流的大模型 API,都提供了一个叫 JSON mode(或 response_format)的功能——开启它之后,API 会保证返回给你的内容,是一段语法合法、能被 json.loads 解析的 JSON:
def extract_with_json_mode(text):
"""开启 JSON mode:让模型在 API 层面保证输出是合法 JSON。"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
# 用了 JSON mode,prompt 里通常必须出现 "JSON" 字样,否则会报错
{"role": "system",
"content": "你是数据提取助手,只输出一个 JSON 对象。"},
{"role": "user",
"content": f"提取姓名(name)和年龄(age):\n{text}"},
],
# 关键:response_format 让 API 保证返回的是可解析的 JSON
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
# 注意:JSON mode 只保证"语法合法",不保证"字段符合你的要求"。
JSON mode 是一道很有用的防线,但你必须清楚它的边界。它解决的是:不会再有 Markdown 围栏、不会再有"好的,这是结果:"的开场白、不会再吐出半个引号没闭合的烂 JSON——语法层面的脏东西,它基本扫干净了。但它不解决的是:它不保证里面有你要的字段、不保证 age 是个数字而不是字符串、不保证枚举值在你允许的范围内。换句话说,JSON mode 保证你拿到的是"一段合法的 JSON",但不保证它是"你想要的那段 JSON"。这里的认知要点是:JSON mode 把"能不能解析"这个问题解决了,但"解析出来对不对"这个问题,还得靠后面的 schema 校验。而且——并非所有模型、所有场景都能用上 JSON mode,所以下一道防线仍然不可省:容错解析。
三、解析必须容错:剥围栏、抠片段
就算用了 JSON mode,你也不该假设"拿到的一定是纯 JSON"——可能你对接的是不支持 JSON mode 的模型,可能是本地部署的开源模型,也可能 JSON mode 偶尔失灵。所以解析这一步,要自己写得足够皮实:不管模型回的是纯 JSON、带围栏的 JSON、还是夹着解释的 JSON,都要能把真正的 JSON 部分抠出来:
import re
def strip_to_json(raw):
"""从模型的回复里,把真正的 JSON 部分抠出来。"""
text = raw.strip()
# 情况一:被 markdown 代码围栏包住,形如三个反引号 json ... 三个反引号
fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if fence:
return fence.group(1).strip()
# 情况二:JSON 前后夹着解释文字,就截取第一个 { 到最后一个 }
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
# 情况三:实在找不到,原样返回,交给后面的解析去报错
return text
这个 strip_to_json 处理了三种最常见的情况:代码围栏(用正则把围栏内部抠出来)、前后夹解释(从第一个 { 截到最后一个 })、实在没有(原样交给下一步报错)。有了它,再包一层"解析失败不崩、而是返回 None"的安全解析:
def safe_parse(raw):
"""容错解析:先抠出 JSON 片段,再尝试 loads,失败返回 None。"""
candidate = strip_to_json(raw)
try:
return json.loads(candidate)
except json.JSONDecodeError:
# 解析失败不抛异常,而是返回 None,
# 把"接下来怎么办"的决定权,交给调用方(去重试,还是去兜底)
return None
这里的设计要点,是 safe_parse 失败时返回 None、而不是抛异常。这看着是个小细节,意义却很大:抛异常,意味着"流程到此为止、出错了";返回 None,意味着"这次没成功,但还有别的办法"。对大模型这种"偶尔抽风、再试一次往往就好"的东西,后者才是对的姿态。这里的认知要点是:面对模型输出,解析这一步的目标不是"一次解析成功",而是"失败了也能优雅地知道失败、并把决定权交出去"。解析的皮实劲儿有了,可还有个更深的问题:JSON 能解析,不代表里面的数据是对的。
四、用 schema 校验:能 parse 不等于字段对
开头那个"parse 成功了、字段却对不上",根子是我把"能解析"误当成了"数据正确"。一个 {"naem": "张伟"}——字段名拼错了——它是一段完全合法的 JSON,json.loads 能顺利解析,可它对你的程序毫无用处。所以 parse 之后,必须再过一道校验。校验的依据,是一个你自己定义的 schema——它描述"我到底要什么",而不只是"我要个 JSON":
# 用一个 schema 描述"我到底要什么",而不只是"我要个 JSON"
PERSON_SCHEMA = {
"name": {"type": str, "required": True},
"age": {"type": int, "required": True},
"gender": {"type": str, "required": False,
"enum": ["male", "female", "unknown"]},
}
有了 schema,校验函数就逐条比对:必填字段在不在、字段类型对不对、枚举值合不合法:
def validate(data, schema):
"""对照 schema 校验:字段在不在、类型对不对、枚举值合不合法。"""
errors = []
for field, rule in schema.items():
if field not in data:
# 必填字段缺失,记一条错误;非必填缺失则跳过
if rule.get("required"):
errors.append(f"缺少必填字段 {field}")
continue
value = data[field]
# 类型检查:比如 age 必须是 int,不能是字符串 "二十八"
if not isinstance(value, rule["type"]):
errors.append(f"字段 {field} 类型应为 {rule['type'].__name__}")
# 枚举检查:取值必须落在允许的集合里
if "enum" in rule and value not in rule["enum"]:
errors.append(f"字段 {field} 取值 {value} 不在允许范围内")
return errors
这个 validate 返回的不是一个简单的"对/错",而是一份具体的错误清单——"缺少必填字段 age"、"字段 age 类型应为 int"。这份清单非常关键,它有两个用处:第一,它替你的程序挡住了脏数据,不让"看起来是个 JSON、实则字段全错"的东西流进后面的业务逻辑;第二——也是更妙的——这份具体的错误,马上就能反过来喂给模型,让它自己改。这里的认知要点是:"能 json.loads"和"数据符合我的要求",是两道独立的关卡,JSON mode 管前者,schema 校验管后者,两道都不能少。校验能挑出错,下一个问题是:挑出错之后,怎么办?
五、解析失败要重试,且要带错误反馈
解析失败、或校验不通过,最朴素的反应是"原样再请求一次"。但这很笨——模型不知道自己上次错在哪,大概率会再错一次。聪明的做法,是把模型上一次的错误输出和具体的错误信息,一起拼回对话里,让模型"看着自己的错误去改":
def extract_with_retry(text, schema, max_retries=3):
"""解析或校验失败时,把错误信息反馈给模型,让它自己改。"""
messages = [
{"role": "system", "content": "你是数据提取助手,只输出 JSON 对象。"},
{"role": "user", "content": f"提取姓名和年龄:\n{text}"},
]
for attempt in range(1, max_retries + 1):
resp = client.chat.completions.create(
model="gpt-4o", messages=messages,
response_format={"type": "json_object"},
)
raw = resp.choices[0].message.content
data = safe_parse(raw)
errors = ["JSON 解析失败"] if data is None else validate(data, schema)
if not errors:
return data
# 关键:把模型上一次的输出、和这次的具体错误,都拼回对话里
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user",
"content": f"上次输出有问题:{'; '.join(errors)}。"
"请改正后,重新只输出 JSON。"})
raise ValueError(f"重试 {max_retries} 次仍未得到合法输出")
这个 extract_with_retry 的精髓,在于那两行 messages.append。它把重试,从"再赌一把",变成了"带着上次的考卷和批改意见,重做一遍"。模型是很擅长"照着具体反馈改"的——你只要明确告诉它"你上次缺了 age 字段",它下一次补上的概率就非常高。这比"原样重发、盲目祈祷"有效得多。当然,重试必须有上限(max_retries),不能让一个死活搞不定的输入把模型无限调用下去。下面这张图,把一次可靠的结构化输出流程串起来:
六、工程坑:few-shot、截断与枚举
五块设计之外,还有几个工程坑,不处理就会让结构化输出不稳或踩雷。坑 1:与其反复用文字描述格式,不如直接给范例。你在 prompt 里写一百字去描述"我要什么格式",效果往往不如直接甩给模型一两个"输入长这样、输出就该长这样"的范例。模型极擅长模仿范例,这就是 few-shot:
def build_fewshot_messages(text):
"""给模型一两个输入-输出范例,它会模仿范例的格式,稳得多。"""
return [
{"role": "system", "content": "从文本提取人物信息,只输出 JSON 对象。"},
# 范例:用一问一答,演示"输入长这样、输出就该长这样"
{"role": "user", "content": "张伟今年 28 岁。"},
{"role": "assistant",
"content": '{"name": "张伟", "age": 28, "gender": "unknown"}'},
# 范例之后,才是真正要处理的输入
{"role": "user", "content": text},
]
坑 2:输出可能被 max_tokens 截断,要主动检测。这是我开头那个"JSON 缺右括号"的真凶。如果模型要输出的内容,超过了你设的 max_tokens,API 就会在中途硬生生把它砍断——你拿到的是半截 JSON,必然解析失败。而且这种失败,你不能靠重试解决(再试还是会被截断)。要靠 finish_reason 主动识别它:
def extract_check_truncation(text):
"""检测输出是否因 max_tokens 不够而被截断。"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=build_fewshot_messages(text),
response_format={"type": "json_object"},
max_tokens=512,
)
choice = resp.choices[0]
# finish_reason == "length" 说明输出是被截断的,JSON 多半不完整。
# 这种错,重试也没用,必须调大 max_tokens 或把任务拆小。
if choice.finish_reason == "length":
raise ValueError("输出被截断,请调大 max_tokens 或拆分任务")
return json.loads(choice.message.content)
坑 3:枚举字段一定要在 prompt 里写死可选值,并在校验时兜底。如果你要一个 gender 字段、只接受 male / female / unknown 三个值,就必须在 prompt 里把这三个值明明白白列出来,否则模型可能给你返回"男"、"M"、"保密"这种你处理不了的值。光在 prompt 里说还不够,校验时也要再卡一道(就是上面 validate 里的 enum 检查)——prompt 是"软约束",校验是"硬约束",两道都要。坑 4:结构化任务,温度调到 0。提取、分类这类有标准答案的结构化任务,要的是稳定、可复现,不是创意。把 temperature 调到 0,能让模型的输出尽量确定,同样的输入尽量给同样的结果。把前面所有手段串成一条完整的可靠管线:
def robust_extract(text, schema, max_retries=3):
"""把所有手段串起来:few-shot + JSON mode + 容错解析 + 校验 + 重试。"""
messages = build_fewshot_messages(text)
for attempt in range(1, max_retries + 1):
resp = client.chat.completions.create(
model="gpt-4o", messages=messages,
response_format={"type": "json_object"},
temperature=0, # 结构化任务,温度调 0 求稳定
max_tokens=512,
)
choice = resp.choices[0]
# 截断是无法靠重试解决的错,直接抛出
if choice.finish_reason == "length":
raise ValueError("输出被截断,请调大 max_tokens 或拆分任务")
raw = choice.message.content
data = safe_parse(raw)
errors = ["JSON 无法解析"] if data is None else validate(data, schema)
if not errors:
return data
# 把错误反馈拼回去,带着批改意见重做
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user",
"content": f"输出有误:{'; '.join(errors)},请改正。"})
raise ValueError("多次重试仍未得到合法的结构化输出")
这个 robust_extract,就是把六节内容收成的一条管线:few-shot 示范格式、JSON mode 约束语法、温度调 0 求稳、截断主动检测、容错解析、schema 校验、带错误反馈重试。坑 5:别忘了给最终的兜底。哪怕这套管线已经很皮实,重试 N 次仍然失败的情况,在海量真实输入下依然会发生。所以调用 robust_extract 的地方,必须接住它最后抛出的异常,并想好兜底策略:是记一条日志、返回一个默认值,还是把这条输入转人工处理?一个健壮的系统,不是"假设永不失败",而是"想清楚失败了怎么办"。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 结构化输出 | 让模型返回程序能可靠解析使用的结构化数据 |
| JSON mode | response_format 让 API 保证返回语法合法的 JSON |
| JSON mode 边界 | 只保证语法合法,不保证字段符合你的要求 |
| 容错解析 | 剥离 markdown 围栏、抠出 JSON 片段再解析 |
| safe_parse | 解析失败返回 None 而非抛异常,把决定权交出去 |
| schema 校验 | 校验必填字段、类型、枚举值,能 parse 不等于对 |
| 带反馈重试 | 把上次输出和具体错误拼回对话,让模型照着改 |
| few-shot 范例 | 给输入输出范例,模型模仿范例格式比读描述更稳 |
| 截断检测 | finish_reason 为 length 说明被截断,重试无用 |
| 温度调 0 | 结构化任务求稳定可复现,不需要创意 |
避坑清单
- prompt 里说一句"返回 JSON"只是建议,不是保证,不能直接 json.loads。
- 开启 JSON mode 约束语法,但它只保证能解析,不保证字段对。
- 解析要容错:剥掉 markdown 围栏、从第一个花括号抠到最后一个。
- 解析失败返回 None 而非抛异常,把重试还是兜底的决定权交出去。
- 能 json.loads 不等于数据对,必须再用 schema 校验字段类型枚举。
- 重试别原样重发,要把上次输出和具体错误反馈给模型让它改。
- 重试必须设上限,别让搞不定的输入把模型无限调用下去。
- 与其用文字描述格式,不如直接给一两个输入输出的 few-shot 范例。
- finish_reason 为 length 是截断,重试无用,要调大 max_tokens。
- 枚举值要在 prompt 里写死并在校验时兜底,温度调 0 求稳定。
总结
回头看那串"json.loads 频繁崩、字段对不上、模型自作主张、JSON 被截断"的问题,以及我后来在结构化输出上接连踩的坑,最该记住的不是某一个 API 参数,而是我动手前那个想当然的判断——"结构化输出就是在 prompt 里说一句返回 JSON,然后 json.loads"。这句话错在它把大模型当成了一个"听话的程序"。我以为我下了一道"返回 JSON"的命令,模型就会像一个函数一样,精确地、确定地执行它。可大模型根本不是程序,它是一个概率生成器——它做的事,是"根据上文,猜下一个最可能的字"。我那句"返回 JSON",没有给它装上任何开关,只是让它"接下来吐 JSON"的概率高了一些。而"概率高",意味着"大多数时候是对的",也意味着"总有些时候是错的"——它会按概率包围栏、按概率夹解释、按概率漏字段。
所以做结构化输出,真正的工程量不在"在 prompt 里写一句返回 JSON"那一句话上。那一句话,谁都会写。真正的工程量,在于你要承认"模型的输出天生不确定",并为这份不确定,搭起一整道纵深防线:它语法可能脏,你就用 JSON mode 从源头滤一遍;它仍可能带围栏,你就用容错解析把 JSON 抠出来;它能 parse 不代表数据对,你就用 schema 校验逐字段查;它错了,你就把错误反馈给它、让它带着批改意见重做;它可能被截断,你就用 finish_reason 主动识别。这篇文章的几节,其实就是顺着这条防线展开的:先想清楚"说一句返回 JSON"为什么不算结构化输出,再用 JSON mode 守住语法、用容错解析守住"抠得出 JSON"、用 schema 校验守住"字段是对的"、用带反馈重试守住"错了能改回来",最后是 few-shot、截断、枚举这几个把结构化输出做扎实的工程细节。
你会发现,跟大模型要结构化数据,和现实里"让一个很有才、但很随性的实习生帮你填一张表"完全相通。一个不会带这种实习生的人,会怎么做?他只丢一句"把信息填成表格给我",然后对结果不闻不问(这就是只说一句"返回 JSON")。实习生很有才,大多数时候填得不错,可他很随性:有时在表格边上画满了批注(这就是夹解释、加围栏);有时漏填了一栏(这就是缺字段);有时该填数字的地方填了一句话(这就是类型错);写得太长时纸不够、最后一行戛然而止(这就是截断)。而一个会带实习生的人怎么做?他先拿一张填好的样表给实习生看——"照这个样子填"(这就是 few-shot);他拿到表格后会逐栏核对,而不是看一眼就归档(这就是 schema 校验);发现哪栏错了,他不会把表撕了重来,而是指着那一栏说"这里填错了,这样改"(这就是带错误反馈的重试);他还会提前说清"性别这栏只能填这三个里的一个"(这就是枚举约束)。跟模型要结构化数据,成败从来不在于你那句指令下得多漂亮,而在于你认不认得清它"有才但随性"的本性,并为此把核对、反馈、重做这一整套流程建起来。
最后想说,结构化输出做没做对,差距永远不会在"本地测几条都通了"时暴露——本地你测的就那么几条、还都是你精心挑的、规规整整的输入,模型恰好都乖乖配合,你会觉得"说一句返回 JSON、json.loads 一下"已经是全部。它只在真实的、有海量五花八门输入、模型偶尔就要抽一次风的线上环境里才显形。那时候它会用最让人措手不及的方式给你结账:做不好,你会像我一样,被一串解析崩溃追着跑——后台日志里全是 JSONDecodeError;偶尔没崩的,字段又是错的,脏数据悄悄流进了下游;你查不出规律,因为模型这次这么抽风、下次那么抽风;而做对了,你的提取流程会稳得让人踏实:模型偶尔的抽风,被 JSON mode 和容错解析悄悄抹平;字段错了,schema 校验当场拦下、重试自动修好;真正搞不定的极少数,也有兜底、有日志、转了人工。所以别等"满屏的解析异常"找上门,在你写下那句"返回 JSON"的那一刻就该想清楚:我面对的不是一个会精确执行命令的程序,而是一个有才却随性的概率生成器——它的语法、它的字段、它的长度、它的取值,这一道道防线,我是不是每一道都搭上了?这些问题有了答案,你拿到的才不只是一段"看起来像 JSON"的文本,而是真正可靠、可校验、能放心交给下游程序使用的结构化数据。
—— 别看了 · 2026