1. 一个被默认忽略的边界
人与 LLM 的交互界面是字符串进、字符串出,但 LLM 内部只认识一种东西:整数序列。从「人类可读的 prompt」到「LLM 可读的 token 序列」之间,存在一条精心设计的预处理流水线。理解这条流水线能解释很多日常困惑:
- 为什么
"hello"和" hello"在 tokenizer 里常常是不同的 token? - 中文 prompt 真的比英文更耗 token 吗?多消耗多少?为什么?
- 为什么模型从不报「无法识别的字符」错误?
- 为什么
<|endoftext|>这种字符串无法被无意注入到 prompt 里? - 为什么 Context Window 是 200k tokens,而不是 200k characters?
本文用一条链路把这些问题串起来:字符 → 码点 → 字节 → 字节 token → BPE 合并 → 最终 token ID,然后再展开讲讲和 tokenization 相关的一些有趣的信息。
2. 第一层:字符 → 码点(Unicode)
屏幕上看到的 H、你、😀 都是字符 (character)。计算机不识字,只认识数字。Unicode 标准给世界上每个字符分配了一个独一无二的编号,叫码点 (code point)。
| 字符 | 码点 (十六进制) | 码点 (十进制) |
|---|---|---|
H | U+0048 | 72 |
i | U+0069 | 105 |
| U+0020 | 32 |
你 | U+4F60 | 20320 |
好 | U+597D | 22909 |
😀 | U+1F600 | 128512 |
字符 "你" → 查 Unicode 表 → 码点 20320
码点只是一个抽象的整数编号,它不规定字符在硬盘/网络上怎么存。
3. 第二层:码点 → 字节(UTF-8)
字节是计算机存储和传输的最小可寻址单元,1 字节 = 8 bit,取值范围 0~255。
把码点存成字节序列时,不能简单地「把十进制转二进制填进去」。因为不同字符的码点大小悬殊(H 是 72,😀 是 128512),直接用原始二进制硬塞,解码器读完一串字节后根本不知道字符边界在哪里——E4 BD A0 E5 A5 BD 是两个 3 字节中文字,还是六个 1 字节字符?无法分辨。
UTF-8 用变长编码 + 每字节前缀标识解决了这个问题:
| 码点范围 | 字节数 | 模板 |
|---|---|---|
| 0 ~ 127 | 1 | 0xxxxxxx |
| 128 ~ 2047 | 2 | 110xxxxx 10xxxxxx |
| 2048 ~ 65535 | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| 65536 ~ 1114111 | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
每个字节开头的 0、110、1110、11110、10 就是自描述的标识位。解码器读第一个字节的前几位就知道这个字符占几个字节,读到 10 开头就知道它是「续字节」。代价是:标识位牺牲了一部分存储空间,换来了字符边界的确定性和ASCII 完全兼容(前 128 个字符与 ASCII 一致,1 字节存储)。
3.1 具体计算:你 (码点 20320)
码点 0x4F60 = 二进制 0100 1111 0110 0000 (16 位)
20320 落在 2048~65535 → 用 3 字节模板:
1110xxxx 10xxxxxx 10xxxxxx
码点 16 位拆成三组填入:
高 4 位 0100 → 1110 0100 = 0xE4 = 228
中 6 位 111101 → 10 111101 = 0xBD = 189
低 6 位 100000 → 10 100000 = 0xA0 = 160
结果: [228, 189, 160]
3.2 一个直接影响成本的事实
字符: H i (空格) 你 好
码点: 72 105 32 20320 22909
UTF-8: [0x48] [0x69] [0x20] [0xE4,0xBD,0xA0] [0xE5,0xA5,0xBD]
字节数: 1 1 1 3 3
至此,"Hi 你好" 在计算机里变成了 [72, 105, 32, 228, 189, 160, 229, 165, 189] —— 一串纯粹的、一维的字节序列。这是任何文本在内存中的真实形态,也是 BPE 工作的真正起点。
关于「中文比英文更耗 token」的一个常见误区。 经常能看到这样的说法:「英文字符 1 字节、中文 3 字节,所以中文 prompt 必然更耗 token」。这个推理把三件事混成了一件,需要拆开来看:
层级 中文 vs 英文能直接比吗? 关键点 UTF-8 字节 可以 ASCII 通常 1 字节,常见汉字通常 3 字节(少数扩展汉字 4 字节)——这是编码事实 token 不能由字节数推出 token 是某个具体模型的 tokenizer 切出来的单位,可以覆盖多字节、多字符甚至整个短语 信息量 / 同义 不能用字符/字节衡量 中文常常能用更少字符表达同一意思( 人工智能4 字 vsartificial intelligence23 字符)UTF-8 字节数只是存储编码的事实,并不是 token 数的定律。BPE 完全可以把一个汉字的 3 个字节合并成 1 个 token、把双字词合并成 1 个 token、甚至把常见短语合并成 1 个 token——是否真的合并,取决于该 tokenizer 的词表对中文的覆盖程度,与 UTF-8 无关。第 9.2 节会用
tiktoken跑一组真实平行语料的对比,并讨论现有研究是怎么说的。
4. 第三层:BPE 要解决什么问题?
假设要训练一个语言模型,它只能接受整数(token ID)作为输入。这就需要一个编码器把任意人类文本变成一串整数。这个编码器必须同时满足三个看似冲突的需求:
- 完备性:任何文本都不能编码失败,不能因为没见过某个字符就报错、丢字或用
<UNK>代替; - 高效性:常见的词/子词应该用极少的 token 表达,避免一段普通英文被切成几百个字符 token;
- 可逆性:从 token 列表必须能无损还原原文。
朴素方案各有致命缺陷:
| 方案 | 完备性 | 效率 | 致命问题 |
|---|---|---|---|
| 按词切(word-level) | ✗ | 高 | 词表爆炸,新词全部变 <UNK> |
| 按字符切(char-level) | ✓ | 低 | 序列过长,浪费算力,丢失语义粒度 |
| 按字节切(byte-level) | ✓ | 极低 | 序列更长,比按字符还低效 |
Byte Pair Encoding (BPE) 是字节和词之间的折中方案,它的核心思想一句话概括:
从 256 个单字节 token 起步,在训练数据上反复合并最常见的相邻 token 对,逐步学到有意义的子词单元。
GPT-2 / GPT-3.5 / GPT-4 / Claude 等主流模型都用的是 byte-level BPE——直接在 UTF-8 字节流上做合并。这种设计带来了两个不可替代的好处:
- 永远不会有
<UNK>:最坏情况下任何字节都能用基础的 256 个字节 token 表示。 - 天然支持所有语言、emoji、乱码、二进制:BPE 不需要理解「这是中文」「这是日文」,它只在数字序列上做统计。
4.1 256 个原子的诞生
BPE 词表的前 256 个 token,每个对应一个字节值:
ranks = {}
for i in range(2**8): # i = 0, 1, 2, ..., 255
ranks[bytes([i])] = i # {b'\x00': 0, b'\x01': 1, ..., b'\xFF': 255}
| Token ID | 对应字节 | 恰好等于哪个 ASCII 字符 |
|---|---|---|
| 0 | 0x00 | (空) |
| 32 | 0x20 | 空格 |
| 72 | 0x48 | 大写 H |
| 97 | 0x61 | 小写 a |
| 228 | 0xE4 | 不可读(仅 你 的第 1 字节) |
| 255 | 0xFF | 不可读 |
256 覆盖了全部可能的字节值。任何 UTF-8 文本都能被这 256 个原子 token 编码——这就是「BPE 从不需要 <UNK>」的根本原因。
4.2 BPE 训练:在字节序列上反复合并
训练时每次迭代做四件事:
(a) 统计训练语料中所有相邻 pair 的出现次数
(b) 取出现次数最多的那个 pair
(c) 把它作为新 token 加入词表,分配下一个可用 ID
(d) 在训练数据中把所有出现的这个 pair 全部替换为新 token
以 tiktoken._educational 在自身源码上训练为例,前若干次合并大致是:
| 轮次 | 最常见 pair | 新 token | rank | 本质 |
|---|---|---|---|---|
| 1 | b' ' + b' ' | b' ' | 257 | 两个空格合并 |
| 4 | b'i' + b'n' | b'in' | 260 | 字母对合并 |
| 8 | b'c' + b'o' | b'co' | 265 | 字母对合并 |
| 12 | b'o' + b'k' | b'ok' | 268 | 字母对合并 |
| 13 | b'ok' + b'en' | b'oken' | 269 | 已合并 token 继续合并 |
| 14 | b't' + b'e' | b'te' | 270 | 字母对合并 |
| 21 | b'y' + b'te' | b'yte' | 282 | 已合并 token 继续合并 |
注意 269 号和 282 号——合并是递归叠加的:先 ok + en → oken,再 t + oken 最终就可能变成 token。
4.3 对中文会怎样?
"你好" → UTF-8: [228, 189, 160, 229, 165, 189]
如果训练语料中中文足够多:
b'\xE4' + b'\xBD' + b'\xA0' → 合并成 b'\xE4\xBD\xA0' (= "你")
→ "你" 变成 1 个 token
如果训练语料中中文很少:
b'\xE4' + b'\xBD' → 可能只合并成 2 字节 token
→ "你" 仍然要 2~3 个 token
BPE 不”认识”中文或英文,它只是在字节值序列上做统计合并。 训练语料里谁出现得多,谁就更容易被压缩成更短的 token 序列。所以以英文为主训练的模型对英文压缩率高、对中文/小语种压缩率低——并不是模型「歧视」中文,而是统计意义上中文在训练集里出现得不够多。
4.4 BPE 编码(推理时):用学到的规则压缩新文本
训练完成后,mergeable_ranks 就是一个 {bytes 序列: rank} 字典,rank 越小优先级越高。编码新文本时:
输入: "hello world"
1. regex 切分 → ["hello", " world"]
2. 转单字节序列 → [[104,101,108,108,111], [32,119,111,114,108,100]]
3. 对每个词块调 bpe_encode:
- 从单字节出发: [104, 101, 108, 108, 111]
- 在所有相邻 pair 中找 rank 最小的合并
- 反复合并直到没有可合并的 pair
- → 合并成 [388] (对应 b'hello')
对 [32,119,111,114,108,100]:
- → 合并成 [372] (对应 b' world')
4. 输出 token: [388, 372]
为什么是「rank 最小」优先? rank 越小 = 训练时越早学到的规则。按训练时的合并顺序来编码,才能保证结果与训练一致,也才能保证「无损可逆」。
5. 词表大小是怎么定的?一场 GPU 上的成本权衡
BPE 是「反复合并最常见的相邻 pair」,但合并多少次才停?为什么 GPT-2 选 50,257、cl100k_base 选 ~10 万、o200k_base 选 ~20 万,而不是 1 万或 100 万?
这不是语言学问题,而是一道 GPU 工程优化题。词表大小 在硬件层面有两个对立的成本,需要找平衡点。
5.1 词表会扩张两个矩阵
假设:
- :词表大小(如 50,000)
- :模型隐藏维度(如 4,096)
- :batch size
- :每条样本的 token 长度
模型要把 token ID 映射成 维向量,用的是输入 embedding 矩阵:
V = 50,000,d = 4,096
Embedding 矩阵 = 50,000 × 4,096 = 204,800,000 个参数
BF16 / FP16 下,每参数 2 字节:
204.8M × 2 ≈ 410 MB
模型还要把最后的隐藏状态投影回整个词表,预测「下一个 token 是哪个」,对应输出投影矩阵:
如果输入 embedding 和输出投影不共享权重,就又是一份约 410 MB。很多 Transformer 会做 weight tying 让两者共享同一套参数,省掉一份显存——但输出端的计算照样要做。
5.2 更大的开销在输出 logits
训练时,模型每个位置都要算出「词表里每个 token 的分数」,得到 logits 张量:
B = 8,L = 2,048,V = 50,000
logits 张量 = 8 × 2,048 × 50,000 = 819,200,000 个数
BF16 下:
819.2M × 2 ≈ 1.64 GB
这还只是 logits 本身,训练时还要算 softmax、交叉熵、反向梯度等中间张量。实际工程会用 fused kernel、分块计算、重算等手段降峰值显存,但词表越大,开销就越大的趋势是对的。
最后一层本质就是一个矩阵乘法:[B × L × d] × [d × V]。词表从 50k 翻到 100k,这一层的输出宽度需要 ×2。
5.3 反向收益:词表变大,序列可能变短?
如果只看 5.1 和 5.2,结论是「词表越小越好」。但有一个反向作用:
词表更大
→ tokenizer 能把更多常见片段合并成 1 个 token
→ 同样文本的 token 数变少
→ 序列长度 L 下降
→ Transformer 主体的计算下降
而 Transformer 主体对 极其敏感:
- Attention 的某些核心计算近似按 增长;
- MLP、线性层按 线性增长;
- KV cache 按 增长;
- 自回归解码的步数也按 增长。
所以词表大小是一场两类成本的对冲:
| 词表变大后 | 好处 | 代价 |
|---|---|---|
| token 更少 | 序列更短、上下文容纳更多文本、解码步数更少 | — |
| embedding 矩阵更大 | 更容易覆盖罕见词、术语、代码、多语言 | 参数 + 显存增加 |
| 输出 softmax 更大 | — | 最后一层矩阵乘法和 logits 更重 |
| 多语言切分更好 | 小语种不容易被切碎 | 词表名额可能被高频语言挤占 |
5.4 长上下文模型为什么更愿意把词表做大
举个直观对比:同一篇中文文档被切成
方案 A:10,000 token
方案 B:7,000 token (词表更大、最后 softmax 更贵)
方案 B 看似单步更贵,但它带来:
- 更少的 KV cache(推理显存的大头);
- 更少的 Transformer 层计算;
- 更低的 attention 二次方成本;
- 更大的实际可读文本容量(128k context 能装更多真实信息);
- RAG 切块时更少的 token 浪费。
这就是为什么从 GPT-3.5 的 cl100k_base(~10 万)到 GPT-4o 的 o200k_base(~20 万),词表整整翻了一倍的原因:长上下文 + 多语言 + 代码场景下,收益远大于最后一层 softmax 的额外开销。
5.5 不同词表下 embedding 矩阵的体量对比
固定 :
| 词表大小 | Embedding 参数量 | 单份 BF16 权重 |
|---|---|---|
| 32k | 约 1.31 亿 | 约 262 MB |
| 50k | 约 2.05 亿 | 约 410 MB |
| 100k | 约 4.10 亿 | 约 819 MB |
| 200k | 约 8.19 亿 | 约 1.64 GB |
如果输入输出不共享权重,再 ×2。训练时还会出现梯度、Adam 一阶/二阶动量、可能的 FP32 master weights……实际训练显存远高于权重文件本身。
不过在超大模型里,Transformer 的 MLP 和 attention 通常占了绝大多数参数,词表矩阵是否「特别大」,取决于 相对于模型维度和总参数量的比例。
5.6 还有一个细节:矩阵对齐
实际工程里,词表大小常被选成或 padded 到某些对齐值(64、128、256 的倍数附近)。原因不是语言学,而是 GPU 矩阵乘法硬件喜欢规则的尺寸:Tensor Core、tensor parallel 切分、内存布局、kernel 实现都在对齐尺寸上更高效。
比如模型在 8 张 GPU 上做 tensor parallel 时,希望 是整齐的大小,方便每张卡负责词表的一部分。所以一个团队可能不会选 50,003 这种”恰好”的数字,而是 50,176、50,688 或 51,200 这种容易对齐和分片的值。
GPT-2 的 50,257 就是个例子:50,000 个 BPE 合并 token + 256 个字节 token + 1 个 <|endoftext|>——表面上看像是”语言学决定的”,背后其实是「凑齐后再做对齐」的工程选择。
5.7 回到「中文为何可能更耗 token」
GPU/模型预算
→ 能接受多大的 V
→ 给中文分到多少有效词表项
→ 中文常见字、词、短语能否被合并
→ 实际中文 token 数
所以并不是网上流传的所谓近似数据:
汉字 3 字节 → 必然 3 倍 token → 中文必然更贵
中文是否被切得碎,取决于该模型是否愿意、也是否有足够词表容量,把常见中文字、词、短语和中英混排模式学进去。词表太小或中文训练数据占比太低时,中文容易吃亏;词表设计得好时,中文完全可能比英文更紧凑——cl100k_base 到 o200k_base 的变化就是最好的证据。
6. Regex 预切分:BPE 的「围墙」
如果直接把整篇文章的字节流喂给 BPE,会面临一个比较严重问题:
原文: "hello world"
如果不对 BPE 设边界:
"hello" 的最后一个字节 + "world" 的第一个字节
→ b'o' + b' ' → 可能被合并成 b'o '
→ b' ' + b'w' → 可能被合并成 b' w'
合并跨越了单词边界,产生 b'o ', b' w' 这种毫无语义的 token,
浪费词表空间,破坏跨上下文的泛化能力。
所以 GPT-2 引入了 regex 预切分:先用一个精心设计的正则把文本切成「伪词」(pre-tokens),然后在每个伪词内部独立地做 BPE 合并。BPE 永远不会跨越伪词边界。
6.1 GPT-2 的 regex pattern 拆解
gpt2_pattern = r"""'s|'t|'re|'ve|'m|'ll|'d| ?[\p{L}]+| ?[\p{N}]+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
| 分隔六条子规则,优先级从左到右递减。以 "Don't look back 123 times!" 为例:
| 子规则 | 匹配内容 | 作用 |
|---|---|---|
's|'t|'re|'ve|'m|'ll|'d | 't | 英文缩写单独切开,'t 可在 don't/can't/won't 间共享 |
?[\p{L}]+ | Don, look, back, times | 字母词,前面的 ? 把前导空格吸进token 里面 |
?[\p{N}]+ | 123 | 数字串,同样把前导空格吸进去 |
?[^\s\p{L}\p{N}]+ | ! | 标点串,前导空格也吸进去 |
\s+(?!\S) | (行尾空白,此例无) | 行尾的纯空白单独切 |
\s+ | 其余多余空白 | 兜底 |
切分结果:
"Don" "'t" " look" " back" " 123" " times" "!"
6.2 前导空格 ? 是什么 ?
注意 ?[\p{L}]+ 中开头那个 ?(可选空格)。它带来两个核心好处:
"hello world" → ["hello", " world"]
↑ ↑
无前空格 空格被吸进 "world" 这个 token 里
- 自然重建原文:解码时 token
" world"=+world,拼回去就是"hello world",不需要任何额外的空格处理逻辑; - 区分词首与词中:
" world"(句中)和"world"(句首/紧跟标点)在 BPE 眼中是不同的字节序列起点,会被分配成不同的 token ID。
这也是为什么 hello 和 hello 在 tiktoken 中常常对应不同的 token。
6.3 完整预处理流程
输入: "Don't look back 123 times!"
│
│ regex.findall(gpt2_pattern, text)
▼
["Don", "'t", " look", " back", " 123", " times", "!"]
│
│ 每个词块分别 .encode("utf-8") → 单字节序列
▼
[[68,111,110], [39,116], [32,108,111,111,107], [32,98,97,99,107],
[32,49,50,51], [32,116,105,109,101,115], [33]]
│
│ 对每个词块内部分别执行 BPE 合并
▼
[b'Don'], [b"'t"], [b' look'], [b' back'], [b' 123'], [b' times'], [b'!']
│
│ 查 mergeable_ranks → token ID
▼
[4652, 1109, 1820, 2314, 4186, 1788, 0]
regex 切分定义了 BPE 的「围墙」——合并只发生在词块内部,永远不会跨越词块边界。这就是为什么 GPT 系列模型的所有 subword token 都不会跨单词。
7. 特殊词表:模型的带内控制信号
到目前为止,所有 token 都来自 UTF-8 字节经 BPE 合并的结果,它们都可以被「字符串化」。但 LLM 还需要一类永远不会自然出现在普通文本中的特殊 token,作为模型与上层框架之间的「带内控制信号」:
| 特殊 token | 用途 |
|---|---|
<|endoftext|> | 标记一段文档/对话结束,训练时切分样本边界 |
<|startoftext|> | 部分模型在每段开头插入 |
<|im_start|> / <|im_end|> | ChatML 格式中的消息边界(OpenAI 聊天模型) |
<|fim_prefix|> / <|fim_middle|> / <|fim_suffix|> | Fill-in-the-Middle,代码补全场景 |
<|system|> / <|user|> / <|assistant|> | 各家自定义的角色 token |
这些 token 不参与 BPE 合并,而是被显式追加在词表末尾,分配独立的 ID。tiktoken 在编码时会做严格隔离:
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# 默认情况:用户输入中出现 <|endoftext|> 字符串会直接抛错
enc.encode("hello <|endoftext|> world")
# raises ValueError
# 显式声明信任:作为特殊 token 处理
enc.encode("hello <|endoftext|> world", allowed_special={"<|endoftext|>"})
# → [..., 100257, ...]
# 显式声明禁止:作为普通字面量逐字节切分(不会变成 100257)
enc.encode("hello <|endoftext|> world", disallowed_special=())
这个隔离机制的本质是防止 prompt injection。如果攻击者在用户输入里写一段 <|im_end|><|user|>...<|im_end|>,而上层粗心地直接拼进 prompt,模型就可能被骗。tiktoken 的默认行为是「碰到特殊 token 字符串就报错」,强制开发者显式声明是否信任输入来源。
这也解释了为什么特殊 token 在词表中是「保留区」——它们不能被自然文本无意命中。哪怕用户真的输入了
<|endoftext|>这个字符串,只要按普通文本走,BPE 也只会把它切成<、|、end、…… 这种零碎的字节 token,绝不会变成那个单独的特殊 ID。
8. 完整链路串联
把前面所有层叠起来:
"Hi 你好<|endoftext|>" (人类看到的字符串)
│
│ Unicode 标准
▼
[72, 105, 32, 20320, 22909] + 特殊 token 标记 (码点 + 特殊标识)
│
│ UTF-8 编码(仅对自然文本部分)
▼
[72, 105, 32, 228, 189, 160, 229, 165, 189] (字节序列)
│
│ GPT-2 regex 预切分
▼
["Hi", " 你好"] (伪词)
│
│ Byte-level BPE 合并(256 初始 token + 训练学到的合并规则)
▼
[13347, 50230, ...] (常规 token ID,取决于具体编码)
│
│ 追加特殊 token
▼
[13347, 50230, ..., 100257] (最终送入模型的整数序列)
每一层都是对上一层的纯函数转换:
| 层级 | 输入 | 输出 | 转换规则 | 谁定义 |
|---|---|---|---|---|
| Unicode | 字符 | 码点 | 查表 | Unicode 标准化组织 |
| UTF-8 | 码点 | 字节序列 | 位运算 + 变长编码 | RFC 3629 |
| Pre-token | 字节序列 | 伪词数组 | regex | 模型作者(如 GPT-2) |
| BPE | 伪词(字节) | token ID 数组 | 训练学到的合并规则 | 训练语料统计 |
| Special | 上层控制信号 | 保留 token ID | 词表末尾保留 + 编码时隔离 | 框架/模型上层协议 |
BPE 始终工作在字节层。 它对字符、码点、语言都一无所知,只是在 0~255 的数字序列上做统计。BPE 词表里看到的 b'in'、b'oken' 等可读片段,只是因为 ASCII 范围的字节值恰好对应可打印字符——这是巧合,不是 BPE 在「理解语言」。
9. tiktoken 实操:Token 计数与 Context Window 管理
OpenAI 开源的 tiktoken 是 GPT 系列模型实际使用的 BPE 实现(Rust 写的,比纯 Python 快上百倍)。日常工程中,它最有用的场景是在请求发出之前,准确知道这段 prompt 会消耗多少 token。
9.1 基础用法
import tiktoken
# 方式 1:按模型名拿编码器(推荐,自动选对应版本)
enc = tiktoken.encoding_for_model("gpt-4o") # → o200k_base
enc = tiktoken.encoding_for_model("gpt-4") # → cl100k_base
enc = tiktoken.encoding_for_model("gpt-3.5-turbo") # → cl100k_base
# 方式 2:按编码名拿(只关心编码方案时使用)
enc = tiktoken.get_encoding("cl100k_base")
# 编码
tokens = enc.encode("Hello, 你好世界!")
print(tokens)
# [9906, 11, 220, 57668, 53901, 3574, 30543, 6447]
# 看看每个 token 长啥样
for t in tokens:
print(t, repr(enc.decode([t])))
# 9906 'Hello'
# 11 ','
# 220 ' '
# 57668 '你'
# 53901 '好'
# 3574 '世'
# 30543 '界'
# 6447 '!'
# 反编码
text = enc.decode(tokens) # 'Hello, 你好世界!'
# 只关心数量
n = len(enc.encode(prompt))
观察上面的结果:
"Hello"是 1 个 token(英文常见词被完整学进了词表);","单独 1 个 token;" "单独 1 个 token(因为后面跟的是中文,前导空格没有被「吸进」中文 token);"你"、"好"、"世"、"界"各 1 个 token(cl100k_base 中常用中文字基本各自 1 token);"!"(中文全角)1 个 token。
9.2 中文真的「更耗 token」吗?拿数据说话
第 3 节末尾已经指出「中文字符 3 字节」是 UTF-8 编码层面的事实,不能直接推出「中文 prompt 必然更耗 token」。但反过来,到底实际差距有多大?这个问题没法靠推理回答,只能靠实测。
错误的比较方式
最常见的两种错误对比:
- 按字符数比:
"人工智能"4 字 vs"artificial intelligence"23 字符——拿这个比 token 数毫无意义,因为信息量不等价。 - 按字节数比:4 × 3 = 12 字节 vs 23 字节——中文反而更少,又会得出相反的错误结论。
正确的对比应该使用同义平行文本:同一句话的自然中文表达与自然英文表达,分别用目标模型的 tokenizer 编码,比较 token 数。这正是学术界做的事——例如 FLORES 平行语料库就是把同一批句子翻译成 200 多种语言,用于跨语言公平比较。
一组 tiktoken 对照实验
用 10 组手工对齐的中英文句对,分别在 cl100k_base (GPT-3.5/4) 和 o200k_base (GPT-4o / o1) 下编码:
| 编码器 | 同义 prompt ZH/EN token 比 | 34 个常用汉字的平均 token 数 | 词表大小 |
|---|---|---|---|
cl100k_base (GPT-3.5 / GPT-4) | 1.86× | 1.12 tok/字(30 个 1 tok,4 个 2 tok) | ~10 万 |
o200k_base (GPT-4o / o1) | 1.13× | 1.00 tok/字(34 个全部 1 tok) | ~20 万 |
几个具体对比例子(o200k_base):
"I love natural language processing." → 6 tok
"我喜欢自然语言处理。" → 6 tok (1.00×)
"Artificial intelligence is changing the world." → 7 tok
"人工智能正在改变世界。" → 6 tok (0.86×,中文反而更短)
"Machine learning models require large amounts of data and computation." → 11 tok
"机器学习模型需要大量的数据和计算资源。" → 10 tok (0.91×)
学术界怎么说
上面的小样本实测和已发表的研究结论方向一致,但学术研究覆盖的语言范围远比”中英文”宽得多,结论也更细致:
- Ahia et al., EMNLP 2023, Do All Languages Cost the Same?:在 FLORES 平行语料上比较了 ChatGPT/GPT-3.5 tokenizer 对不同语言的 token 消耗,发现最不利的语言编码同一内容时最多要用约 5× 的 token。差异来源是训练语料分布 + 文字系统属性的联合作用,而不是单由 UTF-8 字节长度决定。
- Petrov et al., NeurIPS 2023, Language Model Tokenizers Introduce Unfairness Between Languages:研究了多个 tokenizer,发现同一句话翻译成不同语言后,token 数可能出现很大倍数差异;论文的重点是 tokenizer 的词表分配和语言覆盖不公平,而非”某种文字天生更贵”。
- Limisiewicz et al., ACL Findings 2023:进一步表明,多语言 tokenizer 中某语言获得多少专属词表项、不同语言如何共享词表,直接影响下游效率与表现。
也就是说:“某语言更耗 token” 是一个模型与 tokenizer 特定的经验问题,不是 UTF-8 的必然推论。
正确的表述
| 原说法 | 判定 | 正确的版本 |
|---|---|---|
| 英文字符 1 字节、中文 3 字节 | 基本正确 | 这是 UTF-8 字节层面的事实(少数扩展汉字 4 字节) |
| 所以同样信息量的中文 prompt 通常更耗 token | 过度推论 | 对某些英文中心、中文覆盖不足的 tokenizer 经验上可能成立,但不能从字节数直接推出,也不是跨模型规律 |
| BPE 也无法完全抹平这个差距 | 不严谨 | BPE 完全可以把汉字的多字节、常用中文词乃至常见短语合并成单个 token;真正的限制是 tokenizer 词表对中文的覆盖,而非 UTF-8 的 3 字节性质 |
实践建议
不要猜,直接用目标模型的 tokenizer 数。OpenAI 官方提供了 Tokenizer 页面;本地用 tiktoken.encoding_for_model("gpt-4o").encode(text) 一行就能拿到准确数字。如果是 Claude/Gemini/开源模型,对应的 tokenizer 也都有 SDK。别用一个模型的 token 比例去外推另一个模型的成本。
9.3 Context Window 管理的常见模式
Context Window 是以 token 数而非字符数计的上限:GPT-4o 是 128k,Claude 3.5 是 200k,o1 系列是 200k。输入 + 输出都计入这个窗口。
模式一:请求前预算检查
MAX_INPUT = 120_000 # 给输出留 8k
MAX_OUTPUT = 8_000
def safe_call(prompt: str, model: str = "gpt-4o"):
enc = tiktoken.encoding_for_model(model)
n = len(enc.encode(prompt))
if n > MAX_INPUT:
raise ValueError(f"prompt too long: {n} > {MAX_INPUT}")
return openai.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=MAX_OUTPUT,
)
模式二:按 token 截断长文本(不能按字符截断,会把多字节字符切碎)
def truncate_by_token(text: str, max_tokens: int, model: str = "gpt-4o") -> str:
enc = tiktoken.encoding_for_model(model)
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
return text
return enc.decode(tokens[:max_tokens])
注意:
enc.decode即使切在多字节字符中间也能正确处理——tiktoken 内部用errors="replace"兜底,不会抛异常,但末尾可能出现替换符。如果对边界敏感,使用decode_with_offsets或自行在字符边界对齐。
模式三:滑动窗口的对话历史压缩
def trim_messages(messages: list[dict], max_tokens: int, model: str = "gpt-4o"):
enc = tiktoken.encoding_for_model(model)
def count(m): return len(enc.encode(m["content"])) + 4 # ChatML 包装开销
total = sum(count(m) for m in messages)
system = [m for m in messages if m["role"] == "system"]
rest = [m for m in messages if m["role"] != "system"]
while total > max_tokens and len(rest) > 2:
dropped = rest.pop(0)
total -= count(dropped)
return system + rest
模式四:精确的 ChatML 计费
OpenAI 聊天接口的 token 计数并不是简单地 len(enc.encode(content)),而是包括了 <|im_start|>、role、<|im_end|> 等控制 token。社区已有标准实现(参考 官方 cookbook):
def num_tokens_from_messages(messages, model="gpt-4o"):
enc = tiktoken.encoding_for_model(model)
tokens_per_message = 3 # <|start|>role\ncontent<|end|>
tokens_per_name = 1
n = 0
for m in messages:
n += tokens_per_message
for k, v in m.items():
n += len(enc.encode(v))
if k == "name":
n += tokens_per_name
n += 3 # 每次回复以 <|start|>assistant<|end|> 起头
return n
模式五:流式输出时的实时计数
stream = client.chat.completions.create(model="gpt-4o", messages=..., stream=True)
out_tokens = 0
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
out_tokens += len(enc.encode(delta))
if out_tokens > MAX_OUTPUT:
break # 主动切断
10. 推荐玩具:tiktoken._educational
tiktoken 仓库里藏着一个少有人提的模块 tiktoken/_educational.py。它是纯 Python 实现的 BPE 训练 + 编码 + 可视化,专门为教学而生——完全不追求性能,但能把本文讨论的每一个细节都看得见、摸得着。
from tiktoken._educational import SimpleBytePairEncoding, train_simple_encoding
# 1. 在 _educational.py 自身源码上训练一个 mini 编码器
enc = train_simple_encoding()
# 训练过程会逐步打印:
# round 1: merging (b' ', b' ') -> b' ' (rank=257)
# round 4: merging (b'i', b'n') -> b'in' (rank=260)
# ...
# round 13: merging (b'ok', b'en') -> b'oken' (rank=269)
# 2. 用它编码任意文本,并打印每一步合并过程
enc.encode("hello world", visualise="colour")
# 会用颜色把每次合并的 pair 高亮出来
# 3. 也可以加载真实的 cl100k 编码器,但用慢速 Python 实现来观察
import tiktoken
real = tiktoken.get_encoding("cl100k_base")
slow = SimpleBytePairEncoding(
pat_str=real._pat_str,
mergeable_ranks=real._mergeable_ranks,
)
slow.encode("Don't look back!", visualise="colour")
跑一遍 train_simple_encoding() 即可观察到:
- 第 1 轮就把
(空格, 空格)合并了——因为源码里两空格缩进太常见; - 字母对会比标点对更早被合并;
- 已合并的 token 会在后续轮次里继续作为「原子」参与更上层的合并(如
ok+en→oken); - 整个过程没有「语言」概念,完全是字节频次驱动。
读懂这两百行代码远比读十篇博客更能说清 BPE 的工作机制——它就是这么朴素,又这么有效。
11. 总结
整条流水线本质上是把人类世界的「字符」这个高维抽象,逐层降维成 LLM 唯一能消化的整数序列。几个会反复用到的事实:
- BPE 不理解语言,只统计字节——所以中文、emoji、乱码、二进制都能编码,但效率取决于训练语料分布;
- regex 预切分是 BPE 的「围墙」——subword 永远不会跨单词;
- 前导空格被吸进 token——所以
"hello"和" hello"是不同 token,且解码无需额外处理空格; - 特殊 token 是带内控制信号——使用前请决定信任策略,否则
tiktoken会主动报错防止 prompt injection; - Context Window 以 token 计——任何长文本处理、对话历史维护、流式输出,都应基于
tiktoken做精确预算,而不是字符长度。
从一段「人话」到模型眼中的一串整数,中间没有任何神秘魔法,只有 Unicode 的查表、UTF-8 的位运算、regex 的语言学经验,和 BPE 在字节频次上的贪心合并。理解这条流水线,才真正理解 token 计费的来由、上下文窗口的边界,以及为什么有些 prompt 写法比另一些「便宜」得多。