模块 10 · 第 2 课

LoRA 的原理,手写一遍

微调大模型,要不要把几十亿个参数全训练一遍?LoRA 冻结原来的参数,只在旁边加两个小矩阵。在第 09 模块的小 GPT 上手写 LoRA,只训练 1.5% 的参数就改了它的风格。

  • 约 45 分钟
  • 难度:深入
  • 实测:2026-09-15 torch 2.14,Apple M4 CPU,固定随机种子

上一课说,确实需要微调时,首选 LoRA。这一课弄明白它是什么,并在第 09 模块训练好的小 GPT 上亲手实现一遍。

python lora_from_scratch.py

全量微调的麻烦

微调,就是在训练好的模型上用新的数据接着训练。最直接的做法是所有参数都参与训练,叫全量微调

对大模型来说,这很贵。训练时,每个参数除了它本身,还要存它的梯度,AdamW 优化器还要为每个参数存两个额外的数(第 08 模块第 5 课提到过,它记录每个参数的"动量")。一个 70 亿参数的模型,光这些就要上百 GB 的显存。

而且每微调一次,就得到一份完整的新模型。为十个客户各微调一个版本,就要存十份几十 GB 的文件。

LoRA 的想法

LoRA(Low-Rank Adaptation,低秩适配)的想法是:微调时,权重需要的改变量其实不需要那么"复杂"。

一个线性层的权重是一个矩阵 W,比如 128×384。全量微调就是学一个同样大小的改变量 ΔW,加到 W 上。LoRA 不直接学 ΔW,而是把它拆成两个很瘦的矩阵相乘:

      ΔW       =       A       ×     B
  (128 × 384)      (128 × 8)     (8 × 384)

  49152 个数       1024 个数   +  3072 个数  =  4096 个数

中间那个 8 叫(rank,r)。A 和 B 加起来只有 4096 个数,是 ΔW 的十二分之一。r 越小,要学的参数越少,但能表达的改变也越有限。

训练时,原来的 W 冻结不动,只训练 A 和 B:

输出 = x × W  +  x × A × B × 缩放系数
       ─────     ─────────────────────
      原来的路        新加的支路

实现

包住一个训练好的 nn.Linear,只要十几行:

class LoRALinear(nn.Module):
    """包住一个已经训练好的线性层:原来的权重冻结不动,旁边加一条 A、B 两个小矩阵的"支路"。"""

    def __init__(self, base: nn.Linear, rank=8, alpha=16):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad = False  # 原来的参数一个都不训练
        self.A = nn.Parameter(torch.randn(base.in_features, rank) * 0.01)  # 输入维度 × r
        self.B = nn.Parameter(torch.zeros(rank, base.out_features))  # r × 输出维度,初始为 0
        self.scale = alpha / rank

    def forward(self, x):
        # 原来的输出 + 支路的输出。B 一开始是 0,所以刚加上时模型的行为和原来完全一样
        return self.base(x) + (x @ self.A @ self.B) * self.scale

有两个细节。

B 初始化为 0。这样一开始 A×B 是 0,支路没有任何作用,模型和原来一模一样。训练从原模型出发,而不是从一个被随机支路打乱的模型出发。A 不能也是 0,否则 A 和 B 的梯度都是 0,永远学不动。

缩放系数 alpha / r。它控制支路的影响有多大。这样调整 r 时,不必重新调学习率。r=8、alpha=16 是很常见的一组设置。

然后给模型里每个注意力层的 qkvproj 都换上 LoRA:

for p in model.parameters():
    p.requires_grad = False
for block in model.blocks:
    block.attn.qkv = LoRALinear(block.attn.qkv)
    block.attn.proj = LoRALinear(block.attn.proj)

任务:让它专写五言绝句

第 09 模块训练的小 GPT,什么格式的诗都写。现在只用五言绝句微调它,看它能不能改成专写五言绝句。

原来的训练数据里,五言绝句占 10.4%(3538 首)
微调前:生成 200 首,其中五言绝句 67 首

模型共 1,639,296 个参数,要训练的只有 24,576 个(1.50%)
刚装上 LoRA 时,输出和原模型一样吗?True

微调前,200 首里有 67 首是五言绝句。这比训练数据里的 10.4% 高不少,可能是因为五言绝句最短、最容易写完整。

装上 LoRA 后,要训练的只有 24576 个参数,是整个模型的 1.5%。4 层,每层两个 LoRA:qkv 的 A、B 是 128×8 和 8×384,proj 的是 128×8 和 8×128,一层 6144 个,4 层 24576 个。

"刚装上 LoRA 时输出一样吗?True",验证了 B 初始化为 0 的效果。

训练

