拆.
协议与适配——AI 怎么对接不同 LLM · 第 07

Token 怎么算账

4 种统计方式

4500读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:每个 provider 的 usage 字段差异

我们在 2.3 章讲过 token 经济学——cost 跟 token 紧密相关。但实际计算 cost 比你想象的复杂——因为每家 provider 的 usage 字段不一样

我们看 4 家主流 provider 的 usage 结构。

Anthropic 的 usage

{
  "usage": {
    "input_tokens": 1234,
    "output_tokens": 567,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0
  }
}

4 个字段:

  • input_tokens —— 这次 input 的 token 数(不包含缓存命中的 token)
  • output_tokens —— 这次 output 的 token 数
  • cache_creation_input_tokens —— 这次 input 中写入缓存的 token 数(cache_write,付 1.25x normal price)
  • cache_read_input_tokens —— 这次 input 中从缓存读取的 token 数(cache_read,付 0.1x normal price)

要算总 input cost:

total_input_cost = 
  input_tokens × input_price
  + cache_creation_input_tokens × (input_price × 1.25)
  + cache_read_input_tokens × (input_price × 0.1)

总 input token 数:

total_input_tokens = 
  input_tokens 
  + cache_creation_input_tokens 
  + cache_read_input_tokens

注意——input_tokens非缓存部分。如果你想知道总 input——要加另外两个。

这种分离让你能清楚看到缓存效果——cache_read 占 input 90% 意味着你的 caching 命中率高。

OpenAI 的 usage

{
  "usage": {
    "prompt_tokens": 1801,
    "completion_tokens": 567,
    "total_tokens": 2368,
    "prompt_tokens_details": {
      "cached_tokens": 1200,
      "audio_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "audio_tokens": 0
    }
  }
}

更复杂的结构:

  • prompt_tokens —— input 总数(包含缓存命中的)
  • completion_tokens —— output 总数
  • total_tokens —— 总数(prompt + completion)
  • prompt_tokens_details.cached_tokens —— 其中缓存命中的(折扣价)
  • completion_tokens_details.reasoning_tokens —— o1 模型的 reasoning 消耗(计费但不可见)

要算总 cost:

non_cached_input = prompt_tokens - cached_tokens
total_input_cost = 
  non_cached_input × input_price
  + cached_tokens × (input_price × 0.5)  // OpenAI 缓存 50% 折扣

total_output_cost = completion_tokens × output_price

注意几个差别:

  • prompt_tokens 包含缓存(需要减出来才知道非缓存)
  • 缓存折扣 50%(不是 Anthropic 的 90%)
  • reasoning_tokens 在 completion 里——但用户看不到内容,只收费

Google Gemini 的 usage

{
  "usageMetadata": {
    "promptTokenCount": 1801,
    "candidatesTokenCount": 567,
    "totalTokenCount": 2368,
    "cachedContentTokenCount": 1200
  }
}
  • promptTokenCount —— 类似 OpenAI 的 prompt_tokens
  • candidatesTokenCount —— output(Gemini 叫 candidates)
  • cachedContentTokenCount —— 缓存命中(Gemini 的 caching 用 separate API)

注意:Gemini 用 camelCase(其他 provider 用 snake_case)——adapter 要处理大小写转换。

Bedrock 的 usage

{
  "usage": {
    "inputTokens": 1801,
    "outputTokens": 567,
    "totalTokens": 2368
  }
}

最简单——3 个字段、camelCase、没有 caching 信息。

因为 Bedrock 不太支持 prompt caching(至少在某些 model 上)——usage 里没这个数据。

把这 4 个 provider 的 usage 合起来——你看到的差异:

  • 字段名(snake_case vs camelCase)
  • 字段语义(prompt_tokens 包不包含缓存)
  • 缓存信息(有 vs 无、折扣率不同)
  • 特殊 token(reasoning vs thinking)

这些差异每个都要 adapter 处理——内部统一表示、外部按 provider 解析。

