从零手写大模型 · 推理篇 03 - Embedding(嵌入层)
推理篇 · 查看系列写在前面
上一篇我们聊了BPE分词,把"猫吃鱼"这句话拆成了子词,再映射成一串数字:[3, 15, 27]。
看到这串数字,很多人第一反应是:这不就完事了吗?文字变成数字,直接扔给模型不就行了?
还真不行。这篇文章要讲清楚一个问题:为什么Token ID不能直接喂给模型,中间还必须经过Embedding这一层?
一、Token ID的本质:一个没有意义的编号
先做一个类比。如果你做过后端开发,一定对"主键"(Primary Key)这个概念很熟悉。
在数据库里,user_id = 15 只是一个编号,它不代表这个用户比 user_id = 3 的用户"更晚注册"或者"更重要"——它纯粹就是一个身份标识,数字大小本身不携带任何业务含义。
Token ID也是一样的逻辑。假设在某个词表里:
"猫" → 3
"狗" → 4
"鱼" → 27
这里的3、4、27纯粹是词表里的位置编号。如果你直接把这几个数字喂给神经网络做计算,模型会怎么理解?它会天真地认为:3和4的数值差是1,3和27的数值差是24,所以"猫"和"狗"的关系,应该比"猫"和"鱼"的关系更紧密。
这个结论显然是错的。"猫"和"狗"语义上确实更接近(都是宠物),但这纯属巧合——如果词表构建的顺序变一下,"猫"和"鱼"编号相邻,"猫"和"狗"编号相差很远,模型的这套"数值距离"逻辑就完全失效了。
结论很明确:Token ID只是一个索引,不能直接代表语义关系,必须转换成别的东西。
二、Embedding层:给每个Token配一张"语义身份证"
这个"别的东西",就是Embedding(词嵌入)。
2.1 从数据库视角理解Embedding
继续用数据库类比:如果Token ID是主键,那Embedding就是这个主键对应的那一整行数据。
想象一张表:
| Token ID | 向量(8维示例) |
|---|---|
| 3 (猫) | [0.12, -0.45, 0.88, ..., 0.03] |
| 4 (狗) | [0.15, -0.42, 0.79, ..., 0.11] |
| 27 (鱼) | [0.91, 0.02, -0.33, ..., -0.67] |
这张表有多少行,取决于词表大小(vocab size);每一行有多少列,取决于你设定的向量维度(embedding dimension),这是一个需要自己指定的超参数,常见取值有128、256、768(GPT-2用的就是768)等。
这张表在模型刚初始化的时候,里面的数值是随机生成的,没有任何语义可言。但随着训练的推进,模型会不断调整这些数值——最终语义相近的词,它们的向量在高维空间里会彼此靠近;语义无关的词,向量会彼此远离。
这就是Embedding的核心价值:把一个孤立的、无意义的编号,转换成一个能承载语义信息、可以参与数学运算的向量。
2.2 一个常见的误解
很多科普文章会说"Embedding的值在-1到1之间",这个说法并不准确。
Embedding向量的取值范围并没有严格的数学约束,它完全取决于模型的初始化方式和训练过程。真正有明确取值边界的,是后面会讲到的位置编码(Positional Encoding)——因为位置编码用的是正弦、余弦函数,这两个函数的值域天然被限制在[-1, 1]之间。
这是两个完全不同的概念,容易被混为一谈,这里特别提醒一下。
三、代码实现:把"猫吃鱼"变成向量
理论讲完,来看PyTorch里怎么实现。
import torch
import torch.nn as nn
# 词表大小:假设我们的词表一共有30个Token
vocab_size = 30
# 向量维度:每个Token用一个8维向量来表示
# 这个数字是自己设定的超参数,实际大模型里通常是几百到几千维
embedding_dim = 8
# 创建Embedding层:本质上就是初始化一张 30行 × 8列 的随机数表
embedding_layer = nn.Embedding(vocab_size, embedding_dim)
# 上一篇分词得到的"猫吃鱼"对应的Token ID
token_ids = torch.tensor([3, 15, 27])
# 查表操作:输入Token ID,返回对应的向量
vectors = embedding_layer(token_ids)
print(vectors.shape)
# 输出: torch.Size([3, 8])
# 含义:3个Token,每个Token被表示成一个8维向量
print(vectors)
代码逐行拆解
nn.Embedding(vocab_size, embedding_dim):这一行创建的其实就是前面表格里那张"查找表"。在底层,它就是一个形状为(30, 8)的可训练参数矩阵,初始值是随机的。embedding_layer(token_ids):这一步是一次查表操作。传入三个Token ID[3, 15, 27],模型分别去表的第3行、第15行、第27行取出对应的向量,拼接成一个3 × 8的矩阵返回。输出结果:此时打印出来的向量数值是没有意义的,因为模型还没训练。这张表会在后续的训练过程中,通过反向传播不断更新,直到里面的数值真正承载起语义信息。
这里有一个很重要的认知:Embedding层本身也是模型的一部分参数,它是可以被训练、被优化的,并不是一个固定不变的查表工具。这也是为什么不同任务、不同训练数据训练出来的Embedding层,即便词表相同,里面的向量数值也完全不同。
四、Embedding解决了什么,又留下了什么问题
到这一步,"猫吃鱼"这三个字已经从字符串,变成了一个 3 × 8 的数值矩阵,模型终于可以对它做数学运算了。
但一个新问题随之出现:"猫吃鱼"和"鱼吃猫",这两句话如果分别过一遍Embedding层,得到的三个向量在数值上是完全一样的,只是排列顺序不同。
换句话说,Embedding层只知道"这句话里有猫、吃、鱼这三个Token",但完全不知道它们出现的先后顺序。而语序在语言里恰恰至关重要——"猫吃鱼"和"鱼吃猫"表达的是两件完全不同的事。
这个问题该怎么解决?答案是下一层要讲的位置编码(Positional Encoding)——给每个Token的向量再叠加一层"位置信息",让模型知道谁在前、谁在后。这部分会放在下一篇详细展开。
小结
- Token ID只是一个索引编号,数值大小不代表语义关系
- Embedding层本质是一张可训练的查找表,把每个Token ID映射成一个高维向量
- 训练过程会让语义相近的词,其向量在空间中逐渐靠拢
- Embedding的取值范围没有固定边界,这一点和位置编码不同,容易混淆
- Embedding解决了"语义表示"的问题,但丢失了"顺序信息",这是下一篇要解决的问题
下一篇,我们继续手写代码,看看位置编码是怎么把"顺序"这个信息,重新塞回向量里的。