文章演示如何利用API返回的四类用量字段,分别计算输入、输出、缓存创建和缓存读取成本。它还强调模型费率应从供应商定价页动态维护,避免粗略统计失真。
书籍:AI That Answers
系列:《AI in TypeScript》——共 5 本,从第一次调用 LLM 到在生产环境中运行 Agent——全部五本都在这里。
我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 和其他 AI 编程工具交付产品的开发者 IDE。
关于我:xgabriel.com | GitHub
你的 Node 服务会为每一次对外 HTTP 调用记录耗时和状态码。早在你为系统加入任何 AI 功能之前,它就已经这么做了。因此,当你加入 LLM 调用时,这套监控机制也自然沿用了下来。现在,你可以准确地告诉我模型响应花了多长时间,却完全不知道这次调用花了多少钱。
你只能等到月底,看到一个涵盖所有调用的总金额。
这种信息缺口很奇怪,因为所需的数据明明就在那里。每个响应都带有一个 usage 对象。之所以没人读取它,是因为在过去十五年的后端开发中,延迟一直是最值得衡量的指标,而这个习惯也被延续了下来。
响应里其实已经有了。
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
messages,
});
console.log(res.usage);
// { input_tokens, output_tokens,
// cache_creation_input_tokens, cache_read_input_tokens }
这里有四个数字,而后两个比大多数人想象的更重要。缓存输入与新输入的计费方式不同——这正是 prompt caching 的全部意义所在。因此,如果你的实现只是把 input_tokens 和 output_tokens 相加,再乘以同一个费率,最终得到的数字就会是错的,而且这种偏差足以影响决策。
应该把这四个字段视为四个独立的计费项,并分别使用四种不同的费率。
在编写代码之前,首先要把这部分处理正确,因为这正是此类文章最容易误导读者的地方。
每 Token 的价格会发生变化。不同模型的价格不同,不同提供商的价格不同,过去两年里价格已经调整过多次,而且还有一些只适用于特定类别的折扣,例如 batch 和缓存读取折扣。博客文章中写下的任何价格都只是某一时刻的快照;写进源代码里的价格同样如此,只不过十八个月后可能仍然有人在相信它。
所以,请从提供商的定价页面读取当前价格,将其放入配置,并对配置进行版本管理。
// rates.ts — values are per 1M tokens, in USD.
// Source: your provider's pricing page. CHECK CURRENT PRICING;
// these keys exist so the numbers live in one reviewable place.
export type Rates = {
input: number;
output: number;
cacheWrite: number;
cacheRead: number;
};
export const RATES: Record<string, Rates> = {
"claude-opus-5": loadFromConfig("claude-opus-5"),
"claude-sonnet-5": loadFromConfig("claude-sonnet-5"),
};
这里有两个值得保留的特性。第一,遇到缺失的模型时应该抛出异常,而不是默认使用零费率——悄无声息地返回零,会让一个未知模型看起来像是免费的,而你不会察觉。第二,配置中应该包含生效日期,这样查询历史成本时,就不会使用今天的价格重新计算。
有了费率,成本计算就只是简单的算术。
export function costOf(model: string, u: Usage): number {
const r = RATES[model];
if (!r) throw new UnknownModel(model);
return (
(u.input_tokens * r.input +
u.output_tokens * r.output +
(u.cache_creation_input_tokens ?? 0) * r.cacheWrite +
(u.cache_read_input_tokens ?? 0) * r.cacheRead) / 1_000_000
);
}
这就是全部的计算逻辑。真正有意思的工程问题并不在这里,而在于如何确保每一条执行路径都会调用这个函数。
如果一个辅助函数需要靠你记得去调用,那么在工期紧张时新增的代码路径里,它迟早会被漏掉。更好的做法是直接包装客户端。
type Meter = (e: {
model: string;
usage: Usage;
costUsd: number;
ms: number;
}) => void;
export function metered(client: Anthropic, meter: Meter) {
return {
async create(params: MessageCreateParams) {
const started = performance.now();
const res = await client.messages.create(params);
meter({
model: params.model,
usage: res.usage,
costUsd: costOf(params.model, res.usage),
ms: performance.now() - started,
});
return res;
},
};
}
现在,每一次通过 metered(...) 发起的调用都会被计入成本,而且新的调用位置也无法因为开发者忘记操作而绕过计费。应该从模块中导出包装后的客户端,不要导出原始客户端。

单次调用的成本数字本身还不够实用。你真正需要的是每个请求、每个用户、每项功能的成本——这意味着,无论一个 HTTP 请求发起多少次模型调用,都要将它们的成本累计起来。
AsyncLocalStorage 是 Node 为此提供的原生机制。它能让你拥有请求级状态,而不必在每个函数签名中逐层传递 context 对象。
import { AsyncLocalStorage } from "node:async_hooks";
type Ledger = { costUsd: number; calls: number };
export const ledger = new AsyncLocalStorage<Ledger>();
export function costMiddleware(req, res, next) {
const entry: Ledger = { costUsd: 0, calls: 0 };
res.on("finish", () => {
logger.info("request cost", {
route: req.route?.path,
userId: req.user?.id,
costUsd: +entry.costUsd.toFixed(6),
calls: entry.calls,
status: res.statusCode,
});
});
ledger.run(entry, () => next());
}
然后,meter 会将数据写入当前处于活动状态的 ledger:
const meter: Meter = (e) => {
const entry = ledger.getStore();
if (entry) {
entry.costUsd += e.costUsd;
entry.calls += 1;
}
};
保留六位小数,而不是两位。单次调用的成本经常不到一美分,如果每次调用都四舍五入到美分,真实的数字就会变成一整列零。
一旦每个请求都携带成本信息,那些原本无法回答的问题,现在只需要一条查询语句。
哪条路由的单次调用成本最高——不是总成本,而是每次调用的成本。这通常意味着某个 prompt 所做的工作已经超出了该功能本身的合理需求。
哪些用户的成本最高。在按席位收费的产品中,如果少量用户贡献了很大一部分支出,那就是一个定价问题;没有这些数字,你甚至无法开始讨论这个问题。
每次成功结果的成本。如果一个请求在调用模型三次之后以 4xx 状态结束,那么这些调用就是纯粹的损失,而且它们根本不会在总支出图表中显现出来。
还有缓存读取量与新输入量之间的比例,它能告诉你 prompt caching 是否真的发挥了作用。你原以为缓存命中率很高,实际却并非如此——这是一个常见且代价高昂的意外。

不要为了在调用前预测成本,而在客户端使用 tokenizer 估算 Token 数量。这种做法看似诱人,却会逐渐产生偏差:tokenizer 版本会变化,系统会注入 system prompt,工具定义也会计入输入量,而且缓存片段的计费方式不同。usage 对象才是提供商实际向你计费的依据。应该测量它。
提前估算在限制请求规模时是合理的,例如拒绝发送一个明显过大的请求。但它不能替代对实际调用结果的测量。
《AI That Answers》完整讲解了第一个 LLM 应用中的成本问题——每种 Token 类别分别意味着什么、缓存会在哪里改变计算方式,以及如何在真正需要成本核算之前就把它构建进系统,而不是等账单到来后再补救。

关于 Agent 循环的成本上限——在这种场景下,问题会变得更加尖锐——将在第五本书中介绍。完整系列位于 xgabriel.com/ai-in-typescript。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。