Tokenizer:文字变数字
模型的输入不是文字,是整数。Tokenizer 就是把文字变成整数的翻译官。
这一章做什么?
从零实现一个字节级 BPE Tokenizer:训练合并规则、编码文本为 token id、解码还原。完成后你会得到一个 512 词表的 Tokenizer,能对任意 Unicode 文本无损编解码——包括中文、emoji、代码,永远不会出现 <unk>。
从问题出发:LLM 的输入是什么?
LLM(大语言模型)本质是一个数学函数:接受一串整数,输出下一个整数的概率分布。这串整数就是 token id。
问题来了:怎么把任意文本变成整数序列?
最直觉的两种方案,以及它们各自的致命问题:
方案一:字符级(每个字符一个ID)
"Hello" → ['H','e','l','l','o'] → [72, 101, 108, 108, 111]
优点:词表只需 ~128 个条目(ASCII)或 ~65536(Unicode)
问题:序列太长。"The quick brown fox" 是 19 个字符,
对 Transformer 来说要处理 19 步,而且 'T','h','e'
三步才拼出一个语义单元,注意力计算代价随序列长度平方增长。
方案二:词语级(每个词一个ID)
"Hello" → ["Hello"] → [31414]
"Hello!" → ["Hello!"] → ??? 词表里没有!
优点:序列短,一个词一步
问题:词表爆炸。英语常用词约10万,加上变形、专有名词、
代码符号、表情符号……轻松突破百万。
更致命:遇到训练时没见过的词,只能输出 <unk>(未知)。
"ChatGPT" 对词语级分词器来说就是陌生词。这两个方案的矛盾是词汇量 vs 序列长度的权衡。BPE 在这两者之间找到了平衡点。
BPE 解决了什么问题
BPE(Byte Pair Encoding,字节对编码)的核心思想很简单:
常见的字节序列合并成一个 token,罕见的保持分开。
训练完成后,同样的文本:
"Hello" → [9707] ← 训练语料中"Hello"足够常见,合并成了单个token
"Hëllo" → [39, 235, 297, 297, 78] ← 带变音符号,不常见,拆成字节
"你好" → [57668, 53901] ← 中文字符按UTF-8字节合并序列长度适中,词表大小可控(GPT-4 用约 100K),且永远不会出现 <unk>。
字节级 BPE:覆盖任意文本的保证
"字节级"是关键设计决策。Unicode 世界里有 14 万多个字符,但 UTF-8 编码只用到 0x00~0xFF 共 256 个字节值。
字节级 BPE 的词表从 256 个单字节 token 出发:
基础词表(256个,覆盖所有字节):
token_id 0 → 字节 0x00
token_id 72 → 字节 0x48 = 'H'
token_id 228 → 字节 0xE4 ← 中文"你"的UTF-8首字节
token_id 184 → 字节 0xB8 ← 中文"你"的UTF-8第二字节
token_id 141 → 字节 0x8D ← 中文"你"的UTF-8第三字节
...(256个覆盖全部)
扩展词表(256+,通过训练合并生成):
token_id 256 → 字节序列 b"th" ← 英文里 t+h 最高频
token_id 257 → 字节序列 b"the" ← the 极其常见
token_id 258 → 字节序列 b"the " ← 带空格的"the "也很常见
...这个设计保证了一件事:任何 UTF-8 文本都能编码,最坏情况退化到逐字节,绝不会出现 <unk>。
BPE 训练过程
从基础词表出发,反复合并最高频的相邻 token 对:
训练语料(字节序列):
"the the the hello hello ..."
初始状态(每字节一个token):
[116, 104, 101, 32, 116, 104, 101, 32, ...]
't' 'h' 'e' ' ' 't' 'h' 'e' ' '
统计相邻对出现频率:
(116, 104) 即 't'+'h' → 出现最多次 ← 找到最高频对
第1次合并:把所有 (t, h) 替换为新token 256
[256, 101, 32, 256, 101, 32, ...]
"th" 'e' ' ' "th" 'e' ' '
再统计相邻对:
(256, 101) 即 "th"+'e' → 现在出现最多次
第2次合并:把所有 (th, e) 替换为新token 257
[257, 32, 257, 32, ...]
"the" ' ' "the" ' '
...重复N次,直到达到目标词表大小本步骤的 tokenizer.py 实现了完整的训练循环(_train_merges),在固定样本文本上训练 256 条合并规则,最终词表大小为 512(256 基础 + 256 合并)。
编码(encode)过程
训练好之后,编码一段新文本的步骤:
输入文本: "Hello, world!"
Step 1:UTF-8 → 字节序列
[72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
'H' 'e' 'l' 'l' 'o' ',' ' ' 'w' 'o' 'r' 'l' 'd' '!'
Step 2:按训练好的合并规则,从第1条规则到最后一条,依次扫描替换
规则256:(t=116, h=104) → 文本里没有 th,跳过
规则257:(th=256, e=101) → 没有,跳过
...
(贪心地应用所有规则)
输出:一串 token id(比原始字节序列短)注意:编码时按训练顺序依次应用合并规则,这是保证编码确定性的关键。
解码(decode)过程
解码非常简单,每个 token id 查词表得到对应字节序列,拼接后 UTF-8 解码:
token ids: [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
查词表:
72 → b'H'
101 → b'e'
108 → b'l'
...
拼接所有字节:b'Hello, world!'
UTF-8 解码:'Hello, world!'解码的正确性依赖词表完整记录了每个 token id 对应的字节序列。
特殊 Token
模型推理时需要一些控制信号,这些信号不对应任何真实文本,但也占据 token id:
| Token | 全称 | 作用 |
|---|---|---|
<bos> | Begin of Sequence | 标记输入开始,模型据此知道这是一条新的对话/文本 |
<eos> | End of Sequence | 模型生成这个 token 时,推理引擎停止生成 |
<pad> | Padding | 批量推理时,短序列末尾填充到相同长度 |
这些特殊 token 的 id 通常在普通词表 id 之后,具体值由模型配置文件(tokenizer_config.json)决定。
运行
python run.py预期输出:
原文: 'Hello, world!'
Token IDs: [...] ← 长度比字符数少,说明发生了合并
词表大小: 512
解码还原: 'Hello, world!'
中文 round-trip OK: '你好世界' → [...] → '你好世界'
✅ step01_tokenizer 通过注意 Token IDs 的长度比原始字符数少——这说明 BPE 成功地把高频字符对合并成了更大的单元。中文 round-trip 验证了字节级 BPE 的核心承诺:任意 Unicode 文本都能无损编解码,永远不会出现 <unk>。
与真实 Tokenizer 的差异
本步骤的实现是教学用简化版,与生产环境(tiktoken、sentencepiece)有以下区别:
| 对比项 | 本实现 | 真实实现 |
|---|---|---|
| 词表大小 | 512 | GPT-4 约 100K,LLaMA 约 32K |
| 训练数据 | 4 行固定文本 | 数百 GB 多语言语料 |
| 预分词 | 无 | 按正则表达式先切分(保证数字、标点单独处理) |
| 合并规则存储 | Python dict | 二进制文件,加载更快 |
| 训练 | 每次实例化重新训练 | 离线训练,运行时只做 encode/decode |
最关键的差异是预分词(pre-tokenization):GPT 系列在 BPE 之前先用正则表达式把文本切成片段(比如"不跨越空格合并"),避免 "dog" 和 " dog" 合并为同一个 token,这影响词表的语义质量。
小结
Tokenizer 解决的是"文字变整数"的问题。字符级太长,词语级太大,BPE 在两者之间取平衡:高频序列合并成单 token,罕见的退化到逐字节。字节级 BPE 从 256 个单字节出发,保证任意 UTF-8 文本都能编码,永不 <unk>。训练只做一次(反复合并最高频对),推理时按训练顺序贪心应用合并规则。
下一步
现在文字变成了整数序列,但模型的输入层接受的不是整数——它需要向量。整数 42 和 43 在数值上只差 1,但对应的词可能毫无关系。怎样让数字携带语义信息?
→ Embedding:数字变向量——每个 token id 对应一行可训练的参数矩阵,这个过程叫 Embedding lookup,是模型理解语义的起点。