只用五言绝句作为数据,训练 300 步:

  第 1 步  损失 4.356
  第 100 步  损失 4.294
  第 200 步  损失 4.335
  第 300 步  损失 4.213
训练 300 步用了 23 秒

微调后:生成 200 首,其中五言绝句 191 首。前 5 首:
  白头还作雨,一鬓自如霜。何时之饮在,试死势悠扬。
  白发勤鸿急,秋贫梦不闲。夜寒休绕郡,秋杀杜陵陂。
  才子俭高人,何言亦重重。言知不见人,辄空一一言。
  一丛生碧霄,危棹度清湍。沙渚穿花发,苍苍隔鹤闲。
  九陌两金阁,一枝归草间。何当重枕簟,归复独裴回。

200 首里有 191 首是五言绝句,从 33.5% 提高到了 95.5%。只动了 1.5% 的参数,训练了 23 秒。

有意思的是损失几乎没变,从 4.36 到 4.21。这说明模型写诗的"水平"(预测每个字的准确程度)没有提高,变的是它的"习惯":写完四句五言之后,它学会了接着输出换行,结束这首诗。LoRA 擅长的就是这类改变:调整格式、风格、行为,而不是教给模型大量新的知识。上一课说"微调改的是行为,知识靠 RAG",这里看得很清楚。

保存和合并

LoRA 的权重单独存下来只有 96 KB,整个模型是 6.3 MB
把 LoRA 合并回原来的权重之后,模型又变回 1,614,720 个参数,输出和合并前最大差别 5.7e-06

LoRA 的第一个好处:只需要存 A 和 B。这里是 96 KB,模型本身是 6.3 MB。在大模型上,这个差距更加悬殊:几十 GB 的模型,LoRA 权重可能只有几十 MB。为十个客户各微调一次,就是一份基座模型加十个小文件。

第二个好处:可以合并。训练完成后,把 A×B×缩放系数直接加到原来的 W 上,就得到一个普通的线性层:

def merged(self):
    """把支路合并回原来的权重,得到一个普通的线性层:推理时没有任何额外开销。"""
    layer = nn.Linear(self.base.in_features, self.base.out_features)
    with torch.no_grad():
        # nn.Linear 的权重形状是 (输出, 输入),所以要转置
        layer.weight.copy_(self.base.weight + (self.A @ self.B).T * self.scale)
        layer.bias.copy_(self.base.bias)
    return layer

合并后,模型的结构和参数量都和原来完全一样,推理时不会变慢。输出和合并前的差别是 5.7e-06,只是浮点数的误差。

LoRA 为什么有效

一个直觉上的解释:预训练的模型已经学会了大部分东西,微调只是在上面做一些"小调整"。2021 年提出 LoRA 的论文(Hu 等,《LoRA: Low-Rank Adaptation of Large Language Models》)的出发点就是:这种调整所需的改变量,本身是"低秩"的,用很小的 r 就能表达。

我们的实验也符合这个解释:让模型"写完四句就结束",是一个很简单的行为改变,1.5% 的参数完全够用。但如果想教给模型大量全新的知识,比如一整个新领域的内容,LoRA 的效果就不一定比得上全量微调了。

LoRA 还有一个常用的变种叫 QLoRA:把冻结的原模型量化成 4 比特(第 4 课会讲量化)来省显存,LoRA 的 A、B 仍然用正常的精度训练。这让在一张消费级显卡上微调几十亿参数的模型成为可能。

练习

  1. rank 改成 1、2、32,微调后五言绝句的比例分别是多少?参数量分别是多少?
  2. 不用 LoRA,直接全量微调(所有参数都训练,学习率改成 1e-4),同样训练 300 步,比较五言绝句的比例和训练时间。
  3. 只给前馈网络加 LoRA(block.mlp[0]block.mlp[2]),不给注意力加,效果有什么不同?

自测

1. LoRA 训练的是哪些参数?原来的参数怎么处理?

原来的权重全部冻结,不参与训练。LoRA 在选定的线性层旁边加两个小矩阵 A 和 B,只训练它们。输出等于原来的输出加上 x×A×B×缩放系数。

2. 为什么 B 要初始化为 0?A 能不能也初始化为 0?

B 为 0 时 A×B 为 0,刚装上 LoRA 的模型和原模型完全一样,训练从原模型出发。A 不能也为 0:如果 A 和 B 都是 0,两者的梯度也都是 0,参数永远不会更新。

3. LoRA 训练完之后,推理时会变慢吗?

可以不变慢。训练完后把 A×B×缩放系数加到原来的权重上合并,得到的模型结构和参数量都和原模型一样,推理的速度也一样。不合并时多了一条支路的计算,会稍慢一点,但好处是可以随时换用不同的 LoRA。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…