做 LLM 推理优化的这些年,有个矛盾越来越尖锐:模型越训越大,上下文越给越长,GPU 显存和算力却永远不够用。CLM(Causal Language Modeling,因果语言建模)作为 GPT 这类自回归模型的基本训练范式,决定了生成时必须从左到右逐个 token 地算;KV Cache 的出现,是把这种重复计算的包袱变成了一份可复用的中间账本;而后缀缓存复用,就是在账本上继续做文章——不再只认从头开始的前缀,而是把同一段上下文的尾部结果也存下来,让模型在多轮对话、长文档分析、批量推理里真正“自己管好上下文”。
这篇文章不打算讲那些浮在表面的概念,我会直接把 CLM 的因果机制、KV Cache 的显存账本、前缀缓存和后缀缓存复用的关系拆开,再给一套在我实际项目里验证过的配置方法和排障思路。适合正在做推理服务优化、被长上下文延迟和显存折磨的工程同学,也适合想搞清楚缓存复用原理、准备在自建推理方案里落地类似功能的算法同学。
1. CLM 是什么:为什么语言模型只能“往后看”
1.1 因果掩码与双向掩码:一句话的区别
先回到最基础的地方。BERT 那类模型用的是双向注意力,训练时一个句子里的每个 token 都能看到上下文两侧的所有 token,像阅读理解,前后文都能参考。CLM 则完全相反:注意力矩阵的上三角区域会被直接掩掉,位置 i 的 token 只能关注位置 0 到 i 的 token,不能看到任何“未来”的信息。
这个掩码不是一个可选的开关,而是自回归生成的根本约束。如果模型在预测下一个 token 时就能偷看到后面的答案,那它学的就不是语言规律,而是“抄答案”。所以 CLM 的训练目标很纯粹:给定前面的文本,预测下一个最合理的 token。生成的时候,模型一次只产出一个 token,把它拼到序列末尾,再继续预测下一个。
用写文章来类比,CLM 就是一个只能往前写、不能回头改的作家。写第 100 句的时候,他只能参考前面的 99 句,没法先看看第 150 句再决定现在怎么写。这种约束看似笨拙,却保证了生成的每一步都建立在“已有信息”之上,这也是它能稳定产出长文本的原因。
1.2 生成时的“单侧依赖”决定了上下文管理的风格
CLM 在推理阶段被分成两个典型阶段:预填充阶段(prefill)和生成阶段(decode)。预填充阶段把整个 prompt 一次性算一遍,产出每个 token 的隐状态;生成阶段则每步只计算最后一个 token 的注意力。问题在于,生成阶段的每一步都依赖前面所有 token 的信息,如果没有 KV Cache,第 t 步就必须把前 t-1 个 token 的 Key 和 Value 全部重算一遍。
这种“单侧依赖”决定了上下文管理的方式:一切优化都围绕“如何把已经算过的东西留到下一步用”。CLM 不像 Bert 那样可以一次性批处理完整个序列,它的解码是逐 token 串行的,所以中间结果复用的价值格外高。KV Cache 就是被这个特性逼出来的产物。
1.3 CLM 的长处与短板:能推理,但吃不下全部上下文
CLM 的因果机制天然适合生成式任务,但也带来两个明显短板。
第一,计算量随上下文长度指数级恶化。注意力复杂度是 O(n²),上下文翻倍,计算量翻四倍。哪怕有 KV Cache,生成阶段每一步也要把所有历史 token 的 K 和当前 Q 做内积,上下文一长,单步延迟就压不下去。
第二,模型并不会主动区分“重要上下文”和“废话上下文”。所有历史 token 的注意力权重都是模型隐式决定的,系统层面如果不做干预,上下文越长,早期信息被稀释得越厉害。“让模型自己管上下文”这句话的真正含义,其实是通过工程手段——缓存复用、窗口裁剪、摘要压缩——让模型在有限的计算预算内,把最需要的那部分上下文利用好。
很多人一开始以为只要上下文窗口大,模型就能记住所有东西。实际跑过就知道,窗口大只是“能塞进去”,不等于“都能用好”。缓存复用解决的不是理解能力问题,而是重复计算问题:既然同一段文本反复出现,每次都重新算一遍就是纯粹的浪费。
2. KV Cache:CLM 的隐形账本
2.1 没有缓存的时代,每次生成都等于把全文重新读一遍
假设一个没有 KV Cache 的推理服务,用户问了一个 1000 token 的问题,模型要回 500 个 token。生成第 1 个 token 时,它要把 1000 个 prompt token 全部重算;生成第 2 个 token 时,又要把 1001 个 token 重算。到第 500 个 token 时,等于把 1500 个 token 的注意力计算重复了 500 次。
算一笔账。如果单次前向计算 1000 个 token 的注意力需要 10 毫秒,那没有缓存时生成 500 个 token 的总耗时接近 500 × 10 毫秒 = 5 秒,而且还没算模型本身的矩阵乘。有缓存时,生成阶段每个 token 只需要处理 1 个新 token 的矩阵乘和与历史 KV 的注意力计算,通常只需要零点几毫秒。差距是数量级的。
这就是为什么 KV Cache 在工程上不是“可选优化”,而是必需品。它本质上是一份显存中的账本,记录着这个请求到目前为止所有 token 的 Key 和 Value,让后续每一步都不用从头算起。
2.2 Key 和 Value 到底存的是什么
要理解缓存复用,必须搞清楚 KV Cache 里存的是什么。一句“把 Key 和 Value 缓存起来”说起来轻松,落地时你要知道张量的形状。
对于一个有 L 层的 Transformer 模型,每一层都有自注意力头。输入的第 i 个 token 经过权重矩阵映射,会产生一个 Query、一个 Key、一个 Value。注意,只有当前正在生成的 token 需要重新计算 Query;历史 token 的 Query 不再需要,因为它们的注意力已经被算过了。但历史 token 的 Key 和 Value 必须留着,因为后面每个新 token 都要和它们做内积。
在一个典型配置里,KV Cache 的一个 token 一层的存储形状是:2(K 和 V)× 头数 × 每个头的维度。假设头数是 32,每个头维度是 128,那么一个 token 一层就要存 2 × 32 × 128 = 8192 个数值。如果模型有 32 层,一个 token 就是 32 × 8192 = 26 万多个数值。用 FP16 存,大概是 520KB 左右。
你可能觉得 520KB 不算大,但这是 1 个 token。上下文 4096 token 就是 2GB 左右,上下文 32K 就是 16GB 量级。一张常见的 80GB 显存显卡,光 KV Cache 就能吃掉一大半。这个数字直接决定了为什么我们要做缓存复用——不是因为它炫酷,而是因为显存真不够用。
2.3 显存公式:为什么上下文长度翻倍,开销远不止翻倍
我把显存公式简化成这样:KV Cache 显存 ≈ 2 × 层数 × 头数 × 头维度 × 序列长度 × 每数值字节数。
这里有两个容易被忽略的点。一是 KV Cache 的显存是随序列长度线性增长的,但伴随它的注意力计算是平方级增长的。二是批处理会放大问题——同一个请求上下文越长,显存越大;批大小越大,每个请求的 KV Cache 越多。两者叠加,长上下文高并发基本是显存杀手。
实际项目里,很多服务会把“最大上下文长度”定得比较保守,不是模型不支持,而是显存装不下足够的并发请求。缓存复用在这里的价值就体现出来了:如果多个请求共享同一段上下文的 KV,那这段 KV 只需要在显存里存一份,省下的空间可以留给更多并发。
3. 缓存复用:从前缀到后缀,把“算过的”存下来
3.1 前缀缓存的基本思路与局限
前缀缓存是目前主流推理框架都支持的基础能力。思路很简单:如果两个请求的 prompt 开头完全一样,那开头的这些 token 经过模型算出来的 K 和 V 也完全一样,直接复用就行。
典型场景是固定 system prompt 的对话。很多产品会给每个用户都拼一段相同的系统提示词,比如“你是一个乐于助人的助手”。用户问“帮我写封信”,prompt 的前半段固定,后半段不同。开启前缀缓存后,固定部分的 KV 只需算一次,后续请求只需要计算新增的那部分。TTFT(首个 token 延迟)能明显下降。
但前缀缓存的局限也很致命:它只认开头。用户的问题不可能永远排在最后。如果一段上下文中间变了,哪怕只是改了一个字,前缀缓存就只能从头重新计算。多轮对话里,用户中途插入一句“等等,我改一下需求”,前缀就失效了;RAG 场景里,检索到的文档在 prompt 中间,不同 query 可能共享同一段文档,但它们在序列中的位置不同,前缀缓存同样帮不上忙。
3.2 后缀缓存复用:跳出“只能复用前缀”的限制
后缀缓存复用的核心思路,是把缓存的匹配粒度从“完整前缀”细化到“任意连续子串”——尤其是上下文的尾部。它的理论根基在于 CLM 的一个特性:某个 token 的 Key 和 Value 只由该 token 自己的隐状态和模型权重决定,不依赖它左侧的文本内容;真正依赖左侧上下文的是注意力计算时的 query-key 内积。这意味着,两段文本即使它们前面的内容不同,只要它们的文本本身逐字相同,并且在序列中的绝对位置一致,这些 token 对应的 K/V 张量在数值上就完全一致。
这句话是理解后缀缓存的关键,值得再拆一遍。在 CLM 模型里,第 i 个 token 的 Key 是把它自己的隐状态过一个线性变换,这个变换不读它前面 token 的任何信息。所以“算力可以复用”这件事,在 token 级别是天然成立的。工程上要做的,就是把一段连续文本看作一个块,计算它的哈希,然后在所有请求的历史里找有没有相同的块,有就直接把 KV 拼过来,不再重算。
后缀缓存复用这个名字,强调的是对“序列尾部”的复用。它的实际形态往往是一个哈希表加上一个块管理机制:请求进入时,把 prompt 切成若干固定大小的块,从尾部开始向前查哈希;命中块越多,需要真实计算的前缀就越短。有的实现用基数树管理这些块,把多个请求的共享部分组织成一棵树,这样不管是前缀命中还是后缀命中,都能统一管理。
大胆一点说,后缀缓存的终极形态就是“任意连续片段缓存”。它能复用一段文本,不用管它出现在请求的开头、中间还是末尾。实际工程里限制在“后缀”这个范围,主要是为了规避位置编码错位的问题——RoPE 这类相对位置编码会把位置信息混进向量,如果复用的块来自不同绝对位置,拼起来语义会乱。所以工程实现通常要求文本片段在原始序列中的位置和复用位置对齐,这天然就向后缀场景倾斜:新请求的最后一段和某个历史请求的最后一段相同,绝对位置完全一致,可以无缝复用。
3.3 命中率分析:什么场景收益最大
不是所有场景都适合后缀缓存,我把命中率高的场景归纳成四类。
第一类是多轮对话,尤其是用户对同一个问题的追问和确认。用户说“再详细一点”“换个例子”“继续”,这些请求的前缀可能完全不同(因为你上一轮的回答变了),但请求的最后几行 prompt 经常是重复的固定模板,比如“请只输出 markdown 格式”。尾部块的缓存命中率很高。
第二类是批量评测和批量打分。假设你在评测一批模型输出,每个样例的 prompt 都是“请判断下面这段文本的情感”加上不同的文本。固定指令在后半段,而不是开头。这种情况下后缀缓存能把固定指令的 KV 缓存下来,每个新样例只需要计算真正不同的部分。
第三类是 Agent 场景。工具的调用说明、代码执行规则、输出格式要求通常拼在 prompt 的末尾,还是那句话,位置越靠后,越适合后缀缓存。
第四类是 RAG 检索。很多 RAG 应用会把检索到的文档放在用户问题的后面,不同用户问题可能检索到同一篇文档的不同切片。只要文档文本相同、位置对齐,就能命中。
需要说明的是,后缀缓存的收益集中在降低 prefill 计算和 TTFT 上,对生成阶段的 decode 速度没有直接帮助——decode 还是要逐步算。但它能显著提升并发吞吐:同样的显存,因为省掉了重复的 KV 存储和计算,可以服务更多请求。
4. 实操:在推理服务中落地后缀缓存复用
4.1 前置条件与框架选型
先给一个判断框架是否支持后缀缓存的方法:找文档里有没有 cache reuse、block manager、suffix cache、radix cache 这些关键词。目前大多数开源推理框架默认支持前缀缓存,但后缀缓存或任意片段缓存通常是进阶功能,需要显式开启,部分框架以 radix cache 机制实现。
选型上,我建议优先选择原生支持 radix cache 的框架。如果你用的框架只支持前缀缓存,不要直接放弃——在很多场景里,把 prompt 结构重新编排一下,把固定不变的指令移到尾部,能够让前缀缓存也沾点光。但这是治标不治本,真正要根治还是要换具备后缀缓存能力的基础设施。
框架选型之外,还要确认你的模型架构是否支持。GQA(分组查询注意力)和 MHA(多头注意力)都支持 KV 缓存复用;但某些模型使用了非标准的注意力实现或自定义位置编码,可能需要在缓存块的位置对齐逻辑上做额外适配。先用 7B 级别的小模型跑通,再去上大模型,这个顺序能省掉很多调试时间。
4.2 关键参数配置与解释
我在项目里用到的配置项和推荐初始值如下:
| 参数 | 推荐初始值 | 作用与说明 |
|---|---|---|
| cache.enabled | true | 总开关,关闭后等价于纯前缀或无缓存模式 |
| cache.block_size | 16 | 缓存块粒度,token 数。太小哈希开销大,太大命中粒度粗 |
| cache.max_blocks | 视显存而定 | 限制缓存占用的最大块数,防止缓存把显存吃光 |
| cache.policy | lru | 块淘汰策略。长尾请求多的场景建议用 lfu |
| cache.suffix.enabled | true | 开启后缀缓存复用。部分框架这里叫 radix_cache |
| cache.normalize_input | true | 对文本做归一化,去掉多余空格、统一换行,提高命中率 |
| cache.min_match_blocks | 1 | 至少命中几个块才触发复用,太小会导致频繁拼接的开销 |
先说 cache.block_size。这是最值得调的参数。块太小,比如 8 个 token,哈希表会变得很大,而且块之间的拼接次数增加,内存拷贝开销反而吃掉收益;块太大,比如 64 个 token,稍微有一点文本差异就整块失效,命中率断崖式下跌。16 到 32 是我从多个模型上实测下来比较稳妥的区间。
再说 cache.normalize_input。这个开关经常被忽略,但它的收益立竿见影。用户输入里多一个空格、换行符不一致、全角半角混用,都会导致 hash 完全不一样,缓存直接失效。打开归一化之后,命中率能提高十几个百分点。
配置示例用一份类 INI 的配置展示:
[server] max_tokens = 2048 [cache] enabled = true block_size = 16 max_blocks = 4096 policy = lru suffix.enabled = true normalize_input = true min_match_blocks = 1需要注意的是,这只是一份示例配置,具体参数名在不同框架里会有差异。但思路是一致的:开总开关、设块大小、开后缀复用、打开文本归一化,然后逐步调。
4.3 效果评估:TTFT、首个 token 延迟、吞吐量
配置好不好,要用数据说话。我习惯盯四个指标。
TTFT 是最直观的。缓存命中时,prefill 阶段要算的 token 数大幅减少,TTFT 通常能下降到原来的三分之一甚至十分之一。比如原来一个 4000 token 的 prompt 耗时 2 秒,命中八成之后,只需计算 800 token,耗时可能降到 0.5 秒以内。
prefill tokens 是另一个侧面指标。在日志里统计每个请求实际参与预填充的 token 数,缓存命中越多,这个数字越小。它可以帮你量化“省了多少计算”。
吞吐量是更宏观的指标。在并发场景下,因为 KV Cache 显存被释放,服务能同时跑的请求变多了,总吞吐自然会涨。我在一次批量评测任务里,开启后缀缓存后,吞吐提升了接近 40%,不是因为单请求变快了,而是因为显存瓶颈缓解了,batch 能开得更大了。
显存峰值是必须监控的指标。缓存不是免费的,它也要占显存。特别是 cache.max_blocks 设置过大的时候,缓存可能挤占正常推理所需的显存空间,导致 OOM。我的经验是,先把 max_blocks 设置到一个保守值,观察显存使用率,再逐步放大,找到一个“命中率最高且显存不挤兑”的平衡点。
4.4 三种典型场景的配置建议
场景不同,配置侧重点完全不同。
多轮对话场景,我建议把 block_size 调小一点,16 比较合适。对话里重复的往往是短句和固定指令,块太大反而不容易命中。同时把 policy 设为 lru,因为对话的访问模式有明显的热点窗口,最近出现的缓存最有可能被再次访问。
批量评测场景,建议把 block_size 调大到 32,甚至 64。评测任务的 prompt 通常非常规整,重复片段很长,大块能减少拼接开销。policy 用 lfu 更合适,因为评测用例会反复访问同一个模板,热点非常固定。
Agent 场景,建议把 normalize_input 打开,因为 Agent 的 prompt 里经常拼接各种工具返回结果,格式化差异比较大。同时要小心 Agent 的 prompt 模板变化频繁,如果模板每次都重新生成,缓存很难命中。把工具描述、输出格式这一部分固定下来,不要动态拼接,缓存率会好看很多。
5. 常见问题与排查技巧实录
5.1 缓存命中率上不去
这是我被问得最多的一个问题。配置都开了,命中率却只有个位数。大多数情况下,问题出在文本哈希的粒度上。
逐个排查这几项:第一,是否打开了 normalize_input,全角半角、空格、换行符这些“看不见的差异”都会让哈希不一致;第二,prompt 模板是不是每次都在变,比如保存了当前时间戳、随机数,或者乱序拼接了参数;第三,检查框架的日志,看缓存命中的块数是不是为零,如果为零,可以先关掉 min_match_blocks,看看是不是阈值太高导致即使命中也不启用。
有一个真实案例:我遇到过命中率一直不上 20% 的情况,排查了很久发现是 prompt 里有一段 user_id 和 session_id 混在一起,导致每个请求的文本都不同。把这些动态字段从共享模板里拆出来,单独拼到尾部,命中率立刻从 15% 涨到 75%。
5.2 显存被缓存撑爆
开启缓存后服务 OOM,很多人的第一反应是把 max_blocks 调小,这没错,但要注意不能一刀切。
显存紧张的本质是缓存和推理在抢空间。推荐的排查顺序是:先用小并发压测,观测不同的缓存块数量下显存占用,画出一条曲线;然后根据这条曲线反推 max_blocks 的安全上限。同时要确认缓存淘汰策略是否正常运行——如果缓存块一致满了,每次进来都要淘汰一个旧块,新块又立刻补进来,整个缓存就成了空转的 FIFO 队列,既占显存又无收益。
还有一点细节:有些框架允许设置“缓存块的预留空间”与“活跃请求的 KV 空间”分离。冲突激烈时,可以把缓存空间和推理空间映射到不同优先级,优先保证活跃请求,避免因为缓存占用导致正在生成的请求被 OOM 打断。
5.3 多轮对话中缓存失效的特殊场景
多轮对话里有个很隐蔽的坑:服务端在拼接历史消息时,如果对超长历史做了截断,那同一段尾部的文本在不同请求里的绝对位置可能不同。前面讲过,CLM 的 KV 张量与绝对位置相关,位置一变,KV 就变了,缓存自然就失效。
这个问题在“随历史增长动态调整截断窗口”的系统里尤其常见。第一次请求时用户问题在位置 800 到 1000,第二次请求由于历史更长,同一段问题被顶到了位置 1500 到 1700,哈希文本相同,但位置不同,缓存块无法匹配。
我给的应对方案是:尽量保持上下文的构建方式一致。如果你的系统必须截断历史,尽量从开头截,不要从中间截,保证最近的对话尾部总是留在相同的位置上。这不仅能提高缓存命中率,对模型的理解效果也有帮助。
5.4 踩过几次坑之后的调优顺序
把踩过的坑归拢一下,我建议遇到缓存问题按这个顺序排查:
先看命中率数据,再判断是没命中,还是命中了没复用。没命中,重点查文本归一化和模板动态字段;命中了但 TTFT 没降,重点查缓存块的拼接开销和 prefill 比例;显存爆炸,重点查 max_blocks 和淘汰策略。不要一上来就调参数,先把日志里的缓存命中日志打开。
我自己一般是分三步走:第一步,用固定模板的评测数据验证机制是否正常,确认命中率能到 90% 以上;第二步,换真实业务流量,统计带噪音的命中率,通常 50% 到 70% 是比较健康的数字;第三步,压测并发场景下的显存和吞吐,找到收益和成本的边界。
最后还有一个非常值得做的优化:对客户端或上游调用方也做文本对齐。市面上很多服务端会做 prompt 的模板化组装,但上游业务方也在动态拼 prompt。双方各拼各的,很容易出现同样的语义内容却有微妙差异。如果把 prompt 的共用部分在业务层统一成固定的模板字符串,缓存命中率会有质的提升。这个优化优先级甚至比服务端调参还高,而且成本极低。