资讯动态

MAMA:动量自适应内存注意力机制,为Transformer打造长期记忆

发布时间:2026/10/24 2:54:09 来源:尧图企业网站定制
注意力机制现在几乎成了深度学习里最通用的特征交互算子做序列建模的人基本绕不开它。但如果你真拿它去处理那种长达几十万 token 的文档、多轮对话、或者持续输入的视频流很快会发现几个很现实的问题计算量随序列长度平方级上涨、历史信息只能靠输入序列本身承载、训练和推理时稍长一点的上下文就断掉了。这类场景下标准注意力机制更像一个“即时计算器”而不是一个“记忆系统”。最近我一直在折腾基于内存增强的注意力变体其中一个方向就是标题里这个 MAMAMomentum-Adaptive Memory Attention基于动量自适应内存的注意力机制。这篇文章我会把它的设计思路、数学形式、PyTorch 实现、以及我实际调试中踩过的坑完整写出来适合已经熟悉 Transformer 基础、想尝试给注意力加“外挂记忆”的读者参考。1. 标准注意力机制为什么需要“外挂内存”1.1 注意力是“即时计算”不是“记忆系统”先把大家熟悉的标准自注意力公式摆出来[ \text{Attention}(Q,K,V) \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V ]这个公式本质上是在做一件事用当前序列里的每个 query去当前序列的所有 key 上检索相关性然后按相关性加权对应的 value。整个过程是“即时计算”的它没有独立的存储模块。你输入 10 个 token它就在这 10 个 token 内部做加权你输入 100 万个 token它就在 100 万个 token 内部做加权计算量非常可观。可以打个比方标准注意力就像开一场只有当前会议室里的白板可用的头脑风暴每个人发言时只能引用此时白板上写的内容。想引用上一次会议的内容你得先把上次的会议记录全部贴在当前会议室里白白增加了占地。这就是为什么在长序列场景中大家普遍觉得注意力机制缺了一个“长期记忆仓库”。1.2 长序列与连续推理下暴露的三个痛点第一个痛点是计算复杂度。自注意力的时间与显存开销都是 (O(N^2)) 量级N 是序列长度。序列长度从 1024 翻到 2048计算量不是翻倍是翻四倍。很多实际业务场景的序列根本不可能无限拉长只能靠截断一截断就丢上下文。第二个痛点是远距离依赖在 softmax 归一化下天然吃亏。虽然理论上注意力可以建模任意距离的依赖但当序列里信息太多时softmax 会把权重分布变得非常尖锐远处的信息很容易被淹没在大量近处 token 的“竞争”中。你需要极深网络、很大的头数才能勉强把远端信息保留下来。第三个痛点是跨片段信息无法共享。训练或推理时通常把长文本切成多个固定长度片段前一个片段算完后面的片段完全看不到。做过文档级 NLP 任务的人肯定有这种体验明明前文说了“小明喜欢蓝色”后文遇到“他选了___颜色的帽子”模型就是答不对因为帽子片段里压根没有“蓝色”这个 token。1.3 给注意力加记忆大致有哪些路子为了解决上面这三个问题业界走过几条主流路线。一类是 Transformer-XL 这种片段级缓存方案把上一个片段的隐状态拼接回来当扩展上下文但这个缓存只是原始隐状态不会做压缩和选择性保留。另一类是 Compressive Transformer 这类方案把旧状态再用一层网络压缩后存起来。还有一类是外向检索式记忆模型先从外部知识库检索相关片段再拼入输入序列。MAMA 走的其实是中间路线不缓存整段原始隐状态而是维护一组压缩的内存槽位memory slots用动量式更新去平滑地写入新信息并在注意力计算的同时读取这份内存。它不像 RAG 那样依赖外部知识库也不像 Transformer-XL 那样无脑缓存内存是模型自身端到端学出来的。这套思路的定位很明确给注意力一个可读写、可长期更新、还能随输入动态调节更新速度的旁路记忆。2. MAMA 的整体设计思路拆解2.1 用“动量”更新内存而不是直接覆盖最早让我注意到动量思想的是优化器。大家都知道 SGD with Momentum 里当前更新方向会带着一点历史方向的信息这样梯度不会来回震荡训练会更平滑。EMA指数滑动平均也是常见做法像 BatchNorm 里统计量的滑动更新或者模型参数的 Polyak 平均本质都是动量式更新。MAMA 把同样的道理搬到了内存更新上。假设当前片段算出来的新信息是 (S_t)上一步的内存是 (M_{t-1})一个最朴素的内存更新方式可能是直接覆盖[ M_t S_t ]这样做法的问题是每个片段的输入都含噪声可能这个片段里恰好有一段异常信息你直接覆盖掉旧内存模型就“失忆”了反过来如果这个片段的有效信息其实是对旧内存的补充你直接丢弃旧内存也很可惜。动量式更新可以写成[ M_t (1 - \alpha) M_{t-1} \alpha S_t ]这里的 (\alpha) 是更新强度(\alpha) 越大新信息占比越大(\alpha) 越小历史信息越占主导。这个形式特别像经典控制理论里的一阶低通滤波也像数据流分析中的指数平滑。它不仅让内存更新变得平滑还给梯度提供了一条从当前输入到历史内存的稳定通路训练时模型更容易学会“哪些旧信息值得长期保留”。2.2 “自适应”到底自适应在哪里如果动量系数 (\alpha) 是固定值比如永远是 0.1那这个机制只能算“带平滑更新的内存注意力”还配不上“自适应”三个字。MAMA 的关键在于内存写入速度应当根据当前输入与历史内存的关系动态调整。想象一个典型场景你在读一篇技术文档前面几段都在讲某个模型的结构后文突然开始讲这个模型的实验数据。此时前后两段内容主题仍相关更新强度可以不大因为实验数据是对模型结构的补充。可如果文档突然从讲模型转为讲数据集构建主题变化很大模型就应该快速把新的主题信息写入内存适当淡化旧主题的影响。MAMA 的自适应机制就用来完成这件事通过一个轻量级网络对比当前片段信息与旧内存内容输出一个动态的 (\alpha)。差异大时 (\alpha) 变大内存快速吸收新内容差异小时 (\alpha) 变小内存保持稳定更像一个“按需写入”的动态记忆体。这个思路和很多 RNN 变体如 GRU 中的更新门很像但放在注意力框架里作用位置更灵活。2.3 内存在注意力计算中的位置MAMA 不是把内存替换掉注意力而是在标准注意力的旁边加一个“记忆旁路”。整体前向流程可以分成四步当前片段先走一遍标准多头注意力拿到当前上下文的表征 (A_t)。当前片段的 query 再对内存槽位做一次注意力读取得到记忆侧表征 (R_t)。把 (A_t) 和 (R_t) 做门控融合得到当前层的输出。把当前片段的有用信息压缩成槽位向量用自适应动量更新内存。这四步里标准注意力保留了对当前序列内部关系的精细建模能力内存注意力则负责从历史状态中检索相关信息。两者互补既不会让模型丢失近距离关系又能支持远距离、跨片段的记忆读取。我实际测试中感觉这个结构还有一个额外的好处标准注意力的权重矩阵可以专注学习当前上下文的交互记忆相关的投影矩阵则专注学习“从记忆中抽什么”职责分离之后训练反而更容易收敛不会出现一个模块既要做即时计算又要硬记长期依赖的情况。3. 数学形式与计算过程细节3.1 符号约定为了方便描述这里统一一下符号。假设输入的序列长度为 (L)每个位置的向量维度为 (d)当前片段输入记作 (H_t \in \mathbb{R}^{L \times d})。内存槽位数量为 (m)内存矩阵记作 (M \in \mathbb{R}^{m \times d})。在多头注意力设置下我会按“每层独立内存”来处理也就是每一层 Transformer 都有自己的内存矩阵而不是全局共享一份这样高层内存能存更抽象的信息低层内存存更具体的信息各司其职。3.2 自适应动量系数的计算内存更新强度不能凭空产生需要通过一个轻量级计算模块得到。我使用的方案是先把当前片段 (H_t) 做平均池化得到 (\bar h_t \in \mathbb{R}^d)再把内存矩阵 (M_{t-1}) 按槽位做平均池化得到 (\bar m_{t-1} \in \mathbb{R}^d)然后把两个向量拼接过一个小型 MLP再用 sigmoid 压缩到 0 到 1 的区间[ g_t \sigma \left( W_g^\top [\bar h_t; \bar m_{t-1}] b_g \right) ]为了让更新的幅度有下限加一个缩放[ \alpha_t \alpha_{\min} (\alpha_{\max} - \alpha_{\min}) \cdot g_t ]这里 (\sigma) 是 sigmoid(\alpha_{\min}) 和 (\alpha_{\max}) 是超参数分别表示最小和最大更新强度。比如设置 (\alpha_{\min}0.05)、(\alpha_{\max}0.5)那内存每次最多只能吸收一半的新信息避免单片段信息主导整个长期记忆。考虑更细粒度的话还可以让每个内存槽位拥有独立的更新强度。比如把 ([ \bar h_t; \bar m^{(i)}_{t-1} ]) 分别输入同一个 MLP得到每个槽位的更新系数 (\alpha^{(i)}_t)。这样内存的不同槽位可以分别存储不同主题的信息某个槽位如果已经存满了很重要的旧主题当新输入不相关时该槽位的新写入强度就很小旧信息不会被冲掉。3.3 内存读取从槽位里检索历史信息内存读取的方式跟标准注意力读取几乎一样。当前片段的 query 与内存槽位的 key 做内积得到匹配分数归一化成权重对内存槽位的 value 做加权求和[ R_t \text{softmax}\left( \frac{H_t W_Q (M_{t-1} W_K)^\top}{\sqrt{d}} \right) M_{t-1} W_V ]这里的 (W_Q, W_K, W_V) 是独立的投影矩阵不与会话内自注意力的投影共享。原因前面也说过了当前内容检索和记忆内容检索是两个不同任务共享参数会导致模式冲突尤其在训练早期。读取结果 (R_t) 和标准注意力结果 (A_t) 怎么融合我一开始是直接相加后来发现加一个可学习的门控更稳定[ O_t A_t \lambda_t \cdot R_t ]其中 (\lambda_t \sigma(w_\lambda^\top [\bar h_t; \bar r_t] b_\lambda))(\bar r_t) 是读取结果的全局池化。门控让模型学习当前时刻“该不该大量参考历史记忆”。有些场景比如对话开头上下文还没展开就不应该依赖记忆等到对话进入深水区门控自然会把记忆读取权重调大。3.4 内存写入槽位池化与动量更新当前片段的原始信息不能直接塞进内存因为序列长度 (L) 和内存槽位数量 (m) 通常不一致。我采用可学习的槽位向量做一次注意力池化把变长序列压成固定数量槽位[ S_t \text{softmax}\left( \frac{\text{Slot}_Q (H_t W_K)^\top}{\sqrt{d}} \right) H_t W_V ]这里 Slot_Q 是可学习的 (m \times d) 矩阵相当于 m 个“聚类中心”每个中心把序列里与其相关度最高的部分抽取出来。这样不管序列长度是 100 还是 1000内存写入的槽位数始终是 m 个维度统一写法统一。最后做动量式写入[ M_t (1 - \alpha_t) M_{t-1} \alpha_t S_t ]配合前面算出的自适应 (\alpha_t)这个式子就完成了“该快就快、该稳就稳”的动态更新。3.5 与标准 Transformer 的复杂度对比模块单片段计算复杂度说明标准自注意力(O(L^2 d))L 是当前片段长度MAMA 内存读取(O(L m d m^2 d))m 是槽位数量MAMA 内存写入(O(L m d))槽位池化Transformer-XL 缓存拼接(O((LC)^2 d))C 为缓存长度通常等于 L实际翻倍可以看到 MAMA 的额外开销是 (O(L m d))只要把 m 控制在一个小范围比如 32 或 64这笔开销远比把 L 翻倍带来的平方增长划算。我实测里一个 12 层模型开 64 个内存槽位显存开销大概增加 15%-20%但有效上下文范围可以拉长很多。4. 从公式到代码PyTorch 实现4.1 依赖与约定代码用 PyTorch 实现建议 2.0 以上自动用 torch.nn.functional.scaled_dot_product_attention 替代手写 softmax能白嫖 Flash Attention 加速。代码里约定输入是 batch size B、片段时间步 L、特征维度 d 的张量。多头逻辑为了看起来清晰先写成单头形式再在代码注释里说明怎么扩多头。4.2 一个极简 MAMA 模块下面是完整可运行的 PyTorch 代码包含内存读取、自适应动量计算、内存写入三部分。import torch import torch.nn as nn import torch.nn.functional as F class MAMAMemory(nn.Module): 基于动量自适应内存的注意力机制单头版 def __init__(self, d_model: int, n_slots: int 32, alpha_min: float 0.05, alpha_max: float 0.5): super().__init__() self.d_model d_model self.n_slots n_slots self.alpha_min alpha_min self.alpha_max alpha_max # 内存读取专用投影 self.read_q nn.Linear(d_model, d_model, biasFalse) self.read_k nn.Linear(d_model, d_model, biasFalse) self.read_v nn.Linear(d_model, d_model, biasFalse) # 槽位池化专用投影 self.pool_k nn.Linear(d_model, d_model, biasFalse) self.pool_v nn.Linear(d_model, d_model, biasFalse) self.slot_q nn.Parameter(torch.randn(n_slots, d_model)) # 自适应动量系数网络 self.gate_net nn.Sequential( nn.Linear(d_model * 2, d_model // 2), nn.ReLU(), nn.Linear(d_model // 2, 1), ) # 读取门控网络 self.read_gate nn.Sequential( nn.Linear(d_model * 2, d_model // 2), nn.ReLU(), nn.Linear(d_model // 2, 1), ) self._reset_memory() def _reset_memory(self): # 内存槽位初始化为很小的随机值避免初始阶段占比过大 init_val 0.02 self.register_buffer(memory, init_val * torch.randn(self.n_slots, self.d_model)) def _update_memory(self, x, alpha): 把当前片段的输入 x 压缩成槽位向量再按动量更新 # x: [B, L, d] B, L, d x.shape k self.pool_k(x) # [B, L, d] v self.pool_v(x) # [B, L, d] q self.slot_q.unsqueeze(0) # [1, m, d] attn torch.matmul(q, k.transpose(-1, -2)) / (d ** 0.5) # [B, m, L] attn F.softmax(attn, dim-1) slot_value torch.matmul(attn, v) # [B, m, d] # alpha: [B, 1] - [B, 1, 1] alpha alpha.unsqueeze(-1) new_memory (1 - alpha) * self.memory.unsqueeze(0) alpha * slot_value return new_memory def _compute_alpha(self, x, memory): 基于当前输入与旧内存的关系计算自适应动量系数 B, L, d x.shape x_pool x.mean(dim1) # [B, d] m_pool memory.mean(dim1) # [B, d] gate_in torch.cat([x_pool, m_pool], dim-1) g torch.sigmoid(self.gate_net(gate_in)) # [B, 1] alpha self.alpha_min (self.alpha_max - self.alpha_min) * g return alpha def forward(self, x): x: [B, L, d] 当前片段输入 返回: [B, L, d] 读取到的记忆信息同时内部更新内存 B, L, d x.shape # 1. 计算自适应动量系数 alpha self._compute_alpha(x, self.memory.unsqueeze(0)) # [B, 1] # 2. 从旧内存读取信息 q self.read_q(x) # [B, L, d] k self.read_k(self.memory.unsqueeze(0).expand(B, -1, -1)) # [B, m, d] v self.read_v(self.memory.unsqueeze(0).expand(B, -1, -1)) # [B, m, d] attn torch.matmul(q, k.transpose(-1, -2)) / (d ** 0.5) # [B, L, m] attn F.softmax(attn, dim-1) memory_out torch.matmul(attn, v) # [B, L, d] # 3. 计算读取门控 mem_pool memory_out.mean(dim1) # [B, d] gate_in torch.cat([x_pool_broadcast(mem_pool, x), mem_pool], dim-1) # 简化写法这里直接用 x 的均值替代 x_pool x.mean(dim1) gate_in torch.cat([x_pool, mem_pool], dim-1) lam torch.sigmoid(self.read_gate(gate_in)) # [B, 1] memory_out lam.unsqueeze(-1) * memory_out # 门控融合 # 4. 更新内存 self.memory self._update_memory(x, alpha).mean(dim0) return memory_out代码里有两点需要特别说明。第一self.memory注册为 buffer 而不是 Parameter因为它不属于可学习参数是模型维护的状态但 forward 里每次都会重新赋值所以推理时需要注意重置或按需保存。第二这里为了演示把 memory 更新放进了 forward意味着同一个 batch 内不同样本会各自独立地修改同一份 buffer多 batch 并行时会互相污染。实际工程里要么把 batch 内的内存聚合更新要么在训练循环中显式控制每个样本的 memory 状态下面的调用示例会展示一种处理方式。4.3 把 MAMA 挂在 Transformer Layer 里的方式MAMA 模块的输出是“记忆侧表征”它要作为旁路和标准注意力输出融合。常见的挂载位置是先做标准注意力然后加上 MAMA 的读取结果再过一个 LayerNorm最后进入 FFN。class TransformerLayerWithMemory(nn.Module): def __init__(self, d_model: int, n_heads: int, n_slots: int 32): super().__init__() self.self_attn nn.MultiheadAttention(d_model, n_heads, dropout0.1, batch_firstTrue) self.mama MAMAMemory(d_model, n_slotsn_slots) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.ffn nn.Sequential( nn.Linear(d_model, d_model * 4), nn.GELU(), nn.Linear(d_model * 4, d_model), ) self.dropout nn.Dropout(0.1) def forward(self, x): # x: [B, L, d] attn_out, _ self.self_attn(x, x, x) mem_out self.mama(x) # 双路融合 fused self.norm1(self.dropout(attn_out mem_out) x) ffn_out self.ffn(fused) out self.norm2(self.dropout(ffn_out) fused) return out这个挂载方式里MAMA 的内存读取发生在标准注意力之后原因是此时当前片段已经完成了内部信息交换再拿 query 去读内存query 里已经带有上下文信息检索精准度会更高。如果你想让 MAMA 参与当前片段的初始编码那就要把它的读取放在标准注意力前面形成并行分支。两种方式我都试过实践下来“先标准注意力再读内存”更稳内存读取的噪声更小。5. 训练中的超参与稳定性问题5.1 核心超参数速查表超参数建议范围影响内存槽位数量 n_slots16~128越大记忆容量越大但增大读取计算量alpha_min0.01~0.1控制内存最低更新幅度太小会导致新信息吸收过慢alpha_max0.3~0.8控制内存最高更新幅度太大会导致记忆震荡读取门控初始化偏移初始化 b_λ 为负值训练早期让内存读取权重较小先学当前上下文内存初始化标准差0.01~0.05过大会干扰刚开始的注意力计算5.2 数值稳定性问题内存矩阵是和输入特征同一维度空间的状态量但它不经过常规归一化直接参与注意力计算数值分布稍微出问题就会影响整个层。第一个经验是内存初始值一定要小。如果初始化太大刚开始训练时 query 和内存槽位的点积会特别大softmax 直接饱和成 one-hot内存读取就退化成了硬选择梯度很小学不动。第二个经验是读取投影的缩放要跟标准注意力保持一致点积除以 (\sqrt{d}) 是必须的。有些实现为了方便直接省略缩放词向量维度一大attention 的 logits 方差变大softmax 输出分布变尖内存能记住的信息就非常有限。第三个经验是混合精度训练时的数值问题。内存更新是一个时间维度的递归结构float16 下长期累积的内存值容易下溢或出现精度截断。我实际跑的时候要么把内存相关的更新放在 float32 下做要么定期把 memory 做一次 normalization。混合精度经常报 loss spike顺手检查下 memory 的数值范围经常一查一个准。5.3 冷启动问题内存初始化时是随机小值意味着模型前几个 batch 读到的完全是噪声。如果此时读取门控也是接近 1 的初始值模型会拿噪声特征当有效记忆训练前期容易被带偏。我的解决思路是给读取门控的 bias 设置一个负的初始值让 sigmoid 输出接近 0模型一开始尽量依赖当前上下文等训练进行一段时间门控自己会学习把记忆读取调大。代码里可以给 read_gate 最后一层 bias 初始化成 -2 或 -3with torch.no_grad(): self.read_gate[2].bias.fill_(-2.0)5.4 学习率与梯度裁剪MAMA 的额外参数包括 read_q/read_k/read_v、pool_k/pool_v、slot_q、gate_net、read_gate参数总量并不大但内存读取这条路在反向传播时梯度会流经旧内存的路径时间越长梯度回传路径越深容易导致梯度爆炸。我实践中给整个模型加梯度裁剪是常规操作clip_grad_norm 设置在 1.0 左右比较合适。如果发现加了 MAMA 之后训练比原来更不稳优先怀疑这里。6. 常见问题与排查思路在实际跑 MAMA 相关实验的过程中我积累了一张问题排查表。如果你遇到类似的现象建议按这个顺序对照排查。现象可能原因排查与解决方法内存读取门控迅速变为 0模型完全不依赖记忆门控初始偏置太小且 gate_net 训练不充分调整门控初始化把 bias 从 -2 提到 -0.5或者先手动固定门控值为 0.5 训练几步再放开内存更新过快loss 剧烈震荡alpha_max 设置过大内存被单片段信息主导把 alpha_max 调低到 0.3 以下观察 loss 曲线是否平滑长序列评测效果没有提升内存槽数过少信息容量不够先检查内存读取的门控是否打开再尝试增大槽位数量到 128训练和推理行为不一致训练时 buffer 状态在 batch 更新中互相污染在训练循环中为每个样本独立维护 memory或用全局 memory 但确保 batch 内部状态一致显存开销超出预期槽位数量设置过大或读取的 key/value 投影未共享检查 n_slots 与 hidden size 的乘积是否过大适当降槽位梯度回传受限训练缓慢内存内容在数值上接近 0梯度无法流过检查 memory buffer 的数值范围必要时初始化加一个小的正均值偏移还有一个实操层面的经验MAMA 这类带内存的模块在数据加载阶段就要考虑样本顺序。如果你的训练集是随机打乱的内存没有连续的“故事感”那么动量更新的意义会被削弱。让每个 batch 内样本按时间有序排列或者用按文档顺序切分的长片段序列来做训练效果比完全随机打乱好不少。另外内存的跨 batch 一致性也是个常见的隐蔽问题。我一开始在图里直接实现了可学习的 memory buffer结果多 batch 并行时发现每个 batch 都在改全局 buffer设置乱套。后面改成对每个 batch 计算独立的候选内存并做梯度平均或者干脆在显存允许范围内增大 batch size尽量用整篇文档作为单 batch 输入才能稳定推进训练。7. 一点个人体会和后续可玩的方向调试 MAMA 的过程中我最大的感受是给注意力加内存并不难难的是让内存更新频率和模型任务节奏对齐。固定更新强度虽然简单但在一个长文档里模型既需要长时间维持某个主题的稳定记忆又需要在主题切换时快速重写内存这种“又稳又快”的需求只有动态动量系数能兼顾。我后来还试过把自适应系数从单一标量扩展成 per-slot 向量让每个槽位自己决定要不要被新输入覆盖效果在主题分布比较均匀的长文档上进一步提升了几个点代价是多了一组参数运行速度稍微慢一点。另一个值得尝试的扩展方向是把 MAMA 的内存读取结果喂给下游任务的分类层或者生成头的 cross-attention而不仅仅停留在自注意力层的旁路融合。相当于让解码器可以直接从长期记忆里获取信息而不必绕道每一层的隐藏状态。我自己在一项图像描述生成的实验里试过这个思路生成的句子在长文本段落覆盖度上有明显改善。最后说一个我踩了几次坑之后养成的习惯每次改完内存更新逻辑先跑一个短序列的 overfit 小实验用一个 batch 样本反复训练观察内存数值变化是否跟预期一致。很多时候你以为在“平滑更新”实际内存已经悄悄被某个异常 token 带飞了只有盯住内存数值分布才能及时发现。MAMA 本质上是一个温和的递归结构状态管理的细节远比网络结构本身更考验工程经验希望这篇直接把底层逻辑和代码都铺开的笔记能帮你少走几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