构建AI发票助手时,货币计算全部交给SQL和decimal.js,模型只负责理解问题和叙述结果,避免浮点误差和幻觉数字。
上传一份发票 PDF 到聊天机器人,问:"第二季度我在软件上花了多少钱?单位用欧元。"
你会得到一个自信的回答。供应商名称看起来没问题。图表看起来没问题。然后你打开实际文件,发现总额是错的,货币被混在一起了,而且某处 0.1 + 0.2 变成了 0.30000000000000004——因为这就是浮点数学的结果,模型还很愉快地把它写成了一句话。
这就是我着手解决 Invoice Assistant 的问题:一款模型永远不会做数学运算的聊天应用。它从不加钱,从不编造汇率,从不凭空捏造分类。屏幕上显示的每一个数字都来自真实代码——SQL、decimal.js、一份带日期的 FX 表格——模型只是做旁白。
以下是它的工作原理、我的 evals 捕捉到了什么,以及运行成本。

核心思想:模型负责路由,代码负责计算
语言模型在这份工作上真正擅长的部分:理解你在问什么。哪个文件?哪个日期范围?哪种货币?这是路由,模型很擅长。
模型不是账本。金钱计算涉及浮点数、混合货币(MAD、EUR、USD)、摩洛哥增值税税率,以及必须与实际 PDF 匹配的总额。这些是执行问题,不是写作问题——所以应该被执行,而不是被写作。
应用使用 Next.js + AI SDK 的 streamText、Postgres 数据库和六个工具。系统提示词直白地说明分工:始终调用工具,工具结果是真相来源。
extractInvoice 使用 generateObject 填充 Zod schema:供应商、日期、总计、行项目,以及一个不可读标志。这个标志很重要——当扫描件是空白或乱码时,模型必须说出来,而不是凭空捏造 INV-0000。
即便如此,我不会盲目信任输出。应用会将行项目与声明的总计进行对账,并在保存前要求人工审核。结构化输出是 schema 加上二次校验。
calculate 完全不调用模型。它就是纯 decimal.js。当您提出支出问题时,工具链在单轮中串联——queryInvoices → calculate → convertCurrency——循环上限为八步(最后一步仅文本,所以不会失控)。如果工具抛出错误,模型会收到 { error } 并被告知不要编造替代品。
在语言有帮助的地方使用生成。在 IEEE-754 不适用的地方使用函数。
还有一件事:上传的 PDF 被视为敌对的。提取的文本被包装在 <<<UNTRUSTED_DOCUMENT>>> 分隔符中,因此包含注入的发票只是数据,绝不会是指令。
"生成式 UI"听起来像是模型写 React。它不是——也不应该是。
实际发生的事情:每个工具的类型的输出作为消息部件流式传输到客户端,客户端已经知道如何渲染每种类型。服务器运行 streamText 并通过 toUIMessageStream 管道传输。客户端使用 useChat<InvoiceAssistantUIMessage>——一个由六个工具的输入和输出类型参数化的 UIMessage。因此当 TypeScript 在 output-available 状态中看到 type: "tool-generateReport" 的部件时,它知道载荷是 GenerateReportResult。
每个工具部件经历三种状态,UI 对每种状态作出反应:
input-streaming / input-available → 状态芯片("正在提取发票…"、"正在生成报告…")
output-available → 用相同的 Zod schema 重新校验,然后渲染正确的组件
output-error → 显示错误信息,绝不显示假图表
组件切换是整个诀窍:提取 → 带"审核"按钮的发票卡片、查询 → 表格、报告 → 带 CSV 导出的柱状图/饼图、计算 → 数学卡片、转换 → 汇率+日期、分类 → 标签+原因。如果 JSON 格式损坏,错误边界会捕获它并显示一行回退,而不是让线程崩溃。
模型仍然会在小组件旁边写一段简短回答——如果你用英语写它就用英语,用法语或阿拉伯语写它就用法语或阿拉伯语。但小组件里的数字绝不是来自那段文字。它们来自 Postgres 和 decimal.js。助手是工具 I/O 上的旁白,不是套了聊天外壳的计算器。
单元测试覆盖了货币数学和注入 fixture。但它们无法证明的是,线上 agent 是否真的会在有人问"250 的 20%"时调用 calculate——或者它是否会拒绝写一个 Gmail 爬虫。
为此,我用 promptfoo 针对真实的 /api/chat 端点运行测试,使用带种子数据的发票数据库(npm run eval)。合并到 main 分支时,如果测试套件失败则阻止部署。
为确保测试套件不是橡皮图章,我还用一个故意写得很弱的提示词运行它(允许心算、允许跑题)。在 2026-08-20:
强提示词:19/19。弱提示词:10 个标记案例中失败 4 个。这个差距就是重点——它证明断言确实可以失败。
两个发现让我意外:
有些算术是"顽固"的,有些不是。即使是弱提示词也为著名的 0.1 + 0.2 bug 调用了工具——但"250 的 20%"却用头脑算了。如果你只测著名案例,就会把增值税留在脑子里。
注入防御是分层的。包装 PDF 的防御在弱提示词下站住了;聊天级别的越狱没有。分别测量两层。
还有一条我现在认为不可或缺的规则:一个说得流利的"220 USD"但从未调用 queryInvoices 就是失败,即使数字恰好是对的。你在评估的是一个 agent,不是散文。
在 Claude Haiku 4.5 上对 214 场本地对话的测量,按标价(每百万输入/输出 token 1 美元/5 美元;缓存读取 0.10 美元,5 分钟缓存写入 1.25 美元):
几件事保持低成本和稳定:
静态系统提示词被缓存;每次上传的文件 ID 放在第二个未缓存的系统消息中,所以缓存不会断裂。
每条助手消息都存储其 token 计数和美元成本;同样的明细流向 Langfuse。
速率限制:每用户每分钟 20 个聊天请求,每天 200k token。
Haiku 是默认选项,因为这个工作负载是许多小的路由步骤;只有提取使用更智能的模型层级。
在提供商 429/5xx 错误时:重试,然后回退到 OpenAI——无需重建。
工具优于更聪明的提示词。弱提示词的 canary 测试失败的地方,正是基于文本的防御总是失败的地方:工具使用、拒绝、越狱。结构获胜。
结构化输出 = schema + 二次校验。generateObject 保证的是形状,不是真相。与行项目不一致的总计、不可读的扫描、超出枚举范围的分类,仍然需要后处理和人工审核步骤。
生成式 UI 是类型的工具部件,不是模型选择组件。参数化 UIMessage,按 part.type 和 part.state 切换,客户端重新校验,始终保留文本回退。
评估你发布的 agent。打到真实端点。断言工具名称和金额以及 canary。在测试套件中保留弱提示词,这样绿色运行才有意义。把它放到部署路径上。
限制循环并隔离不可信字节。最多八步,最后一步仅文本,上传时做魔术字节检查,PDF 文本加分隔符。金融 agent 作为系统失败——失控循环、提示注入、浮点数学——的频率远高于作为写作者失败。