全部文章 笔记与思考

Token 是怎么计算的:AI 厂商和中转站如何统计用量

从 tokenizer、上下文窗口、输入输出 token、流式响应、工具调用、多模态计费到中转站用量记录,系统讲清楚 AI 服务为什么按 token 计费,以及平台如何统计用户消耗。

Token 是怎么计算的:AI 厂商和中转站如何统计用量

用大模型 API 时,经常会看到 prompt_tokens、completion_tokens、total_tokens 这些字段。很多人第一反应是:“Token 是不是就是字数?”不是。Token 是模型真正处理文本时使用的基本编号单元,厂商和中转站的用量统计,本质上就是围绕这些编号单元做输入、输出、缓存、工具调用和账单记录。

Token 用量统计流程

先给出一句话版本:

问题 简短答案
Token 是什么 模型 tokenizer 把文本切分后得到的编号单元
Token 等于字数吗 不等于。中文、英文、标点、空格、代码都会有不同切法
厂商怎么统计 请求进入模型前统计输入 token,生成时统计输出 token,结束后返回 usage
中转站怎么统计 优先读取上游厂商返回的 usage,同时自己预估、限额、落库、扣费
为什么我本地算的不一样 tokenizer 不同、隐藏模板、工具 schema、RAG 上下文、多模态、重试和缓存都会带来差异

一、Token 到底是什么

大模型并不是直接理解“字”或“词”。在进入模型前,文本会先经过一个叫 Tokenizer 的组件,被切成一串 token,然后每个 token 再映射成数字 ID。

flowchart LR A[原始文本] --> B[Tokenizer 切分] B --> C[Token 序列] C --> D[Token ID] D --> E[模型推理]

比如用户输入:

1
帮我写一个 Dockerfile

在 tokenizer 看来,它可能会被切成类似这样的片段:

1
["帮", "我", "写", "一个", " Docker", "file"]

这只是示意,不同模型的 tokenizer 结果可能完全不同。有的模型会把常见词切成一个 token,有的会把生僻字、特殊符号、空格、换行或代码符号切得更碎。

因此:

  • hello 可能是 1 个 token;
  • hello world 可能是 2 到 3 个 token;
  • 一个中文词可能是 1 个 token,也可能被拆成多个 token;
  • 代码、JSON、Base64、日志堆栈通常比较费 token;
  • 空格、换行、标点也可能被计入 token。

更准确地说,Token 是 “模型词表里的最小可识别片段”。模型每次生成回答,本质上也是一个 token 一个 token 地往外吐。

二、为什么大模型要按 Token 计费

因为大模型的主要成本和 token 数强相关:

  1. 输入越长,模型要读的上下文越多;
  2. 输出越长,模型要生成的步骤越多;
  3. 上下文越长,显存、缓存和注意力计算压力越大;
  4. 如果启用工具调用、多模态、长上下文、推理增强,成本结构还会更复杂。

所以厂商通常不会简单按“请求次数”计费,而是按 token 计量。

常见账单可以抽象成:

1
2
3
费用 = 输入 token 数 * 输入单价
     + 输出 token 数 * 输出单价
     + 缓存 / 推理 / 图片 / 音频 / 工具等附加 token 成本

为什么输出 token 往往更贵?因为输出不是一次性算出来的,而是模型逐步生成。每生成一个新 token,都要基于已有上下文继续推理一次。输入阶段可以有缓存和批处理优化,输出阶段通常更受实时生成过程约束。

三、一次请求里哪些内容会被计入 Token

很多人只会数用户输入的那句话,但真实 API 请求里远不止这些内容。

内容 是否通常计入 说明
用户消息 是 用户真正输入的问题
系统提示词 是 system 或平台预置角色设定
开发者提示词 是 一些平台会额外注入规则或安全策略
历史对话 是 多轮聊天时,前面的上下文也会重新进入模型
RAG 检索片段 是 从知识库召回后拼进 prompt 的内容
工具定义 是 Function calling / tool schema 往往很占 token
工具调用结果 是 工具返回的 JSON、表格、日志也会进入后续上下文
输出文本 是 模型生成的回答
图片、音频、视频 视模型而定 通常会折算成专门的输入或输出计量单位
缓存命中内容 视厂商而定 有的平台会单独返回 cached tokens,并按更低价格计费

