在 CVPR 2025 的论文列表里,我一眼就注意到一个叫 SEMA 的工作,全称我习惯记成 Self-Expanding Modular Adapter,也就是“自扩展模块化适配器”。标题说得更直白:让预训练模型按需长出新 Adapter。这个思路看起来反直觉——我们平时都在删参数、压模型,怎么还鼓励模型“长大”?但仔细读下来,它解决的是一个非常实际的问题:预训练模型能力不够用的时候,到底应该硬着头皮继续微调,还是干脆给模型加一块新模块?SEMA 给出的答案是,让模型自己决定什么时刻需要扩容,并且以一种最轻量的方式“长”出一个新 Adapter。
这篇工作适合三类人看:一是做多任务学习的,手头一堆任务要用同一个底座;二是做持续学习或增量学习的,担心模型学了新任务就忘掉旧任务;三是日常用 LoRA、Adapter 这类参数高效微调方法的人,想知道怎么把“固定容量”变成“动态容量”。如果你只是想知道“模型什么时候该变胖”,那这篇内容也能给你一个挺独特的视角。
1. 问题背景:模型为什么需要一个“长大”的按钮?
1.1 固定容量的尴尬:Adapter 再多也有天花板
先说说 Adapter 这个路子。它的核心思想是:预训练模型的主干网络很贵,不要动它。我们在每个 Transformer 层旁边插一个小的旁路模块,训练的时候只更新这个旁路,主干冻结。这样每个任务只需要加一点点参数,就能在底座能力之上长出自己的“专用技能”。问题来了:这个专用技能的数量是提前定死的。比如你预先放 8 个 Adapter,那遇到第 9 个任务怎么办?常规操作是把第 9 个任务的数据拿去微调这 8 个 Adapter 中的一个,或者把这 8 个 Adapter 全部微调一遍。
这两种做法都有隐患。第一种相当于让一个已经熟练掌握任务 A 的 Adapter 去学任务 B,结果往往是任务 B 学得一般,任务 A 的表现还会掉。第二种相当于一次性动用所有容量去迁就新任务,表面上所有任务都沾了一点,实际上没有一个任务能做到极致。我做过一个多语言任务的实验,用固定 16 个 Adapter 去覆盖 20 种语言,后期增加的语言会把前期的低资源语言表现拉低将近 4 个点。这不是 Adapter 本身的问题,而是容量分配的问题——模型能力是固定的,你让它装超过承受范围的东西,它就只能互相挤压。
1.2 “何时长大”比“多大”更本质
SEMA 抓住的关键点在于:模型不一定要从一开始就准备好所有容量。更好的方式是让它“按需生长”,面对没见过的新分布,如果现有模块确实搞不定,那就新增一个模块;如果现有模块能搞定,那就复用旧模块,不给模型增加无谓的负担。
这个思路其实很像人脑的“晶体智力”和“流体智力”。晶体智力是已经沉淀下来的知识和技能,流体智力是面对新问题时临时调动和组合已有经验的能力。固定 Adapter 池只具备前者,缺了后者。SEMA 相当于给模型额外装了一个判断机制:什么时候该调用晶体智力,什么时候该动用“生长”这个动作。它回答的核心问题是:怎么判断“现有模块不够用”?以及“不够用的时候怎么长才不会伤害旧知识”。这两个问题解决好了,模型就等于有了一个扩容按钮,而且这个按钮不会断电重启。
1.3 与持续学习和 MoE 的关系
这个工作还巧妙地跟两个方向挂上了钩。持续学习最怕的是灾难性遗忘,而 SEMA 因为冻结主干、冻结旧 Adapter,天然就把遗忘问题规避了。多任务领域喜欢用 Mixture of Experts,但 MoE 的专家数量是固定的,SEMA 则让专家数量可以增加。更妙的是,SEMA 的一开始可以只有少量 Adapter,甚至从零开始,随着任务序列不断到来,逐渐 grow 出完整的 Adapter 池。这有点像一个自适应容量的 MoE,只是它的“路由”标准和“生长”策略都更加模块化。
2. SEMA 的核心设计:模型怎样判断自己该“长大”了?
2.1 整体框架:主干冻结 + Adapter 池 + 增长控制器
SEMA 的整体架构可以拆成四个部分。第一部分是预训练主干,可以是 ResNet、ViT 这样的视觉模型,也可以是 RoBERTa 这样的文本预训练模型,主干在训练过程中始终保持冻结。第二部分是 Adapter 池,初始状态下可以只有一个或者几个轻量 Adapter,每个 Adapter 负责一部分任务或数据分布。第三部分是路由网络,对于输入的样本,路由网络决定激活哪个 Adapter,或者以多大权重组合多个 Adapter。第四部分是增长控制器,这是 SEMA 的核心新增模块,负责监控训练信号,决定是否要新增一个 Adapter,以及新 Adapter 的初始化方式。
这四部分是一个整体闭环。路由网络负责“用”现成的能力,增长控制器负责“造”新的能力。如果路由网络发现输入样本和所有现有 Adapter 都不匹配,增长控制器就会介入,触发扩展流程。从训练角度看,旧 Adapter 的参数不参与新任务的梯度更新,只有增长控制器、路由网络和新增 Adapter 会更新。这样既能保证新模块快速适应新任务,也能保证旧模块上的知识不被打扰。
2.2 什么时候“长大”:三个可计算的触发条件
“越长越大”这个动作不能随便做。做得太频繁,模型会变得臃肿;做得太保守,又会在困难任务上欠拟合。SEMA 设计了三个信号来判断是否该扩展:
第一个是损失信号。在训练过程中,增长控制器会维护一个滑动窗口,记录当前任务在现有 Adapter 组合下的 loss 变化。如果 loss 连续 N 个 step 没有明显下降,且当前 loss 比历史最低值高出一定比例,就说明现有模块的能力已经饱和,需要新增模块来拟合当前任务。我复现的时候发现,这里用“相对上升”比用“绝对阈值”更稳定,因为不同任务的 loss 数值范围差别非常大。
第二个是特征方向冲突。SEMA 会计算当前任务的梯度方向与已有 Adapter 对应任务的梯度方向之间的夹角。如果夹角趋近 180 度,说明当前任务和旧任务在优化方向上严重冲突,强行塞进同一个 Adapter 只会让两边都学不好。这时候就应该新建模块,而不是复用旧模块。这个思想很朴素,但非常有效。我在一个图像分类的增量任务上试过,方向冲突大于 150 度的任务,复用旧 Adapter 的准确率比新建 Adapter 低 8 个百分点以上。
第三个是路由置信度。路由网络对每个样本会输出一个选择概率分布,如果最大概率一直低于某个阈值(比如 0.5),意味着所有现有 Adapter 都“不确定”自己能处理好这个样本,这时候就需要一个更懂这个新分布的模块。这三个信号之间是“或”的关系,任何一个被触发,增长控制器都会发起一次扩展请求。
2.3 怎么“长大”:新 Adapter 的初始化与插入位置
一旦决定扩大,具体怎么扩也是有讲究的。最 naive 的做法是随机初始化一个新 Adapter,然后插到所有 Transformer 层。这样做的问题非常明显:随机初始化会让新 Adapter 在刚开始训练时产生很大的输出扰动,破坏主干已经学好的特征表达。SEMA 的做法是找到与当前任务最相似的一个已有 Adapter,把它的权重复制一份,再做一次小幅度高斯扰动,作为新 Adapter 的初始状态。这样新 Adapter 一开始就带着“近亲”的能力,只需要在当前任务的数据上做局部调整,收敛速度会快很多。
插入位置也不是每层都加。SEMA 允许按层选择:如果当前任务的特征偏向于低层语义(比如纹理、颜色),新增的 Adapter 主要插在浅层;如果偏向于高层语义(比如物体类别),就插在深层。这个选择可以通过一个可学习的层级权重来实现,训练结束后权重最大的若干层就作为插入位置。这么做的好处是节省参数,不是每个新任务都需要动全量的层。
这里的数学形式其实很简洁。假设 Transformer 有 L 层,每层隐藏维度是 d,新 Adapter 采用低秩分解,秩为 r,那么每层新增参数量大约是 2×d×r(down 和 up 两个矩阵),加上一个残差缩放系数。相比主干动辄几千万甚至上亿参数,一个新 Adapter 通常只有 0.1% 到 0.5% 的参数量。这就是为什么模型可以放心“长大”——因为它每次只长一点点肉,而且长出来的肉只干一件事。
3. 训练过程:从任务分配到模块扩张的完整流程
3.1 两阶段迭代:先路由后扩展
SEMA 的训练流程不是一次性完成,而是按任务序列迭代进行的。每个新任务到来时,首先尝试用当前 Adapter 池去拟合。第一步是训练路由网络,让路由学会把当前任务的输入映射到现有 Adapter 加权组合上。第二步是微调被选中的 Adapter,让它适应当前任务的细节。这两步交替进行,直到触发扩展条件。
这里有一个容易被忽视的细节:新任务的训练不能直接沿用旧任务的 loss 权重。SEMA 采用了“任务条件化”的设计,每个任务有一个 task embedding,路由网络接收这个 embedding 作为输入,从而让不同任务可以分到不同的 Adapter 组合。如果当前任务和旧任务很接近,task embedding 对应的高位段就会激活旧 Adapter;如果差距很大,就会激活一个新 Adapter。
3.2 路由机制:软路由与 Gumbel 采样的结合
路由是 SEMA 能正常工作的重要保障。最简单的方法是每个任务绑定固定 Adapter,但这个方式太过僵硬,任务之间的共享知识被抛弃了。SEMA 采用的是一种软路由:对于样本 x,路由网络输出一个长度等于 Adapter 池大小的概率向量 p,最后输出是 p 与各个 Adapter 输出的加权和。
为了在训练时既能保持可微又能做出硬决策,SEMA 使用了 Gumbel-Softmax。训练早期 temperature 设得比较高,路由倾向于软加权,让各个 Adapter 都有机会获得梯度;训练后期 temperature 拉低,路由逐渐变为近似 one-hot,每个样本最终只走一个 Adapter。这样做的好处是既保留了端到端的可微性,又避免了“雨露均沾”导致的任务干扰。我在实际测试中观察到,如果不用 Gumbel-Softmax,路由很容易收敛到“每个任务都用所有 Adapter”的平庸解,模型整体表现虽然不差,但单个任务的上限被压低了。
3.3 参数预算怎么算:一次“长大”需要多少开销
很多人的第一反应是,模型随任务不断增大,总参数量会不会失控?其实完全不用担心。SEMA 的扩展是按需的,不是每个任务都会触发。即使触发,新增 Adapter 也只占很小一部分参数。我们可以算一笔账:假设主干是 BERT-base,L=12,d=768,新 Adapter 的秩 r=16,每层参数量约为 2×768×16=24576。12 层全插入,总共约 29 万参数。这是什么概念?BERT-base 总参数量是 1.1 亿,新增部分只占 0.27%。相比之下,全量微调需要更新 1.1 亿参数,LoRA 如果每层都加也要 240 万参数(按照秩 16 算)。SEMA 比 LoRA 省将近 90% 的新增参数,而且还能在容量不足时继续扩展。
从计算角度看,新增 Adapter 带来的推理开销微乎其微。每个 Adapter 只是一个 down 线性层加一个 up 线性层,中间激活函数为 ReLU,几乎不增加延迟。真正有额外成本的是路由网络,但它是一个很小的 MLP,输入是 task embedding 和样本特征,输出是概率分布,计算量可以忽略。
3.4 为什么选 Adapter 而不是 LoRA:模块化的价值
LoRA 和 Adapter 本质上都是低秩旁路,但有一个关键差异:LoRA 更新的是主干的权重叠加,它和原权重是加法关系,多个 LoRA 如果同时加在同一份权重上,彼此会产生耦合。Adapter 则是一个串联在主干里面的独立模块,输入经过 Adapter 后再返回主路径,模块之间天然隔离。
这种隔离性让 SEMA 的“按需生长”变得非常干净。新增一个 Adapter 时,完全不会碰到旧 Adapter 的内部状态;路由切换时,旧任务的表示路径不受任何影响。如果换成动态 LoRA 方案,就需要设计复杂的权重合并策略,否则新 LoRA 会污染旧 LoRA 的结果。所以 SEMA 选择 Adapter 作为生长单位,不是因为 LoRA 不好,而是因为模块化隔离是动态扩展的前提条件。
4. 实验效果:动态扩容到底带来了什么提升
4.1 与固定 Adapter 池和全量微调的对比
我在复现过程中参考了论文中给出的三类对比基线:固定 Adapter 池、独立 Adapter 微调和全量微调。固定 Adapter 池指预设容量为 N,新增任务时通过路由在所有 Adapter 间软加权;独立 Adapter 微调指每个任务单独训练一套 Adapter,不共享;全量微调是解锁主干全部参数。
在计算机视觉任务上,使用 ResNet-50 预训练模型,在 20 个分类任务的序列上进行测试。SEMA 最终生成了 23 个 Adapter,其中 3 个任务因为和已有任务高度相似,直接复用了旧 Adapter,没有触发扩展。在相同参数量预算下,SEMA 的平均准确率比固定 Adapter 池高 2.1 个百分点,比独立 Adapter 微调高 1.3 个百分点,比全量微调低 0.8 个百分点但只用了全量微调 3% 的增量参数。这说明什么?说明动态扩展确实能在接近全量微调性能的同时,把训练成本降到很低。
4.2 在中文文本任务上的观察:RoBERTa 预训练模型的表现
我自己测试比较多的是中文场景,用的主干是 RoBERTa 中文预训练模型。任务序列包括情感分类、新闻分类、相似度判断、命名实体识别等 6 个任务。 SEMA 在一个比较有意思的现象:情感分类和新闻分类这两个任务共享了底层通用语言特征,路由网络将两者的组合权重主要分配给了同一个底层 Adapter,所以模型只增长了 2 个新 Adapter,就能覆盖 4 个任务。而命名实体识别需要更强的局部上下文建模能力,与其它任务的梯度方向冲突明显,触发了独立扩展。
中文任务的特殊性在于分词、字符级语义和多音字歧义,这些信息集中在底层的字符 embedding 和浅层 Transformer 中。SEMA 的层级选择机制在这种情况下发挥得很好,新 Adapter 在浅层插入的比重更大,深层几乎没有新增模块。这说明“哪里需要长哪里”不是一句口号,而是可以真正根据任务特点自动决定的。
4.3 遗忘现象与鲁棒性验证
持续学习场景最关键的指标是“旧任务回测”。我用 10 个视觉任务做了一轮完整的任务序列训练,每学完一个新任务,就回测之前所有任务。SEMA 的旧任务准确率几乎没有变动,波动范围在 0.2 个百分点以内。原因也不难理解:旧任务使用的路径由旧 Adapter 和主干组成,这两个部分在后续训练中完全不更新。只要路由网络不把旧任务样本错分到新 Adapter,旧任务的表现就一定是稳定的。我还特意做了一个对抗测试:在第 8 个任务时强制模型删除一个旧 Adapter,旧任务准确率直接下降 12 个百分点。这印证了一个观点——SEMA 的可靠来自模块隔离,一旦隔离被打破,优势也就不复存在了。
5. 复现要点:从零搭建一个按需生长的 Adapter 系统
5.1 代码框架:AdapterPool、Router 和 GrowthController
复现 SEMA 不需要什么 fancy 的框架,PyTorch 就能搞定。我建议把代码拆成三个核心模块。AdapterPool 负责维护当前所有 Adapter,支持增加新 Adapter、冻结旧 Adapter、按层插入。Router 是一个小型 MLP,输入为 task embedding 和最后一层池化后的特征,输出为各 Adapter 的权重。GrowthController 负责维护训练状态,包括 loss 滑动窗口、梯度方向缓存、路由置信度统计。
伪代码大致长这样:
class AdapterPool(nn.Module): def __init__(self, config): super().__init__() self.adapters = nn.ModuleList() self.frozen = [] def add_adapter(self, init_from_idx=None): new_adapter = Adapter(config) if init_from_idx is not None: new_adapter.load_state_dict(self.adapters[init_from_idx].state_dict()) # 加一点随机扰动,打破对称性 for p in new_adapter.parameters(): p.data += torch.randn_like(p) * 0.01 self.adapters.append(new_adapter) return len(self.adapters) - 1 class Router(nn.Module): def __init__(self, task_emb_dim, feat_dim, num_adapters): super().__init__() self.net = nn.Sequential( nn.Linear(task_emb_dim + feat_dim, 128), nn.ReLU(), nn.Linear(128, num_adapters) ) def forward(self, task_emb, feat, temperature=1.0): logits = self.net(torch.cat([task_emb, feat], dim=-1)) return gumbel_softmax(logits, temperature=temperature, hard=True) class GrowthController: def __init__(self, thresholds): self.loss_window = deque(maxlen=20) self.confidence_threshold = 0.5 self.loss_rise_threshold = 0.15 self.conflict_threshold = 150 # 度5.2 关键参数怎么调:三个实用经验
第一个经验是增长阈值不要一开始就调得很激进。我把 loss 上升阈值从 0.1 调到 0.3,模型的扩展次数从 15 次降到了 6 次,但最终平均准确率反而低了 1.4 个百分点。原因在于,训练早期模型对现有 Adapter 的拟合还不充分,loss 轻微波动是正常的,如果因为一点波动就触发扩展,新增的 Adapter 很容易学到与旧模块重复的特征。建议先用 0.2 到 0.25 作为起步值,观察扩展频率再微调。
第二个经验是梯度方向冲突的计算不能直接用原始梯度,而要用“投影后的任务特征向量”。我最初的做法是把主干最后一层的梯度展平算余弦相似度,结果不同任务的梯度几乎都是正交的,冲突检测形同虚设。换成了 Adapter 输出特征的中心向量之后就正常了,SEMA 的出发点是判断模块层面的冲突,而不是主干层面的冲突。
第三个经验是路由网络需要 warmup。在第一个任务上,如果一开始就启用增长控制器,它会因为 loss 太大而误判为“能力不足”,疯狂扩展 Adapter。解决方法是前 K 个 step 只训练路由和 Adapter,不触发增长逻辑,等 loss 进入合理区间后再打开控制器。K 可以设为总训练 step 的 5% 到 10%,基本不会出问题。
5.3 踩过的坑:新手最容易犯的三个错误
第一个坑是忘记冻结旧 Adapter。很多人写完代码图省事,直接在整个 AdapterPool 上做反向传播,结果新任务训练完,旧任务效果掉得飞快。SEMA 的精髓就是“旧模块不更新”,这个约束必须在 PyTorch 里显式设置 requires_grad=False。
第二个坑是新 Adapter 的残差缩放系数设得太大。Adapter 结构里通常有一个可初始化为 0 的缩放参数,意思是插入 Adapter 的初始状态应该等同于恒等映射,不让它影响主干输出。如果你把这个参数初始化成 1,新 Adapter 一上来就会把主干输出带偏,后面要花很长时间才能修正。
第三个坑是路由网络的输出维度不随 Adapter 池动态变化。AdapterPool 每新增一个 Adapter,Router 的输出层神经元数量就必须对应增加。更好的做法是动态增大最后一层权重矩阵,并把新增行初始化为 0,这样不会影响旧任务的既有路由决策。我一开始偷懒把 Router 预开到最大容量 64,虽然能用,但会让路由在小池子阶段出现过拟合。
5.4 评估:如何观察模型“长大”带来的收益
除了看任务准确率,我强烈建议画两条曲线。一条是参数量随任务数量的增长曲线,另一条是平均准确率随任务数量的曲线。前者应该是阶梯状,每个台阶对应一次扩展;后者应该是单调上升或趋于平稳。如果参数量曲线非常平滑,说明模型每次扩展幅度很小但次数很多,这时要警惕是不是阈值设得太低。如果准确率曲线在某个任务之后开始下降,而参数量还在增长,那大概率是路由网络出了问题,把新任务的路由发散到错误模块上。
还可以做一个可视化:对每个任务的所有测试样本,统计路由选择矩阵。这个矩阵的每一行是一个任务,每一列是一个 Adapter,值表示该任务调用该 Adapter 的比例。 SEMA 的理想结果是一个对角块结构:相近任务共享一组 Adapter,差异大的任务独享一组。如果某个任务在多个 Adapter 之间来回横跳,说明该任务的 task embedding 还不够稳定,可以适当增加任务的 embedding 维度。
6. 常见问题速查:关于 SEMA 的十个疑点
6.1 模型会不会无限长大?
理论上会,但实际不会。我们可以给 Adapter 池设置一个上限,比如最多 64 个。达到上限后,SEMA 会进入“降级模式”,不再新增 Adapter,而是通过适配器融合将若干冗余 Adapter 合并成一个。融合的方法可以用权重平均,也可以用更复杂的知识蒸馏。我在实验中把上限设为 32,20 个任务最终只用到 23 个,距离上限还有很大余量。
6.2 SEMA 和 MoE 的区别是什么?
MoE 通常有一个固定的专家集合,输入通过门控网络选择专家,专家数量和结构是一开始定好的。SEMA 的 Adapter 池是动态增长的,而且每一个新“专家”都是为特定任务或分布触发的。你可以把 SEMA 理解成一个能自我扩容的 MoE,或者是一个有“成长能力”的条件计算模型。
6.3 新增 Adapter 会不会导致旧任务被新任务影响?
不会。旧任务的推理路径完全绕过新 Adapter,新 Adapter 的参数更新也不影响旧 Adapter。只要路由网络不被扰动,旧任务的输出就和训练完成那一刻完全一致。这种特性是由模块隔离和主干冻结共同保证的。
6.4 路由网络什么时候需要一起更新?
旧任务到来时,路由网络需要更新以适配新任务;旧任务回测时,不建议更新路由网络。 SEMA 的做法是每个任务训练时都创建当时的 task embedding 和路由权重快照,回测旧任务时使用对应快照。这样严格保证了持续学习中的“无遗忘”性质。
6.5 任务相似度很高时怎么办?
如果两个任务高度相似,梯度方向冲突检测会给出很小的夹角,路由网络也会倾向于复用同一个 Adapter,因此不会触发扩展。这和人类学新技能很像:会的越多,学新东西越快,因为底层的可复用模块足够多。
6.6 小数据量任务能不能撑起一次扩展?
可以,但需要小心。如果任务只有几百条样本,单独训练一个新 Adapter 很容易过拟合。这时建议把扩展阈值调高,尽量复用已有模块。如果复用的表现实在无法接受,才考虑扩展,并配合较高的正则化强度。
6.7 SEMA 支持 LoRA 替换吗?
支持,但不建议。如果坚持用 LoRA,需要额外设计动态权重合并机制,保证多个 LoRA 之间不互相污染。实现上会比 Adapter 复杂很多,效果也不一定有优势。
6.8 推理时会不会因为 Adapter 太多而变慢?
不会。路由网络每一条样本只会选中一个或少量 Adapter,其他 Adapter 不需要参与计算。即使池子里有几十个 Adapter,每次推理也只走其中一条路径,所以延迟与 Adapter 池大小无关,只与单个 Adapter 的大小有关。
6.9 增长控制器判断“该长大了”的延迟有多大?
增长控制器的判断基于滑动窗口和梯度夹角,计算开销很小。需要注意的是不要在每个 step 都做一次完整检测,建议每 10 个或 20 个 step 采样一次,这样更稳定,也能减少训练中的额外开销。
6.10 这套方法能用到生成模型吗?
可以,但要注意生成任务的路由选择需要按 token 级别进行,而不是按样本级别。 SEMA 原文主要展示的是分类和识别任务,在生成场景下还需要额外设计 token 级路由策略,这是一个很有潜力的后续方向。
7. 个人实操感受与一个容易被忽略的小技巧
读这篇工作的时候,我最开始只盯着“怎么长”这个技术点,后来才发现“什么时候长”才是真正难得的思考。很多动态网络方法都是一遇到困难任务就加容量,结果模型越加越复杂,最终和全量微调没什么区别。SEMA 的价值在于它给“扩容”建立了一套相对严谨的判定标准,而且还兼顾了模块隔离和参数效率。
我自己在复现过程中最有用的一个小技巧是:把增长控制器的决策信息记录下来,每个 step 记录当前 loss、置信度、梯度夹角和是否触发扩展。训练结束后画一张时间线图,你会非常清楚地看到模型是在哪些节点“被逼着长大”的。一张这种图比任何评估指标都能帮你理解模型的瓶颈在哪里,也能帮你更快地调阈值。如果你也想试,建议从一个小规模的 Adapter 池(两三个 Adapter)开始,配合一个包含 5 到 8 个任务的序列做实验,第一天就能看出动态扩容带来的差别。别指望它解决所有问题,但它确实让“模型什么时候该长大”这个抽象问题,变成了一个可以被计算、被度量、被决策的工程问题。