分词 入门
LLM 处理文本前的第一件事,是把文本切成小块、给每一块查一个整数 ID。这些小块就叫token,这个切的过程叫分词(tokenization)。 4 个短主题:框定整个问题的字符 / 单词 / 子词的取舍;BPE(GPT 用的算法);WordPiece、SentencePiece、Unigram几个主要变种;以及为什么现代 LLM 的词表大小坐落在5 万到 20 万之间。
字符、单词,还是子词?
「单位」选什么,定下了之后所有代价和能力的形状。
任何「文本到数字」流水线里的第一个决策,也是后果最大的一个:什么算「一个 token」? 传统上三个答案,每个都有自己的缺陷,直到子词分词出现、悄悄把它们全换掉。
- 字符级。每个字符是一个 token。词表小(ASCII ~100、Unicode 数千), 没有 OOV —— 任何文本都能切。代价:序列很长。100 词的句子变成 ~500 token。 序列长度对 Transformer 来说什么都贵 —— attention 是 O(n²)、KV 缓存随之线性增长。 能做,但慢。
- 单词级。每个词是一个 token。100 词的句子就是 100 token。 单位自然、序列短。但代价不小:词表会爆炸(光英语,算上复数、屈折、复合词、人名, 就有几百万种独立词形),任何没见过的词都是「词表外(OOV)」—— 模型完全不知道该拿它怎么办。
- 子词。混合做法:常见词保持整体,罕见词拆成可复用片段。 \"unhappiness\" 可能变成 \"un\" + \"happi\" + \"ness\"。词表保持小 (5 万 – 20 万),序列保持短,任何没见过的词都能用熟悉的片段拼出来。没有 OOV。 所有现代 LLM 都用这套。
子词为啥赢,看另外两种各自的失败模式最清楚。纯字符级能表示一切, 但 n² 的 attention 代价让长上下文推理不现实。纯单词级表示不了词表之外的任何东西, 一旦你拿网络文本去训,OOV 就爆炸了。子词是「需要的时候是字符、够用的时候是单词」。 现代 tokenizer 都是这个思路的变种。
一个细节。子词 token 的「合适大小」取决于语言。英语里一个典型 token 大约是半个词 (平均 3–4 个字符)。中文或日文里,一个 token 经常是单字、甚至一个 Unicode 字节。 代码里常常是子关键字:\"def\" + \" \" + \"function\"。多语言或多格式语料上训出的 tokenizer 会小心平衡这些 —— 平衡得不好时,你会看到诡异的 token 账单 (早期 GPT-3 上,中文 token 数能到同等英文的 3 倍)。
在 Transformer 里:tokenizer 是模型碰的第一样和最后一样东西。 输入文本 → token ID → embeddings → attention 层 → ... → 输出 logits → 下一个 token ID → 再解码回文本。Transformer 付的每一项代价 —— 显存、算力、延迟、API 价格 —— 都跟 tokenizer 切出来多少 token 成比例。 从这个意义上说,tokenizer 是「打扮成预处理步骤的架构决策」。
BPE —— Byte Pair Encoding
GPT 用的贪婪合并算法,1994 年原本是为数据压缩发明的。
Byte Pair Encoding 是 2026 年子词分词的主流算法。从 GPT-2 一直到 GPT-4o 全 GPT 系列、 以及大部分 LLaMA 系模型都用它。算法本身意外地小。
训练:
1. 词表 = 语料里所有出现过的字符。
2. 把每个词切成字符序列。
3. 重复 target_vocab_size − len(vocab) 次:
a. 统计语料里所有相邻 token 对。
b. 找出最频繁的那一对 (a, b)。
c. 合并成新 token "ab",加入词表。
d. 在整个语料里应用这次合并。
切分(用训好的 merges):
1. 把输入切成字符。
2. 按训练时学到的顺序贪心应用 merges。
3. 返回 token 序列。整个算法就这些。在大语料上跑够久,就得到一个能用的 LLM tokenizer。
「byte pair encoding」里的「byte」藏了几个细节:
- 从字节起步。GPT-2 / GPT-3 / GPT-4 用的是字节级BPE —— 从 256 个原始字节开始,不从 Unicode 字符开始。 意思是这个 tokenizer 不需要专门的「未知字符」码,任何语言的任何字节序列都是合法输入。
- 预分词(pre-tokenization)。跑 BPE 之前,现代 tokenizer 会先按 空白和标点把文本切开,这样 BPE 就不会跨词边界做合并。OpenAI tokenizer 里有个正则 处理这件事:把连续的字母、数字、空白等分别成组。
- 特殊 token。真实词表里还有「不是文本」的 token ——
<|endoftext|>、像<|im_start|>这样的 对话格式标记、padding token 等等。这些不来自 BPE 合并,是手工加进来的, 在词表顶端预留几百个 ID。 - 确定性。训好之后,tokenizer 是一个从文本到 token ID 的确定函数。 训练是随机的(语料里要做子采样),但推理不是。
从历史角度看,BPE 之所以赢,是一个关于「实用主义」的故事。在它之前也有子词方法 (Morfessor 等),但是 BPE 简单到 100 行代码就能实现、快到能在网页规模语料上训, 最后给出能用的 tokenizer。Sennrich 等(2016)把它从数据压缩搬到神经机器翻译上, 两年内成了默认。2019 年的 GPT-2 把字节级 BPE 钉死成了自回归 LLM 的标准。
在 Transformer 里:每个现代 LLM 都附带一个 BPE merges 文件, 里面就是当年学到的合并顺序的一张配对列表。tokenizer 启动时读一次、建一棵 trie 或哈希表, 然后在每一条 prompt 上贪心应用。tiktoken(OpenAI 的 tokenizer)能跑到几百 MB/s。 模型永远看不到文本 —— 只看到从这一头出去的整数 ID。
WordPiece、SentencePiece、Unigram
子词分词的三个变种,各自挑了一个略不同的合并准则。
BPE 不是唯一的子词配方。还有另外三种方法跟它并存,在某些场景甚至比它好用。 它们产生的 tokenizer 在性质上很相似 —— 定长词表、没有 OOV、常见词保持整体 —— 但在「词表怎么学、文本怎么切」的细节上各执一词。
- WordPiece(Schuster 和 Nakajima,2012)。最早出自 Google 的日语 语音识别系统,被 BERT 采纳。跟 BPE 类似,但用「似然」准则替代「频率」:每一步选 那个能让训练语料(在一个 unigram 语言模型下)概率最大的合并。实战中得到的词表很像, 但 WordPiece 倾向于保留稍长一点的子词。续接片段会加
##前缀 —— 所以 BERT 的输出里到处是##ing、##tion之类。 - Unigram(Kudo,2018)。一个概率视角的替代方案。先从一个大初始词表 (通常用 BPE 初始化)开始,然后通过 EM 优化、把最没用的 token 逐步剪掉。 一个词的「最佳切分」是「在 unigram 模型下总概率最大」的那一种。 独特之处:同一个词可以有多种合法切分。这一点在训练时做数据增强 (\"subword regularization\")很有用。
- SentencePiece(Kudo 和 Richardson,2018)。不是新的合并算法, 而是另一种预处理:把输入当作包含空格的原始字节流。空格成了 token 的一部分 (渲染成
▁,U+2581)。结果:完全语言无关,不需要单独的「分词」步骤, 对中文、日文这种没有空格的语言、还有代码,都一视同仁。SentencePiece 是一个框架,里面可以跑 BPE 或 Unigram;LLaMA 系模型一般用 SentencePiece + BPE。
「##」和「▁」这套约定,踩坑之前总觉得有点随意:
- BERT 的 WordPiece 假定输入已经按词切好。token 上的
##意思是「我接在上一个词后面、前面别加空格」。对用空格分词的语言很方便, 对不用空格的就尴尬。 - SentencePiece 对空格不做任何假设。任意一个词如果在原文里前面有空格, 它的 token 就以
▁开头,否则不加。解码时只要把▁标记去掉就行。对称、语言中立,代码里到处都不需要「这是不是续接片?」的特判。 - GPT 的 BPE 居中:空格本身是 token 的一部分(第一空格经常被算作下一个 token 的开头,比如
\" the\"是一个 token),但没有续接标记。
实际上你不太会自己挑 tokenizer;你继承基础模型用的那个。这些选择更多是历史包袱: GPT 选了字节级 BPE、BERT 选了 WordPiece、T5 和大多数多语言模型选了 SentencePiece。 差别在「跨模型家族微调」或者「给一个非标准领域训新 tokenizer」时才真正会影响你。
在 Transformer 里:tokenizer 是跟模型权重一起发布的一个独立工件。 一次典型的模型发布是(权重、tokenizer、config)三件套,tokenizer 是其中最小的 (几百 KB)。给一套权重配错 tokenizer,是深度学习里最隐蔽的失败之一 —— 模型会自信地输出垃圾。
现代词表大小 —— 5 万到 20 万
为什么「合适的词表大小」落在这个带里,以及在带内移动有什么代价。
LLM 时代的大部分时间(2019–2024),「词表大小」是没人动的旋钮 —— 5 万左右就是默认, 是 GPT-2 那次有影响力的发布定下来的,后来大部分模型都继承了。前沿在最近两年往上挪了, 现在大致在 5 万到 20 万这一带。下面是原因。
几个流行模型的具体数字:
- GPT-2(2019):50,257 个 token。在以英文为主的网页文本上做字节级 BPE。 做了大约 5 年的默认值。
- LLaMA 1 / 2(2023):32,000 个 token。为效率比 GPT 更小、用 SentencePiece BPE。 主要为英语优化,多语言支持有限。
- GPT-4 / cl100k(2023):100,256 个 token。差不多把 GPT-2 翻倍, 对代码、非英语、现代技术名词覆盖大大改善。
- LLaMA 3(2024):128,256 个 token。相对 LLaMA 2 一次大跳, 特别是为了改进多语言和代码覆盖。
- GPT-4o(2024):大约 20 万个 token。又把前沿往前推了一截; 特别是非英文文本被压缩得厉害(同一段中文或日文,有时候比 GPT-4 少 4 倍 token)。
- Claude 3 / Gemini(2024):未公开,传闻在 10 万 – 20 万这个区间。
这些数字背后的取舍,是核心设计问题:
- 词表越大 → 每段文本的 token 数越少。每个 token 平均覆盖更多字符。 10 万词表可以把 \"unhappiness\" 当成一个 token;3 万的就大概率拆开。 更少 token 意味着更便宜的推理(attention 计算更少、KV 缓存更小、用户 API 价更低)。
- 词表越大 → embedding 表越大。embedding 表是
vocab_size × d_model。d_model = 4096时,从 5 万 vocab 到 20 万 vocab, 表从 2 亿涨到 8 亿参数 —— 训练和存储上都是实打实的代价。现代 LLM 经常把 输入 embedding 和输出投影共享权重(\"tied weights\")来减一半。 - 词表越大 → 罕见 token 的长尾越长。20 万 vocab 里会有几千个 训练中很少出现的 token。它们的 embedding 学不好,误触会在非常见输入上伤害质量 (\"glitch token\" 现象 —— 搜
SolidGoldMagikarp)。 - 词表越大 → 非英语 / 代码覆盖越好。这是从 5 万跳到 20 万最大的单一理由。 GPT-3 上需要 1000 token 的中文段落,GPT-4o 上可能只要 250 token, 因为大词表里有许多中文专有的多字 token。代码也一样 —— 10 万+ 的词表能把
printf、def、console.log这样的常见标识符 作为单 token 保留下来。
如果你哪天真要挑词表大小:挑「能让你关心的语言和格式拿到可接受 token/字符比」 的最小那个。只搞英语研究的话,3 万 – 5 万还能用。多语言或重代码场景, 2026 年的底线是 10 万+。没充分理由别低于这个;embedding 表很少是瓶颈, 而序列长度上的节省在每条 prompt 的每个 token 上累加。
在 Transformer 里:词表大小是把整个模型「夹起来」的那个参数。 它决定输入端的 embedding 维度和输出端分类头的维度;中间所有东西都跟它无关。 一个 32K 词表的 7B LLaMA-2 和一个 128K 词表的 7B LLaMA-3,中间的架构一模一样, 但后者在 embedding + 输出头上多了 9600 万参数。 tokenizer 的选择会一路影响到每一层。
整个前置知识栈到此为止 —— 数学、数据、优化、硬件、软件、词嵌入、分词。 所有零件都铺好了。下一篇 primer 终于开始组装它们:Transformer。