Tokenizer 与 BPE:LLM 看到的不是字,是 token
为什么 GPT 把"strawberry"切成 4 个 token?为什么数错"strawberry 有几个 r"?这些都和 tokenizer 有关。
L0-11 词汇表里说过:LLM 不看字,看 token。
那 token 到底怎么来的?这一篇打开 tokenizer 的”黑盒”,看清 LLM 输入端的真相。
🎮 建议先打开 Tokenizer Playground 可视化 玩 5 分钟——同一句话被 GPT/Claude/Llama 切成完全不同的样子。本文是它的伴读。
第一站:为什么不直接用”字”
最朴素的做法:每个字母 / 汉字 = 一个 token。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 字符级 | 词表小(英文 ~128,加中文 ~5万) | 序列太长(“Hello”变 5 个 token),训练慢 |
| 词级 | 序列短 | 词表巨大(英文几十万词),未登录词(OOV)无法处理 |
两个极端都不好。最终方案是”子词级”——subword tokenization。
第二站:BPE 算法的故事
BPE(Byte Pair Encoding) 本来是 1994 年的数据压缩算法。
2015 年研究者发现它意外地适合做 NLP 分词。今天 GPT、Llama、Mistral 都用 BPE 的变种。
BPE 训练的核心思路
- 初始词表 = 所有单个字符
- 数训练语料里”最常出现的字符对”
- 把它合并成一个新 token,加进词表
- 重复几万次,直到词表达到目标大小
让我们手动跑一遍。
语料:
low low low low low
lower lower
newest newest newest
widest widest
Step 0: 初始词表
每个字符 + 空格符号:
{ 'l', 'o', 'w', 'e', 'r', 'n', 's', 't', 'i', 'd', '·' } # · 表示词尾
每个词切成字符序列:
l·o·w·</w> 出现 5 次
l·o·w·e·r·</w> 出现 2 次
n·e·w·e·s·t·</w> 出现 3 次
w·i·d·e·s·t·</w> 出现 2 次
Step 1: 数最频繁的字符对
| 字符对 | 出现次数 |
|---|---|
| (l, o) | 5+2 = 7 |
| (o, w) | 5+2 = 7 |
| (e, s) | 3+2 = 5 |
| (s, t) | 3+2 = 5 |
| (n, e) | 3 |
| … |
最高的是 (l, o) 和 (o, w)——都 7 次。假设我们合并 (l, o):
Step 2: 合并 → 新 token “lo”
更新所有词:
lo·w·</w> 出现 5 次
lo·w·e·r·</w> 出现 2 次
n·e·w·e·s·t·</w> 出现 3 次
w·i·d·e·s·t·</w> 出现 2 次
词表新增 'lo'。
Step 3: 重新数
现在最频繁的是 (lo, w) = 7 次。合并 → 'low'。
low·</w> 出现 5 次
low·e·r·</w> 出现 2 次
n·e·w·e·s·t·</w> 出现 3 次
w·i·d·e·s·t·</w> 出现 2 次
Step 4: 继续合并
接下来可能是 (e, s)、(es, t)、(low, </w>)……
每次合并都把语料中最频繁的模式打包成一个 token。
训练结束
通常合并 5 万–10 万次,得到一个 5 万–10 万大小的词表。
这就是 BPE 训练。
第三站:BPE 如何切新词
训练完拿到词表后,编码新词的过程其实是”贪心匹配最长前缀”:
"lower" 怎么切?
step 1: 找最长能匹配的前缀
"lower" 在词表? 不在
"lowe" 在词表? 不在
"low" 在词表? ✓
→ 拿走 "low",剩 "er"
step 2:
"er" 在词表? ✓
→ 拿走 "er",剩空
结果: ["low", "er"] ← 2 个 token
这就是为什么”lower”在 GPT 里通常是 1-2 个 token。
第四站:BPE 的几种变种
1. 字节级 BPE(GPT 用的)
不在字符上做 BPE——直接在字节上做。
为什么?因为 Unicode 太大(几万个字符),字节级只有 256 种,覆盖性更好。
代价:中文每个字 3 字节,所以”你好”在 byte-BPE 上有时被切成 6 个 token。这也是为什么中文 API 调用比英文贵 2-3 倍——L0-09 提过的现象,源头在这里。
2. SentencePiece(Llama / T5 用的)
Google 开发的库。 不需要预切词(不像 BPE 假设”词之间有空格”),适合中日韩这种没明显词边界的语言。
特点:用 ▁(特殊下划线)表示词前空格。
"Hello world" → ["▁Hello", "▁world"]
"你好世界" → ["▁你好", "世界"] (SentencePiece)
3. WordPiece(BERT 用的)
类似 BPE,但合并准则不一样——它优化的是语言模型的对数似然而不是单纯”最频繁”。
特点:用 ## 表示非词首子词。
"unhappy" → ["un", "##happy"]
第五站:用代码做一遍
# 用 HuggingFace tokenizers 训一个简单的 BPE
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
trainer = trainers.BpeTrainer(
vocab_size=5000,
special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]"]
)
# 训练
tokenizer.train(files=["my_corpus.txt"], trainer=trainer)
# 用它编码
encoded = tokenizer.encode("Hello, AI world!")
print(encoded.tokens) # ['Hello', ',', 'AI', 'world', '!']
print(encoded.ids) # [4567, 89, 1234, 567, 90]
或者直接调商用 tokenizer:
# tiktoken 是 OpenAI 开源的 GPT tokenizer
import tiktoken
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 用的
tokens = enc.encode("Hello, AI world! 你好!🌍")
print(tokens) # token IDs
print(len(tokens)) # 总 token 数
print([enc.decode([t]) for t in tokens]) # 还原每个 token
第六站:为什么 GPT 数不清字母
L0-04 我们说 LLM 会幻觉。有些幻觉是因为 tokenizer——
问 GPT-4:“strawberry 里有几个 r?” 它经常答错。
原因:
"strawberry" → ["st", "raw", "ber", "ry"] # 4 个 token
GPT 看不到单独的字母——它看到的是 4 个 token 组成的”块”。它没有”字母级”的认知——只有”token 级”。
这也解释了:
- 为什么 GPT 算字符数容易错
- 为什么处理 anagram 类问题不行
- 为什么提示词错别字会让它”理解错”——一个错字可能让整个 token 序列完全不同
LLM 不”读”句子——它读 token 序列。对人类显而易见的”字母组成”,对 LLM 来说是隐藏在 token 后面的内部细节。
第七站:tokenizer 影响一切
| 维度 | tokenizer 怎么影响 |
|---|---|
| API 费用 | 按 token 收费——切得越细越贵 |
| 上下文长度 | ”128k 上下文”是 128k token,不是字 |
| 训练效率 | token 越少,前向计算越快 |
| 多语言能力 | 词表里有多少中文、阿拉伯文 token 决定了模型在那门语言上的表现 |
| 生成多样性 | tokenizer 决定了模型能”说”出哪些组合 |
一个关键发现:Llama 系列在中文上的能力,被它的 tokenizer 严重拖累。它的词表 90% 是英文 token,中文的字几乎都是 byte 级切分——意味着它”读”中文比”读”英文费力 3 倍。Llama 3 后来扩了多语言词表,但仍然不如专门为中文优化的 Qwen。
第八站:高级话题
Tokenizer 是固定的吗?
训练时定,之后不能改。
如果你想给模型加新的”专有名词 token”(比如某个公司名),必须重训模型——这是个工程难题。
能不能不用 tokenizer?
有研究在尝试”无 tokenizer” LLM(如 ByT5,纯字节级)——但代价是序列变长,计算量增加 10 倍。
未来可能有混合方案:粗粒度 + 字节级冗余。
Tokenizer 也是攻击面
恶意攻击者可以用”特殊 token 字符串”让模型行为异常。 例如某些 token 在训练时几乎没见过——它们触发的模型行为完全不可预测。
一句话总结
Tokenizer = LLM 的输入端。
它决定了模型怎么看世界、为什么算不清字母、为什么中文贵。
改变 tokenizer,就是改变 LLM 的基础设施。
想”看见”它
👀 Tokenizer Playground 可视化 —— 输入文字,看 GPT/Claude/Llama 怎么切,对比 token 数差异,估算 API 费用。
你已经懂了 tokenizer(输入端)。接下来要懂”位置编码”(让 Transformer 知道 token 顺序)和”采样”(输出端的概率分布怎么选词)。
- L4-03 · 位置编码(RoPE / ALiBi)
- L4-04 · 采样策略(结合 LLM 采样可视化)
读到这里说明你认真在学 🎯
订阅每周精选 —— 下一篇新文章 / 新可视化第一时间送到邮箱。
讨论区
· 用 GitHub 账号登录评论src/components/Comments.astro 顶部填入
仓库 ID 和分类 ID(见组件注释里的配置步骤)。