☰
后缀缓存复用:破解LLM推理中长上下文重复预填充难题
2026/10/22 11:56:59 网站建设 项目流程

从一次重复的预填充说起:长上下文请求里藏着大量"白算"

做 LLM 推理服务的人,几乎都遇到过这样一个画面:线上一个 Agent 应用,每次调用都会带着一大段固定的 System Prompt,里面是角色设定、工具描述、输出格式约定,动辄一两千 Token。用户输入其实只有几十个字,但每次请求进来,预填充阶段照样把这一大段从头到尾重新算一遍。

前阵子我在内部排查一次线上服务毛刺,打开监控一算,发现所有长上下文请求里,接近 70% 的预填充计算量都花在了用户请求之间几乎完全不变的"脚手架"Token 上。更让人心疼的是,这批 Token 的显存空间明明已经在上一轮完整算过了,结果下一轮请求一来,又得推倒重算。

当时团队里第一反应是上前缀缓存。前缀缓存的思路很朴素:如果请求 A 和请求 B 的开头一样,那就把开头这部分 KV 缓存复用。可我们面临的场景恰恰相反——两个请求是"开头不同、结尾相同"。System Prompt 放在尾部而不是头部,前缀缓存直接失灵。

真正解决问题的,是标题里这几个字:CLM 与后缀缓存复用。让模型自己管理上下文,本质上就是把 KV 状态从"跟着请求全文重算"变成"按可复用的后缀块缓存"。这篇文章,我就把这个方案从原理讲到落地踩坑,全程记录我实际做过的设计、评测和教训。如果你也在打磨长上下文推理链路,或者正在为"请求相似但又不完全相似"的高重复计算头疼,这篇文章应该能给你一条新的排查方向。

1. 因果语言模型的"可拼接性":后缀缓存复用的原理根基

1.1 自回归解码与 KV Cache 到底缓存了什么

要理解后缀缓存为什么可行,先得回到 Transformer 推理的基本盘面。现在主流的大语言模型都是自回归架构,一次推理被切成两个阶段:预填充阶段处理整个输入序列,并为每个 Token 生成对应的 Key、Value 向量;解码阶段每步只输入一个新 Token,注意力计算会用到前面所有 Token 的 Key、Value。

KV Cache 就是在预填充阶段做完后,把这套 Key、Value 向量连同注意力相关的中间状态存放在显存里。它本质上是"历史输入的一种可复用投影"。因为解码阶段每往后走一步,都只需要新 Token 去查询已有的 Key、Value,所以只要历史部分算过,后面就不用再重算。

问题在于:常规用法里,KV Cache 的生命周期绑定在单次请求上。请求结束,缓存释放;下一个请求进来,就算内容和上一个请求高度重叠,也是从零开始预填充。你可以把 KV Cache 想象成一个饭前摆好的菜碟——你这次吃完,全部撤掉,下个客人来了哪怕点的是同一桌菜,厨房也得重新做一遍。

1.2 因果掩码决定了什么:第 n 个位置的状态只依赖前 n 个 Token

这里就要提到 CLM,因果语言模型最核心的机制。在 Transformer 的多头注意力里,给序列做的是带因果掩码的注意力计算:第 i 个 Token 只能看到第 1 到第 i 个 Token,后面的都是掩码屏蔽掉的。

这个设计导致的直接结果,是注意力状态具有严格的"前缀依赖性"。推到极致就是:序列里第 n 个位置的 Key、Value 向量,只由前 n 个 Token 的输入决定,和"未来"没有任何关系。这个性质乍看平平无奇,但它给了 KV 复用一个非常强力的数学保障——只要你能保证某个位置之前的输入完全相同,那么从这个位置开始的 KV 状态就一定可以原样复用。

前缀缓存利用的正是这个性质:从序列头部依次比对,找到公共前缀,把公共前缀的 KV 结果直接搬过来用。后缀缓存看起来反直觉,是因为大多数人默认"缓存只能从开头共享",但实际上,在因果模型的约束下,还有一个隐含的备用路径。