所以你看到的一次“简单提问”,在服务端可能是这样的:

1
2
3
4
5
6
7
8
系统提示词
+ 平台安全规则
+ 历史对话
+ 用户当前问题
+ RAG 检索内容
+ 工具 schema
+ 输出格式约束
= 实际输入给模型的 prompt

这也是为什么同样一句“帮我总结一下”,在不同应用里消耗 token 差异很大。聊天框、知识库问答、Agent、代码助手、联网搜索,它们背后拼进去的上下文不一样。

四、厂商是怎么统计 Token 的

AI 厂商统计 token,通常发生在模型服务的几个关键节点。

sequenceDiagram participant C as Client participant G as API Gateway participant T as Tokenizer participant M as Model Server participant B as Billing C->>G: 发送请求 G->>G: 鉴权、限流、检查上下文长度 G->>T: 对输入消息做 tokenization T-->>G: input token count G->>M: 输入 token ID M-->>G: 逐 token 生成输出 G->>G: 统计 output token count G->>B: 写入 usage 和账单事件 G-->>C: 返回 response + usage

大致流程是:

  1. 请求进入网关:先鉴权,确认 API Key、组织、项目、余额、限速策略;
  2. 规范化请求:把 messages、tools、response format、多模态内容等转成模型内部格式;
  3. 输入计数:用该模型对应的 tokenizer 计算输入 token;
  4. 上下文检查:判断输入 token + 允许输出 token 是否超过模型上下文窗口;
  5. 模型生成:模型每生成一个 token,服务端就能记录输出 token;
  6. 结束汇总:生成完成、被 stop 截断、达到最大输出长度或发生错误后,汇总 usage;
  7. 账单落库:把模型、用户、请求 ID、token 数、价格、状态写入计费系统;
  8. 返回结果:API 响应里通常会带 usage 字段,供调用方记录。

一个典型 usage 结构可能长这样:

1
2
3
4
5
6
7
{
  "usage": {
    "prompt_tokens": 1200,
    "completion_tokens": 350,
    "total_tokens": 1550
  }
}

更复杂的平台还可能拆得更细:

1
2
3
4
5
6
7
8
9
{
  "usage": {
    "input_tokens": 1200,
    "output_tokens": 350,
    "cached_input_tokens": 800,
    "reasoning_tokens": 120,
    "image_input_tokens": 240
  }
}

字段名和含义会随厂商变化,但底层逻辑类似:把模型实际处理过的输入和实际生成过的输出,折算成可计费单位。

五、流式输出怎么统计

流式输出看起来像模型一边想一边发,其实服务端仍然知道自己生成了多少 token。

普通非流式响应:

1
模型生成完整回答 → 统计输出 token → 一次性返回 response + usage

流式响应:

1
2
3
4
5
模型生成 token 1 → 发一个 chunk
模型生成 token 2 → 发一个 chunk
模型生成 token 3 → 发一个 chunk
...
模型结束 → 返回最终 usage 或由服务端内部记录

中转站处理流式请求时通常会做两件事:

  1. 边转发 chunk 给客户端,保证用户体验;
  2. 同时缓存或累积输出文本,等上游结束后记录用量。

如果上游在最后一个 chunk 里返回 usage,中转站就直接使用它。如果上游不返回 usage,中转站可能会用 tokenizer 对完整输出再算一次,作为估算值。

流式计费最容易出问题的地方是断连:

场景 通常怎么处理
用户主动停止生成 已经生成的输出 token 仍可能计费
客户端网络断开 上游可能还生成了一小段,取决于中转站是否及时取消请求
中转站转发失败 要看上游是否已经成功处理,以及中转站的计费策略
请求超时 可能只有输入被处理,也可能已有部分输出

因此严谨的中转站不会只根据“客户端收到了多少字”扣费,而会尽量以 上游 usage 或服务端实际生成记录 为准。

六、中转站是怎么统计用户用量的

中转站可以理解成站在用户和上游模型厂商之间的 API 网关。它要做的不只是转发请求,还要做鉴权、限流、余额、计费、渠道选择和审计。

