把发票提取 Prompt 纳入 PR 回归检查
教程使用 Promptfoo 与 GitHub Actions 比较两个提示词版本,在每次 PR 中检测提取质量退化。配套仓库包含 20 张合成发票,以及 JSON 格式、Schema、字段精确匹配和自定义 JavaScript 等分层断言。
AI 深度解读
发票提取最麻烦的故障,是结果看起来完全正常:JSON 能解析,字段也齐全,欧元却被记成了美元。原文给出的办法,是把 prompt 纳入回归测试,用 Promptfoo 比较修改前后的输出,再让 GitHub Actions 拦住质量下降的改动。
这套教程值得看的地方,是它把格式、字段和金额分别验证,还提供了不用 API key 的离线方案。但有一个边界必须先讲清:离线流水线全绿,只能证明测试设施能工作,不能证明真实模型不会出错。
JSON 合法,为什么还会把账记错
作者用发票提取串起整个流程:输入发票文本,输出发票号、供应商、日期、币种、明细、小计、税额和总额。原文开头的币种事故是设想场景,没有交代它是一次真实生产事故。
第一版 prompts/v1.txt 只规定输出字段,以及日期使用 YYYY-MM-DD、币种使用 ISO code。第二版 prompts/v2.txt 增加了禁止代码围栏、根据货币符号判断币种、金额输出纯数字,以及兼容 TOTAL 和 Total Due 等要求。
Promptfoo 把 {{invoice}} 当作 Nunjucks 模板变量,由每条用例填入。将两个文件一起放进 prompts:,同一张发票就会分别测试两个版本,形成修改前后的对照。
但对照有没有价值,取决于测试数据。
示例准备了 20 张合成发票,使用虚构供应商和随机明细,税率固定为 7%,金额由生成程序计算。其中两张使用欧元;inv19 把总额标签改成 TOTAL;inv20 则在发票头部前加入一段闲聊文字。后两张分别检查标签变化和无关前文的影响。
这些数据方便公开,也有明确答案。不过,20 张整洁的合成发票覆盖不了真实业务。作者建议把生产中出现的失败样本脱敏后加入 invoices/,让它成为长期保留的回归用例。
测试集的价值,在于保存已经踩过的坑。
断言要检查结果,而不只是检查格式
原文把验证分成四层,前三层可以通过确定性规则执行,第四层按需引入模型评分。
第一层是 is-json,配合 value: file://schema.json 验证结构。这个 schema 要求字段齐全、币种只能是 USD 或 EUR、日期符合 YYYY-MM-DD,并且至少有一条明细。代码围栏、JSON 前的解释文字,以及 "$1,234.00" 这样的金额字符串,都属于它要拦截的问题。
第二层检查已知答案。下面这段直接验证发票号、币种和总额,能抓住开头那类币种错误:
- type: javascript
value: |
const o = JSON.parse(output);
return o.invoice_number === 'INV-1007' && o.currency === 'EUR' && Math.abs(o.total - 1043.29) < 0.01;
关键是最后的 < 0.01:金额比较使用容差,不直接用 === 比较浮点数。不过,这里只精确检查了三个字段,并不代表供应商、日期和每条明细都已经核对。
第三层检查内部算术是否成立。asserts/totals-add-up.js 从数量和单价重新计算小计,再检查小计加税额是否等于总额:
module.exports = (output) => {
const o = JSON.parse(output);
const sum = o.line_items.reduce((s, l) => s + l.quantity * l.unit_price, 0);
const ok = Math.abs(sum - o.subtotal) < 0.01 && Math.abs(o.subtotal + o.tax - o.total) < 0.01;
return { pass: ok, score: ok ? 1 : 0,
reason: ok ? 'totals reconcile' : `sum ${sum.toFixed(2)} vs subtotal ${o.subtotal}` };
};
要留意返回值里的 reason。相比一个布尔值,它能在查看器中给出具体差异,帮助定位漏掉的明细。但算术自洽不等于忠于原文:如果明细和金额一起被改错,仍有可能通过。
第四层是可选的 llm-rubric,让评分模型判断模糊字段。例如,供应商名称是否指向同一家公司,忽略 Inc、LLC 等后缀。它需要真实评分模型和 API key,示例默认关闭。能精确比较的字段先交给规则,确实存在表达差异时再考虑模型评分。
先接通流水线,再让真实模型接受检查
示例提供了一个自定义 JavaScript provider,通过 id() 和 callApi(prompt) 接入,配置为 id: file://providers/local-extractor.js。
它从渲染后的 prompt 中找到 INVOICE,再用正则提取字段,返回 { output: JSON.stringify(out) }。它没有调用 LLM,因此不需要 API key,也没有模型调用费用。
本地执行 npx promptfoo@0.125.0 eval -c promptfooconfig.yaml --no-cache,再运行 npx promptfoo@0.125.0 view,就能查看结果。原文报告,离线 provider 下,20 条用例乘以两个 prompt,共 40 个单元格全部通过。
这 40 个通过,验证的是测试设施和本地解析器。
GitHub Actions 则把评测接到 PR:当 prompts/**、promptfooconfig.yaml、tests.yaml、invoices/**、asserts/** 或 providers/** 变化时触发。工作流使用 actions/checkout@v4、actions/setup-node@v4,将 Node 固定为 22.22,Promptfoo 固定为 0.125.0。示例注明运行环境为 Node 22.22+。
CI 命令是 npx --yes promptfoo@0.125.0 eval -c promptfooconfig.yaml --no-cache -o results.json,并设置 PROMPTFOO_DISABLE_TELEMETRY: 1。随后从 results.stats 读取 successes 和 failures,通过率低于 95% 就退出失败。actions/upload-artifact@v4 配合 if: always(),即使失败也上传 results.json。
--no-cache 用来避免旧缓存掩盖新的行为变化;结果文件则让评审者能直接检查失败输出。
接入真实模型时,作者保留本地基线,再添加 openai:gpt-4.1-mini,设置 temperature: 0,通过仓库 secret OPENAI_API_KEY 向工作流传入密钥。真实模型版本也应固定。
作者估算,20 张短发票、两个 prompt、一个模型,每次是 40 个小请求,使用 mini 级模型的费用远低于 1 美分。原文没有提供实际账单、token 用量或评分模型费用,这个数字只能当作示例估算。
我的判断
这套方法最值得借鉴的,是把业务正确性拆成可执行的断言。Schema 管结构,字段检查管答案,算术检查管内部关系,失败时还能给出定位线索。相比只靠人工看输出,它更容易重复执行。
不过,95% 是作者建议的起点,没有对照实验支持它适用于所有团队。按示例脚本的汇总公式,40 个结果中失败两个仍能达到 95%;继续添加 provider 后,本地解析器的通过结果还会进入总分,可能稀释真实模型的失败比例。对于金额、币种这样的关键字段,我更倾向于单独设为必须通过,再按模型和 prompt 分别检查通过率。
另外,示例触发路径漏了 schema.json。如果只修改 schema,按这份 paths: 配置不会触发评测,落地时需要补上。
原文末尾建议删除币种指令来观察门禁失败,但默认离线 provider 直接解析发票文本,没有测量模型对指令的响应。因此,删除这条指令未必能让离线测试变红;这类实验应接入真实模型后再做。原文没有展示相应失败结果。
把失败样本持续加入测试集的方向很合理;至于 20 条用例、95% 门槛和低温度配置能带来多稳定的效果,目前仍属于教程经验,不能当作生产质量保证。
能拿来做什么
- 如果你的 Node 服务已经用 LLM 提取结构化数据,可以先保存输入、预期关键字段和 schema,把 prompt 改动纳入 PR 评测,并将
schema.json加入触发路径。 - 为金额、币种等高风险字段设置明确断言,同时检查内部算术关系;关键错误应单独拦截,避免被整体通过率覆盖。
- 暂时没有 CI 密钥时,可以先用本地 provider 验证配置、断言和结果上传;接入真实模型后,再评估 prompt 修改是否改善输出。
- 遇到新的生产失败,先脱敏并加入用例,确认测试能复现问题,再修改 prompt;排查时结合
results.json、promptfoo view和自定义断言的reason。