一句话:BPE(Byte Pair Encoding)是一种把文本切分成"子词单元"的算法,是现代大模型 Tokenizer 的核心。tiktoken 是 OpenAI 实现的高性能 BPE 库,GPT 系列模型使用它。
一、为什么需要 Tokenizer?
模型不能直接处理文字,需要先把文字转成数字(token ID),再转成向量(embedding)。
原始文本:"今天天气很好"
↓ Tokenizer
token 序列:["今天", "天气", "很", "好"]
↓ 查词表
token ID:[1234, 5678, 910, 1112]
↓ Embedding 层
向量矩阵:[[...], [...], [...], [...]] ← 模型真正处理的输入
二、三种切分粒度的对比
在 BPE 出现之前,有两种极端方案:
方案1:词级别(Word-level)
"unhappiness" → ["unhappiness"] ← 一个词一个 token
问题:
- 词表会非常大(英语有几十万个词)
- 遇到训练时没见过的新词(OOV,Out-of-Vocabulary)就不认识
- 不同语言、不同形态的词需要分别存储(“run”、“running”、“ran” 是三个不同 token)
方案2:字符级别(Character-level)
"unhappiness" → ["u","n","h","a","p","p","i","n","e","s","s"] ← 每个字符一个 token
问题:
- 序列太长,Attention 的 $O(T^2)$ 复杂度爆炸
- 字符本身语义信息太少,模型难以学习
方案3:子词级别(Subword-level)—— BPE 的目标
"unhappiness" → ["un", "happi", "ness"] ← 有意义的子词单元
优点:
- 词表大小可控(通常 3 万~10 万)
- 没见过的词可以拆成已知子词组合,不会 OOV
- 保留了一定的语义信息
三、BPE 算法详解
BPE 最初是数据压缩算法,被 Sennrich 等人(2016)引入 NLP。
核心思想
反复合并出现频率最高的相邻字节/字符对,直到达到目标词表大小。
训练过程(逐步演示)
初始语料(简化示例,括号内是出现频率):
"low"(5次) "lower"(2次) "newest"(6次) "widest"(3次)
第0步:初始化,把每个词拆成字符,加上词尾标记 </w>(表示这是一个词的结尾):
l o w </w> (5次)
l o w e r </w> (2次)
n e w e s t </w> (6次)
w i d e s t </w> (3次)
统计所有相邻字符对的频率:
(l, o): 5+2 = 7
(o, w): 5+2 = 7
(w, </w>): 5
(w, e): 2+6 = 8 ← 最高!
(e, r): 2
(e, s): 6+3 = 9 ← 最高!
...
第1步:合并频率最高的对 (e, s) → es:
l o w </w> (5次)
l o w e r </w> (2次)
n e w es t </w> (6次) ← "e s" 合并为 "es"
w i d es t </w> (3次) ← "e s" 合并为 "es"
第2步:重新统计,合并频率最高的对 (es, t) → est:
l o w </w> (5次)
l o w e r </w> (2次)
n e w est </w> (6次) ← "es t" 合并为 "est"
w i d est </w> (3次) ← "es t" 合并为 "est"
第3步:合并 (w, e) → we(频率 8):
l o w </w> (5次)
l o we r </w> (2次)
n e we st </w> (6次)
w i d est </w> (3次)
第4步:合并 (l, o) → lo(频率 7):
lo w </w> (5次)
lo we r </w> (2次)
n e we st </w> (6次)
w i d est </w> (3次)
……持续合并,直到词表达到目标大小(如 50000 个 token)
最终词表包含所有合并规则,例如:
合并规则列表(按顺序):
1. e + s → es
2. es + t → est
3. w + e → we
4. l + o → lo
5. lo + w → low
...
推理时如何切分新词
拿到新词,按照训练时学到的**合并规则列表(按顺序)**逐一应用:
新词:"lowest"
初始:l o w e s t </w>
应用规则1 (e+s→es):l o w es t </w>
应用规则2 (es+t→est):l o w est </w>
应用规则3 (w+e→we):无匹配(w 后面是 est,不是 e)
应用规则4 (l+o→lo):lo w est </w>
应用规则5 (lo+w→low):low est </w>
最终切分:["low", "est</w>"] → 对应 token ID
四、Byte-level BPE(字节级 BPE)
GPT-2 及之后的模型使用的是 Byte-level BPE,在字节(byte)而非字符上做 BPE。
动机:
- 普通 BPE 以 Unicode 字符为基本单元,词表初始化需要覆盖所有字符(中文就有几万个)
- Byte-level BPE 以 256 个字节为基本单元,初始词表只有 256 个元素,可以表示任意语言、任意字符,永远不会 OOV
"你好" 的 UTF-8 字节:
"你" → [0xE4, 0xBD, 0xA0]
"好" → [0xE5, 0xA5, 0xBD]
Byte-level BPE 从这 6 个字节开始合并,最终可能得到:
["你好"](如果这个组合在训练数据中足够频繁)
或 ["你", "好"]
或更细的字节组合
五、tiktoken 是什么?
tiktoken 是 OpenAI 开源的高性能 BPE tokenizer 库,用 Rust 实现核心逻辑,Python 封装调用,速度比纯 Python 实现快 3~6 倍。
GPT 系列使用的编码方案
| 模型 | 编码方案 | 词表大小 |
|---|---|---|
| GPT-2 | gpt2 |
50,257 |
| GPT-3.5 / GPT-4 | cl100k_base |
100,277 |
| GPT-4o | o200k_base |
200,019 |
| text-embedding-ada-002 | cl100k_base |
100,277 |
词表越大,同样的文本切分出的 token 数越少,处理效率越高,但模型的 embedding 层参数也越多。
tiktoken 安装和基本使用
# 安装
# pip install tiktoken
import tiktoken
# 加载编码方案
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 使用的编码
# 或者直接按模型名加载
enc = tiktoken.encoding_for_model("gpt-4")
<![CDATA[# 实际输出:
# token IDs:[9906, 11, 220, 57668, 53901, 6447, 33, 1777, 374, 8056, 13]
# token 数量:11]]>
# ── 解码:token ID → 文本 ──────────────────────────────
decoded = enc.decode(token_ids)
print(f"解码还原:{decoded}") # "Hello, 你好!BPE is amazing."
# ── 查看每个 token 对应的文本 ──────────────────────────
for token_id in token_ids:
token_bytes = enc.decode_single_token_bytes(token_id)
print(f" ID {token_id:6d} → {token_bytes}")
# 实际输出:
# ID 9906 → b'Hello'
# ID 11 → b','
# ID 220 → b' '
# ID 57668 → b'\xe4\xbd\xa0' ← "你" 的 UTF-8 字节
# ID 53901 → b'\xe5\xa5\xbd' ← "好" 的 UTF-8 字节
# ID 6447 → b'\xef\xbc\x81' ← "!" 的 UTF-8 字节
# ID 33 → b'B'
# ID 1777 → b'PE'
# ID 374 → b' is'
# ID 8056 → b' amazing'
# ID 13 → b'.'
# ── 统计 token 数(不解码,只计数,更快)──────────────
num_tokens = len(enc.encode(text))
print(f"token 数:{num_tokens}")
# ── 特殊 token ─────────────────────────────────────────
# GPT 系列有一些特殊 token,如 <|endoftext|>
special_enc = tiktoken.get_encoding("cl100k_base")
eot_id = special_enc.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})
print(f"<|endoftext|> 的 token ID:{eot_id}") # [100257]
实用场景:计算 API 费用前预估 token 数
OpenAI API 按 token 计费,发送前先估算:
import tiktoken
def count_tokens_for_gpt4(messages: list[dict]) -> int:
"""
估算 GPT-4 API 调用的 token 数。
messages 格式:[{"role": "user", "content": "..."}, ...]
"""
enc = tiktoken.encoding_for_model("gpt-4")
total_tokens = 0
for message in messages:
# 每条消息有固定的格式开销(role + 分隔符)
total_tokens += 4
for value in message.values():
total_tokens += len(enc.encode(str(value)))
total_tokens += 2 # 回复的起始开销
return total_tokens
messages = [
{"role": "system", "content": "你是一个有帮助的助手。"},
{"role": "user", "content": "请帮我写一首关于春天的诗。"},
]
estimated_tokens = count_tokens_for_gpt4(messages)
print(f"预估输入 token 数:{estimated_tokens}")
# 按 GPT-4 价格 $0.03/1K tokens 估算费用
cost_usd = estimated_tokens / 1000 * 0.03
print(f"预估费用:${cost_usd:.6f}")
六、其他主流模型的 Tokenizer
| 模型系列 | Tokenizer 类型 | 词表大小 | 库 |
|---|---|---|---|
| GPT-2/3/4 | Byte-level BPE | 50K / 100K / 200K | tiktoken |
| LLaMA-1/2 | SentencePiece BPE | 32,000 | sentencepiece |
| LLaMA-3 | Byte-level BPE | 128,256 | tiktoken 兼容 |
| Qwen2 | Byte-level BPE | 151,936 | tiktoken 兼容 |
| BERT | WordPiece | 30,522 | HuggingFace tokenizers |
| T5 | SentencePiece Unigram | 32,100 | sentencepiece |
WordPiece(BERT 用)和 BPE 思路类似,区别在于合并标准:
- BPE:合并频率最高的字符对
- WordPiece:合并能最大化训练数据似然的字符对(更精确但更慢)
SentencePiece(LLaMA-1/2 用):
- 直接在原始文本上操作,不需要预分词(空格也是普通字符)
- 支持 BPE 和 Unigram 两种算法
- 跨语言效果好
七、一个有趣的现象:token 边界影响模型能力
BPE 切分方式会直接影响模型的推理能力:
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# "9.11" 和 "9.9" 的 token 切分
print(enc.encode("9.11")) # 实际输出:[24, 13, 806] → [b'9', b'.', b'11']
print(enc.encode("9.9")) # 实际输出:[24, 13, 24] → [b'9', b'.', b'9']
# GPT 早期版本认为 9.11 > 9.9,因为 "11" > "9"(字符串比较)
# 这是 token 粒度导致的数字理解问题
# 字符计数问题:strawberry 里有几个 r?
print(enc.encode("strawberry"))
# 实际输出:[496, 675, 15717] → [b'str', b'aw', b'berry']
# 模型看到的是三个 token(str / aw / berry),不是字符,所以数字符时容易出错
这也是为什么现代模型(GPT-4o 用 o200k_base,词表 20 万)要扩大词表——更大的词表意味着更少的切分,更完整的语义单元,减少这类边界问题。
八、一句话总结
- BPE:反复合并频率最高的相邻字符对,训练出一套合并规则,推理时按规则切分新词。解决了词级别 OOV 和字符级别序列过长的问题。
- Byte-level BPE:在字节上做 BPE,初始词表只有 256 个元素,永远不会 OOV,GPT-2 之后的主流方案。
- tiktoken:OpenAI 的高性能 BPE 实现,Rust 核心,GPT 系列标配,常用于计算 token 数和 API 费用估算。