1.2 讲过:每个 token 的 K/V 向量算出来之后就不再改变。这里要补上关键的第二句——第 i 个 token 的 K/V 只取决于它自己和它前面的 token(因果注意力的定义)。后面来什么内容,完全不影响前面已经算好的 K/V。
这个性质有一个立刻可用的推论:两个请求只要开头的 token 序列完全相同,这段前缀的 KV cache 就是逐位相同的。既然如此,第二个请求何必重算?把第一个请求的这段 KV 留着,直接接上去就行。
这就是 prompt caching(提示词缓存,底层实现常称 prefix caching,前缀缓存)。它跳过的是 prefill 阶段对已缓存部分的全部计算,因此收益出现在两个地方:
为什么这件事值得单独一章?因为 LLM 应用的输入有一个普遍结构:绝大部分内容在多次调用之间是重复的。system prompt、工具定义、few-shot 示例、检索到的文档、多轮对话的历史——真正变化的往往只有末尾那一句用户提问。1.1 的估算题算过一个 6 轮对话:11,800 个输入 token 里有 6,240 个是同一段 system prompt 被重复计费了 6 次。缓存要吃掉的正是这一部分。
整章最重要的一句话:缓存匹配是从头开始的、逐 token 精确的前缀匹配。不是模糊匹配,不是「内容差不多」,也不是「找到中间那段相同的」。一旦第 k 个 token 不一致,从 k 开始直到结尾的全部内容都必须重算——哪怕后面的内容和上次一模一样。
因为 KV 依赖于「它前面的所有 token」,前缀一变,后面所有 token 的 K/V 都随之改变,缓存自然全部失效。这个机制简单得近乎粗暴,但正是它派生出了整章的工程结论。
这是本章标题里「反向约束」的意思——一个底层的性能机制,反过来对上层的提示词工程提出了硬性要求。四条可以直接执行的规则:
① 按「变化频率」从低到高排列。最稳定的(system prompt、工具 schema、few-shot)放最前,会话级的(历史对话、检索文档)放中间,请求级的(用户当前提问)放最后。这是唯一一条真正重要的规则,其余三条都是它的推论。
② 把动态噪声赶出前缀。当前时间、请求 ID、随机排序的列表、用户昵称——这类内容一旦出现在靠前位置,会让缓存永久命中率为零,而且这个 bug 从功能测试上完全看不出来。如果业务确实需要当前时间,把它放到用户提问旁边。
③ 警惕「无害」的改动。给 system prompt 加一个空格、调整 JSON 字段顺序、更新版本号注释——任何一处 token 级变化都会让全体缓存失效。所以 system prompt 应该被当作需要版本管理和变更审批的资产,而不是随手可改的配置。
④ 检索结果的顺序要稳定。RAG 场景中,若把检索到的文档拼在提问之前,而检索结果的顺序在多次调用间不稳定(例如按分数排序但分数有微小抖动),缓存同样会大面积失效。可行的做法是固定排序规则,或干脆把文档放到提问之后。
一个容易踩的坑:缓存不是免费的。写入缓存通常有额外开销(自托管是显存占用,商业 API 常有写入溢价),且缓存有存活时间。因此只有被复用的前缀才划算:一次性的长文档分析、每个用户各不相同的超长上下文,可能写入了就再也没命中,反而更贵。判断标准很简单——这段前缀在缓存存活期内会被读几次?少于一两次就不值得。
早期的前缀缓存需要应用显式声明「这一段是可复用前缀」。SGLang 的 RadixAttention 把它变成了自动机制:用一棵基数树(radix tree)组织所有请求的 KV cache,新请求进来时自动在树上匹配最长公共前缀,命中部分直接复用;配合 LRU 策略做淘汰。
树结构在这里是恰到好处的选择——它天然表达了「多个请求共享同一段前缀,然后各自分叉」的结构,而这正是 LLM 应用请求的典型形态:同一个 system prompt 下分出成千上万个不同的用户提问。
条件:提升幅度高度依赖负载的前缀共享程度——多轮对话、few-shot、同一套 system prompt 服务大量用户的场景收益最大;而每个请求都完全不同的负载则几乎没有收益。这也再次印证了本章的判断标准:缓存的价值等于复用的程度。
值得注意的是它与 1.2 的 PagedAttention 的关系:分页让「多个逻辑块指向同一个物理块」成为可能,RadixAttention 则在其上回答「哪些块应该被共享、什么时候淘汰」。底层机制解锁能力,上层策略决定收益。
显式 vs 自动:缓存断点有两种给法——在请求里手动标记断点(对可缓存段的边界有精确控制),或由服务自动放在最新的可缓存位置(几乎不用改代码,但控制力弱)。同一家服务常常两种都支持,且因模型而异,接入前按所用模型确认,这决定了要不要改调用代码。
三个必查参数:① 最小可缓存长度——短于某个 token 数的前缀通常不予缓存;② 存活时间 TTL——从几分钟到数小时不等:有的默认 5 分钟、可付费延长到 1 小时,有的按模型或组织的数据策略决定默认值、最长可到 24 小时;请求间隔超过 TTL 就命中不了;③ 计费方式——缓存写入、缓存读取、普通输入三者单价各不相同,且各家差异很大。价格属于工具层,请以官方文档为准,本书不给具体数字。
自托管:vLLM 当前版本对支持的模型默认开启自动前缀缓存(需要关闭时用 --no-enable-prefix-caching);SGLang 默认启用 RadixAttention。自托管没有「缓存计费」,代价是显存——缓存占用的显存会挤压可用于并发的 KV 预算,需在 2.4 的容量模型里一并考虑。
怎么验证真的命中了:不要假设它生效了。商业 API 一般在响应里返回缓存读/写的 token 数,把它接进监控,做成「缓存命中率」指标;自托管则看框架暴露的 prefix cache 命中率。一次不慎的 system prompt 改动就可能让命中率归零,而账单要到月底才会告诉你。
不开缓存:每次输入 3100 token,按单价 1 计,日成本 = 50万 × 3100 = 15.5 亿单位。
开缓存(假设命中率接近 100%,忽略写入开销):命中部分 3000 token 按 0.1 计价 = 300;未命中 100 token 按 1 计价 = 100。每次 = 400 单位,日成本 = 50万 × 400 = 2 亿单位。
节省 ≈ 87%。同时 TTFT 也会明显下降,因为 3000 token 的 prefill 被跳过了。
但要检查两个前提:① 调用频率必须高于缓存 TTL——若 TTL 是 5 分钟而调用稀疏,大量请求会落在缓存过期后,实际命中率远低于 100%,收益按比例缩水;② 那 3000 token 必须真的一字不变——如果里面嵌了当前日期,命中率是 0 而不是 100%。
这道题的真正考点:缓存收益 = 可缓存前缀占比 × 命中率 × 折扣力度。三个因子任何一个塌掉,收益都会消失,而只有第一个因子是设计时可见的。
先量再猜:第一步永远是确认命中率指标(响应里的缓存读取 token 数)。若命中率本身就接近 0,往下查;若命中率很高但账单没变,说明可缓存部分占比太小,是设计问题而非配置问题。
命中率为 0 的常见原因,按发生频率排序:
① 前缀里有动态内容——最常见。当前时间、请求 ID、用户名、随机排序的检索结果出现在靠前位置。检查方法:把连续两次请求的完整 prompt 逐字符 diff,第一个差异点之后的全部内容都是没命中的。
② 顺序放反了——用户提问放在了 system prompt 前面,或每次拼接顺序不稳定。
③ 没达到最小可缓存长度——前缀太短,服务端根本不缓存。
④ TTL 过期——调用间隔大于缓存存活时间。这类情况命中率会呈现明显的时间相关性(高峰期高、低谷期低)。
⑤ 没真正开启——显式缓存机制需要在请求中标记,只改了配置没改调用代码。
因为缓存复用的是已经算好的 KV cache,它替代的是 prefill 阶段的计算。TTFT 主要由 prefill 时间构成,跳过大部分 prefill,TTFT 自然下降。
而 TPOT 由 decode 阶段决定:每生成一个新 token,都要完整读一遍模型权重、并对全部 KV cache 做注意力计算。这部分工作与前缀是算出来的还是缓存来的毫无关系——缓存省的是「算 K/V」,不是「用 K/V」。
甚至要注意反向效应:命中缓存意味着上下文里有一段很长的前缀,decode 每一步都要对它做注意力计算,所以长前缀下的 TPOT 反而略高于短前缀。缓存让你「更便宜地拥有长上下文」,但长上下文本身的解码成本依然存在。
问题一:最易变的内容被放在了最前面。检索结果因查询而异,几乎每次都不同——这直接导致命中率恒为 0,后面的 system prompt 和对话历史(本来是最该被缓存的部分)全部陪葬。这是整个设计里唯一致命的错误。
问题二:即使把文档后移,排序抖动仍是隐患。相关性分数相近的文档,顺序可能在多次检索间发生变化;若文档段本身希望被缓存(比如同一批文档服务多轮追问),不稳定的排序会让它失效。修法是按文档 ID 等稳定键做二次排序。
问题三:对话历史放在用户提问之前是对的,但要注意历史的截断策略。如果采用「保留最近 N 轮」的滑动窗口,那么每多一轮,历史的开头就会被切掉一段,前缀随之改变、缓存全部失效。更缓存友好的做法是从头累积、只在超限时做一次性压缩(见 4.3),把失效频率从「每轮」降到「偶尔」。
建议结构:[system prompt] + [工具定义] + [对话历史(累积式)] + [检索文档] + [用户提问]——严格按变化频率从低到高排列。