1.3 为什么后缀能跨请求复用

后缀缓存复用的核心洞察是:因果掩码虽然给了前缀强烈的依赖性,但它对"后缀"给出的是温和的确定性。也就是说,一个新的请求,如果它包含着一个与之前某个请求完全相同的尾部,那么在这个尾部内部的注意力计算过程中,每个 Token 所消费的 Key、Value,都只来自该尾部内部的更早 Token 以及更早的前缀。

等等,这不就等于说后缀内部的每个 Key、Value 依然会受前面不同前缀的影响吗?没错,如果从序列开头到尾部边界完整走一遍正向计算,尾部每个位置的注意力确实会看到来自不同前缀的信息。那还复用什么?

关键在于,自回归模型生成的时候,决定下一个 Token 概率的隐藏状态,是需要逐层累积的。如果你想复用的不只是某几层的注意力结果,而是完整的能够继续解码的状态,那么必须保证从某个切分点开始,状态携带的"历史信息"完全一致。对于因果模型来说,位置 i 的隐藏状态本质上是"以 i 之前全部内容为条件"的概率分布摘要。如果两个请求在相同位置集合上拥有不同的前缀,那么即使尾部相同,这个条件概率摘要也不同——理论上不能直接换。

这就带来一个实现上的取舍问题。实际的推理服务中,后缀缓存复用的形态通常不是去替换任意请求的完整状态块,而是出现在下面这种模式:

  • 缓存一个由固定 System Prompt 和 Tool List 构成的完整独立序列,它本身就是一次完整的预填充,拥有自己的完整 KV Cache。
  • 新请求进入时,将用户输入的动态部分作为前缀,和这个固定后缀拼接。此时,如果服务端按"先并行计算动态前缀的 KV,再拼接上固定后缀的 KV"来处理,同时要求模型在计算时能区分这两段来源,那么固定后缀的 KV 就可以作为整体复用。

只是直接拼接会破坏因果掩码所依赖的相对顺序,也绕不开位置编码的绝对位置问题。所以我更愿意这样描述后缀缓存的准确形态:它让"同一段固定文本"在多次请求中始终出现在相同的位置区间,从而把这部分文本的 KV 状态固化下来,不管它前面接了什么,反正它自己永远从固定的位置编号开始。这样,前缀怎么变都不影响这段尾部内部的计算。

这种形态有一个专业的对应概念叫"Torso/Preface 缓存",但很多推理框架只实现了前半段(前缀缓存),没实现后半段(固定尾部缓存)。原因很现实:把固定文本放尾部,位置编码的对齐和拼接逻辑要更小心处理,踩的坑也更多。下一节我详细展开。

1.4 关键工程细节:位置编码对齐

前面说到的"固定位置区间",在实际工程里就是后缀缓存能否落地的那道坎。现在主流模型用的都是 RoPE 这类相对位置编码,虽然相对编码的好处是短距离位置关系不会因为绝对位置偏移而改变,但实际的实现是把旋转角度加在 Query 和 Key 上,而这个旋转因子由绝对位置决定。

我的落地经验是:选定一个"锚点位置" P,固定后缀每次进入缓存时,就按它应该出现的位置区间 [P, P + L - 1] 来做 RoPE 预处理,并把这个区间的 KV 状态缓存下来。新请求的动态前缀部分,无论长度是多少,都从位置 0 开始计算;前缀长到触碰到锚点位置 P 之前,尾部缓存都不会失效。

这段话有点抽象,我画个简化示意:假设 P = 2048,缓存了一个从 2048 号位置开始的固定尾段。新请求进来,动态前缀有 500 个 Token,它占的位置是 0..499,尾段仍然从 2048 开始。那么在这条序列里,Token 500 到 2047 是一段空白,处理时要跳过,但尾段的相对位置关系完全没变,缓存直接命中。这就是"让模型自己管上下文"的一种物理实现方式——模型根本不需要知道前缀的内容,它只需要知道尾段永远在固定坐标上等着被续写。

2. 后缀缓存复用如何落地:设计一个可复用的 KV 状态库

