轻量微调与 LoRA:在小显存上用大模型

这一篇聚焦在“模型已经很大了,如何在有限资源下适配具体任务”:从最朴素的全参微调,到 LoRA、Prefix-Tuning 这类轻量方案的直觉和工程用法。

最直接的做法是:把预训练 Transformer 当普通网络,所有参数都参与更新

好处:

  • 表达能力完全打开,下游任务如果数据足够多,效果往往最好;
  • 理论上没有“适配瓶颈”,能充分利用预训练表示。

代价:

  • 显存压力极大:不仅要存下所有参数,还要存优化器状态(如 Adam 的一阶/二阶矩);
  • 训练成本高:每个 step 的前向/反向都要对全模型做一遍。

在大多数“纯应用”场景下,全参微调已经不现实,需要更轻量的方式。

最朴素的轻量微调思路是:

  • 冻结大部分 Transformer 层,只在顶部加一个小头(或几层),只更新这些新加的参数

这在 BERT 时代非常常见,例如:

  • 用预训练 Encoder 编码句子;
  • 在上面接一个两层 MLP 做分类,只更新 MLP 的参数。

好处:

  • 显存和训练成本都很低;
  • 实现简单,不需要改动主干结构。

不足:

  • 对某些和预训练分布差别较大的任务,可能“不够解锁”,表现有限;
  • 主干参数完全冻结,适配能力靠顶部几层,有时会感觉“瓶颈明显”。

LoRA(Low-Rank Adaptation)的关键想法是:

  • 不直接改原始权重矩阵 $W \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}}$;
  • 而是在它旁边加一个低秩矩阵 $BA$,只训练 $A, B$:
$$ W' = W + BA $$

其中:

  • $A \in \mathbb{R}^{r \times d_{\text{in}}}$
  • $B \in \mathbb{R}^{d_{\text{out}} \times r}$
  • $r$ 很小(比如 4, 8, 16),远小于 $d_{\text{in}}, d_{\text{out}}$。

这样一来:

  • 原始大矩阵 $W$ 在微调过程中保持冻结
  • 只新增一个秩为 $r$ 的“补丁” $BA$,参数量大幅减少。

工程上常见的做法是只对某些关键权重应用 LoRA,比如:

  • 注意力里的 $W_Q, W_K, W_V, W_O$;
  • 或者只对 $W_Q, W_V$ 做 LoRA。

抽象一下,一个原始线性层是:

1class Linear(nn.Module):
2  def __init__(self, in_features, out_features):
3    super().__init__()
4    self.weight = nn.Parameter(torch.empty(out_features, in_features))
5
6  def forward(self, x):
7    return x @ self.weight.T

LoRA 版的大致形态可以是:

 1class LoRALinear(nn.Module):
 2  def __init__(self, in_features, out_features, r=8, alpha=1.0):
 3    super().__init__()
 4    self.weight = nn.Parameter(torch.empty(out_features, in_features), requires_grad=False)
 5    self.A = nn.Parameter(torch.empty(r, in_features))
 6    self.B = nn.Parameter(torch.empty(out_features, r))
 7    self.scaling = alpha / r
 8
 9  def forward(self, x):
10    base = x @ self.weight.T                 # 冻结的原始权重
11    lora = (x @ self.A.T) @ self.B.T * self.scaling
12    return base + lora

实际实现会更复杂一些(比如支持 merge/unmerge、不同 rank、bias 等),但直觉就是:让“调整空间”局限在一个低秩子空间里

另一类轻量方法(Prefix-Tuning、Prompt-Tuning)则是:

  • 不改模型权重;
  • 在输入序列前面加一段“可学习的前缀”或者“可学习的 embedding”。

例如,在 Decoder-only 模型里,可以:

  • 为每一层的 Self-Attention 引入若干“虚拟 token”的 K/V,作为“前缀”;
  • 这段前缀的向量是可训练参数,下游任务只更新这些前缀向量。

直觉上,相当于:

  • 给不同任务各自分配一段“任务专属提示”,这些提示在训练过程中被学出来;
  • 主干模型保持不动,只通过不同提示来“切换行为模式”。

这类方法的优势在于:

  • 不需要改动模型结构;
  • 可以为不同任务保存不同的前缀/提示参数,非常适合多任务场景。

在很多实际工程里,会把多种轻量方案组合使用,比如:

  • 主干 Attention/FFN 层上加 LoRA;
  • 顶部再加一个小的任务头,只更新头部 + LoRA 参数。

这种组合有几个好处:

  • 适配空间足够大,下游任务效果通常接近全参微调;
  • 新增参数量仍然远小于全参,方便在多任务、多版本之间切换;
  • 便于“热插拔”:可以按需加载/卸载某个任务的 LoRA 权重。

可以按资源约束和任务数量来大致划分:

  • 显存/算力富裕 + 单一关键任务:全参微调仍然是一个选项;
  • 中等资源 + 任务相对简单:冻结主干 + 头部微调就够用;
  • 资源紧张 + 多任务 / 多版本切换:LoRA / Prefix-Tuning 一类轻量方案性价比更高。

对日常工程来说,掌握 LoRA 这一类方法,基本就能把“把大模型迁到某个具体业务上”这件事变得可操作,而不是只能停留在“只能调用通用 API”这一层。