LoRA(Low-Rank Adaptation)是 2021 年由微软研究院提出的参数高效微调方法。它冻结预训练权重,只往每一层注入一对可训练的小矩阵,在 GPT-3 175B 上把可训练参数压缩到原来的万分之一、GPU 显存降到原来的三分之一,且推理时无额外延迟。下面拆开它的核心结构、为什么”低秩就够用”,以及一段能直接跑起来的最小实现。
一、LoRA 在做什么
全量微调要把模型里所有权重都更新一遍——对一个 7B 模型来说,光是 Adam 优化器状态(动量 + 方差)就要额外占 2× 参数量的内存。LoRA 的解决思路是:把每层权重更新量 ΔW 拆成两个低秩矩阵相乘 B·A,B ∈ ℝ^(d×r),A ∈ ℝ^(r×k),r 远小于 d 和 k。
| 维度 | 全量微调 | LoRA | QLoRA |
|---|---|---|---|
| 可训练参数 | 100% | 约 0.01%–1% | 约 0.01%–1% |
| 基础模型精度 | FP16/BF16 | FP16/BF16 | 4-bit(NF4) |
| 推理延迟 | 基准 | 零开销(可合并) | 零开销 |
| 7B 显存参考 | 60–80 GB | 15–20 GB | 6–10 GB |
| 适合场景 | 数据极充足、追求极限 | 通用微调 | 消费级显卡 |
在 Hugging Face PEFT 库里,LoRA 是默认推荐路径之一;在 LLaMA、Qwen、ChatGLM 等开源模型的社区微调中,几乎所有公开脚本都建立在 LoRA 或 QLoRA 之上。
从工程视角看 LoRA 与全量微调的差异:全量微调是”我整张桌子都换”,LoRA 是”在桌子腿上贴两块小铁片”。换桌子的代价是存一张完整权重的副本、做一次完整反向;贴铁片的代价是只存两个几十兆的矩阵、只对它们做反向。两种方式输出的桌子外观可以做到几乎一致,但生产环境对回滚、成本、迭代速度的要求,通常把天平推向 LoRA 一侧。
二、为什么”低秩”能近似全量微调
LoRA 的论文基于一个关键观察:大模型在做下游任务适配时,权重变化 ΔW 的”内在秩”非常低。换句话说,真正有效的方向只有几个,绝大部分维度上的更新接近 0。把 ΔW 限制在 r 维子空间里训练,既限制了搜索范围,又因为这个范围恰好覆盖了真实需要的方向,效果就不会掉。
工程上的直接好处是:
- 优化器状态只对 B、A 维护,内存开销按 r 而非 d 缩放;
- 同一份基础模型可以挂很多份 LoRA 适配器,任务切换只需替换小矩阵;
- 训练完之后把 B·A 加回原权重 W₀,推理路径完全不变,无额外算子。
多任务部署的实战:一家做内容审核的团队在同一份 7B 基座上挂了 12 个 LoRA 适配器,按业务线分发请求,显存只比单适配器多 2 GB——这是全量微调无论如何做不到的资源密度。把适配器目录当作可插拔的”业务插件”,是新架构下值得养成的工程习惯。
下面这段代码是 LoRA 的最小 PyTorch 实现,展示了”冻结 + 低秩旁路”这一核心动作。
从原理到落地,按下面四步把 LoRA 跑通,基本不会偏:
- 选基座:在 Hugging Face 上找一个 7B 量级的开源模型(如 Qwen2.5-7B、Llama-3.1-8B),确认已支持 trustremotecode;
- 冻结主体:用 requires_grad = False 把所有原始参数锁住,只保留 LoRA 旁路的 A、B 两个矩阵可训练;
- 选 rank 与 alpha:从 r=8、alpha=16 起步,验证集不再涨时再考虑 r=16 或 r=32;
- 跑通后合并:训练结束用 peft 的 mergeandunload 把 B·A 加回 W₀,得到一个与原模型推理路径完全一致的产物。
三、十分钟能跑通的最小实现
下面这段代码解决”如何把一个普通 nn.Linear 改造成带 LoRA 旁路的层”。准备好 PyTorch 后,直接复制运行即可。
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16):
super().__init__()
# 冻结基础权重,只让它跑前向,不更新
self.base = base
for p in self.base.parameters():
p.requires_grad = False
d_out, d_in = base.weight.shape
# A 用随机高斯初始化,B 用零初始化 -> 训练起点 ΔW = 0
self.lora_A = nn.Parameter(torch.randn(r, d_in) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(d_out, r))
self.scale = alpha / r # 控制 LoRA 步长的缩放因子
def forward(self, x):
base_out = self.base(x)
lora_out = (x @ self.lora_A.T) @ self.lora_B.T
return base_out + self.scale * lora_out
# 替换一个真实模型的注意力投影
layer = nn.Linear(768, 768)
lora_layer = LoRALinear(layer, r=8, alpha=16)
x = torch.randn(2, 768)
print(lora_layer(x).shape) # torch.Size([2, 768])
注意三处容易踩的坑:第一,基础层必须冻结,否则优化器会把 W₀ 也更新掉,等价于全量微调;第二,α/r 这个缩放因子别乱调,论文里 α 常用 2r,直接把 scale 锁死能让不同 rank 的实验更可比;第三,B 一定要零初始化,否则第一步就偏离预训练分布,会引入不必要的训练震荡。
到这里 LoRA 的”是什么、为什么有用、怎么用”就完整了。下一篇文章里,会进一步展开低秩分解背后的数学依据,以及它为什么能在多种任务上逼近全量微调。
常见问题(FAQ)
Q1:LoRA 训练完如何合并到原模型?
把 B·A 按 α/r 缩放后加到 base.weight 上,后续推理用普通 Linear 即可,无额外算子。
Q2:LoRA 选 rank 多少合适?
简单任务 4–8,代码生成 16–32,低资源机器翻译可到 64;先小后大,看验证集效果。
Q3:LoRA 效果真的能追平全量微调吗?
在多数文本任务上结论是”持平或略好”,但在数据极多、需深度改写分布的场景仍不及全量。