# Step 23: 成本追踪

一句话导读:在 step22 的 token 计数之上叠一层 CostTracker,用「每百万 token 单价」把冷冰冰的 token 数换算成美元,让用户每轮都看得见「这次对话到底烧了多少钱」。


# 一、这一步做了什么(What)

新增 utils/cost.ts 的 CostTracker 类,并接进主循环:

  1. 内置一张按模型分的价目表(input/output 每百万 token 各多少美元);
  2. 每次 API 调用后 add(inputTokens, outputTokens),按单价累加进 total;
  3. /cost 查看累计花费,/info 与启动横幅同步显示;
  4. 金额四舍五入到 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 }; }
}
1
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 += 本轮费用
1
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"
1
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 引入对话压缩:把早期对话摘要成一段文字,在不丢关键信息的前提下把上下文「瘦身」。