AI 结构化输出的隐形失败:合法 JSON 不等于正确数据
很多人接入 OpenAI 结构化输出(Structured Outputs)的体验是这样的:用 Pydantic 定义一个模型,把 response_format 指过去,模型就乖乖按字段返回,不再有 markdown 代码块包裹、不再有缺字段、不再需要写正则和重试循环去兜 JSON 解析。跑通的那一刻,很容易把它当成"可靠"的代名词。
但"能稳定解析"和"数据是真的"之间,隔着一整条流水线。
考虑这样一个场景:你有一条流水线,负责把付款确认消息解析成交易记录,再交给对账系统比对。上线几周后,对账开始报出一小撮异常——金额、付款人对得上,唯独日期差了几小时。比例不高,每周大概 2% 到 3%,不会崩,也不会触发类型检查。
第一反应通常是时区 bug。但当你把原始消息和解析结果并排拉出来看,规律就出现了:每一笔对不上的交易,源头消息里压根没有日期。
消息可能长这样:
收到来自张伟的付款,金额 45000 元,单号 TXN-82K91。
整段没有一个日期。但你的 Schema 把 transaction_date 标成了必填字段,模型不可能返回空——于是它自己找了一个合理的值塞进去,通常是提取任务执行的那一天,误差不到一小时。
返回的 JSON 类型完全正确,Pydantic 校验通过,没有任何异常抛出。可这个值是模型编出来的,而响应里没有任何标记告诉你"这个字段其实是凑的"。
这就是结构化输出最容易被低估的风险:它消除的是"格式错误"这类显性失败,却为"字段凭空生成"这类隐性失败创造了条件。

