Token 词元到底是什么?从原理到 Claude Code 的工程实践
LLM 如何看待文字,以及一个生产级 AI Agent 如何精确掌控上下文预算
你以为 token 就是"一个词"?实际上,一个中文字可能是 1-3 个 token,一个 emoji 可能吃掉 5 个 token,而
}}}}却只算 1 个 token。理解 token 是用好 LLM 的第一步。
🎯 为什么你需要关心 Token?
如果你在用 ChatGPT、Claude 或任何 LLM API,token 与你息息相关:
| 维度 | Token 的影响 |
|---|---|
| 💰 费用 | API 按 token 计费,input token 和 output token 分开定价 |
| 📏 上下文窗口 | 模型能"记住"的内容有上限,超了就会丢失信息或报错 |
| ⏱️ 延迟 | token 越多,推理越慢 |
| 🧠 质量 | 上下文塞太满,模型注意力分散,回答质量下降 |
| 对于开发 AI Agent 的人来说,token 管理是核心架构问题 —— 你的 agent 在多轮对话中不断积累消息,不管理 token 预算,迟早会撞上下文墙。 |
🧠 Token 到底是什么?
不是字,不是词,是"子词片段"
Token 是 tokenizer (分词器) 把文本切出来的最小单元。现代 LLM 普遍使用 BPE (Byte Pair Encoding) 算法,核心思路是:
- 从单个字节开始
- 统计训练语料中哪些相邻字节对出现频率最高
- 把最频繁的字节对合并成一个新 token
- 反复迭代,直到词表达到预设大小 (通常 50k-200k) 结果就是:高频词被整个收编,低频词被拆成碎片。
直观感受
英文:
"Hello world" → ["Hello", " world"] = 2 tokens
"tokenization" → ["token", "ization"] = 2 tokens
"anthropomorphize" → ["anthrop", "omorph", "ize"] = 3 tokens
中文:
"你好" → ["你", "好"] = 2 tokens
"人工智能" → ["人工", "智能"] = 2 tokens
"中华人民共和国" → ["中华", "人民", "共和国"] = 3 tokens
代码:
"console.log" → ["console", ".", "log"] = 3 tokens
"}}}" → ["}}}"] = 1 token
" " (4空格) → [" "] = 1 token
特殊:
"🎉" → [多个字节 token] ≈ 3-5 tokens
经验法则
| 语言 | 大致比例 |
|---|---|
| 英文 | 1 token ≈ 4 个字符 ≈ 0.75 个单词 |
| 中文 | 1 token ≈ 1.5-2 个字符 |
| 代码 | 1 token ≈ 3-4 个字符 (取决于语言) |
| JSON | 1 token ≈ 2 个字符 (大量 {, }, :, , 各占 1 token) |
🏛️ API 返回的 Token 计数
当你调用 LLM API 时,response 里会附带 usage 字段。以 Anthropic API 为例:
{
"usage": {
"input_tokens": 15234,
"output_tokens": 892,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 12000
}
}
| 字段 | 含义 |
|---|---|
input_tokens | 本次请求发送的全部内容的 token 数 (system prompt + tools + 所有 messages) |
output_tokens | 模型本次生成的 token 数 |
cache_creation_input_tokens | 本次新写入缓存的 token 数 |
cache_read_input_tokens | 本次从缓存读取的 token 数 |
关键认知: input_tokens 不是"本轮新增",而是完整上下文。你发了 10 轮对话,第 10 次请求的 input_tokens 包含了前面所有 9 轮的消息。 | |
| 这也意味着: |
第 1 轮: input_tokens = 500 (system + 用户第一条消息)
第 2 轮: input_tokens = 1800 (system + 前两轮消息)
第 3 轮: input_tokens = 4200 (system + 前三轮消息)
...
input_tokens 是单调递增的 (除非你主动删了消息)
🔍 Claude Code 是如何计算 Token 的?
Claude Code 是 Anthropic 官方的 AI Coding Agent,它在多轮对话中需要精确掌控上下文预算。我阅读了它的源码,发现它的 token 计算策略非常务实。
第一层: 上下文窗口总大小 —— 查表
"模型最多能容纳多少 token?" 这个问题不需要运行时探测,Claude Code 用一个多级查找链:
优先级 (高 → 低):
1. 环境变量 CLAUDE_CODE_MAX_CONTEXT_TOKENS (人工覆盖)
2. 模型名后缀 [1m] (显式 1M 上下文)
3. Model Capabilities API 缓存 (调 API 查询,缓存到本地)
4. Beta header 判断 (是否开启了 1M beta)
5. 硬编码默认值 200,000
大多数情况下走第 3 步: Claude Code 启动时调用 Anthropic 的 Models List API,拿到每个模型的 max_input_tokens,缓存到 ~/.claude/cache/model-capabilities.json。
第二层: 当前已用量 —— "精确基准 + 粗估增量"
这是最精妙的部分。Claude Code 不自己累加每轮的 token,而是用一个混合策略:
当前上下文占用 = 最近一次 API 响应的 usage (精确值)
+ 该响应之后新增消息的粗估 (近似值)
为什么这样设计?
- 不累加: 如果你把每轮的
output_tokens累加,第 2 轮的input_tokens已经包含了第 1 轮的 output,累加就重复计算了 - 不纯粗估: 纯用字符数 / 4 估算整个对话,误差会随对话变长而放大
- 取长补短: API 返回的
usage是 server 端精确计算的结果,以它为锚点;只对"API 返回后到下一次 API 调用前"这个小窗口内的新消息做粗估,误差很小 用时序图来看:
API 调用 1 API 调用 2 API 调用 3
│ │ │
时间 ──────────────▶│ │ │
│ │ │
▼ ▼ ▼
usage 返回 usage 返回 usage 返回
(精确锚点 A) (精确锚点 B) (精确锚点 C)
│ │ │
│ 用户输入 │ tool 结果 │
│ tool 结果 │ 用户输入 │
│ ← 这些做粗估 → │ ← 这些做粗估 → │
│ │ │
估算 = A + 粗估 估算 = B + 粗估 估算 = C + 粗估
第三层: 粗估算法 —— 字符数除以常数
对于还没送去 API 的新消息,Claude Code 的粗估极其简单:
tokens ≈ 字符数 / 4 (通用文本)
tokens ≈ 字符数 / 2 (JSON,因为大量单字符 token)
tokens ≈ 2000 (图片,固定估值)
分 block 类型处理:
text→ 文本长度 / 4tool_use→ 工具名 + JSON.stringify(input) 的长度 / 4tool_result→ 递归估算内容thinking→ thinking 文本长度 / 4image/document→ 固定 2000- 其他 block → JSON.stringify 后 / 4
第四层: 计算时机 —— 不是 Streaming 中,是 Stream 结束后
usage 字段在 stream 的 message_stop 事件中返回,不是 streaming 过程中可用的。所以 Claude Code 的计算时机是:
Agent Loop 每次迭代:
1. [循环顶部] 用上一轮的 usage 锚点 + 新消息粗估 → 得到当前 token 估算
2. [判断] 是否超过 auto compact 阈值?
3. [调用 API] 发送请求,streaming 接收响应
4. [stream 结束] 拿到新的 usage → 更新锚点
5. [执行 tool] tool 结果追加到 messages
6. → 回到步骤 1
⚙️ 阈值与预算管理
知道了"总大小"和"当前用量",就可以设阈值了。Claude Code 的计算:
有效上下文 = context_window - max(model_max_output, 20000)
↑ 预留给模型输出的空间
auto compact 阈值 = 有效上下文 - 13000
↑ 额外 buffer,避免刚好压线
以 200k 模型为例:
context_window = 200,000
预留输出空间 = 20,000
有效上下文 = 180,000
auto compact buffer = 13,000
────────────────────────────────
auto compact 阈值 = 167,000 (≈ 83.5%)
当估算 token 数超过 167,000 时,Claude Code 就会自动触发压缩 —— 调 LLM 生成对话摘要,替换掉大部分历史消息。 实际状态划分:
0% ████████████████████████████░░░░░░░░░░░░░░ 83.5% 97% 100%
│← 正常区域 →│ │← warning →│ │err│ │block│
auto compact ──→ ↑ ↑
手动提醒 强制提醒
⚠️ 并行 Tool Call 的边界问题
Claude Code 源码中有一段很有意思的处理。当模型一次返回多个 tool_use 时,streaming 代码会把每个 tool_use 拆成独立的 assistant message (共享同一个 message.id),tool_result 交错插入:
messages 数组实际布局:
assistant(id=A, tool_use_1) ← usage 在这条
user(tool_result_1)
assistant(id=A, tool_use_2) ← 同一个 API 响应
user(tool_result_2)
assistant(id=A, tool_use_3) ← 同一个 API 响应
user(tool_result_3)
如果只从最后一条 assistant(id=A) 开始粗估,就只会估到 tool_result_3,漏掉了 tool_result_1 和 tool_result_2。
Claude Code 的解法: 找到有 usage 的 assistant message 后,回溯到同一 message.id 的第一条,从那里开始粗估,确保中间交错的 tool_result 都被覆盖。
📝 总结
| 问题 | 答案 |
|---|---|
| Token 是什么? | BPE 分词器切出的子词片段,不等于字也不等于词 |
| 1 token 约多少字符? | 英文 ≈ 4 字符,中文 ≈ 1.5-2 字符,JSON ≈ 2 字符 |
| 上下文总大小哪来的? | 查表 (API 缓存 + 硬编码默认值 + 环境变量覆盖) |
| 当前用了多少 token? | 最近一次 API 返回的 usage (精确) + 新消息字符数粗估 |
| 为什么不累加? | 每次 input_tokens 已包含全部历史,累加会重复计算 |
| 什么时候算? | Stream 结束后拿到 usage,在 agent loop 每轮开头做判断 |
| 超了怎么办? | 超过阈值触发 auto compact (调 LLM 做摘要替换历史消息) |
| 如果你在开发自己的 AI Agent,token 管理的核心就三件事: |
- 记录每次 API 返回的 usage —— 这是你最准确的数据源
- 对新增消息做轻量粗估 —— 字符数 / 4 就够用
- 设合理阈值,留足 buffer —— 别等撞墙了才处理