LoRA 参数高效微调

阶段 5 · 只训练不到 1% 的参数,就能获得接近全量微调的效果

微调太贵了

你花了两周在 8 张 GPU 上完成了 SFT 微调。现在想针对医疗领域再微调一次。

但全量微调意味着 64M 个参数全部要重新训练。每个新领域都重来一遍?

LoRA(Low-Rank Adaptation)就是为此而生的:在原始权重旁加一条"小路",训练参数不到原始的 1%,效果几乎不输全量微调。

全量 vs LoRA

全量微调

所有参数都可训练

# 64M 参数全部更新
optimizer = AdamW(model.parameters())
# 10 个领域 = 10 × 64M

LoRA 微调

只训练旁路参数

# 仅 ~0.4M 参数可训练
optimizer = AdamW(lora_params)
# 10 个领域 = 10 × 0.4M

Part 1:核心思想

低秩分解——用两个小矩阵近似一个大矩阵的变化量。

全量微调需要更新模型的所有参数,代价高昂。LoRA 的洞察是:大模型在微调时,参数的变化量其实是"低秩"的——不需要那么多自由度。用两个小矩阵在旁边搭一条"旁路",就能以极少的参数量达到接近全量微调的效果。

  • 低秩分解旁路原理——不改动原始权重 W,在旁边加 B·A,等效变化为 W' = W + B·A
  • 参数量对比——rank=16 时,LoRA 只需要全量微调约 4.2% 的参数量

低秩分解旁路

不修改原始权重,而是在旁边加一条"小路"

定义:LoRA 在原始权重矩阵 W 旁添加低秩分解矩阵 B·A,等效权重变为 W' = W + B·A。

说白了,原始权重像一条主路,LoRA 像一条绕行的辅路。两条路的输出加在一起就是最终结果。训练时只修辅路,主路不动。

这个比喻只是为了帮你建立直观感受。在计算机内部,LoRA 实际上是两个线性变换的串联——先把维度从 d 压缩到 r(A 矩阵),再从 r 还原到 d(B 矩阵),没有真正的"路"。

图:LoRA 低秩分解旁路结构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:上方"输入 x"节点,向下分成两条路。左路:"原始权重 W 冻结"(灰色大矩形,标注"不训练")。右路:"A 矩阵 d→r"(蓝色小矩形,标注"降维")→ "B 矩阵 r→d"(蓝色小矩形,标注"升维")。两条路汇入"+"相加节点,输出"输出 y" - 各元素关系:输入 x 同时进入 W 和 A 两条路径,W 路直接输出,A→B 路输出后与 W 路在"+"节点相加,得到最终输出 y - 关键标注:W 旁标注"冻结(不训练)",A 和 B 旁标注"训练","+"节点标注"相加融合" - 配色建议:白色背景,W 灰色半透明,A/B 亮蓝色,相加节点橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

参数量对比

rank=16 时,单个被注入权重的 LoRA 仅为 4.2%

矩阵形状参数量说明
原始 W(768, 768)589,824冻结,不训练
LoRA A(768, 16)12,288降维矩阵
LoRA B(16, 768)12,288升维矩阵
LoRA 合计—24,576单个 W 的 4.2%

关键在于 rank(秩)。MiniMind 默认 rank=16,远小于隐藏维度 768。rank 越大表达能力越强但参数越多。常见取值:8、16、32、64。

注意两个口径:表里的 4.2% 是单个方形权重矩阵的 LoRA 占比(LoRA 原论文的标准说法)。而 MiniMind 的 apply_lora 只注入方形 Linear(每层的 q_proj 和 o_proj,共 8×2=16 个),所以全模型视角下可训练参数约 0.4M,占 64M 总参数的不到 1%——比 4% 还要省。

