Tokenizer: 文本如何变成 token id?
这一节准备先回答下面几个问题:
- 字符串是如何被输入到 LLM 中的?
- token 到底是什么?它和“字”或“词”是什么关系?
- 为什么今天的大模型通常不直接按 word level 处理,而更常见是 subword level?
Q1: 字符串是如何被输入到 LLM 中的?
这是这一节最核心的问题。
模型本身并不能直接理解字符串。
从计算的角度看,神经网络最终能处理的是:
- 数值
- 向量
- 张量
而原始文本首先只是字符序列。
所以在把文本送入模型之前,必须先把它变成可以计算的离散符号,再进一步变成数值表示和连续向量。
如果把这条链路写完整,可以先记成:
字符串 -> tokenizer -> token -> token id -> embedding -> 模型主体
这里面其实包含了几层不同的对象:
- 原始字符串
- tokenizer 切分后的 token
- token 在词表中的编号,也就是 token id
- token id 经过 embedding 层后得到的向量
这几层非常容易混在一起,所以最好拆开看。
第一步: 字符串先被切成 token
tokenizer 的第一件事,是把原始字符串切成模型使用的离散单位,也就是 token。
例如,一段文本不会直接进入 Transformer,而是先被 tokenizer 拆成一串 token。
这些 token 不一定等于“一个字”,也不一定等于“一个完整单词”,它们只是 tokenizer 定义出来的基本处理单位。
第二步: token 再映射成 token id
模型内部不会直接处理 token 字符串,而会处理它们在词表中的编号。
这里就会引出一个很重要的概念:词表,也就是 vocab。
词表可以理解成:
- tokenizer 允许使用的那一套离散符号集合
- 以及这些符号到整数 id 的映射关系
也就是说,词表本质上是在维护:
token <-> token id
例如,一个非常简化的词表可以写成:
"我" -> 0
"喜欢" -> 1
"学习" -> 2
"AI" -> 3
"<unk>" -> 4
这里:
- 左边是 token
- 右边是 token 对应的整数 id
所以 tokenizer 输出的通常不是 embedding,而是一串 token id。
第三步: token id 再通过 embedding 层变成向量
到了这一步,离散的 token id 才会进入 embedding 层,被映射成连续向量。
如果词表大小记作 $V$,embedding 维度记作 $d$,那么 embedding 矩阵通常可以写成:
$$ E \in \mathbb{R}^{V \times d} $$
其中:
- $V$ 表示词表大小
- $d$ 表示 embedding 维度
这个式子的直觉非常重要。它表示:
- 词表里一共有 $V$ 个 token
- 每个 token 都对应 embedding 矩阵中的一行向量
所以从实现角度看,一个 token id 进入 embedding 层,本质上就是在 embedding 矩阵中“查一行”。
例如,如果一个 token 的 id 是 17,那么 embedding 层返回的就是 embedding 矩阵第 17 行对应的向量。
vocab 和 embedding 的关系到底是什么?
这部分非常值得单独说清楚,因为很多时候最容易混淆的就是这里。
可以简单概括成:
- vocab 决定“有哪些 token,以及它们分别编号是多少”
- embedding 决定“这些 token 被表示成什么向量”
也就是说:
- vocab 是离散符号空间
- embedding 是连续表示空间
两者一一对应,但不是一回事。
更进一步说:
- tokenizer 负责把字符串变成 token
- vocab 负责把 token 变成 token id
- embedding 层负责把 token id 变成向量
所以 tokenizer 的工作并不是“直接产生语义向量”,而是先把文本送进模型可以处理的离散接口中。
为什么 embedding 层本身也很重要?
虽然 embedding 层看起来像“查表”,但它其实已经是模型参数的一部分。
因为 embedding 矩阵中的每一行向量,都会在训练过程中被更新。
也就是说,模型不仅在学习“怎么组合 token”,也在学习:
- 哪些 token 的表示更接近
- 哪些 token 在语义或统计关系上更相似
所以如果用一句话总结这个核心问题,我觉得可以写成:
字符串先经过 tokenizer 被切成 token,再通过词表映射成 token id,最后经过 embedding 层变成模型真正处理的连续向量。
一个最小例子
如果只看抽象定义,token -> token id -> embedding 这条链很容易停留在概念层面。
所以不妨先看一个非常简化的例子。
假设原始输入字符串是:
我喜欢AI
现在假设某个 tokenizer 把它切成了下面三个 token:
["我", "喜欢", "AI"]
再假设它对应的词表是:
"我" -> 0
"喜欢" -> 1
"AI" -> 2
"<unk>" -> 3
那么这一步之后,字符串就会被表示成 token id 序列:
[0, 1, 2]
接下来,假设 embedding 维度是 4,那么 embedding 矩阵可以写成:
$$ E \in \mathbb{R}^{4 \times 4} $$
其中:
- 第一个 $4$ 表示词表大小
- 第二个 $4$ 表示 embedding 维度
为了方便说明,假设这个 embedding 矩阵的每一行分别是:
$$ E = \begin{bmatrix} 0.2 & -0.1 & 0.5 & 0.7 \ 0.9 & 0.3 & -0.4 & 0.1 \ -0.2 & 0.8 & 0.6 & -0.5 \ 0.0 & 0.0 & 0.0 & 0.0 \end{bmatrix} $$
那么:
- token id
0对应"我",取出第 0 行 - token id
1对应"喜欢",取出第 1 行 - token id
2对应"AI",取出第 2 行
于是输入序列 [0, 1, 2] 进入 embedding 层之后,就会变成下面三个向量:
$$ \begin{aligned} e_0 &= [0.2, -0.1, 0.5, 0.7] \ e_1 &= [0.9, 0.3, -0.4, 0.1] \ e_2 &= [-0.2, 0.8, 0.6, -0.5] \end{aligned} $$
到了这一步,模型真正看到的就不再是字符串 "我喜欢AI",也不再是离散编号 [0, 1, 2],而是这些连续向量组成的矩阵。
如果把它写成更紧凑的形式,就是:
$$ X \in \mathbb{R}^{3 \times 4} $$
其中:
- $3$ 表示序列长度,也就是 token 个数
- $4$ 表示 embedding 维度
这个例子虽然非常简化,但它已经把最关键的几层关系串起来了:
- 原始字符串
- token 序列
- token id 序列
- embedding 向量序列
后面进入 Transformer 之前,模型真正处理的就是这些向量,而不是原始文本本身。
Q2: token 到底是什么?
token 可以先粗略理解成:
模型在文本处理中使用的基本离散单位。
但这个单位不一定等于“一个字”,也不一定等于“一个完整单词”。
例如在不同 tokenizer 里,一个 token 可能是:
- 一个汉字
- 一个英文单词
- 一个单词的一部分
- 一个标点
- 一个空格相关的片段
所以更准确地说,token 是 tokenizer 根据某种规则切分出来的文本片段。
这也是为什么“token 数”和“字数”或“单词数”通常并不相等。
Q3: 为什么今天通常是 subword,而不是直接按词切分?
如果完全按单词切分,看起来很自然,但会遇到很多问题:
- 词表会非常大
- 生僻词、新词、拼写变化很难处理
- 多语言场景下词表管理会非常麻烦
所以现在更常见的做法,是让 tokenizer 处理到 subword 级别,也就是“比单词更小,但又不是纯字符”的单位。
这样做的好处通常包括:
- 词表规模更可控
- 未登录词更容易被拆解处理
- 可以在表达能力和词表大小之间做折中
例如一个较长的英文单词,可能会被拆成多个更常见的片段;而中文里,很多 tokenizer 的行为又会更接近字级别,但不一定严格等于“每个汉字一个 token”。
所以从实际效果上看,subword tokenizer 更像是一种工程上非常有效的折中方案。
Q4: 这一节之后最重要的收获是什么?
如果只保留最关键的几件事,我觉得是:
- 字符串进入 LLM 之前,要先经过
token -> token id -> embedding这条链路。 - token 不等于“字”或“词”,它是 tokenizer 定义出来的离散单位。
- vocab 是 token 到整数 id 的映射,它决定了系统内部的离散符号空间。
- embedding 层负责把 token id 变成连续向量,它和 vocab 一一对应,但和 tokenizer 不是同一个层次的东西。
- 今天的大模型通常更常用 subword tokenizer,因为它在词表大小和表达能力之间更平衡。
下一节再继续回到模型结构本身,讨论一个预训练模型通常由哪些部分组成。