"返回了合法 JSON"不是终点,是另一种失败的起点
Structured Outputs 解决的问题是真实存在的。在它之前,从大模型手里拿稳定 JSON,意味着写正则解析、写重试循环,以及在提示词里反复强调"只输出 JSON,不要前言"。这套东西现在基本可以丢掉了。
用 OpenAI Python SDK 配合 Pydantic,干净消息上的表现确实和宣传一致:
import logging
from datetime import date
from pydantic import BaseModel
from openai import OpenAI
logger = logging.getLogger(__name__)
client = OpenAI()
class Transaction(BaseModel):
sender: str
amount: float
transaction_id: str
transaction_date: date
document = """
收到来自张伟的付款。
金额:45000 元
单号:TXN-82K91
日期:2026 年 8 月 11 日
"""
completion = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "提取这笔交易的明细。"},
{"role": "user", "content": document},
],
response_format=Transaction,
)
txn = completion.choices[0].message.parsed
logger.info("解析到交易 %s", txn.transaction_id)
每个字段都在,类型都对,不用 try/except 去抓 JSON 外面那层 markdown 围栏。到此为止,一切良好。
问题出在下一条消息——来源里压根没有日期的那种:
document = """
收到来自张伟的付款。
金额:45000 元
单号:TXN-82K91
"""
# 这条消息里没有日期。
Schema 不会因为日期不在原文里就放过模型。transaction_date 仍然是必填,所以这个槽位必须被填满,而 Schema 本身永远不会向模型让步。
于是模型伸手去够任何能凑出合法值的东西:当前日期、训练截止日、一个看着合理的猜测。返回的内容类型检查完美,内容本身却是编的,而且响应里没有任何信号告诉你哪些字段是真的、哪些是凑的。
> 多数讲 Structured Outputs 的文章停在"它不会再返回坏 JSON 了"。这只是解决了可靠性问题的一个版本。
第一道防线:让字段允许为空
修这个问题的思路,与其说是改代码,不如说是换认知。
在信息提取任务里,某个字段为空往往不是错误,而是事实——源头确实没提日期。把字段设成可空(nullable),等于把"编一个值"的压力从模型身上卸掉:
class Transaction(BaseModel):
sender: str | None
amount: float | None
transaction_id: str | None
transaction_date: date | None
现在如果原文没有日期,模型可以直接返回 None,而不是硬凑一个。这同时把一个容易被混为一谈的界限摆了出来:提取和推断是两件事。
提取是"告诉我原文里有什么"。推断是"告诉我原文意味着什么"。一条消息写"周二付款",而你的 Schema 要一个 ISO 日期,这就已经是推断,不管你是不是有意识地在要求它。
有时候推断正是你要的,但这个决定应该由你的代码做,而不是模型默认替你做。一个可空字段把决定权交还给了业务代码:
if transaction.transaction_date is None:
request_missing_info(transaction_id=transaction.transaction_id)
第二道防线:给每个值配上"证据原文"
可空字段解决了"凭空编值"的问题,却解决不了另一个更糟的:模型给了一个值,你却分不清它到底是从原文读出来的,还是靠模式匹配蒙出来的。
普通 chat 响应里,至少还能看它一步步推理。Structured Outputs 直接跳到最终形态。所以可以在每个值旁边再要一个字段:支撑这个值的那段原文。
from pydantic import Field
class Extracted(BaseModel):
"""通用包装类,省得每个字段类型都写一个几乎一样的类。"""
value: float | date | str | None
evidence: str | None = Field(
description="支撑该值的原文片段,未找到则为空"
)
class Transaction(BaseModel):
sender: str | None
amount: Extracted
transaction_id: str | None
transaction_date: Extracted
这里要说明一个取舍:Extracted 是偷懒的写法,不是最佳实践。value 现在成了联合类型,丢掉了原来 Schema 里 float 那种干净的具体类型安全。如果 Schema 只有少数几个字段类型,宁可单独写 ExtractedFloat、ExtractedDate、ExtractedString,通常更干净。字段类型一多,再考虑通用包装类。
这个模式的价值体现在两处。
第一,把 evidence 排在 value 之前。因为字段按键序生成,让模型先写下它看到的东西,再下结论——一种小小的强制"打草稿"。
第二,它给审核者一个不用重读原文就能核对的锚点。如果 value 填了但 evidence 是空的,或者 evidence 里的文字在原文里根本找不到,这个不匹配就是幻觉在数据里的直接体现。
代价不是没有。在几百条交易消息的批量任务上,全 Schema 加 evidence 字段后,输出 token 大概涨了三分之一,延迟也涨到了流水线规模上能感知到的程度。
> 五位数的邮编不值得这么搞。但一个会被人拿来决策的金额,值。
第三道防线:用校验器拦住"形状对、但不符合常理"的值
到这一步,Schema 已经背负了不少东西:可空类型防它编值,证据字段防它蒙混。但还有一类错误这两层都碰不到——值本身作为一条关于世界的事实,是否说得通。
Schema 保证 amount 是浮点数。它不保证这个浮点数不是负的,也不保证 transaction_date 不会是三天以后。
早先我试过在提示词里加约束,比如"金额必须大于零"。回头看,让语言模型去当约束执行器是件很怪的事。它不是计算器。一个校验器做这件事,每一次都对,而且不花一分钱:
from pydantic import model_validator, ValidationError
class Transaction(BaseModel):
sender: str | None
amount: float | None
transaction_id: str | None
transaction_date: date | None
@model_validator(mode="after")
def check_sane_values(self) -> "Transaction":
# 负金额出现过两次,源头都是把"退款"描述成了"付款"
if self.amount is not None and self.amount <= 0:
raise ValueError(f"amount 必须为正,得到 {self.amount}")
if self.transaction_date is not None and self.transaction_date > date.today():
raise ValueError(f"transaction_date {self.transaction_date} 在未来")
return self
这样 API 在生成响应的瞬间保证结构,Pydantic 在解析成对象的瞬间保证数据说得通,而且第二次检查完全脱离 LLM、每次都一样。
校验器抛错时,你有两条路:把这条记录交给人工,或者把精确的报错回喂给模型让它再试一次。下面是后者,并硬性卡死两次重试:
MAX_RETRIES = 2
def extract_with_retry(document: str) -> Transaction:
history = [
{"role": "system", "content": "提取这笔交易的明细。"},
{"role": "user", "content": document},
]
for attempt in range(MAX_RETRIES + 1):
completion = client.beta.chat.completions.parse(
model="gpt-4o", messages=history, response_format=Transaction
)
raw = completion.choices[0].message.content
try:
return Transaction.model_validate_json(raw)
except ValidationError as e:
if attempt == MAX_RETRIES:
raise # 放弃,交给调用方转人工
logger.warning("第 %d 次校验失败:%s", attempt, e)
history += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"校验失败:{e}。只修出错的那一项。"},
]
MAX_RETRIES 这个上限比看起来重要。我的第一反应是让它一直试,这是错的。连续两次失败,几乎总是说明源文档本身有问题,而不是提示词的问题;第三次自动重试只是在烧 API 调用,而人工十分钟就能清掉。
这套做法也并非 OpenAI 专属。本文每个代码块都基于 OpenAI SDK,但换成 Anthropic 的 tool use,或者用 vLLM + Outlines 自托管,Pydantic 模型一行都不用动,变的只是外面那圈 API 调用。
把提取的目标重新想一遍
我刚在 Hostease 的 VPS上 把这套东西跑通时,成功的标准低得有点不好意思:模型有没有在不弄坏解析器的前提下填完对象。
回头看,这个标准奖励的是错的东西。一个不管眼前是什么都急吼吼填满每个字段的模型,并不叫可靠,它只是自信——而自信是更危险的那种东西。
Structured Outputs 在它该做的事上确实好。它只是没做我最初以为它做的事。它保证的是形状,不是事实。当你不再为括号和引号转义操心,真正的问题一直等在那里:这个对象里的每一个值,是不是都有它存在的真实理由?
那个问题从来都是难的部分。Schema 只是把藏住它的那层壳揭掉了。
上线前该补的几件事
本地把代码跑通,只说明流程可行。要把提取流水线变成一个长期运行的服务,还得补几样:
1.输入和输出日志,便于回溯模型到底返回了什么、返回了哪一版。
2.失败样本留档,方便后续调整 Schema、证据字段和提示词。
3.超时控制,避免单次请求把整条流水线卡住。
4.重试要设上限,连续失败就转人工,别无限循环。
5.模型版本记录,换模型后结果分布变了才能对得上。
6.隐私边界,确认哪些字段可以传给下游系统。
如果只是个人实验,笔记本够用;要让它长期跑、定时批量处理消息或对外提供 API,就需要一台稳定的 Linux 服务器了。
更多推荐


所有评论(0)