27届大模型岗面试准备(八):参数高效微调 PEFT——LoRA/QLoRA/Adapter 原理、显存账与手写注入
上一篇讲了 RLHF 和 DPO,无论走哪条对齐路线,都绕不开一个现实问题:全参数微调一个 7B 模型需要多少显存?答案是至少 100GB 以上——这对绝大多数团队是不可接受的。参数高效微调(PEFT,Parameter-Efficient Fine-Tuning)就是为了解决这个问题,而其中的 LoRA 几乎是27届面试的必考题:原理推导、超参选择、显存计算、和全参微调的效果差距,四连问是标准套路。
这一篇把 PEFT 的主流方法讲透,重点是 LoRA 和 QLoRA,最后手写一个不依赖 peft 库的 LoRA 注入实现——面试手撕环节真的会考。
一、先算清楚:全参微调到底贵在哪
面试官问"为什么需要 PEFT",不要只说"省显存",要能把显存账算出来。以 7B 模型、BF16 训练、Adam 优化器为例:
- 模型权重:7B × 2 字节 = 14 GB
- 梯度:7B × 2 字节 = 14 GB
- Adam 优化器状态:一阶动量 + 二阶动量,通常以 FP32 存储,7B × 4 × 2 = 56 GB
- FP32 主权重副本(混合精度训练需要):7B × 4 = 28 GB
- 激活值:与 batch size、序列长度、是否开梯度检查点有关,数 GB 到数十 GB
合计约112 GB + 激活值,单卡 A100 80GB 都放不下,必须多卡 + ZeRO。而推理只需要 14GB——训练贵就贵在梯度和优化器状态是权重的 5 倍。
PEFT 的核心思路:冻结原模型权重,只训练极少量新增参数。梯度和优化器状态只为新增参数分配,显存立刻塌缩下来。
二、PEFT 方法谱系
PEFT 不止 LoRA 一家,面试要能说出谱系和各自的代价:
| 方法 | 核心思路 | 可训练参数量 | 推理额外延迟 | 主要缺点 |
|---|---|---|---|---|
| Adapter (2019) | 在 FFN 后插入瓶颈层(降维→非线性→升维) | ~1% | 有(串行结构) | 增加推理延迟 |
| Prefix Tuning | 在每层 KV 前拼接可训练前缀向量 | ~0.1% | 有(占用上下文) | 压缩有效序列长度、训练不稳定 |
| Prompt Tuning | 只在输入层加软提示向量 | ~0.01% | 轻微 | 小模型上效果差 |
| LoRA (2021) | 用低秩矩阵 BA 学习权重增量 ΔW | 0.1%~1% | 无(可合并) | 秩的选择需要调参 |
| QLoRA (2023) | 4-bit 量化底座 + LoRA | 同 LoRA | 无(合并后) | 训练略慢(反量化开销) |
| DoRA (2024) | 把 ΔW 分解为幅度+方向分别学习 | 略高于 LoRA | 无 | 实现复杂度稍高 |
LoRA 胜出的关键就在"推理零额外延迟"这一格:训练完把 BA 合并回原权重(W' = W + BA),部署时和原模型结构完全一致。Adapter 和 Prefix 都做不到这一点。
三、LoRA 原理精讲
核心假设:微调时权重的变化量 ΔW 是低秩的。直觉解释:预训练模型已经学到了通用能力,下游任务适配只需要在少数几个方向上调整权重,不需要动整个满秩空间。这个假设有实证支撑——原论文测得 GPT-3 微调的 ΔW 有效秩(intrinsic rank)非常低,r=1~4 就能逼近全参效果。
数学形式:对一个线性层 W ∈ R^(d×k),LoRA 不直接更新 W,而是学习:
h = Wx + (α/r)·BAx
其中 A ∈ R^(r×k),B ∈ R^(d×r),r ≪ min(d,k)。可训练参数从 d×k 降到 r×(d+k)。以 4096×4096 的注意力投影、r=8 为例:参数量从 1678 万降到 6.5 万,压缩 256 倍。
三个必考细节:
- 初始化:A 用高斯初始化,B 初始化为全零。这样训练开始时 BA=0,模型输出和原模型完全一致,保证训练起点稳定。反过来(A 零、B 高斯)也能保证起点为零,但会导致 A 的梯度初始为零、更新不对称,实践用前者。
- 缩放因子 α/r:α 是常数(常取 16 或 32),除以 r 是为了在改变 r 时保持增量的量级稳定,减少重调学习率的需要。
- 挂载位置:原论文只挂 Q、V 投影;后续实践(QLoRA 论文)表明挂所有线性层(Q/K/V/O + FFN 的 gate/up/down)效果更好,是当前默认做法。
秩 r 怎么选:常见 8~64。任务和预训练分布差距越大(如中文医疗、代码),r 应越大;简单风格化任务 r=8 足够。r 增大收益边际递减,且过大会过拟合小数据集。
四、QLoRA:单卡 24GB 微调 33B 的魔法
QLoRA 的贡献是把"冻结的底座"压到 4-bit,让显存大头(模型权重)也塌下来。三个技术点面试常被追问:
1. NF4 量化(4-bit NormalFloat)。普通 INT4 是均匀量化,但神经网络权重近似正态分布——大部分值集中在 0 附近。NF4 按标准正态分布的分位数设置 16 个量化格点,让每个格点覆盖等概率质量,对正态分布权重是信息论最优的。这是"数据分布感知量化"的典型案例。
2. 双重量化(Double Quantization)。分块量化需要为每块存一个 FP32 的缩放常数(block=64 时平摊 0.5 bit/参数),QLoRA 把这些常数再量化到 8-bit,每参数再省 0.37 bit。7B 模型省约 0.3GB,不大但白捡。
3. 分页优化器(Paged Optimizer)。用 CUDA 统一内存把优化器状态在显存吃紧时换页到内存,防止长序列导致的 OOM 尖峰。
关键理解:QLoRA 中 4-bit 的只有冻结底座,LoRA 增量和计算仍在 BF16 进行——前向时把 4-bit 权重反量化为 BF16 再算。所以 QLoRA 训练比 LoRA 慢约 30%,是拿时间换显存。
显存对比(7B 模型微调):
| 方案 | 底座权重 | 梯度+优化器 | 大致总需求 |
|---|---|---|---|
| 全参微调 BF16 | 14 GB | ~98 GB | >112 GB |
| LoRA (r=16) | 14 GB | <1 GB | ~20 GB |
| QLoRA (r=16, NF4) | ~3.8 GB | <1 GB | ~9 GB |
QLoRA 论文的结论也要记住:4-bit 底座 + LoRA 在指令微调基准上可以追平 16-bit 全参微调——这是它敢叫"高效"而不叫"降级"的底气。
五、手写 LoRA 注入(不依赖 peft 库)
面试手撕环节的经典题:给你一个nn.Linear,把它替换成带 LoRA 的版本。完整可运行代码:
import torch import torch.nn as nn import math class LoRALinear(nn.Module): """把一个冻结的 nn.Linear 包装成 W x + (alpha/r) * B A x""" def __init__(self, base: nn.Linear, r=8, alpha=16, dropout=0.0): super().__init__() self.base = base for p in self.base.parameters(): # 冻结原权重 p.requires_grad_(False) self.r, self.scaling = r, alpha / r self.lora_A = nn.Parameter(torch.empty(r, base.in_features)) self.lora_B = nn.Parameter(torch.zeros(base.out_features, r)) nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) # A 高斯/均匀,B 全零 self.dropout = nn.Dropout(dropout) def forward(self, x): delta = self.dropout(x) @ self.lora_A.T @ self.lora_B.T return self.base(x) + self.scaling * delta @torch.no_grad() def merge(self): """训练结束后合并:W <- W + (alpha/r) * B @ A,推理零开销""" self.base.weight += self.scaling * (self.lora_B @ self.lora_A) def inject_lora(model: nn.Module, targets=("q_proj", "v_proj"), r=8, alpha=16): """递归遍历模型,把名字命中 targets 的 Linear 换成 LoRALinear""" for name, child in model.named_children(): if isinstance(child, nn.Linear) and any(t in name for t in targets): setattr(model, name, LoRALinear(child, r=r, alpha=alpha)) else: inject_lora(child, targets, r, alpha) return model # ---- 演示:一个迷你注意力块 ---- class TinyAttn(nn.Module): def __init__(self, d=64): super().__init__() self.q_proj = nn.Linear(d, d) self.k_proj = nn.Linear(d, d) self.v_proj = nn.Linear(d, d) model = TinyAttn() inject_lora(model, targets=("q_proj", "v_proj"), r=4, 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} | 可训练 {trainable} | 占比 {trainable/total:.2%}") x = torch.randn(2, 10, 64) y_before = model.q_proj(x) # B 初始为零 => 输出等于原模型 base_out = model.q_proj.base(x) print("注入后初始输出与原模型一致:", torch.allclose(y_before, base_out)) # 模拟一步训练后合并 model.q_proj.lora_B.data.normal_(0, 0.02) merged_ref = model.q_proj(x) # 合并前(走 LoRA 分支) model.q_proj.merge() merged_out = model.q_proj.base(x) # 合并后(只走原 Linear) print("合并后输出一致:", torch.allclose(merged_ref, merged_out, atol=1e-5))运行结果三行输出分别验证了 LoRA 的三个关键性质:可训练参数占比极小(本例约 2%)、零初始化保证起点等价、合并后推理与训练时数值一致。把这三个验证点讲给面试官,比背十遍论文都有说服力。
实际项目中用 HuggingFace peft 库两行搞定(LoraConfig+get_peft_model),但原理实现必须会——"peft 库里 merge_and_unload 做了什么"这种问题就是在筛掉只会调库的人。
六、工程实践要点与高频追问
多 LoRA 服务:一个底座 + N 个业务各自的 LoRA 权重(每个几十 MB),推理时按请求动态切换——vLLM 的 multi-LoRA 功能支持同 batch 混合不同 adapter。这是"一个基座服务全公司"的标准架构,面试聊到部署时主动提这个是加分项。
LoRA 的局限(被问"LoRA 是万能的吗"时用):
- 注入新知识的能力弱于全参微调——低秩假设对"风格/格式适配"成立,对"大量新领域知识"不成立,后者更适合继续预训练或 RAG;
- 大学习率下训练不稳定,常用比全参微调大 10 倍左右的 lr 但要配 warmup;
- r 和挂载位置是新增超参,需要实验。
高频面试题清单:
- LoRA 为什么 B 矩阵初始化为零?(保证起点等价于原模型)
- α/r 缩放的作用?(换 r 时保持增量量级稳定)
- QLoRA 的 NF4 比 INT4 好在哪?(按正态分位数量化,等概率格点,信息论最优)
- LoRA 挂在哪些层效果最好?(全线性层 > 只挂 QV)
- LoRA 训练完怎么部署?(merge 回底座,或 multi-LoRA 动态加载)
- 什么场景不该用 LoRA?(大规模知识注入、与预训练分布差异极大的任务)
至此,训练侧的主线(预训练 → SFT → RLHF/DPO → PEFT)全部讲完。下一篇进入推理侧:量化实战 INT8/INT4/GPTQ/AWQ。