《Transformer 与 LLM:结构、算量与数值》把一个 LLM 的成本算成了 token 的函数:每个 token 多少 FLOPs、多少字节 KV、prefill 多长、decode 多久。”token 数”在所有公式里都是自变量——它从哪来,那八篇一直没有问。它来自 tokenizer。

本系列是那张成本表的训练侧:对象从”模型作为一个计算对象的结构”转到”这个模型是怎么训出来的”——分词与词表、scaling law、数据工程、训练配方。方法不变:写出公式,代入真实模型的数字,解释数字对系统意味着什么。tokenizer 排在端到端那一篇之后、其余各篇之前,因为它同时决定成本表的两端:词表大小 \(V\) 直接进参数量与 lm_head 的 FLOPs,压缩率决定一段文字要付多少个 token 的钱。

本篇要回答的核心问题是:

Llama 3 把词表从 Llama 2 的 32K 扩到 128K,参数多了 0.79B、每个 token 贵了 5.6%,为什么反而是省钱的?1 同一句中文在 Llama 3 和 DeepSeek-V3 的 tokenizer 下相差 2.1 倍的 token 数——这个差距在成本表上是什么?2

一、总览:成本表里最后一个外生变量

1. 先说答案

tokenizer 对成本的影响走两条相反的路:

tokenizer 影响成本的两条路
路 变量 影响 Llama 2 → Llama 3 的数字
词表大小 \(V\)
  • embedding 与 lm_head 各 \(V \times d\) 个参数
  • lm_head 每 token \(2Vd\) FLOPs、decode 每步读 \(2Vd\) 字节
  • 训练时 logits 占 \(\text{tokens} \times V \times 4\) 字节
\(V\) 越大,每个 token 越贵 32K → 128K:8B 骨架的参数 7.24B → 8.03B,每 token FLOPs 14.2 → 15.0 G(+5.6%)
压缩率 每个 token 平均对应多少字符(或字节) 压缩率越高,同一段文字的 token 越少 英文 3.17 → 3.94 字符/token(+24%)

两条路合在一起,成本应该按每个字符而不是每个 token 算:

\[\text{FLOPs/字符} = \frac{\text{FLOPs/token}}{\text{字符/token}}\]

Llama-3-8B 的骨架配 32K 词表是 \(14.2 / 3.17 = 4.49\) GFLOPs/字符,配 128K 词表是 \(15.0 / 3.94 = 3.81\) GFLOPs/字符——每个字符便宜 15%,每个字符的 KV 少 20%。更大的词表让每个 token 贵了一点,让每段文字的 token 少了很多,后者赢。这是 Llama 3、Qwen、Gemma、DeepSeek 都把词表做到 128K–256K 的原因。

但这个结论有一个前提:词表要针对目标语言训练。同一段中文在 cl100k(Llama 3 词表的英文部分)下每个汉字要 1.46 个 token,在 DeepSeek-V3 下是 0.69 个;用 8B 规格的模型算,一个汉字的成本是 21.9 GFLOPs 对 10.4 GFLOPs。词表大小相近,效率差 2.1 倍——差距不在 \(V\),在词表是用什么语料训出来的。

2. 本文的路线

全文沿一条线走:tokenizer 是什么 → 它怎么进成本表 → 它怎么漏进模型行为 → 怎么换掉它 → 动手。

  1. 它是什么(第二、三章)。第二章不算账,用三张图把 tokenizer 是什么、BPE 在做什么、词表多大合适讲明白,已经清楚的读者可以跳过;第三章把同样的事讲严格:文本的几种表示方式各自解决什么、败在哪,BPE 的算法与它成为默认的原因,byte-level 与预分词,WordPiece / Unigram 两个替代准则,以及真实 tokenizer 的四段流水线。
  2. 它怎么进成本表(第四、五章)。上面那张表的两条路各算一章:第四章算词表大小 \(V\) 这一侧——参数、FLOPs、字节、训练状态、采样;第五章算压缩率这一侧,并把两侧合成”每字符成本”。
  3. 它怎么漏进模型行为(第六章)。欠训练 token、数字切分与算术、多语言的价格差、token 边界偏差、特殊 token。
  4. 怎么换掉它(第七章)。扩词表、裁剪、移植,以及干脆不用 tokenizer 的字节级模型。
  5. 动手(第八章)。三个脚本:从零实现 BPE、五个真实 tokenizer 对比、成本表加上词表这一列。

数字来自五个可以公开下载的 tokenizer:

本文比较的五个 tokenizer
tokenizer 词表 用在 类型
GPT-2 50 257 GPT-2 / GPT-3 byte-level BPE,第一个主流的字节级词表
cl100k_base 100 277 GPT-4;Llama 3 的 128K 词表以它为基础再加 28K 非英语 token tiktoken
o200k_base 200 019 GPT-4o tiktoken
Qwen2.5 151 665 Qwen2 / 2.5 / 3 byte-level BPE,中英双语料
DeepSeek-V3 128 815(config.json 里 vocab_size 为 129 280) DeepSeek-V3 / R1 byte-level BPE,中英双语料

Llama 3 自己的 tokenizer 需要授权下载,本文用 cl100k_base 近似它的英文行为;两者在英文上的切分几乎相同,中文上 Llama 3 多出的 28K token 会比 cl100k 好一些,但仍远不及双语料训练的 Qwen 与 DeepSeek。

一个提醒:表里”词表”一列是 tokenizer 实际拥有的 token 数,模型 config.json 里的 vocab_size 往往比它大——Qwen2.5-7B 是 152 064 对 151 665,DeepSeek-V3 是 129 280 对 128 815。多出来的几百个是填充位,第四章会解释它为什么存在、为什么恰好都是 128 的倍数。

3. 本文的章节安排

本文的章节安排
章 主题 内容
二 先讲明白
  • 模型为什么只认整数
  • 同一句话三种切法
  • BPE 逐步演示:次数怎么数、”够大”是多大、十几行核心代码
  • 四个 tokenizer 切同一段话
  • 词表多大合适——一个自己训出来的曲线
三 从词到子词
  • 词级与字符级两端各失败在哪
  • 文本表示的五种方式与 LLM 为什么选”子词 + 可训练 embedding”
  • BPE 算法的三个观察与复杂度
  • byte-level 与预分词
  • WordPiece 与 Unigram 的准则
  • 四段流水线逐段拆开,五个 tokenizer.json 对照
四 词表大小的账 \(2Vd\) 参数、tied 与 untied、填充到 128 的倍数、lm_head 的 FLOPs 与字节、logits 显存与 vocab-parallel 交叉熵、训练状态、采样成本;六个模型的数字
五 token 效率的账 字符/token、每字符成本、跨 tokenizer 怎么比 loss、词表大小的边际收益与词表的 scaling law、中文 / 代码 / 数字三个特例、上下文窗口”有多长”
六 tokenizer 与模型行为 词表是语料的化石、欠训练 token 的检测、数字与算术、多语言的价格差、token 边界偏差与 token healing、特殊 token
七 换词表 扩词表继续预训练、词表裁剪、tokenizer 移植、无 tokenizer 的字节模型
八 实践 从零实现 BPE、真实 tokenizer 对比、llm_cost.py 第九版
九 本文小结  
十 自测 5 道题

二、先讲明白:tokenizer 是什么、为什么需要它

这一章不算账,只把三件事讲明白:模型为什么需要 tokenizer、BPE 到底在做什么、词表多大合适。已经清楚的读者可以直接跳到第三章。

1. 模型只认整数

神经网络的输入是数字,不是字符串。所以第一步要把文本变成一串整数,每个整数对应一个”符号”——词表(vocabulary)就是这张符号表:第 \(i\) 个符号有一个 id \(i\),模型里有一张 \(V \times d\) 的 embedding 表,第 \(i\) 行就是这个符号的向量;输出端再有一张同样大小的表(lm_head)算”下一个是哪个符号”的概率。tokenizer 做的事只有两个:把字符串切成符号序列(编码),以及把符号序列拼回字符串(解码)。

问题是:什么算一个符号? 这个决定看起来是文本预处理的小事,实际上决定了一段话要花多少个 token——也就是要花多少算力、多少显存、多少钱——以及模型能不能学会拼写、算术、中文。

2. 同一句话,三种切法

同一句话按字符切是 43 个 token、按词切 6 个、按 GPT-2 的子词切 12 个;字符级序列长、词级词表大且遇到生词就丢信息、子词在中间

三种切法的对照
  按字符切 按词切 按子词切(BPE)
词表多大 几十到 256(字节) 几十万,还是有生词 自己定:32K、128K、256K
这句话几个 token 43 6 12
遇到没见过的词 没有”没见过”,全是字母 变成 <unk>,信息全丢 拆成碎片,什么都能表示
模型要额外学什么 “t-h-e 是一个词”、拼写 不用学拼写,但 low / lower / lowest 是三个无关的符号 常见词整个一格,罕见词的碎片带一点语义(un、izers)
谁在用 早期字符级 RNN;《Transformer 与 LLM》第四篇的莎士比亚模型 Word2Vec 时代的 NLP 2016 年后的几乎所有模型

两端各有一个致命问题。按字符切序列太长:模型每一步只能看固定长度的窗口,窗口 256 个字符装不下一段话;attention 的算量随长度平方增长(第三章第 1 节有具体数字)。按词切词表关不住:英文单词加变形、专名、拼写错误、其他语言,永远有词不在表里;而且 embedding 表有 \(V\) 行,百万级词表在 \(d = 4096\) 下是 40 亿参数。

子词是折中:常见词整个是一个 token,不常见的词拆成几段。哪些词算”常见”、拆成什么样的段,不是人定的,是从语料里统计出来的——这就是 BPE。

3. BPE:让数据自己决定词表

BPE(byte-pair encoding)的训练只有一个动作,重复很多次:数一数语料里哪两个相邻的 token 最常挨在一起,把它们合并成一个新 token。从”每个字符一个 token”开始,合并到词表达到事先定好的大小为止——”够大”不是算法自己判断的,是一个超参数,第 5 节讲它怎么定。用 Sennrich 等 2016 论文里的玩具语料演示:语料里只有四个词型,low 出现 5 次、lower 2 次、newest 6 次、widest 3 次(与 GPT-2 一样,每个词连同它前面的空格 ␣ 算一个词),合并 8 步:

BPE 训练 8 步:列头是四个词各出现 5、2、6、3 次;第 1 步 e+s 共 9 次 = 6 + 3 → es,第 2 步 es+t → est,第 3–5 步三个 7 次的对把 ␣low 合成一个 token,第 6–8 步 6 次的对合出 ␣new;右侧是每一步之后四个词各切成什么样

