跳到主内容

结构化输出攻坚实录:JSON Schema 约束下的可靠抽取与校验重试 ​

凡是让大模型「输出 JSON」的团队,几乎都踩过同一批坑:模型在 JSON 前热情地补一句「好的,以下是您要的内容」,导致解析直接失败;字段名这次叫 invoice_date、下次叫 date;日期格式时而 2026/10/12 时而「2026年10月12日」;偶发性地输出到一半戛然而止。单发请求看着都挺好,一上批量就千疮百孔——问题不在模型「不会」输出 JSON,而在于自然语言的自由发挥空间没有被彻底关掉。

结构化输出攻坚的目标,是把「希望它输出 JSON」变成「它只能输出符合约定结构的 JSON」。这件事要靠三层防线配合:解码层约束、Schema 校验、带反馈的重试。缺任何一层,可靠性都上不了生产。

方案对比:三层可靠性台阶 ​

方案做法典型失败模式
纯提示词约束在提示词里写「请输出 JSON」前后夹带说明文字、字段名漂移
JSON Mode强制响应是合法 JSONJSON 合法但结构、字段、类型随意
Schema 严格约束 + 校验重试解码层按 JSON Schema 约束生成,外层再做业务校验仅剩业务语义错误,且可被重试收敛

纯提示词约束只适合内部一次性脚本;JSON Mode 解决了「是不是合法 JSON」,但管不住结构;主流厂商的 Schema 严格约束(如 OpenAI 的 Structured Outputs,通过受支持字段在解码层强制按 Schema 生成)才是生产级的起点。但注意,Schema 约束只能保证「形状正确」,保证不了「内容正确」——日期是不是真的在那张发票上、金额有没有抽错行,仍然要靠业务校验和重试兜底。

理解约束解码的原理,有助于判断它的能力边界:这类方案在解码的每一步把不符合 Schema 的 token 屏蔽掉,所以输出的结构合法性是由机制保证的,而不是靠模型的自觉。这解释了两个常见现象——它无法修复「抽取错了内容」这类语义错误,因为屏蔽 token 只管格式;它也依赖 Schema 本身写得合理,一个字段含义模糊的 Schema,只会让模型「合规地」填出错误理解。机制保证格式,提示词与校验保证语义,两条线不要互相指望。

实操第一步:用 Pydantic 把约定写成代码 ​

数据契约不要写在提示词里让模型「自觉遵守」,而要用 Pydantic 定义成代码,同时服务于请求参数和响应校验:

python
from pydantic import BaseModel, Field

class InvoiceItem(BaseModel):
    name: str = Field(description="品名,保留发票原文表述")
    quantity: float = Field(description="数量,纯数字")
    amount: float = Field(description="金额,单位元,纯数字")

class Invoice(BaseModel):
    invoice_no: str = Field(description="发票号码,纯数字字符串")
    invoice_date: str = Field(description="开票日期,统一输出 YYYY-MM-DD 格式")
    seller_name: str = Field(description="销售方名称")
    total_amount: float = Field(description="价税合计,单位元")
    items: list[InvoiceItem] = Field(description="明细行,无法识别时给空数组")

字段设计的三个关键动作:每个字段带 description,它会被写进发给模型的 Schema,相当于内联的填写说明;格式类字段在描述里写死目标格式(如日期统一 YYYY-MM-DD),把「格式自由度」提前消灭;允许缺信息的位置给明确出口(空数组、null),否则模型会为了「填满」而编造。

结构本身的克制同样重要:字段命名全库统一一种风格,避免同义字段(date 与 invoice_date)并存;层级尽量压扁,三四层以上的嵌套会同时抬高模型的出错率和下游解析的成本;可枚举的字段坚决用枚举而不是自由文本。Schema 是人机之间的契约,契约写得越含糊,扯皮(出错)的机会就越多。

实操第二步:Schema 约束解码 + 带反馈的重试循环 ​

调用侧用支持结构化输出的 SDK 入口,把 Pydantic 模型直接作为 response_format;校验失败时把具体错误回传给模型修正,而不是原样重问:

python
from openai import OpenAI

client = OpenAI()

def extract_invoice(text: str, max_attempts: int = 3) -> Invoice:
    messages = [
        {"role": "system",
         "content": "你是发票信息抽取器。只输出符合 Schema 的 JSON,"
                    "图片或文本中不存在的字段填 null,禁止编造。"},
        {"role": "user", "content": text},
    ]
    for attempt in range(max_attempts):
        resp = client.beta.chat.completions.parse(
            model="gpt-4o",
            messages=messages,
            response_format=Invoice,     # Schema 约束解码
        )
        # 截断检测:输出被 max_tokens 截断时再重试也没用,直接升级处理
        if resp.choices[0].finish_reason == "length":
            raise ValueError("输出被截断,请增大 max_tokens 或精简字段")
        try:
            return resp.choices[0].message.parsed   # SDK 内完成 Pydantic 校验
        except Exception as err:
            # 关键:把校验错误的具体内容喂回去,让模型针对性修正
            messages.append({"role": "assistant",
                             "content": resp.choices[0].message.content or ""})
            messages.append({"role": "user",
                             "content": f"上次输出未通过校验:{err}。请修正后仅输出 JSON。"})
    raise RuntimeError("多次抽取仍未通过校验,转人工处理")

这个循环的精髓在重试的质量:盲目的「再试一次」大概率复现同样的错误,而把 Pydantic 的报错原文回传,模型能精确定位「哪个字段、什么原因」并修正。三次封顶后转人工,把失败变成显式的人工工单,而不是静默的脏数据。

实操第三步:分层校验,别把所有规则塞进 Schema ​

生产级的校验应该是三层漏斗:第一层语法校验(能不能解析成 JSON),Schema 约束解码已基本消灭;第二层结构校验(字段、类型、枚举),由 Pydantic 承担;第三层业务校验(合计金额等于明细之和、发票号校验位合法、日期不晚于今天),用普通代码写。业务校验永远不要试图全塞进 JSON Schema——Schema 表达不了「明细求和等于合计」这类跨字段规则,硬塞只会让 Schema 不可维护。

避坑总结 ​

  1. 严格模式不是万能 Schema。Schema 约束解码对支持的特性有限制(如通常要求显式关闭额外属性、部分格式化关键字受限),复杂 Schema 先小流量验证,别等全量上线才发现字段被静默忽略。
  2. 截断是重试救不了的失败。输出被 max_tokens 截断后无论重试多少次都会截断,必须检查 finish_reason 并走「加大上限或精简输出」的分支,与校验失败分开处理。
  3. 重试必须携带错误反馈。把校验器报错原文回传给模型做针对性修正,命中率远高于原样重问;同时设重试上限,失败转人工,避免无限循环烧钱。
  4. 枚举值要收敛再收敛。让模型自由发挥「工单类型」必然五花八门,改成受限枚举并在 description 里写清判定标准,必要时在提示词里给两个边界案例。
  5. 温度与任务性质要匹配。抽取类任务用低温度追求稳定,别沿用对话类任务的默认参数;每一次改字段、改提示词都跑一遍留存的评测样本,确认没有把昨天修好的 case 改坏。

写在最后 ​

结构化输出的可靠性不是某个参数调出来的,而是一套防线:解码层关掉格式自由度,Schema 收敛结构,业务校验守住语义,重试循环消化残余错误,人工兜底承接长尾。把这五件事做齐,抽取类应用的可用率才能从「demo 能跑」走到「生产敢用」——中间差的不是模型能力,是工程纪律。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。