2.1 缓存主键设计:把 Token 序列尾部做成稳定指纹

原理通了,落地第一步是把"固定后缀"变成可检索的缓存键。当时我实现的第一版键设计很简单:对固定尾段的 Token ID 序列取哈希,然后和尾段长度、锚点位置一起组成主键。多租户场景下还要带上模型版本、LoRA 标识、采样参数组,否则几个模型之间容易互相串。

这里要注意:一定不要直接拿"字符串原文"当键。同一段语义、不同 Token 化结果(比如换行空格差异)会产生不同的键,导致缓存命中率下降;更危险的是不同字符串哈希碰撞导致误命中。我建议用双层校验:一层是 64 位哈希做快速索引,另一层是 Token 原始序列的完整比对,确认后才能真正复用。

主键之外还要维护一组辅助索引,常见的有:

  • 尾段 Token 数;
  • 尾段起始的锚点位置;
  • 最后写入时间;
  • 引用计数;
  • 关联的模型批次 ID。

这套结构本质上就是一张 KV 状态数据库的表。可以把它放在推理进程的共享内存里,也可以放进外部的分布式 KV 存储。考虑到 KV 块体积不小(动辄几百 MB),我建议先用单机多卡的共享缓存,暂不设计跨机迁移——跨机通信的代价往往比重新预填充还高。

2.2 状态块的数据结构与存储形态

KV 状态块不是普通的内存对象,它有很强的形状依赖。一般我会按 Transformer 层的维度去存储:每一层分别存放 K 张量和 V 张量,shape 为 [batch_size, num_heads, seq_len, head_dim]。注意 batch_size 这里要固定为 1,因为不同请求的动态前缀不同,拼接后不应该夸大到 batch 合并。

因为 seq_len 是固定尾段的长度,K、V 张量在显存里是一块紧凑的连续段。这给拼接带来了很大方便:动态前缀算完后,在显存里只需要做一次张量级 concat,把尾段的 K/V 追加到动态前缀的 K/V 后面即可。只要位置编码预先对齐,concat 操作本身不涉及任何重新计算。

不过要提醒一个容易踩的坑:很多推理框架的 KV 缓存分配是按请求的生命周期来的,服务端一旦在请求结束后把 KV 释放,尾段缓存就没了。所以你需要单独给后缀缓存块开一块持久化的显存池,用缓存淘汰策略管理,而不是跟着请求走。这是整个改造中代码侵入量最大的一处。

2.3 命中后如何拼接自回归解码

假设我们命中了一个尾段状态块,动态前缀也刚完成预填充,接下来的解码过程,可以抽象成几个步骤:

  1. 动态前缀部分(0..P 之前)得到自己的 KV 序列。
  2. 在 attention 计算时,每条 query 要能够"看到"两段 KV:动态前缀的 KV + 尾段缓存的 KV。
  3. 因为尾段 KV 是预先生成好的,它内部不参与这次迭代的刷新,只承担被查询的角色。
  4. 解码步进时,新生成的 Token(属于尾段之后的扩展区)会持续追加新 KV,同时也可以继续引用尾段 KV 进行注意力的计算。

从实现上看,这一步就是把前向传播里的 KV 列表从一段变成两段拼接。关键是注意力 Mask 要重新构造:动态前缀内部是因果掩码,动态前缀和尾段之间是完全可见的,但需要注意不能让处于空白位置区间(如果存在的话)的 Token 参与任何注意力计算,否则会引入噪声。我当时在这个 Mask 构造上花了最长时间,反复出现生成质量滑坡,最后定位到是空白区间的 Attention 掩码设定错误。

2.4 过期、淘汰与并发写入的处理

尾段缓存不可能是无限驻留的。我在生产环境里主要做了三层管理:

第一层,显存水位控制:给尾段缓存池设定一个上限,比如占可用显存的 20%。超限后按 LRU 淘汰,优先淘汰引用计数为 0 的块。第二层,一致性版本控制:模型权重更新、LoRA 切换、词汇表扩容,任何一个变更都会让所有尾段 KV 失效,需要一个单调递增的 generation 字段,主键里带上它。第三层,并发快照管理:两个请求可能同时命中同一个尾段块并同时读取,这个并行读是安全的;但如果一个请求在解码期间,另一个请求触发该块的失效回收,就会产生悬垂指针。我的做法是为每个尾段块维护读写锁,让"读取 KV"和"标记失效"互斥,宁可让少数请求重新预填充,也绝不带病共享。

管理维度设计选择原因
主键哈希 + Token 序列双层校验降低误命中,保证状态一致性
存储每层分离 K/V,紧凑连续段支持 concat 快速拼接
显存LRU 淘汰 + 引用计数控制常驻显存成本
一致性generation 字段 + 读写锁权重更新安全 + 并发读安全
掩码动态前缀可视 + 空白跳过保证注意力计算不带噪声

3. 实战评测:什么样的 Prompt 结构最适合后缀复用

3.1 模板场景一:System Prompt 固定在尾部

先说我测试里收益最明显的一类:把 System Prompt 和工具说明整体放到用户输入之后。

举一个具体例子:线上某个 Agent 场景,用户请求 80 Token,System Prompt 加工具描述一共 1200 Token。原始调用方式是 用户Token 在前,System Prompt 在后(有些应用为了让模型优先遵循末尾指令,故意这么排版)。这时候前缀缓存完全帮不上忙,因为每个请求的开头都是不同的用户输入。

我把 System Prompt 拆成固定尾段,锚点位置设为 1024,做了后缀缓存后,测试数字长这样:第一次请求需要完整预填充 1280 Token,耗时可作为基线;后续请求的动态前缀每次只有 80 Token 需要预填充,尾段 1200 Token 全部命中缓存,预填充时间直接降到了原来的 6% 到 15% 之间,TTFT(首 Token 延迟)从 480ms 降到 70ms 左右。多个并发请求同时进来时,只要共享同一个尾段块,收益还会叠加。

3.2 模板场景二:多轮工具调用中不变的调用约定

第二个收益场景来自工具调用类 Agent。这类应用有个典型痛点:每一轮工具调用,都要在请求里带上完整的历史消息、角色标记、各个工具的参数说明。如果历史很长,这些"组装模板"部分几乎完全一致,只有中间的某几轮工具结果在变。

我把工具说明、输出格式约束、角色标记这类文本固化到尾段后,连续测试五轮工具调用,第三轮开始缓存命中率基本稳定在 80% 以上。解码阶段因为尾段的 KV 始终常驻,也省去了每轮都要重新读一遍这套模板的时间。整体延迟从 900ms 压到 500ms 上下,吞吐提升接近一倍。

这个场景能成立的关键是:模板文本必须稳定出现在同一个位置区间。如果你把模板放在动态历史和最新用户输入之间,它的绝对位置会随历史长度变化,尾段缓存就无法命中。这里就需要对 Prompt 结构做固定化改造。

3.3 模板场景三:流式生成与"回溯再生成"操作

还有一个容易被忽略的场景:流式输出时的回溯重试。用户在对话框里触发重新生成,很多实现会把之前的完整上下文再发一次,只是修改生成参数。这种情况下,如果生成上下文里有一段固定的"指令尾",比如"请用 Markdown 输出,不要解释过程",那么重试请求可以直接复用这个尾段,省掉重新预填充的等待。

我测试中,这种场景下的命中率甚至能到 95% 以上。如果你做了一个"重新生成"按钮,把生成链路和后缀缓存打通,用户体验会非常顺滑,因为首 Token 延迟几乎无感。

3.4 收益不明显甚至变慢的场景

不是所有场景都适合后缀缓存复用。我踩过的反例有两类。

第一类是 Prompt 动态性太高,尾段长度占比太小。比如用户输入占据 90% 的 Token,固定尾段只有 50 Token,这时缓存命中的收益覆盖不了额外的主键检索和 Mask 构造开销,整体反而可能慢 5% 到 8%。