flowchart TD A[用户请求] --> B[中转站鉴权] B --> C[余额和额度检查] C --> D[预估输入 token] D --> E[选择上游渠道] E --> F[转发给模型厂商] F --> G[读取上游 usage] G --> H[按价格表计算成本] H --> I[扣用户余额或额度] I --> J[写入用量流水] J --> K[返回结果给用户]

中转站一般会有三层统计:

层级 作用 准确性
请求前预估 判断是否超上下文、余额是否足够、是否限流 估算
请求后结算 根据上游 usage 或本地 tokenizer 计算真实消耗 更接近真实
离线对账 和上游账单、渠道日志、用户流水做一致性校验 最终核对

1. 请求前:预估 Token

请求还没发给上游时,中转站通常已经要做判断:

  • 这个请求会不会超过模型上下文窗口?
  • 用户余额够不够?
  • 是否超过每分钟 token 限额?
  • 是否命中某个模型或渠道的风控限制?

这时它会用对应模型的 tokenizer 先估算输入 token:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
model = request.model
tokenizer = tokenizer_registry.get(model)

normalized_prompt = render_messages(
    system=request.system,
    messages=request.messages,
    tools=request.tools,
    response_format=request.response_format
)

input_tokens_estimated = tokenizer.count(normalized_prompt)

if input_tokens_estimated + request.max_output_tokens > model.context_window:
    reject("context length exceeded")

if user.remaining_quota < estimate_cost(input_tokens_estimated, request.max_output_tokens):
    reject("insufficient quota")

这里的 render_messages 很关键。真实模型不是只看用户输入的字符串,而是看消息角色、分隔符、工具定义、输出格式等拼接后的内部 prompt。中转站如果只数 messages[-1].content,一定会低估。

2. 请求后:读取 usage 并扣费

请求完成后,中转站会优先使用上游厂商返回的 usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
response = upstream.call(request)
usage = normalize_usage(response.usage, provider, model)

cost = usage.input_tokens * price.input
     + usage.output_tokens * price.output
     + usage.cached_tokens * price.cached_input
     + usage.reasoning_tokens * price.reasoning

ledger.insert({
    request_id,
    user_id,
    provider,
    model,
    usage,
    cost,
    status: "success"
})

user.balance -= cost

为什么要优先用上游 usage?因为上游最清楚模型内部到底拼了什么、缓存命中了多少、输出生成了多少、特殊模态如何折算。中转站自己的 tokenizer 只能尽量接近,不能保证完全一致。

3. 落库:为什么要有用量流水

一个成熟中转站不会只在用户表里减余额,还会写不可随意改动的流水记录。否则一旦用户质疑扣费,平台很难解释。

一个简化的用量表可以这样设计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
CREATE TABLE ai_usage_ledger (
    id BIGINT PRIMARY KEY,
    request_id VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    provider VARCHAR(32) NOT NULL,
    model VARCHAR(128) NOT NULL,
    prompt_tokens INT DEFAULT 0,
    completion_tokens INT DEFAULT 0,
    cached_tokens INT DEFAULT 0,
    reasoning_tokens INT DEFAULT 0,
    total_tokens INT DEFAULT 0,
    upstream_cost DECIMAL(18, 8) DEFAULT 0,
    user_cost DECIMAL(18, 8) DEFAULT 0,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL
);

关键字段:

字段 用途
request_id 幂等和排查,避免重试导致重复扣费
provider 区分不同上游厂商
model 不同模型价格和 tokenizer 不同
prompt_tokens 输入 token
completion_tokens 输出 token
cached_tokens 缓存命中的输入 token
reasoning_tokens 推理类模型可能单独统计的内部输出
upstream_cost 中转站实际付给厂商的成本
user_cost 中转站按自己价格表向用户收取的费用
status 成功、失败、取消、超时、部分输出等状态

如果平台有余额系统,扣费还要做事务处理:

1
2
3
4
5
begin transaction
  insert usage_ledger
  update user_balance set balance = balance - user_cost
  insert balance_change_log
commit

这样才能保证“用量流水、余额变化、账单记录”三者对得上。

七、中转站为什么会和官方统计不一致

如果你自己写一个 token 计算器,经常会发现结果和官方 usage 对不上。原因主要有这些:

