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 词表构建实战

词表构建不是简单的统计词频。这里有几个关键点:

  1. 语料清洗:去掉HTML标签、特殊符号、重复内容。我见过有人直接用爬虫数据训练,结果词表里全是乱码。
  2. 字节回退:确保任何输入都能被分词。BPE/SentencePiece都有<unk>标记,但最好让所有字符都能被覆盖。
  3. 特殊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在整个推理引擎中的位置和关系:

Tokenization与Embedding知识体系 输入文本 分词器 (BPE/Unigram) Token IDs Embedding层 权重共享 输出层 (共享权重) 特殊Token处理 <pad> - 填充对齐 <unk> - 未知词处理 <bos>/<eos> - 序列边界 <mask>/<sep> - 特殊用途 词表构建要点 语料清洗 → 字节回退 特殊Token预留 → 词表大小 BPE vs Unigram 选择

从这张图可以看出,整个流程是:输入文本 → 分词器 → Token IDs → Embedding层 → 权重共享 → 输出层。左侧的特殊Token处理和词表构建,是支撑整个流程的基础设施。

好了,这一章的内容就到这里。Tokenization和Embedding看似简单,但细节决定成败。我建议你在实际项目中,多花点时间调优分词器和词表,这比调模型参数性价比高得多。


专注资料整理