次数怎么数:相邻对的次数按词频加权——一对在某个词型里相邻,这个词型在语料里出现几次就记几次。e s 只在 newest 和 widest 两个词型里相邻,看起来只有两处,但 newest 出现 6 次、widest 3 次,所以是 \(6 + 3 = 9\) 次,比任何别的对都多(␣ l、l o、o w 各只有 \(5 + 2 = 7\) 次)。于是第 1 步合出 es;合并之后 es t 仍是 9 次,第 2 步合出 est;第 3–5 步轮到三个 7 次的对,把 ␣low 合成一格(三个并列,按先遇到的来——真实实现里并列的打破规则各家不同,影响的只是词表尾部的 token);␣ n、␣n e、␣ne w 各 6 次,第 6–8 步。8 步之后,␣low、est、␣new 各是一格——语料里最常见的碎片先成为 token。

写成代码就是一个循环(上面这张图就是它的输出;带字节级细节的完整版在第八章的 bpe_from_scratch.py):

from collections import Counter

def train_bpe(words, n_merges):
    """words: {词(字符元组): 出现次数};返回按学习顺序排好的合并规则列表"""
    merges = []
    for _ in range(n_merges):
        pairs = Counter()
        for word, freq in words.items():
            for a, b in zip(word, word[1:]):          # 词内每一对相邻 token
                pairs[(a, b)] += freq                 # 按词频加权
        (a, b), _ = pairs.most_common(1)[0]           # 最高频的一对
        merges.append((a, b))
        words = {merge_pair(word, a, b): freq for word, freq in words.items()}   # 语料里替换
    return merges

def merge_pair(word, a, b):
    """把一个词里所有相邻的 (a, b) 换成 a + b"""
    out, i = [], 0
    while i < len(word):
        if i + 1 < len(word) and word[i] == a and word[i + 1] == b:
            out.append(a + b); i += 2
        else:
            out.append(word[i]); i += 1
    return tuple(out)

train_bpe({tuple(" low"): 5, tuple(" lower"): 2, tuple(" newest"): 6, tuple(" widest"): 3}, 8) 返回的就是图里那 8 条规则。训练的产物不是”一堆词”,而是这 8 条合并规则,按顺序排好。编码一个新词时,先拆成字符,再按同样的顺序把规则走一遍:

  • ␣lowest(语料里没出现过)→ ␣low + est——两个碎片都学过,虽然这个词本身没见过;
  • ␣newer → ␣new + e + r——er 从没成为高频对,退回字符;
  • ␣wide → ␣ + w + i + d + e——五个碎片,wi、id 也从没高频过。

真实的 tokenizer 是这个过程放大十万倍:语料几十 GB,合并十几万步,初始符号不是字符而是 256 个字节(这样任何文字、emoji、乱码都能表示,第三章第 4 节)。GPT-2 词表里 ␣the 的 id 是 262:前 256 个 id 是字节,它是第 7 条合并规则的产物(前六条是 ␣t、␣a、he、in、re、on)——英文里最常见的词,最早成为一格。

4. 同一段文字,四个 tokenizer

四个真实的 tokenizer——GPT-2(2019,词表 50K)、cl100k(GPT-4,100K,也是 Llama 3 词表的底子)、Qwen2.5(152K)、DeepSeek-V3(129K)——切同一段英文、中文、代码:

英文四家 9–10 个 token;中文 GPT-2 47 个(几乎全是字节碎片)、cl100k 30 个、Qwen 15 个、DeepSeek 13 个;Python 那行 GPT-2 29 个(每个空格一格)、其他 17–18 个

三个观察:

  1. 英文四家几乎一样(9 或 10 个)。词表到 50K 时英文常用词就已经各是一格了,再扩大词表加进来的是罕见词和其他语言。
  2. 中文差 3.6 倍(47 对 13)。GPT-2 的训练语料几乎没有中文,一个汉字是 3 个字节,它只能切成 3 个字节碎片(图里的 �);cl100k 好一些,但”词”“决”“价”这些常用字仍是碎片;Qwen 和 DeepSeek 的语料里中文多,”一句话”“决定了”“价钱”这样的词组都成了一格。同一句中文,用 GPT-2 系的模型处理要付 3.6 倍的 token——prefill、decode 步数、KV cache、API 计费全部按这个比例。
  3. 代码里的空格。GPT-2 把 8 个缩进空格切成 8 个 token,后来的 tokenizer 把连续空格合成一格(␣␣␣␣␣␣␣␣)。这一项改进让代码的 token 数几乎减半。

这三个观察合起来是一句话:词表是训练语料的化石。哪种语言、哪种文本在训 tokenizer 的语料里多,它在这个词表下就便宜。第五章第 5 节讲中文 / 代码 / 数字三个特例,第六章第 1 节用它解释”欠训练 token”,第六章第 3 节讲多语言的价格差。

5. 词表多大合适:一个自己训出来的曲线

第 3 节说 BPE “合并到词表够大为止”——这个”够大”是一个事先定好的数字,BPE 自己不会停:GPT-2 定 50 257(256 个字节 + 50 000 条合并 + 1 个 <|endoftext|>);Llama 3 定 128 000 + 256 个特殊位;Qwen2.5 定 151 643 + 22 个特殊 token。既然词表大小是自己定的,那定多大?直觉是”越大越好”——每个词都是一格,token 最少。但 embedding 与 lm_head 各有 \(V \times d\) 个参数,词表翻倍它们就翻倍;小模型里这两块能占参数的四分之一以上(第四章)。所以要看词表翻倍能省多少 token。

第一篇在 38 MB 清洗过的英文网页上从零训 BPE,词表从 264 扫到 16384(pretrain_e2e/step4_tokenizer.py):

词表每翻一倍,每个 token 多代表多少字符(英文网页语料)
词表 \(V\) 264 512 1024 2048 4096 8192 16384 GPT-2 的 50257
字符/token 1.10 2.02 2.53 3.00 3.46 3.90 4.27 4.53
比上一档多省 — +0.92 +0.51 +0.47 +0.46 +0.44 +0.37  

词表从 264 到 16384,字符/token 从 1.1 涨到 4.3,曲线越来越平;GPT-2 的 50K 词表在同一批文本上是 4.53

前几次翻倍收益很大(264 → 512 几乎翻倍),之后每翻一倍只多 0.4–0.5 个字符——边际收益递减,曲线越来越平。而成本(embedding、lm_head 的参数与算力)是线性涨的。一条越来越平的收益曲线对一条直线的成本曲线,交点就是”合适的词表大小”;模型越大,lm_head 占的比例越小,这个交点越靠右——所以 GPT-2 用 50K、Llama 3 用 128K、Gemma 用 256K。第五章第 4 节把这条曲线和 Zipf 定律、Tao 等 2024 的”词表 scaling law”接上。

为什么收益会递减?因为语料里词的频率是长尾的(Zipf 定律):最常见的几千个词覆盖了大部分文本,词表从 4K 扩到 8K 新加进来的 4 千个 token 都是不常见的词,每个只出现几次,合起来也省不了多少。

所以”够大”的定法是:用目标语言、目标领域的语料训出这条收益曲线,按模型规模画出成本直线,取交点附近的值,再凑成 128 的倍数(第四章第 2 节讲为什么)。它是针对一个模型、一批语料的设计决定,不是 BPE 算法的输出——同一份算法,给它 32K 它停在 32K,给它 256K 它停在 256K。

6. 这一章之后

到这里,tokenizer 是什么、BPE 怎么工作、词表大小换什么,都有了图。剩下的章节是算账:把”词表翻倍成本线性涨”里的那条线算出来(第四章:\(2Vd\) 参数、lm_head 的 FLOPs 与字节、logits 显存),把”省多少 token”换成每字符的成本(第五章),再看 tokenizer 对模型行为的副作用(第六章)和怎么换词表(第七章)。读的时候记住这一章的两张图就够了:合并规则表,和那条越来越平的曲线。

三、从词到子词:为什么是 BPE

1. 两端都不行

模型的输入必须是一串来自有限集合的 id。这个集合怎么定,有两个极端:

  • 词级:每个单词一个 id。英文常用词几十万,加上变形、专名、拼写错误、其他语言,词表无上限;训练中没见过的词(OOV)只能映射到一个 <unk>,信息全丢。而且 \(V\) 是 embedding 参数的一个因子,百万级的词表在 \(d = 4096\) 下是 4B 参数——比 Llama-3-8B 的一半还多。
  • 字符级(或字节级):\(V = 256\),永远没有 OOV。但一段英文平均每个字符一个 token,序列长度是词级的四五倍:attention 的二次项、KV cache 的一次项、生成时的 decode 步数全部按比例上升。《Transformer 与 LLM》第十篇算过 Llama-3-8B 在 128K 上下文下 attention 项已超过权重项;序列长 4 倍,同样的文本量 attention 算量长 16 倍。

子词(subword)是中间解:常见词整个是一个 token,罕见词拆成几段有意义的碎片,任何字符串都能表示,词表大小可以自己定。BPE 是得到子词词表最常用的算法。

用一个数字看两端的差距。《Transformer 与 LLM》第十篇的 prefill 账:Llama-3-8B 处理 \(s\) 个 token 的权重项是 \(2Ns\),attention 项是 \(4 d L s^2 = 4 \times 4096 \times 32 \times s^2\)。一段 100 万字符的英文,词级切分约 20 万 token,字节级 100 万 token:

词级、字符级与子词切分的 prefill 算量
切分 token 数 权重项 attention 项 合计
词级(≈5 字符/token) 200K 3.0 PFLOP 21 PFLOP 24 PFLOP
子词(3.94 字符/token) 254K 3.8 PFLOP 34 PFLOP 38 PFLOP
字节级 1M 15 PFLOP 524 PFLOP 539 PFLOP

字节级比子词贵 14 倍,其中 attention 项贵 15 倍——序列每长 \(k\) 倍,attention 项长 \(k^2\) 倍。(这是一整段当作一条序列的极端算法;实际按 8K 分块后 attention 项会小得多,但字节级的 decode 步数仍是 4 倍,KV 也是 4 倍。)字节级模型不是没人做,第七章会看到它们要另想办法把序列压回去。

2. 文本怎么变成数字:五种表示方式,以及 LLM 为什么选”子词 + embedding”

tokenizer 回答的是”什么算一个符号”,但这只是文本进模型的第一步,第二步是每个符号用什么向量表示。NLP 几十年里两步各有几种做法,放在一张表上,就能看出 LLM 为什么选了现在这一种:

文本表示的五种方式
表示 符号单位 向量怎么来 擅长 短板 今天还在哪用
one-hot / 词袋 / TF-IDF 词 稀疏向量,维数 = 词表大小,一个词占一维 简单、可解释;检索与分类上至今是强基线
  • 不带语义(cat 与 dog 正交)
  • 丢词序
  • 生词没有位置(OOV)
  • BM25 检索
  • 第四篇数据过滤里的分类器特征
n-gram 语言模型 词 不学向量,直接数”前 \(n-1\) 个词之后接什么”的频率 next-token prediction 的祖先;困惑度这个指标从这里来
  • 只能看 \(n-1\) 个词
  • 组合爆炸,要靠平滑
  • 评估指标 PPL
  • 数据过滤里的 KenLM 困惑度打分
静态词向量(Word2Vec、GloVe) 词 几百维稠密向量,单独预训练后冻结 相近的词向量相近,king − man + woman ≈ queen
  • 一词一向量,bank 的两个意思是同一个点
  • OOV
  • 与下游模型分开训
  • 推荐、轻量检索
  • 小模型的初始化
字符级 / 字节级 字符或字节 几十到 256 行的 embedding 表,与模型一起训 没有 OOV,词表极小,拼写与形态全在序列里 序列长 4–5 倍,attention 项贵 \(k^2\) 倍(上一节的 14 倍)
  • 字符级 RNN
  • ByT5 一类字节模型(第七章第 4 节)
子词 id + 可训练 embedding + 上下文表示 子词(BPE / WordPiece / Unigram) \(V \times d\) 的 embedding 表与模型联合训练,经过 attention 后随上下文变化
  • 词表大小可控
  • 没有 OOV
  • 常见词一格
  • 同一 token 在不同句子里向量不同
  • 词表是语料的化石(第六章)
  • 数字与多语言的副作用
2018 年 ELMo / BERT 之后的几乎所有模型,包括全部 LLM

为什么 LLM 落在最后一行?四个原因,每个都对应前几行的一个短板:

  1. 生成需要一个有限的离散集合。模型每一步要输出”下一个符号是谁”的概率分布,lm_head 的输出维数就是词表大小。词级词表几十万到上百万,这一层算不起、也训不动尾部的词;字符级 256 个倒是算得起,但序列长 4–5 倍,attention 与 KV cache 贵得多(上一节)。子词把 \(V\) 控制在 32K–256K,两头的账都算得过来。
  2. embedding 要和模型一起训。Word2Vec 的向量是单独训好再冻结的,模型只能用、不能改。LLM 的 embedding 表就是 Word2Vec 的思想搬进模型第一层:仍然是”一个 token 一行向量”,但随 loss 一起更新,学到的是对这个模型的下一个 token 预测最有用的表示。
  3. 表示要随上下文变。静态词向量一词一点;ELMo(Peters 等 2018)与 BERT(Devlin 等 2018)之后,embedding 查出来的只是起点,经过几层 attention 之后同一个 token 在不同句子里的向量不同——多义词的问题在模型内部解决,不必在词表里解决。
  4. 不能有 OOV。预训练语料里什么都有:其他语言、代码、乱码、emoji。词级表示的 <unk> 会把这些信息整个丢掉;byte-level 的初始词表让任何字节序列都能编码(下一节)。

这张表上有两个概念被 LLM 原样继承,后文会反复用到。一是困惑度(perplexity):\(\text{PPL} = \exp(\text{平均每 token 的交叉熵})\),今天仍是预训练 loss 的另一种写法,loss 2.0 nats 对应 PPL 7.4;但它依赖 tokenizer——同一段文字切成更多 token,每个 token 更好预测,PPL 更低,这不代表模型更好,跨 tokenizer 比较要换算到每字节(第五章第 3 节)。二是 embedding 表本身:Transformer 第一层那张 \(V \times d\) 的表,与第四章要算的 lm_head 是同一尺寸的两张表,词表每加一行,两张表各多 \(d\) 个参数。

3. BPE 算法

Byte Pair Encoding 最初是一种压缩算法(Gage 1994),Sennrich 等 2016 把它用到机器翻译的词表构建上。算法本身第二章第 3 节已经写成代码、画成图:初始词表是全部单字符(或 256 个字节),把语料切成词、每个词表示为符号序列,然后重复”统计相邻对 → 合并最高频的一对 → 语料里替换”,直到词表到目标大小。这里不再重复,只补三个在真实词表上同样成立的观察。

  1. 合并顺序就是词表。训练的产物不是一个词的集合,而是一个有序的 merge 列表;编码时对每个词按同样顺序反复合并。所以 BPE 的编码是确定的、贪心的、不需要搜索。tiktoken 把这个列表存成”token 字节串 → rank”的字典,编码时每次找 rank 最小的相邻对合并,是同一件事的另一种写法。
  2. 子词是统计的产物,不是语言学的。est 被学出来是因为 newest 和 widest 都有它,不是因为它是后缀;同理 GPT-2 的词表里有 ␣the 也有 the(␣ 表示前导空格;后者不带空格,出现在行首或引号后)——两个 id,模型要各学一遍。大小写也是如此:The、␣The、the、␣the、THE 是五个 token,词表里有相当一部分位置花在同一个词的变体上。
  3. 没见过的组合退回到碎片。␣wide 退成 ␣ w i d e 五格,因为 w i、i d 从没成为高频对。真实词表上这就是”罕见词被切碎”——一个专有名词或一段 base64 可能占几十个 token。

编码的复杂度值得一提。朴素实现对一个长度 \(n\) 的词每次合并要扫一遍,最多合并 \(n - 1\) 次,\(O(n^2)\);由于预分词把词切得很短(英文词平均 5 个字符),这不成问题。真正的成本在训练:每合并一次要重新统计全部相邻对的频次,语料 \(T\) 个字节、合并 \(V\) 次是 \(O(TV)\)——1 MB 语料训 16K 词表在配套脚本的朴素实现里要两分钟;工业实现(Hugging Face tokenizers、SentencePiece)用增量更新(只更新受本次合并影响的对)加多线程,几十 GB 语料训 100K 词表以小时计。

4. byte-level 与预分词

Sennrich 的 BPE 以字符为初始词表,遇到训练语料里没有的字符仍会 OOV。GPT-2(Radford 等 2019)把初始词表改成 256 个字节:任何 Unicode 字符先编成 UTF-8,再在字节序列上做 BPE。词表 50 257 = 256 个字节 + 50 000 次合并 + 1 个 <|endoftext|>。代价是非 ASCII 字符从一开始就是多个字节:一个汉字 3 字节,一个 emoji 4 字节,如果语料里没有足够多的这种字符让它们合并起来,它们就以 3–4 个 token 的价格留在那里(第六章会看到这正是发生在中文上的事)。

byte-level 还带来一个不那么显眼的性质:任何 token 序列都能解码成字节串,但不一定是合法的 UTF-8。模型可以生成一个汉字的前两个字节然后停下,或者生成两个不属于同一个字符的字节相邻。这在流式输出里是真实问题——推理服务器必须缓冲到能凑成完整字符再发出去,Hugging Face 的 decode 与 vLLM 的 detokenizer 都有这一层处理,也是为什么 tokenizer 对比表里会出现 �� 这样的占位符。

第二个设计是预分词(pre-tokenization):BPE 不在整个语料上合并,而是先用正则把文本切成”词”,合并只在词内进行。GPT-2 的正则:

's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