二、案例:opencode 怎么 normalize 4 种 token 统计

R6-04 阶段的研究告诉我们——opencode 在 packages/opencode/src/session/session.ts 有个 getUsage() 函数——把每个 provider 的 usage 翻译成统一格式。

简化版逻辑:

interface NormalizedUsage {
  input_tokens: number       // 总 input(包含缓存)
  output_tokens: number      // 总 output
  cache_read_tokens: number  // 缓存读
  cache_write_tokens: number // 缓存写
  reasoning_tokens: number   // o1 reasoning
  total_tokens: number       // 总和
}

function normalizeAnthropicUsage(raw): NormalizedUsage {
  return {
    input_tokens: raw.input_tokens + raw.cache_read_input_tokens + raw.cache_creation_input_tokens,
    output_tokens: raw.output_tokens,
    cache_read_tokens: raw.cache_read_input_tokens,
    cache_write_tokens: raw.cache_creation_input_tokens,
    reasoning_tokens: 0,  // Anthropic thinking 算 output
    total_tokens: 0  // 算
  }
}

function normalizeOpenAIUsage(raw): NormalizedUsage {
  const cached = raw.prompt_tokens_details?.cached_tokens || 0
  const reasoning = raw.completion_tokens_details?.reasoning_tokens || 0
  return {
    input_tokens: raw.prompt_tokens,  // 已经包含缓存
    output_tokens: raw.completion_tokens,
    cache_read_tokens: cached,
    cache_write_tokens: 0,  // OpenAI 不暴露 write 数
    reasoning_tokens: reasoning,
    total_tokens: raw.total_tokens
  }
}

function normalizeGeminiUsage(raw): NormalizedUsage {
  return {
    input_tokens: raw.promptTokenCount,
    output_tokens: raw.candidatesTokenCount,
    cache_read_tokens: raw.cachedContentTokenCount || 0,
    cache_write_tokens: 0,
    reasoning_tokens: 0,
    total_tokens: raw.totalTokenCount
  }
}

function normalizeBedrockUsage(raw): NormalizedUsage {
  return {
    input_tokens: raw.inputTokens,
    output_tokens: raw.outputTokens,
    cache_read_tokens: 0,  // 不暴露
    cache_write_tokens: 0,
    reasoning_tokens: 0,
    total_tokens: raw.totalTokens
  }
}

每个 adapter 把自家 usage 翻译成 NormalizedUsage。上层代码用统一的字段算 cost:

function calculateCost(usage: NormalizedUsage, model: ModelConfig): number {
  const non_cached_input = usage.input_tokens - usage.cache_read_tokens - usage.cache_write_tokens
  
  return (
    non_cached_input * model.input_price +
    usage.cache_read_tokens * model.cache_read_price +
    usage.cache_write_tokens * model.cache_write_price +
    usage.output_tokens * model.output_price
  ) / 1_000_000  // 价格通常按"百万 token"算
}

这种"normalize 后统一算"让上层代码不感知 provider 差异。

但 normalize 有几个

坑 1:Anthropic 的 input_tokens 不包含缓存

如果你以为 Anthropic 的 input_tokens 是总数——会少算 cache 部分的 token。

input_tokens + cache_creation + cache_read 才是总数。

坑 2:OpenAI 的 prompt_tokens 包含缓存

如果你以为 OpenAI 的 prompt_tokens 不包含缓存——会重复加。

prompt_tokens 已经是总数——直接用。

两家相反——容易搞混。

坑 3:reasoning_tokens 计费但不可见

OpenAI o1 的 completion_tokens_details.reasoning_tokens 是隐藏的 reasoning 消耗——用户付钱但看不到内容。

你的 UI 上显示"output: X tokens" 要不要包含 reasoning?

opencode 的选择是——算在 output 里——因为它就是按 output_price 计费。用户看 output 数字时知道"这是包含 reasoning 的总 output"。

