
做过多任务或增量式业务模型训练的同学大概率遇到过这样一个场景模型先学会了 A 任务的数据分布等训练完 B 任务再回头测试 A 任务时A 的准确率掉得让人怀疑代码是不是写错了。这种现象并不是玄学而是深度学习中非常经典且棘手的问题——灾难性遗忘Catastrophic Forgetting。业内已经提出了很多缓解思路回放旧样本、对重要参数做约束、给不同任务分配独立分支等。但它们在真实业务中往往会遇到存储开销大、参数膨胀快、训练不稳定等问题。最近持续学习方向有个非常值得关注的范式用“一个 Adapter”通过任务条件化的特征变换Task-Conditioned Feature Transformations让一套共享基础网络服务多个任务。这类思路不仅论文越来越多也逐渐被引入到多任务视觉模型、增量分类、个性化推荐等工程场景中。本文将围绕“One Adapter, Many Tasks”这条路线做一次系统的原理拆解与思路演示。我会先讲清楚它解决什么问题再分析模块结构与训练方式最后给出可复现的 PyTorch 思路代码和避坑建议。适合正在做持续学习、多任务学习或模型增量更新的研究者和工程师阅读。1. 背景与核心问题1.1 持续学习的基本设定持续学习Continual Learning要解决的核心问题是模型在一系列任务上顺序训练需要同时做到三点学会当前任务。不遗忘历史任务。能够复用之前学到的通用特征而不是每个任务从头学。按照测试时的假设持续学习通常被分成几类设置训练阶段测试阶段难度Task-Incremental Learning按任务顺序训练知道当前测试样本属于哪个任务相对容易Domain-Incremental Learning按域顺序训练不需要知道任务标识中等Class-Incremental Learning按类别组顺序训练不仅要分类还要推断任务来源较难本文讨论的 Task-Conditioned Feature Transformations天然适合“测试时任务编号已知或可推断”的设置。这也是很多持续学习论文默认的评估方式。1.2 为什么普通微调会导致灾难性遗忘假设模型已经学会了 A 任务我们要继续学 B 任务。如果直接让整网络参与梯度更新优化器会为了降低 B 的 loss把大量权重推向适合 B 的方向。此时那些对 A 很关键、但对 B 并不重要的权重也会被修改。从特征空间的角度看B 任务训练会让特征提取器逐渐把表征空间“扭曲”成更适合 B 的形状。任务 A 的旧样本如果不再出现A 的分类边界就缺少梯度约束因此很容易被覆盖。常见的防遗忘方案有回放真实样本或生成样本。对重要权重施加二次惩罚。新任务增加新分支或扩展新容量。回放方法效果直观但需要维护样本缓冲区并要考虑隐私与存储成本。正则化方法只更新共享参数但容易在任务数量较多时约束过强或过弱。动态扩展新分支的方法效果不错可模型体积会随着任务数量线性增大。1.3 Adapter 与“参数高效”思路的引入近年来很多研究者开始转向一种更经济的做法固定预训练骨干网络的绝大部分参数只训练插入其中的少量 Adapter 模块。每个任务用很小的额外参数去适配新的数据分布。“One Adapter, Many Tasks”的范式则更进一步不打算为每个任务保存一份独立 Adapter而是希望所有任务共享同一个 Adapter 实例通过任务条件Task Conditioning机制去控制这个 Adapter 对特征施加的变换。这样做的好处显而易见额外参数不会随任务数量线性膨胀。共享模块保留了任务间可迁移的通用知识。任务特有信息被压缩在低维的任务嵌入中。特征变换的任务条件化让模型可以“按需调整”特征空间。这就是本文将重点分析的思路核心。2. Task-Conditioned Feature Transformations 核心概念2.1 Adapter 的经典形态自然语言处理中非常常见的 Adapter 结构是一个“瓶颈”子网络先对输入特征做降维。经过非线性激活。再升维回到原始维度。通过残差连接叠加到原始特征上。在视觉骨干网络里Adapter 通常插入在每个 Transformer Block 或卷积 Block 之后把主干特征做一次轻量变换。这样做的好处是骨干网络可以保持预训练状态大幅减少需要更新的参数量同时又能贴近目标任务进行特征调整。2.2 特征变换的两种基本类型在“One Adapter”路线中特征变换通常可以归纳为两种类型第一种是“特征级仿射变换”。模型根据任务标识为每个特征通道生成一组缩放系数 gamma 和偏置 beta。设共享特征为 h任务条件特征变换可以写成h gamma_t ⊙ h beta_t其中 gamma_t 和 beta_t 是根据任务 t 生成的。这类方法与 FiLM、Conditional Normalization 的思想一脉相承优点是计算成本极低只需要逐通道操作即可完成对特征分布的调整。第二种是“低秩变换”。我们可以把共享特征 h 乘上一个任务相关的小矩阵 Delta_t再叠加到原特征上h h Delta_t(h)如果 Delta_t 被打成低秩形式例如通过两个低维矩阵相乘实现那么不同任务共享同一套参数骨架只是低秩矩阵的组合系数不同。在实际论文中这两种操作经常被合在一起使用。底层 Base Model 提供通用表示Adapter 提供任务相关的特征调整Task Embedding 则负责生成调整参数。2.3 “任务条件”到底条件化什么很多人第一次看到“Task-Conditioned”时会困惑任务编号是一个离散标签怎么和神经网络联系起来最常见的做法是为每个任务学习一个低维任务向量Task Embedding。这个任务向量的长度可能只有 16、32 或 64 维。它参与预测 Adapter 参数而不是直接作为输入拼接到图像或文本特征上。例如任务嵌入输入到一个小型 Hypernetwork 中。Hypernetwork 输出每个通道的 gamma 和 beta。或者输出低秩矩阵的系数。测试阶段如果任务编号已知直接根据 task id 读取对应的任务向量计算该任务的变换参数即可。任务编号未知时就需要额外的任务推断模块或者用“每轮输入特征选出最相似任务向量”的方式来近似。2.4 为什么共享 Adapter 不容易遗忘传统全量微调容易遗忘的一个重要原因是模型几乎没有“任务隔离”机制。而共享 Adapter 模式下事情变得不一样骨干网络被冻结通用特征分布稳定。每个任务通过自己的 Task Embedding 决定特征的调整方向。学习新任务时Adapter 只负责更新所有任务共享的“变换能力”而任务本身的信息被压缩在任务向量中。也就是说任务 A 的表征空间并没有被破坏。每次训练后任务 A 对应的 Task Embedding 和 Adapter 配合后依然能还原出“A 任务想要的特征变换”。遗忘的风险从“整个网络权重漂移”降低为“共享 Adapter 内部发生冲突”而后者更容易通过正则、低秩约束或不共享骨干等方式缓解。3. 系统结构与训练流程拆解3.1 一个通用系统框架为了实现“一个 Adapter多任务共用”整个系统通常由以下部分组成组件作用是否随任务增加骨干网络提取通用特征否共享 Adapter对特征做任务条件变换否Task Embedding描述任务特性是每个任务一个向量Task Condition Generator由任务向量生成变换参数否Task Classifier当前任务分类头看设计可共享可独立这里最值得注意的点是额外参数的增长只有 Task Embedding其余模块可跨任务复用。Task Embedding 如果每任务只占几十个浮点数那即使训练几百个任务额外参数量也远小于给每个任务单独存一份 Adapter。3.2 主干特征提取用卷积网络举例去掉分类层后模型对输入图片 x 输出一个特征图或特征向量h Backbone(x)为了让 Adapter 尽量轻量通常会在特征维度较高时先降维。例如 Transformer 中每层特征可能为 768 维Adapter 内部先降到 96 维再做变换。3.3 Adapter 的任务条件化生成当需要处理第 t 个任务时系统会读取任务向量 e_t。然后任务条件生成器根据 e_t 生成 Affine 参数[gamma_t, beta_t] G(e_t)其中 G 可以是一个两层 MLP。为了让不同任务尽量使用不同的特征变换方向可以对 gamma_t 初始化成接近 1 的数对 beta_t 初始化成接近 0 的数。这样在训练初期Adapter 近似于恒等映射不会破坏骨干网络的原有表征。3.4 分类与损失经过 Adapter 变换后的特征 h_t 会被送入任务 t 对应的分类头。有两种常见分类头设计如果有共享的语义空间所有任务共享一个分类头只在旧类别输出位置做 Mask。如果每个任务标签空间独立可以为每个任务保留一个轻量分类头。第二种更符合 Task-Incremental 的设定。由于任务向量本身已包含任务信息分类头甚至可以直接由任务向量动态生成进一步减少任务独立参数量。3.5 训练阶段的“串行”逻辑持续学习的训练过程不是多任务联合训练而是按顺序进行的训练任务 1 时随机初始化 Task Embedding 1和共享 Adapter、分类头一起更新。训练任务 2 时冻结骨干网络新加入 Task Embedding 2。优化器继续更新共享 Adapter同时更新 Task Embedding 2。为了增强稳定性可以在更新共享 Adapter 时加一些正则约束或者使用较低的学习率。这样可以保证当前任务学到的新知识不会对旧任务产生毁灭性影响。4. 用一个 PyTorch 示例演示核心思路论文中的完整代码通常比较工程化。为了帮助你理解原理这里我用一个简化示例演示整套流程。这个 Demo 不是论文官方实现而是“任务条件特征变换”思想的最小复现版本。4.1 创建任务条件 Adapter假设骨干网络输出一个 256 维的特征向量。我们为每个任务维护一个 32 维的任务向量用一个生成器输出缩放系数与偏置。import torch import torch.nn as nn import torch.nn.functional as F class TaskConditionedAdapter(nn.Module): 任务条件化的 Feature Transform 1. 每个任务有一个 task embedding。 2. 条件生成器根据 task embedding 生成逐通道 gamma 和 beta。 3. 特征经过 affine 变换后与残差连接。 def __init__(self, feature_dim256, cond_dim32): super().__init__() self.feature_dim feature_dim self.cond_dim cond_dim # 每个任务的 embedding会在训练中按任务索引读取 self.register_buffer(task_embeddings, None) self.task_embedding_dim cond_dim # 条件生成器task embedding - (gamma, beta) self.condition_net nn.Sequential( nn.Linear(cond_dim, feature_dim), nn.ReLU(inplaceTrue), nn.Linear(feature_dim, feature_dim * 2), ) # 一个简单的瓶颈变换作为共享特征变换能力 self.transform nn.Sequential( nn.Linear(feature_dim, feature_dim // 2), nn.ReLU(inplaceTrue), nn.Linear(feature_dim // 2, feature_dim), ) # 初始化 for m in self.condition_net.modules(): if isinstance(m, nn.Linear): nn.init.zeros_(m.weight) nn.init.zeros_(m.bias) # 这样初始时 gamma ≈ 1, beta ≈ 0 # 后续真正实现时需要将最后一个 Linear 的 bias 设置为 [1, 0] # 简单起见这里在 forward 中对输出做了数学处理 def set_task_embeddings(self, num_tasks): 为 num_tasks 个任务创建可学习的 task embedding。 self.task_embeddings nn.Parameter( torch.randn(num_tasks, self.task_embedding_dim) * 0.02 ) def forward(self, x, task_id): x: [B, feature_dim] task_id: [B] 或标量 if not isinstance(task_id, torch.Tensor): task_id torch.tensor(task_id, dtypetorch.long, devicex.device) if task_id.dim() 0: task_id task_id.unsqueeze(0).expand(x.size(0)) task_emb self.task_embeddings[task_id] # [B, cond_dim] cond_out self.condition_net(task_emb) # [B, feature_dim * 2] gamma, beta cond_out.chunk(2, dim-1) # 让初始 gamma 接近 1beta 接近 0 gamma torch.tanh(gamma) * 0.1 1.0 beta torch.tanh(beta) * 0.1 transformed x * gamma beta residual self.transform(transformed) return F.relu(transformed residual)这个模块的核心是“条件生成器 共享特征变换”。不同任务虽然共享同一个 Adapter 实例但由于任务 embedding 不同gamma 和 beta 也不同因此产生的特征变换是具有任务倾向性的。但这段代码还比较粗糙真实使用时需要注意最后一层初始化和标准化方式。如果希望逐通道缩放还可以让 gamma、beta 保持和 x 第二维相同如果 x 是[B, C, H, W]则需要按通道维度缩放。4.2 构造一个简单的持续学习训练循环为了让整个流程更清晰我写出一个模拟训练循环。这里没有用真实数据集只是演示“如何让新任务与旧任务协同训练”。def train_one_task(model, adapter, classifier, train_loader, task_id): 训练单个任务。 假设模型 backbone 已冻结需要训练 adapter、task embedding 和当前任务分类头。 # 取出当前任务的 embedding task_embedding adapter.task_embeddings[task_id] # 需要优化的参数adapter 中除 task embedding 外的共享参数 当前 task embedding 当前任务分类头 params [] params.append(task_embedding) params list(adapter.transform.parameters()) params list(adapter.condition_net.parameters()) params list(classifier.parameters()) optimizer torch.optim.Adam(params, lr1e-3) loss_fn nn.CrossEntropyLoss() for epoch in range(3): for x, y in train_loader: x x.cuda() y y.cuda() adapter.train() classifier.train() with torch.no_grad(): feat model(x) # 特征 feat_adapted adapter(feat, task_id) # 任务条件特征变换 logits classifier(feat_adapted) loss loss_fn(logits, y) optimizer.zero_grad() loss.backward() optimizer.step()实际训练中我们不建议完全零梯度冻结 backbone因为低学习率微调骨干网络往往能提升新任务表现。但这时要配合 EWC 等正则方法防止灾难性遗忘。4.3 测试阶段如何利用任务标识在 Task-Incremental 设置下测试时知道样本属于哪个任务。因此我们可以直接读取对应任务的 task embedding并调用与任务配套的分类头。torch.no_grad() def eval_task(model, adapter, classifiers, test_loader, task_id): 验证第 task_id 个任务的准确率。 classifiers 是一个 list每个任务一个分类头。 model.eval() adapter.eval() classifier classifiers[task_id] classifier.eval() correct 0 total 0 for x, y in test_loader: x x.cuda() y y.cuda() feat model(x) feat_adapted adapter(feat, task_id) logits classifier(feat_adapted) pred logits.argmax(dim1) correct (pred y).sum().item() total y.size(0) return correct / total这个测试逻辑说明了一件事在 Task-Incremental 场景下task id 是模型推理时的重要先验信息。如果你的实际场景拿不到 task id那么还需要额外增加任务推断分支或者在特征空间设计“最近邻任务匹配”策略。4.4 评估持续学习效果平均准确率与遗忘度学术界评估一个持续学习算法通常不只关心最后一个任务。常见指标是“所有任务的平均准确率”。它可以简单实现如下def compute_average_accuracy(results): results: dict, key 为训练到第 k 个任务后测试得到的准确率矩阵 这里简化为 list of lists第 i 行表示训练完第 i 个任务后 分别测试第 0..i 个任务的准确率。 total 0.0 for history in results: total history[-1] return total / len(results)更常用的指标是 BWTBackward Transfer表示在学到新任务后旧任务准确率的变化。负值越大说明遗忘越严重。def compute_bwt(acc_matrix): acc_matrix[i][j]训练完第 i 个任务后测试第 j 个任务的准确率。 BWT 通常是 (训练完最后一个任务后对任务 j 的准确率 - 刚训练完任务 j 时的准确率) 的平均。 n_tasks len(acc_matrix) total_bwt 0.0 for j in range(n_tasks - 1): total_bwt acc_matrix[-1][j] - acc_matrix[j][j] return total_bwt / (n_tasks - 1)5. 几个高频问题与排查思路5.1 为什么新任务会降低旧任务准确率即使我们冻结了骨干网络只训练共享 Adapter理论上遗忘风险已经降低但仍然可能出现旧任务准确率下降。原因通常有三个条件生成器或共享 Adapter 被新任务过度优化扰动了旧任务的变换方向。任务 embedding 初始化太差导致新旧任务的任务向量在高维空间发生冲突。学习率过高共享参数更新步长太大。排查时可以先冻结 Adapter 的共享参数只训练任务 embedding 和分类头再逐步放开限制。如果这种方式旧任务表现稳定就说明问题出在共享 Adapter 的更新策略上。5.2 增大 Task Embedding 维度一定更好吗不一定。任务向量维度过高可能让条件生成器过拟合训练时的任务分布也容易导致不同任务向量之间正交性不足。维度太低则可能无法充分表达不同任务的差异。建议先从 16 或 32 维开始尝试。在训练时可以对 Task Embedding 加 L2 正则甚至加一个正交化约束促使任务向量之间保持区分度。5.3 测试时 task id 未知怎么办这是从 Task-IL 走向 Class-IL 时最核心的难点。几种常见思路用一个小型任务预测网络在特征层面判断当前样本属于哪个任务。把每个任务的分类头向量存下来测试样本特征经过不同 Adapter 变换后用熵值最低的那个任务作为结果。设计完全 task-agnostic 的条件机制例如通过聚类或查询模型来自动决定变换。最后一种难度更高但如果业务场景不允许任务标签就必须认真设计。5.4 是每个 Block 都插入 Adapter还是只插最后几层取决于特征空间的性质。如果不同任务输入分布差异很大例如自然图像与医学图像建议在较浅层也做任务条件化变换。如果任务差异主要体现在高层语义例如类别不同但图像背景相似只在最后部分插入 Adapter 就足够。任务条件化 Feature Transform 通常比普通 Adapter 更灵活因为它直接调制特征通道对浅层特征也能产生较强调整。但从训练稳定性的角度看插入位置过多会导致优化负担增大。5.5 Adapter 参数量可以进一步压缩吗可以。常见压缩策略把 task embedding 先降维再输入条件生成器。gamma 和 beta 只作用于部分通道例如每隔几个通道共享一组参数。使用低秩矩阵近似替代 MLP 形式的条件生成器。共享条件生成器不同任务只学习极低维的调制向量。6. Adapter、Prompt、Hypernetwork 之间的对比在“One Adapter, Many Tasks”范式中很容易联想到它的近亲Prompt、Hypernetwork、条件归一化。方法类别基本假设额外参数稳定性适用场景每个任务独立 Adapter任务间完全不共享适配参数随任务数量线性增长高任务差异极大本文讨论的任务条件 Adapter任务共享多数参数仅用任务向量调制每任务仅一个低维向量中高任务数量多、差异可控Prompt / Prefix Tuning在输入或特征序列中加可学习前缀每任务 prompt 参数中预训练 Transformer 模型Hypernetwork用超网络批次生成任务参数每任务一个 embedding中需要动态生成较多网络参数全量微调 EWC直接更新骨干很少低任务数量少、关系强从参数效率与性能之间的平衡来看任务条件 Adapter 的思路非常优秀。它的关键优势在于任务之间的冲突被限制在一个极小的变换模块内并且这个模块不仅是“存储任务信息”还带着建模共享特征变换规律的能力。Prompt 也很有竞争力但 Prompt 更适合 Transformer 结构且很难把“特征缩放”这种操作直接做出来。而 Task-Conditioned Feature Transformations 可以自由应用在 CNN、MLP、Transformer 任意特征层上应用范围更广。7. 实验设计与最佳实践7.1 适合验证该思路的数据集学术研究中常用来评估持续学习算法效果的数据集包括Split-CIFAR-10 / Split-CIFAR-100把类别分成多个任务。Split Tiny-ImageNet每轮 20 或 25 个类。DomainNet / Core50更偏域增量场景。如果只是自己快速验证思路可以先从较小的 Split-CIFAR-10 开始。它任务数量少训练成本低便于观察模型是否遗忘。7.2 工程设计层面的注意事项如果要在项目中落地这套思路我建议从以下几个角度做好设计。第一将“任务无关能力”和“任务相关能力”分离。骨干网络最好使用预训练模型并冻结或者用很小的学习率微调。任务向量负责任务差异Adapter 的共享能力负责跨任务迁移。第二明确新旧任务的更新边界。新任务加入时不应随意改动旧任务的分类头。必要情况下可以把旧分类头设置为不可训练只通过共享 Adapter 去兼容新旧任务。第三设计好实验对照组。不要只对比“从头训练”和“当前模型”。最好对比全量微调。每个任务独立 Adapter。每个任务独立 Prompt。本文所示的任务条件共享 Adapter。这样才能清楚看出“任务条件化”带来的增益到底来自共享参数量少还是来自任务间信息迁移。第四调参时重点关注条件生成器的大小和学习率。条件生成器如果太大相当于引入了一个小型网络反而增加优化难度学习率太大则会让不同任务的任务向量在训练末期剧烈漂移。第五使用余弦退火或更低的学习率来训练 Task Embedding。持续学习任务在后期往往进入“微调瓶颈”此时过大的学习率会导致旧任务信息被覆盖但过小的学习率又会拖慢新任务收敛。7.3 面向真实业务的改造思路如果业务场景是“用户分类模型每个月新增一个类别组”可以考虑预训练一个通用骨干网络。骨干网络后面接一个任务条件 Adapter。每个新增类别组只创建一个新的 Task Embedding 和一个对应的分类头。旧类别的分类头可以冻结让旧特征流过任务条件变换后再进入旧分类器。这种方式在工程上的最大收益是不需要长期保存全量历史训练数据也不需要重训整个模型甚至可以把 Task Embedding 当作一种轻量记忆单元存到数据库或配置中心。每次要支持新业务只需要更新少量参数。7.4 安全与生产环境注意事项如果这套思路用于生产环境还有两点必须强调任何删除或覆盖旧任务参数的操作都应该先做模型版本备份并准备回滚机制。在涉及用户隐私的增量学习场景中不要为了提升效果而违规留存用户原始数据。回放样本和旧任务特征都应该经过脱敏或去标识化处理。8. 总结与下一步学习建议这篇文章从持续学习中的灾难性遗忘出发拆解了“One Adapter, Many Tasks”背后的任务条件化特征变换思想。重点可以归纳为共享骨干网络提供任务无关的基础表征。一个轻量 Adapter 对特征做变换。不同任务共享 Adapter 参数任务差异由低维 Task Embedding 决定。条件生成器把任务描述转换成通道级别的缩放因子和偏移量。整个系统额外参数增长极少适合任务数较多、不能全量存储模型的场景。文章中的 PyTorch 示例只用于演示“任务条件生成 特征调制”的操作关系并不能直接替换论文实现。真要复现或改造还建议结合具体骨干网络的层结构、输入大小与任务的类别划分来推敲网络深度和参数维度。如果你正在做持续学习方向的科研下一步可以从三个方向深入阅读 Parameter-Efficient Fine-TuningPEFT的经典工作理解 Adapter 的插入位置与初始化方式。尝试把 Task Embedding 替换成可在线更新的原型向量探索更贴近 Class-IL 的设置。对比 Prompt、Adapter、Hypernetwork 三者在你业务数据集上的实际差距。这套思路最迷人的地方是把“记忆”变成了一种可以训练的变换。真正做好它需要你在任务表征、特征空间稳定性和参数共享之间反复权衡。建议动手先用一个小的数据集搭起训练框架然后把全量微调、独立 Adapter、共享任务条件 Adapter 三组实验跑出来。数据不会骗人对比结果会告诉你这个思路是否适合你的场景。