从零构建LLM 附录E:LoRA 低秩适配
1. LoRA 的核心思想
全量微调 (Full Fine-tuning) 的问题很直接:124M 参数的 GPT-2 Small 还好,但 7B、70B 的模型呢?微调所有参数需要巨大的显存和计算资源。
LoRA 的洞察是:微调时的权重变化量 ΔW 是低秩的 — 也就是说,虽然 ΔW 是一个大矩阵,但它的「有效维度」远小于它的实际维度。既然如此,就可以用两个小矩阵的乘积来近似它。
原来需要更新的权重:Wupdated = W + ΔW(其中 ΔW 是 d×d 的矩阵)
LoRA 的做法:Wupdated = W + A · B(其中 A 是 d×r,B 是 r×d,r « d)
ℹ️ 参数量对比:一个 768×768 的权重矩阵有 589,824 个参数。如果 rank=8,LoRA 只需要 A(768×8) + B(8×768) = 12,288 个参数 — 仅为原来的 2%。而且原始权重 W 完全冻结,不需要计算梯度,显存开销大幅降低。
2. 低秩分解:用两个小矩阵代替一个大矩阵
LoRA 的初始化和缩放有讲究:
- 矩阵 A:用 Kaiming 均匀初始化(保证初始输出有合理方差)
- 矩阵 B:全零初始化 — 这确保训练开始时 A·B = 0,LoRA 的旁路不会干扰原始模型
- 缩放因子:
alpha / rank,用来控制 LoRA 更新的幅度。alpha 通常等于 rank
计算过程:output = W·x + (alpha/rank) · A·B·x。原始线性层的前向传播不变,LoRA 只是额外加了一个低秩修正项。
3. 代码实现
书中将 LoRA 实现为三个组件:LoRALayer(核心低秩层)、LinearWithLoRA(包装器)、replace_linear_with_lora(全局替换函数)。
class LoRALayer(nn.Module):
def __init__(self, in_dim, out_dim, rank, alpha):
super().__init__()
# A: Kaiming 初始化(保证方差合理)
self.A = nn.Parameter(torch.empty(in_dim, rank))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
# B: 零初始化(确保初始 LoRA 输出为零)
self.B = nn.Parameter(torch.zeros(rank, out_dim))
self.alpha = alpha
def forward(self, x):
# 低秩修正:x → A → B,缩放后加到原始输出
return self.alpha * (x @ self.A @ self.B)
class LinearWithLoRA(nn.Module):
def __init__(self, linear, rank, alpha):
super().__init__()
self.linear = linear # 原始线性层(冻结)
self.lora = LoRALayer(
linear.in_features, linear.out_features, rank, alpha
)
def forward(self, x):
# 原始输出 + LoRA 修正
return self.linear(x) + self.lora(x)
有了这两个类,接下来只需要一个函数遍历模型,将所有 nn.Linear 替换为 LinearWithLoRA:
def replace_linear_with_lora(model, rank, alpha):
"""递归遍历模型,将所有 nn.Linear 替换为 LinearWithLoRA"""
for name, module in model.named_children():
if isinstance(module, nn.Linear):
setattr(model, name,
LinearWithLoRA(module, rank, alpha))
else:
# 递归处理子模块
replace_linear_with_lora(module, rank, alpha)
# 使用:先冻结原模型所有参数,再注入 LoRA
for param in model.parameters():
param.requires_grad = False
replace_linear_with_lora(model, rank=8, alpha=8)
# 只有 LoRA 参数是可训练的
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters()
if p.requires_grad)
print(f"Total: {total_params:,}") # 124,441,346
print(f"Trainable: {trainable_params:,}") # 2,666,528 (2.1%)
print(f"Ratio: {trainable_params/total_params:.1%}")
4. 应用到 GPT 分类微调
书中在第6章的垃圾邮件分类任务上对比了三种微调策略:
| 方法 | 可训练参数 | 训练准确率 | 测试准确率 |
|---|---|---|---|
| 全量微调 (Ch6, 全部解冻) | 124.4M (100%) | 99.52% | 95.67% |
| 冻结 + 换头 (Ch6, 只解冻最后层) | ~7.4M (~6%) | 97.21% | 95.67% |
| LoRA (rank=8) | 2.7M (2.1%) | 99.81% | 96.67% |
结果令人印象深刻:LoRA 用仅 2.1% 的可训练参数,不仅匹配了全量微调的测试准确率 (96.67% vs 95.67%),训练准确率还更高 (99.81%)。这证明了微调时的权重变化确实是低秩的 — 不需要更新所有参数就能达到优异效果。
⚠️ LoRA 推理时可以「合并」:训练完成后,可以将 LoRA 权重合并回原始权重:W_merged = W + alpha * A @ B。合并后的模型结构和原始模型完全一样,推理速度零开销。这意味着可以为不同任务训练不同的 LoRA 适配器,按需合并,不增加推理成本。
5. 为什么这很重要
LoRA 是目前最主流的大模型微调技术之一。以下是它为什么如此重要:
- 显存效率:全量微调需要存储所有参数的梯度和优化器状态。对于 7B 模型,这意味着约 56GB 显存。LoRA 只需要为 2% 的参数存储这些状态,显存需求降低到约 16GB — 一张消费级 GPU 就能搞定。
- 多任务部署:可以为同一个基座模型训练多个 LoRA 适配器(翻译、摘要、代码生成等),每个只有几十 MB。推理时动态加载不同的适配器就能切换任务,无需部署多个完整模型。
- QLoRA:LoRA 可以和量化 (Quantization) 结合使用。QLoRA 将基座模型量化到 4-bit,LoRA 适配器保持 16-bit,进一步将 7B 模型的微调显存降到约 6GB。
- rank 的选择:rank 越高,LoRA 的表达能力越强但参数越多。书中用 rank=8 就达到了优异效果。实际使用中 rank=4~32 是常见范围。对于复杂任务(如代码生成),可能需要更高的 rank。
- Hugging Face PEFT:实际工作中通常使用 Hugging Face 的 PEFT 库,它提供了 LoRA 的标准实现和更多变体(如 AdaLoRA、IA3 等),无需从头实现。
ℹ️ 总结 — LoRA 的工作流程:加载预训练模型 → 冻结所有原始权重 → 为每个线性层注入 LoRALayer (A·B 旁路) → 只训练 LoRA 参数 (约 2% 的参数量) → 训练完成后可选择合并回原始权重(推理零开销)。LoRA 的成功验证了一个深刻的直觉:大模型微调时的参数变化存在于一个低维子空间中,不需要「全面修改」就能实现任务适配。
本文是 Build a Large Language Model From Scratch (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。