从0训练LLM · 现代大模型体系 阅读 + 实验 ≈ 35 分钟 页内实验 ×2
C1 文字怎么变成数字 第 3 课

BPE 怎么学会「该怎么切」?

BPE:从统计里长出词表
BPE 从单个字节出发,每一步把语料里最常挨在一起的一对粘成新 token。粘多少次,词表就长多少——切法不是人规定的,是统计出来的。

本课一问:BPE 怎么学会「该怎么切」?

这一课先不碰:为什么初始单元是字节而不是字(岔路课「为什么中文更贵」)、词表该定多大(第 4 课)。只看 BPE 的训练规则,以及它在真语料上一步步做了什么。

② 从已有出发

子词切分:哪些块算「常见」?

从已有出发 · 累计数据流 点已学环节看形状与参数
解码层

第 2 课的结论是子词切分:常见的字串整块进词表,罕见的拆成碎片。贯穿例句「小明把苹果给了小王」在 Qwen3.8 里是 5 块:小明 / 把 / 苹果 / 给了 / 小王。剩下一个问题——谁来决定「小明」「苹果」「给了」是整块?

③ 先自己设计

朴素的想法:人来规定怎么切

最直接的办法是人来定:写一套切分规则,或者编一本词典,词典里有的就整块切。看看我们自己那份不大的训练语料(三国演义),要为多少种「块」做决定:

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案人来规定切法:写规则,或者编一本词典
④ 改进到现代做法

BPE 的四步规则

本课在演进图里的位置:第一代子词切分。

文字 → 数字的演进:每一步都被上一步的某个真数字逼出来 · 高亮 = 本课
  1. 起点 每个字符直接用 Unicode 码点当编号 最容易想到的做法
    解决了不用训练,任何文字都能编号。
    卡在码点从 0 排到 1,114,111,embedding 表得开 1,114,112 行,是 Qwen3.8 实际 248,320 行的 4.5 倍;而且一个字一个 ID,序列一点不缩短。
  2. 朴素 ① 按字切(字符级词表) 只收语料里出现过的字
    解决了字符集封闭,词表小。
    卡在序列最长:209 字的段落按字切要做 43,681 次注意力打分,是 Qwen3.8 子词切分(106 个 token)的 3.9 倍。
  3. 朴素 ② 按词切 + 固定词表 早期神经机器翻译的做法(Sennrich 2015 的对照组)
    解决了一个词一块,序列短。
    卡在词是开放集合:我们的语料按空白和标点切出 70,310 种块,94.8% 只出现过一次;验证集里连训练集没见过的字都有 83 种——含它们的词只能记成 UNK。
  4. 第一代 子词 BPE(以字符为初始单元) Gage 1994 压缩算法 → Sennrich 2015 用于切词 本课
    解决了按频次反复合并相邻对:常见的整块、罕见的拆碎,词表大小由合并次数控制。
    卡在初始单元是字符:要覆盖全部 Unicode,基础词表就超过 13 万(GPT-2 论文 §2.2);只收见过的字,我们的验证集照样缺 83 种字。
  5. 第二代 字节级 BPE(256 个字节兜底) GPT-2 2019
    解决了初始词表就是 256 个字节,任何文字都能编码,永远不出 UNK。
    卡在词表没学过的语言会被切碎:同义的中文段落,GPT-2 切 461 个 token,Qwen3.8 切 106 个,差 4.3 倍;同义英文段落两者一样多。
  6. 第三代 为多语言训练的大词表 2024–2026
    解决了中文常用字串整块进词表:「我喜欢机器学习」只要 2 个 token(GPT-2 要 17 个)。各家词表:DeepSeek-V3 129,280 · Qwen3 151,936 · GLM-5.3 154,880 · Kimi K3 163,840 · Qwen3.8 248,320。
    卡在两头各一张 V × d 的表:2 × 248,320 × 5,120 = 2,542,796,800 个参数,占 Qwen3.8 的 9.2%;同样的词表搬到我们 d = 256 的小模型上,embedding 占 95.3%。
  7. 表示 ① ID → Embedding 查表 神经语言模型的标准做法
    解决了ID 只是行号,取出一行 5,120 维向量;one-hot 乘矩阵与查表等价,但查表只拷现成的数。
    卡在表里的 1,271,398,400 个数(输入端一张表)从哪来?人填不完,也不知道每一维该是什么意思。
  8. 表示 ② 随机初始化、和整个模型一起训练的 Embedding Bengio 等 2003 → 今天的 LLM
    解决了用「预测下一个 token」的 loss 端到端训练,用法相近的 token 被推到相近的位置。
    卡在只在出现时才被推:我们的项目里一个 batch 8,192 个位置,平均只出现 1,453 个不同 token;罕见 token 学得少——MiniMax 的「嘉祺」在后训练里被推偏,输出层那一行与原来的余弦只剩 0.329。
  9. 表示 ③ 向量该多宽(hidden size d) 各家自选:GPT-2 768 · Qwen3-8B 4,096 · Qwen3.8-2.4T 8,192 · Qwen3.8-27B 5,120
    解决了维度越高,每个 token 能装的信息越多。
    卡在参数按 d² 涨:我们的模型 d 从 128 到 256,总参数 1,066,112 → 3,688,704,val loss 4.776 → 4.641(d = 256 这一档是参照 run ref_baseline,与项目基线同配置同种子;基线记录的 4.626 与它之差是重跑波动,见 C14);没有公式能给出「最优的 d」。
