作者为LLM调用构建本地花费上限,但并行负载下拦截器失效导致超支4.2倍。问题根源在于计费管道延迟+Agent重试循环速度超过监管能力,最终通过NODE_OPTIONS注入模块修补fetch解决。
提供商的消费上限什么都拦不住。它们穿着刹车外衣的警报器。
今年有据可查的案例都很惨烈。一位开发者设了 250 美元上限,一觉醒来收到 10138 美元账单。一位启用了异常检测的 AWS 客户,为单次 Bedrock 推理运行支付了 30141 美元——没有触发任何警报。FinOps 团队报告说在年内第四个月就烧完了整年的 token 预算。
这些都不是因为模型贵。是结构性问题:提供商的限额基于延迟数分钟到数小时的计费管道。在最坏情况是一台被遗忘的 EC2 实例 4 美元/小时的时候,这个设计是可以接受的。一个陷入重试循环的 agent 比计费系统能观测到的速度更快。
所以我写了一个本地版本。这是先把它做错的故事,因为它的失败方式比修复方案更有意思。
第一个问题是如何看到所有请求。我想要一个封装器——对用户零代码改动:
burnix --cap 5.00 -- npm run agent
诀窍是 NODE_OPTIONS。当你 spawn 一个子进程时,可以注入一个在任何用户代码之前加载的模块:
const child = spawn(cmd, args, {
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: `--require ${hookPath} ${process.env.NODE_OPTIONS ?? ''}`,
},
});
这个 hook 在 SDK 拿到全局引用之前就 patch 了 global fetch:
const original = globalThis.fetch;
globalThis.fetch = async function (input, init) {
const url = typeof input === 'string' ? input : input.url;
if (!isTrackedHost(url)) return original(input, init);
const res = await original(input, init);
const clone = res.clone();
const body = await clone.json();
recordCost(body.usage);
return res;
};
我验证了 Anthropic SDK 确实先走 globalThis.fetch 再构建其他东西——如果答案是否的话,这三十分钟能省下三天。
对于流式响应,res.body 是一个只能消费一次的 ReadableStream,所以必须用 tee():在重建的 Response 里返回一支给调用者,自己读另一支,然后解析 SSE 获取最终的 usage 事件。
这些都能工作。顺序测试:0.05 美元上限,在第三次调用时阻塞,退出码非零。发货。
然后我并行跑了二十个调用。
await Promise.all(Array.from({ length: 20 }, () => makeCall()));
burnix: session 9k5qws done — $0.2100 spent across 20 call(s)
上限是 0.05 美元。实际消费 0.21 美元。零个调用被拦截。
原因一旦看到就简单得令人尴尬。成本是在每次响应返回后才加入状态的。二十个并行请求在任何一个完成之前都读到了 spent = 0,所以二十个都通过了检查:
t=0ms req 1..20 全部读到 spent=0,全部通过
t=800ms req 1..20 全部返回,每个加上自己的成本
t=801ms spent = 0.21。上限在 750ms 后才触发。
我的顺序测试通过了。功能看起来完成了。而产品唯一的承诺——它会阻止消费——在它存在的精确工作负载下是假的。Agent 会并行调用工具。这就是它的用例。
修复方案是在调用前计费,而不是之后。
预留 —— 估算一个悲观的最高成本,立即加入状态,以 reservation id 为 key
检查 —— 如果 spent + reserved >= cap,释放 reservation 并拒绝
对账 —— 响应到达时,删除 reservation 并加入实际成本
估算值故意设为悲观:
const inputTokens = JSON.stringify(body).length / 4;
const outputTokens = body.max_tokens; // API 不能超过这个值
const worstCase = (inputTokens * inPrice + outputTokens * outPrice) / 1e6;
过度预留让上限提前触发,这是安全的方向。预留不足才是你要修复的 bug。
在 finally 中释放 reservation。泄漏的 reservation 会永久抬高会话的消费额,而你一定会在某个请求抛出时泄漏一个。
这里是让它能工作的细节,而且很容易搞错:
预留步骤必须是同步的。readFileSync、mutate、writeFileSync,在读和写之间没有任何 await。
Node 是单线程的。同步的读-改-写不能被另一个待处理 promise 插入,因为事件循环在阻塞期间没有机会运行任何其他东西。一旦在读取状态和写入状态之间引入 await,你就重新打开了你正在关闭的竞态窗口:
// 有问题 —— await 创建了插入窗口
const state = await readState();
state.reserved[id] = cost; // ← 其他请求在这里运行
await writeState(state);
// 正确 —— 没有让出点
const state = JSON.parse(fs.readFileSync(path, 'utf8'));
state.reserved[id] = cost;
fs.writeFileSync(tmp, JSON.stringify(state));
fs.renameSync(tmp, path); // 原子替换
后续的对账可以是异步的。预留不能。
同样测试的结果:
burnix: session 4saeq3 done — $0.0454 spent across 6 call(s)
六个成功,十四个被拦截,在上限之内。停在 0.0454 美元而非正好 0.05 美元是悲观预留起了作用——它提前触发了,这是正确的。
然后显示开始出问题。
进度条显示的是已结算消费。所以你会看到它爬到 87%,然后:
burnix CAP REACHED request blocked
在 87% 被拦截了。对任何在看的人来说,这都像是坏了。
其实没有——reservation 已经越过了上限,虽然已结算消费还没有。但"技术正确"是没用的,如果用户认为你的工具在骗他们。
修复方案是让 reservation 可见:
total $0.0017 / $0.0020 ██████▒▒ 87% (+$0.0005 in flight)
实心块是已结算消费,阴影块是飞行中的 reservation。现在你看组合后的 bar 达到上限然后拦截,行为就自我解释了。
经验可以推广:当内部状态驱动用户可见的决策时,只展示部分状态会让正确的行为看起来像 bug。
只能拦截 Node 子进程。NODE_OPTIONS hook 无法触及 Python 或 Go 子进程。本地代理会是语言无关的;那是下一步。跨进程竞态收窄了,但没有消除。共享会话的两个独立 Node 进程仍然可以在它们的同步读和写之间插入。窗口是微秒级而不是秒级。
Reservation 以 max_tokens 为 key。如果你的 max_tokens 比典型响应大很多,它会提前触发。试试看:
npm install -g @burnix/cli
burnix --watch -- npm run agent # 只跟踪,不拦截任何东西
burnix --cap 5.00 -- npm run agent # 真正停止
配合 Groq 的免费层使用,测试零成本。MIT 许可:github.com/pr3yrik/burnix
有一个我还没找到好答案的问题:如果你在共享的团队 API key 上运行 agent,你现在怎么算出是谁烧掉了预算?我找到的每个答案都是"看仪表盘",它只告诉你总数,别的什么都没有。