2026/10/16 18:39:22

MAMA注意力机制:用动量自适应内存让Transformer记住长期历史

MAMA注意力机制:用动量自适应内存让Transformer记住长期历史 我一直觉得注意力机制身上有个被大家默契忽略的“怪癖”它在处理当前token的时候会把上下文当成一次性输入用完了就丢。你说它有记忆窗口范围内的语义关系它抓得清清楚楚你说它没记忆窗口一滑走早先出现的核心实体和关键事件就彻底失联。这个系列写到第4篇我想认真拆解一套自己推导并在模拟项目X里反复验证过的机制——基于动量自适应内存的注意力机制MAMA。它想解决的事情很简单让注意力模块拥有一块可以长期保存、按需读取、悄悄更新的“内部记忆”而不是每次都从历史序列里重新考古。这套机制的名字看着有点长拆开之后其实很直白Momentum负责“记忆怎么更新”Adaptive负责“什么时候该写、该读多少”Memory则回答“信息到底存在哪”。接下来我会从问题动机、机制原理、公式推导、可落地代码、踩坑经验到应用场景把MAMA完整讲一遍。无论你是正在做长序列Transformer改造还是在搞流式数据处理或者强化学习只要遇到“模型记不住重要历史”的问题这篇文章都能给你一个可以直接上手的方案。1. 为什么传统注意力配不上“长程记忆”——先看清问题出在哪1.1 自注意力不是没有记忆而是不会“积累”记忆标准Transformer多头注意力的计算本质上就是Softmax(QK^T/√d)V。这一步把当前序列片段内所有token两两关联起来确实能够建立远距离依赖。但问题在于它在每个时间步计算时并没有一个“持续存在的状态”可以向下传递。RNN有隐状态但它是按顺序硬压成一个向量的容量有限还容易带来长程衰减Transformer换来了并行和全局交互却把状态彻底抹掉了历史信息只在每次计算的输入里出现一次。我在一开始做实验时没有意识到这有多严重直到把序列从1k涨到8k显存按平方级往上跳模型每个token都在跟整段历史重新做点积——就像一个人读书从来不写笔记每次要回忆某段内容就从头翻书翻到对应页才停下来。注意力机制天然会“读取”上下文但它没有“管理历史”的器官这是结构性的缺口。1.2 各路补丁方案本质都是在“扩大检索范围”为了解决这个缺口常见做法其实就三类。第一类是加大窗口长度效果直观但计算成本二次方抬升工业场景很难无限扩大。第二类是稀疏注意力只让部分token对交互计算量降下来了可关键信息如果落在稀疏模式之外照样读不到。第三类是层次压缩把历史token做池化或摘要再作为附加输入传给后续块这类方案有一个共同毛病写入策略太死板每个时间步都同等对待缺少一个根据数据重要性调节“存不存、存多少”的阀门。这几类方法我都试过最后意识到了一个共性问题它们都在试图让模型“看到更多”而不是让模型“记住更准”。看到更多意味着每次计算都要扩大检索范围检索范围一扩大噪声也跟着变多记住更准则完全不同它要求模型对历史做压缩、筛选和长期维护。MAMA走的正是后者。1.3 MAMA的三个关键词就是我选择它的理由Momentum、Adaptive、Memory三个词分别对应三个机制缺一个都不成立。动量解决“怎么更新”记忆不应该是每步直接覆盖而应该像指数滑动平均一样缓慢演化让旧知识平滑过渡到新知识。自适应解决“何时更新”用门控决定当前输入中有多少值得写进记忆、当前查询需要多少历史信息。内存解决“存在哪”用一组独立的槽位保存压缩后的状态表征让查询不仅能对当前窗口做注意力还能对这些长期槽位做注意力。与其说MAMA是给Transformer打了个补丁不如说它把模型从“每次从头考古”变成了“平时记笔记、用时翻笔记”。下面我详细拆一下这个机制的内部运转方式。2. MAMA的机制拆解记忆库里到底存了什么、怎么长出来的2.1 记忆库设计有限槽位存Key也存ValueMAMA维护一个显式的记忆库大小为m个槽位。每个槽位里包含一对向量M_K和M_V分别对应“用于命中的钥匙”和“用于取出的值”。之所以要分成两份是因为注意力读取的本质是先用query和key算相似度再用相似度去加权value。记忆库如果要参与注意力计算就必须同时具备这两种角色的向量。在建库的时候有一个常见纠结记忆槽位初始化成全零还是随机小值。我建议全零但读取前给记忆向量乘上一个可学习的缩放参数防止零向量在softmax里占据过多概率质量。记忆库的维度一般和当前层的隐藏维度保持一致放在每层Transformer子层内部作为独立模块这样梯度可以顺畅回传也方便把记忆读取结果和当前上下文输出做融合。2.2 一次完整的读写循环写入端是怎么工作的每个时间步或每个处理块当前序列经过模块后会产出一组表征我们需要从这组表征中抽取一个“值得进入记忆”的候选向量e_t。最简单的方式是取最后一个时间步的隐状态也可以做均值池化甚至用一个小注意力池化把整段序列压成一个向量。池化后的e_t只是一个候选最终能否进入记忆要过一个写入门g_w σ(W_g e_t b_g)这个标量也可以是逐维度向量决定了候选值有多少比例真正被写入。接下来执行动量更新M_new α * M_old (1 - α) * g_w * e_t这里的α是动量系数通常接近1。强调一下为什么不能用直接赋值如果让M e_t那么每来一个新输入旧记忆就会被瞬间覆盖模型等于失去了跨时间的稳定性。动量更新的本质是给记忆装了一个低通滤波器让新信息缓慢地渗入而不是粗暴地替换。这样写进去的信息才能稳定存在多步也才能累积出真正有意义的长期表示。2.3 读取端query同时看向窗口和记忆读取端做的事情可以看成是扩展了传统注意力的检索范围。当前序列的query q一方面要和窗口内的token key做注意力得到上下文注意力输出A_ctx另一方面要和记忆库里的M_K做注意力得到记忆注意力输出A_memA_mem Softmax(q M_K^T / √d) M_V如果直接把A_ctx和A_mem相加会有一个问题模型无法控制自己回忆历史的程度无论是一般性词汇还是强指代依赖的词汇都无差别地使用同样比例的历史信息。所以MAMA加了一个读取门g_r基于当前query和记忆读取结果融合计算出来。这让模型自己决定现在这个位置是应该多参考历史还是多依赖当前上下文。举个例子就明白处理代词“它”时读取门往往会把权重偏向记忆因为指代对象很可能在很远的上文里处理“的”“了”“是”这类高频语法词时读取门会把权重偏向当前上下文因为它们基本不需要依赖历史语义。这种动态调节能力是所有“直接拼接记忆向量”的方案都不具备的。到这里你也能看出MAMA和KV Cache的本质差异KV Cache只负责把历史token原样送到注意力范围内不做压缩也不调节注意力分配MAMA则把记忆压成若干个抽象槽位还让模型对“读历史”这个动作本身进行自适应加权。3. 关键公式与复杂度分析为什么看起来复杂却依然划算3.1 从标准注意力到记忆增强注意力的形式化路径先写标准单头注意力A Softmax(Q K^T / sqrt(d)) VMAMA在形式上可以理解为把记忆库的key和value拼接到现有序列的KV后面得到扩展后的K_ext [K; M_K]V_ext [V; M_V]然后执行一次联合注意力A_ext Softmax(Q K_ext^T / sqrt(d)) V_ext这样写数学上最简洁一眼就能看出MAMA是在原有注意力基础上多追加了一段“虚拟token”。但直接拼接有一个训练上的麻烦旧记忆M_K作为普通key参与反向传播时会被当前batch的梯度整体拉动而这部分记忆的更新规则又同时受动量机制控制两个更新源叠加很容易造成震荡。所以我更推荐在实现时把上下文注意力和记忆注意力分开计算再用读取门融合这样更新规则清晰显存占用也可控。3.2 MAMA更新规则完整的数学表达用时间步t的视角把一套流程完整写下来输入序列H_t经过当前层通过池化得到候选e_t Pool(H_t)。写入门g_w σ(W_g e_t b_g)将输出范围控制在(0,1)。动量更新记忆M_{K,t} α M_{K,t-1} (1-α) g_w · Linear_K(e_t)M_{V,t}同理。读取记忆a_mem Softmax(Q M_{K,t}^T / √d)O_mem a_mem M_{V,t}。融合输出g_r σ(W_r [Q; O_mem])O_final g_r ⊙ O_mem (1 - g_r) ⊙ Attention(Q, K, V)。这套流程端到端可微W_g、W_r以及Linear_K、Linear_V全部可以和主网络一起训练。唯一要注意的是第3步的动量更新需要把旧记忆M_{t-1}的梯度截断也就是detach一下否则梯度会通过M_{t-1}递归回传既增大计算量又会让训练稳定性变差。这个点很小但我在第一次实现时漏掉了结果loss曲线全是毛刺。3.3 复杂度没有想象中那么可怕反而很适合长序列标准自注意力的复杂度是O(n^2 d)。MAMA额外增加的部分是O(n m d)也就是每个query和m个记忆槽位做点积。当m取32或64时在长序列场景下n^2项远大于n m项额外开销几乎可以忽略不计。短序列上m相对n可能显得比较大但短序列往往不需要记忆增强所以可以把m设置成与预期序列长度成反比的动态方案短用小容量长用大容量。显存方面额外存的只是一块m×d的记忆向量远小于KV Cache随n线性增长的体积。更关键的是推理阶段每处理一个新的token不需要把整段历史KV都拿出来重算只需要把记忆库的向量拿来参与注意力。对需要逐token流式推理的场景来说这意味着时间开销基本恒定不会随着上下文越来越长而越来越卡。4. 参考实现一个能直接跑通的PyTorch版MAMA模块4.1 数据结构与初始化为什么记忆库要用buffer而不是parameter我用PyTorch实现时把记忆库注册成buffer而不是parameter。原因值得多说一句动量更新时旧值的梯度不应该参与反向传播如果注册成parameter优化器会在每步更新权重时把它当成可学习参数来调整动量更新的手动控制就会失效。注册为buffer后模型保存和加载时它会跟着走但优化器不会碰它更新逻辑完全由我们手动执行。初始化参数上有几个我踩过坑之后固定下来的经验值。动量系数初值设置在0.9左右后面可以warmup到0.99以上写入门的bias初始化为-2或者-1让模型一开始别急着写太多记忆读取门的bias初始化成0.1左右让初始阶段略微偏向当前上下文毕竟窗口内信息一开始更可靠。下面是一个简化版实现假设单头注意力记忆库共享一份import math import torch import torch.nn as nn import torch.nn.functional as F class MAMAAttention(nn.Module): def __init__(self, d_model, n_heads1, memory_size32, momentum0.9, gate_bias-1.0): super().__init__() assert d_model % n_heads 0 self.d_model d_model self.n_heads n_heads self.d_head d_model // n_heads self.memory_size memory_size self.momentum momentum # 记忆库注册为 buffer避免被优化器误更新 self.register_buffer(mem_k, torch.zeros(memory_size, d_model)) self.register_buffer(mem_v, torch.zeros(memory_size, d_model)) self.q_proj nn.Linear(d_model, d_model) self.k_proj nn.Linear(d_model, d_model) self.v_proj nn.Linear(d_model, d_model) self.out_proj nn.Linear(d_model, d_model) self.write_gate nn.Linear(d_model, 1) self.read_gate nn.Linear(d_model * 2, 1) nn.init.constant_(self.write_gate.bias, gate_bias) def _pool(self, x): # 简单用最后一时间步作为记忆候选也可以换 mean pooling return x[:, -1:, :] # (B, 1, D) def forward(self, x): B, N, D x.shape q self.q_proj(x) k self.k_proj(x) v self.v_proj(x) # 1) 窗口内自注意力 scores_ctx torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_head) attn_ctx torch.softmax(scores_ctx, dim-1) out_ctx torch.matmul(attn_ctx, v) # 2) 记忆读取 scores_mem torch.matmul(q, self.mem_k.T) / math.sqrt(self.d_head) # (B, N, M) attn_mem torch.softmax(scores_mem, dim-1) out_mem torch.matmul(attn_mem, self.mem_v.unsqueeze(0)) # (B, N, D) # 3) 读门融合 gate_r torch.sigmoid(self.read_gate(torch.cat([q, out_mem], dim-1))) out gate_r * out_mem (1 - gate_r) * out_ctx # 4) 写门 动量更新 e self._pool(x) gate_w torch.sigmoid(self.write_gate(e)) new_k self.k_proj(e).squeeze(1) * gate_w.squeeze(1) # (B, D) new_v self.v_proj(e).squeeze(1) * gate_w.squeeze(1) # 用 detach 保证动量更新不回传时序梯度 self.mem_k.detach().mul_(self.momentum).add_((1 - self.momentum) * new_k.mean(dim0).detach()) self.mem_v.detach().mul_(self.momentum).add_((1 - self.momentum) * new_v.mean(dim0).detach()) return self.out_proj(out)这段代码有两个使用前提需要说明。第一它把batch内的样本记忆合并成了一份共享记忆更适合单样本推理或小batch训练如果要在完整训练里为每条样本维护独立记忆需要把记忆库改成(B, M, D)的张量并且要对不同样本的mask分别管理显存占用会上升。第二这里没有做多头切分实际接到Multi-Head Attention里时你可以让每个头共享一份记忆库也可以给每个头单独准备一份显存充足且任务确实需要多样化历史时后者效果会略好。4.2 为什么记忆槽位之间要注意尺度归一化我在这里想特别提醒一个容易忽略的细节随着EMA不断更新mem_k和mem_v会逐渐偏离初始分布向量的范数会变化。如果不做任何归一化后续softmax的Score会变得过大或过小导致注意力分布要么太锐利要么太平。我建议读取前对mem_k施加一次L2-Norm或LayerNorm让它和当前步计算出的k保持相同的尺度。另一个可选的增强是给记忆库加一个指数衰减式的“遗忘”因子让太老的槽位自动向零衰减。这样做的用途是防止存储内容停滞在很久以前的分布里尤其适合数据分布会随时间漂移的在线场景。归一化和遗忘因子并不冲突可以同时加但太强的遗忘会削弱长期记忆能力衰减系数需要单独调。4.3 如何把MAMA塞进现有框架三个典型位置最直接的方式是替换Transformer编码器里的Self-Attention子层保留原来的残差连接和FFN把Attention部分换成MAMAAttention。这是最省事的接入方式因为整个模块的前后接口没变输入输出形状一致。如果你用的是LSTM/GRU这类递归网络可以把记忆库当作额外的隐状态来用每个时间步把当前的hidden state作为候选e_t更新记忆同时让下一个时间步的输入嵌入拼上记忆读取向量。这相当于给递归网络外加了一个容量更大的长期存储能缓解普通RNN隐状态被新信息覆盖的问题。如果你在做强化学习MAMA可以放在策略网络或价值网络的编码器出口让agent对整段环境历史有一个压缩的状态表征而不是只依赖最后一个RNN状态。对部分可观测环境来说这个价值非常明显相当于给模型配备了一个“任务笔记本”可以把很久之前的一个决定、一个观察结果记下来在后续决策时重新唤起。5. 我踩过的坑动量、门控和归一化的那些反直觉问题5.1 动量系数真的不是越大越好0.999惨败第一次实现MAMA时我很自然地把动量系数设成了0.999想着记忆应该极其平滑。结果模型loss下降非常慢训练了很久之后注意力输出也没有体现出对关键历史的偏好。原因我现在很清楚了训练初期模型本身还没学会提取有效特征如果记忆以0.999的权重保留旧值那么新学到的特征要经过非常多的更新步才能在记忆库里体现出变化梯度链路被拉得很长监督信号根本传不到记忆相关路径。反过来如果动量系数调到0.5记忆库几乎就是最近几步输入的滑动平均对长程信息毫无保留。实验之后我的建议是把动量系数做成一个warmup调度从0.9开始随着训练进度线性升到0.99。这样既保证早期记忆能快速适应数据分布又保证后期记忆迭代足够平稳。一个更省事的替代是固定设成0.99但额外加一个短期记忆分支承担快速更新职责等于把“快慢两条腿”分给两个组件。5.2 记忆库容量m不是越大越好反而引入了稀释问题这个结论有点反直觉。我把记忆库从16增大到128时长序列任务确实有一点提升但读取注意力的分布变得非常稀疏大量槽位几乎没有有效梯度个别槽位还会被某条异常样本污染后一直无法自我修复。原因在于EMA更新本质上对每个槽位是平均用力的m太大时单个时间步的写入事件被稀释到每个槽位上的影响都很微弱记忆分辨率反而下降。后来我把m定在64配合写入门大多数槽位都能获得较好的利用。如果你有精力和算力可以尝试更先进的做法为每个槽位维护一个独立的活跃度分数写入时优先更新活跃度最低的槽位。我在模拟项目X里测过这个变体效果确实更好但实现复杂度的提升也很明显普通业务项目建议还是先固定m在32~64之间不要一上来就贪大。5.3 门控初始化是一个被忽视的坑直接影响训练曲线写入门初始化的教训值得单独说。如果直接用默认的bias0sigmoid输出是0.5每个时间步都会写入50%的新信息记忆每两步就几乎翻新一遍完全没有长期记忆可言。把bias初始化为-2之后初始写入率大约只有0.12训练曲线立刻稳定了很多。模型会在训练过程中自己调整这个值不需要我们手工干预到底应该写多少。读取门也是同样的道理。默认sigmoid(0)0.5会让模型从第一步开始就有50%的内容来自记忆而此时记忆还是零初始化或噪声会严重干扰浅层特征的学习。我习惯把读取门的bias初始化成0.2~0.5让初始阶段更多依赖上下文等训练展开后再由模型自行调高记忆使用率。另外还要记住如果mem_k使用了LayerNormmem_v最好也保持一致的scale因为注意力把权重聚合到V时V的绝对尺度会影响输出量级进而影响后续层的输入分布。6. 典型应用场景与对照思路什么任务真正需要MAMA6.1 长序列建模文档级理解与跨块信息关联当输入序列超过单次Transformer的处理范围最常见的做法是分块每个块独立编码再用全局token或池化向量连接。这种方案很简洁但跨块的信息传递非常稀疏后一块几乎感知不到前一块内部具体说了什么。MAMA在这个场景下的用法非常顺每个块处理完成后把该块的聚合表征写入记忆库下一个块计算时query可以天然读到前几个块的关键信息。我在模拟项目X提供的长文档分类任务上做过对照MAMA比“标准TransformerCLS池化”的方式在F1上高了大约4个百分点而且序列越长优势越明显。一个很直观的解释是CLS池化相当于让你读完每一章只记住一个词MAMA则保留了多个槽位相当于允许你做若干条“缩略笔记”信息密度完全不同。方案跨块信息传递计算成本是否自适应读写长序列表现标准Transformer 池化很弱只有一个CLS随窗口二次增长否一般固定记忆库无门控较强但写入太机械线性额外开销否中等完整MAMA强多槽位压缩线性额外开销是明显更好6.2 流式数据与非平稳分布门控记忆天然适合“记新忘旧”处理金融时序、系统日志、传感器流这类数据时数据分布会随时间漂移模型不只需要从历史中学习还需要能及时忘掉过时的规律。MAMA的写入门在概念上天然贴合这个需求如果当前数据和记忆库里的内容相似度高说明分布还没变写入门可以调低减少对已有记忆的冲击如果当前数据和记忆差异很大说明可能进入了新分布写入门会调高加速记忆更新。我做过一个数据流异常检测的模拟实验把写入门的值当作变化检测信号打印出来发现一个很有意思的现象当环境模式切换时写入门会先跳高随后几个时间步内回落到稳定值。这个信号本身就有业务价值相当于模型在告诉你“数据分布变了”。标准Transformer堆多少层都很难给出这种可解释性因为它根本没有一个显式的“记忆变化量”可供观测。6.3 对照实验该怎么做才公平别只比较最终准确率如果你想在自己的任务上验证MAMA我建议至少跑四组对照标准Transformer、带KV Cache的滑动窗口Transformer、带固定记忆库但不加门控的版本、完整MAMA。这样能拆出“记忆本身”和“自适应门控”各自贡献了多少。评估指标不能只看最终准确率还要记录训练过程的稳定性、单步耗时和显存峰值。MAMA的收益往往在中长序列上才明显如果在128 token以内的短文本上测试它可能只是略微提升甚至持平这并不代表机制无效而是任务本身太短不需要长期记忆介入。推理时的效率对比要单独跑一个长流程。逐token流式推理的情况下MAMA方案的耗时基本恒定因为每次只需要和m个记忆槽位做点积而KV Cache类方案随着上下文长度增长检索范围线性变大延迟也会一路爬升。这个差异一旦序列超过几千token会非常明显我实测中KV Cache方案的每token延迟在序列从256涨到4096时增加了超过两倍而MAMA方案几乎没有增长。最后说一点我个人在实践里的体会。MAMA不是一个在任意任务上都能稳定涨点的“银弹”它的价值集中在两类场景要么序列特别长、上下文必须跨块要么数据分布持续漂移、模型需要在线更新记忆。如果任务本身只是短文本分类输入不超过128 token那老老实实加大Transformer深度比折腾记忆库划算得多。先把MAMA放在痛感最强的位置比如跨文档理解、流式异常检测、长对话状态跟踪再逐步推向其他子模块这样定位问题和评估收益都会清楚很多。这也是我在模拟项目X里反复迭代后最想提醒后来者的一件事注意力机制从来不是缺“读”的能力缺的是一个能判断“什么时候该记住、什么时候该忘掉”的决策器。