附录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比全量微调大一个数量级
dropout0 ~ 0.1数据少时调高一点
应用层Q、V(强烈推荐);K、O、FFN(按需)加得越多效果越好但参数越多

E.6 衍生变体(仅作了解)

  • QLoRA:把基础模型量化到 4-bit,再加 LoRA。能在单张 24G 卡上微调 33B 模型。
  • DoRA:把权重分解为方向 + 模长,分别处理,效果略好于 LoRA。
  • AdaLoRA:训练过程中动态调整每层的秩 r。

掌握标准 LoRA 后,这些变体的论文都能在一两个小时内读完并实现。


← 附录 D · 返回目录