资讯动态

后缀缓存复用:破解LLM推理中长上下文重复预填充难题

发布时间:2026/10/22 11:57:14 来源:尧图企业网站定制
从一次重复的预填充说起长上下文请求里藏着大量白算做 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 命中后如何拼接自回归解码假设我们命中了一个尾段状态块动态前缀也刚完成预填充接下来的解码过程可以抽象成几个步骤动态前缀部分0..P 之前得到自己的 KV 序列。在 attention 计算时每条 query 要能够看到两段 KV动态前缀的 KV 尾段缓存的 KV。因为尾段 KV 是预先生成好的它内部不参与这次迭代的刷新只承担被查询的角色。解码步进时新生成的 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 TokenSystem 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. 踩过的坑与排查链路后缀复用不是简单换个 key4.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%但端到端的耗时没有下降。排查过程如下先确认命中逻辑是否真的走了缓存复用分支——看日志发现命中了返回的 KV 块指针也正确。再确认拼接后的计算是否有跳过缓存部分——看到前向函数里还是把整段序列重新做了 prefill原因是拼接实现里没有真正借用缓存 K/V而是仍然将尾段 Token 作为输入重新过了一遍网络。最后定位程序确实把缓存块查出来了但是查出来后没有替换掉尾段 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 池打满。这让我养成了一个习惯任何缓存类优化都必须预留一个秒级失效的开关而且要确保失效动作本身不引发新的雪崩。

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

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

免费获取报价 →
↑