bobofamilyNIU CHUN
28/36

一张合并表就是分词器:为什么要分词,以及从零写 BPE

2026-09-2615 分钟—阅读

第 23 篇说 token 是自己定义的,那时用的是一个字符一个编号的 stoi;第 27 篇说文本进分词器之前先变成字节。 这一篇接上中间那一段:字节怎么变成 token。

文字 → UTF-8 字节(第 27 篇) → BPE 合并(本篇) → token 编号 → 模型

模型只看编号

第 23 到 26 篇用的是字符级分词:莎士比亚里出现过的 65 个字符,一个字符一个编号,嵌入表 65 行。

"hii there"
↓ 按字符切,查 stoi 表
[46, 47, 47, 1, 58, 46, 43, 56, 43] ← 9 个 token
↓ 每个编号取出嵌入表的一行
进入模型

模型从来看不到文字,只看到编号。 分词器是在文字和编号之间来回翻译的那一层。 真实的模型不按字符切,而是按「块」切:常见的词是一整块,少见的词拆成几块。 GPT-2 的词表有 50,257 个 token,上下文 1024 —— 1024 数的是 token,不是字符。

token 是大模型的基本单位:上下文多长、训练数据多大、API 怎么计费,全按 token 算。

切法决定模型看见什么

Karpathy 在视频开头列了一串大模型的怪毛病,根子都在分词。其中四条:

现象原因
不会拼写,数不清一个词里有几个 r egg 对模型是一个编号,它看不到 e、g、g 三个字母;Egg、EGG 又是另外的编号,是同一个词这件事只能从数据里学
算术差数字被切成的块和数位对不上,做加法要个位对个位,模型看到的「块」却是乱的
非英语更差分词器主要用英文训练,别的语言很少被合并成大块,同样的内容要多花很多 token
GPT-2 写 Python 吃力缩进的每个空格都占一个 token,代码里到处是缩进

最后一条在视频里有下文:GPT-4 的分词器把连续的空格合成一块,这是它写代码比 GPT-2 好的原因之一。

同一段话,换个分词器就是另一串编号

OpenAI 发布 GPT-4o 时公布过一组对比:新分词器 o200k_base(词表约 20 万)和 GPT-4 用的 cl100k_base(约 10 万) 切同样的文字,中日韩越这些东亚语言的 token 数少了 1.4—1.7 倍,印地语等印度语言少了 2.9—4.4 倍。

原因在 BPE 本身:词表更大,能合并出更多、更长的块;而分词器训练时见到某种语言越多, 这种语言的片段就越容易被合成整块 —— 下面的算法会说明为什么。

🔑 同一个模型家族、同一段文字,只换分词器,token 数就能差出几倍 —— 一段话花多少 token,是分词器决定的,不是模型。 第 23 篇说「选 Qwen 是因为中文 token 少」,底下就是这件事。

词表不能无限大

每多一个 token,嵌入表多一行、输出层多一列。拿第 25 篇实验 D 的模型算(每个 token 384 维):

词表参数总数嵌入表 + 输出层占
字符级6510,788,9290.5%
换成 GPT-2 的词表50,257约 4,939 万78%

多出来的 3,860 万个参数全在这两层上。第 23 篇说「小模型配大词表很别扭」,这张表是它的具体数字: 词表越大,同样的文字切出的 token 越少,但模型要花越多参数去存这些 token 本身。


码点太多,字节太长

模型要的是一串整数。把文字变成整数,现成的路有两条,两条都走不通 —— 这正是 BPE 要解决的问题。

码点当 token

Python 的字符串本来就是一串码点:

>>> [ord(c) for c in "你好,世界"]
[20320, 22909, 65292, 19990, 30028]

不行的原因有两个:词表太大 —— Unicode 收了约 15 万个字符,词表就是整张表;而且不稳定 —— Unicode 每年都在加新字符,词表跟着变,模型就得重新训练。

字节当 token