图:全量微调 vs LoRA 的参数对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧大矩阵 W(768×768,灰色,标注"冻结"),右侧上小矩阵 A(768×16,蓝色)和下小矩阵 B(16×768,蓝色) - 数据流:输入 x → W → 中间结果1,同时 x → A → B → 中间结果2,两结果相加得最终输出 - 标注:W 旁标注"589,824 参数(冻结)",A+B 旁标注"24,576 参数(训练)= 4.2%" - 配色建议:白色背景,W 灰色半透明,A/B 亮蓝色,箭头深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

Part 2:65 行代码的秘密

整个 LoRA 实现只有 65 行代码——一个类加四个函数。我们逐一理解它们各自解决什么问题。

LoRA 的原理很简单,但工程实现里有不少巧妙的细节——比如为什么要故意把 B 初始化为零、怎么用 monkey-patch 把 LoRA 注入到已有模型里、训练完怎么把旁路合并回原始权重。理解这些细节,你才能真正"用"好 LoRA。

  • LoRA 类定义——两个无偏置的线性层串联,结构极简
  • 初始化策略——B 矩阵初始化为零,保证训练起点和原始模型完全一致
  • apply_lora 注入——用 monkey-patch 把 LoRA 层替换到模型中
  • 保存与加载——save_lora / load_lora 只处理 LoRA 部分,体积极小
  • merge_lora 合并——训练结束后把 B·A 加回原始权重,推理零开销

LoRA 类定义

两个无偏置的线性层串联,仅此而已

class LoRA(nn.Module):
    def __init__(self, in_features, out_features, rank):
        super().__init__()
        self.rank = rank  # 秩,控制低秩矩阵大小
        self.A = nn.Linear(in_features, rank, bias=False)  # (d, r) 降维
        self.B = nn.Linear(rank, out_features, bias=False)  # (r, d) 升维
        # A 高斯初始化
        self.A.weight.data.normal_(mean=0.0, std=0.02)
        # B 全零初始化
        self.B.weight.data.zero_()

    def forward(self, x):
        return self.B(self.A(x))  # x → A(x) → B(A(x))

为什么 A 用高斯初始化、B 用零初始化?B=0 所以训练开始时 LoRA(x) = B(A(x)) = 0。模型从"不加任何修改"的状态起步,训练初期行为与原始模型完全一致。

初始化策略

B=0 保证了训练起点的一致性

图:LoRA 初始化策略——B=0 的设计意图

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从顶部"训练第0步"开始,向下到菱形判断"LoRA 输出 = B·A·x = ?"。两条分支:左支"B全零"(绿色)→ "LoRA(x) = 0"→ "模型行为 = 原始模型"(绿色对勾)。右支"B不为零"(红色)→ "LoRA(x) ≠ 0"→ "起点被随机扰动,可能破坏已有知识"(红色警告) - 各元素关系:决策树结构,B=0 是正确路径(绿色),B≠0 是错误路径(红色) - 关键标注:左支标注"推荐",右支标注"不推荐",底部总结"B零初始化确保训练从原始模型状态起步" - 配色建议:浅灰背景,正确路径绿色,错误路径红色,判断菱形橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果

这是精心设计的细节:让 LoRA 从"什么都不做"开始,逐渐学会"需要做什么"。

注入 LoRA

遍历所有方形 Linear 层,逐个注入旁路

def apply_lora(model, rank=16):
    for name, module in model.named_modules():
        # 只对方形线性层注入(Q/K/V/O 投影、FFN 层)
        if isinstance(module, nn.Linear) and \
           module.weight.shape[0] == module.weight.shape[1]:
            lora = LoRA(module.weight.shape[0],
                        module.weight.shape[1],
                        rank=rank).to(model.device)
            setattr(module, "lora", lora)
            original_forward = module.forward

            def forward_with_lora(x, layer1=original_forward, layer2=lora):
                return layer1(x) + layer2(x)

            module.forward = forward_with_lora  # monkey-patch

为什么只选方形层?因为 Q/K/V/O 投影和 FFN 层是 (d, d) 的方形矩阵。Embedding 和 lm_head 不是方形,不注入。

