Skip to content

约 9 分钟阅读

02. 分词与词嵌入:文字怎样变成向量 ​

上一篇的全景图里,“年假怎么申请?”在进入 32 层之前先过了两道工序:分词器把它切成 5 个编号,词嵌入把每个编号换成一个 4096 维的向量。图的另一头,输出层又把向量变回 128,256 个分数。这一篇讲这两端:文字按什么规则切成 token,中文为什么有时一个字一块、有时两个字一块、有时半个字一块;词表大小怎样影响参数量和计算量;词嵌入为什么只是查表;输出层和词嵌入“共享”又是什么意思。

读完应能拿到任意一个模型的分词器,自己验证一句话切成几个 token;能从 vocab_size、hidden_size 和 tie_word_embeddings 三个字段算出词表占了多少参数。分词器怎样从语料里训练出来(BPE 的合并过程手算)已经在训练系列的预训练数据与分词里讲过,这里只讲用的时候发生了什么。

分词器做的三件事 ​

先看实测结果。用本机加载 Llama 3.1 和 Qwen2.5-0.5B-Instruct 的 tokenizer.json(tokenizers 库,不加特殊 token),两者都把“年假怎么申请?”切成 5 个 token:

分词三步:正则预切分出“年假怎么申请”和“?”;转成 21 个 UTF-8 字节;按合并规则合并为 年、假、怎么、申请、? 五个 token。“钉钉审批”在 Llama 3 中切成 6 个 token,“钉”拆成两段字节;Qwen2.5 只需 3 个

这两个分词器都属于字节级 BPE(byte-level BPE),处理一段文字分三步:

  1. 预切分。先用一条正则表达式把文字粗切成段,合并只在段内进行。两者的正则里都有 \p{L}+,意思是“连续的字母类字符算一段”,汉字属于字母类,所以“年假怎么申请”是一整段,全角问号“?”是标点,单独一段。数字的规则不同:Llama 3 是 \p{N}{1,3},最多 3 位数字一组;Qwen2.5 是 \p{N},每个数字单独一段。所以“2026”在 Llama 3 里切成 202 / 6 两块,在 Qwen2.5 里切成四块(实测)。
  2. 转成字节。每段文字按 UTF-8 编码成字节。一个常用汉字 3 字节,“年假怎么申请?”共 21 字节。词表的起点就是 256 个单字节,任何文字都能表示,不存在“未知字符”。
  3. 按合并规则合并。分词器训练时学到了一串有先后顺序的合并规则(例如“E6 80 + 8E → 怎”“怎 + 么 → 怎么”),切分时按顺序套用,直到不能再合并。最后每一块查词表得到编号。

结果里“怎么”“申请”各是一个 token,“年假”却是两个。这不是语法判断,只是训练语料里“怎么”“申请”出现得足够多,合并成了整块,“年假”没有。分词器不懂词义,它只记录哪些字节组合常见。

同一句话、同样 5 块,两边的编号完全不同:“申请”在 Llama 3 里是 112838,在 Qwen2.5 里是 101915。编号只是在各自词表里的行号,换一个模型就对不上,所以两个分词器不同的模型不能直接交换 token 序列;用大模型的输出概率蒸馏小模型时,也要先解决两张词表怎样对齐的问题。

中文怎样切:常见字成块,生僻字退回字节 ​

多试几句就能看出两个分词器的差异(实测,单句样例,不代表平均水平;更长文本的统计见训练系列第 06 篇):

输入Llama 3.1Qwen2.5
年假怎么申请?5:年 / 假 / 怎么 / 申请 / ?5:年 / 假 / 怎么 / 申请 / ?
请问年假怎么申请?7:请 / 问 / 年 / 假 / 怎么 / 申请 / ?6:请问 / 年 / 假 / 怎么 / 申请 / ?
我想请5天年假7:我 / 想 / 请 / 5 / 天 / 年 / 假6:我想 / 请 / 5 / 天 / 年 / 假
钉钉审批6:“钉”每个拆 2 块字节 / 审 / 批3:钉 / 钉 / 审批
𠮷(扩展区汉字,4 字节)4:每个字节一块1
How do I apply for annual leave?88

