3. Tokenization与Embedding:分词器原理、词嵌入与位置编码的推理时行为

好,咱们进入第三章。说实话,很多同学学Transformer,注意力机制背得滚瓜烂熟,但一问他分词器怎么工作的,立马卡壳。我当年刚入行时也犯过这毛病——模型跑通了,但输入稍微变个格式就崩。后来才明白,Tokenizer和Embedding才是模型理解世界的入口

3.1 分词器:把文本切成模型能吃的块

模型不认识单词,只认识数字。分词器干的事,说白了就是把文本变成一串整数ID。但怎么切,学问大了。

3.1.1 BPE(Byte Pair Encoding)——最主流的分词算法

BPE是目前大模型最常用的分词方法。它的核心思想很简单:从字符开始,逐步合并最常见的字符对

举个例子:

初始词汇表:['l', 'o', 'w', 'e', 'r', 'n', 'w', 'a', 'd']
统计频率最高的相邻对:('l','o') 出现2次 → 合并为 'lo'
新词汇表:['lo', 'w', 'e', 'r', 'n', 'w', 'a', 'd']
继续合并:('lo','w') 出现2次 → 合并为 'low'
... 直到达到预设的词汇表大小

我在项目中遇到过一个问题:用BPE分词时,同一个词在不同上下文可能被切成不同的子词。比如“playing”可能被切成“play”+“ing”,也可能被切成“playing”本身——这取决于训练时的合并频率。嗯,这里要注意,推理时的分词行为必须和训练时完全一致,否则ID序列就乱了。

关键点:BPE分词器在推理时是确定性的——同样的输入文本,永远输出同样的ID序列。这是模型可复现性的基础。

3.1.2 特殊Token的处理

分词器里有一类特殊的Token,比如[CLS][SEP][PAD][UNK]。推理时,这些Token的行为需要特别注意:

  • [CLS]:通常放在序列开头,用于分类任务。推理时一定要加,位置不能错。
  • [SEP]:分隔句子。如果你做的是单句推理,也得加一个在末尾——很多新手会漏掉。
  • [PAD]:填充Token。推理时如果batch size > 1,短句需要padding到相同长度。但注意,padding的位置会影响注意力掩码
  • [UNK]:未登录词。如果输入里有词汇表里没有的字符,就会被映射成[UNK]。我曾经踩过坑:用户输入了一个emoji,分词器直接变成了[UNK],模型输出完全跑偏。
避坑指南:我曾经在生产环境遇到过一个问题——同一个句子,用不同版本的分词器(比如transformers库升级后),得到的ID序列不一样。结果模型推理结果全变了。所以,务必固定分词器的版本和配置,最好把tokenizer文件一起保存。

3.2 词嵌入:把离散ID变成连续向量

分词器输出的是整数ID,比如[101, 2057, 2003, 102]。但模型内部处理的是浮点数向量。词嵌入层(Embedding Layer)就是干这个的——把每个ID映射成一个固定维度的稠密向量

3.2.1 嵌入矩阵的推理时行为

词嵌入本质上是一个查找表。假设词汇表大小是V,嵌入维度是d,那么嵌入矩阵的形状就是[V, d]。推理时,输入ID为i,就直接取矩阵的第i行:

# 伪代码
embedding_matrix = nn.Embedding(vocab_size, d_model)
input_ids = [101, 2057, 2003, 102]
embeddings = embedding_matrix(input_ids)  # 形状: [4, d_model]

这里有个细节:推理时嵌入矩阵是冻结的,不会更新。但如果你用的是预训练模型,嵌入矩阵里已经包含了丰富的语义信息——比如“king”和“queen”的向量距离很近,而“king”和“apple”距离很远。

个人经验:我习惯在推理前检查一下嵌入矩阵的范数分布。如果某些Token的嵌入向量范数特别大或特别小,往往意味着训练数据里有异常。有一次我发现[UNK]的嵌入向量范数比其他Token大10倍,排查后发现是数据预处理时漏掉了某些特殊字符。

3.3 位置编码:告诉模型词与词的相对位置

Transformer没有循环结构,它天生不知道词的顺序。你想想看,如果把“我打你”和“你打我”的Token序列调换顺序,模型看到的嵌入向量完全一样——这显然不行。位置编码就是来解决这个问题的。