数字口径:码点总数按 Unicode 标准(U+0000–U+10FFFF);词表与宽度取各模型官方 config.json(anchor-model.json、frontier-models.json);token 数取真 Qwen3.8 / GPT-2 分词器的离线切分(c1-tokenize.json);语料统计、batch 覆盖、宽度扫描取项目实测(c1-tokens.json);「13 万以上」出自 GPT-2 论文 §2.2;「嘉祺」的余弦 0.329 出自 MiniMax 官方博客 2026-05-25(官方自报)。

BPE 的训练只有一条规则,重复执行:

1. 把语料全部拆成最小单元,作为初始词表(字节级 BPE 用 256 个字节);
2. 统计所有相邻单元对的出现次数;
3. 把出现最多的那一对合并成一个新单元,加进词表,并把语料里所有这一对都替换成新单元;
4. 回到第 2 步,直到词表达到设定的大小——这是你来定的超参数。

🔍 BPE 的来历

BPE 不是为 NLP 发明的。它是 Philip Gage 1994 年发表的一个数据压缩算法(《A New Algorithm for Data Compression》,C Users Journal 1994 年 2 月,网页存档):反复把最常见的一对字节替换成一个没用过的字节。Sennrich、Haddow、Birch 2015 年把它改造成子词切分(本地 PDF · arXiv:1508.07909,ACL 2016),要解决的是机器翻译里的罕见词:词表装不下所有词,没见过的词只能输出 UNK。论文 §3.2 的原话要点:最终词表大小 = 初始词表大小 + 合并次数,合并次数是这个算法唯一的超参数。注意归属:BPE 算法不是 Sennrich 发明的,他们的贡献是把它用来切词。

训练完得到两样东西:词表(每个单元一个 ID)和合并规则表(按学到的先后排好)。之后切任何新文本,都是按同样的先后顺序套用合并规则——哪怕这个词训练时从没见过,也能被切成词表里已有的块。

先在 4 个英文词的小语料上亲手跑一遍真 BPE,每点一次就执行一步:

模拟器 · 亲手训练一个 BPE(真算法,小语料)

语料只有 4 个词(右上角是出现次数)。每点一次「执行合并」,就执行一步真正的 BPE 训练:先看下面的相邻对统计表,最高频的一对(紫色行)将被合并。

相邻对出现次数
已学到的合并规则(按顺序):
(还没有)
🎯 合并 8~10 步后试试:用学到的规则切一个语料里没有的新词 lowest →

