一开始让我真正把这俩概念搞明白的,是一次长文本问答场景的排查。当时服务端显存看着没满,但每个新用户带着一段固定角色设定进来,GPU 都会卡上几秒。打开推理日志才发现,问题不在解码那句回答,而在前面那几百个 token 被白白重复算了好几遍。后来我把重复的提示前缀改成可命中,再配合模型服务的 KV Cache 复用,首字延迟立刻降了一个量级。这文章我就顺着这个执行过程,把 KV Cache 和 Prompt Cache 的区别、显存开销、实际部署参数一次性讲透,适合正在做大模型推理部署、或者准备优化服务时延的朋友参考。
1. 先看一次普通调用:预填充与解码
1.1 预填充阶段到底在算什么
一个请求进到大模型里,不是直接就开始“生成回答”。在输出第一个 token 之前,模型要先处理你输入的完整提示词,这个阶段叫预填充,也常叫 prefilling。它会把提示词里所有 token 并行走一遍 Transformer 网络,算出每一层的注意力结果和中间状态,最后预测出第一个 token。
预填充的计算量大致随输入长度线性增长,但因为它是并行处理的,GPU 在算力上能吃得消。真正容易被忽略的是:输入提示词越长,这个阶段占用的时间越久。比如一个提示词有 2000 个 token,那首 token 之前的耗时通常比后续生成 500 个词还要高。很多人以为解码慢,实际首字延迟老下不来,瓶颈都在这个预填充阶段。
那为什么预填充要单独强调?因为它在每次请求里都会被触发。如果每个用户进来都带同一个超长系统提示,那这部分计算对每个用户来说都是重复的。这部分冗余,就是 KV Cache 和 Prompt Cache 能解决的核心问题。
1.2 解码为什么要“一个个蹦字”
第一个 token 出来后,模型进入解码阶段。解码是一个自回归过程,意思是模型根据“已经生成的所有 token”来预测下一个 token。这导致一个天然约束:生成第 100 个 token 时,模型需要拿当前状态去和前 99 个 token 做注意力计算。
如果没有缓存,每生成一个 token 都得把前 100 个 token 重新完整算一遍注意力,下一次 101,再下一次 102。这样算下来,序列越长,单步耗时越大,整体复杂度是平方级的。而大模型输出动辄几百上千 token,这个重复计算绝对跑不掉。
有了 KV Cache 之后,之前 token 的 Key 和 Value 会存在显存里。生成第 100 个 token 时,模型只需要算当前这个 token 的 Query,然后拿它去和缓存里已有的 Key、Value 做注意力。也就是说,每一步少掉了对历史 token 的重复计算,单步耗时基本稳定在“只算一个 token”的水平上。这也是为什么 KV Cache 几乎是所有主流推理框架的标配,而不是一种可选的锦上添花。
2. KV Cache 的本质与显存账本
2.1 为什么缓存的是 K、V,而不是完整注意力
如果你去读注意力机制的公式,会发现每一步其实是在计算 Query 和 Key 的点积,得到相关性分数,再用这个分数去加权 Value。问题来了:新 token 要和所有历史 token 做交互,历史侧的 Key 和 Value 是恒定不变的,不管新 token 是什么,它们都不需要重新计算。
那为什么不缓存注意力分数或者完整的向量?因为新 token 进来后,会改变注意力的分母和整体分布,旧分数无法直接复用。更关键的是:模型最终需要的是一段“聚合了历史信息的上下文”,而这些历史信息已经全部压缩在 K 和 V 里了。所以 KV Cache 是一种很有针对性的取舍:留下每层的历史 Key 和 Value,新的 Query 每步现算,历史侧不再重复穿越整个网络。
实际工程中,Cache 的对象不仅包括 Key 和 Value 本身,还包括位置编码等辅助数据。不同框架处理方式有细微差别,但核心都是同一个思路:把已经算过的历史状态存下来,避免每次解码时“重新读一遍全文”。
2.2 显存估算:一条长会话会吃掉多少缓存
KV Cache 最让部署头疼的就是显存占用。它不像模型权重那样固定不变,而是随着上下文长度和并发请求数动态增长。估算公式可以从缓存的实际内容来推导。
一条请求里 KV Cache 的大致字节数可以这样算:
缓存字节数 = 2 * 层数 * batch_size * 当前序列长度 * kv_heads * head_dim * 数据类型字节数
这个公式里的 2,是因为 K 和 V 各存一份。当前序列长度就是已经处理过的 token 总数。kv_heads 乘以 head_dim 表示每个 token 在每层里实际缓存的 Key 或 Value 的元素个数。
我拿一个常见的 7B 级别模型来算笔账。假设它采用 GQA,4 个 KV 头,28 层 Transformer,head_dim 为 128,用 FP16 推理。当上下文长度到 16K,也就是大约 16000 个 token 时:
2 × 28 × 1 × 16384 × 4 × 128 × 2 字节 ≈ 0.94GB
也就是说,光这一条请求在上下文中段时,KV Cache 就能占接近 1GB 的显存。这在显存规划里是个不小的数字,尤其当并发请求多了以后,它不是线性增加,甚至会因为调度不均衡产生碎片。
如果换成一个没有做 GQA 的旧结构模型,比如每层 32 个 KV 头且 head_dim 不变,那同样的 16K 上下文就会膨胀到约 8GB,这对单卡部署几乎是灾难。所以 KV Cache 降不下来时,即使模型权重只有 14GB,整个显存也会因为长上下文迅速见底。这也解释了为什么现在新模型的 KV 头都设计得很小,GQA 几乎是标配。
2.3 KV Cache 引发的工程改动:从连续分配变成 PagedAttention
KV Cache 是动态增长的。比如一个对话先回答到第 50 个 token,又继续生成了 20 个,缓存就需要扩容。简单的推理框架会为每个序列预分配一整块连续显存,但这种方式很浪费,因为你不知道最终会生成多长,预留太大就会空占显存,预留太小又不够用。更重要的是,多个请求并发时,容易产生大量无法利用的显存碎片。
这也是工业级推理框架和朴素实现拉开差距的地方。vLLM 引入了 PagedAttention,把 KV Cache 拆成固定大小的物理块,逻辑上连续的序列,物理上可以分散在显存不同位置。就像操作系统分页管理内存一样,只有真正需要缓存新 token 的时候才去申请新的物理块。
这种设计还带来一个额外好处:多个请求如果拥有相同前缀,物理块可以被共享,不用重复分配和计算。这就是工程里常见的前缀缓存机制,也是后面要说的 Prompt Cache 能够落地的底层支撑。
3. Prompt Cache:把常用提示拆成可复用模块
3.1 前缀缓存与模块化复用
KV Cache 天然就能存历史 token,但它默认只服务于同一条请求。如果两条请求拥有完全相同的开头,比如都有一段固定的系统提示,那服务端其实没有必要为第二条请求重新算一遍这段前缀。把公共前缀的 KV Cache 复用到新请求上,就是所谓前缀缓存。很多推理框架现在默认带着这个能力。
但注意这里的前缀,指的是 token 层面完全一致的前缀,顺序不能变,中间不能插任何动态内容。实际业务中,提示往往不是整齐的:你也许希望复用角色定义、工具说明、示例对话,但后面的具体问题每次不同。前缀缓存只能命中整段完全相同的开头,一旦开头有变化,后面哪怕非常相似,全部缓存都失效。
Prompt Cache 的核心思路更进了一步:它不把提示词当作一段连续的字符串,而是拆成结构化的模块,比如角色定义模块、资料模块、任务说明模块、问题模块。只要各个模块的内容在多次请求间保持一致,它们各自的 KV 结果可以单独缓存,最后按请求顺序拼起来。这样即使问题每次不同,前面的角色模块和资料模块依然能命中,不用全部从头算。
3.2 从可验证声明理解缓存安全边界
Prompt Cache 听上去很理想,但一个关键问题是:怎么保证一个模块缓存真的对应提示里的那一段?如果服务端只凭用户说“我要命中某个缓存”就直接返回,而没有检查缓存和当前请求是否匹配,就会出现严重错误,甚至被恶意构造的提示词利用。
所以论文和工程实现里都强调“可验证”三个字。提示词中的模块需要由服务端能识别的结构承载,比如声明式的标签或者固定格式。当新请求进来,系统要校验当前提示词中各模块的内容、顺序、位置是否符合缓存记录的版本,全部对应上了,才允许复用对应缓存。任何不匹配模块都会被视作新内容重新计算,或者直接拒掉。
对我个人来说,这部分比缓存本身更重要。因为缓存了错误内容的 KV Cache,不会像文本匹配那样报格式错误,它表现出的症状往往是输出莫名混乱,而且很难排查。实际产品里,“这个模块可以被安全复用吗”这类校验逻辑,绝不能省。
3.3 KV Cache 与 Prompt Cache 的差异对比
标题里这两个概念放在一起时,很多人会混淆。其实它们的关系不是对立,而是不同层面的缓存策略。KV Cache 发生在模型执行层面,只关心当前 token 的 Key、Value 是否算过;Prompt Cache 发生在请求调度和提示词结构层面,关心的是不同请求之间有没有重复计算的任务性内容。
| 对比维度 | KV Cache | Prompt Cache(含前缀缓存) |
|---|---|---|
| 缓存粒度和时机 | token 粒度,解码过程中自动写入 | 模块或整段提示粒度,预填充前解析判断 |
| 是否需要跨请求 | 单请求内部就必须用 | 跨请求才会产生收益 |
| 主要解决耗时阶段 | 解码阶段每步的历史重复计算 | 预填充阶段的对同一段提示重复计算 |
| 实现依赖 | 模型结构和显存管理器 | 提示词结构化 + 缓存管理器 + 命中校验 |
| 不命中的代价 | 单步耗时上升,生成很慢 | 首字延迟提高,GPU 要重复算长前缀 |
实际系统里,Prompt Cache 的复用结果最终还是以 KV Cache 的形式放在显存里。可以简单理解成:KV Cache 是基础能力,Prompt Cache 是让它变得更聪明的上层策略。
4. 实际部署中如何把两类缓存用好
4.1 用 vLLM 开启前缀缓存并观察命中率
如果你已经用 vLLM 做推理服务,那前缀缓存不是一个遥远概念。在较早版本里需要加参数开启,较新版本默认开启。为了一条命令说清楚,我平时测试会用类似这样一条:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --enable-prefix-caching \ --gpu-memory-utilization 0.9 \ --max-model-len 32768如果你的 vLLM 版本较新,--enable-prefix-caching 即使不写也可能默认生效,保留这个参数主要是为了兼容旧版环境。注意 --gpu-memory-utilization 不能设成 1.0,要留一部分余量给推理算子本身的临时显存,否则请求一多很容易 OOM。
开启之后,最直接的验证方式是连续发送两条开头完全相同的请求,观察服务端日志或监控指标里的前缀缓存命中率。我自己在调优时最关注的是 vllm:prefix_cache_hit_rate 这个指标。正常情况下,如果我的提示词前 80% 是固定内容,第二次请求的命中率应该稳定在 0.7 以上;如果日志显示命中率只有 0.2 甚至 0,那就说明提示词在 token 层面并不稳定,后面的调整方向就清晰了。
4.2 Ollama 端侧模型的 KV Cache 量化配置
Ollama 这类轻量工具面对的往往是个人电脑,显存没那么充裕。Ollama 从 0.5.x 版本开始支持 KV Cache 量化,也就是把缓存的 Key 和 Value 从 FP16 压到 int8 甚至 int4。使用前先确认版本:
ollama --version版本没问题后,设置环境变量:
export OLLAMA_KV_CACHE_TYPE=q8_0 ollama serve把缓存精度从 FP16 降到 8bit,显存占用直接减半,在 8GB 显卡上运行 7B 模型时感知非常明显。不过必须提醒一句:量化是有代价的。KV Cache 参与注意力计算,精度损失会直接影响生成结果的连贯性。
我自己测试下来,q8_0 对大多数问答场景几乎没有可感知影响;但如果你的任务是写代码、长文本摘要或者要求输出非常严谨的格式,KV Cache 量化后偶发“语气漂移”和“格式错乱”并不是新鲜事。所以端侧部署如果显存能撑住,我更倾向先不开量化,等确实放不下了再退一步用 q8_0,而不是盲目追求最低精度。
4.3 提示词结构设计对缓存命中率的影响
部署层面全弄好后,最容易被忽略的一块是提示词本身的结构。我发现很多团队用了 vLLM 却拿不到理想命中率,原因不是框架配置不对,而是提示词里固定内容和动态内容的排列有问题。
前缀缓存判定的单位是 token 序列,你写出的文本必须先被 tokenizer 转成相同 token,才能命中。这意味着哪怕只是多了一个空格、把某个字段换成了当前时间,前面一堆内容都可能失配。想让固定内容被复用,就应该把动态数据集中放到提示词的靠后位置。尽量保证整个提示词开头一大段是“每一次请求都完全一致”的内容。
这类模板我习惯写成模块化:
PROMPT_TEMPLATE = "\n\n".join([ "<system>你是一个严谨的技术客服,回答要简洁,不要虚构数据。</system>", "<context>{fixed_docs}</context>", "<history>{conversation_history}</history>", "<question>{user_question}</question>", ])这些标签本身不决定缓存逻辑,它能起作用是因为你人为保证了每个模块之间的文字是固定分隔符。如果以后想对接更完善的 Prompt Cache,这样的结构化布局也方便系统检查模块边界,不至于缓存和计算混在一起拆不开。
5. 遇到过的坑与排查思路
5.1 常见问题速查表
这一类优化遇到的问题不会特别偏门,大多是下面这几种。把它们整理成一张表,方便你对照排查。
| 现象或问题 | 排查方向 | 解决方法 |
|---|---|---|
| 相同前缀请求,命中率始终为 0 | 提示词里是否藏了动态字段,或者存在空格的差异 | 用 tokenizer 打印前后两次请求的前 50 个 token 对比 |
| 显存随对话轮数持续暴涨 | KV Cache 没有按预期释放,或没有做增量管理 | 检查推理框架是否默认开启分页缓存,必要时重启服务并开启 PagedAttention |
| 开启量化后输出偶尔混乱 | KV Cache 精度损失 | 把 OLLAMA_KV_CACHE_TYPE 调回 f16,或换 q8_0 而非 q4 |
| 首字延迟很高但单 token 速度不差 | 预填充阶段重复计算过长提示前缀 | 打开前缀缓存命中率指标,把长固定提示挪到开头 |
| 第二条请求并没有比第一条快 | 权重或者请求未真正命中公共前缀 | 确认请求使用的是同一个服务实例,不同实例缓存不共享 |
| 多用户并发时显存/OOM 频发 | 缓存块预留过多或内存利用率太高 | 调低 --gpu-memory-utilization,给临时算子和调度留空间 |
里面有几次误区挺典型的。比如 CPU 内存充足但 GPU 显存不足时,有些人会去调大上下文长度,结果 KV Cache 跟着暴涨。真实的瓶颈往往是缓存容量而非模型权重,这种时候最该做的是先量化缓存,再考虑模型本身。
5.2 两次印象深刻的排查经历
一次是线上对话系统的首字延迟忽高忽低。刚开始我以为模型权重加载有问题,反复重启服务,现象依然存在。后来抓了两次完全相同的请求,发现第二个请求和第一个请求的首字延迟差了几十倍,这让问题清楚了很多:第一个请求没有命中任何缓存,因为它处理的是一段被业务代码动态插入的时间戳开头。把时间戳挪到提示词最后、系统提示放最前,命中率一下子从接近 0 拉到 0.9,首字延迟的毛刺基本消失。
另一次是端侧测试部署 7B 模型,显卡只有 8GB,权重加载完以后剩余显存只够跑很短上下文。当时没有先检查 KV Cache,而是直接去压上下文长度,结果压到 4K 后模型连基本对话都断断续续。后来才意识到问题不在模型权重的“主存占用”,而在 KV Cache。给 Ollama 开了 q8_0 缓存量化后,显存腾出了约 1/4,“可用的上下文长度”反而比原来更宽裕,表现也明显顺畅。
我自己现在遇到长文本重复调用场景,习惯先不看单次延迟,先检查缓存命中率和重复 token 比例。如果提示词本身只有几十个 token,那 KV Cache 怎么调影响都不大;如果一段提示词有上千 token,并且被大量重复请求共用,那就值得花时间把固定前缀和动态内容拆好。多数时候,延迟下不来的原因不是模型不够强,而是相同的计算已经无意义地跑了很多遍。