它规定:英文缩写的后缀单独成段;字母串(可带一个前导空格)、数字串、标点串各成段;空白单独处理。作用是给合并加边界——of the 永远不会成为一个 token,因为它跨了两个预分词段;数字与字母不混。后来的 tokenizer 改的主要就是这条正则:

  • cl100k / Llama 3 把数字限制为最多 3 位一段(\p{N}{1,3}),于是 15000000000000 切成 150|000|000|000|00,任何数字都由 1–3 位的块组成,词表里不会出现 00000000 这样的 token(GPT-2 里有);
  • cl100k 允许空格串合并(GPT-2 的 \s+(?!\S) 让连续空格逐个成 token),8 个空格的缩进从 8 个 token 变成 1 个,这对代码影响很大——第五章有数字;
  • cl100k 对缩写后缀加了大小写不敏感((?i:'s|'t|'re|…)),IT'S 与 it's 切法一致;
  • Qwen 把数字拆成单个数字,15000000000000 是 14 个 token。这是一个刻意的取舍:算术更容易(每一位对齐),但数字更长;
  • DeepSeek-V3 的预分词允许标点与换行合并成一个 token(V3 技术报告 3.4 节),多行文本更省,但带来一个副作用:一段以标点结尾、没有换行的 prompt,最后一个 token 与训练时见到的”标点 + 换行”不同——他们的对策是训练时随机拆开一部分这类 token。这是第六章”token 边界偏差”的一个实例。

预分词正则是 tokenizer 里最容易被忽视、又最难改的部分:它决定词表里可能出现什么。一旦模型训好,改正则就等于换词表。

5. WordPiece 与 Unigram

另外两种得到子词词表的方法,结果与 BPE 相近,区别在选择合并(或删除)的准则。

WordPiece(Schuster & Nakajima 2012,BERT 用它)和 BPE 一样自底向上合并,但打分不是频次而是合并后语言模型似然的增量。把语料看成 unigram 模型,合并 \(a, b\) 为 \(ab\) 后对数似然的变化近似为

\[\Delta \log \mathcal{L} \approx \text{freq}(ab) \cdot \log \frac{\text{freq}(ab)}{\text{freq}(a)\,\text{freq}(b)}\]

实现上取 \(\frac{\text{freq}(ab)}{\text{freq}(a)\,\text{freq}(b)}\) 作分数:分母惩罚”各自很常见、碰巧相邻”的对(如 e 与 s),偏向”各自不常见但总是一起出现”的对。编码时不按 merge 顺序而是最长匹配:从词首开始找词表里最长的前缀,切下,重复;词内非首段加 ## 前缀。

Unigram(Kudo 2018,T5 与 ALBERT 用它)反过来:从一个很大的候选词表出发(比如所有出现过的子串中频次最高的百万个),假定每个 token 独立出现、概率 \(p(x_i)\),一个词 \(w\) 的概率是它所有切分方式的概率之和

\[P(w) = \sum_{\mathbf{x} \in S(w)} \prod_i p(x_i)\]

用 EM 迭代:E 步在每个词的切分格(lattice)上做前向–后向,算出每个 token 的期望出现次数;M 步用期望次数重新估计 \(p(x_i)\)。然后计算删掉每个 token 会让语料对数似然下降多少,删掉损失最小的 10–20%,重复直到词表缩到目标大小。编码用 Viterbi 找概率最大的切分;因为有概率模型,也可以按概率采样一种非最优切分——这就是 subword regularization,训练时给同一个词不同的切法作数据增强。

BPE、WordPiece 与 Unigram 的对照
方法 训练方向 准则 编码 用在
BPE 自底向上合并 频次 按 merge 顺序贪心 GPT 系列、Llama 3、Qwen、DeepSeek
WordPiece 自底向上合并 似然增量 \(\frac{f(ab)}{f(a) f(b)}\) 最长匹配 BERT
Unigram 自顶向下删除 删除后的似然损失 Viterbi(可采样) T5、ALBERT

SentencePiece(Kudo & Richardson 2018)是一个实现,同时支持 BPE 与 Unigram;它的特点是把空格当成普通字符 ▁ 处理,不依赖语言相关的预分词,且默认做 NFKC 归一化。Llama 1 / 2 用 SentencePiece BPE(32K,字符级初始词表加 byte fallback:没见过的字符退回到字节);Llama 3 换成了 tiktoken 风格的 byte-level BPE。工程上今天的主流是 byte-level BPE + 一条精心设计的预分词正则,三种方法在压缩率上的差异远小于词表大小与训练语料带来的差异。

6. tokenizer 的四段流水线

把上面的部件按 Hugging Face tokenizers 库的组织方式排一下,一个 tokenizer 是四段可替换的流水线——tokenizer.json 文件的顶层就是这四个键(外加一个负责反向还原的 decoder):

%% 图:tokenizer 的四段流水线:Normalizer → Pre-tokenizer → Model → Post-processor
flowchart TB
    T["原始文本"] --> N["Normalizer<br/>NFC/NFKC、小写、去重音<br/>(byte-level BPE 通常为空)"]
    N --> P["Pre-tokenizer<br/>正则切段 + 字节映射"]
    P --> M["Model<br/>BPE / WordPiece / Unigram<br/>在每段内切成 token"]
    M --> PP["Post-processor<br/>加 BOS/EOS、特殊 token、模板"]
    PP --> I["id 序列"]

    classDef hot fill:#fde68a,stroke:#b45309;
    class P,M hot;

逐段看它的输入输出、常见选项,以及改它会改变什么:

  1. Normalizer:统一写法。输入原始字符串,输出规范化后的字符串。常见操作有 Unicode 规范化(NFC 把”e + 组合重音”合成一个 é;NFKC 还把全角 A 折成半角 A、① 折成 1)、小写化、去重音。它决定”同一个字符串的不同 Unicode 写法算不算一个 token”。byte-level BPE 大多留空或只做 NFC:GPT-2 是 null,Qwen2.5 是 NFC,DeepSeek-V3 是空序列;BERT 的 BertNormalizer 做小写 + 清理控制字符 + 给每个汉字两侧加空格(所以 BERT 的中文永远是单字);T5 用 SentencePiece 的预编译字符映射表(Precompiled)。它对压缩率影响很小,但不可逆:小写化之后 Apple 与 apple 再也分不开——这是 BERT-uncased 不区分大小写、GPT 系列区分的原因。
  2. Pre-tokenizer:先切成不可跨越的段。输入规范化后的字符串,输出一串”词”(各带在原文中的偏移)。Model 只在每段内部合并,段的边界就是 token 永远不能跨越的边界,所以上一节讲的数字切法、空格归属、标点能不能连着换行,全在这一段决定。GPT-2 是 ByteLevel(内置那条正则 + 字节到可见字符的映射);Qwen2.5 是 Split(自己的正则,数字 \p{N} 逐位)接 ByteLevel;DeepSeek-V3 是四段串联——\p{N}{1,3} 三位一段、CJK 连续段、再一条主正则——最后 ByteLevel;BERT 的 BertPreTokenizer 按空白与标点切;T5 的 Metaspace 把空格换成 ▁ 并粘到后面的词上。
  3. Model:段内切成 token。输入一个个段,输出 token 与 id。三种模型:BPE(词表 + 有序 merges,第 3 节)、WordPiece(词表 + ## 续词前缀,第 5 节)、Unigram(每个 token 带一个对数概率,第 5 节)。tokenizer.json 里体积最大的就是这一段的 vocab 与 merges:GPT-2 50 257 项 + 50 000 条 merges,Qwen2.5 151 643 + 151 387,DeepSeek-V3 128 000 + 127 741。压缩率与词表内容由第 2、3 两段共同决定——第 2 段定边界,第 3 段在边界内分配词表。
  4. Post-processor:加上模型约定的特殊 token。输入 token 序列,输出最终喂给模型的序列。BERT 的 TemplateProcessing 把单句包成 [CLS] A [SEP]、句对包成 [CLS] A [SEP] B [SEP] 并给出 segment id;T5 在句尾加 </s>;GPT-2 / Qwen2.5 / DeepSeek-V3 这一段是 ByteLevel,只修正偏移量,不加任何 token——它们的 BOS/EOS 与对话模板由模型侧的 chat_template 负责(第六章第 5 节)。这一段决定特殊 token 怎么进入序列,不影响词表内容。

把五个 tokenizer 的四段并排,再喂同一句 Hello world! 机器学习 2024:

五个 tokenizer 的四段流水线与同一句话的切法(tokenizers 0.23 读出的 tokenizer.json)
  GPT-2 Qwen2.5 DeepSeek-V3 BERT-base-uncased T5-small
Normalizer 无 NFC 无 小写 + 清理 + 汉字两侧加空格 SentencePiece 预编译映射
Pre-tokenizer ByteLevel(GPT-2 正则) Split(数字逐位)→ ByteLevel Split ×3(数字 3 位、CJK 段、主正则)→ ByteLevel 空白 + 标点 空白 → Metaspace ▁
Model BPE,50 257 BPE,151 643 BPE,128 000 WordPiece,30 522 Unigram,32 100
Post-processor ByteLevel(不加 token) ByteLevel(不加 token) ByteLevel(不加 token) [CLS] … [SEP] 尾加 </s>
切出来 Hello ␣world ! + 中文 12 个字节切成 9 个碎片 + ␣2024,13 个 Hello ␣world ! ␣ 机器 学习 ␣ 2 0 2 4,11 个 Hello ␣world ! ␣ 机器学习 ␣ 202 4,8 个 [CLS] hello world ! [UNK] [UNK] 学 [UNK] 202 ##4 [SEP],11 个 ▁Hello ▁world ! ▁ 机器学习 ▁20 24 </s>,8 个

同一句话把四段的作用各演了一遍:BERT 的 hello 小写了(Normalizer);2024 四种切法——GPT-2 整个一格、Qwen 逐位、DeepSeek 三位一段、T5 两位两位(Pre-tokenizer 定边界,Model 在边界内分);中文从 9 个字节碎片到 1 格(Model 的词表里有没有);只有 BERT 与 T5 多出了 [CLS]、[SEP]、</s>(Post-processor)。读一个陌生 tokenizer 时按这个顺序看它的 tokenizer.json,几分钟就知道它的数字、空格、中文会怎么切:

import json
from tokenizers import Tokenizer

tok = Tokenizer.from_pretrained("Qwen/Qwen2.5-0.5B")
cfg = json.loads(tok.to_str())                      # 就是 tokenizer.json 的内容
for part in ["normalizer", "pre_tokenizer", "model", "post_processor"]:
    print(part, "->", cfg[part]["type"] if cfg[part] else None)
print(tok.encode("Hello world! 机器学习 2024").tokens)

四、词表大小的账

1. 参数:2Vd,tied 与 untied

词表进入模型的地方有两个:输入端的 embedding 表 \(V \times d\),输出端的 lm_head \(d \times V\)。两者不共享(untied)时词表参数是 \(2Vd\),共享(tied)时是 \(Vd\)。《Transformer 与 LLM》第五篇的参数量公式里这一项写成 \(2 V d\);代入六个模型(llm_cost_09_vocab.py 的输出):

各模型词表参数与 lm_head 的占比
模型 \(V\) \(d\) 总参数 词表参数 占比 lm_head FLOPs/token 占 FLOPs lm_head 字节(BF16)
Llama-2-7B 32 000 4096 6.74B 262M 3.9% 0.26 G 2.0% 262 MB
Llama-3-8B 128 256 4096 8.03B 1.05B 13.1% 1.05 G 7.0% 1.05 GB
Llama-3-70B 128 256 8192 70.6B 2.10B 3.0% 2.10 G 1.5% 2.10 GB
Qwen2.5-7B 152 064 3584 7.62B 1.09B 14.3% 1.09 G 7.7% 1.09 GB
Qwen2.5-0.5B(tied) 151 936 896 494M 136M 27.6% 0.27 G 27.6% 272 MB
Gemma-2-2B(tied) 256 000 2304 2.61B 590M 22.6% 1.18 G 22.6% 1.18 GB

Llama 2 到 Llama 3 的 7B/8B 规格,骨架几乎一样(都是 32 层、\(d = 4096\),Llama 3 的 FFN 略宽并换了 GQA),参数从 6.74B 到 8.03B 里有 0.79B 是词表扩大带来的——“8B”比”7B”多出来的那 1B 主要是词表。

要不要 tie,是一个随模型大小变化的取舍。tie 的理由是省参数:0.5B 模型 untie 要多 136M,占 27%。不 tie 的理由是两张表干的不是一件事——embedding 把 id 映射到输入空间,lm_head 把最后一层的表示投影到 logits,两者的几何结构不同(输出侧的向量范数与 token 频率强相关,输入侧不必如此),共享一张表要模型在两种用途间妥协。经验上小模型 tie,大模型 untie:Qwen2.5 从 0.5B 到 3B tie(3B 的 config.json 里 tie_word_embeddings: true)、7B 起 untie;Gemma 全系 tie(它的词表 256K,untie 的代价太大);Llama 全系 untie。在参数量的公式里,这是”\(Vd\) 还是 \(2Vd\)“的一个开关,读 config.json 里的 tie_word_embeddings 即知。

2. 为什么 vocab_size 是 128 的倍数

第一章留的问题:Qwen2.5-7B 的 tokenizer 有 151 665 个 token,vocab_size 却是 152 064;DeepSeek-V3 是 128 815 对 129 280;Llama 3 的 128 256 = 128 000 + 256 个特殊 token 位。三个数除以 128 分别是 1188、1010、1002,都是整数。

这是训练框架的要求。Megatron-LM 的 --make-vocab-size-divisible-by 默认 128,理由有两个:

  • GEMM 对齐。lm_head 的输出维度 \(V\) 是矩阵乘的 \(N\) 维,Tensor Core 在 \(N\) 是 64 或 128 的倍数时才走最快的 tile 形状;
  • 张量并行整除。lm_head 与 embedding 沿 \(V\) 切到 TP 组的各卡上(下一节),\(V\) 必须能被 TP 度整除,且每卡的分片也要对齐。128 的倍数在 TP = 8 时每卡 16 的倍数——刚好够。

多出来的填充位在 tokenizer 里没有对应的字符串,模型永远不会输出它们(训练时它们从不作为目标出现,logits 会被压到极低),但它们占参数、占 FLOPs、占显存——Qwen2.5-7B 的 399 个填充位是 \(2 \times 399 \times 3584 = 2.9\)M 参数,不多,但推理时如果 top-k 采样不把它们排除,理论上仍可能被采到。vLLM 与 HF 的 generate 都按 tokenizer 的实际大小截断 logits,这是那个 vocab_size ≠ len(tokenizer) 差异在推理框架里的落点。

3. FLOPs:lm_head 是一个 d × V 的 GEMM

embedding 是查表,不算 FLOPs;lm_head 是每个 token 一次 \([1, d] \times [d, V]\) 的矩阵乘,\(2Vd\) FLOPs。Llama-3-8B 每 token 1.05 GFLOPs,占 15.0 GFLOPs 的 7%;这是一层 Transformer 的两倍多(每层 \(2 \times 218\text{M} = 0.44\) GFLOPs)——lm_head 是模型里最贵的单个矩阵。

在小模型里它的占比失控:Qwen2.5-0.5B 的 lm_head 占每 token FLOPs 的 27.6%,Gemma-2-2B 占 22.6%。这两个模型都 tie 了 embedding,参数上只算一份,但 FLOPs 上 lm_head 一分不少——算 FLOPs 分母时那张共享表要作为 lm_head 算进去(早期版本这里把它从分母里扣掉了,得到 38%,是算错)。tied 模型的”词表参数占比”与”lm_head FLOPs 占比”恰好相等,不是巧合:分子都是 \(Vd\)、分母都是含一张表的总量。小模型选大词表,是为了和同系列的大模型共用 tokenizer(数据只需 tokenize 一次、蒸馏时 logits 可对齐——第三篇与后训练系列会用到),代价是三分之一的算力花在输出层。

训练时这一项更重。反向传播对 lm_head 要算两个梯度(对权重、对输入),《Transformer 与 LLM》第十篇的”训练 = 3 × 前向”对它同样成立:Llama-3-8B 每 token 训练 FLOPs 约 \(6N = 48\) GFLOPs,其中 lm_head 贡献 \(6 \times 0.525\text{B} = 3.15\) GFLOPs,仍是 7%。但 15T token 乘下来,Llama-3-8B 全部预训练里有约 \(4.7 \times 10^{22}\) FLOPs 花在输出层——按 H100 40% MFU 算约 33 000 GPU·小时。

4. 字节:decode 每步读一遍 lm_head,训练时 logits 要放得下

decode 是 memory-bound 的(《Transformer 与 LLM》第十篇),每步读一遍全部权重,lm_head 也在其中:Llama-3-8B 的 1.05 GB 占 16.06 GB 的 6.5%,H100 上 0.31 ms。词表从 32K 到 256K 时这一项从 0.08 ms 到 0.63 ms——不致命,但它是权重里唯一随 \(V\) 线性增长的部分。embedding 的读取则可以忽略:每 token 只 gather 一行 \(d\) 个数,是随机访存但总量极小。

更大的问题在训练侧。交叉熵要在 FP32 下算 softmax,logits 张量是 \(\text{tokens} \times V \times 4\) 字节:一条 8K 的序列在 128K 词表下是 3.9 GiB,Llama 3 405B 训练时 16K 序列的 logits 是 7.8 GiB 每条序列——比模型任何一层的激活值都大(一层的主要激活是 \(\text{tokens} \times d \times 2\) 字节,\(d = 16384\) 时 16K 序列只 0.5 GiB)。而且 logits 在反向时还要留一份梯度,同样大小。

两种标准解法,都是”永远不把整个 logits 张量放进显存”:

vocab-parallel 交叉熵(Megatron)。lm_head 沿 \(V\) 切到 TP 组的 \(t\) 张卡上,每卡算自己那 \(V/t\) 列的 logits \(z^{(k)}\),logits 的分片自然就是 \(1/t\)。交叉熵 \(\ell = \log \sum_j e^{z_j} - z_y\) 需要全局的 logsumexp 与目标 token 的 logit,两者都能用两次标量 all-reduce 拼出来:

\[m = \max_k m^{(k)}, \quad m^{(k)} = \max_{j \in \text{shard } k} z_j \qquad S = \sum_k S^{(k)}, \quad S^{(k)} = \sum_{j \in \text{shard } k} e^{z_j - m}\] \[\ell = m + \log S - z_y\]

其中 \(z_y\) 只在持有目标 id 的那张卡上非零,其余卡填 0,一次 all-reduce sum 取得。每个 token 三个标量的通信,logits 显存降为 \(1/t\):TP = 8 时 8K 序列 3.9 GiB → 0.49 GiB。梯度 \(\partial \ell / \partial z_j = \text{softmax}_j - \mathbb{1}[j = y]\) 每卡在自己的分片上就地算出,不需要再通信。

分块融合(Liger Kernel、Apex 的 fused cross entropy、Unsloth)。不切卡而是切 token:把 \(\text{tokens}\) 分成 \(c\) 个块,每块算 \([\text{chunk}, d] \times [d, V]\) 得到一小块 logits,立刻算 loss 与梯度 \(\partial \ell / \partial h\)(\([\text{chunk}, d]\),很小)并把对 lm_head 权重的梯度累加进去,然后丢掉这块 logits。峰值显存从 \(\text{tokens} \times V \times 4\) 降到 \(\text{chunk} \times V \times 4\):chunk = 1024 时 128K 词表只需 0.5 GiB,与序列长度无关。代价是 lm_head 的 GEMM 被切成 \(c\) 个小 GEMM,\(\text{chunk}\) 太小会掉出 Tensor Core 的高效区,所以 chunk 通常取 1K–4K。

同一个 8B 骨架换四种词表的全套数字:

同一 8B 骨架换四种词表的全套数字
\(V\) 参数 词表占比 FLOPs/token lm_head 占比 decode 读 lm_head(H100) 8K 序列 logits(FP32)
32 000 7.24B 3.6% 14.22 G 1.8% 0.08 ms 1.0 GiB
64 000 7.50B 7.0% 14.48 G 3.6% 0.16 ms 2.0 GiB
128 256 8.03B 13.1% 15.01 G 7.0% 0.31 ms 3.9 GiB
256 000 9.08B 23.1% 16.06 G 13.1% 0.63 ms 7.8 GiB

这一侧的结论:词表每翻一倍,8B 模型每个 token 贵约 3.5%,训练时 logits 显存翻倍。

5. 训练状态:词表参数按 16 字节算

《Transformer 与 LLM》第十二篇算 LoRA 时用过训练状态的账:混合精度 + Adam 下每个参数 BF16 权重 2 B + FP32 主权重 4 B + Adam 一阶、二阶矩各 4 B + BF16 梯度 2 B = 16 字节。词表参数也在其中:Llama-3-8B 的 1.05B 词表参数是 16.8 GB 训练状态,比 lm_head 权重本身(1.05 GB)大一个数量级;Llama-3-70B 的 2.1B 是 33.6 GB。这部分状态在 TP 里沿 \(V\) 切、在 ZeRO 里按参数切,不构成单卡瓶颈,但它提醒一件事:扩词表的成本在训练时是 16 倍于推理时——Llama 3 从 32K 到 128K 多出的 0.79B 参数,训练时是 12.6 GB 的状态。

另有一个更细的问题:embedding 的梯度是稀疏的——一个 batch 里没出现的 token,它的 embedding 行梯度为零。但 Adam 的状态是稠密的,\(m\)、\(v\) 每步都要按全表更新(衰减),所以 embedding 的优化器开销与它的更新频率无关。这也是为什么有的框架给 embedding 单独用 SparseAdam 或不同的权重衰减:一个 15T token 的训练里,一个只出现过一万次的罕见 token,它的 embedding 行接受了一万次有效梯度和几百万次纯衰减。

6. 采样:每步对 V 个 logits 做 softmax 与排序

推理侧还有一项与 \(V\) 成正比、常被忽略的成本:采样。每一步 decode,每条序列要对 \(V\) 个 logits 做温度缩放、softmax、top-k 或 top-p 截断再采样。top-p 需要排序(或至少部分排序),\(O(V \log V)\);128K 个 FP32 的排序在 GPU 上是微秒级,但 batch 256 就是 256 次,加上 repetition penalty、logit bias、grammar 约束(结构化输出要对 \(V\) 个 token 逐个判断是否合法)等每个 token 的处理,采样器在高并发下可以占到 decode 步时间的 5–10%。vLLM 把采样器写成一个独立的融合 kernel,SGLang 的约束解码把语法状态编译成对词表的位掩码——都是为了把这一步压到与 \(V\) 无关的量级。这是词表大小在推理路径上除 lm_head 之外的第二个落点。

五、token 效率的账

1. 度量:字符/token 与 fertility

压缩率有两种写法:每 token 多少字符(或字节;越大越省)和 fertility——每个词平均切成多少 token(越小越省)。前者跨语言可比,后者只对有空格分词的语言有意义。本文用字符/token 与它的倒数 token/字符。

五个 tokenizer 在四段样本上的数字(tokenizer_compare.py):

五个 tokenizer 在四段样本上的压缩率
tokenizer 词表 英文(174 字符) 中文(63 字符) Python(189 字符) 数字混排(113 字符)
GPT-2 50K 33 tok · 5.27 c/t 97 tok · 0.65 c/t 95 tok · 1.99 c/t 34 tok · 3.32 c/t
cl100k_base 100K 32 tok · 5.44 c/t 57 tok · 1.11 c/t 52 tok · 3.63 c/t 41 tok · 2.76 c/t
o200k_base 200K 32 tok · 5.44 c/t 39 tok · 1.62 c/t 52 tok · 3.63 c/t 40 tok · 2.83 c/t
Qwen2.5 152K 32 tok · 5.44 c/t 31 tok · 2.03 c/t 52 tok · 3.63 c/t 58 tok · 1.95 c/t
DeepSeek-V3 129K 33 tok · 5.27 c/t 27 tok · 2.33 c/t 58 tok · 3.26 c/t 41 tok · 2.76 c/t

英文一列几乎没有差别:从 50K 到 200K 词表,同一句话 33 → 32 个 token。英文早已饱和——常用词在 50K 词表里就已经各是一个 token,再扩词表加进来的是罕见词与其他语言。差别全在另外三列。

这一句英文样本 5.44 字符/token 比 Llama 3 报告的 3.94 高,是因为样本是通顺的散文;真实预训练语料里有代码、表格、URL、拼写错误,平均值会低得多。压缩率是语料的性质,不只是 tokenizer 的性质,比较两个 tokenizer 要在同一份足够大、足够杂的样本上比。

2. 每字符成本:Llama 2 → Llama 3

Llama 3 论文给出它的 tokenizer 在英文上把压缩率从 Llama 2 的 3.17 字符/token 提到 3.94。把第四章的 FLOPs/token 除进去:

Llama 2 与 Llama 3 tokenizer 的每字符成本
  FLOPs/token 字符/token FLOPs/字符 KV/字符
8B 骨架 + 32K 词表 14.22 G 3.17 4.49 G 40.4 KiB
Llama-3-8B(128K) 15.01 G 3.94 3.81 G(−15%) 32.5 KiB(−20%)

每个 token 贵 5.6%,每段英文的 token 少 20%,净效果每字符便宜 15%。KV 的收益更大(20%),因为 KV/token 不随 \(V\) 变。对推理系统这意味着:同样的显存放下多 25% 的上下文字符、同样的 prompt 少 20% 的 prefill 时间、生成同一段回答少 20% 的 decode 步——tokenizer 的改进是少数对 prefill、decode、KV 三项同时有效的优化,而且是零运行时开销的。

从训练侧看同一件事:Llama 3 报告 15T token 的预训练。如果换回 32K 词表,同样的文本量要 \(15 \times 3.94 / 3.17 = 18.6\)T token,每 token 少 5.6% 的 FLOPs 也追不回 24% 的 token 数——训练算力多 17%。第三篇的 scaling law 里 \(D\) 是 token 数,但决定模型见过多少信息的是字符数;tokenizer 的压缩率是 \(D\) 与”数据量”之间的汇率。

3. 跨 tokenizer 怎么比 loss

第三章留下的问题:困惑度依赖 tokenizer,那 Llama 2 的预训练 loss 与 Llama 3 的能不能比?直接比不能。一段文本 \(B\) 个字节,切成 \(T\) 个 token,模型对它的总负对数似然是 \(T \cdot L\)(\(L\) 是每 token 的平均 loss,nats)。两个不同的模型对同一段文字给出的总概率当然不同——这个总量是模型的性质,不是文本的性质;bits/byte 的意义只是把它换到一个与切法无关的单位(每字节几 bit),让两个 tokenizer 不同的模型可以放在同一把尺上比。换算是:

\[\text{bits/byte} = \frac{T \cdot L}{B \cdot \ln 2} = \frac{L}{\ln 2} \cdot \frac{T}{B}\]

即每 token 的 loss 乘上 token/字节,再换到以 2 为底。Llama 2 的 tokenizer 每 token 3.17 字符(英文约 1 字节/字符),Llama 3 是 3.94:若两个模型的 bits/byte 相同,Llama 3 的每 token loss 应比 Llama 2 高 \(3.94 / 3.17 = 1.24\) 倍。看到 Llama 3 的训练曲线收在比 Llama 2 更高的 loss 值,不说明它更差——它每个 token 装了更多的字符,每个 token 更难预测。

反过来这也解释了一个常见的错误结论”大词表让 loss 变高”或”中文 loss 比英文高”:一个汉字在 Qwen 词表下约 0.8 个 token、信息量约 10 bits,每 token 的 loss 天然高于每 token 装 5 个英文字母的英文。第三篇画 scaling law 曲线时默认所有模型共用一个 tokenizer,正是为了避开这个换算;比较不同 tokenizer 的模型只能用 bits/byte 或下游任务。

4. 词表大小的边际收益递减

在 1 MB Python 标准库源码上从零训 BPE,词表从 256 扫到 16 384(bpe_from_scratch.py):

从零训 BPE 时词表大小的边际收益
词表 训练集 bytes/token 英文句 Python 片段 中文句
256 1.00 1.00 1.00 1.00
512 2.21 1.63 2.17 1.00
1 024 2.81 1.79 2.32 1.00
2 048 3.35 2.42 2.41 1.00
4 096 3.85 2.68 2.41 1.00
8 192 4.27 2.68 2.41 1.00
16 384 4.57 3.00 2.50 1.00

词表每翻一倍,训练集的压缩率大约加 0.4–0.5 字节/token——近似对数增长。前几次翻倍收益最大(256 → 2048 从 1.0 到 3.35),之后每翻一倍只多 10% 左右。这与词频的 Zipf 分布一致:第 \(r\) 常见的 token 频率约 \(\propto 1/r\),词表从 \(V\) 扩到 \(2V\) 新增的那些 token 合计只覆盖语料的 \(\ln 2 / \ln V\) 左右——\(V = 64\text{K}\) 时约 6%。

而第四章算过每翻一倍 lm_head 的成本翻一倍。两条曲线一条对数一条线性,交点就是”最优词表”;它在哪取决于模型多大——lm_head 在 70B 模型里只占 1.5%,翻倍几乎免费,在 0.5B 模型里占 28%,翻倍要付真金白银。

Tao 等 2024 把这件事做成了 scaling law:在固定训练算力下,最优词表大小随非词表参数量 \(N_{nv}\) 呈幂律增长(他们拟合的指数约 0.4–0.5——词表应比参数长得慢,但要一起长),且当前多数模型的词表偏小——他们估计 Llama-2-70B 的最优词表应在 216K 以上而不是 32K。论文的另一个结论对系统更有用:训练数据越多,最优词表越大。理由是 embedding 要靠出现次数训练,罕见 token 在数据少时训不好,拖累整体;数据多了这个约束放松。这与业界的走向一致:Llama 3 128K、Qwen 152K、Gemma 256K、GPT-4o 200K。

中文一列在这个实验里始终是 1.00——语料里没有中文,BPE 学不到任何跨字节的合并,每个汉字保持 3 个字节 token。这是下一节的主题。

5. 三个特例:中文、代码、数字

中文。第一节的表里,中文一列从 0.65 到 2.33 字符/token 相差 3.6 倍,按每个汉字算:

各 tokenizer 每个汉字的 token 数与切分示例
tokenizer token/汉字 8B 规格下每个汉字 切分示例
GPT-2 2.49 — 全部是字节碎片 � � �
cl100k(≈ Llama 3 英文部分) 1.46 21.9 GFLOPs · 187 KiB KV 分 · �� · 器 · �� · 定 · 一
o200k 1.00 15.0 GFLOPs · 128 KiB KV 分 · 词 · 器 · 决定 · 一句 · 话
Qwen2.5 0.79 11.9 GFLOPs · 101 KiB KV 分 · 词 · 器 · 决定 · 一句话 · 变成
DeepSeek-V3 0.69 10.4 GFLOPs · 88 KiB KV 分词 · 器 · 决定 · 一句话 · 变成 · 多少个

cl100k 里”词”和”决”各是两个不完整的字节 token(��),说明这两个字在它的训练语料里不够频繁,没有合并成整字。GB2312 常用汉字 6763 个,若要每个字至少是一个 token,词表里要留至少这么多位;要让常用双字词成为一个 token,还要再几万位——Qwen 与 DeepSeek 的词表里中文 token 估计占三到四成。它们的词表大小和 cl100k 在同一量级,但训练语料里有大量中文,常用词组(一句话、多少个)都成了单个 token。同一段中文,用 cl100k 系的模型服务比用 DeepSeek 贵 2.1 倍——prefill、decode 步数、KV 全部按这个比例。对多语言服务的容量规划,”每请求多少 token”必须按语言分别估。

代码。GPT-2 与 cl100k 在 Python 上差 1.8 倍(1.99 vs 3.63 字符/token),几乎全部来自空白处理:GPT-2 把每个空格切成一个 token,8 个空格的缩进是 8 个 token;cl100k 起的 tokenizer 把连续空格合成一个。四层缩进的代码有四分之一的 token 是空格——这是 GPT-2 时代”代码模型要单独训 tokenizer”的原因,现在已经不是问题。代码还有第二个特点:标识符是驼峰或下划线拼接的多词(getUserById、max_seq_len),预分词正则把 \p{L}+ 当一段,于是 getUserById 在词表里没有整体只能切成 get|User|By|Id——碎片有语义,反而是好事;而 max_seq_len 会在下划线处被正则切开,是三段字母加两个下划线标点。

数字。同一个 13 位数 15000000000000:

  • GPT-2:[‘15’, ‘00000000’, ‘0000’];3 个 token,切法依赖词表里碰巧有哪些数字串
  • cl100k:[‘150’, ‘000’, ‘000’, ‘000’, ‘00’];5 个,固定 1–3 位一段
  • DeepSeek-V3:[‘150’, ‘000’, ‘000’, ‘000’, ‘00’];同 cl100k
  • Qwen2.5:[‘1’,’5’,’0’,’0’,’0’,’0’,’0’,’0’,’0’,’0’,’0’,’0’,’0’,’0’];14 个,逐位

GPT-2 的切法对算术是灾难:1000 和 1001 可能被切成完全不同的段,模型看不到位值。3 位一段是”块对齐”,逐位是”位对齐”;Qwen 为算术精度付出数字长 3 倍的代价,在数字密集的表格与日志上 token 效率明显低(第一节的数字混排列:58 对 41)。

6. 上下文窗口”有多长”

一个 128K token 的上下文窗口,装得下多少文字,取决于装什么、用哪个 tokenizer。用第 1 节与第 5 节的压缩率换算:

128K 上下文窗口装得下多少文字
内容 tokenizer 字符/token 128K token 装下
英文散文 任何主流 ≈ 4–5 50–65 万字符 ≈ 10–13 万词,一本长篇小说
中文 cl100k 系 0.68 8.9 万字,一部中篇
中文 DeepSeek-V3 1.45 19 万字,一部长篇
Python 代码 cl100k 系 3.6 46 万字符 ≈ 1.2 万行
Python 代码 GPT-2 2.0 26 万字符 ≈ 6 500 行
base64 / 哈希 / 随机串 任何 ≈ 1.5–2.5 20–30 万字符

同一个”128K”对中文用户可能只有英文用户的五分之一到三分之一。RAG 系统的 chunk 预算、长文档摘要的分段策略、agent 的上下文管理,如果按”一个 token 约等于 0.75 个英文词”的经验规则设计,在非英文流量上会系统性地超预算。

六、tokenizer 与模型行为

1. 词表是训练语料的化石

BPE 的词表完全由训练 tokenizer 用的语料决定,与模型训练语料无关——两者常常不同。GPT-2 的词表在 WebText 上训练,其中包含大量 Reddit 用户名与日志垃圾,于是 ␣SolidGoldMagikarp、␣petertodd 一类字符串成了单个 token;模型预训练语料里几乎没有它们,这些 token 的 embedding 基本没被更新,输入时模型行为异常(Rumbelow & Watkins 2023 的”glitch token”)。同类问题在每个词表里都存在:任何在 tokenizer 语料里频繁、在模型语料里罕见的 token,都是欠训练的。

Land & Bartolo 2024 给出了系统的检测方法,思路是看输出侧:一个从未作为预测目标出现过的 token,它在 lm_head 里那一行只受到过 softmax 的”推低”梯度(\(\partial \ell / \partial z_j = p_j\),永远为正),没有过”拉高”的梯度(目标 token 的 \(p_y - 1 < 0\)),于是它的 lm_head 向量会朝着一个共同的方向收缩,与其他欠训练 token 聚在一起,范数偏小。计算每个 token 的 lm_head 行与这个”欠训练方向”的余弦或范数,排序,尾部就是候选;再用 prompt 让模型复述这些 token 验证。他们在几乎每个开源模型里都找到了几十到几千个这样的 token——tied embedding 的模型更多,因为输入侧的 embedding 同样没被训练,两个问题叠在一行上。

反过来,tokenizer 语料里没有的东西永远是碎片。第五章的实验里用纯英文代码语料训出的词表把中文全部退回字节;Llama 2 的 32K SentencePiece 词表包含约 700 个汉字,其余全走 byte fallback。要服务一种语言,先看它在词表里的压缩率,这比模型大小更直接地决定成本。

2. 数字与算术

第五章的三种切法对应三种算术表现。位对齐(Qwen)让加法的每一列在 token 序列上对齐,进位是局部操作;3 位一段(Llama 3 / GPT-4)介于两者之间;GPT-2 式的任意切分让模型必须先”记住”每个数字串 token 是几位、值多少。研究普遍发现从右向左按 3 位分组(与人类的千分位一致)比从左向右好,但主流 tokenizer 的正则是从左向右匹配的——1234567 被切成 123|456|7 而不是 1|234|567。这是一个已知的、与 tokenizer 正则绑定的缺陷,改它意味着换词表重训。

一个更隐蔽的例子是小数与单位。3.14 在 3 位一段的正则下是 3|.|14,3.140 是 3|.|140,两者数值几乎相等却是不同的 token 序列;10kg 与 10 kg 也是。模型要在训练中学会这些等价关系,而不是从表示里免费得到。评测里”模型算不对多位数乘法”有一部分要归到这里——不是推理能力,是表示。

3. 多语言的价格差

第五章的中文数字换个角度看:同一个 API 按 token 计费,同样一段内容,中文用户付的钱可以是英文用户的两倍以上。cl100k 下中文 1.11 字符/token 对英文 5.44;按信息量折算——一个英文单词约相当于 1.5–2 个汉字——中文每”词”约 2.6 个 token,英文约 1.05 个,差 2.4 倍。Petrov 等 2023 在 17 个 tokenizer 上比较了同一段平行语料的 token 数,最大差距超过 15 倍(缅甸文、阿姆哈拉文等在英文为主的词表下每个字符 4–6 个 token);Ahia 等 2023 把它换算成 API 价格,同样的内容低资源语言用户付的钱可以是英文的十倍以上。这既是公平性问题也是容量问题——”平均每请求 N 个 token”的假设在多语言流量下不成立;同样的上下文窗口对不同语言”长度”不同(第五章第 6 节);同样的 max_tokens 限制在不同语言下截断的位置不同。

对做模型的人这也是一个训练问题:预训练语料里中文占 10%,但如果 tokenizer 对中文的压缩率只有英文的一半,那么按 token 数算中文占了 20% 的训练步、按信息量算却只有 10%——语料配比要按什么单位算(第四篇)与 tokenizer 直接相关。

4. token 边界偏差与 token healing

BPE 是贪心的,一段文字的切法依赖它后面跟着什么。http:// 后面接 www 时,词表里可能有 ://www 这个 token;如果 prompt 恰好以 http:// 结尾,tokenizer 只能切出 ://,而模型在训练中很少见到 :// 后面紧接一个新 token 的情形——它见到的都是 ://www、://github 这样的整体。于是模型在这个位置的预测分布是偏的:它会低估 www 的概率,因为训练时这个组合几乎从未以”两个 token”的形式出现。

这就是 token 边界偏差(token boundary bias),在代码补全(光标停在标识符中间)、结构化输出(prompt 以 {"name": " 结尾)与 few-shot 模板(示例以标点结尾无换行——DeepSeek-V3 报告的那个问题)里最常见。token healing(Microsoft 的 guidance 库提出,vLLM、llama.cpp 等已实现)的修法是:把 prompt 的最后一个 token 回退掉,生成时把词表限制在以被回退的字符串为前缀的 token 上,让模型自己”重新切”最后一段。代价是第一步的采样要加一个前缀掩码,收益是消除了一类不好解释的输出退化。DeepSeek-V3 的做法是从训练侧解决:随机拆开一部分合并 token,让模型见过”拆开的”版本。两种方法说明同一件事——tokenizer 的贪心性会漏到模型行为里,不是纯预处理。

5. 特殊 token 与 chat template

词表里除了子词还有一组特殊 token:<|begin_of_text|>、<|eot_id|>、<|start_header_id|> 这类控制符,对话格式(chat template)用它们标记角色与轮次边界。它们在预训练时通常不出现(或只作为文档分隔符),在后训练阶段才被赋予含义;embedding 在预训练结束时是欠训练的,SFT 的一部分工作就是把它们训出来。Llama 3 预留了 256 个特殊 token 位(128 000–128 255),这就是 128 256 这个数字的来源:100K(cl100k)+ 28K(非英语)+ 256(特殊)。

特殊 token 与普通 token 的另一个区别是它们是否能从文本里切出来是一个开关,而且默认值与直觉相反:HF tokenizer 的 split_special_tokens 默认 False,意思是”不拆开特殊 token”——用户输入里若出现字面的 <|eot_id|> 字符串,默认会被识别成那一个控制 id(本地用 3 词的 toy tokenizer 验证:字面 <eot> 直接输出特殊 id;设成 True 才被切成普通碎片)。所以把 tokenizer 当安全边界是错的:要防止用户伪造”助手回合结束”,推理服务器必须显式地对用户输入做转义或在应用层过滤(vLLM 等引擎有对应选项),不能靠默认行为;把它设错是一类真实的注入漏洞。后训练系列的第一篇会回到特殊 token 与模板的细节。

七、换词表:扩展、裁剪、移植与不用 tokenizer

1. 扩词表继续预训练

一个英文为主的模型要服务中文,最直接的办法是往词表里加 token然后继续预训练。Chinese-LLaMA(Cui 等 2023)给 Llama 的 32K 词表加了约 20K 中文 token 到 49 953,中文压缩率提高约一倍。工程上有三个问题:

  • 新 embedding 怎么初始化。随机初始化的新行会让模型在见到新 token 时输出乱码,且要很多数据才能追上。常见做法是把新 token 按旧词表切成碎片,取碎片 embedding 的均值作初始值——一个由 分 + 词 合成的新 token,初始向量是两个字的平均。lm_head 的新行同样处理。
  • 要多少数据。新 token 的 embedding 每出现一次才更新一次。按 Zipf 估计,词表末尾的 token 在语料中的频率约 \(1/(V \ln V)\),\(V = 50\text{K}\) 时约 \(2 \times 10^{-6}\);要让它累积 1 万次有效更新,需要约 50 亿 token 的目标语言语料——Chinese-LLaMA 用了 200 亿。数据不足时新 token 就是第六章的欠训练 token。
  • 旧能力怎么保。继续预训练用的语料如果全是目标语言,英文能力会退化(第五篇会讲课程与回放)。Chinese-LLaMA 的做法是先只训 embedding 与 lm_head、再放开 LoRA、最后全量。

一个反面的账:扩词表让每个 token 贵了(\(V\) 从 32K 到 50K,7B 模型每 token +2%),如果目标语言在流量里只占一小部分,这 2% 是所有请求都要付的税。所以商业模型倾向于一开始就用大的多语言词表,而不是事后扩。

2. 词表裁剪

反方向:一个 256K 词表的模型部署到只服务英文的场景,词表里七成的 token 永远用不到,但 lm_head 的每一行都要读、都要算。裁剪(vocabulary pruning / trimming)把从未在目标语料中出现的 token 从 embedding 与 lm_head 里删掉,tokenizer 同步移除对应的 merge。Gemma-2-2B 的 lm_head 是 1.18 GB、占 FLOPs 的 29%,裁到 64K 后是 0.29 GB、占 9%——每 token 省 22% 的 FLOPs,模型对保留的 token 行为完全不变(softmax 的分母少了一些几乎为零的项,微小的分布变化)。代价是被删的 token 对应的文本从此只能走碎片,跟第六章说的”永远是碎片”一样。这在边缘部署与小模型上是真实收益,在大模型上不值得——70B 的 lm_head 只占 1.5%。

3. tokenizer 移植

蒸馏(后训练系列第七篇)需要教师与学生的 logits 逐位对齐,前提是共用词表。当两个模型词表不同时有两条路:把学生换到教师的 tokenizer(相当于第 1 节的扩词表 + 裁剪一起做,然后用一段继续预训练恢复),或者在两个词表之间建一张映射——对每个学生 token 找教师词表里字符串相同或最接近的 token,只在对齐上的位置算 KL,其余退化成序列级蒸馏。前者代价大但干净,后者便宜但损失一部分信号。同系列大小模型共用 tokenizer(Qwen、Llama、Gemma 都是这样)正是为了让这一步不存在——这是第四章”小模型为什么背一个大词表”的另一半理由。

4. 不用 tokenizer:字节级模型

既然 tokenizer 带来这么多副作用——多语言不公平、数字切分、边界偏差、欠训练 token——为什么不直接在字节上建模?ByT5(Xue 等 2022)做了:\(V = 256\),没有任何上述问题,但序列长 4–5 倍,按第三章的账 attention 项贵 16–25 倍,同等算力下质量落后。

之后的字节级工作都在解决”怎么把序列压回去”。MegaByte(Yu 等 2023)把字节按固定长度分块,一个大模型处理块级表示、一个小模型在块内逐字节生成。Byte Latent Transformer(Pagnoni 等 2024,Meta)把固定分块换成动态分块:用一个小的字节级语言模型算每个位置的下一字节熵,熵高的地方(新词开头、不可预测处)切一个 patch 边界,熵低的地方(词的后半、常见搭配)延长 patch——平均 patch 长度可以调到 6–8 个字节,比 BPE 的 4–5 字符/token 还长。主模型在 patch 上运行,参数量与 FLOPs 都不再与词表挂钩(没有 \(2Vd\)),局部编解码器负责字节与 patch 的转换。论文报告在同等训练 FLOPs 下能追平 Llama 3 的 BPE 模型,并且在噪声输入、字符级任务与低资源语言上更好。

它还没有成为主流,原因是工程栈:推理框架、KV cache、投机解码、结构化输出全部围绕”token”设计,patch 长度可变让 batch 调度复杂;而 BPE 的问题虽多,都已经有了补丁。但它指出了一个方向:tokenizer 本质上是一个不学习的、固定的压缩器,用学习的压缩器替代它是自然的一步。第三篇讨论 scaling law 时会看到,”每 FLOP 学到多少”的比较里 tokenizer 是一个被固定住的变量,而它未必应该被固定。

八、实践:三个脚本

1. bpe_from_scratch.py:从零实现 byte-level BPE

纯标准库,150 行。训练循环就是第二章第 3 节贴的那十几行,脚本里只多两处字节级细节:词先 encode("utf-8") 成字节序列(初始 id 0–255),第 \(k\) 步合出的新 token 的 id 是 \(256 + k\)。玩具例子的输出与第二章的图逐步一致,每一步同时打印次数的来源:

merge   1:        'e' + 's'        -> id 256  (9 次 = 6 + 3)
merge   2:       'es' + 't'        -> id 257  (9 次 = 6 + 3)
merge   3:        ' ' + 'l'        -> id 258  (7 次 = 5 + 2)
merge   4:       ' l' + 'o'        -> id 259  (7 次 = 5 + 2)
merge   5:      ' lo' + 'w'        -> id 260  (7 次 = 5 + 2)
merge   6:        ' ' + 'n'        -> id 261  (6 次 = 6)
merge   7:       ' n' + 'e'        -> id 262  (6 次 = 6)
merge   8:      ' ne' + 'w'        -> id 263  (6 次 = 6)
encode(' lowest') -> [' low', 'est']
encode(' newer') -> [' new', 'e', 'r']
encode(' wide') -> [' ', 'w', 'i', 'd', 'e']

编码是同一件事的镜像——对每个预分词后的词,反复找 merge 顺序最早的相邻对合并,直到没有可合并的:

def encode_word(word_bytes, rank):                    # rank: (a, b) -> merge 序号
    ids = list(word_bytes)
    while len(ids) > 1:
        pairs = [(rank.get((x, y), INF), i) for i, (x, y) in enumerate(zip(ids, ids[1:]))]
        r, i = min(pairs)
        if r == INF: break                             # 没有可合并的对
        ids[i:i + 2] = [256 + r]
    return ids

脚本跑三件事:玩具例子逐步打印合并(上面那段);在 Python 标准库源码上扫词表大小 256 → 16 384 并报告训练集与三段样本的 bytes/token(第五章第 4 节的表);把中文句子喂给这个词表看它退回字节级。1 MB 语料到 16K 词表约两分钟——朴素实现每次合并都重新统计全部对;--quick 只跑玩具例子与两个词表大小。

2. tokenizer_compare.py:五个真实 tokenizer

依赖 tiktoken 与 tokenizers,输出第五章的全部对比表:四段样本的 token 数与字符/token、中文的 token/汉字与切分示例、13 位数字的切法、8 个空格缩进的 token 数。换成自己的样本只需改 SAMPLES。想加 Llama 3 自己的 tokenizer,用 Tokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")(需要 Hugging Face 授权)。一个实用的扩展:把你线上真实的 prompt 抽样几千条喂进去,按语言分组算字符/token——这比任何论文里的数字都更接近你的容量规划需要的汇率。

3. llm_cost_09_vocab.py:成本表加上词表这一列

沿用第七版的 ModelConfig 与 param_count,新增四个模型(Llama-2-7B、Qwen2.5-7B、Qwen2.5-0.5B、Gemma-2-2B)和三个函数:

def vocab_account(cfg):             # 词表参数与占比、lm_head 的 FLOPs 与占比、lm_head 字节
def logits_bytes(cfg, tokens):      # 训练时 logits 张量:tokens × V × 4 B
def per_char_cost(cfg, chars_per_token):   # FLOPs/字符、KV/字符

输出第四章的两张表和第五章的每字符成本。per_char_cost 是这一版最重要的一个函数:它把成本表的单位从 token 换成字符,让不同 tokenizer 的模型可以直接比价。ModelConfig 新增了 tie_embeddings 字段,param_count 据此决定词表项是 \(Vd\) 还是 \(2Vd\)。

九、本文小结

tokenizer 决定成本表的两端:

tokenizer 决定的成本表两端
项 公式 Llama-3-8B 的数字
词表参数 \(2Vd\)(tied 为 \(Vd\)) 1.05B,占 13.1%;训练状态 16.8 GB
lm_head FLOPs \(2Vd\) /token 1.05 G,占 7.0%;0.5B 模型里占 28%
lm_head 字节 \(2Vd\) B(BF16) 1.05 GB,decode 每步 0.31 ms
训练 logits \(\text{tokens} \times V \times 4\) B 8K 序列 3.9 GiB,vocab-parallel 或分块融合
压缩率 字符/token 英文 3.94(Llama 2 为 3.17);中文视词表 0.4–1.5
每字符成本 FLOPs/token ÷ 字符/token 3.81 GFLOPs,比 32K 词表低 15%;KV 低 20%
跨 tokenizer 比 loss \(\text{bits/byte} = \frac{L}{\ln 2} \cdot \frac{T}{B}\) 同等 bits/byte 下 Llama 3 每 token loss 应比 Llama 2 高 24%

几条对系统的含义:

  • 词表每翻倍,8B 模型每 token 贵 3.5%、训练 logits 显存翻倍,小模型的 lm_head 占比会失控;最优词表随模型与数据一起增长,当前的 128K–256K 是这条曲线上的一段而不是终点。
  • 多语言服务的 token 预算、上下文窗口”有多长”、API 计费的公平性,都要按语言分别估;”1 token ≈ 0.75 个英文词”的经验规则只对英文成立。
  • tokenizer 的贪心性会漏到模型行为里:欠训练 token、数字切分、token 边界偏差都不是纯预处理问题,各有训练侧与推理侧的补丁。
  • 换 tokenizer 是少数对全部三个成本项同时有效、且零运行时开销的优化——代价是要从头预训练,或者付一段继续预训练的钱去扩词表。

配套代码:transformer-and-llm/bpe_from_scratch.py(从零实现、词表扫描)、tokenizer_compare.py(五个真实 tokenizer 的对比,需 tiktoken 与 tokenizers)、llm_cost_09_vocab.py(词表的账);运行输出在 expected/。

十、自测

  1. Llama-3-8B 的 lm_head 每 token 多少 FLOPs、占总 FLOPs 多少?0.5B 的模型(\(d = 896\)、\(V = 152\)K)呢?

    答案

    \(2Vd = 2 \times 128256 \times 4096 = 1.05\) GFLOPs,占 16 G 的 7%;0.5B:\(2 \times 152\text{K} \times 896 = 0.27\) G,占约 0.99 GFLOPs 的 28%(tied 模型的分母要把共享表作为 lm_head 算进去)——小模型的词表开销失控。

  2. 训练时一个 8K 序列的 logits(FP32、\(V = 128\)K)占多少显存?为什么这是词表翻倍最先撞上的墙?

    答案

    \(8192 \times 128256 \times 4 = 3.9\) GiB,每个序列;词表翻倍它翻倍,而且 softmax 前后要两份——所以要 vocab-parallel 或分块融合的交叉熵。

  3. 英文压缩率从 3.17 到 3.94 字符/token,同一篇 10 万字符的文章 token 数各多少?每字符 FLOPs 相差多少?

    答案

    31.5K vs 25.4K token,少 19%;每 token 贵 5.6%,每字符 \(1.056 / 1.243 = 0.85\),低 15%。

  4. 两个 tokenizer 不同的模型,A 的 loss 是 2.0 nats/token、B 是 2.4 nats/token,能说 A 更好吗?还需要什么?

    答案

    不能。换算成 bits/byte:\(L / \ln 2 \times (T / B)\),需要各自在同一段文本上的 token 数 \(T\) 与字节数 \(B\)。B 的 tokenizer 更细(token 多)时每 token loss 低是自然的,反之亦然。

  5. “1 token ≈ 0.75 个英文词”对中文成立吗?对一个多语言 API 的计费意味着什么?

    答案

    不成立:中文视词表 0.4–1.5 字符/token,同一段意思的 token 数可能是英文的 1.5–3 倍;按 token 计费对不同语言的用户价格不同,上下文窗口对不同语言”有多长”也不同——预算要按语言分别估。

  1. 因为成本要按字符算而不是按 token 算。词表从 32K 到 128K,embedding + lm_head 多 \(2 \times 96\text{K} \times 4096 = 0.79\)B 参数、lm_head 的 FLOPs 让每 token 贵 5.6%;但更大的词表让同一段文本切成更少的 token——英文压缩率从 3.17 字符/token 到 3.94,每字符成本 3.81 GFLOPs 比 32K 词表低 15%、KV 低 20%,训练同样多字符的数据、推理同样长的回答都更便宜。详见第四章、第五章。 ↩

  2. 意味着 KV cache、prefill FLOPs、decode 步数、API 计费全部差 2.1 倍,上下文窗口「能装多少字」也差 2.1 倍——tokenizer 是成本表里最后一个外生变量;且跨 tokenizer 比 loss 必须换算成 bits/byte 才可比。详见第五章。 ↩

这篇对你有用?

本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/tokenizer-vocabulary-and-token-efficiency.html)的前提下,欢迎各种形式的转载、翻译或商业引用。


COMMENTS

评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。

×