2023 年我做一个简历解析功能。需求是这样的:用户上传一份简历,我把简历的纯文本丢给大模型,让它帮我把关键信息抽取出来——姓名、电话、最高学历、工作年限、技能列表——抽完之后,我的后端代码要拿这些字段,存进数据库。既然要存进数据库,我就需要模型把结果结构化地给我,最自然的选择就是 JSON。第一版我做得很直接:在 prompt 里写一句"请以 JSON 格式返回结果",把字段名列清楚,然后把模型返回的内容,直接 json.loads 解析成字典。本地拿几份简历一测,居然真的成了——模型规规矩矩返回了一个 JSON,解析也顺利。我当时就觉得这事儿成了。可一上量,问题就接二连三地冒出来。有的时候,模型返回的不是纯 JSON,它会在前面加一句"好的,以下是抽取的结果:",甚至把 JSON 包在一个 ```json 的 Markdown 代码块里——这种内容喂给 json.loads,直接抛异常。有的时候,JSON 本身格式没错,但模型少给了一个字段,比如简历里没写工作年限,它就干脆不返回那个 key,我的代码 result["work_years"] 一取就 KeyError。还有的时候,字段在、类型却不对——我要的"工作年限"是个数字,它给我返回个字符串 "五年左右";我要的"最高学历"本该是"本科/硕士/博士"之一,它返回了个"研究生",一个不在我枚举里的值。我一开始的应对方式很笨:报错一个,我就回去把 prompt 改一改——"请只返回 JSON,不要任何多余的话","所有字段都必须返回","工作年限必须是数字"……我把这些要求一条条加粗、重复、写进 prompt,确实能压下去一部分,可总有新的简历、新的边角情况,让模型用一种我没料到的方式再次出格。那段时间我的解析代码,被各种 try/except 和特判补丁糊得不成样子。后来我才彻底想明白,第一版错在一个根本的认知上:我把大模型当成了一个"会严格遵守格式约定的程序"。可它不是。大模型的本质,是一个概率性的文本生成器——它是在"生成看起来最合理的下一个词",而不是在"执行一份格式契约"。你在 prompt 里写"请返回 JSON",对它来说不是一道必须遵守的指令,而只是一个强烈的倾向暗示。绝大多数时候它会顺着这个暗示走,但它没有任何机制保证自己每一次都不跑偏。所以我真正缺的,不是一句更严厉的 prompt,而是一整套不依赖模型自觉的机制:用平台提供的能力去强制它输出合法 JSON、用 schema 去约束它的字段结构、在代码里对它的输出做容错解析和校验、出格了能自动重试。这套机制,就是结构化输出。我以为结构化输出不过是"prompt 里写一句返回 JSON",结果真做下来坑一个接一个。那次之后我才认真把它从头搞明白。这篇文章就把它梳理一遍:为什么模型返回的 JSON 不可靠、结构化输出的本质是什么、怎么用 JSON mode 和 schema 强约束、怎么做容错解析和校验重试,以及字段缺失、枚举幻觉、长输出截断这些把结构化输出真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个简历解析功能,在 prompt 里要求模型以 JSON 返回抽取结果,再用 json.loads 解析。本地能跑通,上量后接连出问题:模型有时夹带"好的,以下是结果"等多余文字或 Markdown 代码块包裹,导致解析抛异常;有时少返回某个字段导致 KeyError;有时字段类型不对,或返回了枚举范围外的值。
我当时的错误认知:"在 prompt 里写清楚'请返回 JSON、字段如下',模型就会严格按这个格式返回。"
真相:大模型是一个概率性的文本生成器,它在"生成最合理的下一个词",不是在"执行格式契约"。prompt 里的"请返回 JSON"对它只是强烈的倾向暗示,不是强制约束。要可靠地拿到结构化数据,不能依赖模型自觉,要用一整套机制:平台的 JSON mode 强制合法 JSON、用 JSON Schema 约束字段、代码侧做容错解析与校验、出格了自动重试。
要把结构化输出做好,需要几块认知:
- 为什么模型返回的 JSON 不可靠,prompt 写得再严也不行;
- 结构化输出的本质,是从"祈求模型守约"变成"用机制强制约束";
- 怎么用 JSON mode 强制模型只输出合法 JSON;
- 怎么用 JSON Schema 进一步约束字段的名字、类型、取值;
- 容错解析、字段校验、枚举幻觉、长输出截断这些工程坑怎么处理。
一、为什么模型返回的 JSON 不可靠
先把这件最根本的事钉死:模型在"生成像 JSON 的文本",不是在"保证输出合法 JSON"。
大模型生成内容,是一个词一个词地往外蹦,每一步都在选"概率最高的下一个词"。当你在 prompt 里要求 JSON,你只是让"生成 JSON 结构"这件事的概率变得很高——但很高不等于百分之百。下面这段代码,就是我那个"写一句 prompt 就 json.loads"的第一版:
from openai import OpenAI
import json
client = OpenAI()
def extract_naive(resume_text: str) -> dict:
# 反面教材:prompt 里求模型返回 JSON,然后直接 json.loads。
prompt = f"""请从简历中抽取信息,以 JSON 格式返回,字段:
name, phone, education, work_years, skills。
简历:{resume_text}"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
content = resp.choices[0].message.content
return json.loads(content)
# 问题:content 可能是 '好的,结果如下:{...}',
# 可能是被 ```json 代码块包着的,可能少字段、类型不对 ——
# 任意一种,json.loads 就抛异常,或者解析出来的 dict 不能用。
这段代码的错,不在某一行语法,在它的根本假设:它假设"我要求了 JSON,拿到的就一定是干净的、完整的、字段类型都对的 JSON"。可模型对你这个要求的遵守,是概率性的。它大概率会给你 JSON,但它会在某些时候,加一句客套话、包一层代码块、漏一个字段、把数字写成中文。每一种,都会让后面 json.loads 那一行,或者用到结果的那行代码,崩掉。
而靠"改 prompt"去治这个病,治标不治本。你把"只返回 JSON"加粗一百遍,你也只是把出格的概率压低了,你压不到零。只要还有万分之一的概率出格,在足够大的请求量下,它就一定会发生。所以正确的方向不是"求模型做得更好",而是"不再依赖模型的自觉"。
二、结构化输出的本质:从"祈求"变成"约束"
把上一节的事实接受下来,结构化输出要做的事就清晰了:把"祈求模型守约",换成"用机制强制约束"。
"祈求"和"约束"的区别在哪?祈求,是你在 prompt 里写"请你一定返回 JSON 啊"——做不做到,决定权在模型手里。约束,是你不再把决定权交给模型:要么用平台层面的能力,从生成机制上就让模型不可能输出非法 JSON;要么在你自己的代码里,对模型的输出做把关——解析它、校验它、不合格就打回重做。决定权,回到了你手里。
这套"约束"是分层的,像安检一样一道一道来:第一层,用 JSON mode,保证拿到的至少是合法的 JSON(不会有客套话、代码块);第二层,用 JSON Schema,保证 JSON 里的字段名、类型都符合你的定义;第三层,在代码里做校验和容错,接住前两层仍然漏过来的意外,并在必要时重试。这三层不是互相替代,而是层层兜底。下面几节,就一层一层把它们搭起来。
三、JSON mode:强制模型只输出合法 JSON
第一层约束,是 JSON mode。这是模型平台直接提供的能力——通过一个参数,告诉接口"这次的输出,必须是一个合法的 JSON 对象"。
def extract_json_mode(resume_text: str) -> dict:
"""用 JSON mode:response_format 强制输出合法 JSON。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
# 关键:声明本次响应必须是 JSON 对象
response_format={"type": "json_object"},
messages=[
# 用了 JSON mode 时,prompt 里也要明确提到 JSON
{"role": "system", "content": "你是简历信息抽取助手,"
"只输出 JSON,字段:name, phone, education, "
"work_years, skills。"},
{"role": "user", "content": resume_text},
],
)
# 此时 content 一定是一段合法 JSON 文本,json.loads 不会因
# "夹带客套话""被代码块包裹"而失败
return json.loads(resp.choices[0].message.content)
JSON mode 解决的,是上一节那个最常见、最烦人的问题:模型夹带多余文字。开了它,模型的输出保证是 { 开头、} 结尾的一段合法 JSON,不会再有"好的,结果如下"、不会再被 ```json 包裹。json.loads 这一行,从此不会因为格式脏而崩。
但要注意 JSON mode 的边界:它只保证"是合法的 JSON",它不保证这个 JSON 里有哪些字段、字段是什么类型。模型完全可以返回一个合法的、但少了 work_years 的 JSON,或者把 work_years 写成字符串。格式干净了,但结构还没被管住——这要靠第二层。
四、JSON Schema:把字段结构也约束死
第二层约束,是 JSON Schema。如果说 JSON mode 管的是"是不是 JSON",那 Schema 管的是"是不是我要的那个 JSON"。
Schema 是一份对数据结构的精确描述:有哪些字段、每个字段什么类型、哪些是必填、枚举字段能取哪些值。先把简历抽取的 Schema 写出来:
EXTRACT_SCHEMA = {
"type": "object",
"properties": {
"name": {"type": "string"},
"phone": {"type": "string"},
# 枚举:学历只能是这三个值之一
"education": {"type": "string",
"enum": ["本科", "硕士", "博士"]},
# 类型:工作年限必须是整数
"work_years": {"type": "integer"},
"skills": {"type": "array", "items": {"type": "string"}},
},
# 必填:这几个字段模型必须返回
"required": ["name", "phone", "education", "work_years", "skills"],
"additionalProperties": False,
}
把这份 Schema 通过结构化输出接口交给模型,模型平台会在生成时按 Schema 来约束:它必须返回这几个字段、work_years 必须是整数、education 必须是枚举里的值。
def extract_with_schema(resume_text: str) -> dict:
"""用 JSON Schema 约束:字段名、类型、必填、枚举都被管住。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
response_format={
"type": "json_schema",
"json_schema": {"name": "resume", "strict": True,
"schema": EXTRACT_SCHEMA},
},
messages=[
{"role": "system", "content": "从简历中抽取信息。"},
{"role": "user", "content": resume_text},
],
)
return json.loads(resp.choices[0].message.content)
这一层是质变。第一节那几个困扰我的问题——少字段、类型不对、枚举值乱写——在 strict 的 Schema 约束下,模型从生成机制上就被挡住了。你拿到的 JSON,字段、类型都和你的定义对得上。这远比"在 prompt 里写一句'工作年限必须是数字'"可靠得多——因为它不再是暗示,而是约束。
五、容错解析与校验:接住仍然漏过来的意外
有了 JSON mode 和 Schema,绝大多数问题都被挡在前面了。但工程上,你不能假设前两层永不失手——可能你用的模型版本不支持严格 Schema、可能输出太长被截断、可能极小概率仍有意外。第三层,是你自己代码里的容错。
第一件事,是容错地解析。万一真的拿到一段夹带文字的输出,别让 json.loads 直接崩,先把那段 JSON 抠出来:
import re
def extract_json_block(text: str):
"""容错解析:从可能夹带文字的输出里,把 JSON 那一段抠出来。"""
try:
return json.loads(text) # 先按理想情况直接解析
except json.JSONDecodeError:
pass
# 退而求其次:用正则找出第一个 { 到最后一个 } 的内容
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
return None
第二件事,是校验。就算解析出了一个字典,也要逐项检查它符不符合你的要求——必填字段在不在、类型对不对、枚举值合不合法:
def validate_result(data: dict):
"""校验:逐项检查解析结果,返回 (是否合格, 错误说明)。"""
if data is None:
return False, "无法解析出 JSON"
for field in ["name", "phone", "education", "work_years", "skills"]:
if field not in data:
return False, f"缺少必填字段: {field}"
if not isinstance(data["work_years"], int):
return False, "work_years 必须是整数"
if data["education"] not in ["本科", "硕士", "博士"]:
return False, f"education 取值非法: {data['education']}"
return True, ""
有了"解析"和"校验",就能拼出一个带重试的健壮版本。它的关键技巧是:校验不通过时,把具体的错误信息,当作新的提示喂回给模型,让它针对性地改,而不是盲目重试:
def extract_robust(resume_text: str, max_retry: int = 2) -> dict:
"""健壮版:解析 + 校验 + 把错误喂回模型重试。"""
messages = [{"role": "system", "content": "从简历抽取信息,输出 JSON。"},
{"role": "user", "content": resume_text}]
for attempt in range(max_retry + 1):
resp = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=messages)
content = resp.choices[0].message.content
data = extract_json_block(content)
ok, err = validate_result(data)
if ok:
return data
# 校验不通过:把模型这次的输出和【具体错误】一起喂回去
messages.append({"role": "assistant", "content": content})
messages.append({"role": "user",
"content": f"输出有误:{err}。请改正后重新返回。"})
raise ValueError("多次重试仍无法得到合格的结构化输出")
六、工程坑:字段缺省、枚举幻觉与长输出截断
三层约束都搭好了,但要把结构化输出真正做稳,还有几个绕不开的工程坑。
坑 1:可选字段要给默认值,别让缺失变成崩溃。不是所有字段都该"必填"。简历里本来就可能没有某项信息(比如没写技能)。对这种可选字段,正确的做法不是逼模型必须返回,而是在代码里给一个兜底默认值——字段缺了,就用默认值补上,让后续代码永远能安全地取到它。
def normalize_result(data: dict) -> dict:
"""归一化:给可选字段补默认值,把枚举幻觉值纠正过来。"""
defaults = {"name": "", "phone": "", "education": "本科",
"work_years": 0, "skills": []}
result = {**defaults, **data} # 缺失的字段用默认值补齐
# 纠正枚举幻觉:模型可能返回"研究生"这类范围外的值
edu_map = {"研究生": "硕士", "大学": "本科", "博士后": "博士"}
result["education"] = edu_map.get(result["education"],
result["education"])
if result["education"] not in ["本科", "硕士", "博士"]:
result["education"] = "本科" # 实在对不上,退到默认值
return result
坑 2:警惕"枚举幻觉"。就算 prompt 里写明了"学历只能填本科/硕士/博士",模型还是可能返回一个"研究生""大专"这类范围之外的值。对所有枚举字段,代码里都要做最后一道映射和兜底,把常见的近义说法归一过去,实在对不上的退到默认值——绝不能让一个非法枚举值流进数据库。
坑 3:长输出会被截断,截断的 JSON 必然非法。模型单次输出有长度上限。如果你让它抽取的内容很多(比如一份超长简历的几十项信息),输出可能没写完就被截断,得到半截 JSON——它一定解析失败。应对:要么调大输出长度上限,要么把大任务拆成多次小抽取,别让单次输出顶到天花板。
坑 4:嵌套越深,模型越容易出错。结构化输出的 Schema,越扁平越可靠。一个三四层嵌套的复杂结构,模型填错某一层的概率显著上升。能拆平的尽量拆平,真需要嵌套也控制在两层以内。下面这张图,把一次结构化抽取的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 模型是概率生成器 | 它在生成最合理的下一个词,不是在执行格式契约,守约是概率性的 |
| prompt 只是暗示 | prompt 里写"请返回 JSON"是强烈倾向暗示,不是强制约束,压不到零 |
| 从祈求到约束 | 结构化输出的本质是把决定权从模型手里收回到自己手里 |
| 三层约束 | JSON mode 保合法、JSON Schema 保结构、代码校验容错兜底 |
| JSON mode | response_format 声明输出必须是合法 JSON,杜绝夹带文字和代码块 |
| JSON Schema | 精确描述字段名类型必填枚举,strict 模式下从生成机制约束结构 |
| 容错解析 | json.loads 失败时用正则抠出第一个花括号到最后一个的内容再解析 |
| 校验加重试 | 逐项校验结果,不合格把具体错误喂回模型针对性重试而非盲目重试 |
| 枚举幻觉 | 模型可能返回枚举范围外的值,代码要做映射归一和默认值兜底 |
| 长输出截断 | 输出有长度上限,超长会被截断成半截非法 JSON,要调大上限或拆任务 |
避坑清单
- 大模型是概率文本生成器,在 prompt 里要求返回 JSON 只是暗示,它守约是概率性的不是必然。
- 靠改 prompt 治格式问题只能把出格概率压低压不到零,足够大的量下意外一定会发生。
- 结构化输出的本质是从祈求模型守约变成用机制强制约束,把决定权收回到自己手里。
- 用 JSON mode 声明响应必须是合法 JSON,杜绝夹带客套话和被 Markdown 代码块包裹。
- JSON mode 只保证是合法 JSON,不保证字段和类型,结构约束要靠 JSON Schema。
- 用 strict 的 JSON Schema 约束字段名类型必填枚举,模型从生成机制上就被挡住出格。
- 不能假设平台层约束永不失手,代码侧仍要容错解析:json.loads 失败用正则抠出 JSON 块。
- 解析出字典还要逐项校验,不合格时把具体错误喂回模型做针对性重试而不是盲目重试。
- 可选字段给默认值别逼模型必返,枚举字段做映射归一,非法值退到默认值绝不流进数据库。
- 长输出会被截断成半截非法 JSON,要调大输出上限或拆成多次;Schema 越扁平越可靠。
总结
回头看那个"让模型返回 JSON、结果 json.loads 直接崩了"的简历解析功能,以及我后来在结构化输出上接连踩的坑,最该记住的不是某一个参数、某一段 Schema,而是我动手前那个想当然的判断——"我在 prompt 里写清楚格式,模型就会严格照做"。这句话错在它把大模型理解成了一个"执行契约的程序"。程序执行契约是确定的——你写 return json.dumps(...),它每一次都给你合法 JSON。可大模型不是程序,它是一个概率系统,它做的是"生成此刻看起来最合理的文本"。"看起来最合理"绝大多数时候恰好就是你要的 JSON,但它没有、也不可能有一个机制,保证每一次都不偏。结构化输出想清楚的,正是这件事:既然模型这一侧的"守约"靠不住,那就别把希望寄托在那里——把可靠性,建在你能控制的地方。
所以做结构化输出,真正的工程量不在"prompt 里写一句返回 JSON"那一下。那句话谁都会写,它在你手动测几条数据时也确实管用。真正的工程量在那些模型偏离时你做了什么准备:模型夹带了客套话,你有没有 JSON mode 从源头掐掉它?模型少给一个字段、把数字写成中文,你有没有 Schema 从生成机制上约束它?前两层万一仍漏过来一个意外,你的代码是容错地接住、校验、重试,还是直接 json.loads 一行崩掉?模型返回了一个你枚举里没有的"研究生",你是归一化地纠正它,还是让这个脏值一路流进数据库?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么靠不住,再看结构化输出的本质是从祈求变约束,然后是 JSON mode、JSON Schema、容错校验这三层主干,最后是字段缺省、枚举幻觉、长输出截断这几个把它真正做稳的工程细节。
你会发现,结构化输出的思路,和我们和任何"能力很强、但不够严谨"的协作者打交道的经验都是相通的。你请一个很有经验、但做事大大咧咧的人帮你填一沓表格,你不会只丢一句"按格式填好"就走开——你知道他十有八九填得不错,但总有几张会漏一栏、会把日期格式写岔。一个靠谱的做法是:给他一份带固定格子和下拉选项的模板(这是 Schema),让他没法填到格子外面去;收回来之后,你还会自己再过一遍(这是校验),发现问题就退回去让他改那一张(这是重试)。你不是不信任他的能力,你是不把整件事的正确性,赌在他每一次都不出错上。和大模型协作,正是这个道理。
最后想说,结构化输出做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你拿三五条干净的数据测,模型每次都返回漂亮的 JSON,有没有那三层约束,跑起来一模一样。它只在真实的、海量的、千奇百怪的输入面前才显形。那时候它会用最难堪的方式给你结账:线上跑了十万次,第八万次时一份格式诡异的简历让模型夹带了一段解释,你的 json.loads 抛出未捕获的异常,接口 500;一个用户的学历被模型写成了"研究生",这个枚举外的脏值一路写进数据库,直到统计报表的时候才发现有一类怎么都归不了类的数据;一份超长简历让模型的输出顶到了长度上限,你拿到半截 JSON,那一整条记录就这么无声地丢了。所以别等线上的脏数据来找你,在你写下第一个让模型返回 JSON 的接口时就该想清楚:模型夹带了文字,我掐得掉吗?它少给一个字段,我兜得住吗?它返回一个枚举外的值,我拦得下吗?它的输出被截断了,我察觉得到吗?这几个问题都有了答案,你的结构化输出才不只是 Demo 里那个几条数据跑得通的样子,而是一个无论输入多刁钻、模型多不按常理出牌,都能稳稳拿到干净、完整、字段类型全部正确的结构化数据的可靠系统。
—— 别看了 · 2026