原因 为什么会导致差异
tokenizer 版本不同 模型升级后词表或特殊 token 规则可能变了
消息模板不同 厂商内部会给 role、message、tool 加包装格式
系统提示词不可见 应用或中转站可能注入了用户看不到的 prompt
历史上下文被带入 多轮聊天会把旧消息一起计入输入
工具 schema 很大 一个复杂 JSON Schema 可能比用户问题还长
RAG 召回内容多 知识库片段会直接增加输入 token
输出格式约束 JSON mode、结构化输出、示例格式都会增加 prompt
多模态折算 图片、音频、视频通常不是按普通文本 token 简单计算
缓存命中 cached tokens 可能计入总量,但按不同价格结算
请求重试 上游失败重试可能产生额外成本,平台要定义是否转嫁

所以中转站的合理做法是:

  1. 请求前用本地 tokenizer 预估,用于限额和上下文判断;
  2. 请求后以上游 usage 为准结算,尽量减少争议;
  3. 没有上游 usage 时再本地估算,并在账单里标记来源;
  4. 定期离线对账,比较中转站记录和上游账单差异。

八、上下文窗口和最大输出 Token

Token 统计不只影响费用,也影响请求能不能成功。

每个模型都有上下文窗口,比如能处理 8K、32K、128K 或更长上下文。这个窗口通常要同时容纳输入和输出:

1
输入 token + 最大输出 token <= 模型上下文窗口

假设某模型上下文窗口是 8192:

输入 token 设置最大输出 是否安全
2000 1000 安全
7000 1000 接近上限
7900 1000 可能被拒绝
8200 500 输入本身已经超限

很多“上下文超长”错误,不是用户当前问题太长,而是应用把历史对话、系统 prompt、RAG 内容和工具 schema 全塞进去了。

中转站通常会在请求前做保护:

1
2
3
4
5
6
available_output = model.context_window - estimated_input_tokens

if available_output <= 0:
    reject("input too long")

max_output_tokens = min(request.max_output_tokens, available_output, platform_limit)

这也是为什么有些平台会提示“请开启上下文压缩”或“减少知识库引用数量”。本质是在帮你节省输入 token。

九、缓存 Token 是怎么回事

一些厂商会对重复前缀做上下文缓存。比如系统提示词、工具 schema、长文档前半部分每次都一样,那么服务端可以复用已经处理过的缓存,降低延迟和成本。

抽象来看:

1
2
3
4
5
6
7
请求 1:
长系统提示词 + 工具 schema + 用户问题 A
全部输入都需要正常处理

请求 2:
长系统提示词 + 工具 schema + 用户问题 B
前面相同部分可能命中缓存

这类 usage 可能拆成:

字段 含义
input_tokens 输入 token 总数
cached_input_tokens 输入中命中缓存的部分
billable_input_tokens 实际按普通输入价计费的部分,具体取决于厂商规则

对中转站来说,缓存会让计费更复杂。它不能只看 total_tokens 乘一个固定单价,而要按厂商返回的细分字段计算。

十、不同任务的 Token 消耗差异

同一个模型,不同应用形态的 token 消耗会差很多。

场景 Token 消耗特点
普通问答 用户输入短,输出长度决定主要成本
长文总结 输入很长,输出中等
代码生成 代码符号、缩进、上下文文件都比较费 token
RAG 问答 检索片段越多,输入 token 越高
Agent 调工具 工具定义和工具结果会反复进入上下文
JSON 结构化输出 schema、字段名、示例都会增加输入和输出
Embedding 通常只按输入文本 token 计量,没有 completion token
图片理解 图片会被折算成视觉输入 token 或类似单位
语音转写 / 语音生成 可能按音频时长、音频 token 或混合规则计费

如果你在开发 AI 应用,最容易低估的是三类开销:

  1. 历史对话:用户只发一句话,但你把前 30 轮都带上了;
  2. 工具 schema:函数参数定义复杂,每次请求都要传;
  3. RAG 内容:Top-K 设得太大,召回片段太长。

优化 token 成本,通常不是把用户问题压短,而是控制这些“看不见的上下文”。

十一、如何自己估算 Token

如果你是应用开发者,至少要在请求前做一次本地估算,用来控制上下文和成本。

最简单但不精确的方法:

1
estimated_tokens = text.length / 3

