第 4 课:Tokenization 机制

模型不识字,只看数字。把"我爱吃苹果"这五个汉字变成模型能理解的数字序列——这一步叫 Tokenization。不同的切法直接影响模型的理解力、推理速度和显存开销。

🎯 学完这 6 步你将能够:
① 亲手推演 BPE 算法的合并过程,说出"为什么 lo 要和 w 合并"
② 算出中文比英文多多少 token,解释"多语言税"的来源
③ 说出词表从 32K 扩大到 152K 的 Embedding 层代价,判断值不值得
🗺 本节推理链(6 步:从「词表有限 + 要能表示任何字符串」逼出子词切分)
1为什么要切? — 从「我爱吃苹果」出发:模型只吃整数 ID,而「什么算一个词」没有共识——切太粗会撞生词([UNK]),切太细序列又太长,于是要找一种「有限词表内、可复现」的 token 单元
↓
2切法怎么学到? — 规则靠人工拍脑袋定不下来,所以要让它从语料里长出来:反复合并最高频的相邻符号对,这就是 BPE,每个合并出的新符号也是一个 token
↓
3为什么中文更费 token? — 词表容量有限、训练语料却英文占多数,所以同样的意思中文要更多 token(多语言税),代价落在上下文窗口和成本上
↓
4词表多大才划算? — token 越少推理越省,但词表越大 Embedding 参数越多,所以要在「省 token」和「省参数」之间算一笔账(32K vs 152K)
↓
5不出 [UNK] 就没问题了吗? — 切出来的单元得是训练里见过的,所以见得极少的「幽灵 token」会让输出失控(重复、乱码),逼出词表清洗与重复抑制
↓
6把口径冻结成一份词表,链路才闭合 — 幽灵 token 说明「切出来的必须是训练里见过的」,因此词表一旦冻结就不能随意新增;于是训练 tokenizer、编码、推理、解码四步必须共用同一份词表,整条链路才自洽
BPEByte-Pair EncodingSubwordVocab SizeMultilingual
1
"我爱吃苹果"五个汉字——模型是怎么把它们变成一串数字的?
提示:模型内部只有矩阵乘法,只能吃数字。中文不像英文可以按空格分——"吃"和"苹果"之间没有天然的分隔符。
▾
知识背景
Tokenization 是人类文本与模型数学之间的桥梁——模型只吃数字,不吃字符。子词分词(Subword Tokenization)在 2016 年左右成为标准(Sennrich et al.),替代了词级分词(OOV 问题)和字符级分词(序列过长)。BPE(GPT 系列)、WordPiece(BERT)、Unigram(T5 采用的算法)是三大主流子词算法;它们常由 SentencePiece 这类分词库实现——SentencePiece 是库(支持 BPE 与 unigram 两种算法),本身不等于某一种算法。

Transformer 的输入不是文字,是一组整数 ID。把文本切成 token、再映射到 ID 的过程就是 Tokenization。不同切法天差地别:

按字切 — "我/爱/吃/苹/果" 5 个 token,信息太散
按词切 — "我/爱/吃/苹果" 4 个 token,但"苹果"要事先在词表里
按子词切 — "我/爱/吃/苹果"(中文可能是 1-2 字一组),这是现代大模型的主流方案

Tokenization 就像给一本没有目录的书编页码:页码(token)编得太细,翻页次数暴增;编得太粗,一页里混着好几件事。模型的上下文窗口就是这本书能翻的页数——所以「页码怎么编」直接决定它一次能读多少内容。

为什么不直接用「整词表」或「单字符表」?两头都会撞墙:按单字符切,英文「I love apples」会变成 11 个 token(而按词只要 3 个),序列一长注意力计算量按长度平方增长;按整词切,只要遇到没进词表的「apples」,就直接变成 [UNK]、信息全丢。子词是唯一能同时躲开这两头的切法。