坑 4:Gemini 的 caching 是 separate API

Gemini 的 prompt caching 不是 inline 标记(像 Anthropic)——是单独的 cachedContent API:你先调一次 API 创建缓存、得到 cache ID、后续请求引用 cache ID。

usage 里的 cachedContentTokenCount 只是"这次请求从那个缓存读了多少"——不包含缓存的创建成本(那个是单独 API 的成本)。

如果你的 cost 计算只看 usage——会漏算 cache 创建成本。

需要单独追踪。

坑 5:Bedrock 没有 caching 数据

Bedrock 即使支持 caching(某些 model)——usage 里也不暴露。你不知道 cache 命中率、不知道省了多少。

这是 Bedrock 的透明度缺陷——你的 cost 计算只能"按 input/output 算"——caching 折扣没法 surface 给用户。

这些坑都是 opencode 在维护过程中踩出来的——经过反复 debug 和测试形成 normalize 逻辑。

三、设计启示:cost 计算的真实复杂度

这一章的核心论点:Cost 计算不是"input × price + output × price" 这么简单——每个 provider 的 usage 语义不同,要 normalize

设计 AI 产品时关于 cost 计算,下面几条原则有用:

1. 定义统一的 NormalizedUsage

不要直接用各 provider 的原始 usage——定义一个内部统一结构。

每个 adapter 翻译进内部结构——cost 计算用内部结构。

2. 区分 "non_cached_input" 和 "input_tokens"

input_tokens 是 LLM 看到的总输入(包含缓存)——用于 token budget 计算。

non_cached_input 是非缓存部分——用于 cost 计算。

两者用途不同——分别保存。

3. 缓存折扣率每个 provider 不同

  • Anthropic:cache_read 0.1x normal、cache_write 1.25x normal
  • OpenAI:cache_read 0.5x normal、cache_write 是 normal(隐含)
  • Gemini:单独 API、单独定价

不要假设"所有 provider 缓存都 90% 折扣"——按 provider 配置。

4. reasoning_tokens 单独追踪

OpenAI o1 的 reasoning 占 output token——但用户看不到内容。

UI 上可以分开显示: "Output: 500 tokens (含 reasoning: 300, 可见 text: 200)"

让用户看到 reasoning 消耗。

5. 价格表数据驱动

把每个 model 的价格放配置(如 models.dev、price.json)——不要 hardcode 在代码里。

provider 改价格时——更新配置即可。

opencode 用 models.dev——可以参考。

6. 监控 token 估算 vs 实际

你自己用 4 字符近似估算 input tokens(2.3 章)——实际从 usage 拿真实值。两者差距:

  • 估算误差 5-10% 算正常
  • 误差 > 20% 说明估算算法需要调

监控差距——校准估算。

7. cost 显示要透明

UI 上展示成本——分项展示比总和好:

Input: $0.012 (1,234 tokens × $10/M)
Cache read: $0.001 (1,200 tokens × $1/M)
Output: $0.085 (567 tokens × $150/M)
Total: $0.098

让用户看清"钱花在哪里"——也能审计你的算法对不对。

8. 测试每个 provider 的 cost 准确性

跑一次小 session,自己算 cost、对比 provider 的账单——两者应该差异 < 1%。

如果差距大——你的 normalize 有 bug。

最后一个观察。Cost 计算这件事——看起来工程——实际是产品 UX 的关键。

用户用 AI 产品最焦虑的是"我花了多少钱、为什么花这么多"。如果你的 cost 数字不准、不透明、不细致——用户失去信任、慢慢流失。

opencode 在 cost 计算上的投入(每个 provider 准确 normalize、缓存分项、reasoning 单独追踪)——是产品 UX 的"隐性投资"。

你做 AI 产品也应该投资这块——花时间把 cost 计算做准、做透明。这是用户信任的来源

下一章 6.8 是第 6 篇的收尾——我们看 Prompt Caching 这个 cost 优化机制的三种姿态。