3.3.1 绝对位置编码 vs 相对位置编码

类型 代表模型 推理时行为 优缺点
绝对位置编码 原始Transformer、BERT 每个位置有固定的编码向量,直接加到词嵌入上 简单直观,但无法处理超过训练时最大长度的序列
相对位置编码 GPT-2、T5、RoBERTa 在注意力计算时动态引入位置偏移信息 可以外推到更长的序列,但实现更复杂

我最早用BERT做文本分类时,一直没搞明白位置编码到底怎么工作的。后来自己手写了一个简化版,才真正理解:

# 正弦位置编码(原始Transformer用)
def sinusoidal_position_encoding(max_len, d_model):
    pe = torch.zeros(max_len, d_model)
    position = torch.arange(0, max_len).unsqueeze(1)
    div_term = torch.exp(torch.arange(0, d_model, 2) * 
                         -(math.log(10000.0) / d_model))
    pe[:, 0::2] = torch.sin(position * div_term)
    pe[:, 1::2] = torch.cos(position * div_term)
    return pe  # 形状: [max_len, d_model]

推理时,位置编码要么是预计算好的固定矩阵(绝对位置编码),要么是在注意力计算时动态生成(相对位置编码)。前者简单,后者灵活。

3.3.2 推理时的长度外推问题

这是个大坑。绝对位置编码的模型,训练时最大长度是512,推理时输入超过512个Token怎么办?

  • 直接截断:丢掉后面的Token。简单粗暴,但会丢失信息。
  • 位置编码插值:把位置编码向量“拉伸”到更长序列。比如把[0, 511]的编码映射到[0, 1023]。我在项目中试过,效果不稳定。
  • 改用相对位置编码的模型:比如GPT-2、LLaMA,它们天然支持更长的推理序列。
核心结论:推理时,位置编码的行为决定了模型能处理的最大序列长度。如果你需要处理长文本,建议优先选择支持相对位置编码的模型架构。

3.4 推理时的完整流程

把上面三个步骤串起来,推理时的Tokenization与Embedding流程就是:

输入文本: "我今天很开心"
    ↓
分词器(BPE): ["我", "今天", "很", "开心"]
    ↓
映射到ID: [101, 2769, 1518, 1962, 102]  # 假设[CLS]和[SEP]
    ↓
词嵌入层: 每个ID查表得到d维向量
    ↓
加上位置编码: 每个位置的向量加上对应的位置编码
    ↓
输出: [seq_len, d_model] 的浮点数矩阵 → 送入Transformer层

这里有个容易被忽略的点:词嵌入和位置编码是相加还是拼接? 原始Transformer是相加,但有些变体(比如ALBERT)是拼接。推理时一定要确认你的模型用的是哪种方式。

调试技巧:我习惯在推理时打印出第一层的输入范数。如果范数突然变得很大或很小,大概率是分词或嵌入环节出了问题。有一次我发现范数全是0,排查后发现是词汇表路径配错了,模型加载了一个空嵌入矩阵。

3.5 本章小结

Tokenization和Embedding,看似简单,但推理时的每一个细节都可能影响最终结果。总结几个我踩过的坑:

  • 分词器版本必须固定,否则ID序列会变
  • 特殊Token的位置不能错,[CLS]和[SEP]该加就得加
  • 位置编码决定了最大长度,长文本推理要选对模型
  • 嵌入矩阵的范数分布可以作为数据质量的快速检查手段

下一章我们会深入注意力机制的推理时行为——那才是Transformer真正“烧脑”的地方。

Tokenization与Embedding推理流程 输入文本 BPE分词器 → Token ID 词嵌入层(查找表) + 位置编码 [seq_len, d_model] 矩阵 ⚠️ 注意事项 • 分词器版本必须固定 • 特殊Token不能漏 • 嵌入矩阵推理时冻结 • 位置编码决定最大长度 • 词嵌入+位置编码是相加 特殊Token [CLS]:分类标记,放开头 [SEP]:分隔标记,放结尾 [PAD]:填充标记,用于batch [UNK]:未登录词,需避免
专注资料整理