🧮 同一个句子,三种切法产出的 token 序列完全不同
Step 1:按字符切 — 最细粒度
原始文本切分结果token 数问题
我爱吃苹果我 / 爱 / 吃 / 苹 / 果5每个字的语义太孤立,"苹果"两个字的信息被拆开了
I love applesI / l / o / v / e / a / p / p / l / e / s11英文按字母切比中文更糟——"love"被拆成 4 个字母
Step 2:按词切 — 理想但不可行
原始文本切分结果token 数问题
我爱吃苹果我 / 爱 / 吃 / 苹果4"苹果"要提前在词表里——但中文词表动辄数万词起步(估算),且永远有没见过的新词
I love applesI / love / apples3"apples" 和 "apple" 算两个词——"apples" 不在词表就变 [UNK]
Step 3:子词切分(BPE)— 大模型的实际做法
原始文本BPE 切分结果token 数
我爱吃苹果我 / 爱吃 / 苹果3(示意:常见双字词可能已被合并成 1 个 token)
I love applesI / love / apple / s4("apples" 没见过,但 "apple"+"s" 能拼出来)
子词的妙处:没见过"apples"也能用 "apple" + "s" = "apples"(2 个 token)拼出来——永远不会输出 [UNK]。
🎯 核心矛盾

切太细(按字符/字节)→ token 序列太长,Attention 复杂度 O(n²) 受不了;切太粗(按词)→ 词表爆炸,新词变 [UNK]。BPE 的子词方案是这两者的平衡点——用高频子词覆盖常见表达,用低频组合拼出新词。

AI 助教
👋 对 Tokenization 的基础概念有疑问?比如"为什么不能直接用 Unicode 码点?""字符、子词、词这三种粒度各有什么优劣?"

我会用具体数字帮你算。
2
BPE 是怎么从一堆文本里"学"出一个分词器的?
提示:BPE 是一步一步"合并"出来的——每一步,谁和谁会被选中合并?依据是什么?先自己想 30 秒。
▾
知识背景
BPE = Byte Pair Encoding。最初由 Gage(1994)用于数据压缩,Sennrich et al.(2016)引入 NLP。核心:迭代合并最频繁相邻符号对,从字符(byte-level 实现则从 256 个字节)开始逐步构建子词,最终形成词表。GPT-2/3/4、Llama、RoBERTa 均使用 BPE。SentencePiece 和 HuggingFace Tokenizers 是主流实现。

上一张卡片我们理解了为什么需要 tokenization——把文本切成 token 再映射到整数 ID。BPE 是最主流的子词切分算法:从字符级开始,反复合并最频繁的相邻符号对。下面从零推导一遍它的合并过程。

BPE 好比整理一堆积木:先按字母把积木摊开,然后每次都把「挨在一起、出现最多」的两块粘成一整块,直到词表装满——没见过的「apples」也能用粘好的「apple」加「s」拼出来。

🧮 完整走查:语料 = "low lower lowest",目标词表大小 = 12
Step 0:初始化 — 每个字符是一个独立符号
文本初始切分(按字符拆开)
lowl · o · w
lowerl · o · w · e · r
lowestl · o · w · e · s · t
初始词表 7 个符号:{l, o, w, e, r, s, t},距离目标 12 还差 5 次合并。
Step 1:数相邻对频率 — (l, o) 出现了 3 次,最高
符号对出现次数来源
(l, o)3每个词都以 "lo" 开头
(o, w)3low / lower / lowest
(w, e)2lower / lowest
(e, r)1lower
(e, s)1lowest
(s, t)1lowest
第一轮合并:lo → "lo",新符号加入词表。词表 = {l, o, w, e, r, s, t, lo},8/12。
Step 2:合并 lo 后重新数对 — (lo, w) 出现了 3 次
文本当前切分
lowlo · w
lowerlo · w · e · r
lowestlo · w · e · s · t
(lo, w) 出现 3 次 → 合并 low。词表 = {..., lo, low},9/12。
Step 3:合并 low 后 — (low, e) 出现了 2 次
文本当前切分
lowerlow · e · r
lowestlow · e · s · t
(low, e) 出现 2 次 → 合并 lowe。词表 10/12。
Step 4:合并 lowe 后 — (lowe, r) 和 (lowe, s) 各 1 次
文本当前切分
lowerlowe · r
lowestlowe · s · t
平局时选哪个?原始 BPE 论文只规定"取最高频对",平局规则由具体实现决定(如取字典序最小)。这里假设 r 先行 → 合并 lower。词表 11/12。
Step 5:最后一轮 — (s, t) 出现 1 次,合并 st
合并 st → 词表达到 12,停止。最终词表 = {l, o, w, e, r, s, t, lo, low, lowe, lower, st}。
现在训练完成。新词 "lowest" 怎么切?按学到的合并规则依次作用(lowest 先合并出 lowe,剩下 st)→ lowe · st = 2 个 token。
🎯 为什么 BPE 优于固定词表?

