LLM 的分词(tokenization),就是把文本切分成称为 token 的小块、再把每个 token 转换成模型能够处理的数字的过程。
在这篇博客里,我们将学习 LLM 的分词------模型在理解任何内容之前,把我们的文本切分成小块的第一步。我们还会看到:为什么需要分词、token 是什么、把文本切成 token 有哪几种方式、字节对编码(Byte Pair Encoding)如何一步一步构建出词表、token 如何变成数字又如何变回文本,以及它在哪里好用、在哪里失灵。
我们将涵盖以下内容:
- 分词是什么?
- 为什么需要分词?
- 什么是 token?
- 方案一:字符级分词
- 方案二:词级分词
- 方案三:子词级分词
- 字节对编码(BPE)是如何工作的?
- 词表与 Token ID
- 解码:从 token 回到文本
- 用代码看分词实战
- 特殊 token
- 分词如何影响 LLM 的实际表现
- 哪里好用,哪里失灵
分词是什么?
分词(tokenization)是把一段文本切分成称为 token 的小块、再把每个 token 转换成一个数字的过程。
假设我们在 LLM(大语言模型)里输入句子 "I love learning"。
我们看到的这句话是三个单词。但模型看到的和我们不一样。模型不理解字母,也不理解单词,它只懂数字。
所以,在其他任何事情发生之前,这句话会先被切成小块,像下面这样:
"I" | " love" | " learning"
然后,每一块被转换成一个数字:
40 | 3021 | 6975
模型真正接收到的就是这些数字。文本永远不会到达模型那里,到达的只有数字。
注意:这些数字只是为了便于理解。真实的数字取决于我们用的是哪个分词器(tokenizer)。
干这个活的程序叫分词器(tokenizer)。每个 LLM 都有自己专属的分词器。
为什么需要分词?
LLM 是一个机器学习模型。它的内核是在做大量数学运算------乘数字、加数字、比较数字。数学只对数字起作用,对字母不起作用。
于是问题来了:我们手里的是文本,而模型需要数字。
我们需要一个把文本转换成数字的东西。于是分词器登场了。
但分词器还有另一份工作。模型必须持有一份它认识的"文本块"的固定清单。模型生成回复时,就是从这份固定清单里一次挑一块。如果清单太大,模型会变得又慢又重;如果清单太小,每句话都会变成一长串碎块。
所以,真正的问题是:每一块切多大才合适?
我们会依次看三种方案,并理解为什么现代 LLM 用的是第三种。
什么是 token?
token 是模型处理文本的最小单元。
一个 token 可以是一个完整的单词、单词的一部分、单个字符、一个标点符号,或者"前面的空格加上单词"。
看几个例子:
- "cat" 是一个 token。
- "unbelievable" 会被切成三个 token:"un"、"believ"、"able"。
- "!" 是一个 token。
- " love"(连同前面的空格)是一个 token。
这里可以注意到,空格是附着在单词上的。大多数现代分词器就是这么做的:空格成为后面那个 token 的一部分。这样一来,模型就知道一个新单词从哪里开始。
英语有个简单的经验法则:一个 token 大约是 4 个字符,也就是大约四分之三个单词。所以 100 个 token 大约是 75 个单词。
接下来看看把文本切成 token 的几种不同方式。
方案一:字符级分词
最简单的想法:每个字符就是一个 token。
拿单词 "learning" 来说,token 会是这样:
"l" | "e" | "a" | "r" | "n" | "i" | "n" | "g"
一个单词变成了 8 个 token。
优点:已知文本块的清单非常小。对英语来说,就只有字母、数字和少量标点。而且模型永远不会遇到"不认识的词",因为任何词都由字符组成。
缺点:每句话都会变得非常长。100 个单词的短段落会变成大约 500 个 token,模型得一个一个地处理。而且每个字符自身携带的语义非常少------单独一个字母 "l" 什么也说明不了。模型必须非常费劲地学习"l"、"e"、"a"、"r"、"n" 合在一起才是 learn。
这个方案的问题在于:序列太长,且每个 token 携带的语义太少。看看下一个方案怎么解决。
方案二:词级分词
下一个想法:每个单词就是一个 token。直接按空格切分。
拿句子 "I love learning" 来说,token 会是这样:
"I" | "love" | "learning"
三个单词,3 个 token。
优点:每个 token 都携带完整语义,序列很短。
缺点:已知单词的清单会变得非常庞大。光英语就有几十万个单词,再加上人名、地名、其他语言、代码,以及每天都在涌现的新词------这份清单永远没有尽头。
那模型遇到清单里没有的词会怎么样?
假设清单里没有 "tokenization" 这个词。分词器别无选择,只能把它替换成一个叫"未知"(unknown)的特殊 token,通常写作 <UNK>。模型看到 <UNK>,完全不知道我们说了什么。
还有一点值得注意:在这个方案里,"learn"、"learning"、"learned"、"learner" 是四个互不相干的 token。模型并不知道它们是相关的,只能一个一个从头学。
这个方案的问题在于:单词清单太庞大,以及未知词问题。看看下一个方案怎么解决。
方案三:子词级分词
现在,我们走中间路线。
想法很简单:常见词保持为一个 token,罕见词拆成常见的小块。
看几个例子:
- "the" 非常常见,保持一个 token:"the"
- "learning" 常见,保持一个 token:"learning"
- "tokenization" 罕见,拆成两个:"token"、"ization"
- "unbelievable" 罕见,拆成三个:"un"、"believ"、"able"
可以看到,切出来的块是有意义的。"token" 是一个真实的块,"ization" 也是一个真实的块。这意味着模型可以学到"ization"通常表示"做某件事的行为",并把学到的规律复用到 "organization"、"civilization" 等很多词上。因此模型学得更快。
优点:已知文本块的清单规模可控,通常在 50,000 到 200,000 之间。没有未知词问题,因为任何罕见词都可以一直拆下去,最坏拆到单个字符也总能命中已知的块。而且相关单词共享小块,模型不用每个都从头学。
缺点:有时一个词的切分位置在我们看来不太自然。
几乎所有现代 LLM 用的都是这一种。
我把三种方案的差异整理成表格,方便你理解:
| 对比项 | 字符级 | 词级 | 子词级 |
|---|---|---|---|
| 已知清单大小 | 非常小 | 非常大 | 中等 |
| 每句话的 token 数 | 非常多 | 少 | 少 |
| 未知词问题 | 从不出现 | 经常出现 | 从不出现 |
| 单个 token 的语义 | 非常少 | 完整 | 良好 |
| 是否用于现代 LLM | 否 | 否 | 是 |
现在的问题是:分词器怎么决定哪些块常见、哪些块罕见?这份清单是谁定的?
于是,字节对编码(Byte Pair Encoding)来救场了。
字节对编码(BPE)是如何工作的?
字节对编码(Byte Pair Encoding,BPE)是一种构建 token 清单的算法:从单个字符出发,反复把"最常一起出现的块对"合并成新块。
简单说,BPE 会审视海量文本,找出最常相邻出现的两个块,把它们粘成一个新块,然后不断重复。
学这个算法最好的办法就是来一个例子。
假设训练文本里只有三个单词,出现次数如下:
- "hug" 出现 10 次
- "pug" 出现 5 次
- "hugs" 出现 5 次
第 0 步:把每个单词拆成字符。
h u g (10 次)
p u g (5 次)
h u g s (5 次)
初始 token 清单:h、u、g、p、s。共 5 个 token。
第 1 步:统计所有"块对"。块对指一个单词内部相邻的两个块。
- "h" + "u" 出现 10 + 5 = 15 次
- "u" + "g" 出现 10 + 5 + 5 = 20 次
- "p" + "u" 出现 5 次
- "g" + "s" 出现 5 次
最频繁的块对是 "u" + "g",共 20 次。把它们合并成新 token ug。
现在 token 清单:h、u、g、p、s、ug。共 6 个 token。
现在单词长这样:
h ug (10 次)
p ug (5 次)
h ug s (5 次)
第 2 步:再次统计块对。
- "h" + "ug" 出现 10 + 5 = 15 次
- "p" + "ug" 出现 5 次
- "ug" + "s" 出现 5 次
最频繁的块对是 "h" + "ug",共 15 次。合并成新 token hug。
现在 token 清单:h、u、g、p、s、ug、hug。共 7 个 token。
现在单词长这样:
hug (10 次)
p ug (5 次)
hug s (5 次)
第 3 步:再次统计块对。
- "p" + "ug" 出现 5 次
- "hug" + "s" 出现 5 次
两者打平。这种情况下,分词器按固定规则选一个。我们把 "p" + "ug" 合并成 pug。
现在 token 清单:h、u、g、p、s、ug、hug、pug。共 8 个 token。
不断重复,直到清单达到我们想要的规模。真实分词器的目标规模是事先定好的,比如 100,000 个 token。
这里我们做了三次合并。每次合并都按顺序保存为一条规则:
规则 1:u + g -> ug
规则 2:h + ug -> hug
规则 3:p + ug -> pug
之后新文本进来时,分词器不再做任何统计。它只是把文本拆成字符,然后按同样的顺序套用这些规则。
假设新文本是 "pugs"。分词器的工作过程如下:
- 起点:
p、u、g、s - 应用规则 1:
p、ug、s - 应用规则 2:没有可合并的,因为这里没有 "h" + "ug"
- 应用规则 3:
pug、s - 最终 token:
pug、s
而 "pugs" 这个词从没在训练文本里出现过,我们照样得到了有意义的 token。
注意:在许多现代分词器(比如 GPT 系列模型用的那些)里,起点清单并不是字母,而是全部 256 种可能的字节值。任何语言的任何字符、任何 emoji、任何符号都以字节形式存储,所以分词器永远不会遇到未知的东西。这种变体叫字节级 BPE(byte-level BPE)。
还有一点值得注意:分词器是单独先行训练的,在 LLM 训练之前。token 清单一旦定好就被冻结,LLM 接着用这份冻结清单来训练。LLM 永远不会更改分词器。
词表与 Token ID
词表(vocabulary)是分词器认识的所有 token 的完整清单。
词表里的每个 token 都有一个唯一的编号,叫 Token ID。
可以把它想象成一部每个词条都带序号的词典:分词器查到某个文本块,找到序号,把这个编号交给模型。
对我们这个小例子来说,词表长这样:
| token | Token ID |
|---|---|
| h | 0 |
| u | 1 |
| g | 2 |
| p | 3 |
| s | 4 |
| ug | 5 |
| hug | 6 |
| pug | 7 |
于是文本 "pugs" 先变成 token pug、s,再变成 Token ID 7、4。
完整旅程是:
文本 -> token -> Token ID
"pugs" -> ["pug", "s"] -> [7, 4]
这里,[7, 4] 就是喂给 LLM 的东西。
那么,词表大小为什么重要?LLM 生成回复时,每一步都要从词表里挑一个 token。所以 100,000 个词表意味着模型每一步都要在 100,000 个选项里做选择。词表更大,每句话的 token 更少,但模型更重;词表更小,模型更轻,但序列更长。真实模型会根据自己的使用场景,在两者之间取一个平衡。
注意:在模型内部,每个 Token ID 会用来查一长串数字,叫嵌入(embedding)。真正的"理解"从那里才开始,那是另一篇博客的主题。现在我们只需知道:分词是嵌入之前的那一步。
解码:从 token 回到文本
现在看文本是怎么从模型里出来的。
LLM 并不产出文本,它产出的是 Token ID,一次一个。
假设模型产出了 6、4。分词器反向查词表:
6是hug4是s
然后拼接起来:"hugs"。
这个反向步骤叫解码(decoding),和我们之前做的正好相反。
Token ID -> token -> 文本
[6, 4] -> ["hug", "s"] -> "hugs"
这就是为什么,当我们看 LLM 在屏幕上打字时,有时会先出现半个单词、然后才出现剩下的部分------那就是 token 边界。模型产出了一个 token,分词器把它解码,然后展示给了我们。
用代码看分词实战
来看整套流程的实际运行。我们用 tiktoken,这是 OpenAI 的库,里面包含 OpenAI 模型所用的分词器。
python
import tiktoken
encoding = tiktoken.get_encoding("cl100k_base")
token_ids = encoding.encode("I love learning")
print(token_ids)
text = encoding.decode(token_ids)
print(text)
它会打印出类似这样的结果:
[40, 3021, 6975]
I love learning
这里我们用的是 cl100k_base,这是一个分词器的名字,词表约有 100,000 个 token。encode 函数把文本转换成 Token ID,decode 函数把 Token ID 转换回文本。我们拿回了原句,一字不差。
再来看看每个 Token ID 对应哪块文本:
python
for token_id in token_ids:
print(token_id, "->", "[" + encoding.decode([token_id]) + "]")
它会打印:
40 -> [I]
3021 -> [ love]
6975 -> [ learning]
可以看到,空格确实附着在单词上,和我们前面讨论的一致:" love" 是一个 token," learning" 是一个 token。
注意:具体的 Token ID 取决于分词器。换一个模型、换一个分词器,同一句话得到的数字就不一样。
特殊 token
除了从文本切出来的 token,词表里还有几个特殊 token。它们不是单词,是信号。
几个重要的:
- 文本结束 (End of text):告诉模型文本到此为止,通常写作
<|endoftext|>或<eos>。模型产出这个 token 时,就停止生成。 - 文本开始 (Beginning of text):标记文本从哪里开始,通常写作
<bos>。 - 填充(Padding):占位用的 token,用来把多个输入补齐到同一长度,方便模型一起处理。
- 对话标记(Chat markers):标记用户消息的起止位置、助手回复的起始位置。对话模型靠这个知道谁说了什么。
这里可以注意到:特殊 token 永远不会由切分普通文本产生,它们是分词器或模型外层系统有意添加的。
分词如何影响 LLM 的实际表现
分词发生在模型之前,而模型永远无法看到 token 内部。这一个事实就能解释 LLM 做出的许多怪事。
数字母:假设我们问,"strawberry" 里字母 "r" 出现了几次?分词器把这个词切成了类似 "str"、"aw"、"berry" 的块。模型看到的是三个 Token ID,从没见过单个字母。所以它只能靠记忆去猜这些块里有几个 "r"。这就是 LLM 有时会答错这类问题的原因。
数字算术:数字 "12345" 会被切成 "123"、"45" 这样的块。模型看到的不是五个数位,而是两个 token。在这些块上做加法,比逐位做加法难得多。
成本与限制:每个 LLM 一次能读的 token 数有上限,这就是上下文窗口(context window)。而 LLM 的计价也按 token 算。所以 token 数量同时决定了"装不装得下"和"花多少钱"。
其他语言:大多数分词器训练用的文本以英语为主,所以英语单词的 token 很紧凑。而印地语、泰米尔语、日语的句子会被切成多得多的碎块。同一句话在这些语言里消耗的 token 可能是英语的好几倍------意味着成本更高、上下文窗口被填得更快。
代码:空格和缩进也是 token。四格缩进可能是一个 token,也可能是四个 token,取决于分词器。这会影响模型读写代码的质量。
哪里好用,哪里失灵
总结一下子词分词好用的地方:
- 它能处理任何输入,没有未知词问题。
- 它让常见文本的序列保持得很短。
- 它让相关单词共享有意义的块,模型学得更快。
- 它凭一份词表就能同时服务文本、代码和多种语言。
再看它失灵的地方:
- 需要看到单个字母的任务,比如数字母、反转单词。
- 长数字的算术,因为数位会被按不自然的方式分组。
- 训练文本中占比低的语言------token 更多,意味着成本更高、上下文窗口余量更小。
- 任何被从奇怪位置切开的词都可能让模型困惑,因为切出来的块承载的不是我们以为的语义。
到这里,我们必须已经理解了:LLM 的分词是什么、BPE 怎么构建词表、文本怎么变成 Token ID 又怎么变回来,以及它在哪里好用、在哪里失灵。
常见问题
所有 LLM 都用同一个分词器吗?
不。每个 LLM 都有自己专属的分词器。不同的模型配上不同的分词器,同一句话会得到不同的 Token ID。所以我们拿到的具体数字,永远取决于用的是哪个分词器。
LLM 会在训练过程中更换分词器吗?
不会。分词器是单独先行训练的,在 LLM 训练之前。token 清单一旦定好就被冻结,LLM 接着用这份冻结清单来训练。LLM 永远不会更改分词器。
100 个 token 是多少个单词?
对英语来说,100 个 token 大约是 75 个单词。简单的经验法则:一个 token 大约 4 个字符,也就是大约四分之三个单词。
为什么 LLM 数不清一个单词里的字母?
因为模型从没见过单个字母。像 "strawberry" 这样的词会被切成 "str"、"aw"、"berry" 这样的块,模型只看到它们的 Token ID。所以它只能靠记忆去猜某个字母在这些块里出现了几次。
为什么印地语或日语的文本比英语费更多 token?
大多数分词器训练用的文本以英语为主,所以英语单词的 token 很紧凑。印地语、泰米尔语、日语的句子会被切成多得多的碎块。这意味着成本更高、上下文窗口被填得更快。