4. Tokenization与Embedding:BPE与SentencePiece分词原理、词表构建、Embedding层与权重共享、特殊Token处理
各位同学,今天我们来聊聊大模型推理引擎里最基础、也最容易被忽视的两个环节——Tokenization和Embedding。
我刚开始做推理引擎时,总觉得分词不就是把句子切成词吗?有什么难的?直到我在一个中文大模型项目里,发现同样的句子在不同分词器下,推理结果居然天差地别。嗯,从那以后,我再也不敢小看这一步了。
说白了,Tokenization就是把人类语言变成机器能理解的数字序列。而Embedding,则是把这些数字映射成稠密向量,让模型能“理解”语义。
核心观点:分词质量直接决定了模型的理解上限。词表大小、分词粒度、特殊Token的处理方式,都会影响最终推理效果。
4.1 BPE分词原理
BPE(Byte Pair Encoding)是目前最主流的分词算法之一。GPT系列、LLaMA系列都在用。它的核心思想很简单:从字符级别开始,逐步合并最频繁出现的相邻字符对。
我举个例子。假设我们有语料:
语料:["low", "lower", "lowest", "high", "higher", "highest"]
第一步,统计所有字符频率。第二步,找到最频繁的相邻字符对,比如"lo"出现了3次,那就合并成"lo"。第三步,重复这个过程,直到词表达到预设大小。
你想想看,这个过程其实是在学习语言的“子词”结构。像"low"、"lower"、"lowest"共享了"low"这个子词,BPE能自动发现这种规律。
我的经验:BPE的词表大小一般设置在32K到128K之间。太小了,分词太碎,模型学不到完整语义;太大了,词表稀疏,训练效率低。我个人习惯从64K开始调参。
4.2 SentencePiece分词原理
SentencePiece是Google开源的分词工具,它和BPE最大的区别在于:它把输入视为原始字节流,不依赖任何语言特定的预处理。
什么意思呢?比如中文,传统分词需要先做词性标注、命名实体识别等预处理。但SentencePiece直接对Unicode字节做BPE或Unigram建模,省去了这些麻烦。
我记得在做一个多语言模型时,需要同时处理中、英、日、韩四种语言。用SentencePiece,一个词表就搞定了,不用为每种语言单独准备分词器。
SentencePiece支持两种训练算法:
- BPE模式:和上面讲的BPE一样,从字符开始合并
- Unigram模式:基于语言模型,用EM算法迭代剪枝,保留最可能的子词
我个人更推荐Unigram模式。为什么?因为它能更好地处理罕见词和未登录词。我在项目中遇到过,BPE对低频词容易切得太碎,而Unigram能保留更多语义信息。
4.3 词表构建实战
词表构建不是简单的统计词频。这里有几个关键点:
- 语料清洗:去掉HTML标签、特殊符号、重复内容。我见过有人直接用爬虫数据训练,结果词表里全是乱码。
- 字节回退:确保任何输入都能被分词。BPE/SentencePiece都有
<unk>标记,但最好让所有字符都能被覆盖。 - 特殊Token预留:至少预留100-200个位置给特殊Token。
下面是一个用SentencePiece训练词表的示例:
import sentencepiece as spm
# 训练词表
spm.SentencePieceTrainer.train(
input='corpus.txt',
model_prefix='my_tokenizer',
vocab_size=32000,
model_type='unigram', # 或 'bpe'
character_coverage=0.9995, # 覆盖99.95%的字符
pad_id=0,
unk_id=1,
bos_id=2,
eos_id=3,
pad_piece='<pad>',
unk_piece='<unk>',
bos_piece='<s>',
eos_piece='</s>'
)
# 加载词表
sp = spm.SentencePieceProcessor()
sp.Load('my_tokenizer.model')
# 分词示例
text = "大模型推理引擎"
tokens = sp.EncodeAsPieces(text)
print(tokens) # ['▁大', '模型', '推理', '引擎']
ids = sp.EncodeAsIds(text)
print(ids) # [1234, 5678, 9012, 3456]
注意:SentencePiece默认会在词前加空格标记"▁"。这是为了区分词首和词中位置。在推理时,记得把"▁"替换回空格,否则输出会连在一起。
4.4 Embedding层与权重共享
分词之后,每个Token对应一个ID。Embedding层的作用,就是把ID映射成一个稠密向量。这个向量的维度通常是768、1024或4096,取决于模型大小。
Embedding层本质上是一个查找表:
# 伪代码
embedding_matrix = nn.Embedding(vocab_size, hidden_dim)
# 输入: token_ids [batch_size, seq_len]
# 输出: embeddings [batch_size, seq_len, hidden_dim]
embeddings = embedding_matrix(token_ids)
这里有个重要的技巧——权重共享。什么意思呢?就是让Embedding层的权重和输出层的权重共用同一份参数。
你想想看,输入层把Token映射成向量,输出层把向量映射回Token概率。这两个过程是互逆的,共享权重可以大幅减少参数量,还能提升训练效果。
我在项目中做过对比:一个7B模型,不共享权重时参数量约7.2B,共享后降到6.8B,而且收敛速度还快了10%。
实现方式很简单:
class TransformerWithWeightTying(nn.Module):
def __init__(self, vocab_size, hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, hidden_dim)
self.output_proj = nn.Linear(hidden_dim, vocab_size, bias=False)
# 权重共享
self.output_proj.weight = self.embedding.weight
def forward(self, x):
x = self.embedding(x)
# ... transformer layers ...
logits = self.output_proj(x)
return logits
避坑指南:权重共享时,Embedding的梯度会同时更新输入和输出两个方向。如果学习率太大,容易导致训练不稳定。我建议把学习率调低到原来的0.8倍左右。
4.5 特殊Token处理
特殊Token是分词器里最容易被忽略、但坑最多的地方。常见的特殊Token包括:
| Token | 含义 | ID(示例) |
|---|---|---|
| <pad> | 填充符,用于对齐序列长度 | 0 |
| <unk> | 未知词,词表中没有的词 | 1 |
| <s> / <bos> | 序列开始标记 | 2 |
| </s> / <eos> | 序列结束标记 | 3 |
| <mask> | 掩码标记(BERT用) | 4 |
| <sep> | 分隔符(BERT用) | 5 |
处理特殊Token时,有几个容易踩的坑:
- Attention Mask:
<pad>的位置必须在Attention计算时被mask掉,否则模型会学到无意义的填充信息。 - Loss计算:
<pad>的位置不应该参与Loss计算。我见过有人忘了加ignore_index,结果模型一直在学怎么预测<pad>。 - 生成终止:推理时遇到
<eos>必须立即停止。我曾经遇到过一个bug,模型生成了<eos>后还在继续输出,结果生成了几百个无意义的Token。
重要提醒:特殊Token的ID必须固定,不能随词表训练而变化。我习惯在词表构建时就把特殊Token的ID预留好,比如0-100留给特殊Token,100之后才是普通词。
4.6 知识体系总览
下面这张图展示了Tokenization和Embedding在整个推理引擎中的位置和关系:
从这张图可以看出,整个流程是:输入文本 → 分词器 → Token IDs → Embedding层 → 权重共享 → 输出层。左侧的特殊Token处理和词表构建,是支撑整个流程的基础设施。
好了,这一章的内容就到这里。Tokenization和Embedding看似简单,但细节决定成败。我建议你在实际项目中,多花点时间调优分词器和词表,这比调模型参数性价比高得多。