第二类是模型输出频繁触发上下文裁剪。比如上下文窗口 4096,每次请求的尾段如果被挤掉,或者动态前缀太长,导致锚点位置被迫后移,那么之前的固定尾段 KV 就作废了。这种情况下缓存命中率断崖式下降,还会因为频繁重建尾段造成显存碎片。

场景固定尾段占比缓存命中收益我的评估
System Prompt 固定尾部80% 以上预填充时间降 85% 以上强烈推荐
工具调用多轮约定60% 左右延迟降 40% 左右推荐,需要模板稳定
流式回溯再生成50% 以下首 Token 延迟大幅下降建议叠加
高频短对话低于 20%几乎无收益不推荐

4. 踩过的坑与排查链路:后缀复用不是简单"换个 key"

4.1 坑一:Key 哈希碰撞导致的错误状态复用

第一次上线时,我用 32 位哈希做缓存主键,测试单条请求正常,但线上跑到一定规模后,偶发地出现响应质量突然下降、答非所问。一开始以为是模型服务抽风,后来查日志发现,两次完全不同 Token 序列的后缀,因为 32 位哈希碰撞,被映射到同一个缓存块,直接读错了 KV。

修复方案是双层校验:主键检索只负责定位候选块,取出来之后必须对候选块的 Token 序列做完整比对,完全一致才允许复用。如果发现不一致,就丢掉这次缓存命中,重新预填充并回写新键。成本极低,但能挡住绝大多数碰撞误用。

4.2 坑二:位置编码错位导致的生成质量跳水

我在实验环境里第一次把后缀缓存接入动态前缀时,遇到一个非常诡异的现象:刚接入时一切正常,几轮对话后模型开始胡言乱语,而且错误呈现出周期性——似乎和缓存块的复用次数强相关。

排查链路拉得很长。先怀疑缓存里的值被污染,于是把缓存块逐层 dump 出来,和前向计算逐比特比对,结果完全一致。再怀疑动态前缀的 RoPE 处理有问题,但在没有命中缓存的情况下,同样的动态前缀表现得非常稳定。后来我把注意力集中到"位置编排"上,才发现症结:我把尾段的锚点位置固定成 1024 后,动态前缀超过 1024 Token 时,代码没有做溢位处理,后续 Token 的位置编号继续往上走,但尾段的 RoPE 旋转因子仍然按 1024 计算,导致同样的 Token 在缓存里和在实际计算时位置含义不一致。

这个坑的本质是:位置编码不是真正的"相对"自由,它需要你在拼接时主动维护一个全局一致的位置表。我的最终处理方案是:动态前缀允许占用 0..1023,一旦前缀长度超过这个区间,整个请求直接绕过后缀缓存,走常规预填充路径。虽然偶发性失去缓存收益,但至少保证了输出的确定性。

4.3 坑三:缓存状态被并发更新覆盖

后缀缓存是共享状态,所以在多请求并发场景下,一个请求正在解码读取尾段 KV,另一个请求因为尾段发生了某种变化(比如用户换了 System Prompt 版本)而触发缓存块失效覆盖,就会导致前一个请求读到一半数据变成另一个版本的 KV。这不是哈希碰撞,不是位置错位,而是典型的并发读写未加锁。

排查这个问题的症状很直接:稳定性统计里,请求成功率偶发下降,但模型和硬件监控全都正常。加了读写锁之后,问题消失。这里也建议:缓存块一旦被标记失效,不要立刻释放显存,而是放进一个"待回收"列表,等所有正在使用它的请求完成解码后再统一回收。毕竟训练了一整个 KV 块的成本比等几个请求结束高得多。

4.4 一次疑似命中但不生效的排查过程

有一次测试,缓存命中率统计是 100%,但端到端的耗时没有下降。排查过程如下:

  1. 先确认命中逻辑是否真的走了缓存复用分支——看日志,发现命中了,返回的 KV 块指针也正确。
  2. 再确认拼接后的计算是否有跳过缓存部分——看到前向函数里还是把整段序列重新做了 prefill,原因是拼接实现里没有真正"借用"缓存 K/V,而是仍然将尾段 Token 作为输入重新过了一遍网络。
  3. 最后定位:程序确实把缓存块查出来了,但是查出来后没有替换掉尾段 Token 的 embedding 输入路径,等同于缓存白查了。

