作者的生产管线因模型输出的JSON通过了形状验证却含虚假数据,导致11笔交易关联了不存在的客户ID。教训是验证层应检查数据内容而非仅验证结构,且对AI输出需保留人工复核环节。
上周的报告显示,有十一笔交易关联了一个不存在于 customers 表中的客户 ID。没有抛出异常,webhook 没有失败,每一行日志都显示 processed=true。数据摄取任务完全按照指令行事:解析了模型的 JSON,验证了格式,并提交了记录。
我在 MonkeyCode 的免费服务器上运行这条管道,提取步骤通过其网关调用了一个免费模型。模型的任务是从支持邮件中提取交易详情并以 JSON 形式返回。声明:本文作为 MonkeyCode 产品推广的一部分而撰写。但我即将讲述的这个失败案例并非产品 bug——而是我自己管道中的一个设计缺陷,换作任何返回 JSON 的模型都会遇到。
有趣的是,调试过程我错了三次假设才去看原始响应。让我展示一下这段经历,因为可复用的教训并非"模型会产生幻觉"——而是我的验证层检查了错误的东西。
报告用 LEFT JOIN 将交易与客户关联,并标记了所有客户未解析的行。我就是这样找到那些孤儿的:
SELECT t.transaction_id, t.customer_id, c.name
FROM transactions t
LEFT JOIN customers c ON c.id = t.customer_id
WHERE c.id IS NULL;
返回了 11 行,它们有一个共同点:customer_id 类似 CUST-48291,完美匹配预期格式。正则 ^CUST-\d{4,6}$ 通过了,JSON 是有效的,记录被写入而没有出现任何错误。那么这个错误的 ID 从何而来?
我的第一个念头是报告查询本身,所以用一个已知良好的交易重新运行了它,结果正常。我的第二个怀疑目标是摄取任务与客户同步之间的竞态条件,但时间戳排除了这个可能——孤儿 ID 是在最后一次客户导入数小时后写入的。我的第三个怀疑是截断问题,所以加入了响应长度检查,结果每个响应都是完整的。
然后我终于做了应该首先做的事:为其中一笔错误交易打印了原始模型输出。
{
"transaction_id": "txn_8f3a",
"customer_id": "CUST-48291",
"amount": 129.00,
"currency": "USD",
"timestamp": "2026-08-12T14:22:10Z"
}
JSON 是有效的。类型是对的。格式是对的。客户 ID 是编造的。
生成这笔交易的邮件从未提及客户 ID,而我的提示词告诉模型"如果看起来明显的话就推断客户"。模型自信地推断错了,这是最糟糕的模型失败形式,因为表面上看完全像成功。
管道保持沉默有三个原因,每个都是我自己做的设计决策:
解析器检查了结构和格式但没有检查语义——任何匹配 CUST-\d{4,6} 的字符串都被接受了。
消息在解析后立即被确认,所以事件在任何引用检查之前就消失了。
交易表在 customer_id 上没有外键约束,所以数据库愉快地接受了一个幽灵。
修复:三层验证
我添加了三层,按从最便宜到最贵的顺序捕获失败:
Schema 验证(使用 jsonschema)—— 类型、必填字段和格式模式。
引用检查—— customer_id 必须在记录提交前存在于 customers 表中。
带反馈的重试—— 如果引用检查失败,将验证错误发回给模型一次,要求它纠正输出或返回 null。
这是核心函数,小到可以复制到任何摄取管道中:
import json
from jsonschema import validate, ValidationError, FormatChecker
TRANSACTION_SCHEMA = {
"type": "object",
"required": ["transaction_id", "customer_id", "amount", "currency", "timestamp"],
"properties": {
"transaction_id": {"type": "string", "pattern": "^txn_[a-z0-9]+$"},
"customer_id": {"type": ["string", "null"], "pattern": "^CUST-[0-9]{4,6}$"},
"amount": {"type": "number", "minimum": 0},
"currency": {"type": "string", "minLength": 3, "maxLength": 3},
"timestamp": {"type": "string", "format": "date-time"},
},
}
def resolve_customer(raw: str, known_ids: set[str]) -> dict:
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
return {"ok": False, "stage": "parse", "error": str(exc)}
try:
validate(instance=data, schema=TRANSACTION_SCHEMA, format_checker=FormatChecker())
except ValidationError as exc:
return {"ok": False, "stage": "schema", "error": exc.message}
if data["customer_id"] and data["customer_id"] not in known_ids:
return {
"ok": False,
"stage": "reference",
"error": f"unknown customer {data['customer_id']}",
}
return {"ok": True, "record": data}
带反馈的重试步骤是给模型第二次机会的地方,这也是最让我惊讶的部分:
def ingest_with_feedback(raw: str, known_ids: set[str], model_call) -> dict:
first = resolve_customer(raw, known_ids)
if first["ok"]:
return first
if first["stage"] in ("schema", "reference"):
feedback = (
"The previous JSON failed validation: "
f"{first['error']}. "
"Return the same object with customer_id set to null "
"if you cannot find a real customer ID in the email."
)
second_raw = model_call(raw, feedback)
second = resolve_customer(second_raw, known_ids)
if second["ok"]:
return second
return {"ok": False, "stage": first["stage"], "dead_letter": raw}
model_call 参数是你网关客户端暴露的任何方法;重要的是反馈字符串会被注入到同一个提示词中。当第二次尝试也失败时,原始响应会进入持久化存储的死信队列,而原始消息永远不会被确认。最后这一点比重试本身更重要,因为一条已确认的消息就是一条丢失的事件。
这张表是我实际复用的成果,因为它迫使我在失败发生之前就决定每个失败落在哪里——拿走它并根据自己的管道调整阶段。修复后的那一周,相同的编造 ID 再次出现,重试将其纠正为 null,记录连同原始邮件一起被搁置。现在报告显示的是一个可见的缺口,而不是一个无声的幽灵。
局限性和谁不应该使用这个方案
带反馈的重试需要一次额外的模型调用,在免费层级上这意味着要预算好速率限制——我之前遇到的重试风暴教会我把循环上限设为一次重试,而不是三次。如果你的模型是非确定性的且反馈循环不收敛,你需要的是人工审核队列,而不是更多重试。如果客户和交易在同一批次中创建,引用检查必须在批次完成后运行,而不是逐条记录。如果你的用例可以容忍错误的 ID,那么整个这一层都是过度设计——加上能捕获你实际看到的失败的最便宜检查即可。
格式有效不等于正确,而这两者之间的差距正是无声数据损坏生存的地方。拯救我的调试步骤是:用已知良好的记录复现;在责怪解析器之前先打印原始响应;在数据被写入的同一层添加语义检查。下次当你的模型输出"看起来有效"时,问自己:它怎样才会在你的检查无法看到的方式下出错——然后写那个检查。还有多少其他管道在数据实际正确之前就确认了消息?