附录E:LoRA 参数高效微调
E.1 为什么需要 LoRA
全量微调一个 7B 模型需要存:
- 模型本体:~14 GB(fp16)
- AdamW 优化器状态:~28 GB(一阶 + 二阶动量都是 fp32)
- 梯度:~14 GB
- 激活值:随 batch 和序列长度而定
合计很容易超过 60 GB,单卡 24G 显卡根本跑不动。
LoRA(Low-Rank Adaptation) 的核心观察是:微调阶段,参数的”变化量” $\Delta W$ 实际秩很低。与其直接训练 $W$,不如冻结 $W$,把 $\Delta W$ 参数化为两个小矩阵的乘积:
$$W’ = W + \Delta W = W + B A$$
其中 $W \in \mathbb{R}^{d \times k}$ 被冻结,$A \in \mathbb{R}^{r \times k}$、$B \in \mathbb{R}^{d \times r}$ 是可训练的,秩 $r \ll \min(d, k)$(典型 r=8、16)。可训练参数从 $d \cdot k$ 降到 $(d + k) \cdot r$,通常缩减 100~1000 倍。
E.2 最小实现
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
"""把 nn.Linear 包成带 LoRA 旁路的版本"""
def __init__(self, base_linear: nn.Linear, r=8, alpha=16, dropout=0.0):
super().__init__()
self.base = base_linear
for p in self.base.parameters():
p.requires_grad = False # 冻结原权重
d_in, d_out = base_linear.in_features, base_linear.out_features
self.A = nn.Parameter(torch.zeros(r, d_in))
self.B = nn.Parameter(torch.zeros(d_out, r))
nn.init.kaiming_uniform_(self.A, a=5 ** 0.5)
# B 初始化为 0,使初始 ΔW = 0,模型从原状态启动
self.scaling = alpha / r
self.drop = nn.Dropout(dropout)
def forward(self, x):
# 原前向
out = self.base(x)
# LoRA 旁路: x @ A.T @ B.T * scaling
lora_out = self.drop(x) @ self.A.T @ self.B.T
return out + lora_out * self.scaling
为什么 B 初始化为 0? 确保训练开始的瞬间 $\Delta W = 0$,模型行为完全等于原模型,loss 从一个”已知好”的起点出发,避免初期不稳定。
E.3 把 LoRA 注入到 GPT 中
通常只在注意力的 Q、V 投影上加 LoRA(K 和 FFN 也可以加,效果递减、收益递增):
def inject_lora(model, r=8, alpha=16):
for blk in model.blocks:
attn = blk.attn
attn.W_q = LoRALinear(attn.W_q, r=r, alpha=alpha)
attn.W_v = LoRALinear(attn.W_v, r=r, alpha=alpha)
return model
model = inject_lora(model, r=8, alpha=16)
# 检查可训练参数数量
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"可训练 {trainable/1e6:.2f}M / 全部 {total/1e6:.2f}M "
f"({100 * trainable / total:.2f}%)")
在 124M 模型上,r=8 时可训练参数大约只有 0.3~0.5M,相对全量微调缩减 200 倍。
E.4 训练与合并
训练流程和普通 SFT / 分类微调完全一致——优化器只看到 requires_grad=True 的参数。结束后保存:
# 只保存 LoRA 权重,体积极小
lora_state = {k: v for k, v in model.state_dict().items()
if "A" in k or "B" in k}
torch.save(lora_state, "lora_adapter.pt")
推理时有两种选择:
(a) 保留旁路(适合多任务切换):直接加载 LoRA 权重到模型上,前向时多一次小矩阵乘。
(b) 合并回原权重(适合单任务部署):
$$W_{\text{merged}} = W + \frac{\alpha}{r} B A$$
合并后推理速度与原模型完全一致,但失去切换适配器的灵活性。
@torch.no_grad()
def merge_lora(lora_linear: LoRALinear) -> nn.Linear:
delta = lora_linear.scaling * (lora_linear.B @ lora_linear.A) # (d_out, d_in)
new = nn.Linear(lora_linear.base.in_features,
lora_linear.base.out_features,
bias=lora_linear.base.bias is not None)
new.weight.copy_(lora_linear.base.weight + delta)
if lora_linear.base.bias is not None:
new.bias.copy_(lora_linear.base.bias)
return new
E.5 实践要点
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| r(秩) | 8 ~ 32 | 任务越复杂越大;r 翻倍并不总是更好 |
| alpha | 通常等于 r 或 2r | 控制 LoRA 输出的尺度 |
| 学习率 | 1e-4 ~ 5e-4 | 比全量微调大一个数量级 |
| dropout | 0 ~ 0.1 | 数据少时调高一点 |
| 应用层 | Q、V(强烈推荐);K、O、FFN(按需) | 加得越多效果越好但参数越多 |
E.6 衍生变体(仅作了解)
- QLoRA:把基础模型量化到 4-bit,再加 LoRA。能在单张 24G 卡上微调 33B 模型。
- DoRA:把权重分解为方向 + 模长,分别处理,效果略好于 LoRA。
- AdaLoRA:训练过程中动态调整每层的秩 r。
掌握标准 LoRA 后,这些变体的论文都能在一两个小时内读完并实现。