英文两边一样,中文 Qwen2.5 普遍少一点。原因在词表的来源:Llama 3 的 128K 词表是 OpenAI tiktoken 的 100K 加上 28K 个主要面向非英语的 token,中文常用词能成块的有限;Qwen 的训练语料里中文占比高,“请问”“我想”“审批”这类组合都进了词表。

“钉”这个例子值得细看。它的 UTF-8 是 E9 92 89,Llama 3 的词表里没有这个字的整块,合并只走到 E9 92,剩下的 89 单独一块。这两块单独解码都不是合法字符,会显示成乱码 �。这带来两个实际后果:

  • 生僻字、新词更费 token。员工手册里如果满是“钉钉”“飞书”这类专有名词,或者人名里有生僻字,实际 token 数会比按“一字一 token”估的多,计费和上下文占用都跟着涨。
  • 流式输出要按字节攒齐再显示。模型一步只出一个 token,出到 E9 92 时屏幕上还拼不出“钉”。推理框架的解码器通常会缓存不完整的字节,等下一个 token 补齐再输出;自己写流式解码时如果逐 token 直接 decode,就会看到一闪而过的乱码。

判断方法很直接:对可疑文本调用 tokenizer.encode,再对每个编号单独 decode,看有没有 �、平均每个汉字几个 token。选底座模型时,用自己的真实语料跑一遍比看宣传的“中文友好”可靠。

特殊 token 和对话模板 ​

词表里除了普通文字片段,还有一批特殊 token,它们不对应任何文字,只用来标记结构。Llama 3.1 的 <|begin_of_text|> 是 128000、<|eot_id|>(一轮发言结束)是 128009;Qwen2.5 用 <|im_start|> 151644、<|im_end|> 151645 包住每一轮对话。

聊天应用发给模型的从来不只是用户那一句。用各自的对话模板包装“年假怎么申请?”之后再分词,Qwen2.5-0.5B-Instruct 是 34 个 token(含默认系统提示),Llama 3.1 是 40 个 token(模板自动加了知识截止日期和当天日期)。模型在训练中见过这些标记,靠它们分清谁在说话、什么时候该停。模板用错,模型就可能续写出“用户:”一类的内容停不下来,第 08 篇讲停止 token 时会再回来。

词表大小的账 ​

vocab_size 决定了模型两头那两张矩阵有多大。每个 token 在词嵌入里占一行、在输出层里占一行,每行长度等于隐藏维度:

text
词嵌入参数 = vocab_size × hidden_size
Llama-3.1-8B:128,256 × 4,096 = 525,336,576   (输出层再一份同样大小)
Qwen2.5-0.5B:151,936 × 896   = 136,134,656   (输出层与它共享,不另算)

vocab_size 和分词器实际的 token 数不一定相等。Llama 3.1 是 128,000 个普通 token 加 256 个特殊 token,正好 128,256。Qwen2.5 的分词器实际只有 151,665 个 token(151,643 个普通、22 个特殊),config.json 却是 151,936,多出的 271 行不对应任何 token,只是把矩阵行数补到了 128 的整数倍(151,936 = 128 × 1,187),便于计算。算参数时以 config.json 为准。

词表大一点的好处是同一段文字的 token 更少:每个 token 都要走一遍全部 32 层,token 少了,训练和推理的计算就少,同样 128K 的上下文窗口也能装下更多字。代价有三处:

  • 参数:每多 1 万个 token,Llama-3.1-8B 两头各多 4096 万参数。
  • 输出层的计算:每生成一个 token,输出层都要和全部词表行相乘,2 × 4096 × 128256 ≈ 1.05 GFLOPs,约占每 token 前向计算的 7%,再加上对 128,256 个分数做 softmax。词表翻倍,这部分跟着翻倍。
  • 训练充分度:罕见 token 在训练里出现次数少,它们那一行学得不好。

这本账对小模型格外明显,因为词表矩阵只跟隐藏维度成正比,和层数无关。下一节的图里,同样 128,256 的词表,在 8B 模型里占 13.1%,在 1B 模型里占 21.3%。

词嵌入就是查表 ​

词嵌入层(Transformers 里是 nn.Embedding,名为 embed_tokens)就是一个形状 [vocab_size, hidden_size] 的矩阵。token 编号是几,就取第几行:

5 个 token 编号在 [128256, 4096] 的词嵌入矩阵中各取一行,得到 [5, 4096];Llama-3.1-8B 的输出层另存一份同样大小的矩阵,Qwen2.5-0.5B 让输出层复用词嵌入矩阵;词表在三个模型中分别占 13.1%、21.3%、27.6% 的参数

python
E = nn.Parameter(torch.randn(128256, 4096))   # 训练开始时是随机数
ids = torch.tensor([8107, 107099, 104237, 112838, 11571])
x = E[ids]                                    # [5] → [5, 4096],只是按行号取出

数学上,这等价于把每个编号写成 128,256 维的独热向量(只有第 id 位是 1),再乘以矩阵 E。真去做这个乘法会浪费 128,255 次乘零,所以实现上直接按下标取行,这也是上一篇算计算量时把词嵌入扣掉的原因。

这张表里的数是训练学出来的。开始时全是随机数,训练中哪个 token 的向量怎样调整能让预测更准,就往哪边挪。训练完以后,用法相近的 token 往往在向量空间里离得较近,但输入嵌入只是起点:“申请”在第 0 层的向量对每句话都一样,不知道自己申请的是年假还是报销,这些上下文信息要靠后面的注意力层一层层加进来。也要和检索用的“向量模型”分开:那是另一类专门训练过、把整段文字压成一个向量的模型(见训练系列的向量模型训练),不是大模型的这张词嵌入表。

输出层与共享词嵌入(tie) ​

输出层把最后一层的向量 h(4096 维)变成 128,256 个分数。它是一个没有偏置的线性层,权重形状 [128256, 4096],每一行对应一个 token:

text
logits[i] = h · W[i]            W[i] 是输出层第 i 行(4096 维),i = 0 … 128255
形状:[序列长度, 4096] × [4096, 128256]ᵀ → [序列长度, 128256]

某个 token 的分数,就是最后的向量和它那一行的点积,方向越接近,分数越高。这和词嵌入的形状一模一样,一张表把编号变成向量,另一张表拿向量去和每个编号比。于是有一个自然的做法:两头用同一张表,即共享词嵌入(weight tying)。config.json 里的 tie_word_embeddings: true 就是这个意思,Transformers 加载时让 lm_head.weight 直接指向 embed_tokens.weight。

这个做法 2017 年就有了:原始 Transformer 共享了嵌入层和输出前的线性层,同年 Press 与 Wolf 的研究也显示共享能减少参数并改善语言模型的困惑度。现在的开源模型里,可以看到一个明显的规律:

模型vocab × hiddentie词表相关参数占全模型
Qwen2.5-0.5B151,936 × 896true1.36 亿(一份)27.6%
Llama-3.2-1B128,256 × 2,048true2.63 亿(一份)21.3%
Llama-3.1-8B128,256 × 4,096false10.5 亿(两份)13.1%

小模型(Qwen2.5-0.5B、Qwen3-0.6B、Llama-3.2-1B/3B)基本都共享,Llama-3.1-8B、Qwen3-8B 这一档不共享。原因从比例上就能看出来:0.5B 模型如果不共享,词表要再多 1.36 亿参数,两头合计占到全模型的四成多(2.72 亿 ÷ 6.30 亿);8B 模型多一份只多 6.5%,换来输入和输出各用各的表、不必互相迁就。官方很少专门解释这个选择,上面是从参数比例做的推断。

共享也会影响微调。往词表里加新 token(比如把“钉钉”加成整词)时,共享的模型里新行同时决定“怎样读它”和“什么时候说出它”;不共享的模型要两头都学好。新 token 的向量从随机值或平均值开始,数据少时往往学不好,多数情况下不如让模型继续用已有的子词拼。

分词器把文字切成编号,字节级 BPE 保证任何字符都能表示,常见词成块、生僻字退回字节;词嵌入按编号查表得到 [序列长度, 4096];输出层用同样形状的表给每个 token 打分,小模型通常让两头共用一张表。向量进了第一层以后,最关键的一步是让每个位置看见上下文:下一篇讲注意力,用 4 个 token 手算“申请”怎样回头找到“假”。

别急,先让缓存热一下。