LoRA 原理:低秩矩阵为什么能微调大模型
- Published on
LoRA 原理:低秩矩阵为什么能微调大模型
全量微调大模型最直接,也最昂贵。模型的每个参数都参与更新,训练时要保存梯度、优化器状态和激活;模型越大,显存和存储压力越快失控。更现实的问题是:很多业务只想让模型学会一种回答风格、一个领域任务或一套格式,真的需要改动全部参数吗?
LoRA 给出的答案是:很多时候不需要。它冻结原始模型,在目标线性层旁边加上两个低秩矩阵,只训练这两个小矩阵。训练完成后,基础模型仍然保留,业务能力以一个小型适配器的形式存在。
我第一次看到 LoRA 公式时,觉得它像一个聪明的压缩技巧;真正理解后,我更愿意把它看成一种“控制改动范围”的工程设计。我们不再重新写整本模型,而是在已有能力旁边加一支小笔,记录某个任务需要的方向。它节省了资源,也让多任务切换、版本回滚和实验对比变得容易。
但 LoRA 不是魔法。低秩容量太小,任务学不进去;容量太大,可能过拟合;注入位置不合适,训练 loss 会下降,行为却没有改善。理解它的数学结构,才能知道什么时候该调 rank,什么时候该换数据或换方法。
一、全量微调到底在更新什么
Transformer 里有大量线性层。以一个权重矩阵 W 为例,普通全量微调会直接学习一个更新量 ΔW:
y = (W + ΔW)x
如果 W 的形状是 d_out × d_in,那么更新矩阵也有同样多的参数。对于一个 4096 × 4096 的层,单层更新就超过一千六百万个参数,还不包括梯度和优化器状态。
LoRA 不直接学习完整的 ΔW,而是把它近似成两个小矩阵的乘积:
ΔW ≈ B A
A:r × d_in
B:d_out × r
r 远小于 d_in 和 d_out
前向计算变成:
y = W x + (α / r) B A x
W 保持冻结,A 和 B 参与训练。只要秩 r 足够小,新增参数就远少于完整矩阵。
二、低秩为什么有机会表达任务变化
“低秩”听起来像强行限制模型,为什么还能学到有用能力?一个直觉是:很多任务变化并不需要任意修改模型的每个方向,而是在已有表示空间里调整少数重要方向。
基础模型能力空间
↓
任务需要的变化可能集中在较小子空间
↓
用低秩更新捕捉主要变化方向
这不是说所有任务变化天然低秩,而是大量实践表明,许多指令、风格和领域适配可以用相对小的更新近似。LoRA 用较少参数换来了一个可调的容量旋钮:rank 越高,表达能力越强,资源和过拟合风险也越高。
可以把 r 理解成允许适配器使用的变化方向数量。它不是任务难度的唯一指标,但能帮助我们从“全量修改”转向“只修改必要部分”。
三、参数量到底省了多少
完整矩阵的参数量是:
d_out × d_in
LoRA 两个矩阵的参数量是:
r × d_in + d_out × r
= r × (d_in + d_out)
当 d_in = d_out = 4096、r = 16 时:
全量:4096 × 4096 ≈ 16.8M
LoRA:16 × (4096 + 4096) ≈ 0.13M
单层新增参数只有完整更新的一小部分。模型有多个目标层时,参数仍然会累加,但通常远小于全量微调。
function loraParameterCount(
dIn: number,
dOut: number,
rank: number,
layerCount: number,
) {
return layerCount * rank * (dIn + dOut)
}
这段计算只估算 LoRA 参数,不包含基础模型、激活、梯度和优化器状态。不要看到参数量降低,就把它直接等同于总显存按同样比例下降。训练时序列长度和激活常常才是另一块大头。
四、A 和 B 为什么常见地这样初始化
LoRA 通常让一个矩阵随机初始化,另一个矩阵初始化为零,使得训练开始时 BA 接近零,适配器不会立刻破坏基础模型行为:
训练开始:ΔW ≈ 0
训练过程中:ΔW 逐渐学习任务变化
这个设计让微调从基础模型附近开始,降低一开始的行为跳变。具体初始化策略和缩放方式可以因实现而变化,但核心原则是让适配器能够平稳接入。
type LoraWeights = {
A: Matrix
B: Matrix
alpha: number
rank: number
}
function loraUpdate(weights: LoraWeights, x: Vector) {
const scale = weights.alpha / weights.rank
return scale * multiply(weights.B, multiply(weights.A, x))
}
alpha / rank 是一个重要缩放项。改变 rank 时,如果完全不考虑缩放,更新幅度可能随之改变,训练结果会难以比较。实际训练中要把 rank、alpha、学习率作为一组超参数观察,而不是单独调一个数字。
五、LoRA 通常注入哪些层
Transformer 注意力模块包含 Query、Key、Value 和输出投影,前馈网络也有多个线性层。常见做法是先给注意力投影加 LoRA:
Attention:q_proj / k_proj / v_proj / o_proj
FFN:gate_proj / up_proj / down_proj
注入更多层会增加容量和显存,也可能提高任务适配能力。注入更少层则更轻量,适合风格或窄任务。没有通用答案,应该用固定评测集比较。
type TargetModulePlan = {
modules: string[]
trainableParameters: number
expectedEffect: 'style' | 'format' | 'domain' | 'broad'
}
const attentionOnly: TargetModulePlan = {
modules: ['q_proj', 'v_proj'],
trainableParameters: 0,
expectedEffect: 'style',
}
q_proj 和 v_proj 的组合常被用作轻量起点,但不能把经验配置当成定律。不同架构的模块命名和作用不同,加载前要确认模型实际层名。
六、rank 不是越大越好
rank 太小,适配器可能无法表达任务变化,表现为训练和验证都没有提升;rank 太大,参数和训练成本增加,还可能记住训练样本的措辞。
rank 小 → 成本低、改动小、容量有限
rank 大 → 容量高、成本高、过拟合风险高
可以先用小 rank 做基线,如果独立测试集仍然明显欠拟合,再逐步增加。判断标准不是训练 loss 是否还能下降,而是验证任务是否获得稳定收益。
不同层也可能需要不同 rank。重要的是先建立简单、可解释的配置,再在数据足够时做更精细的分配。为了假想的未来需求一次性给所有层最高 rank,通常属于过度设计。
七、LoRA 和灾难性遗忘
全量微调可能让模型过度偏向新数据,忘记原本的通用能力。LoRA 冻结基础权重,天然保留了一部分原能力,也方便加载多个适配器:
基础模型
├─ 客服 LoRA
├─ 代码 LoRA
└─ 法务 LoRA
但“基础模型冻结”不等于完全没有遗忘。适配器仍可能让输出分布大幅变化,尤其是训练数据偏置严重或 rank 很高时。部署前要评测通用问答、拒答、长上下文和安全行为。
多个适配器混合时更要谨慎。直接把不同任务的更新相加,可能互相干扰;适配器的训练数据、目标和缩放不同,不能假设叠加一定有效。先做独立加载,再做组合实验,并保留清晰的版本和回滚路径。
八、LoRA 的训练流程
一个标准流程可以写成:
加载冻结基础模型
↓
在目标线性层插入 LoRA
↓
只将 A、B 设置为可训练
↓
准备并清洗指令数据
↓
使用训练模板和正确 loss mask
↓
验证、保存适配器并做回归
type TrainableParameter = {
name: string
requiresGrad: boolean
}
function configureLora(model: Model, targets: string[]) {
for (const parameter of model.parameters()) {
parameter.requiresGrad = false
}
for (const module of findModules(model, targets)) {
module.attachLora({ rank: 16, alpha: 32 })
module.loraA.requiresGrad = true
module.loraB.requiresGrad = true
}
}
训练开始前打印可训练参数数量和比例。很多“训练成功但模型没变化”的问题,根因是适配器没有正确插入或所有参数都被冻结;相反,若可训练参数远超预期,可能把基础层误解冻了。
九、学习率和数据量要一起看
LoRA 参数少,学习率常常可以比全量微调更高,但不能脱离数据规模和质量讨论。小数据配高学习率和多轮训练,很容易让适配器记住样本措辞。
数据少 → 小 rank、少轮数、强验证
数据多 → 可尝试更高容量,但仍需独立测试
数据噪声大 → 先清洗,不要靠训练修复
训练过程要观察训练 loss、验证 loss、目标任务准确率、输出长度、重复率和通用能力。LoRA 训练很快,过拟合也可能来得很快。每隔固定步数保存适配器,使用相同样本生成对比,而不是等最后一步才看结果。
十、适配器合并和独立加载
LoRA 训练结束后,有两种常见部署方式。独立加载保留基础模型和适配器分离,便于多任务切换和版本管理;合并则把 BA 加回 W,推理时可以像普通模型一样使用。
独立:W + adapter → 运行时组合
合并:W' = W + (α / r)BA → 普通推理
合并时要确认 dtype、量化状态和数值误差。合并后的权重不一定能无损拆回原适配器,操作前要保留原始基础模型和 adapter。部署版本必须记录基础模型、Tokenizer、模板、适配器和推理参数。
十一、LoRA 的能力边界
LoRA 很适合改变风格、格式和窄领域行为,但它不是实时知识库。每天变化的价格、库存、政策和用户权限,应由 RAG、数据库和工具提供。把需要频繁更新的事实硬训练进 adapter,更新成本高,还可能产生过期答案。
它也不是安全边界。即使适配器训练了“不要越权”,真正的权限仍然要在服务端检查;即使模型学会输出 JSON,关键字段仍然需要 Schema 和业务规则验证。
当任务变化非常大、需要重塑基础能力、或小 rank 长期无法达到目标时,全量微调、持续预训练、检索增强或更换基础模型可能更合适。LoRA 是工具,不是信仰。
十二、如何评测 LoRA 是否真的有效
至少建立三组样本:目标任务、通用回归和安全边界。比较基础模型、Prompt 方案和 LoRA 方案:
type LoraReport = {
targetAccuracy: number
schemaPassRate: number
generalCapabilityDelta: number
safetyRegression: number
avgOutputTokens: number
adapterSizeMb: number
p95LatencyMs: number
}
评测要按任务类型和难度分组,保留失败样本。一个模型在训练集风格上变得更像业务,却在新用户表达上失效,不能算成功;一个适配器体积很小,但质量没有超过 Prompt,也可能不值得维护。
十三、我的总结:用小更新保存大能力
LoRA 的核心公式很短:用 BA 近似完整的 ΔW,冻结 W,只训练两个低秩矩阵。真正值得学习的是它背后的设计思想:不要为了一个局部任务修改整个系统,先寻找最小而足够的变化范围。
它降低了训练参数、显存和存储成本,也让适配器可以独立版本化、切换和回滚。但低成本不代表低要求:数据仍然要清洗,模板仍然要一致,loss mask 仍然要正确,通用能力和安全边界仍然要回归。
我现在做 LoRA 实验,会先从小 rank 和少量目标层开始,打印可训练参数,跑最小数据闭环,再根据独立验证集决定是否增加容量。若结果不好,先问数据和任务是否合理,而不是机械地把 rank 调大。
大模型的价值在基础能力,业务适配需要的往往只是一个方向性的调整。LoRA 给了我们一支轻巧的笔:它不能替你写好内容,却能让你在不重做整本书的情况下,为不同任务添加清楚、可撤回、可比较的一层能力。把这层能力管理好,参数高效微调才真正成为工程,而不是一次偶然的显存技巧。