Jellow 编辑发布,AI 辅助生成并完成技术核对。 计算示例和实测结果会在文中分别说明。
假设一项问答服务打开了前缀缓存,监控上显示 90% 的请求命中,用户却仍要等很久才看到回答。缓存是不是没起作用?
还不能这么判断。“有多少请求碰到了缓存”没有回答两个更关键的问题:省掉了多少工作,以及这份工作原本占等待时间的多少。
本文沿着这两个问题展开。讨论范围是完整因果注意力模型、可复用的前缀 KV,以及普通自回归生成。滑动窗口、混合注意力和跨节点缓存传输都有额外条件,不能直接照搬这里的块数和成本估计。
命中的是 token 前缀,不是相似的问题
前缀缓存保存已经计算过的 KV。新请求如果使用相同前缀,可以跳过相应的 Prefill。它不会因为两句话意思接近,就把一句话的 KV 交给另一句话使用。vLLM 的 APC 功能说明明确把收益定位在共享前缀的计算复用,而非后续生成过程的直接提速。
判断是否相同,应看 chat template、tokenizer 等处理之后真正送进模型的输入。肉眼看起来一样的正文,如果模板、分隔符或前面的内容不同,最终前缀可能不同。
在 vLLM 的哈希块设计中,一个块的身份还依赖前面的块,并包含必要的额外标识,例如 LoRA、多模态输入或缓存隔离信息;该设计只缓存完整块。官方设计文档给出了这些条件。
由此可以推导一个很实用的排查顺序:先确认输入序列和运行上下文真的可复用,再讨论缓存是否足够大。如果一个会变化的时间戳被拼在提示词最前面,后面几千 token 的稳定说明也不能作为相同前缀直接命中。
对于适合调整的业务模板,可以把稳定说明、固定文档放在前面,把本次问题放在后面。但不要只为命中率重排会改变含义的指令,也不要把不同权限或不同租户的数据混在一个共享范围里。模板改动之后仍要检查回答质量。
完整块还有一个取整问题。假设块长为 16 token,两次请求共享的前缀长 1000 token,那么完整共享块覆盖的是:
floor(1000 / 16) × 16 = 992 token
这里的 16 是示例参数,不是对所有后端默认值的声明。实际可复用量还受块是否仍驻留、运行配置是否兼容等条件影响。另外,vLLM 的 KV 管理代码说明,为获得最后位置的 logits,完整命中时仍需重算;块对齐还可能扩大重算范围。因此不能据此断言 Prefill 耗时必为零。
90% 命中,可能只省了 1.54% 的输入 token
为避免与某个框架的监控命名混淆,先自行定义两个统计口径。第 i 个请求的输入长度为 nᵢ,实际复用长度为 hᵢ:
请求命中率 = hᵢ > 0 的请求数 / 总请求数
token 复用率 = Σhᵢ / Σnᵢ
有些监控指标按块查询次数计数,既不是第一个,也不是第二个。看到 hit_rate 时,先查分子、分母和统计窗口。
构造 100 个请求:
| 请求组 | 数量 | 每次输入长度 | 每次实际复用长度 |
|---|---|---|---|
| 短问答 | 90 | 128 token | 16 token |
| 长文档 | 10 | 8192 token | 0 token |
请求命中率的确为 90%。但总输入有 93,440 token,实际只复用了 1,440 token,token 复用率约为 1.54%。
这并不矛盾。大量短请求各碰到一个小前缀,就足以让“命中的请求数”很好看;真正占输入大头的长请求可能一次都没复用。
如果那 10% 未命中的长请求又恰好最慢,整体 p95 仍可能落在它们当中。缓存帮助了多数请求,也不保证最慢一段用户的体验同步改善。这里应按长度和是否命中分组看延迟分布,不能对几组 p95 做加权平均来拼出整体 p95。
token 复用率也不等于计算节省比例
即使知道复用了多少 token,仍然少了一步:原来每个 token 要做的工作并不完全相同。
对长度为 n 的完整因果注意力,注意力中有效的 query-key 位置对数量是 n(n+1)/2。为了区分随 token 数增长的工作和随位置对增长的工作,构造一个成本模型:
C(n) = a × n + b × n(n+1)/2
a 汇总模型各层中按 token 线性增长的工作,b 表示每个有效注意力位置对的相对成本。它们是简化系数,不是通用硬件常数;矩阵形状、算子分块和 GPU 利用率都会让真实时间偏离这条曲线。
若前 h 个 token 已经缓存,后面还剩 m 个,其中 m=n-h。新 token 仍然需要关注前面的 h 个位置,所以剩余成本应为:
Cremaining = a × m + b × [m × h + m(m+1)/2]
= C(n) - C(h)
不能写成 C(n-h)。那会漏掉后缀对已有前缀的注意力。缓存省掉的是前缀自身的计算,不是从注意力视野中删除这段上下文。
因此,假如缓存了 8192 token 输入的一半,在这个模型里线性部分省掉一半,而注意力位置对只省掉约四分之一。最终时间能省多少,还取决于两部分的比例和执行效率。这也解释了为什么“命中了 50% token”不应直接写成“Prefill 快了一倍”。
下面把前面的 100 请求例子完整算一遍。
requests = [(128, 16)] * 90 + [(8192, 0)] * 10
def cost(n, a=1.0, b=1.0 / 1024):
# 人为选择的成本单位,不是微秒或 GPU FLOPs。
return a * n + b * n * (n + 1) / 2
total_tokens = sum(n for n, h in requests)
reused_tokens = sum(h for n, h in requests)
request_hits = sum(h > 0 for n, h in requests)
uncached_work = sum(cost(n) for n, h in requests)
saved_work = sum(cost(h) for n, h in requests)
print(f"request_hit_pct={100 * request_hits / len(requests):.2f}")
print(f"token_reuse_pct={100 * reused_tokens / total_tokens:.2f}")
print(f"model_work_saved_pct={100 * saved_work / uncached_work:.3f}")
n, h = 8192, 4096
m = n - h
remaining = m + (m * h + m * (m + 1) / 2) / 1024
print("remaining_work:", remaining)
print("incorrect_suffix_only:", cost(m))
assert remaining == cost(n) - cost(h)
assert remaining > cost(m)
运行后,三个比例依次为 90.00%、1.54%、0.344%;后两行工作量为 28674.0 和 12290.0。示例中长输入占据大量注意力工作,却完全未命中,按这个成本模型算出的收益便比 token 复用率还小。
这些数字只验证推导。改变 a、b 或请求长度分布,结论的具体幅度会变;是否值得部署,仍要测真实 Prefill 时间。
首 token 的账里,还有排队
用户感知的 TTFT,是发出请求到收到第一个有效输出之间的时间。为了诊断,可以把关键路径概念性地拆成:
TTFT ≈ 输入处理与网络 + 排队 + 缓存查找/必要传输
+ 剩余 Prefill + 输出头与采样 + 首次回传
真实系统中一些阶段可以重叠,trace 里的字段也可能包含彼此;相加之前要先对齐口径。对 Prefill、Decode 与客户端指标不熟悉,可以结合第一篇的指标说明阅读。
假设一次请求排队 800 ms,Prefill 为 200 ms,其他开销暂记为零。缓存把 Prefill 降到 20 ms,而排队没有变化,那么 TTFT 从 1000 ms 变成 820 ms,只提升约 1.22 倍。
这不意味着缓存最多只有这点作用。如果缓存减少了全站计算压力,排队也可能随之下降。上面的例子特意固定排队,只是为了证明:单请求省下的计算时间,与整个服务最终少等的时间,需要分别测量。
多副本部署还会带来另一个取舍。假设副本 A 有缓存,但预计要排队 600 ms、剩余 Prefill 40 ms;副本 B 没有缓存,只排队 20 ms、完整 Prefill 200 ms。忽略其他开销,A 是 640 ms,B 是 220 ms。此时只按“去有缓存的副本”路由,会选错。
这也是一个假设案例,不是在推荐具体路由算法。它给出的决策条件是:缓存省掉的计算与传输,是否大于额外排队和路由成本。热点前缀的局部命中率再漂亮,也需要放回这个比较中。
怎样做一次不会高估收益的实验
先准备一份固定请求序列,保留真实的输入长度、共享前缀长度和到达顺序。只反复发送同一个长提示词,测到的是近乎理想的复用条件;它可以验证功能,却不能代表生产收益。
用同样的到达负载做三个对照:缓存关闭、缓存开启但从空状态开始、缓存开启且经过符合真实流量的预热。记录预热请求,但与正式窗口分开计时。不要用提前知道未来全部请求的方式构造“完美缓存”,再把结果当成正常服务表现。
每次请求至少关联这些数据:
| 数据 | 要回答的问题 |
|---|---|
| 输入 token 数、实际复用 token 数 | 命中的是短前缀,还是主要计算部分 |
| 副本、模型与模板版本、前缀分组标识 | 是否因为输入或路由变化失去复用 |
| 排队时间、Prefill 时间、客户端 TTFT | 收益出现在哪一段,是否被其他等待抵消 |
| 缓存容量压力、淘汰或重算情况 | 缓存是否在真实混合流量下仍能留住热点 |
| 错误、超时和请求到达率 | 是否通过少接请求或剔除失败美化了结果 |
缓存分组可用内部标识关联,通常不必把完整用户提示词写进性能日志。
当结果不理想时,顺序也很重要。复用 token 本来就少,先检查模板和流量结构;复用多但 Prefill 没降,检查实际是否重算以及成本模型是否适合该架构;Prefill 已明显下降而 TTFT 不动,再看队列、路由、传输和客户端缓冲。
缓存优化最终应该交付一件具体的东西:在相同请求到达条件下,哪些请求的等待下降了多少,哪些仍然没有受益。命中率是解释这个结果的线索,不是结果本身。
技术资料核对日期:2026-09-05。代码在 CPU 上验证统计口径和成本恒等式;文中耗时均为假设示例,未进行 GPU 服务实测。