1. 从一个“全量微调翻车”的模拟项目说起
我最近在一个模拟项目里接了个文本分类任务,基座模型用的是某个开源的中等规模预训练模型,任务本身不复杂,就是客服工单的意图识别。我一开始也是按老思路来:把整个模型的权重全部解冻,然后全量微调。
结果没跑几个epoch,显存直接报警。训练进程动不动就被系统杀掉,日志里全是内存分配失败的错误。我当时的训练环境不算太差,但一个7B左右的模型,光是参数本身、梯度、优化器状态再加上中间激活,就把显存吃得干干净净。到最后我只能把batch size压到1,训练速度慢到让人怀疑人生。
也就是在这个时候,我认真去啃了“参数高效微调”这条技术路线。所谓参数高效微调,英文缩写是PEFT,核心思路很简单:在保留预训练模型绝大部分权重不变的前提下,只训练一小部分额外参数或增量参数,让模型适配具体下游任务。它解决的正是我在那个模拟项目里遇到的显存压力大、训练成本高、每个任务都要保存一份完整模型副本的问题。
这篇内容是我整理的学习笔记,也可以当作一份实战入坑指南。它会覆盖PEFT的基本原理、四种主流方法的取舍逻辑、跑通一个LoRA微调流程的关键步骤、参数收益账,以及我实际踩过的几个坑。适合的对象很简单:手头模型越来越大、显卡越来越不够用、或者需要在多个任务之间反复切换部署的开发者、学生和算法工程师。不管是第一次听说PEFT,还是已经用过但想搞清楚底层取舍,这篇应该都能给你一些有用的参考。
2. 把PEFT的原理拆成三句话能讲明白的部分
很多人第一次接触参数高效微调,容易被各种术语绕晕。什么低秩分解、旁路结构、软提示、前缀向量,听着挺玄乎。但归根到底,PEFT的原理可以压缩成三句话。
2.1 第一句:主干不动,只动增量
预训练模型在预训练阶段已经学到了大量的通用语言知识和世界常识。当我们做下游任务时,理想情况是不希望把这些通用知识推翻重来,而是希望模型在原有能力的基础上,长出一些“针对当前任务的小分支”。
全量微调的做法是让所有参数一起变化,这既昂贵又容易让模型忘掉之前学到的通用能力。PEFT的做法则是把绝大部分原始参数冻结住,在训练过程中不对它们做更新。你需要更新的,要么是额外插入的结构参数,要么是对原始权重的一种低维增量表示。这些新参数通常只占原模型参数的0.1%到1%左右。
这个设计带来的第一个直接好处是显存。因为冻结参数在反向传播时不需要计算和保存梯度,优化器也不需要为它们维护状态,所以训练时的显存占用和存储开销会显著下降。
2.2 第二句:增量参数不是“塞进去”,就是“叠上去”,要么“从输入侧引导”
具体到实现层面,PEFT方法虽然五花八门,但本质上的插入位置就三类。
第一类是把新的结构塞进Transformer层内部,典型代表是Adapter。它在注意力层或者前馈网络层后面插入一个小型子网络,训练时只更新子网络参数,原始Transformer层的参数保持不动。
第二类是叠在原有权重之上,典型代表是LoRA。它不改变模型结构,而是对权重更新部分做一个低秩分解,训练时学习一个增量矩阵,推理时把这个增量矩阵加回到原始权重上。
第三类是在输入侧动手脚,典型代表是Prefix-Tuning和Prompt-Tuning。它们把一小段可训练向量拼到输入序列前面,或者插入到每一层的隐藏状态前面,引导模型朝目标任务方向生成输出。
这三种思路没有绝对优劣,区别主要体现在可训练参数量、推理延迟、训练稳定性和对任务的适配度上。
2.3 第三句:它本质上是在低维子空间里做学习,顺带起到正则化作用
为什么只训练这么少参数,效果还能逼近全量微调?这背后有一个在实证中反复出现的观察:预训练模型在高维参数空间中进行任务适配时,真正有效的权重更新方向往往集中在某个低维子空间里,而不是均匀分布在所有参数上。
换句话说,全量微调虽然更新了成千上万个参数,但其中大部分更新是冗余的,甚至只是噪声。LoRA这类方法用低秩矩阵去逼近原始权重更新矩阵,本质上就是在强行约束这个低维子空间。这种约束还有一个额外好处:它天然带了一点正则化效果,在小数据集上往往比全量微调更不容易过拟合,泛化表现反而更稳。
把这三句话串起来,你再看具体方法就会清楚很多:它们不是在发明一种全新的训练范式,而是在找一个更聪明的位置和表达方式,让“只动一小部分”这件事成为可能。
3. LoRA、Adapter、Prefix-Tuning、BitFit:四种主流方案的使用逻辑
先声明一下,PEFT家族远不止这四种,什么IA3、SSF、DoRA、VeRA也都是很有价值的变体。但学习路线建议从这四个代表方法入手。把这四种理解透了,再去看其他变体,基本就是看它们改了什么位置、换了什么约束而已。
3.1 LoRA:把权重更新拆成低秩矩阵
LoRA是我在实际项目里用得最多的方法,也是目前社区讨论度最高的方案。它的数学观点非常简单:假设原始权重矩阵是W0,全量微调时我们希望学习一个更新量ΔW。LoRA不去直接学习完整的ΔW,而是假设ΔW可以分解成两个低秩矩阵的乘积,也就是ΔW约等于B乘以A。
我们用公式来表示:h等于W0乘以x加上ΔW乘以x,而ΔW可以写成BA,其中B的尺寸是d乘以r,A的尺寸是r乘以k,r远小于d和k。训练时W0被冻结,只优化B和A。推理时,如果不想引入额外延迟,可以直接把BA合并进W0,得到一个和原始模型结构完全一致的权重矩阵。
LoRA最大的优点是它在推理阶段几乎零额外开销。合并权重之后,你的模型和一个常规微调模型长得一模一样,部署上没有任何负担。它的另一个隐含优势是可插拔性:同一个基座模型上,你可以训练多个任务各自的LoRA增量,不同任务之间只是几十到几百MB的小文件,切换时加载对应增量就行,不需要同时维护多个完整模型副本。
我在那个模拟项目里就是先用LoRA跑通了分类任务,训练显存从原本的“直接爆掉”降到了比较舒服的范围。后面我会单独用一节来说具体步骤。
3.2 Adapter:在Transformer层里插小旁路
Adapter的思路比LoRA更“物理”。它直接在Transformer的层结构上插入小模块,常见做法是在FFN之后或者Attention之后串一个两层的瓶颈结构:先把特征维度降到很小,再映射回来,形成一个残差旁路。
训练时只有这些Adapter模块被更新,原始层全部冻结。因为Adapter的参数非常少,所以训练的开销也小。它和LoRA相比,缺点是推理时你无法把Adapter合并回原始权重,输入数据必须真实地经过这个额外小网络,因此会带来一点点延迟增加。好处是Adapter的空间表达能力往往更强一点,因为它是在特征层做非线性变换,而不是在权重矩阵上做低秩近似。
如果你的任务是那种需要深层语义重定向的场景,比如情感分析、领域风格迁移,Adapter有时候效果会比LoRA更明显。我之前在另一个文本改写模拟项目里对比过,同样的训练条件下,Adapter在风格一致性上的表现确实略好,但推理吞吐大约损失了5%-10%。
3.3 Prefix-Tuning和Prompt-Tuning:在输入侧做文章
Prefix-Tuning和Prompt-Tuning是另一条路线,主要用在生成式模型上。
Prompt-Tuning的做法最容易理解:在输入序列前面拼上一段可训练的连续向量,相当于把原本需要自然语言描述的提示词,换成一组可以通过梯度下降直接优化的虚拟token。训练时只更新这组虚拟token的参数,模型主体完全不动。
Prefix-Tuning则走得更深一层:它不只是在最底层的输入侧加向量,而是在每一层Transformer的输入隐藏状态前面都插入可训练的前缀键值对。这样相当于每一层都被“提示”了一下,引导能力更强。代价是它占用的显存会比Prompt-Tuning高,因为每一层都有额外的前缀向量需要参与前向计算。
这类方法最省参数,有时候可训练参数占比只有0.01%甚至更低。但它的稳定性相对差一些,对随机种子和学习率比较敏感,而且在序列长度受限的场景里,前缀会占用输入窗口,影响可用长度。
3.4 BitFit:只调偏置,轻到极致
BitFit是我个人觉得最“反直觉”但也很有趣的方法。它什么都不加,什么都不分解,只是选择性地训练模型中的所有偏置项,其他参数全部冻结。
就这么一个小小的改动,在某些任务上居然能取得相当不错的成绩。尤其在一些分类任务上,只训练bias的效果可以逼近全量微调。这个现象说明,预训练模型的大部分能力已经足够强,很多任务适配只需要调整神经元激活的偏移量就够了。
不过BitFit的上限也比较明确,适合快速探路、简单迁移,复杂推理任务基本不够用。它的价值更多在于提醒我们:不要总觉得参数越多的方法效果就越好,真正重要的是找到任务中“足够且必要”的自由度。
3.5 四类方法横向对比
| 方法 | 可训练参数占比 | 推理额外延迟 | 显存节省程度 | 适合场景 | 实战稳定性 |
|---|---|---|---|---|---|
| LoRA | 约0.1%-1% | 极低,可合并权重 | 很高 | 各类NLU、NLG任务,多任务部署 | 高,最推荐入门 |
| Adapter | 约0.5%-2% | 有,无法合并 | 高 | 深层语义调整、风格迁移 | 较高,需注意结构位置 |
| Prefix-Tuning | 约0.01%-0.1% | 有,占用前缀长度 | 最高 | 生成任务、长文本、摘要 | 中等,对超参敏感 |
| Prompt-Tuning | 约0.01% | 极低,但占用输入窗口 | 最高 | 大模型快速任务适配 | 中等,相对较依赖模型规模 |
| BitFit | 约0.1%以下 | 无,可合并 | 最高 | 小数据快速验证、基线对比 | 高,但上限有限 |
4. 亲手跑通一个LoRA微调流程:关键步骤与参数选择
理论说再多都不如亲手跑一遍。下面这部分是我认为最值得反复看的章节。我尽量不依赖某个特定平台或具体库的细节,只讲每一步真正的关键点,你换成自己熟悉的框架也能对上号。
4.1 准备基座模型与数据
LoRA的第一步不是急着写训练代码,而是确定两件事:基座模型是什么、数据从哪来。
基座模型的选择直接决定你后面要承担的风险。我的建议是选择你已经验证过基础能力的模型,不要在一个完全没跑过的模型上直接做LoRA。否则你很难判断效果差到底是LoRA的问题,还是基座模型本身在这个任务上就不行。
数据准备上,最大的坑是“训练集和验证集的分布不一致”。我在模拟项目里就吃过亏,初版训练脚本里数据处理逻辑写得太粗,训练集里手工清洗了脏文本,但验证集没有做同样的清洗,结果验证指标一直上不去,我浪费了整整两天排查特征工程。
数据量方面,PEFT虽然比全量微调抗过拟合,但也不是无中生有。我个人的经验是:低于几百条样本的任务,先别急着做复杂微调,优先考虑用带示例的模型提示方式探一下底;一旦超过一两千条样本,就可以放心使用LoRA这类方法了。
4.2 LoRA配置里的几个关键参数
下面这段是真正决定效果的地方,配置LoRA时你至少要把这几个参数吃透。
第一个是秩r。它决定了低秩矩阵的维度,也就是你允许模型学习的增量表达有多大的自由度。r太小,模型学不动,欠拟合;r太大,不仅训练参数量上去,还容易过拟合,甚至可能出现训练集指标很高、验证集一塌糊涂的情况。常见的取值在4到64之间,分类任务用8到16起步就够。
第二个是缩放系数alpha。它在训练时的实际作用是把BA之后的结果乘以alpha除以r。简单理解,alpha控制的是最终注入到原始权重里的增量强度,和r共同决定有效学习率。不要只看绝对数值,关键看alpha和r的比值。我见过很多人直接把alpha设成和r相等,这在大多数时候没问题,但不是所有任务都合适。如果是小数据集,可以适当调大alpha,让增量信号更强一点,加速收敛。
第三个是target_modules,也就是你想要在模型的哪些模块上挂LoRA。新手最容易在这里翻车。如果你只把LoRA挂在注意力层的query和value投影上,那训练出来的能力更多是“调整注意力分配”;如果你希望模型改变前馈层的知识表达,还需要把LoRA挂到FFN相关模块上。更稳妥的做法是先把全部线性投影模块都挂上,跑一个短训练看效果,再逐个精简看哪些模块是可省的。
第四个是dropout。LoRA层里的dropout不能省,它负责抑制增量矩阵的过拟合风险。但也不宜太大,0.05到0.1通常是安全区间。
4.3 训练超参:学习率、优化器与epoch的取舍
LoRA对学习率的要求和全量微调很不一样。全量微调时,我们常用1e-5到5e-5的学习率,因为所有参数都在动,学习率太大会灾难性遗忘。但LoRA只更新少量增量参数,它的学习率可以更大,常见范围是1e-4到1e-3。
我在模拟项目里第一次用LoRA时,沿用全量微调的习惯把学习率设成了1e-5,结果训练速度慢得离谱,五六个epoch过去验证指标纹丝不动。后来把学习率调到4e-4,两个epoch就开始明显收敛。这是一个让我印象非常深的点:PEFT方法的学习率通常需要比全量微调大一个量级,却又不至于像训练随机初始化模型那样动辄1e-3以上。最实在的办法是跑一个小步数实验,观察前几十步的loss下降速度来调整量级。
优化器方面,Adam类优化器是主流选择。这里要注意权重衰减系数,虽然LoRA只训练少量参数,但增量矩阵如果没有适度的衰减,反而更容易在某几个维度上出现极端值。我习惯把权重衰减设在0.01附近,任务很小时可以再降一点。
epoch数量建议不要和全量微调一样死板地设几十个。LoRA由于参数量少、拟合速度快,往往在3到10个epoch内就到平台期了。关键是盯住验证集指标,一旦连续几个epoch没有提升,就及时停止。
4.4 保存、合并与验证
训练完成后,你手里会有一份增量权重文件,而不是一个完整的模型。这里有两个后续步骤值得认真对待。
第一步是验证“合并”效果。LoRA推理时有两种选择:动态加载增量权重,或者把增量合并回原始权重。我强烈建议你在验证阶段做一次合并,然后对比合并后的模型和动态加载的模型在同一个测试集上的输出是否一致。这一步虽然多花几分钟,但能防止部署时出现“训练时效果好、上线后行为不一致”的诡异问题。
第二步是做一个偏离测试。所谓偏离测试,就是验证模型在微调之后是否还保留着基础能力。我曾经在某个模拟项目里,用LoRA把模型微调得很贴合训练集,结果发现模型连基本的通用问答能力都明显退化。后来才意识到,当时训练数据里混入了大量模板化文本,LoRA把模型带偏了。所以每次微调完,除了看任务指标,一定还要跑一组和任务无关的常识样本,确保模型没有出现严重的灾难性遗忘。
5. 参数收益账:PEFT到底省了多少显存和存储
很多人对PEFT的认识停留在“能省钱”的模糊概念上,但具体能省多少,心里没数。我这里帮你算一笔细账。
5.1 训练显存主要花在哪
一次常规的模型微调,显存开销可以分为四块:模型权重、梯度、优化器状态、中间激活。
模型权重就是参数本身。梯度是反向传播时计算出来的,大小和模型权重一致。优化器状态在Adam类优化器下,通常一个参数要额外维护一阶动量项和二阶动量项,这两项加起来大概是参数量的两倍。中间激活则是前向传播时每一层的输出和临时变量,这和输入序列长度、batch size直接相关。
如果有一个70亿参数左右的模型,假设每个权重用半精度格式存储,那么模型权重大约是14GB左右。全量微调时,梯度再加上优化器状态,这部分开销可能会衔接到二三十GB甚至更多。对单卡训练来说,这是个不小的压力。
5.2 LoRA把哪部分钱省下来了
LoRA的做法只更新增量矩阵,所以冻结住的原始权重不需要存储梯度,也不需要优化器状态。反传时,梯度只流过LoRA层的几个小矩阵。
我们粗略算一下:如果LoRA的秩r取16,注意力层的线性模块都挂上,70亿参数模型里LoRA可训练参数大概只有1000万到2000万级别,占总参数的比例在0.2%左右。那么可训练部分所用的显存,包括梯度和优化器状态,大概只在几百MB量级。对比全量微调动辄几十GB的梯度加优化器状态,节省是比较夸张的。
当然,中间激活这一块没法靠PEFT根除,因为即使冻结层不求梯度,前向传播时依然要计算并暂时保留每一层输出。这就是为什么哪怕用了LoRA,batch size依然不能无限往上调。我在实际训练7B模型时,LoRA让显存占用从全量微调的“完全跑不动”降到了可以正常训练的水平,但真正的瓶颈仍然是激活值。可以尝试开启梯度检查点来进一步压缩这部分。
5.3 存储开销是更直观的优势
训练完成之后,存储收益更加明显。全量微调每个任务都要保存一份完整的模型副本,70亿参数模型的半精度权重就是14GB左右,十个任务就是140GB。而LoRA每个任务只保存增量矩阵,通常只有几十到几百MB。十个任务的增量权重,可能加起来还不到一个完整模型副本的十分之一。
这个优势还带来部署时的灵活性。我可以在一个共享基座模型上同时加载多个任务的增量,根据请求路由到对应任务的LoRA增量。比如客服场景,意图识别用一套增量,情感分析用另一套增量,切换代价只是加载一个小文件。
5.4 实测体验上的一些数字参考
还是用我那个模拟项目来举例。模型规模大概在3B,训练序列长度是512,batch size是8。全量微调时显存占用直接逼近24GB,我只能被迫把batch size减半再减半。换成LoRA之后,同样的batch size下显存占用降到大约12到14GB,峰值明显下降。训练速度也有提升,因为反向传播只更新少量参数,通信开销和计算量都少了。当然速度提升的幅度没有显存那么夸张,大约在1.3到1.6倍之间,取决于LoRA参数占比和硬件环境。
6. 边界与权衡:哪些场景不该无脑上PEFT
聊完优点,得泼一点冷水。PEFT不是万能的,在一些场景下它确实不是最优解。把边界画清楚,你才不会在错误的方向上浪费时间。
6.1 最适合PEFT的场景
首先是多任务并行部署的场景。比如你做的是面向不同行业的客服模型,每个行业一套语义偏好,用LoRA或者Adapter做增量,一个模型容器就能服务所有行业,不同增量之间随时切换。这种场景是PEFT的主场。
其次是显存受限但模型规模较大的场景。学生党、小团队、个人开发者手里一般没有太富裕的GPU资源,用PEFT微调一个几十亿参数的模型是可操作性和效果之间最好的折中点。
第三是垂直领域快速适配。等一个通用模型已经在市场上验证过能力,你想把它迁移到某个具体业务领域,比如法律文书、医疗报告、教育题库,PEFT可以在几天内完成一轮有效适配,而全量微调可能需要更长的调参周期和更充裕的算力。
6.2 不适合PEFT的场景
第一种是任务对模型的表达能力要求远超出预训练模型的已有能力。比如你想让一个纯文本模型学会看图,或者让一个通用对话模型掌握某种完全不存在的自定义语法规则,这些情况下PEFT的低维度增量很难表达出足够复杂的功能变化。
第二种是训练数据量极大且和原任务领域差别很大的情况。假如你有几千万条高质量数据,目标领域和预训练数据差异显著,那么全量微调有更大的容量去充分吸收这些数据。PEFT受限于低秩表达,可能无法充分利用这么大规模的数据。
第三种是非常在意极致精度,且算力完全不是瓶颈的场景。Kaggle比赛或者某些工业项目里,全量微调的精度上限通常还是略高于PEFT,尤其是当你有足够的卡和时间去调参时。PEFT的价值不是在所有指标上碾压全量微调,而是用“小代价”换“接近的收益”。
6.3 我的选型建议
我的个人原则很简单:先花半天时间做一次快速实验,包括零样本提示效果、小数据量Prompt-Tuning、小数据量LoRA。这三个实验能给出一条效果基线。如果零样本已经不错,LoRA在几百条样本上的提升不大,那就根本不需要做复杂微调;如果零样本很差而LoRA提升明显,说明任务适配很重要,再根据数据量和推理延迟要求决定继续优化LoRA还是转全量微调。
这个方法虽然朴素,但非常省时间。很多时候我们不是找不到最优解,而是把太多时间花在了没有必要的全量微调上。
7. 实操翻车记录:我踩过的几个坑与补救办法
最后这部分是真正的经验沉淀。我在学习和实践PEFT的过程中踩了不少坑,挑几个最有代表性的分享出来,希望能帮你绕过去。
7.1 坑一:学习率直接套用全量微调
这是最普遍的问题。我前文说过,PEFT需要的学习率通常比全量微调大。如果沿用默认的1e-5,训练过程会非常缓慢,而且容易让人误判“LoRA效果不好”。
我当时排除了数据问题之后,做了一组学习率扫描实验:分别在1e-5、5e-5、1e-4、5e-4和1e-3下各训练200步,观察loss下降曲线。结果发现在5e-4附近收敛最快且稳定,1e-5基本还停在原地。以后凡是换新任务,我第一个要确认的就是学习率量级。
一个更具体的经验:学习率可以配一个短预热阶段,大概占全部训练步数的5%到10%,然后再衰减到0。这个小动作能明显减少前期loss跳跃的情况。
7.2 坑二:target_modules挂错位置,训练了个寂寞
有一种情况很隐蔽:LoRA配置写好了,loss也在下降,但最终模型效果很差,甚至和加载原始基座模型没有区别。我当时把大量时间花在调数据上,最后才发现target_modules漏掉了任务最依赖的模块。
以分类任务为例,如果模型主要是靠注意力机制捕捉关键token间的关系,那么query和value投影上必须挂LoRA;如果任务需要模型重组知识、提高事实性回答能力,那FFN相关模块不能漏。
我的建议是调试阶段不要“精打细算”,先把所有能和输入输出产生直接映射的线性层都挂上LoRA,跑一个短实验验证任务指标有没有动静。确认有效后,再逐步去掉某些模块观察效果变化。这样既能避免“训练了个寂寞”,也能更清楚地理解哪些模块对该任务贡献最大。
7.3 坑三:合并权重后效果不一致
LoRA动态加载增量和合并权重理论上应该等价,但实际操作中我遇到过轻微的不一致,主要是数据类型没对齐导致的。训练时增量矩阵是浮点型,基座模型可能也是浮点型,但推理时如果你把合并后的权重转成更低精度再保存,就可能出现精度损失。
为了排查这个问题,我在合并前和合并后分别跑了一批固定输入的测试样本,逐位比较输出差异。后来发现问题的根源是合并时没有用训练时的原始计算精度,而是在中间环节做了不安全的类型转换。解决方法是统一精度再做矩阵加法,合并完成后再做低精度转换。
7.4 坑四:多任务增量互相干扰
在一个基座模型上同时维护多个任务的LoRA增量时,任务之间的干扰非常值得注意。客服场景里,一个增量是针对投诉工单的理解,另一个增量是针对售后咨询的回复,如果两个增量训练时使用了高度重叠但又语义不一致的数据,部署时可能会出现“窜味”现象。
我的处理方式比较稳妥:每个任务单独训练自己的LoRA增量,训练时不让其他任务的增量混入同一次前向传播。部署时通过路由显式指定当前请求使用哪个增量。如果确实需要在一个模型里同时处理多任务,那就要考虑更复杂的多LoRA融合方案,而不是简单地把两个增量矩阵相加。
7.5 一个有用的验收习惯
最后分享一个我一直在用的验收习惯:每完成一次PEFT微调,我都会构建一个“三件套”测试集。第一件是任务正向样本,验证模型是否学会目标任务;第二件是任务反方向样本,比如输入里出现否定词或者反义表达时,模型能不能正确区分;第三件是通用能力抽查样本,防止灾难性遗忘。三件套都过关,我才会把模型放上线。
我在实际使用中发现,这三个维度的测试能发现很多“指标上看起来很美”的隐藏问题。PEFT毕竟是在有限参数上做调整,很容易出现“记住了训练集模式但没学会任务规律”的情况。养成这个习惯之后,我踩坑的频率明显降低,模型上线的质量也稳定了很多。