第 9 章 LoRA 低秩适配
本章介绍 MiniMind 中的 LoRA(Low-Rank Adaptation)实现。LoRA 是目前大模型参数高效微调(PEFT)的事实标准,它通过在冻结的原始权重旁挂一个低秩可训练增量,把可训练参数量从亿级压到百万级,同时保持微调效果接近全量。MiniMind 的 LoRA 实现非常精简(不到 60 行),是理解 LoRA 原理的最佳起点。
9.1 LoRA 原理:W' = W + BA
原理说明
LoRA 的核心假设:大模型在下游任务上的微调权重更新 ΔW 具有低秩结构——即可以用两个小矩阵的乘积来近似。
对于原始线性层 W: [out, in],LoRA 把权重更新参数化为:
W' = W + BA
其中:
W:原始权重,冻结不训练,shape[out_features, in_features]A:降秩矩阵,shape[rank, in_features],把输入从in_features维压到rank维B:升秩矩阵,shape[out_features, rank],把rank维还原回out_features维rank:低秩维度,远小于hidden_size
前向传播变为 y = W'x = Wx + BAx。由于 rank << min(in, out),可训练参数从 in × out 降到 rank × (in + out)。
参数量对比(以 MiniMind 默认配置为例)
MiniMind 默认配置:hidden_size=768、num_hidden_layers=8、num_attention_heads=8、num_key_value_heads=4、head_dim=96、intermediate_size=2432、vocab_size=6400、tie_word_embeddings=True。
以一个 768 × 768 的注意力投影层为例(q_proj 或 o_proj):
| 方式 | 可训练参数量 | 计算式 |
|---|---|---|
| 全量微调 | 589,824 | 768 × 768 |
| LoRA (rank=16) | 24,576 | 16 × (768 + 768) |
| 压缩比 | 24× | 589,824 / 24,576 |
全模型可训练参数对比(rank=16):
| 方式 | 可训练参数量 | 说明 |
|---|---|---|
| 全量微调 | ~63.9M | 含 embedding(tied)、8 层 Transformer、final norm |
| LoRA (rank=16) | ~0.39M | 仅 q_proj + o_proj 的 A/B 矩阵(每层 2 个,共 16 个模块) |
| 比例 | ~0.6% | 393,216 / 63,910,656 |
注:MiniMind 的
apply_lora只给方阵 Linear(in_features == out_features)挂 LoRA。在默认 GQA 配置下(num_key_value_heads=4),k_proj/v_proj是768→384非方阵会被跳过,只有q_proj(768→768)和o_proj(768→768)符合条件。若改成标准 MHA(num_key_value_heads=num_attention_heads),则四个注意力投影都是方阵都会被挂上 LoRA。
9.2 LoRA 模块定义
代码位置引用
LoRA 模块定义在 LoRA 类中:
class LoRA(nn.Module):
def __init__(self, in_features, out_features, rank):
super().__init__()
self.rank = rank # LoRA 的秩(rank),控制低秩矩阵的大小
self.A = nn.Linear(in_features, rank, bias=False) # 低秩矩阵 A: [rank, in]
self.B = nn.Linear(rank, out_features, bias=False) # 低秩矩阵 B: [out, rank]
# 矩阵 A 高斯初始化
self.A.weight.data.normal_(mean=0.0, std=0.02)
# 矩阵 B 全 0 初始化
self.B.weight.data.zero_()
def forward(self, x):
return self.B(self.A(x))
初始化策略(关键)
LoRA 的初始化有一个精心设计的非对称结构:
| 矩阵 | 初始化方式 | 初始值 |
|---|---|---|
| A | 高斯随机 N(0, 0.02) |
非零 |
| B | 全零 zero_() |
0 |
这保证了训练初始时 BA = 0,即 W' = W + 0 = W,LoRA 增量在训练开始时对原模型毫无影响。模型从 SFT 权重出发平滑地学习 ΔW,避免了从头训练的不稳定性。这是 LoRA 能稳定微调大模型的关键设计之一。
输入/输出张量说明
| 张量 | Shape | dtype | 含义 |
|---|---|---|---|
| x | [batch, seq, in_features] | float32/bf16 | 输入特征 |
| A(x) | [batch, seq, rank] | float32/bf16 | 降维后的中间表示 |
| B(A(x)) | [batch, seq, out_features] | float32/bf16 | 升维还原后的增量 |
关键计算逻辑
前向 y = B(A(x)) 等价于 y = (B.weight @ A.weight) @ x,其中:
A.weightshape:[rank, in_features]B.weightshape:[out_features, rank]B.weight @ A.weightshape:[out_features, in_features](与 W 同 shape,可相加)
9.3 apply_lora:Monkey-Patch 挂载
原理说明
apply_lora 不修改模型源代码,而是用 monkey-patch 的方式:遍历模型所有子模块,找到符合条件的 nn.Linear,给它挂一个 lora 子模块,并把它的 forward 替换为 Wx + BAx。这种实现与具体模型架构解耦,对 MiniMind、LLaMA、Qwen 等任何用 nn.Linear 的模型都通用。
def apply_lora(model, rank=16):
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and module.in_features == module.out_features:
# 这里只给方阵 Linear 挂 LoRA,覆盖注意力投影等主要可调层,参数量很小。
lora = LoRA(module.in_features, module.out_features, 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
关键细节逐行解释
筛选条件
module.in_features == module.out_features:只对方阵线性层挂载。这覆盖了注意力的q_proj/o_proj(在标准 MHA 下还有k_proj/v_proj),跳过了非方阵的 MLP 投影(gate_proj768→2432 等)。挂载方式
setattr(module, "lora", lora):把 LoRA 模块作为子模块挂上去,PyTorch 会自动注册其参数到model.parameters()中,优化器会自动更新它们。闭包绑定
def forward_with_lora(x, layer1=original_forward, layer2=lora):用默认参数显式捕获当前循环的original_forward和lora,避免 Python 闭包延迟绑定导致的「所有层都引用最后一个 lora」的经典 bug。替换 forward
module.forward = forward_with_lora:直接替换实例方法,原Wx通过layer1(x)保留,叠加layer2(x) = BAx。冻结原权重:
apply_lora本身不冻结原权重,需要调用方在创建优化器前手动设置requires_grad_(False),只把 LoRA 参数加入优化器(典型用法见 MiniMind 的 LoRA 训练脚本)。
输入/输出张量说明
| 张量 | Shape | dtype | 含义 |
|---|---|---|---|
| x | [batch, seq, hidden] | float32/bf16 | 输入特征 |
| original_forward(x) | [batch, seq, hidden] | float32/bf16 | 原始 Wx |
| lora(x) | [batch, seq, hidden] | float32/bf16 | 低秩增量 BAx |
| output | [batch, seq, hidden] | float32/bf16 | Wx + BAx |
9.4 save_lora / load_lora:只保存 LoRA 参数
原理说明
LoRA 微调的最大优势之一:检查点极小。全量微调要保存整个模型权重(MiniMind ~64MB,大模型可达几十 GB),而 LoRA 只需保存 A/B 矩阵(MiniMind rank=16 时仅 ~0.8MB)。这让 LoRA 权重可以方便地分发、版本管理、按任务热切换。
save_lora
def save_lora(model, path):
# LoRA 微调只保存新增的低秩矩阵,不保存原始大模型权重。
raw_model = getattr(model, '_orig_mod', model) # 兼容 torch.compile
state_dict = {}
for name, module in raw_model.named_modules():
if hasattr(module, 'lora'):
clean_name = name[7:] if name.startswith("module.") else name # 兼容 DDP 前缀
lora_state = {f'{clean_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)
关键点:
getattr(model, '_orig_mod', model):兼容torch.compile包装(原始模型存在_orig_mod属性下)。name[7:] if name.startswith("module."):兼容 DDP(DistributedDataParallel)保存出来的module.前缀。v.cpu().half():转 CPU + half 精度,进一步压缩体积。- 只收集
hasattr(module, 'lora')的模块,不保存任何原始权重。
load_lora
def load_lora(model, path):
state_dict = torch.load(path, map_location=model.device)
# 兼容 DDP 保存出来的 module.xxx 前缀。
state_dict = {(k[7:] if k.startswith('module.') else k): v for k, v in state_dict.items()}
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)
加载逻辑:先去掉 module. 前缀做兼容,再遍历模型中所有挂了 lora 的模块,从 state_dict 中筛选出对应的 *.lora.A.weight / *.lora.B.weight 加载回去。
输入/输出张量说明
| 张量 | Shape | dtype | 含义 |
|---|---|---|---|
| lora.A.weight | [rank, in_features] | float16 | 降秩矩阵(保存/加载) |
| lora.B.weight | [out_features, rank] | float16 | 升秩矩阵(保存/加载) |
9.5 merge_lora:合并回原始权重
原理说明
LoRA 训练完成后,可以把 BA 直接加到原始 W 上,得到一个单一体的权重 W' = W + BA。合并后模型结构与原始完全一致,推理时无任何额外开销——这是 LoRA 相比 Adapter Tuning(需要在推理时多跑一层)的重要优势。
合并公式:
W'.weight = W.weight + B.weight @ A.weight
A.weightshape:[rank, in_features]B.weightshape:[out_features, rank]B.weight @ A.weightshape:[out_features, in_features](与W.weight同 shape)
代码位置引用
def merge_lora(model, lora_path, save_path):
load_lora(model, lora_path)
raw_model = getattr(model, '_orig_mod', model)
# 1. 先收集所有非 lora 的原始权重
state_dict = {k: v.cpu().half() for k, v in raw_model.state_dict().items() if '.lora.' not in k}
# 2. 再把 BA 合并回对应的 Linear 权重
for name, module in raw_model.named_modules():
if isinstance(module, nn.Linear) and '.lora.' not in name:
# 合并公式:W' = W + B @ A,合并后推理不再需要额外 LoRA 分支。
state_dict[f'{name}.weight'] = module.weight.data.clone().cpu().half()
if hasattr(module, 'lora'):
state_dict[f'{name}.weight'] += (module.lora.B.weight.data
@ module.lora.A.weight.data).cpu().half()
torch.save(state_dict, save_path)
关键计算逻辑逐行解释
先加载 LoRA 权重
load_lora(model, lora_path):把保存的 A/B 矩阵恢复到模型的lora子模块中。收集原始权重:先复制一份所有非
lora的权重作为基底。module.weight.data.clone()是必要的——直接引用会被后续+=修改原模型权重。合并:遍历所有
nn.Linear,对挂了lora的模块执行W += B @ A。注意B.weight @ A.weight的矩阵乘法维度匹配:[out, rank] @ [rank, in] = [out, in]。保存合并后的完整权重
torch.save(state_dict, save_path):输出的是一个标准模型权重文件,可以直接用init_model加载,不再需要 apply_lora。
输入/输出张量说明
| 张量 | Shape | dtype | 含义 |
|---|---|---|---|
| module.weight | [out_features, in_features] | float16 | 原始权重 W |
| B.weight @ A.weight | [out_features, in_features] | float16 | LoRA 增量 BA |
| state_dict[name].weight | [out_features, in_features] | float16 | 合并后 W' = W + BA |
9.6 LoRA 完整使用流程
把上述四个 API 串起来,LoRA 微调的典型生命周期如下:
# 1. 加载预训练模型
model, tokenizer = init_model(lm_config, base_weight)
# 2. 挂载 LoRA(冻结原权重)
apply_lora(model, rank=16)
for name, param in model.named_parameters():
param.requires_grad = 'lora' in name # 只训练 LoRA 参数
# 3. 训练(与全量微调代码完全一致,只是优化器只更新 LoRA 参数)
optimizer = AdamW([p for p in model.parameters() if p.requires_grad], lr=...)
# ... 标准 train loop ...
# 4. 保存(只存 LoRA,体积很小)
save_lora(model, "lora_weights.pth")
# === 推理阶段有两种选择 ===
# 选项 A:保持 LoRA 形式推理(可热切换多个 LoRA)
apply_lora(model, rank=16)
load_lora(model, "lora_weights.pth")
# 推理时 Wx + BAx,有微小额外开销
# 选项 B:合并后推理(无额外开销,适合生产部署)
merge_lora(model, "lora_weights.pth", "merged_weights.pth")
# 之后直接用 merged_weights.pth 加载原始模型结构即可
小结
本章拆解了 MiniMind 的 LoRA 实现:
LoRA 原理:
W' = W + BA,用两个小矩阵A [rank, in]、B [out, rank]近似权重更新 ΔW,把可训练参数从in × out降到rank × (in + out)。MiniMind 默认配置下仅约 0.6% 参数可训练。非对称初始化:A 高斯随机、B 全零,保证训练初始
BA = 0不破坏原模型,是 LoRA 稳定微调的关键。apply_lora 用 monkey-patch 给符合条件的方阵
nn.Linear挂载 LoRA 子模块并替换 forward 为Wx + BAx,与具体模型架构解耦,闭包默认参数避免了延迟绑定 bug。save_lora / load_lora 只保存/加载 A/B 矩阵,检查点极小,方便分发和热切换。
merge_lora 训练后把
BA合并回W,得到单一权重W' = W + BA,推理时无任何额外开销,适合生产部署。
LoRA 的精妙之处在于:它不是对模型结构的改造,而是对「权重更新」本身做的低秩假设。这个假设在大多数下游任务上都成立,使得 LoRA 成为参数高效微调的事实标准。理解了 MiniMind 这 60 行实现,就掌握了 LoRA 的全部核心思想。