Skip to content

13.2 LoRA 实践

上一节介绍了多种参数高效微调方法的设计思想,其中 LoRA(Low-Rank Adaptation) 凭借"训练时高效、推理时无损"的特性,已经成为大模型微调的事实标准。本节将从零开始,用纯 PyTorch 代码实现一个完整的 LoRA 层,然后逐步演示如何将它注入到 Transformer 模型中、如何管理权重的保存与加载,以及如何在同一基模型上管理多个 Adapter 以支持不同任务的灵活切换。


13.2.1 从零实现 LoRA 层

回顾 LoRA 的核心公式。对于一个预训练线性层 y=W0x,LoRA 冻结原始权重 W0,额外引入两个低秩矩阵 AB 来近似权重的增量更新:

y=W0x+αrBAx

其中 ARr×dinBRdout×r,秩 rmin(din,dout)α 是缩放超参数(scaling factor),控制低秩更新对原始输出的影响幅度。可训练参数量从 din×dout 骤降为 r(din+dout),通常可压缩万倍以上。

LoRA 核心概念:全量微调 vs LoRA 低秩分解对比

图 13-3:全量微调直接更新整个权重矩阵 ΔW,而 LoRA 用两个小矩阵 AB 的乘积来近似 ΔW,训练参数量大幅减少。

初始化策略是 LoRA 训练成功的关键。 标准做法是将 A 采用 Kaiming 均匀初始化(或高斯初始化),而将 B 全零初始化。这样做的目的是保证训练的第一步时 BA=0,使得模型输出 y=W0x+0=W0x,与预训练模型完全一致。如果初始的 BA 不为零,低秩更新会立刻引入随机噪声,可能破坏预训练表征,导致训练难以收敛。

下面用 PyTorch 从零实现 LoRA 层:

python
import math
import torch
import torch.nn as nn

class LoRALayer(nn.Module):
    """低秩自适应层:学习一对低秩矩阵 A 和 B 来近似权重增量。"""
    def __init__(self, in_dim, out_dim, rank, alpha):
        super().__init__()
        # A: (in_dim, rank),Kaiming 均匀初始化
        self.A = nn.Parameter(torch.empty(in_dim, rank))
        nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
        # B: (rank, out_dim),全零初始化 → 保证初始 BA = 0
        self.B = nn.Parameter(torch.zeros(rank, out_dim))
        self.alpha = alpha
        self.rank = rank

    def forward(self, x):
        # x @ A @ B 的形状变化:(batch, in_dim) → (batch, rank) → (batch, out_dim)
        return (self.alpha / self.rank) * (x @ self.A @ self.B)

alpha / rank 的缩放作用值得解释。这个比值使得在不同秩 r 下,LoRA 更新对原始输出的影响保持在相似的数量级。实践中通常设定 α=r(此时缩放因子为 1),也可以设为 α=2r 来增强更新幅度。这种设计的好处是:调整 r 时不需要重新调节学习率。


13.2.2 将 LoRA 注入 Transformer

有了 LoRA 层之后,需要将它与现有的 nn.Linear 层组合。核心思想是"并行旁路":原始线性层的前向传播不变,LoRA 层并行计算一个增量,两者的输出直接相加。

LinearWithLoRA 的结构:原始 Linear 输出与 LoRA 旁路输出相加

图 13-4:LinearWithLoRA 的内部结构。输入 x 分别通过冻结的原始 Linear 层和可训练的 LoRA 旁路,两条路径的输出直接相加。

python
class LinearWithLoRA(nn.Module):
    """用 LoRA 旁路增强原始线性层,推理时可合并为单个矩阵。"""
    def __init__(self, linear, rank, alpha):
        super().__init__()
        self.linear = linear  # 冻结的原始线性层
        self.lora = LoRALayer(linear.in_features, linear.out_features, rank, alpha)

    def forward(self, x):
        return self.linear(x) + self.lora(x)

接下来需要一个递归替换函数,将模型中所有 nn.Linear 层替换为 LinearWithLoRA