Monkey-Patch 机制

不修改原始类定义,运行时替换 forward

图:Monkey-Patch 运行时替换 forward 的机制

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:时序图风格,三个纵向生命线从左到右:"输入 x"、"nn.Linear (768,768)"、"LoRA (768,16,768)"。流程:输入 x 调用 Linear 的 forward(x)(已被替换为 forward_with_lora),Linear 内部执行 original_forward(x) = W·x,同时调用 lora(x) = B·A·x,LoRA 返回输出,Linear 将 W·x + B·A·x 返回给输入 x - 各元素关系:时序图,Linear 是中心角色,它同时执行原始 forward 和 LoRA forward,最后合并结果 - 关键标注:forward 旁标注"被 monkey-patch 替换",合并处标注"W·x + B·A·x" - 配色建议:浅灰背景,Linear 蓝色,LoRA 绿色,输入/输出橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果

Monkey-patch 与 torch.compile 不兼容!LoRA 脚本第 163-165 行会自动关闭 torch.compile。

被注入的层一览

8 层 Transformer 中哪些层会获得 LoRA?

层形状LoRA?原因
q_proj(768, 768)✅方形,Query 投影
k_proj(768, 384)❌非方形(GQA)
v_proj(768, 384)❌非方形(GQA)
o_proj(768, 768)✅方形,Output 投影
gate_proj(768, 2112)❌非方形
up_proj(768, 2112)❌非方形
down_proj(2112, 768)❌非方形

默认配置中只有 q_proj 和 o_proj 被注入。8 层 × 2 = 16 个 LoRA 模块。

保存与加载

只保存 LoRA 参数,文件极小(~50KB)

# 保存——只提取 lora 参数
def save_lora(model, path):
    raw_model = getattr(model, '_orig_mod', model)
    state_dict = {}
    for name, module in raw_model.named_modules():
        if hasattr(module, 'lora'):
            lora_state = {
                f'{name}.lora.{k}': v.cpu().half()
                for k, v in module.lora.state_dict().items()
            }
            state_dict.update(lora_state)
    torch.save(state_dict, path)

# 加载——找到对应 LoRA 恢复权重
def load_lora(model, path):
    state_dict = torch.load(path, map_location=model.device)
    for name, module in model.named_modules():
        if hasattr(module, 'lora'):
            lora_state = {
                k.replace(f'{name}.lora.', ''): v
                for k, v in state_dict.items()
                if f'{name}.lora.' in k
            }
            module.lora.load_state_dict(lora_state)

合并权重

推理时把 LoRA 溶入原始权重,零额外开销

def merge_lora(model, lora_path, save_path):
    load_lora(model, lora_path)
    raw_model = getattr(model, '_orig_mod', model)
    state_dict = {}
    for name, module in raw_model.named_modules():
        if isinstance(module, nn.Linear) and '.lora.' not in name:
            state_dict[f'{name}.weight'] = module.weight.data.clone().cpu().half()
            if hasattr(module, 'lora'):
                # W' = W + B @ A
                state_dict[f'{name}.weight'] += \
                    (module.lora.B.weight.data @ module.lora.A.weight.data).cpu().half()
    torch.save(state_dict, save_path)

图:LoRA 合并权重实现零额外开销推理

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右流程。左侧两个输入:"原始权重 W 768×768"(灰色大矩形)和"LoRA B·A 16×768 × 768×16"(蓝色小矩形),两者汇入"+"相加节点,输出"合并后 W' 768×768"(紫色矩形),再输出到"推理零额外开销"(绿色圆角矩形) - 各元素关系:W 和 B·A 矩阵相加合并为 W',合并后推理时不再需要 LoRA 模块 - 关键标注:W 标注"冻结参数",B·A 标注"训练参数",W' 标注"W' = W + B@A",推理标注"无额外计算" - 配色建议:白色背景,W 灰色,B·A 蓝色,合并后紫色,推理绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果

