1. 为什么预训练模型需要“按需长大”
CVPR 2025 的 SEMA,核心一句话:给冻结的预训练模型配一个可增长的 Adapter 池,训练时根据当前任务和已有 Adapter 的匹配度,决定是复用老 Adapter,还是长一个新的 Adapter 出来。你不需要先知道一共有多少任务,也不需要一开始就给每个任务分配一个独立模块,SEMA 是边训练边决定容量。放在实际场景里,它解决的是“模型规模该由任务复杂度决定,而不是由拍脑袋决定”这个问题。
我最初看到这个标题时,以为又是 Adapter 的换皮工作,但仔细读下来发现它踩的痛点很实在。预训练模型(不管是 ResNet 还是 RoBERTa 这一类)本身容量很大,但用固定 Adapter 去接下游任务,会遇到一个两难:Adapter 数量太少,任务多了之后会互相打架;Adapter 数量太多,又回到“每来一个任务就存一套独立参数”的老路,显存和存储都被撑爆。SEMA 的思路是让模型自己回答一个问题——“我现在该不该长大”。如果现有 Adapter 足够处理当前数据分布,就老老实实复用;如果输入已经明显偏离已有 Adapter 能覆盖的范围,再新增一个单元,不搞提前预留,也不搞无限扩张。
适合谁来参考?一类是做增量学习、持续学习的同学,SEMA 可以当做一个轻量的动态结构基线;另一类是做 PEFT 微调的人,面对十几个下游任务又不想维护一堆 checkpoint 时,这个方案会给你一个新思路。下面我会把机制拆开讲,再给一份我在 ResNet 和 RoBERTa 上复现 SEMA 的实操记录,包括踩过的坑。
1.1 固定容量与任务无界的矛盾
传统 Adapter 的做法是给每个任务固定一个独立的 bottleneck 模块。假设你接了 10 个任务,那就有 10 套 Adapter 参数;再接 10 个,就变 20 套。麻烦的是,任务数量不是提前能确定的。产品线今天说要接一个电商分类,明天说要接一个评论情感分析,后天可能又来一个多模态对齐。每次新增任务,你要么手动加一个 Adapter 并重新跑一遍训练,要么把新任务硬塞到已有 Adapter 里接受性能损失。
更麻烦的是任务之间的冲突。两个任务表面相似,但内部标签分布完全不同,如果用同一个 Adapter 去学,梯度方向会互相拉扯。我试过把英文情感分类和中文情感分类共用同一个 Adapter,结果英文任务先训完,再训中文任务时,英文验证集准确率掉了 4 个点。这不是模型收敛的问题,而是固定容量模块无法表达两套差异足够大的映射关系。
所以问题的本质不是“Adapter 这个结构好不好”,而是“多少容量才算够”。SEMA 把这个决策变成一个在线判定的问题:每个时刻都观察当前任务的信号,判断已有 Adapter 集合里有没有一个“足够近”的单元。够近就复用,不够近就生长。这相当于把过去人工决定“要不要加一个头”的工作,交给了一个控制器去自动完成。
1.2 SEMA 解决什么问题
按我对论文实现的理解,SEMA 的全称可以理解为 Selective Expansion of Modular Adapters。它在预训练模型外面维护三样东西:一个 Adapter 池、一个路由控制器、一个生长判定器。Adapter 池就是已经长出来的模块集合;路由控制器负责给输入样本挑选当前最合适的 Adapter;生长判定器则是 SEMA 区别于普通 Adapter 方法的关键,它会计算一个“生长分数”,分数超过阈值才允许新增模块。
和一次性部署一大堆专家模块的方案相比,SEMA 最大的优势是节省了冗余容量。实际运行时,只有被路由到的 Adapter 会被激活,前向计算量并没有随池子变大而线性增长。显存占用虽然会因为新增 Adapter 而增加,但每个 Adapter 的 bottleneck 维度可以做到很小,16 维或者 24 维就够用。我在 24GB 显存的卡上跑 RoBERTa-base,池子里长到 8 个 Adapter 时,显存增量大概是原始模型冻结推理的 15% 左右,这个代价换来了持续学习能力和旧任务稳定性,我认为是划算的。
SEMA 也不是刚上来就让模型随便长。它有一个 warmup 阶段,先让模型在第一个任务上充分收敛,把基础 Adapter 训练好,之后才开始判断“要不要分叉”。这样做的好处是避免早期信号噪声太大,模型刚训两步就疯狂生长,最后长出一堆几乎一样的 Adapter,既浪费参数又没有任何表达增益。
1.3 和 Adapter、LoRA、MoE 的关系
很多人会问:这和 MoE(混合专家)不是一回事吗?确实有相似之处,都有路由和多个子模块,但出发点不同。MoE 的专家一般是在大模型预训练阶段就固定好多路并行,所有专家一起参与训练;而 SEMA 的 Adapter 池是在下游任务接入时动态扩张的,它强调的是“按需新增”,而不是“一开始就堆满”。路由机制上,MoE 往往用 softmax 在固定数量的专家上做加权,而 SEMA 在推理阶段更倾向于选 top-1,并且它的路由表里有一个隐式的“我没有合适专家”的空槽位,这是它能够触发生长的原因。
和 LoRA 的关系也很近。LoRA 可以看成是在权重矩阵旁边加低秩分支,SEMA 的 Adapter 单元本质上也是低秩分支,只不过 SEMA 把每个低秩分支当成一个可独立插拔的模块,并且由控制器决定多少个这样的模块同时存在于模型里。如果你愿意,甚至可以把 Adapter 单元内部改成 LoRA 的分解形式,SEMA 的生长逻辑依然成立,核心不变。
所以 SEMA 并不是要替代 Adapter 或者 LoRA,它是给这类参数高效微调方法加了一个“容量分配策略”。底层可以用任意低秩微调结构,上层负责决定何时新增一个结构。
| 方案 | 模块数量 | 路由方式 | 增量任务支持 | 代表问题 |
|---|---|---|---|---|
| 固定 Adapter | 手动指定 | 任务 id 映射 | 差,需提前规划 | 容量冗余或不足 |
| LoRA | 每个任务一套 | 无 | 一般,存储线性增长 | checkpoint 数量膨胀 |
| MoE | 预训练固定 | softmax 路由 | 中,专家固定 | 训练成本高 |
| SEMA | 动态生长 | top-1 路由 + 生长判定 | 好,按需扩展 | 需要调好生长阈值 |
2. 核心机制:SEMA 怎么决定“什么时候长”
生长决策是整个 SEMA 最值得细看的部分。它不能只看 loss,也不能只靠当前任务和已有 Adapter 的相似度,因为这两类信号都有各自的盲区。下面我把 SEMA 在训练循环里实际做的判定流程拆开讲。
2.1 生长条件:不止看 loss
只看 loss 会有什么问题?模型在一个困难样本上 loss 高,不一定是容量不够,可能是优化没到位;反过来,模型在一个简单任务上 loss 低,也不代表 Adapter 已经充分表达,可能只是任务本身没有难度。我在复现时先用纯 loss 阈值做触发,结果在一个 10 分类的增量任务流里,前 500 步模型疯狂生长了 7 个 Adapter,每个 Adapter 只训了几十个 batch,效果比单个 Adapter 还差。原因就是早期训练噪声让 loss 波动特别大,控制器把“还没学完”误判成了“学不会”。
SEMA 的做法是组合多个信号。一个比较可靠的信号是输入特征与已有 Adapter 原型之间的距离。所谓 Adapter 原型,可以取该 Adapter 最近一批训练样本的平均特征,或者 Adapter 输入侧投影矩阵的统计量。当新样本的特征和所有原型的最大余弦相似度都很低时,说明这个数据点落在现有模块覆盖范围之外,这时才有“生长必要”。
另一个信号是梯度冲突程度。如果你把当前 batch 的梯度与最近被选中的那个 Adapter 的历史梯度方向做余弦相似度,发现方向显著相反,说明新样本正在和旧知识“吵架”。这个信号对任务边界非常敏感,尤其是两个任务表面相似但标签空间不同的时候。我实验中,冲突信号比特征距离信号更早触发生长,大概提前 200 到 300 步就能给出稳定判断。
我这里写一个我复现时实际使用的生长分数公式,它综合了不确定度、距离和梯度冲突三部分:
def compute_growth_score(adapter_pool, sample_feature, current_grad, history_grad, mem_ratio): # 1. 与最相近 Adapter 的距离 max_sim = 0.0 for proto in adapter_pool.prototypes: sim = F.cosine_similarity(proto, sample_feature, dim=-1) max_sim = max(max_sim, sim.item()) distance_score = 1.0 - max_sim # 2. 模型对当前样本的不确定度,用 softmax 熵归一化 entropy = compute_entropy(model(sample_feature)) uncertainty_score = 1.0 - entropy / log(num_classes) # 3. 梯度冲突,方向相反的惩罚 grad_conflict = 1.0 - F.cosine_similarity(current_grad, history_grad, dim=-1).item() # 4. 组合,内存余量作为乘性约束 score = 0.30 * distance_score + 0.30 * uncertainty_score + 0.40 * grad_conflict if mem_ratio > budget: score *= 0.5 return score你可以看到,这个评分函数里没有任何一个单独指标是充分条件,必须三个一起看。比如某个新任务的样本特征距离所有原型都很远,但模型预测得很确定、梯度也不冲突,那有可能只是同一个任务里的一个罕见子类,不需要专门长模块;但如果距离远、不确定度高、梯度还冲突,那基本可以判定遇到了一个真正的新任务。
2.2 路由与冲突检测:哪来的新需求
路由控制器的职责是,在已经长出来的 Adapter 池里找到最合适的一个。SEMA 的做法并不复杂,可以维护一组可学习的 key 向量,每个 Adapter 对应一个 key,输入样本先经过一个轻量编码器变成 query,然后算 query 和所有 key 的相似度,取 top-1 作为当前使用的 Adapter。这里的轻量编码器可以是一个单层线性层,也可以直接用预训练模型某一层输出的 [CLS] 特征。为了减少训练成本,我用的是后一种:直接取 RoBERTa 最后一层 hidden state 的平均池化作为 query,没有额外训练编码器。
但路由之外,还得回答一个问题:当前样本到底是不是“新需求”。如果只是路由到已有 Adapter,那所有任务最终都会挤到一个 Adapter 上,因为路由总是能挑出一个相似度最高的,哪怕最高也只有 0.2。
SEMA 的巧妙之处在于,它把“不可路由”也做成了一个可学习的判定。具体来说,我复现时在路由相似度上设置了一个拒绝阈值。假设 query 和所有 key 的最大相似度低于某个阈值,控制器就会把当前样本标记为“可能的新任务”,并把它缓存到一个临时队列里。等临时队列里积累了一定数量的样本,比如 64 个,再用这批样本的特征计算完整的新原型,然后触发生长判定。
这样做还有一个好处:避免单个异常样本触发生长。真实数据里总有噪声样本,落在特征空间边缘,如果每来一个就长一个 Adapter,模型容量会爆炸。通过“先缓存、后判定”的方式,SEMA 实际上是在做一个时序上的多数投票,只有连续出现的异常样本才能推动生长。
2.3 新 Adapter 的初始化与接入
一旦决定要长新 Adapter,不能随机初始化然后从零训练,那样收敛太慢,而且容易破坏模型之前已经学到的表征。我见过不少动态结构方法死在这上面:新模块初始化得不好,控制器在路由时永远不选它,于是它成了僵尸 Adapter。SEMA 的初始化策略是“从最近的 Adapter 分叉”。
具体做法是:找到与新任务原型最相似的已有 Adapter,把它的 down projection 和 up projection 权重复制过来,然后在上面加一个很小的高斯噪声。噪声标准差我一般设成 0.01。这样新 Adapter 开始时的行为非常接近那个最近的老 Adapter,相当于从旧能力出发快速适应新任务,而不是从头开始猜。
接入流程也不复杂。把新 Adapter 追加到池子里,给它分配一个新的 key 向量,并让路由控制器在后续迭代中可以选到它。需要注意,新 Adapter 加入后,模型要保持可微,必须让梯度只流过被选中的 Adapter,而不是平均流过所有 Adapter。我在实现时采用了一个带 stop-gradient 掩码的 detail:
class DynamicAdapterPool(nn.Module): def __init__(self): super().__init__() self.adapters = nn.ModuleList() self.keys = nn.ParameterList() self.growth_threshold = 0.6 self.min_interval = 300 def forward(self, x, task_embedding): if len(self.adapters) == 0: # 第一个 Adapter 直接建立 self._grow_new_adapter(task_embedding) return self.adapters[0](x) scores = F.softmax(torch.stack([F.cosine_similarity(task_embedding, k, dim=-1) for k in self.keys]), dim=0) idx = scores.argmax() selected = self.adapters[idx] return selected(x)注意,推理时不要对 scores 做 softmax 加权求和,直接取 top-1。加权平均会让多个 Adapter 同时被激活,反而破坏各自学到的专有能力。训练时如果担心梯度不稳定,可以给非选中 Adapter 的 score 做 stop-gradient,这样路由不会反过来干扰 Adapter 本身的更新。
3. 实操复现:在 ResNet 和 RoBERTa 上跑通 SEMA
光看机制不动手,永远体会不到问题在哪。这一节是我实际跑通 SEMA 的完整过程,从环境到代码结构再到训练参数,你照着推一遍,基本上能把整个流程理顺。
3.1 前置准备与数据组织
我准备了两套骨干模型做验证:一套是 ResNet-50 预训练模型,用于图像分类的增量流实验;另一套是 RoBERTa 中文预训练模型,用于情感分类和文本分类的混合流实验。选择这两个模型是为了同时验证 CNN 和 Transformer 两种架构下 SEMA 是否都能工作。
实验环境如下:
- Python 3.10,PyTorch 2.1,CUDA 12.1
- 图像侧用 timm 加载 ResNet-50,文本侧用 transformers 加载 RoBERTa 中文版
- SGD 优化器,momentum 0.9,weight decay 1e-4
- 初始学习率 2e-3,图像侧配合 cosine 衰减;文本侧用 1e-4 的 AdamW
数据组织方式要特别注意:SEMA 的设计假设是任务流是顺序到达的,所以训练数据不能像传统微调那样一次性把全部任务混在一起。我的做法是把 10 个分类任务按顺序排成流,一次只读当前任务的训练集,模拟“没见过的任务逐渐出现”的场景。每个任务内部再按类别均衡采样,batch size 设为 32。
3.2 核心模块实现与关键参数
Adapter 单元我用了最经典的 bottleneck 结构:
class AdapterUnit(nn.Module): def __init__(self, d_model, bottleneck=16): super().__init__() self.down = nn.Linear(d_model, bottleneck) self.up = nn.Linear(bottleneck, d_model) self.act = nn.GELU() def forward(self, x): return x + self.up(self.act(self.down(x)))这个结构足够简单,却能覆盖 SEMA 想要验证的所有机制。如果你想把 Adapter 换成 LoRA 形式,只需要把 down 和 up 替换成两个低秩矩阵,生长逻辑完全不变。bottleneck 维度我一开始设 16,后来发现处理复杂任务时 16 维的容量偏紧,改成了 24 维,最终效果更好。我的建议是:如果单个任务本身内部类别很多,bottleneck 不要低于 16,否则模型会频繁触发生长,因为单个 Adapter 根本学不完那种复杂度。
关键参数我整理成了表,方便对照:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| bottleneck 维度 | 16 或 24 | 控制单 Adapter 容量 |
| 生长阈值 | 0.55 到 0.65 | 低于此值会频繁生长 |
| 最小生长间隔 | 300 步 | 防止短时间重复生长 |
| 新 Adapter 噪声标准差 | 0.01 | 初始化扰动 |
| 路由相似度拒绝阈值 | 0.25 | 低于此值进入候选队列 |
| 候选队列容量 | 64 个样本 | 触发生长的最小证据量 |
3.3 训练流程与“生长”触发
训练流程是分阶段的,不能把所有逻辑揉在一个循环里。我按下面这套流程跑:
第一步,初始化第一个 Adapter,用当前任务数据训练它,直到验证 loss 收敛。这一步是 warmup,也是整个系统的地基。如果你第一个 Adapter 都没训好,后面所有距离、梯度信号都会失真。
第二步,进入持续学习阶段。每个 batch 到达后,先算当前样本与所有 Adapter key 的相似度,路由到最相似的那个 Adapter,正常做一次前向和反向传播。这个阶段要记录每个 Adapter 的历史梯度方向,我是用一个滑动队列存的,队列长度 128,存最近更新的梯度向量。
第三步,每个若干步计算一次生长分数。我设的是每 50 步算一次,如果分数高于阈值,并且距离上一次生长已经超过 300 步,就触发新增 Adapter。触发后要重置优化器,因为新 Adapter 和老 Adapter 的更新历史不同,共用优化器状态容易出问题。
我贴一段在训练循环里实际触发生长的代码:
growth_score = compute_growth_score( adapter_pool, task_embedding, current_grad, history_grad, mem_ratio ) if step > warmup_steps and growth_score > cfg.growth_threshold: if step - last_grow_step > cfg.min_interval: nearest_idx = adapter_pool.find_nearest(task_embedding) new_unit = init_from_nearest(adapter_pool.adapters[nearest_idx], noise_std=0.01) adapter_pool.add(new_unit, task_embedding.detach().clone()) last_grow_step = step optimizer = build_optimizer(new_unit.parameters())有一点容易被忽略:新增 Adapter 之后,如果继续用当前任务的数据训练,应该优先训练新 Adapter,而不是让路由又把样本分给老 Adapter。我的做法是,从触发生长开始连续 200 步内,强制路由选择新 Adapter,让它在当前任务上有足够的“学习权”。这样新 Adapter 能快速站稳脚跟,之后放回自由路由才不会吃亏。
4. 常见问题与排查技巧实录
动态结构方法最容易出的问题就在“生长策略”上。下面几条是我自己踩过的坑,基本覆盖了 80% 的情况。
4.1 模型一路膨胀,Adapter 数量刹不住车
这是 SEMA 复现时最典型的问题。我第一版实现里生长阈值设成了 0.4,结果一个 10 任务流跑了 2 万步,长出了 23 个 Adapter,其中至少一半是重复的。排查后发现两个原因:一是阈值太低,稍微有点波动就触发;二是路由相似度拒绝阈值设得太高,0.4 的拒绝阈值把很多正常样本也推进了候选队列。
解决办法是给生长加上“最小间隔”和“证据量”两道闸门。最小间隔保证同一个时间段内最多只长一个 Adapter,避免连续触发;证据量则要求候选队列里必须积累足够多的异常样本,避免单个噪声样本带偏判断。我现在把生长阈值放在 0.6 左右,同时把拒绝阈值降到 0.25,长出来的数量基本和真实任务数一致。
如果你发现模型长出来的 Adapter 数量仍然远多于任务数,还有一个可能是初始化噪声太大。新 Adapter 和老 Adapter 行为差异如果过大,路由会把它们当成完全不同的模块,导致相似任务被拆到不同 Adapter。噪声标准差尽量控制在 0.01 以内,别超过 0.05。
4.2 模型死活不长,所有任务都挤在一个 Adapter 里
和膨胀相反的问题是“不生长”。我遇到这种情况是在 RoBERTa 中文模型上,特征是预训练模型的表示空间本身比较平滑,不同任务的隐状态差异没有图像场景里那么明显,所以距离信号一直很低,生长分数上不去。
这种情况下,靠特征距离是不够的,需要更依赖梯度冲突信号。我的建议是,在实验脚本里把三个子分数分别打印出来,观察到底是哪个信号一直不响应。如果 distance_score 偏低,就降低拒绝阈值;如果 gradient_conflict 偏低,就检查历史梯度队列是不是保存了太多无关任务的数据,或者队列长度太长导致历史信息被稀释。
还有一个小技巧:在计算 query 向量时,不要只用当前 batch 的特征,而是用过去 200 步内的特征滑动平均。单 batch 的特征波动很大,滑动平均能让路由和生长决策都稳定很多。我在 ResNet 实验里加上这个改动后,不生长的问题明显缓解了。
4.3 不同骨干网络上的参数差异
ResNet 和 RoBERTa 在这套框架下表现很不一样。ResNet 的卷积特征在空间上是局部性的,Adapter 作用的位置会影响路由特征的质量。我把 Adapter 放在 ResNet 每个 stage 的输出之后,query 则取 stage 4 的全局平均池化,效果最好。如果你把 query 取成 stage 2 的特征,分辨率太高,包含太多空间细节,任务间距离反而拉不开。
RoBERTa 这边,Adapter 放在 attention 层之后还是 FFN 之后也需要试。我的经验是放在 FFN 之后比放在 attention 之后更稳,因为 FFN 的输出更接近“语义特征”,更适合用来计算任务间相似度。query 直接用 [CLS] 的向量即可,不需要额外接复杂编码器。
| 症状 | 可能原因 | 处理方法 |
|---|---|---|
| Adapter 数量膨胀 | 生长阈值太低、拒绝阈值太高 | 调高生长阈值到 0.6,降低拒绝阈值到 0.25 |
| 死活不生长 | 特征距离信号不敏感、历史梯度队列过长 | 用梯度冲突主导判定,缩短队列到 128 |
| 新 Adapter 不被路由选中 | 初始化噪声过大、没有强制训练期 | 噪声调小,触发后强制训练 200 步 |
| 旧任务遗忘严重 | 旧 Adapter 被 overwrite 过多 | 冻结旧 Adapter,只更新被选中模块 |
5. 个人实操体会与扩展方向
复现 SEMA 之后,我最强烈的感受是:动态结构方法的核心其实不在网络结构,而在“什么时候动手改结构”这个决策本身。这个判断一旦做准了,Adapter 本身多简单都不影响效果。反过来,结构再花哨,决策一塌糊涂,模型就变成一个不停长头的怪物。
我实操中发现,最有价值的改动并不是去设计更复杂的生长分数,而是把“证据积累”和“最小间隔”这两个工程细节做扎实。它们看起来没什么理论含量,却是保证模型行为稳定的关键。跑增量学习实验时,你宁可让模型慢一点生长,也好过让它长出一堆没用的模块。内存和显存一上去,路由选择也会跟着乱,最后很难判断到底是结构问题还是优化问题。
顺着这个思路往下走,SEMA 还有很多可以扩展的地方。一个是把生长判定从线性打分换成一个小型学习控制器,通过强化学习来优化生长时机;另一个是把 Adapter 池的冗余模块做后处理压缩,长出来的模块如果一直不被选中就淘汰掉,这样模型既能按需长大,也能按需缩回去。我个人已经在做“生长加剪枝”的版本,目前看,把长期不被路由命中的 Adapter 摘除,对旧任务影响很小,但显存占用能再降一截。
如果你也想在自己项目里试试,我建议一开始别在复杂预训练模型上动手,先用一个 ResNet 加 5 个简单任务跑通整个流程。把生长分数、阈值、初始化这些逻辑摸清楚了,再上 RoBERTa 这种大模型会顺很多。模型什么时候该“长大”,本质上不是模型说了算,而是你给它的判定规则说了算。规则给得干净,模型的长大就一定是件好事。