LLM 结构化输出完全指南:从一次"json.loads 在生产环境随机崩溃"看懂大模型为什么给不了你稳定的 JSON

2024 年我做一个简历解析功能用户上传一段简历文本我让大模型把里面的姓名工作年限技能列表抽取出来变成一个结构化的对象怎么让模型把抽取结果交给我这件事我没多想让它输出 JSON 不就行了第一版我做得很顺手我在提示词里写一句请以 JSON 格式输出结果把模型返回的字符串直接 json.loads 一下拿到 dict 完事本地拿几份简历一测真不错模型乖乖吐出 JSON 解得干干净净字段一个不少我心里很笃定我提示词里都写明了输出 JSON 模型自然会给我一段能直接解析的纯 JSON 可等这个功能真正上线面对成千上万份五花八门的真实简历一串问题冒了出来第一种最先把我打懵 json.loads 在生产环境随机地抛异常模型把 JSON 包在了一对 markdown 代码围栏里第二种最难缠模型在 JSON 的前面后面加了解释性的话 JSON 是对的可它被一堆废话夹在中间第三种最头疼模型输出的 JSON 本身就不合法结尾多一个逗号字段名没加引号用了中文的引号第四种最莫名其妙 JSON 合法解析也成功了但内容是错的该有的字段没了字段类型本该是数字却给了个字符串枚举字段它自己编了一个值我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为我在提示词里要求输出 JSON 模型就会给我一段合法的符合我期望结构的纯 JSON 可大模型的本质是一个文本生成器它生成的是看起来像 JSON 的文字不是一个 JSON 对象我在提示词里写的请输出 JSON 在模型眼里只是一条软性的建议而不是一条硬性的约束要可靠地从大模型那里拿到结构化的数据根上的办法只有一个不能靠请求模型配合而要在机制层面去强制要么用模型厂商提供的受约束解码在生成的每一个 token 上都强制它符合 JSON 语法要么就在拿到模型那段文本之后用我自己的确定性代码去提取去解析去校验去重试真正把大模型的结构化输出做扎实核心不是在提示词里把输出 JSON 说得更用力而是认清自然语言指令只是软建议学会从模型的话里把 JSON 提取清洗出来学会用 schema 校验把合法但错误的 JSON 也挡掉学会用受约束解码从根上保证语法合法学会用带错误反馈的重试和降级兜底本文从头梳理为什么让模型输出 JSON 靠不住怎么从模型的话里提取 JSON 怎么用 schema 校验内容受约束解码怎么从根上解决失败了怎么重试和降级以及一些把它做扎实要避开的工程坑

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 合语法 从根上杜绝非法输出 优先使用
带反馈重试 把错误信息回喂给模型再生成 构成反馈闭环 比闷头重试有效
降级 重试用尽后的可接受退路 把局部失败控制在局部

避坑清单

  1. 别把提示词里的"输出 JSON"当硬约束,它只是软建议,模型随时可能不照做。
  2. 别直接 json.loads 模型返回值,先剥 markdown 围栏、定位花括号把 JSON 抠出来。
  3. 提取只剥壳不修复,对形态宽容对内容严格,绝不靠猜补全非法 JSON。
  4. json.loads 成功不代表内容对,必须再用 schema 校验字段、类型、枚举、范围。
  5. 枚举字段务必校验越界,模型最爱自己编一个你没定义的值。
  6. 能用受约束解码就优先用,structured outputs 把合法性从软约束升级成硬约束。
  7. 受约束解码也别省语义校验,它保证语法结构,保证不了内容填得对。
  8. 重试要带错误反馈,把上次错在哪喂回去,别原地闷头再赌一次。
  9. 重试要设上限并降级,次数用尽果断走转人工或默认值,别无限重试拖垮系统。
  10. 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

布隆过滤器完全指南:从一次"随机ID刷接口把数据库打垮"看懂缓存穿透与概率型数据结构

2026-5-22 19:39:22

技术教程

分布式锁完全指南:从一次"多实例部署后库存被超卖"看懂为什么单机锁挡不住分布式并发

2026-5-22 19:54:56

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