前面几篇文章已经从 tokenizer、dataset、训练循环、模型结构、Attention、RoPE、MoE 和推理生成走完了一条 LLM 工程主线。
这篇开始看一个非常常见的微调技术:LoRA。
LoRA 的全称是 Low-Rank Adaptation。它属于参数高效微调,也就是 PEFT(Parameter-Efficient Fine-Tuning)的一类方法。
它要解决的问题很实际:
我已经有一个训练好的基础模型; 我想让它适配某个领域、某种人设、某类任务; 但我不想全量更新所有参数。
LoRA 的做法是:冻结原模型,只新增并训练一小组低秩参数。
MiniMind 里没有依赖 PEFT 这类封装库,而是手写了一版简洁 LoRA,实现集中在:
model/model_lora.py trainer/train_lora.py
这很适合用来理解 LoRA 的本质。
1. 全参微调和 LoRA 的区别
如果做全参数微调,模型里的原始权重会被直接更新:
W -> W'
也就是说,训练会修改原模型的大量参数。
LoRA 不直接改 W,而是在旁边学习一个增量:
W' = W + ΔW
关键是,ΔW 不直接训练成一个完整大矩阵,而是拆成两个小矩阵:
ΔW = B @ A
其中:
A: 把输入降到低维 rank 空间 B: 把低维表示升回输出维度
如果 rank 很小,那么需要训练的参数量就远小于完整的 W。
这就是 LoRA 的核心:用低秩增量近似一次权重更新。
2. LoRA 在代码里长什么样
MiniMind 的 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)
self.B = nn.Linear(rank, out_features, bias=False)
self.A.weight.data.normal_(mean=0.0, std=0.02)
self.B.weight.data.zero_()
def forward(self, x):
return self.B(self.A(x))
这就是两个小 Linear 串联:
x -> A -> B
形状变化是:
x: [..., in_features] A(x): [..., rank] B(A(x)): [..., out_features]
如果原始 Linear 是:
768 -> 768
原始权重参数量是:
768 * 768 = 589,824
如果 LoRA rank 是 16:
A: 768 -> 16 B: 16 -> 768
LoRA 参数量是:
768 * 16 + 16 * 768 = 24,576
大约只有原始矩阵的 4.17%。
这就是参数高效的来源。
3. 为什么 B 初始化为 0
MiniMind 里:
self.A.weight.data.normal_(mean=0.0, std=0.02) self.B.weight.data.zero_()
刚开始:
B = 0
所以:
ΔW = B @ A = 0
这意味着 LoRA 刚注入时,不会改变原模型输出。
也就是说:
训练开始前: Linear(x) + LoRA(x) = Linear(x)
这个设计很重要。它保证 LoRA 微调从原模型行为出发,而不是一开始就扰乱模型。
4. LoRA 是怎么“挂到” Linear 上的
MiniMind 通过 apply_lora 注入 LoRA:
def apply_lora(model, rank=16):
for name, module in model.named_modules():
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。
原来 Linear 是:
y = Linear(x)
注入 LoRA 后变成:
y = Linear(x) + LoRA(x)
图上看就是:
┌──────────────┐
│ 原 Linear W │
└──────┬───────┘
│
x ───────────────────────┼──────────> add -> y
│
┌──────▼───────┐
│ LoRA: A -> B │
└──────────────┘
所以你可以把 LoRA 理解成:原 Linear 旁边并联了一条低秩小分支。
5. MiniMind 当前会给哪些层加 LoRA
注意,MiniMind 当前实现有一个简化条件:
module.weight.shape[0] == module.weight.shape[1]
也就是只给方阵 Linear 加 LoRA。
在 Attention 里有四个主要 Linear:
self.q_proj = nn.Linear(config.hidden_size, config.num_attention_heads * self.head_dim, bias=False) self.k_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False) self.v_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False) self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=False)
默认配置:
hidden_size = 768 num_attention_heads = 8 num_key_value_heads = 4 head_dim = 96
所以:
| Linear | 形状 | 当前是否命中 LoRA |
|---|---|---|
q_proj | 768 -> 768 | 是 |
k_proj | 768 -> 384 | 否 |
v_proj | 768 -> 384 | 否 |
o_proj | 768 -> 768 | 是 |
因此在 MiniMind 当前默认 Attention 里,LoRA 主要影响:
Q 怎么产生; Attention 输出怎么写回 hidden state。
图上看:
hidden_states
|
|--------- q_proj ---------> Q_base
| |
| +-- LoRA_q: A -> B -> Q_delta
| |
|----------- add <--------------|
| |
|------------------------------> Q = Q_base + Q_delta
|
|--------- k_proj ---------> K
|
|--------- v_proj ---------> V
Q, K
|
v
Attention scores / softmax / @ V
|
v
attention output
|
|--------- o_proj ---------> O_base
|
+-- LoRA_o: A -> B -> O_delta
|
add <----------------|
|
v
final attention output = O_base + O_delta
需要强调:这只是 MiniMind 当前教学实现的选择,不代表 LoRA 只能加到 q_proj/o_proj。
更通用的 LoRA 配置会显式指定 target modules,例如:
q_proj, k_proj, v_proj, o_proj gate_proj, up_proj, down_proj
甚至可以覆盖所有 Linear。
6. LoRA 训练时更新哪些参数
trainer/train_lora.py 里,模型加载后会先注入 LoRA:
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device) apply_lora(model)
然后冻结非 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
优化器只拿到 LoRA 参数:
optimizer = optim.AdamW(lora_params, lr=args.learning_rate)
所以训练时:
基模权重 W 不更新; LoRA 的 A/B 更新。
这就是 LoRA 和 full SFT 的核心差异。
SFT 是训练目标和数据组织方式;LoRA 是参数更新策略。
MiniMind 的 LoRA 训练仍然使用:
SFTDataset
也就是说,它还是在对话数据上做监督微调,只是更新的参数从“全模型”变成了“LoRA 分支”。
7. LoRA 权重为什么可以单独保存
save_lora 只保存带 .lora. 的参数:
if hasattr(module, 'lora'):
lora_state = {
f'{clean_name}.lora.{k}': v.cpu().half()
for k, v in module.lora.state_dict().items()
}
所以保存出来的文件:
lora_xxx_768.pth
不是完整模型,而是 LoRA adapter 权重。
推理时要配合基模:
base model + LoRA weight
这也是 LoRA 很灵活的地方。同一个基模,可以挂不同 LoRA:
基础模型 + 医疗 LoRA 基础模型 + 人设 LoRA 基础模型 + 选择题格式 LoRA
8. 推理时怎么加载 LoRA
eval_llm.py 里的顺序是:
model.load_state_dict(base_ckp, strict=True)
if args.lora_weight != 'None':
apply_lora(model)
load_lora(model, lora_path)
顺序不能反:
先加载基模; 再注入 LoRA 结构; 再加载 LoRA 权重。
使用命令:
python eval_llm.py --weight full_sft --lora_weight lora_medical
这里:
--weight full_sft 指基础模型。 --lora_weight lora_medical 指 LoRA 增量。
此时推理仍然是:
Linear(x) + LoRA(x)
9. merge_lora:把 LoRA 合并回原权重
LoRA 也可以合并回基础模型。
训练和动态加载时:
y = Linear(x) + LoRA(x)
其中:
LoRA(x) = B(A(x))
根据 PyTorch nn.Linear 的权重约定:
A.weight: [rank, in_features] B.weight: [out_features, rank]
两层串联等价于:
ΔW = B.weight @ A.weight
形状是:
[out_features, rank] @ [rank, in_features] = [out_features, in_features]
正好和原始 Linear 权重:
W: [out_features, in_features]
一样。
merge 前:
y = x @ W.T + x @ ΔW.T
合并:
y = x @ (W + ΔW).T
定义:
W' = W + ΔW
于是:
y = x @ W'.T
也就是说,原来的:
Linear 分支 + LoRA 分支
可以折叠成:
一个新的 Linear。
MiniMind 里的代码就是:
state_dict[f'{name}.weight'] += (
module.lora.B.weight.data @ module.lora.A.weight.data
).cpu().half()
merge 后就不需要 LoRA 分支了,适合部署和导出。
10. Adapter 和 LoRA 有什么区别
学习 LoRA 时,经常会遇到另一个词:Adapter。
Adapter Tuning 也是 PEFT 方法。它通常在 Transformer block 里插入一个小模块:
x | down projection | activation | up projection | residual add | y
公式:
Adapter(x) = x + W_up f(W_down x)
LoRA 则是:
Linear(x) + B(A(x))
两者区别:
| 维度 | Adapter | LoRA |
|---|---|---|
| 插入位置 | Transformer block 中额外插小模块 | 挂在已有 Linear 旁边 |
| 形式 | x + W_up f(W_down x) | Linear(x) + B(A(x)) |
| 是否容易合并 | 通常不如 LoRA 容易 | 很容易,W' = W + B @ A |
| 推理结构 | 通常保留 Adapter 模块 | merge 后恢复普通 Linear |
| 直觉 | 插一个小插件层 | 给权重加一个低秩补丁 |
一句话:
Adapter 是插层; LoRA 是改权重增量。
MiniMind 当前实现的是 LoRA,不是 Adapter。
11. 什么时候适合用 LoRA
LoRA 很适合这些场景:
垂直领域适配; 人设或风格微调; 格式对齐; 数据量不大但想快速试验; 显存有限; 希望同一个基模挂多个任务 adapter。
但它也不是万能的。
如果你希望模型获得大量新知识,或者彻底改变能力边界,仅靠小规模 LoRA 不一定够。
它更适合:
在已有基模能力附近做轻量适配。
12. 小结
LoRA 可以从三个层次理解:
第一,数学上:
W' = W + ΔW ΔW = B @ A
第二,结构上:
原 Linear 旁边并联一条 A -> B 低秩分支。
第三,工程上:
训练时冻结基模,只训练 LoRA; 推理时可以动态加载 base + lora; 部署时可以 merge 成普通 Linear。
MiniMind 的实现非常简洁,正适合用来建立 LoRA 的第一性理解。
等理解了这个版本,再看 PEFT / QLoRA / 多 target modules / rank alpha scaling 这些工程增强,就会顺很多。