这告诉团队一件事:光有缓存检索和命中还不够,你必须在运算图上明确地告诉计算引擎"这一段不要重算,直接引用块内张量"。排查这种问题要有链路追踪意识,从读取数据的函数打点开始,一级一级确认数据流走到了哪里。

5. 工程选型与整体收益:什么时候上后缀缓存复用

5.1 自研还是依赖框架:看你的请求模板有多稳定

目前开源推理框架里,前缀缓存的生态成熟度远高于后缀缓存,很多框架已经内置了 prefix sharing。后缀缓存要么自己改推理内核,要么基于底层引擎做二次开发。我的建议是:先评估请求模板的稳定性。

如果你们的请求结构混乱、固定尾段经常变,不要强上后缀复用。这不是一个"加个配置项"就能解决的优化,它对请求结构、缓存生命周期、推理内核改造都有要求。我自己是在模板稳定率达到 95% 以上的场景才动手推的。如果只是想要一个轻量版本,可以先考虑把动态前缀和固定尾段拆成两个独立请求,让尾段在解码服务里常驻,动态前缀每次单独计算后拼接——这种"伪后缀缓存"不用改内核,收益也能拿一半。

5.2 链路改造点:Token 对齐、桶、指标

真要做完整版本,改造点集中在几处:

  • Token 对齐:动态前缀的 Token 化结果必须稳定,否则锚点缓存会失效。前缀的结束符、尾段的开始符,都要固定成同一种 Token。
  • 桶化(Bucketing):因为尾段 KV 块通常是按长度存储的,尾段 Token 数不能频繁变化。合理的做法是允许一组固定长度档位,比如 256、512、1024、2048,每个档位对应一个缓存池。
  • 指标体系:监控项要覆盖缓存命中率、命中 KV 占比、缓存块淘汰数、等待锁的时长、误命中回退次数。这五个指标缺一不可,否则你无法判断上线后到底有没有效果。

5.3 和前缀缓存合用的整体架构

后缀缓存并不排斥前缀缓存。实际上,一个请求可以这样组织:开头是大量固定历史消息,命中前缀缓存;中段是动态用户输入;尾部是固定的指令集,命中后缀缓存。两个缓存同时作用,预填充计算量可以压到极低。我实测过一个长会话场景,前缀缓存命中 60% 的 Token,后缀缓存再命中 20%,全链路只用算 20% 的动态 Token。

场景不同,命中的代码路径也不同。我最后实现的架构里,缓存服务抽象成两层:一层是面向请求开头的 prefix store,一层是面向请求尾部的 suffix store。两层都可以独立替换、独立失效,互相之间的通信只发生在最终拼接 KV 的时候。这样后续换模型或者调整模板,风险都控制在单侧。

5.4 我的实际经验总结

做这件事之前,我也纠结过值不值得。一个很简单的算账方式:一个请求 80% 的 Token 是重复的,如果单次预填充耗时是 500ms,并发请求数是 20,那么每秒钟花在重复预填充上的算力,折算成 GPU 时间是非常可观的。后缀缓存复用本质上是把这部分算力从"每次重复劳动"变成"一次性投入 + 低成本复用"。你只需要保证固定尾段模板在一段时间内基本稳定,就能拿到立竿见影的收益。

最后再分享一个细节:上线初期货架期拉长一点,先只对灰度流量开启命中优化,同时把"未命中回退到完整预填充"的比例打点进监控。我一开始掉以轻心,直接全量开放,结果一个 LoRA 版本切换的线上误操作导致整批缓存作废,服务大面积回退到全量预填充,瞬间把 GPU 池打满。这让我养成了一个习惯:任何缓存类优化,都必须预留一个秒级失效的开关,而且要确保失效动作本身不引发新的雪崩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询