作者用免费模型从邮件中提取结构化数据,生产环境模型悄然返回不同 JSON 形状,解析器温柔接纳导致数据库静默存储 NULL。关键教训:null 不是异常,测试全绿不代表数据正确。
那张表里的每一行数据看起来都没问题。JSON 解析成功了,worker 记录了成功日志,数据库也提交了一个与源邮件完美匹配的 order_id。唯一的异常是连续十一行订单的 customer_email 都是 null,而整个 pipeline 没有发出任何警报。在我查看之前,到底有多少行数据已经悄悄出错了?
我当时在用免费模型从原始邮件中提取结构化订单数据。流程简单得令人尴尬:发一个 prompt,解析返回的 JSON,插入数据。本地测试时,每个样本返回的格式都完全符合要求。但在生产环境,同样的 prompt 悄然返回了不同的格式,而我的解析器居然还客气地接受了。
一个看起来不像 bug 的症状
null 不是异常。这就是问题所在。我的测试是绿的,日志显示 processed: ok,数据库也开心地把 NULL 存储到了本不该有值的列里。还有多少字段我的解析器在没有任何日志的情况下就原谅了?
如果你曾经调试过一个失败模式是缺失值而非崩溃的系统,你就会知道有多容易去怪数据而不是代码。我第一个怀疑的对象是模型——也许它返回 null 是因为邮件内容模糊不清,也许是因为 prompt 太弱,又或者免费模型本身就是不那么可靠。这个假设听起来很合理,但它完全错了。
第一个怀疑对象是错的
以下是我写的解析器,展示其完整而糟糕的风采:
const email = result.customer_email ?? result.email ?? null;
这行代码才是真正的 bug。它被设计成宽容的,而它通过静默转换为 null 原谅了所有偏差。模型根本没有返回 null——它返回的是完全有效的 JSON,只是格式略有不同,而我的解析器把这种差异转化成了缺失值。模型真的那么不可靠吗,还是我的代码在掩盖真相?
于是我回到原始输出,去看模型实际上返回了什么。
模型实际返回的内容
同样的 prompt,三次不同的响应,都是有效的 JSON:
{ "order_id": "A-1042", "customer_email": "ada@example.com", "line_items": [] }
{ "order_id": "A-1043", "email": "grace@example.com", "line_items": [] }
{ "order_id": "A-1044", "customer": { "email": "lin@example.com" }, "line_items": [] }
三种格式,零解析错误,只有其中一个匹配我的 schema。模型在 customer_email、email 和嵌套的 customer.email 对象之间漂移,而我的 ?? 链把每一个不匹配都转化成了 null。修复方案不是更好的 prompt——而是在边界处建立契约。
捕获问题的技术:格式比对
我不再调试单个失败,而是开始测量分布。技巧是用大量样本运行同一个 prompt,然后比较每个响应的扁平化键集合。任何出现在一个响应中但不在另一个响应中的键都是漂移信号。
function flattenKeys(value, prefix = "") {
if (Array.isArray(value)) {
return value.length ? flattenKeys(value[0], `${prefix}[]`) : [`${prefix}[]`];
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([k, v]) =>
flattenKeys(v, prefix ? `${prefix}.${k}` : k)
);
}
return [prefix];
}
然后按扁平化格式对输出进行分组:
const shapes = new Map();
for (const raw of rawOutputs) {
const keys = flattenKeys(raw).sort().join(",");
shapes.set(keys, (shapes.get(keys) ?? 0) + 1);
}
console.table([...shapes.entries()]);
对二十个样本运行这个方法,谜题就变成了一个分布。在我这里,customer_email 格式出现在约 60% 的响应中,email 格式占 30%,嵌套对象占剩下的 10%。漂移不是罕见的边缘情况——它才是常态,而我的解析器一直都在掩盖它。
一旦有了这个测试工具,流程就很简单了:
修复方案:在边界处验证,而不是在解析器里
宽容的解析器是错误的选择。严格性应该属于边界处,也就是原始模型输出进入系统的地方。我用 schema 验证替换了 ?? 链,拒绝任何不符合契约的内容:
import { z } from "zod";
const orderSchema = z.object({
order_id: z.string(),
customer_email: z.string().email(),
line_items: z.array(z.object({
sku: z.string(),
quantity: z.number().int().positive(),
})),
});
const parsed = orderSchema.safeParse(result);
if (!parsed.success) {
await saveRejectedOutput(rawOutput);
throw new Error(`shape mismatch: ${parsed.error.issues.length} issues`);
}
现在漂移变成了一次响亮的、可见的拒绝,而不是静默的 null。原始输出被保存到拒绝桶中,这样我可以精确检查模型的意思,而错误浮现在监控系统中,而不是藏在数据库列里。实现大约花了一个小时,但它在一周内捕获的格式漂移比旧解析器一个月捕获的还多。
MonkeyCode 在这其中扮演了什么角色
声明:本文是作为 MonkeyCode 产品推广的一部分准备的。
我在 MonkeyCode 的免费模型访问上运行了格式比对工具,主要是因为整个练习的目的就是采样数十个响应,而免费访问让这个行为变得容易合理。免费服务器才是 bug 真正咬人的地方:生产路径没有验证,所以漂移静静地落到了数据库里。但教训是关于边界的——同样的静默 null 无论用免费还是付费模型都会发生。
局限性和谁应该跳过本文
严格的 schema 验证能捕获格式漂移,但无法捕获语义漂移。模型可以返回 customer_email: "not-an-email",Zod 会拒绝它,但它也可以返回一个自信的、格式正确的、完全错误的总数——没有任何 schema 能把你从这种情况中拯救出来。验证是一张安全网,不是正确性的证明。
如果你在做一次性的提取,null 是可以接受的,那这套东西就是杀鸡用牛刀。如果你的模型支持约束解码或原生 JSON 模式,先用那个,把验证当作备用。而且如果你的 pipeline 可以容忍缺失字段,尽管保留宽容的解析器——只是要意识到你是在有意选择静默降级。
JSON 解析成功了。数据仍然是错的。修复方案不是更好的 prompt,也不是更好的模型——而是在边界处建立契约,并建立一个工具来衡量模型多久遵守一次契约。如果你也有一个关于静默 null 躲过绿色测试的故事,我真的很想听听你是怎么捕获它的。