1. 先把问题摆清楚全参数微调的三笔账BERT 在 IMDB 上做情感二分类这个组合几乎是所有 NLP 从业者的“入门第一课”。110M 参数、2.5 万条训练样本、二分类头单卡跑起来毫不费力。也正因为太顺手很多人从来没认真算过全参数微调的真实成本直到把场景换成一个基座模型要服务十几个下游任务或者把 BERT-base 换成参数量大两个数量级的模型才发现账本上的数字突然变得刺眼。LoRALow-Rank Adaptation低秩自适应就是在这本账上做文章的技术它不动基座模型的原始权重只在旁路插入一小对低秩矩阵来学习“任务增量”从而把可训练参数压到原来的千分之几。这一章先把痛点摊开后面讲原理和代码才有落点。1.1 显存账优化器状态才是真正的大头很多人对微调显存的直觉是“参数有多少显存就占多少”这个直觉偏差非常大。以 BERT-base 为例1.1 亿参数用 fp32 存储是 440MB梯度再 440MB看着还好。但 Adam 系列优化器要为每个参数维护一阶动量和二阶动量又是两份 440MB光这三项就 1.76GB 打底。真正吃显存的是激活值batch size 32、序列长度 256、12 层 Transformer中间激活全都要留着做反向传播这一块轻松吃掉 5GB 到 8GB。你要是再开 fp32 全精度训练、不打开梯度检查点单卡 16GB 都可能吃紧。LoRA 把可训练参数压到 30 万这个量级优化器状态直接从 880MB 掉到 1MB 多一点几乎可以忽略。激活值那部分省不掉因为你还是要前向传播整张计算图但整体显存占用能下降 30% 到 40%。这个降幅在 BERT 上不算震撼可一旦换到参数量大几十倍的模型上就是“能不能在现有卡上跑起来”的生死线。我在一张 24GB 的卡上做过对比BERT-base、max_len 256、batch 32、fp16 混合精度全参数微调峰值显存约 15.8GB挂上 LoRA 之后约 9.2GB。同一条命令同一份数据唯一的差别就是requires_grad打开的范围。1.2 存储账与切换账一个任务一份权重第二笔账是存储。全参数微调做完你要给每个下游任务保存一份完整的 440MB 权重文件。十个任务就是 4.4GB一百个租户就是 44GB而且每一份都包含了大量重复的、几乎没变的基座参数。从工程角度看这非常浪费——99% 的字节是完全一样的只是被复制了一百遍。LoRA 的适配器权重是什么体量r8、只挂在 query 和 value 上总共 294912 个参数fp32 存下来约 1.2MBfp16 约 0.6MB。一个任务一份几百个任务也就几百兆。第三笔账是切换成本。全参数微调的模型切任务就得把整份权重重新加载进显存一张卡上同时驻留三个任务的模型基本就别想了。LoRA 的做法是基座只加载一份常驻显存任务切换时只换那个几 MB 的适配器加载耗时从秒级降到毫秒级。做在线多租户推理、做 A/B 实验、做快速回滚这个特性带来的工程便利远大于参数量的数字游戏。1.3 低秩假设为什么改一点点就够用LoRA 的核心假设只有一句话模型在适配某个下游任务时权重矩阵的变化量具有很低的“内在秩”。换成生活化的说法一栋大楼要改造成咖啡馆你不需要把承重墙全部推倒重建只要在几个关键位置加装隔断、灯光和吧台就能彻底改变它的功能属性。改造量很小但方向必须对。这个假设不是拍脑袋。早期的实证观察发现微调前后权重矩阵的差值 ΔW其奇异值谱衰减得非常快——前几个奇异值就占了绝大部分能量后面的几乎为零。也就是说虽然 ΔW 表面上是个 768×768 的稠密矩阵但它真正“有效”的自由度可能只有个位数到几十个。所以 LoRA 直接把这个结构约束写进模型里强制 ΔW 分解成两个瘦长矩阵的乘积 B·A其中 B 是 768×rA 是 r×768r 取 4、8、16 这种小数字。参数总量从 768×768≈59 万降到 r×(768768)12288r8 时压缩了 48 倍。而因为低秩假设成立这么狠的压缩几乎没有损失表达能力。注意低秩假设在分类这类“任务偏移较小”的场景下非常稳但如果下游任务和预训练目标差异极大比如从通用语料切到高度专业的领域文本r 设得太小会明显欠拟合这时候要往上调。1.4 为什么拿 BERT IMDB 当验证节点有人会问LoRA 的价值明明在大模型上为什么还要用 BERT 做实验理由很实在。第一成本低。一张消费级显卡半小时能跑完一轮对照实验你可以放心地把 r 从 4 试到 32把 target_modules 换三种组合这在几十亿参数的模型上是不可能的。第二变量干净。BERT 结构简单、社区熟悉任何效果波动都能明确归因到超参数上不像大模型那样一堆玄学。第三IMDB 是个二分类任务准确率这个指标直观、方差小2.5 万条测试样本足够把 0.5 个百分点的差异区分出来。把 BERT 上的这套流程跑通、把参数账算明白、把坑踩一遍换到任何基座模型上代码结构几乎不用改只是模型名和模块名换个字符串。这就是我坚持从这个组合入手的原因。2. LoRA 的数学骨架与代码骨架原理部分我不想写成公式堆砌但该有的推导必须有否则调参时你不知道自己在调什么。这一章从矩阵分解讲到初始化再落到二十行能跑的手写实现最后讨论模块挂载位置的选择——这三点是 LoRA 落地时最容易被忽略、又最影响结果的地方。2.1 从 ΔW 到 B·A一次有约束的降维标准全参数微调的更新式是 W W₀ ΔWW₀ 是预训练权重ΔW 是梯度下降学出来的增量两者同维度都是 d×k。LoRA 把 ΔW 换成一个受约束的形式ΔW B · A · (alpha / r)其中 A 的形状是 r×kB 的形状是 d×rr 远小于 min(d,k)。前向传播变成h W₀x (alpha / r) · B · A · x注意这里 A 先把输入从 k 维压到 r 维B 再把 r 维升回 d 维。整个旁路就是一个“瓶颈结构”和自编码器的思路同源。原始权重 W₀ 全程冻结只有 A 和 B 参与梯度更新。参数量怎么算单个矩阵是 r×k d×r r(dk)。BERT-base 的 hidden 是 768r8 时单个矩阵 12288 个参数对比原始的 589824压缩比 48:1。这就是全部秘密没有任何魔法。2.2 初始化与缩放因子B 置零这个细节很关键初始化方式直接决定训练能否稳定起步。LoRA 的做法是A 用高斯分布随机初始化B 全部置零。这样训练开始时 B·A 0整个旁路输出为零模型行为和原始预训练模型完全一致。这个设计很巧妙——你相当于从一个“已验证可用的解”出发做扰动而不是从一个随机噪声点出发。我见过有人把 B 也随机初始化结果前几百步 loss 剧烈震荡学习率还得推倒重调。这不是玄学是起点被破坏了。缩放因子alpha / r是另一个容易忽略的点。它的作用是让不同 r 下的学习动态保持一致r 变大时B·A 的数值规模会自然变大除以 r 就把这个影响抵消掉了调 r 的时候不用同步改学习率。经验上 alpha 取 2rr8 时 alpha16比较稳也有人取 r 本身效果差别不大但 alpha 和 r 的比例一旦定下来就别乱动否则超参数之间会互相干扰。2.3 手写一个 LoRALinear二十行看透本质理解了公式实现就是体力活。不想依赖任何封装库的话可以这样写import torch import torch.nn as nn import math class LoRALinear(nn.Module): def __init__(self, base: nn.Linear, r8, alpha16, dropout0.1): super().__init__() self.base base for p in self.base.parameters(): p.requires_grad False # 冻结原始权重 self.r r self.scaling alpha / r in_f, out_f base.in_features, base.out_features self.lora_A nn.Parameter(torch.empty(r, in_f)) self.lora_B nn.Parameter(torch.zeros(out_f, r)) # B 置零 nn.init.kaiming_uniform_(self.lora_A, amath.sqrt(5)) self.dropout nn.Dropout(dropout) def forward(self, x): base_out self.base(x) # x: (..., in_f) - A^T - (..., r) - B^T - (..., out_f) lora_out (self.dropout(x) self.lora_A.T) self.lora_B.T return base_out lora_out * self.scaling把它替换进 BERT 的注意力模块遍历所有名为query和value的子模块换掉即可def inject_lora(module, r8, alpha16, targets(query, value)): for name, child in module.named_children(): if isinstance(child, nn.Linear) and name in targets: setattr(module, name, LoRALinear(child, r, alpha)) else: inject_lora(child, r, alpha, targets) inject_lora(model.bert)这里有个细节值得说nn.Linear的权重形状是(out_features, in_features)计算是y x W.T b。所以 LoRA 旁路必须是x A.T B.T维度才对得上。写成x A B会直接报形状错误这是新手最常见的翻车点。提示手写实现适合理解原理和小规模实验。生产环境建议用成熟的适配器库它们在混合精度、梯度检查点、权重合并、多适配器热切换上都做了处理自己造轮子容易在这些边角上出问题。2.4 target_modules 挂哪里q/v 是性价比之王BERT 的每个 Transformer 层里注意力部分有 query、key、value、dense 四个 768×768 矩阵前馈网络有两个 768×3072 的矩阵。全挂上当然表达力最强但参数量会上去收益却未必线性增长。我做过几组对照结论是只挂 query 和 value 的性价比最高。原因不难理解query 决定“我要找什么”value 决定“我拿什么内容回来”这两者直接塑造了注意力的语义选择key 更多是配合 query 做匹配改动它的边际收益低输出投影 dense 和前馈层更多是特征变换挂上去提升有限但参数翻倍。如果你的任务更复杂比如需要细粒度语义改写、长文本推理可以加上dense和intermediate.denser 相应调小一半。但如果只是做情感分类、意图识别这类任务q/v 组合完全够用。不同框架下模块的命名不一样动手前务必打印一遍for name, _ in model.named_modules(): if attention in name: print(name)你会看到类似bert.encoder.layer.0.attention.self.query的路径。用target_modules[query, value]这种后缀匹配比写全路径省事得多也能自动覆盖所有层。3. 动手前的准备环境、数据与基线真正决定实验能不能复现的往往是这些不起眼的准备工作。版本不匹配、验证集没切、随机种子没固定任何一项出问题后面所有对比数字都不可信。这一章把我认为必须提前做对的事情列一遍。3.1 依赖版本与硬件清单我这套流程实测可用的组合是Python 3.10、PyTorch 2.1 及以上、transformers 4.40 及以上、peft 0.10 及以上、datasets 2.x、scikit-learn。版本提示不是摆设——TrainingArguments里评估策略的参数名在 4.41 前后从evaluation_strategy改成了eval_strategy老代码直接报unexpected keyword argument很多人卡在这一步以为是环境坏了。硬件方面单卡 16GB 显存足够跑完全部实验8GB 也能跑但 batch size 要降到 8 并开梯度累积。没有 GPU 的话 CPU 也能跑通流程只是三轮训练要几个小时不建议用来做对照实验。另外强烈建议固定随机种子并把transformers.set_seed(42)放在脚本最前面。IMDB 只有二分类不同种子之间的准确率波动能到 0.3 个百分点不固定种子的话你分不清效果差异是来自超参数还是来自噪声。3.2 IMDB 的 tokenize 与验证集切分IMDB 数据集只有 train 和 test 两个划分各 25000 条没有验证集。很多人直接把 test 当验证集用边训练边看 test 准确率这等于把测试集信息泄进模型选择过程最终报告的指标是虚高的。正确做法是从 train 里切一部分出来from datasets import load_dataset from transformers import AutoTokenizer ds load_dataset(imdb) tok AutoTokenizer.from_pretrained(bert-base-uncased) def preprocess(ex): return tok(ex[text], truncationTrue, max_length256) ds ds.map(preprocess, batchedTrue) ds ds.rename_column(label, labels) ds ds.set_format(torch, columns[input_ids, attention_mask, labels]) split ds[train].train_test_split(test_size0.08, seed42) train_ds, val_ds, test_ds split[train], split[test], ds[test]两个细节。第一max_length256是权衡结果IMDB 的评论长度中位数在 180 词左右256 能覆盖八成以上样本再往上加收益很小但显存和耗时线性增长。如果你在意长尾可以统计一下 token 长度分布再定。第二truncationTrue默认从右侧截断对情感分类来说评论开头往往信息量更大实际上保留开头是合理的如果做的是结论抽取类任务就得考虑截断方向。3.3 先跑一个全参数基线别急着上 LoRA这一步最容易被跳过但它是整个实验的锚点。没有基线你看到 LoRA 跑出 92.8% 的准确率根本不知道这是好还是坏——也许全参数微调也就 93%那 LoRA 的收益就只剩工程层面的也许全参数能到 95%那说明 LoRA 的配置还有优化空间。基线配置很朴素不冻任何参数学习率 2e-5batch 323 轮warmup 比例 0.06fp16。跑完之后记录三个数字验证集准确率、峰值显存、单轮耗时。这三个数字是后面所有对照的参照系。我在同一台机器上测到的基线是验证准确率 93.6%峰值显存 15.8GB单轮约 17 分钟。这个成绩在 BERT-base IMDB 上属于正常水平网上流传的 95% 以上的结果多半动了数据划分或者做了额外技巧不必强求对齐。注意跑基线时一定要用和 LoRA 实验完全一致的数据划分、tokenizer、随机种子。数据划分一变两个数字就不可比了这是我踩过的坑——切验证集时用了不同的 seed结果两次实验差了 0.4 个点排查了半天才发现是数据不同。4. 把 LoRA 挂到 BERT 上完整实操流程准备工作做完真正挂 LoRA 的代码量其实很少。关键在于每一步都要核对中间结果尤其是参数统计那一环它是判断“配置有没有生效”的唯一可靠依据。4.1 构建 LoRA 模型并核对参数用 peft 库核心就几行from transformers import AutoModelForSequenceClassification from peft import LoraConfig, get_peft_model, TaskType base AutoModelForSequenceClassification.from_pretrained( bert-base-uncased, num_labels2 ) cfg LoraConfig( task_typeTaskType.SEQ_CLS, r8, lora_alpha16, target_modules[query, value], lora_dropout0.1, biasnone, ) model get_peft_model(base, cfg) model.print_trainable_parameters()输出应该是trainable params: 296,450 || all params: 109,778,946 || trainable%: 0.27。这个数字要能自己算出来才对12 层 × 2 个矩阵 × (768×8 768×8) 294912加上分类头的 768×22 1538一共 296450。如果你打印出来的数字是 1.1 亿说明基座没冻住如果是 0说明target_modules一个都没匹配上。这两种情况都很常见别嫌麻烦每次改配置都看一眼这个数。biasnone的含义是偏置项也不训练。有人会问要不要把 LayerNorm 的参数放开我的经验是分类任务里放开也就能提升 0.1 到 0.2 个点但可训练参数会多好几万性价比不高除非你的数据分布和预训练语料差异特别大。4.2 训练参数怎么定学习率要比全参数微调大这是 LoRA 调参最反直觉的一点。全参数微调用 2e-5 是常识但 LoRA 用 2e-5 会明显欠拟合我一开始就栽在这里loss 降得非常慢三轮下来准确率只有 89%。原因在于LoRA 训练的是一组从零开始的新参数它们没有预训练积累需要更大的步长才能快速找到方向而全参数微调的权重已经在一个很好的位置附近步子大了反而会破坏原有知识。经验值是全参数的 5 到 10 倍即1e-4 到 5e-4我常用的起点是 2e-4。其他参数参数取值说明learning_rate2e-4LoRA 侧参数比全参数微调大一个量级per_device_train_batch_size32显存够就往上加对齐效果好num_train_epochs3IMDB 上 2 到 3 轮足够第 4 轮开始过拟合warmup_ratio0.06防止前几百步震荡weight_decay0.01对低秩矩阵有轻微正则作用lr_scheduler_typelinear简单可靠没必要上 cosinefp16True显存减半准确率几乎无损还有一个小技巧因为只训练 30 万参数单步计算量小训练会显得“喂不饱”GPU可以把 dataloader 的num_workers调到 4dataloader_pin_memoryTrue能省下不少数据加载的等待时间。4.3 训练与评估Trainer 的配置和常规微调没区别只是指标计算要单独写import numpy as np from sklearn.metrics import accuracy_score, f1_score def compute_metrics(eval_pred): logits, labels eval_pred preds np.argmax(logits, axis-1) return { accuracy: accuracy_score(labels, preds), f1: f1_score(labels, preds), }然后组装from transformers import TrainingArguments, Trainer args TrainingArguments( output_dir./lora-imdb, per_device_train_batch_size32, per_device_eval_batch_size64, learning_rate2e-4, num_train_epochs3, warmup_ratio0.06, weight_decay0.01, eval_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, fp16True, logging_steps50, report_tonone, ) trainer Trainer( modelmodel, argsargs, train_datasettrain_ds, eval_datasetval_ds, compute_metricscompute_metrics, ) trainer.train()训练过程中盯着两个信号训练 loss 是否在前 200 步内明显下降不降就是学习率或模块匹配有问题验证准确率是否在第 2 轮后趋于平缓继续涨说明还能多跑一轮开始掉就是过拟合。我这次跑下来第 1 轮验证准确率 91.9%第 2 轮 92.7%第 3 轮 92.8%曲线基本在第 3 轮走平。最终测试集 92.6%比全参数基线低 1 个点。4.4 合并权重与交付训练完有两种交付形态用途完全不同。第一种是只存适配器文件几 MB适合多任务共存model.save_pretrained(./lora_adapter)加载时先建同构基座再挂适配器from peft import PeftModel base AutoModelForSequenceClassification.from_pretrained( bert-base-uncased, num_labels2 ) model PeftModel.from_pretrained(base, ./lora_adapter)第二种是合并回基座得到一份普通模型推理时零额外开销merged model.merge_and_unload() merged.save_pretrained(./bert-imdb-merged)合并的数学就是 W W₀ (alpha/r)·B·A一次性算完写回权重。实测合并后推理速度比“基座 适配器”的动态挂载快 8% 左右因为省掉了旁路的两