MiniMind 学习笔记之02 Token的三世之旅-大模型怎么理解和处理文字

16 阅读28分钟

上承〔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 从合并规则到分词

训练完成后,分词器手里有两样东西:

  1. 词表:所有 token 的集合,6400 个。
  2. 合并规则:按顺序排列的合并历史,每一步记录了“当时合并了哪一对”。

分词的时候,拿到一句新话,先按原子单元拆开,然后按合并规则的顺序,能做哪步合并就做哪步。最终得到一串 token。

举个例子。假设合并规则按顺序是:

  1. (中, 国) → 中国
  2. (我, 爱) → 我爱
  3. (我爱, 中国) → 我爱中国
  4. (中国, 人) → 中国人

现在来了一个新词:“中国人”。

先拆成:中 国 人

按规则 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 库会按这个顺序执行:

  1. 先把文本做 ByteLevel 预处理,加前缀字节。
  2. 然后按 merges 列表的顺序,逐条尝试合并。
  3. 最后把每个 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 的流程和字符级一样,只是初始单元从“字符”变成“字节”:

  1. 把文本转成 UTF-8 字节序列。
  2. 初始词表是 256 个字节值。
  3. 统计相邻字节对的频率,合并频率最高的对。
  4. 重复,直到词表达到预定大小。

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-250,257BPEOpenAI
GPT-4 / GPT-3.5100,256BPE(cl100k_base)OpenAI
GPT-4o~200,000BPE(o200k_base)OpenAI
Llama 3128,256BPE(tiktoken 基础上扩展)Meta
Qwen2 / Qwen2.5151,643BBPE阿里云
ChatGLM151,329SentencePiece智谱 AI
Mistral32,000BPEMistral AI
Yi64,000BPE01.AI
DeepSeek-V3128,815BBPEDeepSeek
Gemma256,000SentencePieceGoogle
MiniMind6,400BPE + 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 一眼?这就是注意力机制。

十、思考题

  1. 如果把 BPE 的合并次数从 6400 增加到 12800,MiniMind 的 embedding 层参数会变成多少?整个模型参数会变成多少?文件会变成多大?
  2. 为什么中文模型的词表普遍比英文模型大?从 UTF-8 编码的角度想一想。
  3. MiniMind 用 ByteLevel 模式,不显式加 </w> 标记,那它怎么知道词边界?提示:想想 Ġ 这个字符在 UTF-8 里对应什么。
  4. 如果训练语料里完全没有 emoji,但测试时输入了一个 emoji,BBPE 分词器会怎么处理?会报错吗?为什么?

*备注:本篇的 BPE 示例是为了讲清算法而构造的迷你语料,真实分词器的合并规则有几十万条,无法手工展示。MiniMind 的真实分词结果以 train_tokenizer.py 训练出的 tokenizer.json 为准。实验脚本建议在安装了 transformers 和 torch 的环境下运行。*后续会给出ipynb文件包。