大模型结构化输出完全指南:从一次"让模型返回 JSON、结果 json.loads 直接崩了"看懂结构化输出

2023 年我做一个简历解析功能,把简历纯文本丢给大模型让它抽取姓名、电话、最高学历、工作年限、技能列表,抽完之后后端代码要拿这些字段存进数据库。第一版我做得很直接:在 prompt 里写一句请以 JSON 格式返回结果,把字段名列清楚,然后把模型返回的内容直接 json.loads 解析成字典。本地拿几份简历一测居然真的成了。可一上量问题就接二连三冒出来:有的时候模型返回的不是纯 JSON,它会在前面加一句好的以下是抽取的结果,甚至把 JSON 包在 Markdown 代码块里,这种内容喂给 json.loads 直接抛异常;有的时候 JSON 格式没错但模型少给了一个字段,我的代码取那个 key 就 KeyError;还有的时候字段在类型却不对,我要的工作年限是数字它给我返回字符串五年左右,我要的学历本该是本科硕士博士之一它返回了个研究生。我一开始的应对方式很笨:报错一个就回去改 prompt,这些要求一条条加粗重复写进 prompt 确实能压下去一部分,可总有新简历新边角情况让模型再次出格。后来才彻底想明白第一版错在一个根本认知上:我把大模型当成了一个会严格遵守格式约定的程序,可它不是,大模型的本质是一个概率性的文本生成器,它是在生成看起来最合理的下一个词而不是在执行一份格式契约,你在 prompt 里写请返回 JSON 对它不是必须遵守的指令而只是一个强烈的倾向暗示,它没有任何机制保证自己每一次都不跑偏。所以我真正缺的不是一句更严厉的 prompt 而是一整套不依赖模型自觉的机制:用平台能力强制它输出合法 JSON、用 schema 约束字段结构、在代码里对输出做容错解析和校验、出格了能自动重试。本文从头梳理:为什么模型返回的 JSON 不可靠、结构化输出的本质是从祈求变约束、怎么用 JSON mode 和 JSON Schema 强约束、怎么做容错解析和校验重试,以及字段缺失、枚举幻觉、长输出截断这些把结构化输出真正做对要避开的坑。

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,要调大上限或拆任务

避坑清单

  1. 大模型是概率文本生成器,在 prompt 里要求返回 JSON 只是暗示,它守约是概率性的不是必然。
  2. 靠改 prompt 治格式问题只能把出格概率压低压不到零,足够大的量下意外一定会发生。
  3. 结构化输出的本质是从祈求模型守约变成用机制强制约束,把决定权收回到自己手里。
  4. 用 JSON mode 声明响应必须是合法 JSON,杜绝夹带客套话和被 Markdown 代码块包裹。
  5. JSON mode 只保证是合法 JSON,不保证字段和类型,结构约束要靠 JSON Schema。
  6. 用 strict 的 JSON Schema 约束字段名类型必填枚举,模型从生成机制上就被挡住出格。
  7. 不能假设平台层约束永不失手,代码侧仍要容错解析:json.loads 失败用正则抠出 JSON 块。
  8. 解析出字典还要逐项校验,不合格时把具体错误喂回模型做针对性重试而不是盲目重试。
  9. 可选字段给默认值别逼模型必返,枚举字段做映射归一,非法值退到默认值绝不流进数据库。
  10. 长输出会被截断成半截非法 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

消息队列削峰完全指南:从一次"秒杀活动瞬间把数据库打挂"看懂异步削峰

2026-5-21 20:15:48

技术教程

熔断降级完全指南:从一次"一个下游服务慢了、整个系统跟着雪崩"看懂熔断器

2026-5-21 20:26:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索