UTF-8 字节只有 256 种、固定不变、什么文字都能表示(第 27 篇)。UTF-16 和 UTF-32 不考虑: 英文 h 在它们里面是 [104, 0] 和 [104, 0, 0, 0],塞满了 0。

坏处是序列太长。第 27 篇那句中文:

分词是大模型许多怪现象的根源。 15 个字
按字节当 token → 45 个

上下文装得下的内容少了三倍;而注意力要算一张 T×T 的表(第 23 篇的 O(T²)),序列长三倍,计算量是九倍。

BPE:从 256 个字节出发,把常挨在一起的合并

方案词表大小序列长度问题
码点约 15 万,而且会变等于字符数词表太大、不稳定
UTF-8 字节256很长上下文装不下多少内容
BPE自己定(GPT-2 约 5 万)短——

🔑 词表越小,序列越长;词表越大,序列越短。 两者此消彼长,BPE 在中间取一个点: 从 256 个字节出发,把经常挨在一起的一对合并成一个新 token,反复做,直到词表达到想要的大小 —— 保留了「字节能表示一切」,又把序列压短了。

视频里也提到了另一条路:干脆不分词,让模型直接读字节(MEGABYTE,2023,用分层结构的 Transformer)。 Karpathy 的结论是这条路当时还没在大模型上验证过,现阶段还是要用 BPE。


BPE 训练:数相邻对,合并最多的一对,重复

每合并一次,序列短一点、词表多一个成员;合并出来的新符号还能再参与合并。放到字节上,一个汉字的 3 个字节要合并两次才变成一个 token —— 训练完,手里只剩右边那张按顺序排好的表。

算法只有一条规则:找出现次数最多的相邻一对,换成一个新符号,然后重复。 视频用的是维基百科的例子:

aaabdaaabac 出现最多的相邻对是 aa → 换成新符号 Z
ZabdZabac 出现最多的是 ab → 换成 Y
ZYdZYac 出现最多的是 ZY → 换成 X
XdXac 没有出现两次以上的对了,停

序列从 11 个符号缩到 5 个,符号表从 {a, b, c, d} 扩到 {a, b, c, d, Z, Y, X}:每合并一次,序列变短一点,词表多一个成员。 第三步把两个新符号 Z、Y 又合成了 X —— 合并出来的符号还能继续参与合并,X = ZY = aa + ab = aaab。

放到字节上,起点就是 0—255 这 256 个字节,新 token 从 256 开始编号。

只换紧挨着的,重叠从左往右,并列要记顺序

「把最多的一对换成新符号」,换的是这一对两个一起,而且只换真正紧挨着的地方:

# "the tree " 合并 (101, 32),也就是 e 加空格 → 256
[116, 104, 101, 32, 116, 114, 101, 101, 32]
[116, 104, 256, 116, 114, 101, 256] # 9 个变 7 个

「tree」里第一个 e 后面跟的是 e 不是空格,保持 101。词表里的 101 和 32 也照样保留 —— 合并只新增,不删除。

两个容易想错的地方:

  • 重叠时从左往右,先配上的先换:aaa 换成 Za,第三个 a 没有搭档,不动。所以上面那一步 aa 数到 4 次,实际只换了 2 处
  • 并列时选哪个都行,但顺序要记下来:ZabdZabac 里 Za 和 ab 都出现 2 次。维基百科选了 ab;下面代码用 max 会选先数到的 Za。两种都是合法的 BPE —— 要紧的是训练时按什么顺序合并,就原样记进表里

get_stats 与 merge

两个函数就是整个算法:

tokens = list(text.encode("utf-8")) # 先变成 0—255 的整数
def get_stats(ids):
counts = {}
for pair in zip(ids, ids[1:]): # (ids[0],ids[1]), (ids[1],ids[2]), ...
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
newids, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
newids.append(idx) # 这一对换成新编号
i += 2 # 一次跳过两个
else:
newids.append(ids[i])
i += 1
return newids
merge([5, 6, 6, 7, 9, 1], (6, 7), 99) # → [5, 6, 99, 9, 1]
  • zip(ids, ids[1:]):把序列和「往左挪一格的自己」对齐,一次取出所有相邻对
  • merge 从左往右扫,遇到那一对就放入新编号、跳两格,否则照抄、走一格 —— 这就是上面「重叠从左往右」的来源
  • i < len(ids) - 1 防的是在最后一个位置往后越界

