上承〔01 · 一个 64M 的模型,凭什么像 ChatGPT?〕。上一篇里,我们把模型看成一个大黑盒,输入一段话,输出下一个词。但中间有个环节一直没细说:模型拿到的是文字,算的是数字,文字怎么变成数字?这一篇就是把这个转换过程讲透——从“今天天气真好”这五个字,一路走到五个 768 维的向量,中间的每一步都要摊开来看下。
一、为什么不能直接用文字?
模型说到底是个数学函数,里面全是加法和乘法。它不认识“天”这个字,只认识数。所以第一步,必须把文字翻译成数字。
最直觉的做法是查字典:给每个字编个号,“今”是 1,“天”是 2,“天”又是 2……但这里有个麻烦。中文常用字三千多个,加上标点、数字、英文字母、各种符号,轻松过万。如果再算上生僻字、异体字、网络新词,十万都打不住。词表每多一个词,模型就要多学一组参数;词表膨胀到十万,光 embedding 层就能吃掉大量参数。
词表大有什么问题?这里得先把 embedding 说清楚。模型里有一块专门的矩阵,负责把编号变成向量,术语叫 embedding 矩阵(嵌入矩阵)。它的形状是 词表大小 × 向量维度:每一行对应词表里的一个 token,行里的 768 个数,就是那个 token 的向量表示。查表的过程极其简单——token id 是几,就取第几行。【还记得我们在01篇提到过的高维空间表示吗?向量就是点的高维坐标】
第一篇算过 MiniMind 这块矩阵:6400 行 × 768 列,490 多万个数。如果词表扩到十万,同样 768 维,那就是 7680 万个数,比 MiniMind 整个模型还大。词表大小直接决定 embedding 矩阵有多大,这就是为什么词表不能随便扩。
英文看起来简单一点,但也没那么简单。英语单词可以自由组合,run、running、runner、runs——如果每个形式都进词表,词表会爆炸;如果只按字母拆,那 sentence 就是七个 token,太碎了,模型要花更多力气才能看出“这七个字母拼在一起是一个词”。
所以业界选了第三条路:子词(subword) 。常见的词整块保留,罕见的词拆成几块。run 是一个 token,running 拆成 run + ning,unhappiness 拆成 un + happi + ness。词表不用太大,又能覆盖几乎所有表达。
这条路怎么走?答案是 BPE,Byte Pair Encoding,字节对编码。名字听着像个压缩算法——它最早确实就是干压缩的。
1994 年,Philip Gage 在《C Users Journal》上发了一篇文章,提出一种简单的数据压缩方法:反复找出数据里出现频率最高的相邻字节对,用一个新字节替换它。反复执行,数据就被压小了。
这个方法在压缩领域躺了二十年。2015 年,Sennrich 等人在做机器翻译时,把“字节对”换成“字符对”,用它来切词——词表小了,罕见词也能被拆开处理。2019 年,GPT-2 把 BPE 用在语言模型上,从此成为主流。如今 GPT、Llama、Qwen、DeepSeek,分词器背后都是 BPE 或它的变体。
二、BPE:从数零件开始,逐步造零件
2.1 算法一句话
BPE 的全称是 Byte Pair Encoding,字节对编码。它最早是 1994 年 Philip Gage 提出的一种数据压缩方法,后来被引入 NLP 做分词。核心思想简单到可以用一句话说完:
从最小的单元开始,反复找出出现频率最高的相邻对,把它们合并成一个新单元,直到词表达到预定大小。
这句话里每个词都重要,我们拆开看。
2.2 从一个迷你语料开始
为了把每一步都看清楚,我不用真实语料(太大了),也不只用一句话(太少了)。我构造一个刻意缩小但结构完整的语料,一共四句话:
我 爱 中 国 人
中 国 人 很 多
我 爱 中 国
北 京 是 中 国 首 都
注意:这里每个字之间的空格只是为了展示原子单元的边界。真实训练时,分词器会在每个“词”末尾加一个结束符,用来标记词边界。这个约定后面会解释。
2.3 第一步:把语料拆成原子单元
初始词表就是语料里出现过的所有不同字符。数一下:
| 字符 | 出现次数 |
|---|---|
| 我 | 2 |
| 爱 | 2 |
| 中 | 4 |
| 国 | 4 |
| 人 | 2 |
| 很 | 1 |
| 多 | 1 |
| 北 | 1 |
| 京 | 1 |
| 是 | 1 |
| 首 | 1 |
| 都 | 1 |
一共 12 个不同的字符。初始词表大小 = 12。
2.4 第二步:统计所有相邻对
现在把每句话按原子单元拆开,然后数所有相邻对出现的次数。
句1:我 爱 中 国 人 → 相邻对:(我,爱)、(爱,中)、(中,国)、(国,人) 句2:中 国 人 很 多 → (中,国)、(国,人)、(人,很)、(很,多) 句3:我 爱 中 国 → (我,爱)、(爱,中)、(中,国) 句4:北 京 是 中 国 首 都 → (北,京)、(京,是)、(是,中)、(中,国)、(国,首)、(首,都)
数下来:
| 相邻对 | 出现次数 |
|---|---|
| (中, 国) | 4 |
| (我, 爱) | 2 |
| (爱, 中) | 2 |
| (国, 人) | 2 |
| (人, 很) | 1 |
| (很, 多) | 1 |
| (北, 京) | 1 |
| (京, 是) | 1 |
| (是, 中) | 1 |
| (国, 首) | 1 |
| (首, 都) | 1 |
(中, 国) 出现 4 次,最高。合并。
把它合并成一个新 token:“中国”。加入词表。词表大小变成 13。
合并后,语料变成:
我 爱 中国 人
中国 人 很 多
我 爱 中国
北 京 是 中国 首 都
2.5 第三步:再统计,再合并
重新数相邻对:
| 相邻对 | 出现次数 |
|---|---|
| (我, 爱) | 2 |
| (爱, 中国) | 2 |
| (中国, 人) | 2 |
| (人, 很) | 1 |
| (很, 多) | 1 |
| (北, 京) | 1 |
| (京, 是) | 1 |
| (是, 中国) | 1 |
| (中国, 首) | 1 |
| (首, 都) | 1 |
有三个对并列最高,都出现 2 次。这里需要一条规则:并列时,取最先出现的。(不同实现可能用不同规则,HuggingFace 的 BPE 实现就是按这个顺序来的。)
(我, 爱) 最先出现(在句1里),合并。新 token:“我爱”。词表大小 14。
合并后:
我爱 中国 人
中国 人 很 多
我爱 中国
北 京 是 中国 首 都
2.6 第四步:继续
重新数:
| 相邻对 | 出现次数 |
|---|---|
| (我爱, 中国) | 2 |
| (中国, 人) | 2 |
| (人, 很) | 1 |
| (很, 多) | 1 |
| (北, 京) | 1 |
| (京, 是) | 1 |
| (是, 中国) | 1 |
| (中国, 首) | 1 |
| (首, 都) | 1 |
(我爱, 中国) 和 (中国, 人) 并列 2 次。取最先出现的 (我爱, 中国),合并为“我爱中国”。词表大小 15。
我爱中国 人
中国 人 很 多
我爱中国
北 京 是 中国 首 都
2.7 第五步:再继续
| 相邻对 | 出现次数 |
|---|---|
| (中国, 人) | 2 |
| (人, 很) | 1 |
| (很, 多) | 1 |
| (北, 京) | 1 |
| (京, 是) | 1 |
| (是, 中国) | 1 |
| (中国, 首) | 1 |
| (首, 都) | 1 |
(中国, 人) 最高,合并为“中国人”。词表大小 16。
我爱中国人
中国人 很 多
我爱中国
北 京 是 中国 首 都
2.8 这样一直合并下去
每轮合并,词表就多一个 token。这个“合并”的过程,就是 BPE 训练的全部内容。
MiniMind现在 的词表是 6400。前面说过,BPE 从初始词表开始,每合并一次,词表就多一个 token。初始词表多大?MiniMind 用的是 ByteLevel 模式,初始单元是 256 个字节值。
256 个字节值是什么?就是计算机里一个字节能表示的全部可能——从 0 到 255,这 256 个字节值里,有一部分是我们熟悉的 ASCII 字符:
字节值 32 → 空格
字节值 65 → 'A'
字节值 97 → 'a'
字节值 48 → '0'
字节值 46 → '.'
字节值 44 → ','
另一部分是扩展字节,128 到 255,组合起来能表示各种非 ASCII 字符。比如“今”这个字,UTF-8 编码是三个字节:
今 → E4 BB 8A (十六进制)
→ 228 187 138 (十进制,三个字节值)
在 ByteLevel 模式下,“今”进入分词器时,先被转成这三个字节,每个字节对应初始词表里的一个 token。BPE 的合并,就是在这三个字节的基础上,把经常一起出现的字节串合并成更大的单元。
所以初始词表 256 个,不是什么神秘数字,就是一个字节的全部可能取值。任何文本——中文、英文、emoji、代码——转成 UTF-8 字节后,都落在这 256 个值里。BPE 从这 256 个出发,一步步合并,最终长出 6400 个 token。
从 256 出发,每合并一次加一个 token。要长到 6400,需要合并 6400 − 256 = 6144 次。也就是说,MiniMind 的分词器在训练语料上跑了 6144 轮合并,每一轮选出当时频率最高的相邻对,合并成新 token。
6144 轮,每一轮加一个 token,最终凑出 6400 个。词表里存的,就是这 256 个原始字节,加上 6144 个合并出来的新 token。新 token 里,有单字、有双字词、有三字词、有常见短语,长短不一。
(严格算的话,还要扣掉特殊 token 占的位置。MiniMind 的词表里有几个手工加的特殊 token,比如 <|im_start|>、<|im_end|>、<|endoftext|>,它们不参与 BPE 合并,但占词表编号。所以实际合并次数比 6144 略少。)
合并完,词表里存的就不只是单个字了——有单字、有双字词、有三字词、有常见短语。这套合并规则就是分词器的全部家当。
2.9 一个容易忽略的细节:词边界
前面为了讲清楚,语料里字和字之间都留了空格。但真实中文没有空格,怎么办?
做法是:在每个“词”的末尾加一个特殊的结束符。
比如“我爱中国”这个词,预处理后写成 我 爱 中 国 </w>,其中 </w> 表示“词到此结束”。这样 BPE 在统计相邻对时,就知道 (国, </w>) 表示“国”在词尾,和 (国, 人)(“国”后面跟着“人”)是不同的东西。
这个细节直接影响合并的结果。“国”单独成词时后面跟 </w>,“国”后面跟“人”组成“国人”时后面跟“人”——BPE 会学到这两种模式,分别处理。
MiniMind 的 tokenizer 用的是 ByteLevel 模式,不显式用 </w> 标记,而是在每个词前面加一个特殊的前缀字节(通常是空格对应的字节 Ġ,这 是一个 Unicode 字符,长得像带点的大写 G。)。效果一样:让 BPE 知道词从哪里开始。README 里写得很清楚:MiniMind 使用的是自定义的 BPE + ByteLevel 分词器,词表大小为 6400。
两种方式的对应关系是:
| 场景 | 传统 写法 | ByteLevel Ġ 写法 |
|---|---|---|
| “我爱中国”是一个词 | 我 爱 中 国 | 我爱中国(无空格,无 Ġ) |
| “我”“爱”“中”“国”是四个词 | 我 爱 中 国 | 我 Ġ爱 Ġ中 Ġ国 |
2.10 从合并规则到分词
训练完成后,分词器手里有两样东西:
- 词表:所有 token 的集合,6400 个。
- 合并规则:按顺序排列的合并历史,每一步记录了“当时合并了哪一对”。
分词的时候,拿到一句新话,先按原子单元拆开,然后按合并规则的顺序,能做哪步合并就做哪步。最终得到一串 token。
举个例子。假设合并规则按顺序是:
- (中, 国) → 中国
- (我, 爱) → 我爱
- (我爱, 中国) → 我爱中国
- (中国, 人) → 中国人
现在来了一个新词:“中国人”。
先拆成:中 国 人
按规则 1:中 国 → 中国。变成:中国 人 按规则 4:中国 人 → 中国人。变成:中国人
结果:“中国人”是一个 token。
但如果新词是“中国城”呢?
先拆成:中 国 城 按规则 1:中 国 → 中国。变成:中国 城 规则 2、3、4 都不适用(没有“我 爱”对,没有“我爱 中国”对,没有“中国 人”对)。
所以“中国城”分成两个 token:中国 + 城。
这就是 BPE 的核心机制:常见组合合并成一个 token,罕见组合保持拆分。 一个词在训练语料里出现得越多,它越可能被合并成一个整 token;出现得越少,越可能被拆成多个。
2.10.1 BPE 和模型用同一份语料吗?
不必须,但通常高度重叠。
BPE 训练用的语料,和模型训练用的语料,往往来自同一个池子。原因很简单:分词器要在模型见到的文本上切得好,就得见过那种文本。如果 BPE 只见过新闻,模型却在读小说,那小说里的高频词在词表里可能全是碎片,切得稀烂,模型学起来就费劲。
但两者用的量不一样。BPE 通常只从语料池里抽一小部分——几十 GB 到几百 GB,跑一遍统计就够了。模型训练用的是整个语料池,过很多遍。所以是同源,不同量。
2.10.2 模型更新、语料变多时,BPE 怎么办?
这里有个硬约束:分词器和模型是绑死的。
模型里的 embedding 矩阵,每一行对应一个 token id。第 1024 行是“今”的向量,第 875 行是“天”的向量。这个对应关系是训练时定下的。如果换了分词器,token id 全变了,之前训练的 embedding 矩阵就全废了——第 1024 行不再对应“今”,整个模型的输入层和输出层都得重新训。
所以实际做法是:
如果只是给模型加数据继续训练(continue training),分词器不动。
新数据进来,用旧分词器切,切出来还是旧词表里的 token id。模型继续在旧 embedding 矩阵上更新。这是最省事、最常见的做法。
如果是训练一个全新版本的模型,可以换分词器。
比如 GPT-2 到 GPT-3.5,分词器从 5 万扩到 10 万;GPT-3.5 到 GPT-4o,又扩到 20 万。换分词器意味着模型从头训——embedding 矩阵重来,输出层重来。代价很大,但换来了更好的压缩率:同样一段中文,10 万词表比 5 万词表少切出不少 token,推理更快、成本更低。
Llama 也是这样。Llama 1 和 Llama 2 都用 32K 词表,到 Llama 3 才换成 128K,整个模型重新训。
所以规律是:
- 同一个模型版本内:分词器固定,加数据、继续训、微调,都不动它。
- 跨大版本:可以换分词器,但模型要从头训。
- 换不换,看收益:词表大一点,压缩率高一点,但 embedding 层参数也大一点。值不值,看模型规模。
2.10.3 MiniMind 的情况。
MiniMind 的分词器是独立的 train_tokenizer.py 训出来的,词表 6400。模型训练脚本 train_pretrain.py 加载这个分词器,用它切语料。两者绑死。
如果哪天 MiniMind 想换更大的词表,那就得重新跑 train_tokenizer.py,然后 train_pretrain.py 从零开始——之前训的权重全作废。所以小项目一般不动分词器,一次定好,后面一直用。
一句话:分词器是模型的地基。地基可以换,但换了就得重盖房子。
2.11 完整走一遍 MiniMind 的真实例子
光看构造的例子还不够。我们拿 MiniMind 的词表,对“今天天气真好”走一遍。
MiniMind 的 tokenizer 训练脚本是 train_tokenizer.py。训练好的词表存在 tokenizer.json 里,里面有一条 merges 列表,记录了所有合并规则,按训练时的顺序排列。
分词的时候,HuggingFace 的 tokenizers 库会按这个顺序执行:
- 先把文本做 ByteLevel 预处理,加前缀字节。
- 然后按
merges列表的顺序,逐条尝试合并。 - 最后把每个 token 映射成词表里的编号(token id)。
“今天天气真好”这五个字,在 MiniMind 的词表下,可能的分词结果是:
今 天 天 气 真 好 → 6 个 token
或者,如果训练语料里“天气”出现得足够频繁:
今 天 天气 真 好 → 5 个 token
甚至“今天”和“天气”都合并了:
今天 天气 真 好 → 4 个 token
具体是几个 token,取决于训练时词表里有没有这些组合。
MiniMind 的词表只有 6400。这个尺寸决定了:常见的单字、双字词会被收录,三字及以上的词很可能被拆开。README 里也坦承了这一点:minimind_tokenizer 的词表只有 6400,编解码效率弱于 qwen2、glm 等更偏中文友好的 tokenizer,但它能显著压缩 embedding 层和输出层的参数占比,更适合小模型的体积约束。
第一篇里算过:MiniMind 的词典参数是 6400 × 768 = 4,915,200。如果词表扩到 15 万(像 Qwen2 那样),词典参数就是 151,643 × 768 ≈ 1.16 亿——比 MiniMind 整个模型的参数还多。
所以 6400 不是“够用”,是“在 64M 的预算里,只能给词表这么多”。词表再大,embedding 层就把模型撑爆了。
三、从 token 到编号,从编号到向量
3.1 token id
分词完成后,每个 token 要查表换成编号。词表里每个 token 有一个唯一的整数编号,从 0 开始,到 6399 结束。
“今天天气真好”经过分词后得到 5 个 token,查表得到 5 个编号,比如:
今 → 1024
天 → 875
天气 → 3201
真 → 1567
好 → 432
于是模型实际收到的是:
[1024, 875, 3201, 1567, 432]
模型从头到尾处理的都是这串整数。它不知道“天气”是一个词还是两个字——它只知道编号 3201 出现在这个位置,和前后的编号一起,决定了下一个编号应该是什么。
3.2 embedding:从编号到向量
编号还是太“硬”了。1024 和 1025 在编号上只差 1,但“今”和“天”在语义上没有任何关系。编号只表示“在词表里的位置”,不表示任何含义。
所以模型需要一层转换:把每个编号映射成一个向量。
这就是 embedding。
第一篇里算过的那块 6400 × 768 的矩阵,就是干这个的。矩阵有 6400 行,每行 768 个数。第 1024 行就是“今”这个 token 的向量表示,第 875 行就是“天”的向量表示。
查表的过程极其简单:
token id = 1024
→ 取 embedding 矩阵的第 1024 行
→ 得到一个 768 维向量
这个向量里的 768 个数,就是“今”这个 token 在模型眼里的全部信息。这些数一开始是随机的,训练过程中慢慢调整,最终形成一组有意义的数字。
3.3 什么叫“有意义”?
“有意义”的意思是:语义相近的 token,对应的向量在空间里离得近。
“猫”和“狗”都是动物,它们在 768 维空间里的距离应该比“猫”和“桌子”更近。
“好”和“坏”是反义词,方向应该不同,但都和“评价”这个语义维度相关。
“北京”和“上海”都是城市,应该聚在一起。
这不是人手工设定的。是训练过程中自然涌现的。BPE 把“北京”合并成了一个 token,模型在大量文本里看到“北京”和“上海”出现在相似的上下文里(都是城市、都有天气、都有交通),于是把它们的向量慢慢调近。
第一篇里说的“把语言接下来通常怎么说磨进旋钮”,磨的就是这些向量。
3.4 为什么叫“嵌入”
“嵌入”(embedding)这个词来自数学:把一个离散对象放进一个连续空间里。
token id 是离散的——1024 就是 1024,没有“介于 1024 和 1025 之间”的编号。
向量是连续的——768 个数,每个都可以取任意实数。
embedding 做的事就是:把离散的 token 编号,嵌入到连续的向量空间里。每个 token 在这个空间里有一个坐标点。坐标相近的 token,语义相近。
第一篇说“每个 token 被表示成一个 768 维空间里的一个点,这个高维坐标点决定了这个 token 在模型空间里的位置”,说的就是这个。
四、一个字的完整旅程
现在把整条链路串起来。以“今天天气真好”为例:
第一站:文本。
输入:今天天气真好
第二站:分词。
分词器按 BPE 合并规则切分:
今 | 天 | 天气 | 真 | 好
(假设词表里有“天气”,没有“今天”和“真好”。)
第三站:查表换编号。
词表是一个 6400 行的对照表,每行是一个 token 和它对应的编号:
今 → 1024,天 → 875,天气 → 3201,真 → 1567,好 → 432
得到:[1024, 875, 3201, 1567, 432]
第四站:查 embedding 矩阵。 embedding 矩阵是 6400 × 768。第 1024 行是“今”的向量,第 875 行是“天”的向量,以此类推。
查表后得到 5 个 768 维向量:
[1024] → [0.12, -0.45, 0.87, ..., 0.03] (768 个数)
[875] → [-0.33, 0.61, -0.19, ..., 0.75] (768 个数)
[3201] → [0.48, 0.22, 0.95, ..., -0.11] (768 个数)
[1567] → [-0.08, 0.34, -0.72, ..., 0.56] (768 个数)
[432] → [0.91, -0.12, 0.44, ..., 0.28] (768 个数)
第五站:进入 Transformer 积木。
这 5 个向量进入第一篇里说的 8 层积木。每过一层,向量被加工一次。加工的方式是:每个向量看一看上下文里其他向量,然后决定自己应该吸收什么信息、保留什么信息。这就是下一篇要讲的自注意力。
第六站:出分数。
经过 8 层加工后,最后一个位置的向量被送到输出层,算出 6400 个分数——每个 token 一个分数。分数最高的那个 token,就是模型认为“下一个词”应该是什么。
比如模型可能给出:
好 → 0.35
啊 → 0.12
, → 0.09
...
抽一个,接上去,继续循环。
这就是“今天天气真好”在模型内部的完整旅程。
五、BPE 的高级话题
5.1 字节级 BPE(BBPE)
前面讲的是字符级 BPE:初始词表是语料里出现的所有不同字符。
但字符级有个问题:如果训练语料里没有某个字符,测试时遇到了,怎么办?比如训练语料只有简体中文,测试时来了一句繁体中文或日文汉字,分词器不认识,只能标成 <unk>(unknown token)。
解决方法是:从字节开始,不从字符开始。
每个字符在计算机里都是一个或多个字节。UTF-8 编码下,ASCII 字符占 1 字节,中文字符占 3 字节,emoji 占 4 字节。
如果把初始词表设为所有 256 个可能的字节值,那么任何文本都可以被表示为一串字节。不存在“不认识的字符”——任何字符都是字节组成的。
这就是 字节级 BPE(Byte-Level BPE,BBPE)。
BBPE 的流程和字符级一样,只是初始单元从“字符”变成“字节”:
- 把文本转成 UTF-8 字节序列。
- 初始词表是 256 个字节值。
- 统计相邻字节对的频率,合并频率最高的对。
- 重复,直到词表达到预定大小。
MiniMind 用的就是 ByteLevel 模式。Qwen、DeepSeek 等国内主流模型也普遍使用 BBPE。
BBPE 的好处:
- 不存在 OOV(out-of-vocabulary):任何文本都能编码,最差情况是每个字节一个 token。
- 多语言统一:中文、英文、日文、阿拉伯文,都走同一条流水线。
代价是:中文的 UTF-8 字节数多(一个汉字通常 3 字节),初始单元比字符级更多,合并需要更多步骤。
5.2 预分词
BPE 之前还有一步:预分词(pre-tokenization)。
预分词把文本按空白、标点等规则切成“词”级别的片段,然后对每个片段分别做 BPE。这样做是为了避免跨越词边界的合并——比如“北京”和“大学”在语料里经常相邻出现,如果不预分词,BPE 可能把“京大”合并成一个 token,但“京大”本身不是词。
HuggingFace 的 BPE 实现用一个正则表达式做预分词。GPT-2 的预分词模式大概是:按空格、标点、数字切分,但保留缩写和特定模式。
5.3 特殊 token
词表里除了 BPE 学出来的 token,还有一组特殊 token。它们不是通过合并得到的,是人手工加进去的,有自己的固定编号。
MiniMind 的词表里有几个特殊 token:
<|im_start|>:对话开始<|im_end|>:对话结束<|endoftext|>:文本结束
第一篇里展示的对话格式:
<|im_start|>system
你是一个有用的助手。
<|im_end|>
<|im_start|>user
法国首都是哪?
<|im_end|>
<|im_start|>assistant
巴黎。
<|im_end|>
这里 <|im_start|> 和 <|im_end|> 就是特殊 token。它们不参与 BPE 合并,始终作为独立 token 存在。
特殊 token 的作用是给模型提供“结构信息”。模型看到 <|im_start|>assistant,就知道接下来该由 assistant 说话;看到 <|im_end|>,就知道这一轮结束了。
5.4 合并规则 vs. 词表
一个容易混淆的地方:词表和合并规则是两个东西。
- 词表:所有 token 的集合,每个 token 有一个编号。词表大小是 6400,意味着有 6400 个 token。
- 合并规则:按顺序排列的合并历史,每一步记录“把哪一对合并成了什么”。
分词的时候,真正起作用的是合并规则,不是词表本身。词表只是用来查编号的。
HuggingFace 的 tokenizer.json 里,vocab 字段是词表,merges 字段是合并规则。两者配合工作。
5.5 BPE 的局限
BPE 不是完美的。它有两个明显的局限:
其一,它只看频率,不看语义。 BPE 合并的标准是“这一对出现得多”,不是“这一对在语义上应该合并”。所以它可能合并出一些语义上不合理的 token。比如在中文语料里,“的”出现频率极高,BPE 可能把“的”和前后字合并成各种奇怪的组合,纯粹因为频率高。
其二,它依赖训练语料。 同一个词在不同语料上训练出的分词结果不同。如果训练语料偏新闻,那新闻里的高频词会被合并;如果偏小说,小说里的高频词会被合并。换一个领域,分词效率就会下降。
六、主流模型用了多大的词表?
第一篇的思考题里有一个问题:为什么 6400 个 token 就够用?现在可以正面回答了:6400 不是“够用”,是 MiniMind 在 64M 参数预算下的取舍。
看看主流模型的选择,就明白 6400 有多小:
| 模型 | 词表大小 | 分词方式 | 来源 |
|---|---|---|---|
| GPT-2 | 50,257 | BPE | OpenAI |
| GPT-4 / GPT-3.5 | 100,256 | BPE(cl100k_base) | OpenAI |
| GPT-4o | ~200,000 | BPE(o200k_base) | OpenAI |
| Llama 3 | 128,256 | BPE(tiktoken 基础上扩展) | Meta |
| Qwen2 / Qwen2.5 | 151,643 | BBPE | 阿里云 |
| ChatGLM | 151,329 | SentencePiece | 智谱 AI |
| Mistral | 32,000 | BPE | Mistral AI |
| Yi | 64,000 | BPE | 01.AI |
| DeepSeek-V3 | 128,815 | BBPE | DeepSeek |
| Gemma | 256,000 | SentencePiece | |
| MiniMind | 6,400 | BPE + ByteLevel | 本项目 |
数据来源见脚注。
几个值得注意的点:
第一,词表在变大。 GPT-2 的 5 万,到 GPT-4 的 10 万,到 GPT-4o 的 20 万,再到 Gemma 的 25.6 万。词表越大,每个 token 承载的信息越多,同样一段文本需要的 token 数越少,推理越快。但词表大了,embedding 层和输出层的参数也跟着涨。
第二,中文模型的词表普遍偏大。 Qwen2、ChatGLM 都在 15 万以上。原因是中文的 UTF-8 编码每个汉字 3 字节,字节级 BPE 需要更多合并才能把汉字组合成词。词表大一些,中文的压缩率才好看。
第三,MiniMind 的 6400 是刻意的。 README 里说得很直白:词表 6400 能显著压缩 embedding 层和输出层的参数占比。如果把词表扩到 15 万,embedding 层参数从 491 万涨到 1.16 亿,在 64M 的总预算里根本放不下。README 也提到,Qwen2 和 GLM 等词表更偏中文友好,但 MiniMind 的词表在体积约束下是更合适的选择。
七、动手实验
这一篇配一个动手脚本,把分词和 embedding 的过程打出来。
实验一:分词和编号
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("jingyaogong/minimind-3")
text = "今天天气真好"
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text)
print("原文:", text)
print("token:", tokens)
print("token 数:", len(tokens))
print("token id:", ids)
跑一遍,看看“今天天气真好”被切成了几个 token。MiniMind 的 6400 词表下,大概率是 4 到 6 个。
再换一句英文试试:
text = "Hello, how are you?"
tokens = tokenizer.tokenize(text)
print("token:", tokens)
实验二:embedding 查表
import torch
from transformers import AutoModel, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("jingyaogong/minimind-3")
model = AutoModel.from_pretrained("jingyaogong/minimind-3")
text = "今天天气真好"
ids = tokenizer.encode(text)
# 取 embedding 层
embedding_layer = model.get_input_embeddings()
vectors = embedding_layer(torch.tensor([ids]))
print("token id:", ids)
print("向量形状:", vectors.shape) # (1, token数, 768)
print("第一个 token 的向量:", vectors[0, 0, :5]) # 只打印前 5 个数
实验三:语义相近的 token,向量真的近吗?
import torch
from transformers import AutoModel, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("jingyaogong/minimind-3")
model = AutoModel.from_pretrained("jingyaogong/minimind-3")
embedding_layer = model.get_input_embeddings()
# 选几组词
words = ["猫", "狗", "桌子", "北京", "上海", "苹果"]
vectors = {}
for w in words:
ids = tokenizer.encode(w)
if len(ids) == 1:
vectors[w] = embedding_layer(torch.tensor(ids)).detach().squeeze(0)
else:
# 多 token 的词,取平均
vecs = embedding_layer(torch.tensor(ids)).detach()
vectors[w] = vecs.mean(dim=0)
# 算余弦相似度
from torch.nn.functional import cosine_similarity
print("猫 vs 狗:", cosine_similarity(vectors["猫"], vectors["狗"], dim=0).item())
print("猫 vs 桌子:", cosine_similarity(vectors["猫"], vectors["桌子"], dim=0).item())
print("北京 vs 上海:", cosine_similarity(vectors["北京"], vectors["上海"], dim=0).item())
预期结果:“猫”和“狗”的相似度高于“猫”和“桌子”;“北京”和“上海”的相似度较高。
这个实验直接验证了第一篇的结论:embedding 矩阵里的数不是随便填的,训练过程让语义相近的 token 在向量空间里靠近。
八、本篇概念清单
按 00 篇的约定,尽量不引入没讲过的术语。本篇用到的名词,清点如下:
| 概念 | 本篇交代到什么程度 | ||
|---|---|---|---|
| 分词 / tokenization | 讲透:为什么分、怎么分、BPE 完整流程 | ||
| token / 词元 | 讲透:是什么、怎么来的、和“词”的区别 | ||
| BPE | 讲透到实现级:初始词表、相邻对统计、迭代合并、分词规则 | ||
| BBPE(字节级 BPE) | 讲清:和字符级 BPE 的区别,为什么主流模型用它 | ||
| 词表 / vocabulary | 讲透:6400 是怎么定的,和合并规则的区别 | ||
| token id | 讲透:查表换编号,模型只认编号 | ||
| embedding / 嵌入 | 讲透:从编号到向量,为什么叫“嵌入” | ||
| 预分词(pre-tokenization) | 讲清:为什么需要,怎么做 | ||
| 特殊 token | 讲清:`< | im_start | >` 等的作用 |
| 自注意力 | 只点了个名:下一篇展开 | ||
| Transformer 积木 | 延续第一篇的类比,不重复解释 |
本篇要牢记的只有三个词:分词、token id、embedding。
九、回到开头的问题
第一篇的思考题里有一个:词表的参数也是训练出来的吗?
是,也不是。 词表本身——哪些 token 在词表里、每个 token 的编号是多少——是 BPE 在训练语料上跑出来的,不需要梯度下降。但 embedding 矩阵里的 6400 × 768 个数,是跟着整个模型一起训练的,是梯度下降调出来的。
换句话说:BPE 决定“有哪些 token”,训练决定“每个 token 的向量长什么样”。
第一篇说“LLM 是一个用海量文本喂出来的数学函数”,这一篇就是看这个函数的第一步:文字进来,被 BPE 切成 token,被词表换成编号,被 embedding 矩阵换成向量。然后向量进入 Transformer 积木,开始真正的计算。
下一篇,我们看积木里发生了什么——模型怎么知道“天气”和“真好”有关系?怎么决定哪个 token 应该多看哪个 token 一眼?这就是注意力机制。
十、思考题
- 如果把 BPE 的合并次数从 6400 增加到 12800,MiniMind 的 embedding 层参数会变成多少?整个模型参数会变成多少?文件会变成多大?
- 为什么中文模型的词表普遍比英文模型大?从 UTF-8 编码的角度想一想。
- MiniMind 用 ByteLevel 模式,不显式加
</w>标记,那它怎么知道词边界?提示:想想Ġ这个字符在 UTF-8 里对应什么。 - 如果训练语料里完全没有 emoji,但测试时输入了一个 emoji,BBPE 分词器会怎么处理?会报错吗?为什么?
*备注:本篇的 BPE 示例是为了讲清算法而构造的迷你语料,真实分词器的合并规则有几十万条,无法手工展示。MiniMind 的真实分词结果以 train_tokenizer.py 训练出的 tokenizer.json 为准。实验脚本建议在安装了 transformers 和 torch 的环境下运行。*后续会给出ipynb文件包。