python
def replace_linear_with_lora(model, rank, alpha):
    """递归遍历模型,将所有 nn.Linear 替换为 LinearWithLoRA。"""
    for name, module in model.named_children():
        if isinstance(module, nn.Linear):
            setattr(model, name, LinearWithLoRA(module, rank, alpha))
        else:
            replace_linear_with_lora(module, rank, alpha)

递归替换模型中所有 Linear 层为 LinearWithLoRA

图 13-5:递归替换示意。模型中每个 Linear 层被包裹为 LinearWithLoRA,LoRA 旁路是唯一可训练的部分。

实际注入流程分三步:(1) 冻结原始模型全部参数;(2) 执行替换;(3) 验证可训练参数量。

python
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
# 假设 model 已加载预训练权重
# 第一步:冻结所有原始参数
for param in model.parameters():
    param.requires_grad = False

# 第二步:注入 LoRA 旁路(rank=16, alpha=16)
replace_linear_with_lora(model, rank=16, alpha=16)

# 第三步:统计参数量
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"总参数量: {total:,}")
print(f"可训练 LoRA 参数量: {trainable:,}")
print(f"可训练占比: {trainable / total * 100:.2f}%")

以 GPT-2(124M)为例,注入 LoRA 后可训练参数约 267 万,仅占总参数的 2.1%。而对于更小的模型(如 26M 参数的 MiniMind),LoRA 参数约 26 万,占比仅 1%。

注入位置的选择。 LoRA 原论文主要将低秩适配应用于 Transformer 自注意力模块中的 WqWv 矩阵。后续实践表明,将 LoRA 应用于更多线性层——包括 WkWo(注意力输出投影)以及 FFN 中的 WupWdown——往往能进一步提升性能。上面的 replace_linear_with_lora 函数替换了所有 Linear 层,这是最激进但通常效果最好的策略。在显存受限时,可以有选择地只替换注意力投影层。

LoRA 在 Transformer 内部的注入位置

图 13-6:LoRA 可注入到 Transformer 的注意力投影矩阵和 FFN 线性层。注入越多位置,可训练参数越多,但性能通常也越好。


13.2.3 训练循环与参数冻结

LoRA 训练循环与标准微调的唯一区别在于:只有 LoRA 参数参与梯度更新。PyTorch 的 requires_grad 机制会自动跳过冻结参数的梯度计算,因此前向和反向传播中冻结层不会产生梯度存储和优化器状态的额外开销。

python
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
import torch.optim as optim

# 仅收集可训练的 LoRA 参数
lora_params = [p for p in model.parameters() if p.requires_grad]
optimizer = optim.AdamW(lora_params, lr=2e-4, weight_decay=0.1)

# 标准训练循环
model.train()
for epoch in range(num_epochs):
    for batch_input, batch_label in train_loader:
        batch_input = batch_input.to(device)
        batch_label = batch_label.to(device)

        optimizer.zero_grad()
        logits = model(batch_input)
        loss = torch.nn.functional.cross_entropy(logits, batch_label)
        loss.backward()
        optimizer.step()

    print(f"Epoch {epoch+1}/{num_epochs}, Loss: {loss.item():.4f}")

学习率的选择。 使用 LoRA 微调时,通常需要将学习率设为全量微调的 10 倍左右。例如全量微调使用 2×105,LoRA 微调则使用 2×104。这是因为 LoRA 可训练参数极少,需要更大的步长才能产生足够的参数更新。下表总结了不同训练场景下的推荐学习率:

训练方式全量微调学习率LoRA 学习率(约 10x)
SFT(监督微调)2×1052×104
DPO(偏好优化)5×1075×106
GRPO / PPO(强化学习)1×1061×105

表 13-2:不同训练范式下全量微调与 LoRA 微调的推荐学习率对比。


13.2.4 LoRA 权重的保存与加载

LoRA 的一大实用优势是权重文件极小。训练完成后,只需保存 LoRA 参数(即所有 AB 矩阵),而非整个模型。对于 124M 参数的 GPT-2 模型,LoRA 权重通常只有几 MB,而完整模型约 500 MB。