莎士比亚全文上数出来的第一名是 (101, 32),e 加空格,27,643 次:英文里大量的词以 e 结尾,后面紧跟空格。

训练循环:merges 表就是分词器

vocab_size = 276 # 想要的词表大小
num_merges = vocab_size - 256 # = 20 次合并
ids = list(tokens) # 复制一份,不改原数据
merges = {} # (p0, p1) -> 新编号
for i in range(num_merges):
stats = get_stats(ids)
pair = max(stats, key=stats.get)
idx = 256 + i
ids = merge(ids, pair, idx)
merges[pair] = idx

在莎士比亚全文上跑 20 次:

合并 1: (101, 32) -> 256 'e ' 合并 11: (97, 110) -> 266 'an'
合并 2: (116, 104) -> 257 'th' 合并 12: (58, 10) -> 267 ':\n'
合并 3: (116, 32) -> 258 't ' 合并 13: (111, 114) -> 268 'or'
合并 4: (115, 32) -> 259 's ' 合并 14: (111, 32) -> 269 'o '
合并 5: (100, 32) -> 260 'd ' 合并 15: (101, 110) -> 270 'en'
合并 6: (44, 32) -> 261 ', ' 合并 16: (10, 10) -> 271 '\n\n'
合并 7: (111, 117) -> 262 'ou' 合并 17: (97, 114) -> 272 'ar'
合并 8: (101, 114) -> 263 'er' 合并 18: (32, 257) -> 273 ' th' ← 257 又被合并了
合并 9: (105, 110) -> 264 'in' 合并 19: (111, 110) -> 274 'on'
合并 10: (121, 32) -> 265 'y ' 合并 20: (108, 108) -> 275 'll'
字节数 1,115,394 → token 数 882,737,压缩率 1.26x
  • 新 token 会再参与合并:第 18 条把第 2 条合出来的 th(257)和前面的空格又合成了 th,多做几轮就会长出 the
  • 合并规则反映数据的样子:第 12 条 :\n 来自剧本格式,每个角色名后面都是冒号加换行(「First Citizen:」)
  • 压缩率 = 字节数 ÷ token 数:20 次合并压缩了 1.26 倍。合并越多,压缩率越高,词表也越大 —— 就是上一节那个取舍

🔑 训练的产物就是 merges 这张表:20 条规则,按学到的先后排好。分词器训练完,手里只有这张表。 词表大小 = 256 + 合并次数:GPT-2 的 50,257 = 256 + 50,000 次合并 + 1 个特殊标记(<|endoftext|>)。 真实的分词器不是等到「没有重复」才停,而是做够预定的合并次数。

汉字的 3 个字节要合并两次

拿一段中文(423 个字、1,227 个字节)做 10 次合并,挑出几条:

合并 4: (231, 154) -> 259 b'\xe7\x9a' 「的」的前两个字节
合并 5: (259, 132) -> 260 b'\xe7\x9a\x84' = 的 ← 再合并一次,凑成完整的字
合并 2: (229, 173) -> 257 b'\xe5\xad'
合并 6: (257, 151) -> 261 b'\xe5\xad\x97' = 字
合并 9: (136, 229) -> 264 b'\x88\xe5' ← 跨字的碎片
字节数 1,227 → token 数 1,011,压缩率 1.21x

「的」= E7 9A 84,要合并两次才变成一个 token:先合前两个字节,再把结果和第三个字节合起来。

第 9 条更有意思:88 是「合」(E5 90 88)的最后一个字节,E5 是「并」(E5 B9 B6)的第一个字节。 这一对的 18 次里,15 次来自「合并」,3 次来自「先合」 —— 两对毫不相干的字,因为交界处的字节一样,被算成了同一对。 BPE 只看字节,不知道字的边界在哪里。

