Tokenization 机制
第 4 课:Tokenization 机制
模型不识字,只看数字。把"我爱吃苹果"这五个汉字变成模型能理解的数字序列——这一步叫 Tokenization。不同的切法直接影响模型的理解力、推理速度和显存开销。
② 算出中文比英文多多少 token,解释"多语言税"的来源
③ 说出词表从 32K 扩大到 152K 的 Embedding 层代价,判断值不值得
Transformer 的输入不是文字,是一组整数 ID。把文本切成 token、再映射到 ID 的过程就是 Tokenization。不同切法天差地别:
按字切 — "我/爱/吃/苹/果" 5 个 token,信息太散
按词切 — "我/爱/吃/苹果" 4 个 token,但"苹果"要事先在词表里
按子词切 — "我/爱/吃/苹果"(中文可能是 1-2 字一组),这是现代大模型的主流方案
Tokenization 就像给一本没有目录的书编页码:页码(token)编得太细,翻页次数暴增;编得太粗,一页里混着好几件事。模型的上下文窗口就是这本书能翻的页数——所以「页码怎么编」直接决定它一次能读多少内容。
为什么不直接用「整词表」或「单字符表」?两头都会撞墙:按单字符切,英文「I love apples」会变成 11 个 token(而按词只要 3 个),序列一长注意力计算量按长度平方增长;按整词切,只要遇到没进词表的「apples」,就直接变成 [UNK]、信息全丢。子词是唯一能同时躲开这两头的切法。
| 原始文本 | 切分结果 | token 数 | 问题 |
|---|---|---|---|
| 我爱吃苹果 | 我 / 爱 / 吃 / 苹 / 果 | 5 | 每个字的语义太孤立,"苹果"两个字的信息被拆开了 |
| I love apples | I / l / o / v / e / a / p / p / l / e / s | 11 | 英文按字母切比中文更糟——"love"被拆成 4 个字母 |
| 原始文本 | 切分结果 | token 数 | 问题 |
|---|---|---|---|
| 我爱吃苹果 | 我 / 爱 / 吃 / 苹果 | 4 | "苹果"要提前在词表里——但中文词表动辄数万词起步(估算),且永远有没见过的新词 |
| I love apples | I / love / apples | 3 | "apples" 和 "apple" 算两个词——"apples" 不在词表就变 [UNK] |
| 原始文本 | BPE 切分结果 | token 数 |
|---|---|---|
| 我爱吃苹果 | 我 / 爱吃 / 苹果 | 3(示意:常见双字词可能已被合并成 1 个 token) |
| I love apples | I / love / apple / s | 4("apples" 没见过,但 "apple"+"s" 能拼出来) |
切太细(按字符/字节)→ token 序列太长,Attention 复杂度 O(n²) 受不了;切太粗(按词)→ 词表爆炸,新词变 [UNK]。BPE 的子词方案是这两者的平衡点——用高频子词覆盖常见表达,用低频组合拼出新词。
上一张卡片我们理解了为什么需要 tokenization——把文本切成 token 再映射到整数 ID。BPE 是最主流的子词切分算法:从字符级开始,反复合并最频繁的相邻符号对。下面从零推导一遍它的合并过程。
BPE 好比整理一堆积木:先按字母把积木摊开,然后每次都把「挨在一起、出现最多」的两块粘成一整块,直到词表装满——没见过的「apples」也能用粘好的「apple」加「s」拼出来。
| 文本 | 初始切分(按字符拆开) |
|---|---|
| low | l · o · w |
| lower | l · o · w · e · r |
| lowest | l · o · w · e · s · t |
| 符号对 | 出现次数 | 来源 |
|---|---|---|
| (l, o) | 3 | 每个词都以 "lo" 开头 |
| (o, w) | 3 | low / lower / lowest |
| (w, e) | 2 | lower / lowest |
| (e, r) | 1 | lower |
| (e, s) | 1 | lowest |
| (s, t) | 1 | lowest |
| 文本 | 当前切分 |
|---|---|
| low | lo · w |
| lower | lo · w · e · r |
| lowest | lo · w · e · s · t |
| 文本 | 当前切分 |
|---|---|
| lower | low · e · r |
| lowest | low · e · s · t |
| 文本 | 当前切分 |
|---|---|
| lower | lowe · r |
| lowest | lowe · s · t |
本卡语料只有 low / lower / lowest,但测试时遇到 "lowers" 也能切:BPE 会把它切成 lower + s(lower 和 s 都在最终词表里)。只要能拼出没见过的新词,就永远不会输出 [UNK]。
上一张卡片我们走通了 BPE 从字符开始合并出子词的完整过程。现在看看这种机制对不同语言的影响:同样的意思,中文消耗的 token 通常比英文多 50%-80%。这是因为 BPE 训练数据以英文为主,"apple" 被合并成了一个 token,而"苹果"的两个字可能还没被合并——或者更糟,被拆成字节级碎片。
想象一下用一本只收英文词的小字典查中文——「苹果」查不到,只能一个字一个字(甚至一个字节一个字节)地翻。这就是中文在英文优先 tokenizer 下的处境,也是多语言税的来源。
| 文本 | Token 切分 | token 数 |
|---|---|---|
| I like eating apples | I / like / eating / apples | 4 |
| The weather is nice today | The / weather / is / nice / today | 5 |
| Machine learning is fun | Machine / learning / is / fun | 4 |
| 文本 | 可能切分(英文优先 tokenizer) | token 数 |
|---|---|---|
| 我喜欢吃苹果 | 我 / 喜欢 / 吃 / 苹果 | 4 |
| 今天天气不错 | 今天 / 天气 / 不错 | 3 |
| 机器学习很有趣 | 机器 / 学习 / 很 / 有趣 | 4 |
| 内容 | 英文 token 数 | 中文 token 数 | 中文 "溢价" |
|---|---|---|---|
| 一段 100 词的短文 | ~130 token | ~200 token | +54% |
| 一篇 500 词的说明 | ~650 token | ~1100 token | +69% |
| 一次 4096 context 的对话 | 可放 ~3000 词 | 只能放 ~1800 词(≈2200 汉字,单位换算见本课 Card 4) | 内容容量 -40% |
因为 BPE 是在训练数据上统计的——训练数据里英文占绝对多数。英文的 "the" 出现了几百万次,早就被合并成一个 token;中文的"的"虽然频率也高,但因为 UTF-8 编码占 3 字节,BPE 可能需要先合并字节再合并字再合并词——层级更多、合并更慢。这就是为什么 Qwen 要专门为中文训练自己的 tokenizer,把词表做到 152K(相比 Llama 的 32K 大近 5 倍)——就是为了减少中文的"多语言税"。
上一张卡片我们测量了中文的 token 额外开销(+50%-80%)。解决方案之一:扩大词表。Llama 用 32K 词表,Qwen 用 152K 词表。词表越大,每个 token 覆盖的文本越多 → 推理更快。但代价是 Embedding 矩阵疯了似的膨胀。
| 模型 | 词表大小 V | d_model | Embedding 参数 | 占 7B 总参数比 |
|---|---|---|---|---|
| Llama 2 7B | 32,000 | 4096 | 32K × 4096 = 131M | 131M / 7B = 1.9% |
| Qwen 7B | 152,000 | 4096 | 152K × 4096 = 622M | 622M / 7B = 8.9% |
| 文本 | Llama 32K tokenizer | Qwen 152K tokenizer | 节省(估算) |
|---|---|---|---|
| 今天天气真好,我们出去走走吧 | ~18 token | ~12 token | -33% |
| 请帮我写一段关于机器学习的介绍 | ~25 token | ~15 token | -40% |
| 一篇 1000 字中文文章 | ~1800 token | ~1000 token | -44% |
| 维度 | 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 再往上扩,边际收益递减——常见词汇已经被覆盖了,新加入的都是极低频的碎片,几乎用不到(经验观察:词表实际利用率低、存在大量近似重复 token;无一手来源,需人工核实)。而且 Embedding 矩阵太大了会挤占其他层的参数预算(7B 模型 9% 给 Embedding 已经很高)。152K 是 Qwen 团队在中文效率上的一个工程甜点。
前面我们讨论了 tokenization 的效率(token 数量)和代价(Embedding 参数)。但它还有一个更隐蔽的影响:某些 token 的编码方式会让模型陷入「退化循环」——不停重复同一个词或符号。
这就像一列火车在环形轨道上跑——只要某个高频 token 让上下文又回到「和刚才几乎一样」的状态,模型就会沿着同一条轨道再跑一圈,于是重复同一个词。这种循环在论文里叫文本退化(text degeneration)。
| 场景 | 中文输入 | Token 切分 | 为什么危险 |
|---|---|---|---|
| 正常 | 这本书的内容很有意思 | 这本 / 书的 / 内容 / 很 / 有意思 | "的"是独立 token,在语料中出现了几百万次 |
| 退化 | 这本书的的的的的的 | 这本 / 书的 / 的 / 的 / 的 / 的 / 的 / 的 | 模型输出一个"的"后,下一个 token 的上下文和上一个几乎一样→又输出"的"→循环 |
| Token | Llama tokenizer ID | 问题 |
|---|---|---|
| 空格 (▁) | 29871(示例) | 英文开头的空格被编码为独立 token——模型可能无休止输出空格+token 的组合 |
| 换行 (\n) | 13(示例) | 独立编码→模型可能在不该换行的地方疯狂换行 |
| 逗号 (,) | 29892(示例) | 逗号后跟空格是两个 token→"hello, world" 被切成 3 个 token 而不是直觉的 2 个 |
| 现象 | 说明 | 后果 |
|---|---|---|
| 训练集不均衡 | 某些 token 只在特定数据集出现(如 Reddit 用户名 "SolidGoldMagikarp") | 模型记住了这个 token 但几乎没学过它的语义——一旦触发,输出不可控 |
| 空白/特殊字符 token | 词表里保留了 <|endoftext|>、<|padding|> 等特殊 token | 如果用户输入里碰巧触发了这些 token ID → 模型提前终止或输出乱码 |
现代 tokenizer 的改进方向:① 增加词汇表大小(让常见组合被合并,减少单字 token);② 训练时加入更多样化的数据(避免某个 token 只在窄领域出现);③ 推理时加 repetition_penalty(对已出现的 token 降权,打断重复循环)。注意:①② 在 token 侧减少诱因,③ 直接作用在解码上;而重复循环的根因是解码策略,所以 ③ 类手段最直接对症。
<|im_start|> / <|im_end|> 这对特殊 token(special token,词表里被预留、普通文本切不出来的记号),把「角色名 + 内容」包成一轮对话;Meta 的 Llama 2(2023)则用 [INST] … [/INST] 包裹用户指令。痛点:这些标记和系统提示都由系统自动拼接,用户看不见,却同样送去分词、同样按 token 计费、同样占上下文窗口。上一张卡片我们看到,词表里被预留的特殊 token 一旦被误触发,会让模型「鬼畜」——但特殊 token 还有另一面:它们是每一轮对话都要固定付出的成本。
先把两个词说清。特殊 token(special token,词表里被预留、普通文本切不出来的记号)不由你的字切分而来,而是由对话模板(chat template,把系统提示、角色名和内容拼成模型预期格式的那套规则)自动插入。你发出的只是「你好」,模型实际收到的却是一整段带标记的文本——系统提示、角色名、分隔符一个都不少。
关键在于:token 同时是钱(按量计费)和上下文窗口(额度有限)。这些你看不见的标记照样被计费、照样挤占窗口。这就像寄快递——你写的信只占包裹一角,面单、填充物、封条都是系统加的,但它们一样要称重、一样算运费。
| 片段 | 内容(示意) | 是你打的字吗 | token |
|---|---|---|---|
| 系统提示 | You are a helpful assistant. | 否,系统注入 | 6 |
| <|im_start|> | 轮次开始特殊 token | 否 | 1 |
| user | 角色名 | 否 | 1 |
| 换行 | 格式控制符 | 否 | 1 |
| 你好,帮我写封邮件 | 用户输入 | 是 | 6 |
| <|im_end|> | 轮次结束特殊 token | 否 | 1 |
| 项目 | 每轮 token | 10 轮累计 |
|---|---|---|
| user 轮的 4 个角色标记 | 4 | 40 |
| assistant 轮的 4 个角色标记 | 4 | 40 |
| 角色标记小计 | 8 | 80 |
| 系统提示(内容固定,但每轮都要重发) | 500 | 5000 |
| 看不见的开销合计 | — | 5080 |
| 组成 | 10 轮累计 token | 占总 token |
|---|---|---|
| 系统提示 | 5000 | 82.2% |
| 角色标记(模板开销) | 80 | 1.3% |
| 用户 + 助手正文 | 1000 | 16.4% |
| 合计 | 6080 | 100% |
token 有两重身份:一是钱(按量计费),二是上下文窗口(额度有限)。系统提示与角色标记都由系统自动拼接,用户看不见,却一样计费、一样占额度——上面的示意口径里,看不见的开销约占总 token 的 83.6%。
一个反直觉点:结构化格式(JSON、键值对)有时比等义的自然语言更省 token,因为自然语言带着大量连接词、语气词等冗余。经验观察:一段 400 词的散文指令通常能压成 8–12 条要点、降到 100 token 以内,压缩比高正是因为散文带着模型不需要的「连接组织」。但要注意:JSON 自身也有括号、引号、重复键的开销——同一份数据里 JSON 比更紧凑的写法多约 2 倍 token。以上均为经验判断(无一手来源),需人工核实。