深入理解 Transformer 大语言模型
-
- 安装前置包
- 加载大语言模型
- [训练好的Transformer LLM的输入与输出](#训练好的Transformer LLM的输入与输出)
- 查看模型架构
- 从概率分布中选择单个token(采样/解码)
- 通过缓存键值来加速生成
- KV缓存机制详细解释
- [Transformer 的本质探讨](#Transformer 的本质探讨)
在机器学习中,自回归模型 特指一类使用自身早期预测结果来进行后续预测的模型。例如,文本生成式大语言模型在预测下一个词元时,会依赖之前已生成的词元序列,因此它们也被归为自回归模型。这一名称通常用于将其与BERT等非自回归的文本表示模型区分开来。
这类模型在一次前向传播中,除了其循环生成的特性,还包含两个关键内部组件:分词器 和语言建模头。
- 分词器 :其核心是一个固定的词表 。分词器首先将输入文本分解为词元序列,并转换为对应的词元ID。在模型内部,每个词元ID通过词元嵌入层被映射为一个向量表示,作为神经网络的实际输入。
- 神经网络 :主体由一系列堆叠的Transformer块构成,负责所有复杂的计算和处理。
- 语言建模头 :位于Transformer堆栈的末端。它的作用是将神经网络输出的高维向量,转换为一个针对词表中所有词元的概率分数分布,从而预测下一个最可能的词元。
安装前置包
python
# %%capture
# !pip install transformers>=4.41.2 accelerate>=0.31.0
加载大语言模型
这里我们还是以前面的phi3模型为例子
python
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
# 加载模型和分词器
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda", # 自动将模型分配到GPU
torch_dtype=torch.bfloat16 # 选择数据类型为torch.bfloat16
#torch_dtype="auto", # 自动选择数据类型(float16/fp32)
trust_remote_code=False, # 不信任远程代码(安全考虑)
)
# 创建文本生成管道
generator = pipeline(
"text-generation", # 任务类型:文本生成
model=model,
tokenizer=tokenizer,
return_full_text=False, # 只返回新生成的文本,不包括输入
max_new_tokens=50, # 最大生成长度:50个新token
do_sample=False, # 使用贪婪解码而非采样
)
代码解释:
AutoTokenizer.from_pretrained():自动加载与预训练模型匹配的分词器AutoModelForCausalLM.from_pretrained():加载因果语言模型(自回归模型)device_map="cuda":将模型加载到GPU显存中torch_dtype="auto":根据GPU能力自动选择精度(如bfloat16)pipeline():Hugging Face提供的高级API,简化推理流程
训练好的Transformer LLM的输入与输出
python
prompt = "Write an email apologizing to Sarah for the tragic gardening mishap. Explain how it happened."
output = generator(prompt)
print(output[0]['generated_text'])
代码解释:
prompt:用户提供的输入提示文本generator(prompt):使用管道生成文本output[0]['generated_text']:提取生成的结果
输出
Mention the steps you're taking to prevent it in the future.
Dear Sarah,
I hope this message finds you well. I am writing to express my sincerest apologies for the unfortunate incident that occurred
查看模型架构
python
print(model)
输出分析:
Phi3ForCausalLM(
(model): Phi3Model(
(embed_tokens): Embedding(32064, 3072, padding_idx=32000) # 嵌入层:词表大小32064,维度3072
(embed_dropout): Dropout(p=0.0, inplace=False) # 嵌入层dropout
(layers): ModuleList(
(0-31): 32 x Phi3DecoderLayer( # 32个解码器层
(self_attn): Phi3Attention( # 自注意力机制
(o_proj): Linear(in_features=3072, out_features=3072, bias=False)
(qkv_proj): Linear(in_features=3072, out_features=9216, bias=False)
(rotary_emb): Phi3RotaryEmbedding() # 旋转位置编码
)
(mlp): Phi3MLP( # 前馈神经网络
(gate_up_proj): Linear(in_features=3072, out_features=16384, bias=False)
(down_proj): Linear(in_features=8192, out_features=3072, bias=False)
(activation_fn): SiLU() # 激活函数
)
(input_layernorm): Phi3RMSNorm() # 层归一化
(resid_attn_dropout): Dropout(p=0.0, inplace=False)
(resid_mlp_dropout): Dropout(p=0.0, inplace=False)
(post_attention_layernorm): Phi3RMSNorm()
)
)
(norm): Phi3RMSNorm() # 最终层归一化
)
(lm_head): Linear(in_features=3072, out_features=32064, bias=False) # 语言模型头
)
从概率分布中选择单个token(采样/解码)
python
prompt = "中国的首都是"
# 对输入提示进行分词
input_ids = tokenizer(prompt, return_tensors="pt").input_ids
# 将输入移到GPU
input_ids = input_ids.to("cuda")
# 获取模型在lm_head之前的输出(隐藏状态)
model_output = model.model(input_ids)
# 获取lm_head的输出(logits)
lm_head_output = model.lm_head(model_output[0])
# 选择最后一个位置概率最高的token
token_id = lm_head_output[0,-1].argmax(-1)
# 解码回文本
tokenizer.decode(token_id)
代码详细解释:
-
分词过程:
python# 输入:prompt = "中国的首都是" # 分词后:input_ids = [token_id1, token_id2, ...] -
前向传播流程:
输入序列 → 嵌入层 → Transformer层 → 层归一化 → lm_head -
形状分析:
python# model_output[0].shape = torch.Size([1, 6, 3072]) # 含义:[批次大小=1, 序列长度=6个token, 隐藏维度=3072] # lm_head_output.shape = torch.Size([1, 6, 32064]) # 含义:[批次大小=1, 序列长度=6, 词表大小=32064] -
选择token:
lm_head_output[0, -1]:取最后一个位置的logits(形状:32064).argmax(-1):找到概率最高的token索引- 结果应为:'北'
通过缓存键值来加速生成
python
prompt = "Write an long email apologizing to Sarah for the tragic gardening mishap. Explain how it happened."
# 对输入提示进行分词
input_ids = tokenizer(prompt, return_tensors="pt").input_ids
input_ids = input_ids.to("cuda")
使用KV缓存的生成速度测试
python
%%timeit -n 1
# 生成文本(启用KV缓存)
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=100, # 生成100个新token
use_cache=True # 启用KV缓存(默认)
)
# 执行时间:约6.66秒
不使用KV缓存的生成速度测试
python
%%timeit -n 1
# 生成文本(禁用KV缓存)
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=100,
use_cache=False # 禁用KV缓存
)
# 执行时间:约21.9秒(慢3倍多!)
KV缓存机制详细解释
为什么需要KV缓存?
在自回归生成中(一次生成一个token),每次生成新token时,模型需要重新处理整个历史序列。如果没有缓存:
- 第一次生成:处理 N 个token
- 第二次生成:重新处理 N+1 个token
- 第三次生成:重新处理 N+2 个token
- ... 如此类推
时间复杂度:O(n²),其中n是总序列长度
KV缓存如何工作?
-
自注意力机制的关键值(KV):
- 每个注意力头计算:Q(查询)、K(键)、V(值)
- K和V只依赖于输入的嵌入和位置编码,与要生成的内容无关
-
缓存机制:
python# 第一次生成(处理整个提示) 输入: "法国的首都是" 计算: K1, V1, K2, V2, K3, V3, K4, V4, K5, V5, K6, V6 生成: "巴黎" # 第二次生成(只计算新token) 输入: "巴黎" 使用缓存: K1-V6(已经计算过) 只计算: K7, V7(新token的键值) 生成: "。" -
内存与计算权衡:
- 内存开销:需要存储所有历史token的K和V
- 计算节省:避免重复计算历史token的注意力
技术实现细节
python
# 在transformers库中的实现
class Phi3Attention(nn.Module):
def forward(self, hidden_states, past_key_values=None, use_cache=False):
# 如果使用缓存且past_key_values不为None
if use_cache and past_key_values is not None:
# 只计算当前新token的Q
# 从缓存中获取历史的K、V
# 拼接当前K、V到缓存中
pass
# 返回当前的注意力输出和更新后的缓存
return attention_output, new_key_values
性能对比分析
| 方法 | 时间 | 速度提升 | 内存使用 |
|---|---|---|---|
| 无缓存 | 21.9秒 | 1x | 较低 |
| 有缓存 | 6.66秒 | 3.3x | 较高 |
实际应用建议
- 长文本生成必须使用缓存:对于对话、文章生成等场景
- 短文本可不使用:对于单次分类、情感分析等
- 注意内存限制:缓存会占用显存,需平衡序列长度和批次大小
- 滑动窗口注意力:现代模型(如Phi-3)可能使用滑动窗口注意力,只缓存最近的部分token
Transformer 的本质探讨
参数共享范围
不共用,但有层次化的共享关系
共享的层次结构
1. 单个注意力头内:位置间共享
python
# 假设一个注意力头
W_Q = [d_model × d_k] # 所有位置的词共享这个Q投影矩阵
W_K = [d_model × d_k] # 所有位置的词共享这个K投影矩阵
W_V = [d_model × d_v] # 所有位置的词共享这个V投影矩阵
# 对于输入序列X[n × d_model],每个位置i:
Q_i = X[i] @ W_Q # 所有位置用同一个W_Q
K_i = X[i] @ W_K # 所有位置用同一个W_K
V_i = X[i] @ W_V # 所有位置用同一个W_V
在同一个注意力头内,所有位置(所有词)共用同一组W_Q、W_K、W_V矩阵。
2. 不同注意力头间:不共享
python
# 假设8个注意力头(h=8)
W_Q_1, W_K_1, W_V_1 # 头1的投影矩阵
W_Q_2, W_K_2, W_V_2 # 头2的投影矩阵
...
W_Q_8, W_K_8, W_V_8 # 头8的投影矩阵
每个注意力头有自己独立的W_Q、W_K、W_V矩阵。
3. 不同网络层间:不共享
python
# 假设Transformer有6层
Layer1: W_Q_1, W_K_1, W_V_1 # 第1层的所有头的矩阵
Layer2: W_Q_2, W_K_2, W_V_2 # 第2层的所有头的矩阵
...
Layer6: W_Q_6, W_K_6, W_V_6 # 第6层的所有头的矩阵
每一层Transformer都有自己的全套投影矩阵。
| 共享范围 | 是否共享 | 比喻 | 目的 |
|---|---|---|---|
| 同一头内,不同位置 | ✅ 共享 | 同一部门的所有员工用相同工作模板 | 参数效率、泛化 |
| 同一层内,不同头 | ❌ 不共享 | 不同部门有不同专业视角 | 多角度理解 |
| 不同层之间 | ❌ 不共享 | 不同管理层级有不同关注点 | 层次化抽象 |
整个模型不共用一套QKV矩阵,而是按照"层→头→位置"的层次结构进行合理分配,平衡了表达能力和参数效率!
每个词都会生成自己的Q、K、V
假设句子是:"我 爱 吃 苹果"(4个token)
python
输入嵌入向量:
X = [x1, x2, x3, x4] # 每个x_i都是d_model维向量
我 爱 吃 苹果
# 通过线性变换得到Q、K、V
Q = X @ W_Q # [4, d_k] 每个词都有自己的Q向量
K = X @ W_K # [4, d_k] 每个词都有自己的K向量
V = X @ W_V # [4, d_v] 每个词都有自己的V向量
# 结果:
Q = [q1, q2, q3, q4] # q1是"我"的查询向量,q2是"爱"的查询向量...
K = [k1, k2, k3, k4] # 每个词的键向量
V = [v1, v2, v3, v4] # 每个词的值向量
以"吃"这个词(第3个位置)为例:
计算"吃"的新表示时:
用q3(吃的Q)去匹配所有词的K
匹配度1 = q3·k1 # 吃与我相关吗?
匹配度2 = q3·k2 # 吃与爱相关吗?
匹配度3 = q3·k3 # 吃与吃自身相关吗?(自注意力)
匹配度4 = q3·k4 # 吃与苹果相关吗?这个得分会很高!
将匹配度softmax得到权重
权重 = [0.05, 0.02, 0.08, 0.85] # 假设值
加权求和所有词的V
新_吃 = 0.05v1 + 0.02 v2 + 0.08v3 + 0.85 v4
"吃"现在包含了"苹果"的信息,理解了"吃苹果"这个搭配
并行计算
在实际实现中,不是循环处理每个词,而是用矩阵一次算完:
python
# 1. 计算注意力分数矩阵
scores = Q @ K.T # [4,4]矩阵,每个元素是Q_i和K_j的点积
# scores[i,j] = 词i查询与词j键的匹配度
# 2. 计算注意力权重
attn_weights = softmax(scores / sqrt(d_k)) # [4,4]
# 每一行是一个词对所有词的注意力权重
# 3. 计算输出
output = attn_weights @ V # [4, d_v]
# output[i] = 词i的新表示,是所有词V的加权和
输出
output = [
新表示_我, # 基于[我,爱,吃,苹果]的信息
新表示_爱, # 基于[我,爱,吃,苹果]的信息
新表示_吃, # 基于[我,爱,吃,苹果]的信息
新表示_苹果 # 基于[我,爱,吃,苹果]的信息
]
三个容易混淆的概念
词嵌入矩阵(Embedding Matrix)、QKV投影矩阵(Projection Matrices)和QKV计算结果(QKV Vectors)这三个概念非常容易混淆,需要正确理解这三者的关系
词嵌入矩阵(Embedding Matrix)
python
W_embed = [vocab_size, d_model]
# 例如:[30000, 512] # 这是与词表大小相关的唯一矩阵
# 可训练参数:30000 × 512 = 15,360,000
QKV投影矩阵(Projection Matrices)
python
# 单个注意力头:
W_Q = [d_model, d_k] # [512, 64]
W_K = [d_model, d_k] # [512, 64]
W_V = [d_model, d_v] # [512, 64]
W_O = [d_v, d_model] # [64, 512] # 输出投影
# 总参数量:4 × 512 × 64 = 131,072(单个头)
模型隐藏维度
d_model就是词嵌入向量的大小,即d_model= 嵌入维度.d_model(模型隐藏维度):决定了输入的维度和模型的容量
num_heads(注意力头数):决定了多头注意力的并行度
设计选择d_k/d_v:通常等于d_model/num_heads
QKV计算结果(QKV Vectors)
python
# 输入序列长度=4,批量大小=1
Q = [batch=1, seq_len=4, d_k=64] # [1, 4, 64]
K = [batch=1, seq_len=4, d_k=64] # [1, 4, 64]
V = [batch=1, seq_len=4, d_v=64] # [1, 4, 64]
# 注意:这个形状与词表大小(30000)完全无关!