「是不是同一对」只看编号

起点时编号就是字节值;合并之后比的仍是编号,而每个新编号背后是一串固定的字节 —— 归根结底是在比字节序列。 分词器不知道什么是字母、汉字、单词、大小写,也不管意思。三个后果:

  1. 看起来一样、字节不同,就算不同:大小写(E = 69,e = 101)、前面有没有空格;同一个字的多种写法 —— é 可以是一个码点 C3 A9,也可以是 e 加组合重音符 65 CC 81,屏幕上一模一样;全角 ABC(EF BC A1 …) 和半角 ABC(41 42 43)也是。有的分词器在 BPE 之前先把这些写法统一,叫 Unicode 规范化(NFC、NFKC); GPT 的分词器不做,sentencepiece 默认会做
  2. 不同的字共享字节,统计时会合在一起:你 E4 BD A0、住 E4 BD 8F、位 E4 BD 8D、体 E4 BD 93、作 E4 BD 9C、 低 E4 BD 8E,前两个字节都是 E4 BD。(E4, BD) 的次数是这些字加起来的,比其中任何一个完整的字都多 —— 它很容易先被合并,于是这些字各自剩下的那一个字节就成了单独的碎片
  3. 会合出跨字的碎片:上面第 9 条

所以一个汉字的 3 个字节被切开是常事:常见的字学到了完整的合并,是 1 个 token;不够常见的只合并了一部分; 很少见的三个字节各是一个。对模型本身不算错 —— 它只看编号,会从数据里学到「这两块接在一起就是某个字」。 麻烦在把输出还原成文字的时候:

b = "你".encode("utf-8") # b'\xe4\xbd\xa0'
b[:2].decode("utf-8", errors="replace") # '�' 只收到前两个字节的时候
b.decode("utf-8") # '你' 第三个字节也到了以后

模型一块一块地往外吐 token,刚吐出前两个字节时拼不成字 —— 流式输出得先攒着、凑齐了再显示, 否则会闪一下第 27 篇那个 �。


decode 与 encode

encode 每一步挑的不是这段文字里出现最多的那一对,而是表里最早学到的那一对 —— 所以表里明明有 ' th',这里却用不上。decode 只是查表拼字节,但 vocab 必须按训练顺序建:273 要用到 257。

分词器是独立的一步

分词器的训练数据 ──BPE 训练──→ merges 表(从此固定不变)
│
文字 ──encode──→ 编号 ──→ 大模型(训练、生成)
文字 ←──decode── 编号 ←──┘
  • 分词器有自己的训练数据,可以和大模型的不一样。它只训练一次,之后就固定不变
  • 大模型从头到尾只看编号:训练前先把所有训练数据 encode 成一长串编号;模型生成的是编号,要 decode 成文字才能给人看
  • 分词器训练数据里的语言比例很关键:哪种语言在里面多,这种语言学到的合并就多、切出来的 token 就少。 同一段中文在不同分词器下 token 数能差出几倍,这是原因之一

decode:按训练顺序建 vocab

def build_vocab(merges):
vocab = {idx: bytes([idx]) for idx in range(256)} # 0—255:每个编号就是一个字节
for (p0, p1), idx in merges.items(): # 按训练顺序
vocab[idx] = vocab[p0] + vocab[p1] # 新编号 = 两个旧编号的字节拼在一起
return vocab
def decode(ids, vocab):
b = b"".join(vocab[idx] for idx in ids) # 编号 → 字节,拼成一长串
return b.decode("utf-8", errors="replace") # 字节 → 文字
  • 为什么必须按训练顺序建:新编号可能是用更早的新编号拼出来的,273 = 32 + 257,必须先有 257 才算得出 273。 Python 的字典保持插入顺序,遍历 merges.items() 正好就是训练时的顺序
  • 为什么要 errors="replace":不是每一串编号都能拼成合法的文字