本卡语料只有 low / lower / lowest,但测试时遇到 "lowers" 也能切:BPE 会把它切成 lower + s(lower 和 s 都在最终词表里)。只要能拼出没见过的新词,就永远不会输出 [UNK]。

AI 助教
👋 对 BPE 算法细节有疑问?比如"为什么用频率而不是互信息?""BPE 和 WordPiece、Unigram 有什么区别?"

我会用具体例子帮你推演。
3
中文"我喜欢吃苹果"和英文"I like eating apples"——同样意思,中文要多花多少 token?
提示:先数一数——一个中文字在 UTF-8 里占几个字节?一个英文字母占几个字节?训练语料里哪种语言占多数,会不会影响合并的机会?先自己想 30 秒。
▾
知识背景
BPE 天然适应空格分隔的语言(英文)。中文无自然词边界 + 每个字 3 字节 UTF-8 编码 → 同样语义下中文 token 数比英文多 50%-80%(依 tokenizer 而异,估算)。这是 tokenization 领域的持续争议:统一算法 vs 语言定制。Qwen(通义千问)通过大词表(152K)+ 显著提高的中文语料比例大幅缓解了这一问题。

上一张卡片我们走通了 BPE 从字符开始合并出子词的完整过程。现在看看这种机制对不同语言的影响:同样的意思,中文消耗的 token 通常比英文多 50%-80%。这是因为 BPE 训练数据以英文为主,"apple" 被合并成了一个 token,而"苹果"的两个字可能还没被合并——或者更糟,被拆成字节级碎片。

想象一下用一本只收英文词的小字典查中文——「苹果」查不到,只能一个字一个字(甚至一个字节一个字节)地翻。这就是中文在英文优先 tokenizer 下的处境,也是多语言税的来源。

🧮 中英文 tokenization 实测对比(以 Llama 2 tokenizer 为例)
Step 1:英文句子 — 常见词已被合并成大 token
文本Token 切分token 数
I like eating applesI / like / eating / apples4
The weather is nice todayThe / weather / is / nice / today5
Machine learning is funMachine / learning / is / fun4
英文平均每个词 ≈ 1-1.3 个 token(估算;"I like eating apples" 4 token ÷ 4 词 = 1.0)。因为训练数据约 90% 是英文(Llama 2 报告 89.7%),常见英文词早已被合并成完整 token。
Step 2:中文句子 — 每个字通常是独立 token,甚至被拆成字节
文本可能切分(英文优先 tokenizer)token 数
我喜欢吃苹果我 / 喜欢 / 吃 / 苹果4
今天天气不错今天 / 天气 / 不错3
机器学习很有趣机器 / 学习 / 很 / 有趣4
中文 token 数波动大——常见双字词("天气""学习")可能已合并,但低频汉字仍是独立 token。且中文没有空格,tokenizer 需要额外判断边界。
注意:上面两组示例都是极高频词("喜欢""天气""学习"已被合并),中英 token 数才会接近;换成生僻字或不常见表达,中文 token 会明显多于英文——溢价要用整段文本才看得出来,单看一两个高频词会低估它(见下一步整段对比)。
Step 3:量化"多语言税"——同样意思,token 差多少?
内容英文 token 数中文 token 数中文 "溢价"
一段 100 词的短文~130 token~200 token+54%
一篇 500 词的说明~650 token~1100 token+69%
一次 4096 context 的对话可放 ~3000 词只能放 ~1800 词(≈2200 汉字,单位换算见本课 Card 4)内容容量 -40%
多语言税的直接后果:同样的 4096 token 上下文窗口,中文用户能塞进去的实际信息比英文用户少 40%。144K token 的上下文窗口,中文实际可用约 85K 英文等效 token。
🎯 为什么中文 tokenizer 普遍更差?