python
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
def save_lora(model, path):
    """只保存模型中 LoRA 层的参数。"""
    lora_state = {}
    for name, param in model.named_parameters():
        if param.requires_grad:  # 只有 LoRA 参数是可训练的
            lora_state[name] = param.data
    torch.save(lora_state, path)
    print(f"已保存 {len(lora_state)} 个 LoRA 参数到 {path}")

def load_lora(model, path):
    """加载 LoRA 权重并写入模型对应位置。"""
    lora_state = torch.load(path, map_location="cpu")
    model_state = model.state_dict()
    for key, value in lora_state.items():
        if key in model_state:
            model_state[key] = value
    model.load_state_dict(model_state)
    print(f"已从 {path} 加载 {len(lora_state)} 个 LoRA 参数")

# 保存
save_lora(model, "lora_sft.pth")

# 加载:先构建原始模型并注入 LoRA 结构,再加载权重
load_lora(model, "lora_sft.pth")

推理时的权重合并。 LoRA 的另一个核心优势是可以在部署前将低秩矩阵合并回原始权重,使推理时的模型结构与原始模型完全一致,不引入任何额外延迟:

Wdeploy=W0+αrBA
python
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
def merge_lora(model):
    """将 LoRA 权重合并到原始线性层,消除推理开销。"""
    for name, module in model.named_modules():
        if isinstance(module, LinearWithLoRA):
            # 计算合并后的权重:W_merged = W_original + (alpha/r) * (A @ B)^T
            lora = module.lora
            delta = (lora.alpha / lora.rank) * (lora.A @ lora.B)  # (in, out)
            module.linear.weight.data += delta.T  # Linear 的 weight 是 (out, in)
            # 合并后可以丢弃 LoRA 层
    print("LoRA 权重已合并到原始模型")

合并后的模型与全量微调后的模型在数学上等价,推理速度也完全相同。这正是 LoRA 优于 Adapter-Tuning 的关键——Adapter 在推理时会引入额外的前向传播延迟,而 LoRA 可以做到零推理开销


13.2.5 多 Adapter 管理与热切换

LoRA 的模块化特性使得同一基模型可以搭配不同的 Adapter 来服务不同的下游任务。每个 Adapter 只是一组小体积的 AB 矩阵,切换时只需卸载旧 Adapter、加载新 Adapter 即可——这是一个纯内存操作,开销极低。

多 Adapter 热切换:基模型搭配不同 LoRA 适配器服务不同任务

图 13-7:多 Adapter 管理示意。同一基模型可搭配不同的 LoRA 权重,按需切换以适配法律、医疗、金融等不同领域。

下面实现一个完整的多 Adapter 管理器:

python
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
class LoRAAdapterManager:
    """管理多个 LoRA Adapter,支持注册、切换和列表查询。"""
    def __init__(self, model):
        self.model = model
        self.adapters = {}        # name -> state_dict
        self.active_adapter = None

    def register(self, name, path):
        """注册一个 Adapter(延迟加载,只记录路径)。"""
        self.adapters[name] = path
        print(f"已注册 Adapter: {name}")

    def activate(self, name):
        """激活指定 Adapter:加载其 LoRA 权重到模型中。"""
        if name not in self.adapters:
            raise ValueError(f"未知 Adapter: {name}")
        if self.active_adapter == name:
            return  # 已是当前活跃 Adapter
        # 先重置所有 LoRA 参数为零(卸载旧 Adapter)
        self._reset_lora_params()
        # 加载新 Adapter
        load_lora(self.model, self.adapters[name])
        self.active_adapter = name
        print(f"已激活 Adapter: {name}")

    def _reset_lora_params(self):
        """将所有 LoRA 参数归零,等效于卸载当前 Adapter。"""
        for name, param in self.model.named_parameters():
            if param.requires_grad:
                param.data.zero_()

    def list_adapters(self):
        """列出所有已注册的 Adapter。"""
        for name, path in self.adapters.items():
            status = " (active)" if name == self.active_adapter else ""
            print(f"  {name}: {path}{status}")

# 使用示例
manager = LoRAAdapterManager(model)
manager.register("medical", "lora_medical.pth")
manager.register("legal", "lora_legal.pth")
manager.register("finance", "lora_finance.pth")