bytes([128]).decode("utf-8") # 报错:can't decode byte 0x80 in position 0: invalid start byte
decode([128], vocab) # '�'

128 = 10000000,10 开头的是后续字节,不能出现在一个字的开头(第 27 篇)。模型生成时可能吐出任何编号组合, 包括这种拼不成字的;加上 replace,程序不会崩溃,拼不成字的地方用 � 代替。

encode:每步挑表里最早学到的一对

def encode(text, merges):
ids = list(text.encode("utf-8")) # 文字 → 字节
while len(ids) >= 2:
stats = get_stats(ids)
pair = min(stats, key=lambda p: merges.get(p, float("inf"))) # 表里最早学到的那一对
if pair not in merges: # 剩下的对都不在表里,合并不动了
break
ids = merge(ids, pair, merges[pair])
return ids

关键在每一步挑哪一对:不看这段新文字里哪一对出现得最多,而是在当前所有相邻对里挑 merges 表里编号最小的那一对, 也就是训练时最早学到的。后面的合并建立在前面的合并之上,编码时必须按训练的顺序重放一遍。

  • merges.get(p, float("inf")):不在表里的对,编号当作无穷大,永远不会被选中;选出来的那一对都不在表里,就说明合并不动了
  • while len(ids) >= 2:空字符串或只有一个字节时没有相邻对,不这样判断的话 min 会对着空字典报错

用莎士比亚训练出的 20 条规则,对 the thin 编码:

起点 [116, 104, 101, 32, 116, 104, 105, 110] t h e ␣ t h i n
合并 (101, 32) → 256 [116, 104, 256, 116, 104, 105, 110] 'e ' 是第 1 条规则,最先合
合并 (116, 104) → 257 [257, 256, 257, 105, 110] 'th' 两处一起合
合并 (105, 110) → 264 [257, 256, 257, 264] 'in'
没有能合并的对了 → [257, 256, 257, 264] = 'th' 'e ' 'th' 'in'

❗ 表里有 ’ th’ 却用不上

表里有第 18 条 th(273),切 the thin 时却一次都没用上:第 1 条规则 'e ' 先把空格和前面的 e 合在了一起, 第二个 th 前面已经没有单独的空格了。

🔑 顺序决定结果。 同一张表,换一个合并顺序,切出来的就是另一串编号 —— 而模型是用训练时那种切法学出来的, 编码时切法一变,它看到的就是一串陌生的编号。所以 encode 必须严格按表里的顺序重放。

来回一趟

decode(encode(s)) == s 恒成立。莎士比亚前 5000 个字符是 True;这个分词器完全没见过的 「Hello, 世界!分词器没见过中文也能处理。👋」也是 True —— 中文和 emoji 一条合并都没学到,就保持原始字节, 256 个字节能表示任何文字,所以总能转回来。

encode("the other", merges) # [257, 256, 111, 257, 263] 'er' 用上了第 8 条
encode("你好", merges) # [228, 189, 160, 229, 165, 189] 6 个原始字节,一个中文合并都没有

你好 那一行就是 GPT-2 处理中文时的样子:分词器从英文里学来的规则,碰到中文几乎一条都用不上,只是规模小得多。

反过来就不一定:encode(decode(ids)) 未必等于 ids —— [128] 解码成 �,再编码回去就不是 128 了。


这一篇的代码加起来几十行:get_stats 数相邻对、merge 换掉一对、训练循环记下合并的先后,decode 查表、encode 按顺序重放。 一张按顺序排好的合并表,就是整个分词器。

🔮 真实的 GPT-2 分词器比这多一步:先用正则把文本切成一块一块(单词、数字、标点、空格),只在每一块内部做合并、不跨块。 所以上面那条 'e ' 在 GPT-2 里不会出现 —— 它把空格归到后面的词上,the egg 切成 the 和 egg 两块。 这条规则、OpenAI 的 tiktoken 和特殊标记是下一段(1.3);sentencepiece 和那些怪现象的逐条解释在 1.4。