上一章《自监督与对比学习》让机器不用标签也能学;这一章接住它——怎么切、切成多大,是整条流水线上第一个决定,它悄悄决定了模型的中文能力、算术能力和你的账单。
同一段说明书,中文版发一次、英文版发一次,中文那次的账单常常接近两倍。 毛病不在翻译,也不在模型——文本还没进网络,就已经被切碎了: 中文被切得比英文碎,多出来的每一块都要另外付钱。
为什么非得切?模型里全是乘法加法,你可以把"猫"乘以 3 吗?不行。 所以得先有一本字典,把每一段文本映射成整数:
这串编号是实测值(GPT-3.5/4 用的 cl100k_base 分词器);换一个分词器,切法与编号都会不同。
图 · 模型看见的不是文字,是这一步之后的东西
切出来的块数不等于字数,编号之间也没有大小关系——85315 只是「查表用的门牌号:数字大小没有意思,跟它出现得多不多、意思大不大也无关」。
问题在于:这本字典该按什么单位来编?是按字?按词?还是按别的什么? 这个选择有个名字叫 tokenization(分词)。
分词 = 把连续文本切成离散片段(
| 对比项 | 字符级 | 词级 | 子词级(BPE) |
|---|---|---|---|
| 切法 | 深 / 度 / 学 / 习 | 深度学习 | 深度 / 学习(示意) |
| 词表大小 | 极小(按语言:几百~几千) | 爆炸(几十万~几百万) | 折中(3 万 ~ 15 万) |
| 序列长度 | 很长(每个字一个) | 很短 | 中等 |
| 没见过的词 | 永远能拼出来 | 只能变成 [UNK],信息全丢 | 拆成已认识的片段 |
| 语义粒度 | 太碎,没有意义单位 | 一个 token 一个词 | 高频词完整,低频词拆开 |
| 谁在用 | 很少(早期模型) | 基本没人用了 | GPT / LLaMA / Qwen 全部 |
像组装乐高。词级分词 = 每个成品模型都单开一个零件,零件种类会多到无法管理;
字符级 = 只给你最小的 1×1 颗粒,什么都能拼,但要拼一整天。
子词分词聪明的地方是:把常用的整块(如 "the"、"ing"、"深度")做成固定零件,
同时保留用小程序粒拼生僻词的能力。
图 · 同一句话,三条路线切出来不一样长
块数越少,后面每一层要算的次数越少;但词级那种切法一遇到没见过的词,就只剩一个 [UNK]。
图里子词级的「深度 / 学习」是示意——真实分词器怎么切,取决于它自己那本词表:cl100k 把「深度学习」切成了 6 块(第 1 节那串编号)。
BPE(Byte Pair Encoding)的思路简单到不可思议:从单字开始, 反复把出现次数最多的相邻两段合并成一段。重复足够多次,就得到了一本"常用零件表"。 注意这件事完全不需要任何标注——它算是最朴素的一种无监督方法:不猜意义,只数频率。 它最早也不是语言学的方法:BPE 是 1994 年的一个数据压缩算法,2016 年被 Sennrich 等人搬进机器翻译,才成了分词。
互动 · 亲手跑一遍 BPE 合并
训练语料(数字是该词出现的次数)
当前最高频的相邻对
</w>
每个词末尾都加了 </w>(表示"词结束")。这是 BPE 的一个关键细节:
没有它,模型就分不清 "low" 里的 "low" 和 "lower" 里的 "low"——
前者是独立词,后者只是个词根。加了这个标记,同一个字母组合在词中、词首、词尾就会被区别对待。
真算 · 每合并一步,描述这四个词要用的块数就掉一截
第 3 节你亲手把积木粘了起来。可它到底改变了什么?它只改两个数字: 序列长度 L(后面每一层都要按这串块算一遍)和 词表大小 V(每个词表条目都要配一个向量)。 麻烦的是:这两个数字是反着走的——词表大了,序列就短;词表小了,序列就长。 所以一定存在一个「最省」的位置。下面这张图就是那个位置。
先固定一段文本:500 个汉字,按每字约 13 比特的信息量算,一共 6500 比特——它就是公式里的 I。 下面读数里的 e5 表示乘 10 的 5 次方:比如默认位置读到 2.23e5,就是 22.3 万;滑到最低点约 2.20e5,就是 22 万。
真算 · 一头涨、一头跌,中间有个最低点(拖滑杆)
互动 · 公式里每个符号,在图上都管一块东西
把滑杆从左拖到右,看那条紫线什么时候最低。最低点大概在 3 万 ~ 5 万之间—— 正好落在第 4 节那张表说的「折中区间」里。真实模型的词表从 5 万开到 20 万 (GPT-2 5 万、LLaMA-3 12.8 万、o200k 20 万),是因为这个玩具账只算了「词表 × 序列」两笔, 还没算进多语言和代码要占的配额。
(这里用的是一个简化模型:假设每个 token 最多能装 log₂V 比特。 真实数字会不同,但「一头涨、一头跌」这个形状不变。)
| 词表大小 | 好处 | 坏处 |
|---|---|---|
| 太小(< 1 万) | 嵌入矩阵小,输出层计算省 | 序列被切得很长,训练慢、注意力被稀释 |
| 折中(3 万 ~ 15 万) | 现代模型的默认区间 | — |
| 太大(> 50 万) | 序列短,中文/代码表现好 | 输出层 softmax 巨大;低频 token 训练不充分 |
另一个常被忽略的点:词表和模型大小是耦合的。每个 token 编号查表得到一串数,这串数叫
词表 × d。词表 15 万、d = 4096 →
光这张表就 6 亿
真算 · 嵌入层到底有多大:词表 × 维度(拖一下看它涨)
这是 BPE 在真实世界里最容易被踩到的坑。因为最初的分词器大多在英文语料上训练, 词表里塞满了英文常用词和词缀,留给其他语言的配额就不够了。
真算 · 一个汉字,在模型眼里是三个数字
这些数字是浏览器按 UTF-8 真算出来的,不是编的。UTF-8 是计算机存字的一种方式:
一个英文字母占 1 个字节,一个汉字占 3 个。
但字节不等于 token——BPE 会把常见的字节组合粘起来,所以一个汉字最终大约 1~2 个 token:
比英文贵,但没贵到 3 倍。贵在哪,下一张图。
同一个意思,不同语言的 token 开销
| 现象 | 原因 | 什么时候真的会痛 → 怎么办 |
|---|---|---|
| 一个汉字 ≈ 1~2 个 token | 词表里的中文词条不够多,经常退化成按字节切 | 中文账单贵 1.5~2 倍 → 成本敏感或上下文紧张时,先看词表有没有中文配额(新模型都在扩) |
| 算术特别差 | "1234" 可能被切成 ["123", "4"],同一个数字在不同上下文里切法还不一样 | 任务要算数(账单、单位换算)→ 换按位切数字的分词器(Qwen2 / DeepSeek),或让模型调用计算工具 |
| 代码里的缩进很贵 | 4 个空格通常是 1 个 token,但换行 + 缩进会拆成 2 个 | 一段 100 行的代码,光换行 + 缩进就多出约 200 个 token → 代码量大就选在代码语料上训过词表的模型(如 StarCoder) |
| 结尾有空格 / 开头无空格是不同的 token | BPE 保留了空格信息 | 写 prompt(你发给模型的那段话)时踩坑(" cat" ≠ "cat")→ 别随手加、删空格 |
| 同一个数字,换个上下文切法就变 | BPE 是按频率学的,不是按语义 | 需要模型稳定处理数字时 → 关键计算交给代码工具,别让它心算 |
图 · 为什么模型算不好数:它看不到「哪一位」
按位看时,每个格子都知道自己在第几位,所以进位是可学的;按块看时,模型只看到三块积木,没有「位」这个东西可学。
很多"模型算不对数"的抱怨,根子不在模型,在分词。
当你看到 1000000 被切成 [100, 000, 0] 时,
就该明白模型根本没有"位"这个概念可学。
前面把 BPE 当成唯一的子词切法来讲。真实世界里它还有三个常打交道的亲戚, 加上一个把 BPE 和 Unigram 打包起来的工具箱。它们的区别在两点: 词表怎么长出来(合并 / 删减)、按什么挑。
| 切法 | 它怎么挑 | 谁在用 |
|---|---|---|
| BPE | 合起来出现次数最多的相邻两块,反复合并 | 原始翻译模型(2016);今天的子词切法大多是它的变体 |
| byte-level BPE | 先在字节上做 BPE(一个汉字先从 3 个字节起步),任何输入都能编码,不用 [UNK] | GPT-2 · RoBERTa · Qwen |
| WordPiece | 不比谁出现得多,而是比两块绑在一起有多「意外」:score = freq(两块一起) ÷ (freq(块1) × freq(块2))(freq = 出现次数)。两块一起出现得比各自单独出现时「预计」的更多,就先合并它;## 表示这块接在前一块后面、不是词的开头 |
BERT 系(一类只读不写的语言模型;DistilBERT 等) |
| Unigram | 反过来:先开一个很大的词表,再一块块删,删到事先定好的词表大小;切分时按概率挑 | T5 · ALBERT · mBART |
| SentencePiece | 不是一个算法,是一个工具箱:把 BPE 和 Unigram 打包好,直接吃原始文本、不用先按空格切词 | LLaMA 1 / 2 · T5 |
最容易混的是 WordPiece 和 BPE:BPE 问「这块出现得多不多」,WordPiece 问「这两块绑在一起是不是特别意外」。
前者爱粘高频的 the,后者爱粘平时各自很少单独出现的碎片。
词表里除了正常文字,还有一批控制用的特殊 token。它们不表示内容,表示"结构"。
| Token | 作用 |
|---|---|
| [PAD] | 把长短不一的句子补齐成同样长度,不参与损失计算 |
| [UNK] | 词表外的词。子词分词已经很少需要它了 |
| [CLS] / [SEP] | BERT 用来标记句子开头和分隔 |
| [MASK] | BERT 的完形填空占位符 |
| [BOS] / [EOS] | 序列的开始与结束。生成模型靠 EOS 知道何时停笔 |
| <|im_start|> <|im_end|> | ChatGPT 风格的对话分隔符,标记"谁在说话" |
| <|endoftext|> | 文档边界,防止模型把两篇不相关的文章连起来读 |
这些特殊 token 是训练时赋予了特殊含义的。如果推理时用户能直接输入
<|im_end|> 这样的字符串,就可能伪造出"系统消息结束"的假象,
从而绕过对话模板的限制。这是真实存在的提示注入(prompt injection)手法之一——
所以部署时要在分词阶段就过滤掉用户输入里的特殊 token。
图 · 一个 token 是怎么被「打进去」的
分词看起来只是"预处理",但坐在七条线的一条的正中间——而且是全站唯一完全不看意义却决定一切的环节。
回到第 3 节那个互动,连点两次「→ 合并一步」:第一次合并出 e + s,
「词表大小」不动(11 → 11)——es 是多了一块,但 s 已经被用光、
从词表里消失,一进一出正好抵消(e 在 lower 里还得留着);
第二次合并出 es + t,es 和 t 一起消失,它才掉到 10。
整个过程中模型没理解任何意义,只是「最划算」——数字这样往下掉,就是「学习即压缩」;
它得等到一个片段在足够多地方都用得上,才省得下来。
下面就是这一章的答案——你读后面的章时会发现,每章都有这么一块。
| 暗线 | 这一章的回答 |
|---|---|
| A 信息流动 | 数据形状:文本字符串 → [seq] 的整数编号(seq = 一句话切成几块)→
[batch, seq](batch = 一批同时送几句话)→ 查嵌入表 →
[batch, seq, d](d = 每个 token 那串数的长度)。这是整条流水线上唯一一步离散化——过了这一关就不再回头, 后面全是连续向量。还有一件容易忽略的事:id 本身没有大小含义(第 1 节那句话:它只是个门牌号,大小没有意思) |
| B 什么被牺牲了 | 牺牲了信息密度的均匀性与跨语言公平。压缩率是不均匀的:
英文一个词常常 1 个 token,中文一个字要 1~2 个。
结果是同一个窗口对不同语言、不同分词器是不同容量——128K 的上下文,
cl100k(GPT-3.5/4 用的那套)实测每字 0.8 ~ 1.5 个 token,能装约 9 万 ~ 16 万个汉字;
LLaMA-3 的分词器更省(约 0.74 个 token/字),能装约 17 万字。英文同样一个窗口装得下更多词。 还牺牲了数字的位结构:"1000000" 被切成 [100, 000, 0] 之后,
模型看到的就不是七个可以进位的位了 |
| C 参数账本 | 嵌入层参数量 = 词表 × 隐藏维度: 词表 151,936(Qwen1.5-7B)× 维度 4096 ≈ 6.2 亿参数; 最后一层还要把向量翻回词表上每个词的分数,也需要一张同样大的表—— 有的模型直接复用输入那张(共享权重),就不必再存一份。 对照几个真实词表:GPT-2 是 50,257,LLaMA-3 是 128,256, cl100k_base 是 100,277,o200k_base 是 200,019—— 词表逐代变大,就是因为要多给中文和代码一些配额。 小模型上这一块可能是最大的单层:一个 0.5B 模型若用 15 万词表, 嵌入层就占总参数的 20% 以上 |
| D 跑在什么上 | 分词只查表、比字符串,几乎不做乘法;在它跑完之前,GPU 只能等它。
高并发服务里,这一小段常常是延迟的隐藏瓶颈——有些实现开始把分词也搬到 GPU 上并行。 完整的账在 《硬件与算力账本》 |
| E 它假设了什么 | 假设"出现得多 = 值得占用一个词表位置"。这是压缩算法的假设,
不是语言学的假设:它把频率当成了重要性。 后果是高频虚词("的"、"了"、"the")白占一个格子, 而"光合作用"这种意义完整、但出现不多的词反而被切碎。 它还假设切法可以不看上下文——同一个串在哪里都切成一样的(除非专门做随机切分) |
| F 违背了哪个直觉 | "词表越大越好"——不一定。词表大一倍,输出层的 softmax 也跟着大一倍,
低频 token 因为见得太少训不充分,还挤占了其他语言的配额。 第二个更反直觉:字符级看起来最省,实际上最贵。 它把序列拉长好几倍,而后面的注意力账是平方的—— 省下的词表空间,用平方级的算力还回去了。 第三个:分词器是训练前就冻结的,之后改不了。 这是一条硬约束,不是工程习惯 |
它接住了上一章的什么:自监督(《自监督与对比学习》) 解决了"没有标签也能学"。而 BPE 是这条路上最便宜的一个无监督方法—— 它甚至不需要神经网络,只要数一数。
它给下一章留了什么:现在每一段文本都变成了一串整数。 但整数之间没有"意思"可言,而模型要做的是比大小、算距离。 所以下一步是给每个整数配一个可以算的向量—— 这就是《词向量》要回答的问题。
读完这章,你能做两件事:估出一句中文 / 英文 / 数字大概切成几个 token,说出中文为什么更贵、算术为什么更难。
BPE 用"反复合并最高频的相邻对"这一件事,就同时拿到了小词表和不丢信息两个好处。
记住三个真实影响:中文更贵、算术更难、代码更占空间——这些都不是模型的问题,是分词的问题。
上面讲的都是「够用」的版本。想往下挖,这里有三个入口—— 它们不是必修内容,是给想再往前走一步的读者准备的。