这只能做非常粗的 UI 提示。更靠谱的方法是使用模型对应的 tokenizer:

1
2
3
tokenizer = get_tokenizer(model)
tokens = tokenizer.encode(rendered_prompt)
count = len(tokens)

但要记住:本地估算要尽量接近真实请求,而不是只数用户输入。

应该计入:

  • system prompt;
  • developer prompt;
  • 历史 messages;
  • RAG context;
  • tool definitions;
  • response format;
  • 当前用户问题。

不应该只计入:

1
count_tokens(user_message)

更合理的是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
rendered_prompt = render_chat_request(
    system_prompt,
    developer_prompt,
    history_messages,
    retrieved_context,
    tool_schemas,
    response_format,
    user_message
)

count_tokens(rendered_prompt, model)

本地估算的目标不是做到和厂商 100% 一样,而是提前发现明显超限和明显超预算。

十二、中转站计费系统的工程要点

如果从工程实现角度看,一个可靠的中转站至少要有这些模块:

模块 作用
模型路由 把用户请求路由到不同厂商、渠道或模型
Token 预估器 请求前计算输入 token 和最大可能成本
Usage 归一化 把不同厂商返回的 usage 统一成内部格式
价格表 维护模型输入、输出、缓存、多模态等单价
账单流水 记录每次请求的 token、费用、状态和 request_id
余额系统 做预扣、实扣、退款或额度扣减
重试策略 定义上游失败、超时、断流时是否继续扣费
对账任务 和上游账单做日级或小时级核对
风控限流 按用户、模型、IP、组织维度限制请求和 token

一个常见请求链路可以写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
handle_request(req):
    user = authenticate(req.api_key)
    model = resolve_model(req.model)

    estimated = estimate_usage(req, model)
    reserve_quota(user, estimated.max_cost)

    try:
        upstream_resp = call_provider(req, model)
        actual_usage = normalize_usage(upstream_resp.usage)
        actual_cost = calculate_cost(actual_usage, model.price)

        settle_quota(user, actual_cost)
        write_ledger(req.id, user.id, model, actual_usage, actual_cost, "success")
        return upstream_resp

    except ProviderError as err:
        release_or_settle_by_policy(user, req.id, err)
        write_ledger(req.id, user.id, model, estimated, 0, "failed")
        raise

这里有两个细节很关键:

  • 预扣不等于实扣:请求前按最大可能输出预扣,结束后按真实 usage 结算,多退少补;
  • 幂等不等于不重试:同一个 request_id 重复上报时不能重复扣费,但上游真实重试产生的成本也要能解释清楚。

十三、怎么降低 Token 成本

方法 原理 风险
精简系统提示词 减少每次固定输入 规则太少可能影响输出稳定性
压缩历史对话 摘要替代完整历史 摘要可能丢细节
控制 RAG Top-K 少塞无关片段 召回不足会影响答案
缩短工具 schema 减少重复传入的结构定义 过度简化会降低工具调用准确性
设置合理最大输出 避免模型无限写长 可能截断答案
使用缓存 重复前缀命中缓存 依赖厂商支持和请求结构稳定
按任务选模型 简单任务用低成本模型 模型能力不足会带来返工
日志里记录 usage 找到最费 token 的链路 注意隐私和数据合规

实践中最有效的优化通常是这三个:

  1. 不要无脑带全量历史;
  2. 不要把过长的文档片段塞进 prompt;
  3. 不要给简单任务使用过强、过贵、上下文过大的模型。

总结

Token 计算可以拆成三层理解:

层级 关注点
模型层 tokenizer 如何把文本变成 token ID,模型如何逐 token 生成
API 层 输入、输出、工具、多模态、缓存如何汇总成 usage
平台层 中转站如何预估、限额、扣费、落库、对账和处理异常

如果只记住一句话:

Token 不是字数,而是模型实际处理的上下文单位;厂商按模型真实处理量统计 usage,中转站则围绕 usage 做预估、转发、结算和审计。

对开发者来说,真正要养成的习惯是:不要只看用户输入的长度,而要看完整上下文。系统提示词、历史消息、RAG 片段、工具 schema、结构化输出约束,都会变成模型要处理的 token,也都会最终影响成本、延迟和上下文上限。