AI 技术
#KV Cache#H2O#注意力稀疏性#推理优化#显存管理

KV Cache 驱逐策略:H2O 如何通过注意力分数识别重要 Token 并压缩缓存

长上下文在线服务中,KV Cache 随序列长度线性增长,成为显存瓶颈。本文聚焦 H2O 方法,解释其如何基于注意力分数识别 Heavy Hitter Token 并动态驱逐低价值 KV 对,对比 LRU、StreamingLLM 等策略,分析对生成质量、显存与延迟的影响,并讨论适用边界与实现复杂度。

长上下文在线服务中的显存瓶颈

部署一个支持长对话或多轮问答的大语言模型服务时,显存很快会被两类数据占满:模型参数和 KV Cache。KV Cache 存储了每个历史 token 的 Key 和 Value 张量,用于避免每一步解码时重新计算注意力。它的规模随序列长度和批大小线性增长。一个 7B 参数的模型,当上下文达到 2048 token、批大小为 8 时,KV Cache 可能占用数 GB 显存,甚至超过模型本身。

在线服务的典型场景是:用户与客服机器人连续对话,每轮都追加新的用户消息和模型回复,上下文不断变长。如果 KV Cache 无限制增长,服务会在某个时刻因显存不足而崩溃,或者被迫降低批大小,导致吞吐下降。工程上通常用两种思路应对:一是把 KV Cache 换到 CPU 内存或磁盘,但访问延迟显著增加;二是限制上下文长度,但这会丢失早期对话信息。

基线方案是滑动窗口注意力(Sliding Window Attention),只保留最近 N 个 token 的 KV,其余全部丢弃。这个方案实现简单,但存在明显缺陷:当模型需要引用早期信息时(比如用户在第一轮提到的订单号),窗口外的 token 已经不可见,生成质量会急剧下降。StreamingLLM 进一步发现,如果连最初的几个 token 也一并丢弃,模型甚至会失去稳定的注意力锚点,导致生成崩溃。这说明简单的“丢弃最近之外的所有内容”并不可行。

注意力稀疏性:少数 Token 贡献大部分注意力

H2O 方法的核心建立在注意力稀疏性这一观察上。论文作者在 OPT、LLaMA 等模型上分析注意力权重时发现,在大多数生成步骤中,只有一小部分历史 token 被频繁访问,而大部分 token 的注意力权重接近于零。这些被频繁访问的 token 被称为 Heavy Hitters(H2)。

这一现象与文本中 token 的共现频率密切相关。高频共现的 token(如常见词组、实体名称)往往在语义上更重要,模型在生成时倾向于反复关注它们。H2O 论文通过实验证明,如果移除这些 Heavy Hitter token,模型性能会显著下降;而移除其他低注意力 token,性能几乎不受影响。

这个观察为 KV Cache 驱逐提供了依据:与其盲目丢弃最近之外的 token,不如根据注意力分数动态识别哪些 token 值得保留。但注意力分数是逐 token、逐层、逐头计算的,如何将多维信息聚合成一个可用的保留标准,是 H2O 要解决的关键问题。

H2O 的驱逐机制:累计注意力分数与动态更新

H2O 将 KV Cache 驱逐形式化为一个动态子模问题,并提出了一个贪心算法。算法的核心是维护一个固定大小的缓存,当缓存满时,驱逐累计注意力分数最低的 token。

具体来说,H2O 为每个 token 维护一个累计注意力分数,该分数是该 token 在历史所有解码步骤中获得的注意力权重的总和。每次生成新 token 时,新 token 的 KV 会加入缓存;如果缓存超出预算,就移除累计分数最低的 token。这样,缓存中始终保留两类 token:最近加入的 token(保证局部上下文)和累计分数高的 Heavy Hitter token(保证全局信息)。

为什么同时保留最近 token?因为生成是自回归的,新 token 的生成高度依赖紧邻的前几个 token,它们即使累计分数不高,也必须保留。H2O 在实现中通过一个“最近窗口”来强制保留最近的 token,其余位置才用于容纳 Heavy Hitter。

下面用流程图展示 H2O 在一次生成步骤中的决策过程:

flowchart TD
    A[生成新 token t] --> B[计算注意力分数
对每个历史 token]
    B --> C[更新累计分数
score[t] += attn]
    C --> D[将 t 的 KV 加入缓存]
    D --> E{缓存大小
超过预算?}
    E -- 否 --> F[继续生成下一个 token]
    E -- 是 --> G[在非最近窗口中
选择累计分数最低的 token]
    G --> H[驱逐该 token 的 KV]
    H --> F

这个流程在每个解码步骤中执行,驱逐决策是动态的,基于当前已知的注意力信息。H2O 论文证明,在温和假设下,这个贪心算法具有理论保证,能够近似最优的驱逐策略。

与 LRU、StreamingLLM 的对比

LRU(最近最少使用)是操作系统缓存管理的经典策略,但在 KV Cache 场景下并不适用。LRU 假设“最近被访问的数据将来更可能被访问”,但注意力模式并不遵循这一局部性。一个 token 可能在早期被频繁关注,之后长时间不被访问,但一旦被重新提及,它仍然很重要。LRU 会错误地驱逐这类 token。

StreamingLLM 则完全放弃了基于重要性的选择,只保留最近的 token 和最初的几个 attention sink token。它的优点是实现极简,但缺点是无法保留中间位置的重要信息。H2O 通过累计注意力分数,能够识别出那些虽然不在最近窗口但语义重要的 token。

下表从多个维度对比这三种策略:

策略保留依据实现复杂度长上下文任务质量额外计算开销
LRU最近访问时间差,容易丢失重要早期 token
StreamingLLM最近位置 + 开头 sink极低中等,依赖窗口内信息
H2O累计注意力分数 + 最近窗口高,能保留全局重要 token需维护分数并排序

H2O 论文在 OPT-6.7B 和 OPT-30B 上验证了效果:使用 20% 的 Heavy Hitter 预算,相比 DeepSpeed Zero-Inference、Hugging Face Accelerate 和 FlexGen 三个推理系统,吞吐量最高提升 29 倍、29 倍和 3 倍;在相同批大小下,延迟最多降低 1.9 倍。这些数字来自论文实验,具体提升幅度取决于模型和任务。

工程实现:数据结构与开销

实现 H2O 需要维护每个 token 的累计注意力分数。在多头注意力中,一个 token 在每个 head 上都有不同的注意力权重。H2O 的做法是对所有 head 的注意力权重求和或取平均,得到一个统一的分数。工程上通常采用求和,因为不同 head 可能关注不同的语义方面,求和能综合所有 head 的信息。

数据结构上,H2O 需要一个优先队列(或最小堆)来快速找到累计分数最低的 token。每次更新分数时,需要更新堆中对应节点的位置,复杂度为 O(log N)。驱逐时从堆顶取出最低分数 token,并释放其 KV 缓存。

然而,这个操作带来两个问题:一是额外的计算开销,每次解码都要更新分数并可能调整堆;二是缓存重排,驱逐一个中间的 token 后,KV 缓存中留下空洞,后续的注意力计算需要跳过这些空洞,这破坏了连续的内存布局,可能降低矩阵乘法的效率。

伪代码如下,展示了 H2O 的核心逻辑(接口为示意,非真实 SDK):

def generate_with_h2o(model, prompt, budget, recent_window):
    cache = []  # 存储 KV 对和累计分数
    scores = {}
    for t in range(len(prompt)):
        attn = model.compute_attention(prompt[:t+1])
        for i in range(t):
            scores[i] += attn[i]  # 累计注意力分数
        cache.append((prompt[t], model.compute_kv(prompt[t])))
        if len(cache) > budget:
            # 在非最近窗口中找分数最低的 token
            evict_idx = min(
                range(len(cache) - recent_window),
                key=lambda i: scores[i]
            )
            del cache[evict_idx]
    # 生成阶段类似,每步更新分数并驱逐

这段伪代码展示了驱逐的关键步骤:累计分数、检查预算、选择驱逐对象。实际实现中,需要处理多头、多层的情况,以及缓存索引的映射。

失败模式与适用边界

H2O 虽然比盲目丢弃更智能,但仍有明显的失败模式。最核心的问题是:它依赖历史注意力分数来预测未来,而未来 query 是未知的。一个 token 可能在历史中很少被关注,但在未来的某个生成步骤中突然变得重要(例如用户新输入的问题指向了早期的一个细节)。H2O 可能已经驱逐了这个 token,导致生成质量下降。

这种“未来关注”问题在任务切换时尤为严重。比如对话刚开始时,模型关注的是问候语;当用户突然提出一个具体问题时,模型需要关注问题中提到的实体,但这些实体在早期可能只出现过一次,累计分数很低,容易被驱逐。

另一个问题是驱逐导致输出偏移。即使驱逐的 token 在大多数步骤中不重要,但在某些关键步骤中缺失,会改变注意力分布,进而影响后续生成。这种偏移可能累积,导致生成内容偏离原始语义。

H2O 的适用边界也因此清晰:它最适合那些注意力分布相对稳定的任务,比如长文档摘要、代码生成,其中重要 token 在整个生成过程中持续被关注。对于多轮对话这种注意力焦点频繁切换的场景,H2O 的效果会打折扣。

此外,H2O 的驱逐粒度是 token 级,且需要在 prefill 后动态更新分数,这与业界广泛使用的 PagedAttention(vLLM)存在冲突。PagedAttention 将 KV 缓存分页管理,以页为单位分配和释放内存,而 H2O 驱逐单个 token 会导致页内碎片,降低内存利用率。这也是目前 KV Drop 类方法在生产系统中较少被采用的原因之一。

可观测性与验证方法

在生产环境中,要判断 H2O 是否有效,需要观察几个关键指标。首先是显存占用:在相同上下文长度下,H2O 应显著降低 KV Cache 峰值占用。其次是生成质量:通过困惑度(perplexity)或下游任务指标(如问答准确率)对比有无驱逐的差异。

更细粒度的观测是注意力分布的变化。可以记录驱逐前后每个 token 的注意力权重,检查被驱逐的 token 是否在后续步骤中被高频关注。如果发现被驱逐 token 的注意力分数在驱逐后出现反弹(即模型试图访问但已不可见),说明驱逐策略过于激进。

工程上还可以监控缓存命中率:在驱逐后,后续生成步骤中模型实际关注的 token 中有多少仍保留在缓存中。这个指标类似 CPU 缓存的命中率,能直观反映驱逐策略的有效性。

H2O 论文在 OPT、LLaMA 和 GPT-NeoX 上进行了验证,但具体数值依赖于模型和任务。部署时应先在目标数据集上离线评估,确定合适的预算比例(如 20% 或 30%),再上线。

替代方案与未来方向

除了 H2O,KV Cache 压缩还有其他路径。量化方法(如 KIVI)将 KV 向量压缩为低位宽,不丢弃任何 token,但会引入量化误差。稀疏化方法(如 MInference)在 prefill 阶段动态跳过不重要的注意力计算,减少计算量而非存储。

驱逐方法的一个改进方向是预测未来关注。SnapKV 观察到,模型在生成时对提示末尾的“观察窗口”内的 token 有稳定的注意力模式,因此可以在 prefill 阶段利用观察窗口的注意力分布一次性筛选关键 token,避免动态更新的开销。CAKE 则进一步考虑不同层的注意力分散度和时间变化,为每层分配不同的缓存预算。

这些方法各有优劣:SnapKV 减少了运行时开销,但假设观察窗口能代表整个上下文;CAKE 更精细,但实现复杂度更高。H2O 的优势在于动态适应性,但代价是持续的分数维护。

目前,业界 KV Drop 方案尚未大规模落地,主要原因是与动态批处理和 PagedAttention 的兼容性问题。未来如果能将驱逐粒度从 token 级提升到页级,或与分页管理结合,可能更接近生产可用。此外,如何设计无监督的预算分配策略,避免依赖人工调参,也是一个开放问题。

总结

H2O 通过累计注意力分数识别 Heavy Hitter token,在缓存满时驱逐低价值 KV 对,相比 LRU 和 StreamingLLM 能更好地保留全局重要信息。它在长上下文任务中能显著降低显存占用,提升吞吐和延迟,但依赖历史注意力预测未来,存在“未来关注”失效的风险。实现时需权衡额外计算开销和缓存重排成本,且与现有分页缓存系统的集成仍是挑战。对于注意力分布稳定的任务,H2O 是一个值得考虑的方案;对于注意力频繁切换的对话场景,可能需要结合其他策略或接受一定的质量损失。

资料来源

  1. H2O: Heavy-Hitter Oracle for Efficient Generative Inference of Large Language Models
  2. StreamingLLM: Efficient Streaming Language Models with Attention Sinks
  3. vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention
  4. 【论文分享】| 基于驱逐机制的 KV 缓存压缩技术在大模型推理中的应用 | 飞桨开源社区博客