AI 技术
#KV Cache#H2O#LRU#注意力机制#推理优化

KV Cache 驱逐策略:从 LRU 到 H2O 的注意力感知淘汰机制

长上下文在线服务中,KV Cache 随序列增长迅速膨胀,成为显存与延迟瓶颈。本文以企业知识库问答为场景,对比 LRU、滑动窗口与 H2O 等驱逐策略,解释注意力感知淘汰为何更有效,分析其对生成质量、显存和延迟的影响,并讨论适用边界与实现复杂度。

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

部署一个支持 128K 上下文的企业知识库问答服务时,模型权重只占显存的一部分。真正让显存快速耗尽的是自回归生成过程中不断累积的 KV Cache。KV Cache 保存了每个历史 token 的 Key 和 Value 向量,用于避免每一步重新计算整个前缀的注意力。它的规模与序列长度和 batch 大小线性增长,很容易超过模型本身。以 LLaMA-3-70B 为例,80 层、128 个注意力头、BF16 精度下,单条 128K 上下文的 KV Cache 就需要 200GB 以上显存,远超单张 A100 的 80GB。

显存不足直接限制了推理服务的 batch 大小,而 batch 大小又是吞吐的关键。一个常见直觉是:既然缓存太大,就把它当作普通缓存,用 LRU 策略淘汰最久未使用的 token。但 LRU 并不理解 token 在注意力计算中的实际作用。它可能丢掉一个对后续生成至关重要的早期 token,却保留大量冗余的中间 token。

基线方案:LRU 与滑动窗口的失效

LRU(Least Recently Used)是通用缓存管理中最经典的策略,按访问时间淘汰最久未使用的条目。在 KV Cache 场景中,每次生成新 token 时,所有历史 KV 都会被读取一次,因此“最近使用”实际上等同于“位置靠近当前 token”。LRU 因此退化为只保留最近一段窗口内的 token,与滑动窗口策略(Sliding Window)行为几乎一致。

滑动窗口策略假设模型主要依赖最近的上下文,因此只保留末尾固定数量的 token。这在短对话或局部依赖强的任务中表现尚可,但遇到需要长期记忆的场景就会失效。例如,在知识库问答中,用户先问“请总结 2023 年财报”,随后追问“那 2022 年呢?”,模型需要回看最早的“2023 年财报”相关段落。如果这些 token 已被窗口淘汰,模型只能依赖可能已经失真的中间表示,回答质量显著下降。

更本质的问题在于,注意力分布并非均匀地偏向最近 token。研究表明,某些 token 在注意力计算中始终占据主导地位,即使它们出现在很早的位置。这些 token 被称作“重击者”(Heavy Hitters),它们与文本中高频共现的词密切相关。LRU 和滑动窗口完全忽略这一特性,因此淘汰决策与真实重要性脱节。

注意力分数:重击者与持久重要性

H2O(Heavy-Hitter Oracle)论文提出一个关键观察:在计算注意力分数时,一小部分 token 贡献了绝大部分价值。这些 token 就是重击者。作者通过实验发现,重击者的出现是自然的,与 token 在文本中的高频共现强相关;如果移除这些 token,模型性能会显著下降。

这一观察与 Scissorhands 论文的“持久重要性假设”相互印证。Scissorhands 假设:只有在一开始对生成有重大影响的 token,才会在后续步骤中持续产生显著影响。基于这个假设,系统可以只保留少数“关键 token”,而丢弃大部分普通 token,且不会明显损害生成质量。

注意力分数如何反映重要性?在自回归生成的第 t 步,当前 token 的 Query 会与所有历史 Key 计算点积,经 Softmax 后得到注意力权重。权重越高,说明对应历史 token 对当前输出的影响越大。如果某个 token 在多个生成步骤中都获得高注意力权重,它很可能就是重击者。H2O 正是利用这一信号来指导驱逐。

H2O 的驱逐机制:动态保留重击者与最近 token

H2O 的核心算法并不复杂。它维护一个固定大小的缓存池,当新 token 的 KV 对加入时,如果缓存已满,就根据注意力分数驱逐得分最低的 token。但 H2O 有一个重要设计:它始终保留一部分最近生成的 token,即使它们的注意力分数不高。

原因在于,最近 token 对当前生成的局部连贯性至关重要。如果只按注意力分数保留,可能丢掉刚生成的几个 token,导致输出出现跳跃或重复。H2O 将缓存分为两部分:一部分是“重击者”池,按累计注意力分数排序保留;另一部分是“最近”池,固定保留末尾若干 token。新 token 先进入最近池,当最近池满时,将其中注意力分数最低的 token 移入重击者池(如果分数足够高),或者直接驱逐。

这种“重击者 + 最近”的组合,在理论上被形式化为一个动态子模问题。H2O 论文证明,在温和假设下,贪心选择 top-k 注意力分数的 token 可以接近最优子集。这意味着,虽然无法精确求解最优保留集合,但基于注意力分数的贪心策略有理论保证。

贯穿场景:企业知识库问答中的驱逐决策

为了具体说明,我们假设一个企业知识库问答服务,模型需要处理 64K token 的上下文,包括产品手册、历史工单和实时对话。用户先询问“如何配置 VPN”,随后又问“刚才提到的认证方式是什么”。后一个问题需要模型回看前文中的“认证”相关段落。

在 LRU 策略下,当上下文超过缓存上限时,最早的产品手册内容会被淘汰。如果“认证”一词首次出现在手册中,模型就会丢失关键信息,回答只能依赖后续可能不完整的上下文。

在 H2O 策略下,当模型处理手册时,“认证”“VPN”“配置”等词在注意力中频繁获得高分,它们的 KV 对被标记为重击者并保留。即使后续对话很长,这些早期关键 token 依然留在缓存中。当用户追问“认证方式”时,模型仍能访问原始描述,生成准确回答。

下图展示了 H2O 在单次生成中的驱逐流程:

flowchart TD
    A[新 token 生成] --> B{缓存是否已满?}
    B -- 否 --> C[直接加入最近池]
    B -- 是 --> D[计算新 token 与历史 token 的注意力分数]
    D --> E[更新所有 token 的累计分数]
    E --> F[从最近池中选出分数最低的 token]
    F --> G{该 token 是否属于重击者?}
    G -- 是 --> H[将其移入重击者池]
    G -- 否 --> I[驱逐该 token]
    H --> J[若重击者池已满, 驱逐其中分数最低者]
    I --> C
    J --> C

每一步生成都会触发一次注意力计算,因此累计分数的更新是自然的。驱逐决策发生在缓存满时,而不是每一步,这降低了计算开销。

实现要点:数据结构与批处理

实现 H2O 时,需要解决几个工程问题。首先是缓存池的数据结构。由于需要频繁按分数排序和淘汰,通常使用最小堆或平衡树来维护 token 的累计分数,使得插入和驱逐都在 O(log n) 时间内完成。

其次是多头注意力的处理。H2O 论文中的实现为每个注意力头独立维护重击者集合吗?实际上,H2O 在实现中通常对所有头共享同一个驱逐决策,但注意力分数需要跨头聚合。一种做法是取所有头注意力分数的平均值,或者取最大值。不同头的关注点可能不同,但 H2O 的简化处理在实验中表现良好。

在批处理场景中,每个序列的缓存大小不同,驱逐策略需要与 PagedAttention 等内存管理机制配合。vLLM 的 PagedAttention 将 KV 缓存划分为固定大小的块,驱逐时释放整个块。H2O 的 token 级驱逐可以与块级管理结合:驱逐后标记块为空闲,供其他序列复用。

伪代码示意如下:

class H2OCache:
    def __init__(self, budget, recent_size):
        self.budget = budget          # 总 token 数上限
        self.recent_size = recent_size # 最近池大小
        self.recent = []              # 最近 token 列表
        self.hh = []                  # 重击者列表 (按分数排序)
        self.scores = {}              # token -> 累计注意力分数

    def add(self, token_id, key, value, attn_scores):
        # attn_scores: 当前步对所有历史 token 的注意力权重
        for t in self.recent + self.hh:
            self.scores[t] += attn_scores[t]
        self.recent.append((token_id, key, value))
        if len(self.recent) > self.recent_size:
            # 从最近池中选出分数最低的 token
            victim = min(self.recent, key=lambda x: self.scores[x[0]])
            self.recent.remove(victim)
            if self.scores[victim[0]] > self.hh_threshold:
                self.hh.append(victim)
                self.hh.sort(key=lambda x: self.scores[x[0]])
                if len(self.hh) > self.budget - self.recent_size:
                    self.hh.pop(0)  # 驱逐分数最低的重击者
            # 否则直接丢弃

上述伪代码省略了张量存储细节,但展示了核心逻辑。实际实现中,KV 对通常以连续张量存储,驱逐时需要移动内存,这会产生额外开销。因此,工程上更倾向于块级驱逐,即将 token 分组为块,以块为单位淘汰,减少内存碎片。

对比:LRU、滑动窗口、H2O 与 Scissorhands

策略核心思想保留对象质量影响实现复杂度适用场景
LRU / 滑动窗口按最近使用或位置淘汰最近 token长依赖任务下降明显短上下文、局部依赖为主
H2O按累计注意力分数保留重击者 + 最近 token重击者 + 最近 token在多数任务中接近完整缓存长上下文、需要长期记忆
Scissorhands基于持久重要性,以更高概率保留关键 token关键 token(类似重击者)可压缩 5 倍而不明显损失质量与 H2O 类似,但更强调概率保留

H2O 论文报告,在 OPT-6.7B 和 OPT-30B 上,使用 20% 重击者时,相比 DeepSpeed Zero-Inference、Hugging Face Accelerate 和 FlexGen,吞吐提升最高可达 29 倍、29 倍和 3 倍;在相同 batch 下延迟降低最多 1.9 倍。这些数字来自论文实验,具体提升取决于硬件和 workload。

Scissorhands 则报告可将 KV Cache 内存占用降低最多 5 倍而不损害模型质量,并且可以与 4-bit 量化结合,实现最高 20 倍压缩。

需要注意的是,这些数字都是在特定模型和任务上测得的,不能直接推广到所有场景。但趋势一致:注意力感知的驱逐策略在长上下文任务中明显优于 LRU。

失败模式与适用边界

注意力感知驱逐并非万能。最明显的失败模式是分布偏移。如果模型在训练时主要处理短文本,而推理时面对超长上下文,注意力分布可能不同于训练分布,重击者的识别可能不准确。H2O 论文中的实验覆盖了多种任务,但并未覆盖所有可能的长尾场景。

另一个问题是“针在干草堆”任务。这类任务要求模型从长文本中精确找到一个事实,而该事实对应的 token 可能只在某一层、某一个头中获得高注意力分数。如果驱逐策略跨层共享决策,可能会丢掉这个关键 token。资料中明确指出,仅按注意力分数排序驱逐会导致这类任务准确率大幅下降。现代方法如 SnapKV 采用分层、多头感知的驱逐策略,但实现复杂度更高。

驱逐比例的选择也至关重要。资料中的经验公式显示,保留 10% token 时,QA 任务准确率可能下降 20%~40%,而摘要任务仅下降 2%~5%。这说明任务敏感度差异很大。生产环境需要根据具体任务调参,不能盲目设定固定比例。

此外,H2O 的驱逐决策基于历史注意力分数,但未来 token 的注意力分布可能变化。如果模型在生成过程中突然需要某个早期被驱逐的 token,信息已经丢失,无法恢复。这是所有驱逐策略的固有限制。

可观测性与验证方法

部署驱逐策略时,需要监控几个关键指标。首先是缓存命中率,即有多少 token 的 KV 被保留并用于注意力计算。但命中率高并不等于质量好,还需要跟踪生成质量指标,如困惑度(Perplexity)或下游任务准确率。

在在线服务中,可以对比同一请求在启用驱逐与不启用驱逐时的输出,但成本较高。更实际的做法是离线评估:使用代表性数据集,测量不同驱逐比例下的质量下降曲线。资料中的 Pareto 前沿模型可以指导选择驱逐比例,但需要针对具体任务拟合参数。

另一个信号是显存占用和吞吐。驱逐策略的目标是降低显存峰值,从而允许更大的 batch。监控 GPU 显存使用率和每请求延迟,可以判断驱逐是否达到预期效果。如果显存下降但延迟没有改善,可能是驱逐计算本身成为瓶颈。

替代方案与组合使用

KV 驱逐并非唯一的显存优化手段。KV 量化通过降低每个 KV 对的精度(如 INT4/INT8)来压缩存储,与驱逐正交,可以组合使用。Scissorhands 论文就展示了驱逐与 4-bit 量化结合,实现更高压缩。

前缀复用是另一种思路,通过跨请求共享相同前缀的 KV 来避免重复计算。这需要 Radix Tree 等数据结构支持,与驱逐策略可以同时启用。但前缀复用只适用于多请求共享前缀的场景,如多轮对话或批量相似查询。

投机采样(Speculative Decoding)通过小模型预测 token 来减少主模型调用次数,间接降低 KV 缓存压力,但它是另一种优化方向。

工程上,选择哪种策略取决于具体瓶颈。如果显存是主要限制,驱逐和量化都有效;如果延迟是主要限制,驱逐可能增加计算开销,而前缀复用或投机采样可能更合适。

尚未解决的问题

KV 驱逐仍有许多开放问题。如何自动适应不同任务和输入分布?目前驱逐比例和重击者阈值都需要人工调参。如何设计多头感知的驱逐策略,同时保持实现复杂度可控?SnapKV 等尝试了分层保留,但尚未成为主流。

另一个方向是学习驱动的驱逐,即训练一个轻量模型来预测哪些 token 值得保留,而不是依赖启发式注意力分数。但训练成本可能较高,且存在过拟合风险。

最后,驱逐与模型训练的结合。如果模型在训练时就意识到推理时 KV 会被驱逐,是否可以学得更鲁棒?目前大部分方法都是训练后直接应用,不做微调。未来可能出现针对驱逐优化的训练目标。

对于在线服务工程师,理解这些策略的权衡比追逐最新论文更重要。在部署前,用代表性数据评估质量下降曲线,监控显存和延迟,再决定是否采用注意力感知驱逐,以及如何设置参数。

资料来源

  1. H2O: Heavy-Hitter Oracle for Efficient Generative Inference of Large Language Models
  2. Scissorhands: Exploiting the Persistence of Importance Hypothesis for LLM KV Cache Compression at Test Time
  3. Fast Transformer Decoding: One Write-Head is All You Need
  4. 个人博客