Part 3:训练流程

LoRA 训练与全量 SFT 共享数据格式和循环结构,但有三处关键差异。

既然数据格式和训练循环都跟全量 SFT 差不多,那 LoRA 训练到底特殊在哪?答案集中在三处:冻结原始参数、优化器只管 LoRA 参数、保存时只存 LoRA 权重。搞清楚这三点,LoRA 训练就没什么神秘的了。

  • 三处关键差异——冻结原始参数、优化器范围、保存策略
  • 冻结与收集参数——代码层面如何冻结 requires_grad 并只收集 LoRA 参数
  • 训练循环精讲——和 SFT 对比着看,差异一目了然
  • 超参数对比表——LoRA vs 全量 SFT 的关键参数差异

与全量 SFT 的差异

① 冻结原始参数

非 LoRA 参数的 requires_grad 设为 False,反向传播时不计算梯度。

② 优化器只含 LoRA

optimizer = AdamW(lora_params, lr=1e-4),学习率比全量 SFT 高 10 倍。

③ 保存仅 LoRA 权重

文件极小(~50KB vs 全量 ~100MB+),方便分发和切换。

冻结与收集参数

train_lora.py 第 128-145 行

# 加载 SFT 模型,注入 LoRA
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
apply_lora(model)

# 统计参数量
total_params = sum(p.numel() for p in model.parameters())
lora_count = sum(p.numel() for n, p in model.named_parameters() if 'lora' in n)
print(f"总参数: {total_params/1e6:.3f}M | LoRA: {lora_count/1e6:.3f}M | "
      f"占比: {lora_count/total_params*100:.2f}%")

# 冻结非 LoRA 参数
lora_params = []
for name, param in model.named_parameters():
    if 'lora' in name:
        param.requires_grad = True
        lora_params.append(param)
    else:
        param.requires_grad = False

optimizer = optim.AdamW(lora_params, lr=args.learning_rate)

训练循环精讲

与全量 SFT 几乎相同,只改了两处

def train_epoch(epoch, loader, iters, lora_params, start_step=0):
    for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
        # 学习率调度、前向、loss 计算与全量 SFT 一致
        with autocast_ctx:
            res = model(input_ids, labels=labels)
            loss = (res.loss + res.aux_loss) / args.accumulation_steps

        scaler.scale(loss).backward()

        if step % args.accumulation_steps == 0:
            scaler.unscale_(optimizer)
            # ⬇️ 区别 1:梯度裁剪只作用于 LoRA 参数
            torch.nn.utils.clip_grad_norm_(lora_params, args.grad_clip)
            scaler.step(optimizer)
            scaler.update()
            optimizer.zero_grad(set_to_none=True)

        if step % args.save_interval == 0 and is_main_process():
            # ⬇️ 区别 2:只保存 LoRA 权重
            save_lora(model, lora_save_path)

超参数对比

LoRA vs 全量 SFT 的默认配置

参数全量 SFTLoRA为什么不同
学习率1e-51e-4参数少,需更大学习率
训练轮数210每轮更新量小
序列长度768340领域数据通常较短
优化器参数全部仅 LoRA冻结原始权重
torch.compile可选关闭monkey-patch 不兼容

Part 4:使用场景

什么时候用 LoRA,什么时候全量微调?

LoRA 不是万能药,全量微调也不是银弹。选择哪种方式取决于你的场景——数据量多大、显存多少、是否需要同时服务多个领域。这一节帮你做决策,并给出可以直接跑的训练命令。

  • LoRA 适用场景——多领域适配、资源受限、快速实验迭代
  • 数据格式——和全量 SFT 完全相同的 JSONL 格式,零迁移成本
  • 完整训练命令——一行命令跑通 LoRA 微调

LoRA 适用场景

多领域适配

一个基础模型 + 多个 LoRA 权重(医疗、法律、客服),按需切换。

资源受限