小语料上看清了规则,再看真语料。下面是我们项目的分词器在三国演义上的前 30 步真实合并,每一步合并了哪一对、这一对出现了多少次、合并后语料还剩多少 token,全部来自训练日志:

真实日志 · 我们的分词器在真语料上的前 30 步
步合并哪一对新 token这一对出现次数合并后语料 token 数

压轴:用这份真合并表切一句语料里没有的话

注意看两件小语料上看不到的事:① 前几步合并的多是半个字——汉字和全角标点的 UTF-8 编码是 3 个字节,BPE 先把最常见的前两个字节粘起来,再和第三个字节拼成完整的字(为什么是字节,岔路课「为什么中文更贵」讲);② 压轴的输入框里,语料里没有的简体现代句会被切得很碎,拼不出的字停在字节层面——分词器只认识它统计过的东西。

⚠

常见误解:「词表是人编的一本字典」。 词表里的每一项,都是某一步合并的产物;哪一步合并谁,由那一刻语料里的频次决定。人只定了两件事:在什么语料上训练、合并到多大为止。所以同一套算法、不同的语料,会长出完全不同的词表——GPT-2 与 Qwen3.8 切中文差几倍,根源就在这里。

🔍 为什么先切块、再在块里合并

GPT-2 论文 §2.2 观察到:直接在整段字节上跑 BPE,贪心的频次合并会造出 dog.、dog!、dog? 这类「词 + 标点」的 token,白白占掉词表名额。所以它规定不跨字符类别合并(空格除外)——实现上就是先用一条正则把文本切成「字母串 / 数字串 / 标点串 / 空白」这样的块,合并只在块内发生。我们的项目分词器用的是同一条规则(minillm/tokenizer.py 的 GPT2_SPLIT_PATTERN);Qwen3.8 的正则还把数字一位一位切开。(GPT-2 论文本地 PDF)

🔍 我们踩过的坑:正则漏了下划线

项目分词器最初把 GPT-2 的正则改写成 Python 标准库能用的写法时,字母类写成 [^\W\d_]、标点类写成 [^\s\w]——两类都没有包含下划线。结果下划线不属于任何一类,预切分时被静默跳过:decode(encode("snake_case")) 得到的是 snakecase。项目的往返测试照样通过,因为测试语料三国演义里一个下划线都没有。修法是把标点类改成 (?:[^\s\w]|_)+,并在测试里补一条含下划线的样例(2026-09-26 修复)。教训:第 1 课说字节级 BPE 的往返可逆「可以证明」,证明的前提是预切分不丢字符;测试只能覆盖测试数据里出现过的字符类别。

⑤ 极小真数字,算一次

一步合并,拆开看

用模拟器里的小语料(low ×5、lower ×2、newest ×6、widest ×3)算第一步。点公式里每一项:

BPE 的一步
=
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
合并之后:词表多一项 es;语料里每个 e s 相邻处都换成 es,token 总数恰好减少被合并的次数。模拟器第 3、4 步合并 l+o(5 + 2 = 7)与 lo+w(7),此时 lowest 这个语料里没有的词会被切成 low | est——新词用学过的块拼出来。
👀

试三件事: ① 模拟器里点 4 次合并,再点「切分 lowest」,看它被切成哪两块;② 真实日志里一键跑完 30 步,数一数「半个字」「一个完整字符」「多个字符」各占几步;③ 压轴输入框里切「小明把苹果给了小王。」,再切一句三国原文,比较虚线框(半个字)的数量——这就是训练语料决定词表的现场证据。

⑦ 证据三问

BPE 凭什么成了标准做法

⑧ ⑨ ⑩ 带走与下一步

一句话、下一个缺口、你的提问

📌

一句话: BPE 从单个字节出发,每一步把语料里最常挨在一起的一对粘成新 token——粘多少次,词表就长多少;切法是统计出来的,不是人规定的。

下一个缺口

「合并到多大为止」是你要拍板的超参数。那词表该多大?越大越好吗?第 4 课:词表该多大?

你的提问

C1 还没有真实提问记录。你可能会问: