Towards Data Science

Your LLM Can Return Perfect JSON and Still Be Wrong

8.5内容质量

TL;DR · AI 摘要

结构化输出虽确保JSON格式正确,但可能因数据填充错误导致下游系统失效,需额外验证数据真实性。

核心要点

  • 2-3%交易因模型填充缺失日期导致对账错误
  • Pydantic模型强制字段非空时可能引入虚假数据
  • 验证数据真实性比确保JSON格式更重要

结构提纲

按章节快速跳转。

  1. 结构化输出导致对账系统出现隐性错误

  2. 2-3%交易因缺失日期字段被填充默认值

  3. Pydantic模型强制字段非空导致数据失真

  4. 需增加数据真实性验证而非仅依赖格式校验

  5. 支付确认消息解析中的日期填充错误案例

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 结构化输出的陷阱
    • 问题表现
      • 隐性数据错误
      • 对账系统异常
    • 技术原因
      • Pydantic强制校验
      • 模型填充默认值
    • 解决方案
      • 增加数据验证
      • 监控填充模式

金句 / Highlights

值得收藏与分享的关键句。

#LLM#结构化输出#数据验证#Python#OpenAI
打开原文

您的LLM可以返回完美的JSON但仍可能出错 | Towards Data Science

大型语言模型

您的LLM可以返回完美的JSON但仍可能出错

在仔细思考如何在杂乱不完整的数据上生成结构化输出后,我学到的经验

Benjamin Nweke

2026年8月31日

8分钟阅读

照片由Şahin Sezer Dinçer通过Pexels提供

在开启结构化输出功能处理支付确认消息转换为交易记录的流水线三周后,我注意到对账任务开始标记出一小股持续不断的不匹配记录。

这些既不是崩溃,也不是格式错误的行。只是交易金额和发送方完全匹配但日期有误的情况。大约占给定周交易量的2-3%,足够引起注意,但又不至于立刻显而易见。

起初我假设是时区错误。但事实并非如此。

当我将原始消息与提取出的记录并排查看时,一个模式显现出来:所有不匹配的交易都来自从未提及日期的消息。

比如"从Chinedu收到付款,₦45,000,参考编号TXN-82K91。"文本中任何地方都没有日期。而模型仍然填充了transaction_date字段,几乎总是填写提取任务运行的日期,误差不超过一小时。

模式要求transaction_date: date,必填项。模型无法返回空值。所以它选择了填充。

我一直将"生成的JSON有效"视为这条流水线的终点,而且看起来确实如此。

但它不是。

这是另一种更隐蔽的失败开始出现的节点,这种失败不会抛出错误或通过类型检查,直到下游流程依赖该值的真实性时才会显现。

关于结构化输出的大部分讨论都停留在"现在不会生成破损的JSON"这一层面,仿佛这就能解决可靠性问题。它确实解决了某个版本的问题。

完美模式的陷阱

结构化输出确实解决了真实存在的问题。在原生模式验证出现之前,从LLM获取可靠的JSON需要正则表达式解析器、重试循环,以及几乎恳求模型的提示:"只输出JSON,不要markdown,不要前言。"

使用现代OpenAI Python SDK和Pydantic模型后,这类大部分痛点都消失了:

python

code
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 = """
Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91
Date: 11 August 2026
"""
# gpt-4o-mini在处理格式异常的消息时会将金额和参考编号合并到一个字段中
# 所以这里继续使用完整模型,尽管成本更高。等mini版本跟上后可以重新评估
completion = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "Extract the transaction details."},
        {"role": "user", "content": document},
    ],
    response_format=Transaction,
)
txn = completion.choices[0].message.parsed
logger.info("parsed txn %s", txn.transaction_id)

用干净的消息测试时,它确实能按宣传的那样工作。每个字段都存在,每个类型都正确,不需要try/except来捕获JSON周围的markdown边界。

然后有人转发给你这样一条消息:

code
document = """
Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91
"""
# 这条消息中没有日期

但模式并不在意日期是否缺失。它仍然标记为必填字段,因此必须有内容填充该字段,而模式本身绝不会因此做出妥协。

模型会寻找任何能使其达到有效值的替代方案:当前日期、训练截止日期,或看似合理的猜测。

返回的结果类型检查完全通过。但这些数据完全是虚构的,响应本身没有任何信息能告诉你哪些字段对应哪些内容。

为不确定性设计模式

解决方案更侧重于心态转变而非代码修改。空字段并非提取错误,它往往就是事实本身。将字段设为可空能减轻模型虚构内容的压力:

python
class Transaction(BaseModel):
    sender: str | None
    amount: float | None
    transaction_id: str | None
    transaction_date: date | None

现在如果日期缺失,模型可以直接说明这一点。这也带来了容易混淆的区分点:提取(extraction)与推理(inference)。