只有消费级 GPU(如 RTX 3090),全量微调放不下。

快速实验

验证数据集或超参数是否有效,LoRA 训练更快。

何时不用 LoRA?基础模型能力不足时(预训练不充分),LoRA 改进空间有限,应先全量 SFT。

LoRA 数据格式

与 SFT 相同的 JSONL 格式

{"conversations": [
    {"role": "user", "content": "头痛应该吃什么药?"},
    {"role": "assistant", "content": "头痛的原因很多,建议先就医确诊。如果是普通感冒引起的..."}
]}

LoRA 用 SFTDataset 加载数据。数据量不需要太大——几百到几千条即可完成领域适配。质量远比数量重要。

完整训练命令

# LoRA 微调(医疗领域)
$ python trainer/train_lora.py \
    --from_weight full_sft \
    --data_path ../dataset/lora_medical.jsonl \
    --lora_name lora_medical \
    --epochs 10 --learning_rate 1e-4

# 合并 LoRA 到基础模型(推理用)
$ python -c "
from model.model_lora import merge_lora
from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
config = MiniMindConfig()
model = MiniMindForCausalLM(config)
merge_lora(model, '../out/lora_medical_768.pth', '../out/merged_768.pth')
"

Part 5:排错与练习

LoRA 训练中你可能遇到的坑。

LoRA 看起来简单,但实际跑起来可能遇到各种奇怪的问题——LoRA 好像没生效?Loss 死活不下降?合并后效果反而变差了?别慌,这些问题都有套路可解。最后再通过练习巩固理解,把知识变成自己的。

  • 常见错误诊断——LoRA 没生效、Loss 不下降、合并后效果差、compile 报错的排查思路
  • 动手练习——手工计算 LoRA 参数量、理解初始化为什么这样设计
  • 知识全景图——LoRA 的核心知识点一张图总结

常见错误诊断

LoRA 没生效

症状:loss 曲线与全量 SFT 一致

原因:apply_lora() 没调用,或优化器用了 model.parameters()

修复:检查第 129 行和第 151 行

Loss 不下降

症状:10 轮 loss 没变化

排查:① 确认 requires_grad 正确;② 学习率 1e-4 是否合适;③ 数据格式是否正确

合并后效果差

症状:merge_lora 后推理结果不对

原因:设备不一致(GPU 权重 vs CPU 保存)

修复:确保 merge 时张量在同一设备

compile 报错

症状:RuntimeError: mutated variable

修复:代码已自动关闭 compile(163-165 行),手动改过则确保为 0

动手练习

练习 1:计算参数量

隐藏维度 768,rank=16。每层 2 个方形层(q_proj, o_proj),共 8 层。请计算:

  1. 每个 LoRA 模块的参数量?
  2. 全部 LoRA 参数量?
  3. rank=32 时变为多少?

参考

① 768×16 + 16×768 = 24,576

② 24,576 × 2 × 8 = 393,216 ≈ 0.39M

③ rank=32:49,152 × 16 = 786,432 ≈ 0.79M

练习 2:理解初始化

如果把 B 也用高斯初始化(而非零),训练第一步时模型输出会怎样?

参考

模型输出 = W·x + B·A·x。B 随机初始化则 B·A·x 是随机值,等于在原始输出加随机噪声,会破坏 SFT 已学到的能力。

知识全景图

核心思想

  • 低秩分解 W + BA
  • 冻结原始权重
  • B 零初始化

代码实现

  • LoRA 类 65 行
  • apply_lora monkey-patch
  • save / load / merge

训练差异

  • 冻结非 LoRA 参数
  • 优化器仅 LoRA
  • 保存仅 LoRA 权重

使用场景

  • 多领域适配
  • 资源受限
  • 快速实验

阶段 5 完成

LoRA 让微调变得轻量:全模型不到 1% 的可训练参数,接近全量的效果。

下一章:阶段 6 · 知识蒸馏——让大模型"教"小模型。

1 / X