因为 BPE 是在训练数据上统计的——训练数据里英文占绝对多数。英文的 "the" 出现了几百万次,早就被合并成一个 token;中文的"的"虽然频率也高,但因为 UTF-8 编码占 3 字节,BPE 可能需要先合并字节再合并字再合并词——层级更多、合并更慢。这就是为什么 Qwen 要专门为中文训练自己的 tokenizer,把词表做到 152K(相比 Llama 的 32K 大近 5 倍)——就是为了减少中文的"多语言税"。

AI 助教
👋 对多语言 tokenization 有疑问?比如"中文 tokenizer 能专门优化吗?""除了中英文,其他语言(日语、阿拉伯语)的表现如何?"

我会用具体数据帮你分析。
4
词表从 32K 扩到 152K——Embedding 层大了近 5 倍,这买卖划算吗?
提示:Embedding 层的参数量 = 词表大小 × d_model。把词表放大几倍,最直接的变化落在哪一层的参数上、值多少钱?先自己想 30 秒。
▾
知识背景
Qwen(通义千问,阿里巴巴,2023)采用约 151,851 的大词表(用 tiktoken 库实现,相比 Llama 的 32K 大近 5 倍,显著提高中文/多语言覆盖率)。Embedding 参数从 131M → 622M(多了 491M),中文 token 效率提升约 33%-44%(估算)。Qwen 2(2024)进一步优化了多语言均衡。这代表"大词表"路线:用 Embedding 参数换 token 效率。

上一张卡片我们测量了中文的 token 额外开销(+50%-80%)。解决方案之一:扩大词表。Llama 用 32K 词表,Qwen 用 152K 词表。词表越大,每个 token 覆盖的文本越多 → 推理更快。但代价是 Embedding 矩阵疯了似的膨胀。

🧮 词表 32K vs 152K,Embedding 层到底差多少参数?
Step 1:参数计算 — Token Embedding(输入)+ LM Head(输出),通常共享权重
模型词表大小 Vd_modelEmbedding 参数占 7B 总参数比
Llama 2 7B32,000409632K × 4096 = 131M131M / 7B = 1.9%
Qwen 7B152,0004096152K × 4096 = 622M622M / 7B = 8.9%
Qwen 的 Embedding 层比 Llama 多了 491M 参数——输入侧是查表、输出侧 LM Head 参与每次前向(二者通常共享权重,合计算 131M)。对于 7B 模型,近 9% 的参数都花在了这上面。
Step 2:那好处是什么?— 同样的文本消耗更少 token
文本Llama 32K tokenizerQwen 152K tokenizer节省(估算)
今天天气真好,我们出去走走吧~18 token~12 token-33%
请帮我写一段关于机器学习的介绍~25 token~15 token-40%
一篇 1000 字中文文章~1800 token~1000 token-44%
token 数减少 → 推理时少做 Attention(O(n²))、少读 KV Cache(把已算过的键和值存下来复用)→ 同样长度的回复,推理速度快 30-50%(估算),显存省相应比例。
Step 3:所以到底值不值?——算一笔总账
维度Llama 32K 词表Qwen 152K 词表
Embedding 参数131M(轻)622M(重,但加载一次)
中文平均 token/字(估算)~1.8~0.6-0.7(Qwen 官方口径:1 token ≈ 1.5-1.8 汉字)
4096 token 能读的中文~2200 字(≈1800 词)~6100 字(约 2.7 倍)
推理延迟(同等文本)基准快 30-50%(估算)
训练数据效率中文浪费 token每个 token 承载更多信息
结论:对中文为主的场景,152K 词表明显划算——多出的 491M Embedding 参数是一次性加载成本,但推理时每个请求都快 30-50%,吞吐量直接提升。这就像仓库大一点多占点地,但进出货快了三成。
🎯 为什么不是越大越好?

词表 152K 再往上扩,边际收益递减——常见词汇已经被覆盖了,新加入的都是极低频的碎片,几乎用不到(经验观察:词表实际利用率低、存在大量近似重复 token;无一手来源,需人工核实)。而且 Embedding 矩阵太大了会挤占其他层的参数预算(7B 模型 9% 给 Embedding 已经很高)。152K 是 Qwen 团队在中文效率上的一个工程甜点。

AI 助教
👋 对词表大小的 tradeoff 有疑问?比如"如果我有 100B 参数,词表多大合适?""Embedding 层的参数能不能压缩?"

