
1. 这个问题为什么值得单独拿出来讲很多同学第一次听到“滑动窗口注意力”sliding window attention时会觉得它是个训练期概念让每个 token 只关注附近 W 个 token从而把自注意力的 O(T²) 计算量降到 O(T·W)。这个说法在预填充prefill阶段确实没错但一旦进入自回归 decode 阶段很多人会卡在一个看似简单的问题上“窗口不是已经限制住了吗那 K/V 缓存不也只要存 W 个吗为什么还需要环形缓存用普通数组不行吗”问题的答案就在“窗口滑动”这四个字里。窗口限制了注意力计算的范围但没有限制缓存数据的存放方式。如果在 decode 时用一个朴素数组保存最近 W 个 key/value那么每当新 token 进入都要把数组整体左移一位、丢掉最旧的那个。这一步在 CPU 上就是 memcpy在 GPU 上则是一笔不小的显存带宽开销。序列越长、batch 越大这个“隐性搬运”就越明显最终成为 decode 吞吐的瓶颈之一。环形缓存ring buffer / circular buffer解决了这个问题的核心矛盾窗口在“逻辑上”不断向前滑动但 K/V 在“物理上”不需要搬家。它通过固定的存储空间加取模索引让窗口滑动变成一次 O(1) 的索引更新。这篇文章会从原理、位置编码、mask、工程实现和最小示例几个角度把这个问题彻底讲透。先给一个总判断滑动窗口注意力真正难的不是注意力计算而是如何用有限显存高效维护“最近 W 个 token 的上下文”。环形缓存是解决这个问题的标准思路之一也是你在学习 vLLM、Mistral、Llama 长上下文方案时绕不开的基础概念。2. decode 阶段到底发生了什么2.1 自回归生成的基本流程LLM 生成文本时每一轮只产生一个 token然后把这个 token 拼到输入后面再继续预测下一个 token。这个过程叫自回归autoregressive解码。在第 t 步模型需要计算的是当前 token 的 queryQ_t历史所有 token 的 key 和 valueK_0..t-1、V_0..t-1注意力输出 Softmax(Q_t · K^T / √d) · V如果每步都重新计算历史 token 的 K/V那总开销是 O(T³)完全不可接受。所以工程上会把历史 token 的 K/V 缓存下来这就是 KV Cache键值缓存。有了 KV Cache每一 decode 步只需要计算当前 token 的 K/V把新的 K/V 追加到缓存用当前 Q 和缓存中所有 K/V 做一次注意力。遍历 token 数从 1 到 T每步耗时近似相等总显存占用是 O(T·d)这就是标准 KV Cache 的形态。2.2 朴素 KV Cache 的显存压力标准 KV Cache 有一个硬伤随着序列变长缓存线性增长。比如一个 70B 模型40 层、80 个注意力头、每头维度 128以 FP16 存储每个 token 每层需要保存 2K 和 V× 80 × 128 × 2 字节 ≈ 40KB。生成长度 32K 的序列单条请求光 KV Cache 就可能超过 1GB。如果同时服务几十个请求显存直接被打满。这就是为什么“长上下文”不能简单地靠扩大窗口解决。滑动窗口注意力把每个 token 的注意力范围限制在最近 W 个 token这让 KV Cache 的理论上限从 O(T) 降为 O(W)。但问题是显存上限降下来了存储方式却没有自动变简单。3. 滑动窗口注意力的核心原理3.1 从全量注意力到窗口注意力标准注意力中第 i 个 token 的注意力权重是对所有位置 j ≤ i 计算的。窗口注意力把它限制为只对 j ∈ [i - W 1, i] 的 token 计算注意力。在训练/预填充阶段这通常通过一个带状 maskband mask实现mask[i][j] 1 # 不可见 if j i - W 1 或 j imask 为 0 表示可见。这样每个 token 的计算量变成 O(W)而不是 O(i)。对于总长 T 的序列总计算量是 O(T·W)。3.2 窗口是“移动”的注意“最近 W 个 token”是相对当前位置而言的。当模型生成第 100 个 token 时窗口覆盖的是 [81, 100]生成第 101 个 token 时窗口变成 [82, 101]。窗口一直在向右滑动。这意味着两部分工作新 token 的 K/V 要加入缓存超出窗口范围的旧 K/V 要“失效”或者“被覆盖”。后者是环形缓存要解决的问题。3.3 为什么要滑动窗口而不是直接截断有人会问为什么不直接截断输入只保留最后 W 个 token这样就不存在“缓存管理”问题了。但问题是decode 阶段每个新的输出 token 都需要“接住”前面的上下文。如果直接截断历史输入模型在生成“当前句子的后半段”时会丢失刚生成的语境而注意力窗口是持续存在的每个输出 token 都能看到之前 W 个 token包括输入和输出这才能保证生成连贯性。4. 朴素实现的隐藏成本窗口滑动时的数据搬运4.1 一次滑动一次拷贝假设窗口大小 W 4当前缓存中的 token 位置是 [6, 7, 8, 9]。现在模型生成了位置 10 的 token窗口需要变成 [7, 8, 9, 10]。最直观的实现是old_cache [tok6, tok7, tok8, tok9] new_cache old_cache[1:] [tok10] # 丢掉 tok6这段代码在 CPU 上很自然但注意old_cache[1:]会产生一个新的数组。在 GPU 上K/V 是连续显存中的 tensor这个“左移”操作意味着需要把 3 个 token 的 K/V 从旧地址复制到新地址。窗口每滑动一步就要复制一次。4.2 为什么代价被放大了对于单条请求来说复制 W 个 token 的 K/V 看起来不贵。但在真实推理服务中W 可能很大比如 4096 或 8192模型层数很多每层都要复制batch 中可能有几十个序列同时在生成显存带宽是固定资源复制和计算会争抢带宽。结果就是窗口虽然把“计算量”降下来了但“缓存维护”的隐性开销却可能吃掉相当一部分收益。尤其在 GPU 上我们更希望一切操作都建立在固定的、不动的存储地址上而不是频繁发生整块搬移。4.3 更高层的本质朴素实现的本质问题是它把“逻辑窗口”和“物理存储”绑定在了一起逻辑上窗口始终是数组的一段连续区间物理上就要求 K/V 也连续存放。然而窗口维护的核心需求只有一个——能够快速找到“最近 W 个 token”的 K/V并且丢弃最旧的。这个需求不需要物理连续。环形缓存恰好解耦了这两者逻辑上仍是“最近 W 个”物理上则是一个固定大小的圆环通过取模计算找到正确位置。5. 环形缓存让窗口滑动变成 O(1)5.1 核心设计环形缓存是一个固定大小为 W 的数组。它有两个标记起始位置start和当前写入位置slot。每写入一个新 token 的 K/V写入位置 token 绝对位置 % W如果该位置已经有旧数据直接覆盖如果 start 指向的位置被覆盖start 向前移动一位。这样物理数组的大小始终不超过 W但可以表达任意长度的序列。用一张文字版示意图表示初始W4, 空: slot: [0] [1] [2] [3] data: _ _ _ _ start 0 写入 token 0..3: slot: [0] [1] [2] [3] data: k0 k1 k2 k3 start 0 写入 token 4覆盖 slot 0start 右移: slot: [0] [1] [2] [3] data: k4 k1 k2 k3 start 1 写入 token 5覆盖 slot 1: slot: [0] [1] [2] [3] data: k4 k5 k2 k3 start 25.2 读取时如何保证顺序正确decode 时当前 token 需要访问它之前的 W 个 token。假设当前绝对位置是 p那么窗口覆盖的绝对位置是 [p-W1, p]。从环形缓存中读取时只需要按顺序取for offset in range(W): pos p - offset # 从新到旧 slot pos % W # 从 slot 读取最后再反转顺序就能得到“从旧到新”的 K/V 序列。整个过程完全不涉及数据搬移只有索引计算。5.3 一言以蔽之环形缓存解决的核心问题是用固定大小的物理存储表达一个持续滑动的逻辑窗口。它不再让数据搬家而是让指针搬家。代价是需要额外记录每个槽位对应的绝对位置以便正确计算位置编码和 mask。6. 真正的坑位置编码和 mask 必须和环形缓存配合环形缓存看起来简单但如果不理解位置编码与 mask 的关系实现出来一定错。下面展开最容易踩的两个点。6.1 位置编码不能直接用数组下标如果模型使用的是绝对位置编码如训练时固定的 sinusoidal embedding那么位置 token 在序列中的绝对位置。环形缓存中token 5 可能物理存放在 slot 1但它的语义位置仍然是 5。因此存储 K/V 时要么已经完成了位置编码计算要么额外保存绝对位置用于后续计算。这一点只要有 pos 数组记录就能解决。更值得注意的是当前大模型普遍使用的 RoPE旋转位置编码。RoPE 会把绝对位置编码进 Q 和 K 的旋转角度中两个 token 的注意力分数由它们的“相对位置差”决定。在 decode 阶段当前 Q 要和缓存中的每个 K 做点积而每个 K 需要采用它写入时的绝对位置去做旋转。这也是为什么缓存中的 K 在写入前就应该完成 RoPE 编码而不是等到读取时再补。如果实现时不小心用“槽位下标”当位置号在窗口不连续时会直接导致注意力分数计算错误模型输出变得毫无逻辑。6.2 mask窗口限制必须在 mask 中体现虽然滑动窗口只保留最近 W 个 token 的 K/V但在实际注意力计算时还是需要 mask 来保证当前 token 不能看到“未来”的 token如果缓存里混入了不应生效的旧 token例如某些实现为了简化存储会在槽位里保留过期数据需要在 mask 中屏蔽掉。对于 decode 单步mask 不是完整的 [T, T] 矩阵而是一个 [1, W] 的向量对窗口内每个位置标记是否可见。配合相对距离代码逻辑一般是# current_pos 是当前位置cache_positions 是窗口内各槽位的绝对位置 dist current_pos - cache_positions visible (dist 0) (dist W)6.3 小结环形缓存负责“数据在哪里”位置编码负责“数据的位置语义是什么”mask 负责“哪些位置可以参与计算”。三者必须一致任何一层出错模型都会出现莫名其妙的生成退化。7. 用最小 Python 示例跑通整个思路这一节用 NumPy 写一个最小演示不加载真实模型权重只模拟 K/V 的写入、覆盖和读取。读者可以把这段代码跑起来直观看到环形缓存的行为。7.1 朴素实现的对照代码先看朴素版验证窗口滑动会导致数据搬移# naive_window.py def naive_push(history, new_item, window_size): 朴素窗口每次写入都切片产生拷贝 if len(history) window_size: history.append(new_item) else: history history[1:] [new_item] return history history [10, 11, 12, 13] history naive_push(history, 14, 4) print(history) # [11, 12, 13, 14]输出[11, 12, 13, 14]。注意history[1:]产生了一个新列表如果这是 GPU 上的张量就是一个搬运操作。7.2 环形缓存实现# ring_kv.py import numpy as np class RingKV: def __init__(self, window_size, head_dim): self.W window_size self.k np.zeros((window_size, head_dim), dtypenp.float32) self.v np.zeros((window_size, head_dim), dtypenp.float32) self.pos np.full(window_size, -1, dtypenp.int64) self.start 0 def push(self, k_new, v_new, token_pos): slot token_pos % self.W self.k[slot] k_new self.v[slot] v_new self.pos[slot] token_pos # 如果覆盖了 start 指向的槽位start 前移 if slot self.start and token_pos 0: self.start (self.start 1) % self.W def read_recent(self, current_pos, window_size): 返回窗口内 K/V 数组以及对应的绝对位置顺序为旧 - 新 slots [] positions [] for offset in range(window_size - 1, -1, -1): p current_pos - offset if p 0: continue slot p % self.W slots.append(slot) positions.append(p) return self.k[slots], self.v[slots], np.array(positions, dtypenp.int64)测试# demo.py head_dim 8 kv RingKV(window_size4, head_dimhead_dim) for pos in range(8): k np.full((head_dim,), float(pos), dtypenp.float32) # 用一个简单向量模拟 K v np.full((head_dim,), float(pos * 10), dtypenp.float32) kv.push(k, v, pos) if pos 1: recent_k, recent_v, positions kv.read_recent(current_pospos, window_size4) print(fpos{pos}, read positions{positions.tolist()}, k first{recent_k[0][0]}) print(物理槽位 pos 记录:, kv.pos.tolist())运行该代码会看到每次读取到的都是最近 4 个位置的 K/V且槽位被循环复用。这种实现下无论序列多长self.k和self.v的物理大小始终是W × head_dim不会增长。7.3 mask 生成示例# mask_demo.py import numpy as np def build_decode_mask(current_pos, cache_positions, window_size): dist current_pos - cache_positions return (dist 0) (dist window_size) # 假设当前在位置 6缓存中最近 4 个位置是 3, 4, 5, 6 cache_positions np.array([3, 4, 5, 6]) mask build_decode_mask(6, cache_positions, window_size4) print(mask) # [ True True True True] # 如果某个槽位还残留过期数据比如位置 2会被屏蔽掉 cache_positions np.array([2, 4, 5, 6]) mask build_decode_mask(6, cache_positions, window_size4) print(mask) # [False True True True]7.4 验证成功标准上面三段代码跑通后判断标准是read_recent返回的永远是最新 W 个位置顺序正确物理缓存数组大小不变mask 能正确屏蔽过期数据和未来数据。如果第 2 步失败说明实现里还是产生了复制如果第 3 步失败说明位置记录有误。8. 工程实现中的五个关键细节从最小示例走向生产实现还有几个细节必须处理。8.1 start 指针的维护上面示例中start 的维护逻辑是“当新 token 覆盖 start 指向的槽位时start 前移”。在顺序生成场景下这是正确的。但在并行解码、beam search 或多序列共享一个 batch 时每个序列需要独立的 start。实践中通常会为每条序列维护一组元数据而不是让所有序列共用一个 ring。8.2 预填充阶段如何初始化预填充阶段一次性处理大量输入 token此时可以分批写入环形缓存。比如窗口 W 512输入长度 1024前 512 个 token 直接放入前 512 个槽位后 512 个 token 按取模继续写入并覆盖前面的槽位。预填充结束后start 应该指向最近 W 个 token 的起始位置。这里最容易错的是预填充时如果没有更新 start后续 decode 第一次读取就会读到过期槽位。8.3 batch 内长度不一致不同序列可能处于不同生成长度。一个序列可能在位置 100另一个在位置 300。环形缓存的每个槽位记录的是“哪条序列的哪个绝对位置”batch 内不能共享一个缓存数组。常见做法是每条序列独立的 K/V 区域按 batch 维做 gather/scatter或者使用 PagedAttention 那样的分块存储而不是严格的一个大 ring。8.4 与 CUDA Graph 等优化手段叠加CUDA Graph 要求 kernel 启动时的内存地址和 shape 不变。环形缓存的优点是物理地址固定天然适合 CUDA Graph。缺点是在 batch 动态变化时需要为不同 batch 大小准备多套 graph这会增加管理复杂度。8.5 数值一致性测试手写环形缓存时最容易出现“位置混了但推理结果看起来还行”的情况。建议在集成前做一次数值一致性验证用同一个模型、同一个输入对比“朴素窗口实现”和“环形缓存实现”的 logits。两者应当完全一致。如果不一致优先检查位置记录和 mask。9. 常见问题与排查思路问题现象可能原因排查方式解决方案输出突然变得毫无逻辑位置编码使用了槽位下标而不是绝对位置打印每个槽位记录的 pos与理论绝对位置对比在写入 K/V 前完成 RoPE/ALiBi 位置编码存储 pos 数组生成到某个位置后开始重复mask 没有屏蔽超出窗口的过期 token检查 decode 掩码中 dist 的上下界mask 同时限制dist 0和dist W物理缓存数组不断增长误用了 append 而不是固定槽位写入打印缓存 shape 观察变化写入使用取模索引禁止动态扩容批处理中序列互相串扰多个序列共享同一段环形缓存检查各序列读取到的 pos 是否混入其他序列每条序列独立缓存区域或按序列 id 索引预填充后第一次 decode 出错预填充阶段没有正确设置 start验证预填充结束后 pos 数组的最旧槽位位置预填充完成后显式计算 start (max_pos - W 1) % W 并修正性能不升反降每次读取都通过 Python 循环组装索引使用 profile 定位时间热点将索引计算改为向量化 gather或使用 CUDA kernel 实现和朴素实现对比 logits 不一致mask 或位置编码有一处不一致逐层对比中间结果定位首个不一致层修复后跑完整 diff 测试10. 最佳实践与工程建议10.1 优先使用成熟推理框架如果不做内核级研发不建议从头实现环形缓存。vLLM 的 PagedAttention、TensorRT-LLM 的 KV Cache 管理、HF Transformers 的 sliding window 实现都已经处理了缓存布局、并发和显存复用问题。理解环形缓存的最大价值是读懂这些框架的源码和配置项而不是重复造轮子。10.2 把缓存访问抽象成独立模块如果一定要手写建议把“缓存的写入、读取、位置记录、mask 计算”封装成独立模块对上层暴露read(current_pos, window_size)这样的接口。上层注意力计算不感知物理布局。这能显著降低调试成本。10.3 何时不要用滑动窗口滑动窗口会人为切断长距离依赖。对代码补全、长期记忆任务、文档摘要等场景纯滑动窗口可能不够。实践中常见两种方案混合注意力在滑动窗口之外保留若干全局 token如句子首 token、特殊记忆 token参与全量注意力动态分配窗口对重要 token 保留更长的记忆区间。10.4 上线前必须做的验证清单[ ] 与朴素实现对比 logits误差为 0 或接近数值精度上限[ ] 定位到“窗口边缘”的 token检查 mask 中该位置是否被正确屏蔽[ ] 在 batch 大小为 1 和 batch 大小为 8 两种情况下分别验证[ ] 预填充长度小于 W、等于 W、大于 W 三种情况都覆盖[ ] 检查显存占用曲线是否平坦而不是随生成长度线性上涨[ ] 一旦操作涉及生产服务和线上数据先在测试环境验证保留可回滚版本。11. 总结与下一步实践路径回到最开始的问题为什么滑动窗口注意力在 decode 时要使用环形缓存核心原因只有一句话滑动窗口改变了注意力的计算范围但没有自动改变 K/V 的存储方式环形缓存在固定大小的物理空间上实现了逻辑窗口的持续滑动把“每步搬运数据”变成了“每步更新指针”。这里最值得记住的知识链条是decode 阶段必须用 KV Cache 避免重复计算滑动窗口把缓存上限降到 O(W)朴素数组存储会在窗口滑动时产生复制开销环形缓存用取模索引解决复制问题但必须同时处理好位置编码和 mask否则缓存逻辑正确语义仍然错误。对读者后续学习的建议把上面第 7 节的代码跑一遍改成自己的head_dim和window_size观察槽位覆盖行为阅读 vLLM 中 PagedAttention 的设计文档对比“分页缓存”和“环形缓存”的异同用一个小模型如 1B 左右在本地部署分别试 openai 风格的长文本生成观察显存曲线如果你正在做长上下文推理优化可以把“为什么窗口注意力环形缓存不能解决所有长程依赖问题”作为下一个研究问题。这篇文章是“每天一个宝藏问题”系列中的一题。下一题可以继续沿着推理优化的主线展开比如为什么 prefill 阶段与 decode 阶段对 attention 的优化策略完全不同这两个阶段背后正好是两套完全不同的显存与计算优化逻辑。