manager.activate("medical")   # 加载医疗领域 Adapter
# ... 执行医疗领域推理 ...

manager.activate("legal")     # 热切换到法律领域 Adapter
# ... 执行法律领域推理 ...

使用 HuggingFace PEFT 库管理 Adapter。 在生产环境中,HuggingFace 的 peft 库提供了更成熟的多 Adapter 管理能力。通过 PeftModel,可以在一个基模型上加载、切换和合并多个 LoRA Adapter:

python
from peft import LoraConfig, get_peft_model, PeftModel
from transformers import AutoModelForCausalLM

# 加载基模型
base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-0.5B")

# 方式一:从头配置 LoRA 并训练
peft_config = LoraConfig(
    r=32,
    lora_alpha=16,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
model = get_peft_model(base_model, peft_config)
model.print_trainable_parameters()
# 输出示例:trainable params: 2,359,296 || all params: 466,291,712 || trainable%: 0.506

# 方式二:加载已有 Adapter 并在推理前合并
model = PeftModel.from_pretrained(base_model, "path/to/saved/adapter")
model = model.merge_and_unload()  # 合并后变回普通模型,推理零开销

与 TRL(Transformer Reinforcement Learning)库配合使用时,只需在 Trainer 中传入 peft_config 即可自动完成 LoRA 注入:

python
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

peft_config = LoraConfig(r=32, lora_alpha=16, lora_dropout=0.05,
                         bias="none", task_type="CAUSAL_LM")
training_args = SFTConfig(learning_rate=2e-4, num_train_epochs=3,
                          per_device_train_batch_size=4)
trainer = SFTTrainer(
    model="Qwen/Qwen2-0.5B",
    args=training_args,
    train_dataset=dataset,
    peft_config=peft_config,  # 自动注入 LoRA
)
trainer.train()
trainer.save_model("qwen2-sft-lora")  # 只保存 Adapter 权重(几 MB)

13.2.6 秩 r 的选择与消融实验

r 是 LoRA 最核心的超参数,它控制了低秩近似的表达能力。r 越大,LoRA 能表示的权重增量空间越丰富,但可训练参数也越多。

不同秩 r 下 LoRA 的性能消融实验

图 13-8:LoRA 原论文中对秩 r 的消融实验。即使 r=1,微调后模型也具有很强的竞争力,说明权重增量 ΔW 的内在秩极低。

LoRA 原论文中一个令人惊讶的发现是:即使 r=1,模型也能取得很强的竞争力。 这有力地验证了权重增量 ΔW 具有极低的内在秩这一核心假设。实践中的经验法则如下:

任务复杂度推荐秩 r典型场景
简单适配1 -- 4情感分类、风格迁移
中等复杂8 -- 16SFT 指令微调、领域适配
高复杂32 -- 64多任务学习、复杂推理能力注入

表 13-3:秩 r 的选择指南。简单任务用小秩即可,复杂任务可适当增大。

另一个实用的调参技巧是 lora_alphar 的比值。当 α=r 时缩放因子为 1;当 α=2r 时,LoRA 的更新幅度翻倍。一般建议从 α=r 开始尝试,若训练收敛慢则增大 α


13.2.7 小结

本节从零构建了 LoRA 的完整实践流程。核心要点回顾:

  1. LoRA 层实现:两个低秩矩阵 A(Kaiming 初始化)和 B(零初始化),保证初始输出与预训练模型一致。
  2. 注入策略:通过 LinearWithLoRA 包装原始线性层,递归替换模型中的所有 nn.Linear。注入位置越多通常效果越好。
  3. 训练要点:冻结原始参数,只优化 LoRA 参数。学习率通常设为全量微调的 10 倍。
  4. 保存与合并:训练完成后只需保存 LoRA 权重(几 MB)。部署时可将 BA 合并回原始权重,实现零推理开销。
  5. 多 Adapter 管理:同一基模型可搭配多个 LoRA Adapter 按需热切换,适配不同领域任务。
  6. r 选择:简单任务 r=4 即可,复杂任务可用 r=32 甚至 r=64ΔW 的内在秩通常远低于预期。