Skip to content

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)决定。


运行

bash
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)有以下区别:

对比项本实现真实实现
词表大小512GPT-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,是模型理解语义的起点。