作者记录了一次LLM驱动的发票提取系统迁移后,同样的测试用例从100%通过降为98%,两处失败都是由于模型预测的加法token与数学正确结果差1分钱——LLM并不做计算,而是预测token序列。
AssertionError:总和不匹配:期望 119940,实际 119939。在大约两百张发票中有一张差了一分钱,而提示词本身没有任何改动。提取是正确的,每一行项目都是正确的,但总和不对。
症状:差了一分钱
提示词要求模型读取发票 PDF,返回行项目并返回含增值税的总价。它曾经工作正常,在旧模型上通过了上百个 fixture 样例,一年前上线。迁移之后,同样的 fixture 样例通过率变成了百分之九十八,而两次失败都是舍入问题:增值税行是从每项舍入后的金额计算出来的,而非从舍入后的总和计算;以及一个少了一分的总价。
有两件事让这个错误率看起来比实际情况更严重。首先,这个错误是合理的——总价差一分钱看起来完全不像故障,所以它能通过审查。其次,在实用意义上它不是确定性的:同一输入在温度为零时产生相同的错误答案,所以重新运行不会暴露它,任何想推脱的人都可以说"无法复现"。
模型并不是在计算一个总和。它是在预测一个总和的 token,而这些 token 是否正确是模型的属性——取决于模型是如何训练的、它的 tokenizer 如何处理数字序列、以及它愿意在中间步骤上投入多少。这些都不是你的提示词能控制的,而且在不同模型之间也不稳定。能可靠地完成十一位数乘法的模型和不能的模型,可能是同一供应商的相邻版本。
人们通常会想到的三种做法,以及每种实际能带来什么:
温度为零。让答案可重复。但不会让它正确。它把间歇性失败变成了受影响输入的永久性失败,这可以说是一种改进,因为至少是可以发现的。
额外推理。要求模型逐步工作,或启用推理模式,会改变错误率。它不会建立一个有界的保证,而且新的错误率依然是模型属性,所以必须在下一个模型上重新测量。这是一个有持续成本的缓解措施。
"仔细处理算术"。消耗 token,但改变不了任何你能测量的东西。删掉它。
持久有效的做法是意识到这是一个有确定答案的任务,而你拥有一台计算机。
从 schema 中移除计算字段。不要把它们设为可选——直接移除,这样模型就没有地方放总价,没有任何指令能把总价放回去:
{
"type": "object",
"required": ["currency", "line_items", "vat_rate_bps"],
"properties": {
"currency": { "enum": ["EUR", "GBP", "USD"] },
"vat_rate_bps": { "type": "integer", "minimum": 0, "maximum": 10000 },
"line_items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["description", "quantity", "unit_price_minor"],
"properties": {
"description": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 },
"unit_price_minor": { "type": "integer" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
// 唯一产生总价的地方。
function totals(doc) {
const net = doc.line_items.reduce(
(acc, li) => acc + li.quantity * li.unit_price_minor, 0,
);
const vat = Math.round((net * doc.vat_rate_bps) / 10000);
return { net_minor: net, vat_minor: vat, gross_minor: net + vat };
}
这个 schema 中有两个细节在做静默的工作。每一个金额都是最小单位的整数,所以任何地方都不会有浮点舍入;增值税税率是基点而不是带小数的百分比,原因相同。必须输出 0.21 的模型和必须输出 2100 的模型会以不同的方式失败,而只有其中一种失败会被整数类型检查捕获。
留给模型的是转录和分类,这才是它擅长的。舍入规则——对总价而非每行进行增值税舍入——现在是一行财务人员可以直接查看的代码,而不是模型以某种没人写下来的理由正确执行的某种行为。
有时候数字确实是源文档中的,任务是读取它而非计算它——发票上写明了一个总价,你要的是那个总价。这时保留字段,但把它当作需要核对而非信任的值:
const stated = doc.stated_gross_minor; // 从文档中读取
const computed = totals(doc).gross_minor; // 从行项目派生
if (stated !== computed) {
// 不要选其中一个。分歧本身才是发现。
metrics.inc("invoice_total_mismatch");
return {
status: "needs_review",
delta_minor: stated - computed,
stated, computed,
};
}
使这个规则有效的前提是:不匹配永远不会被静默解决。选标注的总价因为"文档是权威的"会隐藏一次糟糕的提取;选计算的总价会隐藏一个遗漏的行项目。两者都是真实存在的,你无法在函数内部区分它们,所以要把结果和差额一起转给人工。在这样做之前,一次移除算术指令的重试是合理的;两次就是一个伪装成修复的重试循环。
求和是显而易见的例子。同样的可靠性问题适用于人们忘记也是算术的每一种操作,而这些正是能通过迁移审查的那些,因为没人想到要测试它们:
比较和排序。"这三份报价哪个最便宜"、"按日期排序"、"是否超过限额"。都是可计算的,都是通过大量文本的提示词悄悄委托给模型的。
计数。"有多少项目提到了 X"。要列表;数它的长度。
日期算术。"是否在三十天内"涉及一个日历、一个时区和对"天"的定义,模型在这些方面和你的数据库没有共识。
单位转换。尤其是在货币之间,汇率是一个有时间戳的事实,而模型没有这个信息。
所有这些的测试都是同一个问题:能否用一个函数从模型能转录的字段中计算出结果?如果可以,它就应该永久地放在那里,无论当前模型做得多好。
对于你决定保留的算术,迁移意味着重新测量,而在这里样本量比大多数检查更重要,因为涉及的概率接近于一。模型在 200 个 fixture 样例中对 199 个,和对 197 个,在这个样本量下是无法区分的——符合度评分页面中的推导给出了算术,而其中的配对设计使比较变得可负担。
生成 fixture 样例而不是收集它们。算术 fixture 是合成数据优于生产数据的罕见情况:你可以列举困难情况(中间值舍入的值、让总数超过位数边界的数量、产生循环小数的税率),而不是等待它们发生。把它们和迁移 fixture 数据集的其余部分放在一起——参见"迁移提示词测试数据集以覆盖新提供商"——并给它们打上标签,这样失败时命名的是算术而非文档。
延伸阅读
Testing Backward Compatibility When You Add a Field to an Output Schema
JSON Mode vs Structured Outputs vs Grammar Constraints
Migrating an Internal Prompt Testing Dataset to Cover a New Provider