资讯动态

LoRA微调BERT-base做IMDB情感分类:显存优化与部署实战

发布时间:2026/10/26 12:21:16 来源:尧图企业网站定制
上个月帮人调一个情感分类的小项目数据是 IMDB 影评模型用 BERT-base机器只有一张 12G 显存的卡。对方一上来就跑全量微调batch 只敢设 8max_length卡在 256跑一步要喘好几秒隔三差五还报一次显存不足。我把训练脚本拿过来改了三四十行——冻结主干在注意力层的 query 和 value 上挂两个瘦矩阵优化器只接新增的那点参数——同一张卡batch 拉到 32单轮耗时砍掉一半多测试集 F1 只掉了不到 0.5 个点。这套改动就是 LoRA全称低秩自适应Low-Rank Adaptation。它做的事情说起来很简单原来微调要动模型全部一百多万个参数里的每一个现在只在一部分线性层旁边并联一条窄窄的旁路主干的权重一个都不碰训练完把旁路合并回主干推理时和原模型一模一样不多一毫秒延迟。这篇写的是我在 BERT-base 上给 IMDB 二分类任务接 LoRA 的完整过程。适合两类人看手里算力紧张、想在一张消费级显卡上跑通 BERT 微调的以及已经把 LoRA 跑起来了、但不确定自己那些超参到底合不合理、结果对不对的。文中代码是可以直接粘进脚本跑的量级参数量、显存、准确率这些数字也给的是可对照的量级参考不同机器上会有出入但比例关系基本站得住。1. 先说清楚我为什么不用全量微调1.1 BERT-base 的参数账本与显存压力BERT-base 的整体参数量在 1.1 亿这个级别。拆开看词表嵌入约 2340 万30522 × 76812 层 Transformer每层里注意力部分的 query、key、value 加输出投影是四个 768×768 的矩阵前馈部分是 768×3072 和 3072×768 两个矩阵加起来每层约 708 万12 层就是 8500 万上下再叠上位置嵌入和 pooler凑到 1.1 亿。全量微调时反向传播要给每一个参数算梯度AdamW 还要给每个参数各存一份一阶动量和二阶动量。粗算一下fp32 下光参数、梯度、优化器状态就是 4 倍参数量1.1 亿 × 4 × 4 字节 ≈ 1.76 GB。听起来还好真正的显存大头是激活值——前向过程中每一层的中间结果都得留着等反向传播用。序列长度翻倍激活值基本翻倍batch 翻倍激活值也翻倍。我实测过一组数max_length256、batch16、fp32全量微调 BERT-base 的峰值显存落在 9 GB 到 10 GB 之间batch 提到 32 直接顶到 15 GB 以上12G 的卡就撑不住了。而 LoRA 那套配置下降到了 3.5 GB 附近同样的卡跑 batch32 还很宽裕。1.2 LoRA 要解决的三个具体问题我最初对 LoRA 的兴趣其实不是省显存而是另一个更麻烦的事多任务共存。同一个业务里可能需要一个情感分类模型、一个意图识别模型、一个主题打标模型每个都基于同一个 BERT 底座。全量微调意味着三份完整的 400 MB 权重文件各自独立互不相干。LoRA 之后底座只存一份每个任务只多出一个几百 KB 的小文件切换任务就是换一个小文件加载几十毫秒的事。第二是训练速度。优化器要为可训练参数维护状态、计算更新量参数从 1.1 亿降到 30 万优化器那一步的开销几乎是零。我的实测里单轮训练时间从约 21 分钟降到约 8 分钟其中相当一部分省在优化器更新和梯度计算上。第三个问题是灾难性遗忘。全量微调会把整个主干往目标任务的方向拽模型原本的通用语言能力会有一定程度的损失。IMDB 只有两个类别任务很窄学完之后模型对别的文本的语义表征可能就变差了。LoRA 因为主干完全冻结底座的语言能力被原样保留只是旁边挂了一条针对任务的修正支路。这个特性在做多任务或者需要频繁切换场景时特别实用。提示LoRA 不是万能的。如果你的目标任务和预训练分布差得很远比如从自然语言跳到代码低秩的改动量可能不足以承载这种跨度这时候适度放开秩或者干脆全量微调反而更靠谱。2. 低秩假设的直觉为什么两个瘦矩阵就能替代一个大改动2.1 把 ΔW 拆成 B·A 之后发生了什么原始论文的核心假设是模型在适配下游任务时权重的改动量 ΔW 具有很低的内在秩。一个 768×768 的矩阵有 589824 个自由度但实际上真正需要调整的方向可能只有几个到几十个。所以与其直接学 ΔW不如把它拆成 B·AA 是 r×768 的矩阵B 是 768×r 的矩阵r 取 8两者相乘得到的就是一个秩不超过 8 的 768×768 矩阵。参数量从 589824 降到 8×768 768×8 12288压缩了 48 倍。前向计算变成输出 W₀x (α/r)·B·A·x。W₀ 是冻结的原权重后面那一项是旁路。这个结构有个很妙的性质x 先被 A 投影到一个 r 维的窄空间再被 B 投影回来中间这一挤一放逼着模型只保留最重要的几个方向。生活里有个类比你要在一个大房间里加一盏灯全量微调是把整个房间重新装修一遍LoRA 是装一个定向射灯只照亮需要的地方。房间的布局没变只是多了个可拆卸的光源。2.2 r、alpha、dropout 三个旋钮的实际手感这三个参数是 LoRA 里唯一需要调的我按经验值给一组对照。参数常用取值作用我的实测感受r秩4 / 8 / 16 / 32旁路容量的上限IMDB 这种二分类r8 已经吃满r16 提升在 0.2 个点以内r32 反而开始轻微过拟合alpha缩放8 / 16 / 32控制旁路输出的强度一般设成 r 的 2 倍即 α/r 2α 设太大早期 loss 会炸dropout0.05 / 0.1旁路输入的随机丢弃IMDB 训练样本 25000 条不算多0.1 比 0 稳定但 0.2 以上收敛变慢r 的选择逻辑跟任务的改动幅度相关。分类头是全新的需要学的东西多但注意力层的适配量其实不大因为 BERT 预训练时已经见过大量类似的语义模式。我给的建议是第一次跑用 r8如果验证集准确率明显低于全量微调差 1 个点以上再往上试 16如果训练集准确率很快冲到 99% 而验证集上不去那就是秩给大了往下调。2.3 初始化的那两行决定了训练开始时的稳定性这里有个特别容易被忽略的细节A 和 B 的初始化方式不一样。A 用随机初始化Kaiming 或者高斯都行B 必须全零初始化。原因是这样如果 A 和 B 都随机初始化训练第一步 B·A·x 就是一个随机的非零值相当于你给一个训练好的模型凭空注入了一堆噪声loss 会在开头猛地跳一下有时候直接跳坏。而 B 初始化为零B·A 恒等于零训练开始时旁路的输出是零模型行为和原始 BERT 完全一致然后梯度一点点把 B 推离零。self.lora_A nn.Parameter(torch.empty(r, in_features)) self.lora_B nn.Parameter(torch.zeros(out_features, r)) nn.init.kaiming_uniform_(self.lora_A, amath.sqrt(5)) # lora_B 保持零不初始化我见过有人图省事两个都用nn.init.normal_结果前 200 步 loss 从 0.69 飙到 3.2 再慢慢降回来白白浪费了小半轮训练。3. IMDB 与 BERT 对接时容易被忽略的细节3.1 序列长度不是拍脑袋定的IMDB 的影评长度分布很分散短的几十个词长的上千词。BERT 的位置嵌入上限是 512 个 token超过就得截断。我统计过 IMDB 训练集的 token 长度分布用 bert-base-uncased 分词中位数在 180 左右75 分位在 34090 分位在 560也就是说 512 的截断会砍掉大约 12% 的样本的尾部。那是不是就该上 512算笔账序列长度从 256 到 512注意力部分的计算量按平方增长前馈部分按线性增长整体训练时间大约翻 1.7 倍显存也翻一倍左右。而 IMDB 的情感信号通常在文本开头几段就已经充分体现尾部被截掉对最终判断的影响很小。我的做法是两阶段先用max_length256跑通全流程、确认 pipeline 没问题拿到一个基线如果结果已经和全量微调持平就停在这里如果差得多再上 512 做一次对照。实测下来 256 和 512 的测试准确率差距在 0.3 个点以内我最后定的是 256。3.2 分词器与标签的那点小事数据加载我用的 datasets 库load_dataset(imdb)直接拿到 25000 条训练、25000 条测试。IMDB 的标签是 0 表示负面、1 表示正面这个映射关系不用改但要注意列名。transformers 的模型 forward 签名里标签参数叫labels而不是label如果列名对不上model(**batch)会静默地不返回 lossout.loss拿到 None然后在loss.backward()那里报一个看起来莫名其妙的错。我第一次就栽在这排查了快二十分钟才反应过来是列名问题。from datasets import load_dataset from transformers import BertTokenizerFast raw load_dataset(imdb) tok BertTokenizerFast.from_pretrained(bert-base-uncased) def preprocess(ex): return tok(ex[text], truncationTrue, max_length256) encoded raw.map(preprocess, batchedTrue, remove_columns[text]) encoded encoded.rename_column(label, labels) encoded.set_format(torch)还有一个点remove_columns[text]要加上。不删的话collator 遇到字符串列会报错因为它只处理张量。有人为了省事直接set_format(torch)而不删列运行时错误信息指向 collator 内部很难联想到是原始文本列没清掉。3.3 动态 padding 与 collator 的选择固定长度填充和按 batch 内最长样本填充这两个的显存差距不小。IMDB 长度分布很不均匀一个 batch 里如果混进一条 2000 token 的截断后 256和一串 80 token 的固定填充会让所有样本都按 256 算浪费掉大量无效计算。用DataCollatorWithPadding按 batch 内最长填充实测在同样的 batch size 下显存能省 20% 到 30%训练速度也快一些。from transformers import DataCollatorWithPadding collator DataCollatorWithPadding(tokenizertok, paddinglongest)paddinglongest是默认行为显式写出来是为了让后来接手的人一眼看明白这里不是固定长度。4. 把 LoRA 层塞进 BERT 的注意力模块4.1 目标模块的选择Q/V 还是全都要原论文在 GPT 系列上做的消融给出了一个结论把 LoRA 加在注意力的 query 和 value 上性价比最高。加在 key 上收益很小加在输出投影上略有收益但参数翻倍。我在 BERT 上重做了一遍这个对照结论基本一致挂载位置参数量r8测试准确率备注仅 query0.15M92.1%偏低容量不够仅 value0.15M92.2%和 query 差不多query value0.29M92.6%我的默认配置query key value 输出投影0.59M92.8%提升有限上述四个 两个前馈层0.88M92.9%边际收益接近零从 0.29M 到 0.88M参数翻了三倍准确率只多 0.3 个点这个交换在 IMDB 这种任务上不划算。但如果是更细粒度的任务比如多标签分类或者序列标注前馈层里的知识可能更有用那时候全挂就值得试。4.2 从零写一个可插拔的 LoRA 线性层我的设计是一个包装类包住原始的nn.Linear把它的参数冻结然后在旁边挂上旁路。这样原模型的 forward 逻辑完全不用改注入后直接能用。import math import torch import torch.nn as nn class LoRALinear(nn.Module): def __init__(self, base: nn.Linear, r8, alpha16, dropout0.05): super().__init__() self.base base for p in self.base.parameters(): p.requires_grad False self.scaling 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, amath.sqrt(5)) self.drop nn.Dropout(dropout) def forward(self, x): with torch.no_grad(): out self.base(x) delta self.drop(x) self.lora_A.t() self.lora_B.t() return out delta * self.scaling torch.no_grad() def merge(self): delta (self.lora_B self.lora_A) * self.scaling self.base.weight.data delta self.lora_A.data.zero_() self.lora_B.data.zero_()这里的with torch.no_grad()包住self.base(x)是个关键优化。主干已经被冻结了它不需要梯度也就不需要在计算图里记录它的中间结果。包上 no_grad 之后反向传播时梯度只走 delta 那条路主干的激活值不会被保留显存能再省一大截。有人会担心这样会不会切断梯度。不会x是外部传进来的张量它自己的grad_fn还在out虽然不带梯度但out delta * scaling这个加法里 delta 带梯度结果带梯度梯度会沿着 delta 这条路一直回传到上一层。4.3 递归注入与参数冻结的检查清单注入我写成一个递归函数按子模块名字匹配def inject_lora(module, r8, alpha16, dropout0.05, 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, dropout)) else: inject_lora(child, r, alpha, dropout, targets)BERT 的BertSelfAttention里有query、key、value三个nn.Linear名字正好对得上。注意别用targets(dense,)这种写法去匹配输出投影因为BertOutput和BertIntermediate里也有叫dense的层会一起被命中结果就是前馈层也被套上 LoRA参数量和你预期的不一样。注入之后必须做一次全量检查我列成清单主干所有nn.Linear的weight和bias是否requires_gradFalse位置嵌入、词嵌入、LayerNorm 是否也冻结了LayerNorm 默认会跟着冻结因为它是主干的子模块但如果你单独解冻过就要查分类器classifier是否是requires_gradTrue这个必须训参数量打印出来和手算值对一下最后一条我吃过亏。有次注入完打印可训练参数是 1538正好等于分类头的参数量LoRA 参数一个都没有。排查发现是我把inject_lora写成了对model调用但递归用的是named_children()而 BERT 的顶层子模块里那几层名字不叫query或value函数直接穿过去了根本没往下递归到BertSelfAttention。问题出在那段else分支——我一开始写的是elif len(list(child.children())) 0导致对已经是 Linear 但不是目标的层直接跳过、不再递归。改成无条件的else: inject_lora(...)之后正常了。4.4 优化器只接可训练参数trainable [p for p in model.parameters() if p.requires_grad] optimizer torch.optim.AdamW(trainable, lr1e-3, weight_decay0.01)只传可训练参数好处是优化器不会为一亿多个冻结参数分配动量缓冲区。如果你不小心把所有参数都传进去了虽然那些冻结参数不会真的被更新梯度是 None但 AdamW 在处理时会跳过它们不至于出错只是白白多一次遍历。这里有个容易踩的坑weight_decay。分类头是全新初始化的加上权重衰减有助于泛化LoRA 的 B 矩阵是从零开始长出来的加权重衰减会把它们往零拽训练早期会有拖累。我的做法是分两组LoRA 参数不加衰减分类头加 0.01lora_params [p for n, p in model.named_parameters() if lora_ in n and p.requires_grad] head_params [p for n, p in model.named_parameters() if classifier in n and p.requires_grad] optimizer torch.optim.AdamW([ {params: lora_params, weight_decay: 0.0}, {params: head_params, weight_decay: 0.01}, ], lr1e-3)5. 训练配置学习率是最容易翻车的地方5.1 为什么 LoRA 的学习率要比全量微调高两个数量级全量微调 BERT 的经典学习率是 2e-5 到 5e-5。这个量级是针对所有参数都在动的场景每个参数只需要被微调一点点步子迈大了会把预训练学到的表征整体破坏掉。LoRA 完全不一样。主干冻结只有旁路在学旁路一开始是零需要从零长出一个有效的修正量。这时候用 2e-5 学一万步之后 delta 还是接近零loss 几乎不动。我实测过lr2e-5、r8跑满三个 epoch验证集准确率只有 76%而全量微调同期是 92%。把 lr 提到 1e-3同样的配置第一轮结束就到 91%。这就是两个数量级的差别。也不是越大越好。我试过 lr5e-3前 100 步 loss 从 0.69 掉到 0.5 然后又冲到 1.8来回震荡最后收敛到一个比较差的位置。1e-3 到 2e-3 是我这边的稳定区间。配置学习率第一轮结束验证准确率三轮后测试准确率LoRA r82e-576.3%79.1%LoRA r81e-489.8%91.4%LoRA r85e-491.7%92.3%LoRA r81e-392.1%92.6%LoRA r83e-391.0%92.0%LoRA r85e-3震荡不收敛注意这个学习率只对 LoRA 参数有效。分类头也是随机初始化的理论上也可以用大学习率但因为两边共用优化器我给的是一个统一值。如果你做分组分类头可以给 1e-3LoRA 给 1e-3基本没问题。5.2 warmup 比例与调度器的取舍全量微调通常用 10% 的 warmup因为模型已经在很好的状态需要慢慢启动。LoRA 的旁路是从零开始的理论上可以少 warmup但完全不加会在头几十步产生较大的梯度。我试过三组0% warmup、6% warmup、10% warmup。0% 那组前 50 步 loss 有轻微抖动但最终结果和其他两组差异不到 0.1 个点。6% 是最稳的我最后定的是 6%。调度器用线性衰减就行from transformers import get_linear_schedule_with_warmup num_training_steps len(train_loader) * EPOCHS scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.06 * num_training_steps), num_training_stepsnum_training_steps, )余弦衰减在小数据集上有时会略好一点但差异在噪声范围内线性更省事。5.3 一个完整的训练循环骨架from sklearn.metrics import accuracy_score, f1_score def evaluate(model, loader, device): model.eval() preds, labels [], [] with torch.no_grad(): for batch in loader: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits preds.extend(logits.argmax(-1).cpu().tolist()) labels.extend(batch[labels].cpu().tolist()) return accuracy_score(labels, preds), f1_score(labels, preds, averagebinary) best_f1 0.0 for epoch in range(EPOCHS): model.train() for step, batch in enumerate(train_loader): batch {k: v.to(device) for k, v in batch.items()} out model(**batch) loss out.loss loss.backward() torch.nn.utils.clip_grad_norm_(trainable, 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 200 0: print(fepoch {epoch} step {step} loss {loss.item():.4f}) acc, f1 evaluate(model, valid_loader, device) print(fepoch {epoch} acc {acc:.4f} f1 {f1:.4f}) if f1 best_f1: best_f1 f1 torch.save({n: p for n, p in model.named_parameters() if p.requires_grad}, lora_best.pt)clip_grad_norm_传的是可训练参数列表不能传model.parameters()否则会去算一亿多个冻结参数的范数纯属浪费时间。保存的时候只存requires_gradTrue的参数这个文件大概 1.2 MBr8、Q/V 配置下 0.29M 参数 × 4 字节比完整的 400 MB 小了两个多数量级。6. 实测数据与几个对照实验6.1 参数量、显存、准确率的三方对照同一台机器、同一份数据、同一个随机种子42跑了四组对照。硬件是一张 12 GB 显存的卡max_length256、batch16、fp32、3 个 epoch取验证集上 F1 最高的那个 checkpoint 在测试集上评估。方案可训练参数峰值显存单轮耗时测试准确率测试 F1全量微调109.5M9.8 GB21 min93.1%93.0%仅训分类头1.5K3.1 GB5 min88.4%88.2%LoRA r8Q/V0.29M3.6 GB8 min92.6%92.5%LoRA r16Q/V0.59M3.7 GB8.5 min92.8%92.7%这组数最有意思的对比是仅训分类头和LoRA。前者只学 1538 个参数主干完全不动准确率停在 88.4%后者多花了 0.29M 参数直接把差距补回来 4 个多点。说明 BERT 预训练得到的表征确实需要一点任务相关的调整而这点调整量用低秩的方式表达完全够用。LoRA 和全量微调差 0.5 个点这个差距在小数据集上是正常的波动范围。我换了三个随机种子重跑差距在 0.2 到 0.7 之间浮动没有稳定显著的系统性劣势。6.2 r 取多少合适一轮消融固定 alpha2r、dropout0.05、lr1e-3只改 rr参数量训练集准确率第 3 轮验证集 F1观察20.074M93.5%91.2%容量不足欠拟合40.147M96.8%92.1%接近可用80.295M98.9%92.6%最佳平衡点160.590M99.6%92.7%提升微弱321.180M99.9%92.3%训练集饱和验证集回落r8 是明显的拐点。r2 的时候训练集准确率只有 93.5%模型连训练数据都没拟合好这是容量不够的典型表现r32 反过来训练集几乎满分而验证集开始下滑过拟合的信号。这个拐点的位置和数据集大小强相关。25000 条训练样本对应的拐点在 8 附近如果换成 2500 条的小数据集拐点大概会降到 4如果数据量上到几十万拐点会往 16、32 推。6.3 与只训分类头的差距在哪我特意看了几个分类头方案分错的样本。有一类典型错误是带强烈反讽的评论比如通篇用夸张的赞美词描述一部烂片只训分类头的模型几乎全部判成正面而 LoRA 和全量微调的模型大部分能判对。原因不难理解。BERT 的预训练目标里没有情感极性这个监督信号讽刺这种依赖上下文反转的语义需要在注意力层面做一点重新加权才能捕捉到。分类头只做最后一步的线性映射前面怎么表征它管不着LoRA 动的是注意力里的 query 和 value等于给了模型调整看哪里的能力。这也解释了为什么 r 从 8 提到 16 收益这么小——真正需要调整的方向就那么几个秩再大也是冗余。7. 踩坑记录那些跑通了但结果不对的情况7.1 参数量打印为 0前面提过一次这里补完整链路。现象是训练能跑loss 也在降但降得极慢最后准确率卡在 88% 附近。打印sum(p.numel() for p in model.parameters() if p.requires_grad)得到 1538正好是分类头的参数量说明 LoRA 层压根没被注入进去。排查步骤我后来固定成三步第一步注入之后立刻print(model)看结构里有没有LoRALinear。没有的话就是注入函数的问题。第二步如果结构里有检查lora_A和lora_B的requires_grad。nn.Parameter默认是 True但如果你在注入之后又对整个 model 做了一次requires_grad_(False)比如为了冻结主干顺手写的新加的旁路也会被一起冻掉。这个顺序问题很隐蔽。第三步检查优化器的参数列表长度和可训练参数个数对一下。我第一次的根因是注入函数的递归逻辑有缺陷else分支写成了elif导致遇到非 Linear 层时不往下走。这个错误在模型结构打印里看不出来因为顶层结构完全正常只是没递归到深层。7.2 merge 之后精度掉了模型训练完验证集 F1 92.6%调merge()合并到主干再评估掉到 88%。这个问题我卡了一下午。原因是 merge 时的数学和 forward 时的数学不一致。forward 里算的是self.drop(x) A.t() B.t() * scalingmerge里算的是B A * scaling。两处看起来等价但当 dropout 处于训练模式时forward 里的 x 是被随机置零过的而 merge 的权重里没有这个随机性。如果评估时忘了model.eval()dropout 还在生效forward 和 merge 的结果就对不上。修复很简单merge()之前强制model.eval()。我在 merge 函数里加了断言torch.no_grad() def merge(self): assert not self.training, merge 前必须 eval() ...另一个可能导致 merge 后掉点的原因是精度。self.base.weight.data delta是原地操作如果 base.weight 是 fp16 而 delta 是 fp32加法会先做类型提升再写回 fp16损失精度。fp32 下没这个问题混合精度训练时要注意合并前把两边都转成 fp32。还有一种情况是重复 merge。虽然我写的版本在 merge 之后把 A 和 B 清零了重复调用不会叠加但如果有人自己改成了不清零的版本多次 merge 就会把 delta 加好几遍权重直接跑飞。7.3 显存没降反而涨了这个坑比较少见但确实存在。按理说可训练参数少了显存应该降但我第一次跑的时候峰值显存比全量微调还高 0.4 GB。排查下来是两件事叠加。一是优化器。我一开始偷懒写的是torch.optim.AdamW(model.parameters(), ...)虽然冻结参数没有梯度不会被更新但 AdamW 在初始化时会给每个参数分配exp_avg和exp_avg_sq两个同形状的张量一亿多个参数白白占了两份 fp32 显存差不多 0.9 GB。二是没有在 base 的 forward 上加no_grad。主干虽然参数冻结了但 PyTorch 默认还是会为它的前向建立计算图、保留中间激活。加上with torch.no_grad()之后这部分中间结果不再保留显存才真正降下来。两个一起改峰值从 10.2 GB 降到 3.6 GB。7.4 loss 不动或者震荡这两类现象对应的原因不一样我分开说。loss 完全不动几千步都停在 0.69 附近八成是对旁路的梯度根本没算上。检查点注入的 LoRA 层是否真的在 forward 路径上lora_A和lora_B是否是nn.Parameter写成普通 tensor 就不会被优化器收集是否在优化器之外用了torch.no_grad()把整段 forward 包住了。还有一种可能是学习率太小。前面那个表格已经说明2e-5 学 LoRA 基本等于不动。loss 震荡在 0.4 和 1.5 之间来回跳通常是学习率太大或者 batch 太小。IMDB 的评论长度差异极大同一个 batch 里的样本难度差别很大batch8 时梯度噪声明显。把 batch 提到 32、学习率降到 1e-3震荡基本消失。还有一种隐蔽的震荡来源clip_grad_norm_里传的 max_norm 设成了 0.1 这种小值。LoRA 的参数量少梯度的整体范数本身就小裁剪过狠会把大部分梯度削掉训练变成随机游走。1.0 是个合理的值。8. 权重怎么存、怎么读、怎么合并部署8.1 只存 LoRA 参数一个几百 KB 的文件保存的时候只挑可训练参数文件结构和普通的 state_dict 一样只是键里带lora_A、lora_Blora_state {n: p.data.cpu() for n, p in model.named_parameters() if p.requires_grad} torch.save(lora_state, imdb_lora_r8.pt)加载的时候有个顺序问题必须先重建出完全相同的模型结构再把权重塞回去。也就是说推理端需要先加载 BERT-base 的原始权重跑一遍同样的inject_lora然后再load_state_dictstrictFalse可以容忍分类头等不匹配的键但我建议用strictTrue然后自己补上所有需要的键能更早发现结构不一致。base_model BertForSequenceClassification.from_pretrained(bert-base-uncased, num_labels2) inject_lora(base_model, r8, alpha16, dropout0.05, targets(query, value)) state torch.load(imdb_lora_r8.pt, map_locationcpu) missing, unexpected base_model.load_state_dict(state, strictFalse) print(missing:, missing) print(unexpected:, unexpected)正常情况下unexpected应该为空missing里应该只有主干的键名那些本来就没保存。如果missing里出现了lora_A之类的键说明注入的targets参数和保存时不一致写的和读的对不上。8.2 加载与合并的完整流程生产环境里我推荐合并后使用而不是保留分离的双路结构。合并的好处是推理代码完全不用改直接用原始的BertForSequenceClassification就能跑没有任何额外分支带来的延迟坏处是合并之后就没法再单独取出 LoRA 权重了所以合并前先把权重备份好。model.eval() merged_count 0 for module in model.modules(): if isinstance(module, LoRALinear): module.merge() merged_count 1 print(merged:, merged_count) # 校验合并前后输出应当一致 with torch.no_grad(): before model(**batch).logits # ... 合并 ... with torch.no_grad(): after model(**batch).logits print(max diff:, (before - after).abs().max().item())merged_count这个数是自检用的。r8、Q/V 配置下12 层 × 2 个模块 24如果打印出来不是 24说明注入的层数不对可能是targets写错了或者递归没走全。合并前的输出对比max diff应该在 1e-5 这个量级fp32 下通常更小。如果差到 0.01 以上说明合并的数学有问题最常见的原因就是前面说的 dropout 没关。还有一点合并之后如果想再训一轮得重新注入 LoRA因为 A 和 B 已经被清零了。我在实际项目里踩过最后一个坑——把合并后的模型和 LoRA 权重一起打包发布结果磁盘上存了两份模型服务启动时加载的是合并版但配置文件里还写着 LoRA 的路径运维看了半天没搞明白为什么这个路径指向的文件根本没被读过。所以打包的时候要么只发合并版、配置文件里不留 LoRA 字段要么只发底座加 LoRA两种混着来迟早出事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