提取是"请精确告诉我文本中有什么",而推理是"请告诉我它暗示了什么"。当文本说"周二支付",而模式要求ISO日期格式时,这就是推理,无论你是否本意如此。

有时推理正是你想要的,但这个决定应由你掌控,而非模型默认替你做决定。可空字段将这个决策权交还给你的代码:

python
if transaction.transaction_date is None:
    request_missing_info(transaction_id=transaction.transaction_id)

证据与出处

可空字段解决了"凭空虚构值"的问题,但无法解决另一个更严重的问题:模型给你一个值,你却无法判断它是真的从文档中读取的,还是通过模式匹配生成的。

在普通聊天响应中,至少能看到模型推导出答案的过程。而结构化输出直接跳到最终形式。因此我开始要求每个值都伴随一个字段,即据称支撑该值的原始文本片段:

python
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现在是联合类型而非干净的float类型,这牺牲了原始模式部分类型安全性。

当模式包含多个字段类型时,这种权衡是值得的。单独编写ExtractedFloat、ExtractedDate、ExtractedString类此时只是繁琐的重复工作。对于一两个字段,保持具体类通常更清晰。

这种模式通过两种方式证明其价值。在证据字段优先于值字段的顺序中,由于键是按顺序生成的,模型必须先写下它正在查看的内容,再提交答案,这种微小的强制展示过程有助于减少幻觉。

它还为审查者提供了具体的检查依据,无需重新阅读原始文本。如果value字段已填充但evidence字段为空,或包含原始文本中不存在的内容,这种不匹配就是数据中出现的幻觉本身。

这可不是免费的。在处理几百条交易消息的批次时,跨模式添加证据字段使输出令牌数量增加了大约三分之一,延迟在流水线规模下也显著增加到需要关注的程度。

对于五位数的邮政编码来说不值得。但如果是涉及财务数据且有人会据此采取行动的信息,那就绝对值得。

生成与验证的边界

到目前为止,模式已经承载了很多内容:通过可为空类型防止其编造信息,通过证据字段确保即使它确实编造了信息也能被捕捉到。

但还有一类错误,既不是上述两种情况能覆盖的,那就是该值是否作为世界事实有意义。

模式保证金额是浮点数,但对这个浮点数是否为负数,或者交易日期是否是未来三天这样的问题,却只字未提。

早期我曾尝试在提示词中解决这个问题,给出类似“金额必须大于零”的指令,现在回想起来,让语言模型执行这样的规则确实有些奇怪。它不是计算器。验证器可以完美地做到这一点,每次都能免费完成:

code
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 must be positive, got {self.amount}")
if self.transaction_date is not None and self.transaction_date > date.today():
raise ValueError(f"transaction_date {self.transaction_date} is in the future")
return self

现在API在生成响应的瞬间就能保证结构,Pydantic在将数据解析为对象的瞬间就能保证数据的合理性,每次都是完全相同的方式,第二次检查完全不需要LLM参与。

当验证器抛出异常时,你有两个选择:将记录交给人工处理,或者将精确的错误信息返回给模型让它重试。我选择了后者,并且硬性限制最多重试两次:

code
MAX_RETRIES = 2
def extract_with_retry(document: str) -> Transaction:
history = [
{"role": "system", "content": "Extract the transaction details."},
{"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("validation failed on attempt %d: %s", attempt, e)
history += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Fix only the bad field."},
]

MAX_RETRIES的硬性限制比看起来更重要。我的第一反应是让它持续尝试,这其实是个错误。两次失败几乎总是意味着源文档本身存在问题,而非提示词的问题,第三次自动重试只会浪费API调用次数,而人类只需十秒就能解决这个问题。

这与 OpenAI 并无特定关联,尽管此处的每个代码块都使用了 OpenAI 的 API。如果你改用 Anthropic 的工具或使用 vLLM 和 Outlines 的自托管设置,Pydantic 模型本身不会有任何变化,唯一改变的只是围绕它的 API 调用。

重新思考提取的目标

当我第一次实现这个功能时,我对成功的标准设得异常低:模型是否能完整填充对象而不破坏解析器。

回顾过去,这个标准完全奖励了错误的事情,因为一个不加辨别地填充所有字段(无论实际内容如何)的模型并不可靠。它只是表现得自信,而这与可靠性是截然不同的,甚至更加危险。

结构化输出(Structured Outputs)确实擅长它们所做的事情。它们只是没有做我最初认为它们应该做的事。它们保证的是结构(shape),而非真实性(truth)。一旦你不再担心括号和引号转义的问题,真正的问题依然在那里等待着你:这个对象中的每个值是否都有实际存在的理由?

这个问题一直是真正的难点。模式(schema)过去只是将它隐藏了起来。