我会用具体数字帮你算。
5
为什么模型有时候会"卡壳"——不停重复同一个词、或者在句子末尾加上无意义的标点?
提示:模型是一个 token 一个 token 地往下写的——如果某个 token 恰好是高频单字或符号,它会不会被反复选中?先自己想 30 秒。
▾
知识背景
文本退化(Text Degeneration)由 Holtzman et al.(2019,"The Curious Case of Neural Text Degeneration")系统记录。现象:模型在长文本生成中陷入重复循环。根因是解码策略——贪心地逐 token 取最高概率(或 beam search 最大化似然)会把分布长尾排除掉,生成因此陷入高概率循环;tokenization 与 Attention 会放大这一现象,但不是根因。解决:nucleus sampling(top-p)、temperature scaling、repetition_penalty——已成为 LLM 推理标准配置。

前面我们讨论了 tokenization 的效率(token 数量)和代价(Embedding 参数)。但它还有一个更隐蔽的影响:某些 token 的编码方式会让模型陷入「退化循环」——不停重复同一个词或符号。

这就像一列火车在环形轨道上跑——只要某个高频 token 让上下文又回到「和刚才几乎一样」的状态,模型就会沿着同一条轨道再跑一圈,于是重复同一个词。这种循环在论文里叫文本退化(text degeneration)。

🧮 高频 token 与解码策略如何共同导致生成退化
Step 1:重复词陷阱 — "的"被编码为单独 token
场景中文输入Token 切分为什么危险
正常这本书的内容很有意思这本 / 书的 / 内容 / 很 / 有意思"的"是独立 token,在语料中出现了几百万次
退化这本书的的的的的的这本 / 书的 / 的 / 的 / 的 / 的 / 的 / 的模型输出一个"的"后,下一个 token 的上下文和上一个几乎一样→又输出"的"→循环
这本 / 书的 / 的×6 → 2 + 6 = 8 个 token。根本原因:高频单字 token 的 Embedding 在训练时见过无数次"独立出现"的模式,模型学会了"看到我就输出我"。
Step 2:标点陷阱 — 空格和换行被编码为独立 token
TokenLlama tokenizer ID问题
空格 (▁)29871(示例)英文开头的空格被编码为独立 token——模型可能无休止输出空格+token 的组合
换行 (\n)13(示例)独立编码→模型可能在不该换行的地方疯狂换行
逗号 (,)29892(示例)逗号后跟空格是两个 token→"hello, world" 被切成 3 个 token 而不是直觉的 2 个
上表 ID 为 Llama 2 词表的示意口径,用于说明"标点被编成独立 token",具体数值以对应 tokenizer 实测为准。
Step 3:SolidGoldMagikarp 现象 — 词表里的"幽灵 token"
现象说明后果
训练集不均衡某些 token 只在特定数据集出现(如 Reddit 用户名 "SolidGoldMagikarp")模型记住了这个 token 但几乎没学过它的语义——一旦触发,输出不可控
空白/特殊字符 token词表里保留了 <|endoftext|>、<|padding|> 等特殊 token如果用户输入里碰巧触发了这些 token ID → 模型提前终止或输出乱码
2023 年(Rumbelow & Watkins,LessWrong 报告)发现的 "SolidGoldMagikarp" 问题:Reddit 用户名被 tokenizer 单独编码,但训练数据里只出现几次——模型对这个 token 的理解是"随机噪声"。只要你输入这个用户名,模型就会产生不可预测的输出。
🎯 怎么缓解?

现代 tokenizer 的改进方向:① 增加词汇表大小(让常见组合被合并,减少单字 token);② 训练时加入更多样化的数据(避免某个 token 只在窄领域出现);③ 推理时加 repetition_penalty(对已出现的 token 降权,打断重复循环)。注意:①② 在 token 侧减少诱因,③ 直接作用在解码上;而重复循环的根因是解码策略,所以 ③ 类手段最直接对症。

AI 助教
👋 对 tokenization 导致的模型行为问题有疑问?比如"repetition_penalty 具体怎么算?""有没有不用 tokenizer 的模型架构?"

