# Step 23: 成本追踪
一句话导读:在 step22 的 token 计数之上叠一层
CostTracker,用「每百万 token 单价」把冷冰冰的 token 数换算成美元,让用户每轮都看得见「这次对话到底烧了多少钱」。
# 一、这一步做了什么(What)
新增 utils/cost.ts 的 CostTracker 类,并接进主循环:
- 内置一张按模型分的价目表(input/output 每百万 token 各多少美元);
- 每次 API 调用后
add(inputTokens, outputTokens),按单价累加进total; /cost查看累计花费,/info与启动横幅同步显示;- 金额四舍五入到 4 位小数(美分级精度),避免浮点长尾。
step22 回答「用了多少 token」,step23 回答「花了多少钱」——两者是同一份 usage 数据的两种视角。
# 二、面试官视角:为什么要做成本追踪?(Why)
面试题:token 预算已经能防止超支了,为什么还要单独做一个成本追踪?只显示 token 数不够吗?
不够——token 数对用户是无感的,美元才是。
| 维度 | 只显示 token | CostTracker 显示美元 |
|---|---|---|
| 用户认知 | "8000 token" 是多是少?没概念 | "$0.12" 一眼知道贵不贵 |
| 模型对比 | Opus/Sonnet token 数看不出差异 | Opus 单价是 Sonnet 5 倍,一算就知道 |
| 决策依据 | 无法据此调整用法 | 看到累计太高会主动换小模型/压缩 |
| 信任感 | 黑盒,不知道在烧钱 | 透明,成本可预期 |
核心动机是成本透明是 AI 工具的基本素养。同样 8000 token,Haiku 和 Opus 的账单能差一个数量级;不同模型 input/output 单价还不一样(output 通常是 input 的 5 倍)。只有把 token 按模型单价换算成钱,用户才能形成「这么用一天要花多少」的直觉,进而主动优化。这也为 step25「按预算自动切换模型」提供了度量基础——没有成本数字,就谈不上成本优化。
# 三、原理:它是怎么工作的(How)
# 完整实现(真实代码)
const PRICES: Record<string, { input: number; output: number }> = {
'claude-sonnet-4-20250514': { input: 3, output: 15 }, // 每百万 token 美元
'claude-sonnet-4': { input: 3, output: 15 },
};
export class CostTracker {
private total = 0;
private model: string;
constructor(model = 'claude-sonnet-4-20250514') { this.model = model; }
add(inT: number, outT: number) {
const p = PRICES[this.model] || { input: 3, output: 15 }; // 兜底默认价
this.total += (inT / 1_000_000 * p.input) + (outT / 1_000_000 * p.output);
}
getTotal(): number { return Math.round(this.total * 10000) / 10000; } // 4 位小数
reset() { this.total = 0; }
stats() { return { total: this.getTotal(), model: this.model }; }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 计费公式
本轮费用 = inputTokens / 1_000_000 × input 单价
+ outputTokens / 1_000_000 × output 单价
累计费用 total += 本轮费用
2
3
单位是「每百万 token 多少美元」,所以要先 / 1_000_000 归一化再乘单价。input 和 output 分开计价是关键——output 通常贵得多(Sonnet 是 3 vs 15),合并成一个数会严重低估「模型话痨」的成本。
# 数据流
callWithTools(...) → { inT, outT }
↓
cost.add(inT, outT) // 按当前 model 的单价累加
↓
budget.add(inT + outT) // 同一份 usage,step22 也用它记 token
↓
/info 或 /cost → cost.stats().total → 显示 "$0.0123"
2
3
4
5
6
7
# 三个设计细节
| 细节 | 做法 | 为什么 |
|---|---|---|
| 价表可缺省兜底 | PRICES[model] \|\| {3,15} | 遇到没登记的模型也不崩,退回 Sonnet 价 |
| input/output 分开 | 两项分别乘不同单价 | output 通常 5 倍贵,合并会失真 |
| 4 位小数截断 | Math.round(total*10000)/10000 | 累加会有浮点长尾,截到美分/厘级足够 |
| model 可切换 | 存 this.model,step25 加 setPrices | 换模型后单价随之变,累计才准确 |
# 四、深入追问(面试常见 follow-up)
Q:为什么 input 和 output 要分开计价,不能用一个平均单价乘总 token 吗? A:不能,因为二者单价差很大(Sonnet input $3 / output $15,5 倍差)。一个话痨模型输出多、input 少,用平均价会严重低估它的成本。分开计价才如实反映「让模型多说话」的代价,也才能指导「让它简洁点」的优化。
Q:total 一直做浮点累加,会不会累积误差?为什么截到 4 位而不是实时截断?
A:内部 total 保留完整浮点精度做累加,只在 getTotal() 输出时才 round 到 4 位——这样每次累加不丢精度,误差不会滚雪球,展示时才取整。若每次 add 都截断,几百轮下来反而会偏。
Q:价表硬编码在代码里,模型涨价了怎么办?
A:这是学习版的简化。真实版会把定价放配置/远端,甚至从 API 元数据拿。硬编码的风险是价格漂移后账单算错——所以 add 里留了 || 默认价 兜底,至少不崩;生产环境应外置价表。
Q:这个成本准吗?和 Anthropic 账单会有出入吗?
A:会有小差异。我们信任 API 返回的 usage,但真实账单还涉及 prompt caching(缓存命中的 input token 单价打折)、批处理折扣等。学习版不建模缓存,所以是「无缓存上限估算」,通常略高于实际。
# 五、踩坑 / 设计权衡
- 信任 usage vs 自己 tokenize:我们直接用 API 返回的 token 数,省事但拿不到「发送前预估」。真实版结合 tokenizer 可以在发请求前就估出成本,用于事前拦截。
- 不建模缓存:真实 cost-tracker(324 行)要处理 cache read/write 的差异化定价,我们这版 18 行只算裸价,是刻意的简化取舍。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
utils/cost.ts(18 行) | cost-tracker.ts(324 行) |
add(inT, outT) 裸价累加 | 额外建模 prompt caching 的读写差价 |
| 硬编码 PRICES 表 | 更完整的多模型价表 + 缓存分档 |
/cost 显示累计 | commands/cost/ 命令 + 更丰富的分项报表 |
| 信任 API 的 usage | 结合 tokenizer 可做发送前预估 |
# 七、一句话总结
CostTracker = 把 token 翻译成美元的收银机:一张按模型分的价目表 + input/output 分开计价 + 浮点累加末尾取整,18 行就让「花了多少钱」变得实时可见,是成本透明的最小实现,也是 step25 成本优化的度量前提。
# 下一节预告
预算会警告、成本能看见,但对话再长总会撞上限。step24 引入对话压缩:把早期对话摘要成一段文字,在不丢关键信息的前提下把上下文「瘦身」。