我会用具体例子解释。
6
你和模型聊一轮,真正被计费的 token 里,有多少根本不是你自己打的字?
提示:先想想你发出的那句「你好」,在送进模型之前被包进了哪些你看不见的字符——它们算不算钱?先自己想 30 秒。
▾
知识背景
ChatML(Chat Markup Language,对话标记语言,OpenAI 2023 年为 ChatGPT 类接口设计)用 <|im_start|> / <|im_end|> 这对特殊 token(special token,词表里被预留、普通文本切不出来的记号),把「角色名 + 内容」包成一轮对话;Meta 的 Llama 2(2023)则用 [INST] … [/INST] 包裹用户指令。痛点:这些标记和系统提示都由系统自动拼接,用户看不见,却同样送去分词、同样按 token 计费、同样占上下文窗口。

上一张卡片我们看到,词表里被预留的特殊 token 一旦被误触发,会让模型「鬼畜」——但特殊 token 还有另一面:它们是每一轮对话都要固定付出的成本。

先把两个词说清。特殊 token(special token,词表里被预留、普通文本切不出来的记号)不由你的字切分而来,而是由对话模板(chat template,把系统提示、角色名和内容拼成模型预期格式的那套规则)自动插入。你发出的只是「你好」,模型实际收到的却是一整段带标记的文本——系统提示、角色名、分隔符一个都不少。

关键在于:token 同时是钱(按量计费)和上下文窗口(额度有限)。这些你看不见的标记照样被计费、照样挤占窗口。这就像寄快递——你写的信只占包裹一角,面单、填充物、封条都是系统加的,但它们一样要称重、一样算运费。

🧮 算一笔账:10 轮对话里,有多少 token 根本不是你自己打的字
Step 1:一轮对话在 ChatML 里被拼成了什么
片段内容(示意)是你打的字吗token
系统提示You are a helpful assistant.否,系统注入6
<|im_start|>轮次开始特殊 token否1
user角色名否1
换行格式控制符否1
你好,帮我写封邮件用户输入是6
<|im_end|>轮次结束特殊 token否1
一轮 user 的模板骨架 = <|im_start|> + 角色名 + 换行 + <|im_end|> = 4 个特殊 token(示意口径,按整数近似)。除你自己打的字之外,模型还要多收 6 个 token 的系统提示。
Step 2:模板开销累积成多少 token?
项目每轮 token10 轮累计
user 轮的 4 个角色标记440
assistant 轮的 4 个角色标记440
角色标记小计880
系统提示(内容固定,但每轮都要重发)5005000
看不见的开销合计—5080
一轮对话 = user 轮 + assistant 轮 = 4 + 4 = 8 个角色标记 token;10 轮就是 8 × 10 = 80 个;系统提示内容固定却每轮都要重发,10 轮累计 500 × 10 = 5000 个;两者相加 = 5000 + 80 = 5080 个(这还没算正文)。
Step 3:这些开销占总 token 的百分之几?
组成10 轮累计 token占总 token
系统提示500082.2%
角色标记(模板开销)801.3%
用户 + 助手正文100016.4%
合计6080100%
设每轮正文 user 20 + assistant 80 = 100 token(示意),10 轮正文 = 1000;系统提示每轮重发,10 轮 = 5000;总 token = 5000 + 80 + 1000 = 6080;模板开销 80 / 6080 ≈ 1.3%;把系统提示也算进「看不见的开销」,则是 5080 / 6080 ≈ 83.6%。
🎯 关键认知

token 有两重身份:一是钱(按量计费),二是上下文窗口(额度有限)。系统提示与角色标记都由系统自动拼接,用户看不见,却一样计费、一样占额度——上面的示意口径里,看不见的开销约占总 token 的 83.6%。

一个反直觉点:结构化格式(JSON、键值对)有时比等义的自然语言更省 token,因为自然语言带着大量连接词、语气词等冗余。经验观察:一段 400 词的散文指令通常能压成 8–12 条要点、降到 100 token 以内,压缩比高正是因为散文带着模型不需要的「连接组织」。但要注意:JSON 自身也有括号、引号、重复键的开销——同一份数据里 JSON 比更紧凑的写法多约 2 倍 token。以上均为经验判断(无一手来源),需人工核实。

AI 助教
👋 对整个 tokenization 链路有疑问?我会用具体数字和计算例子来回答。