大模型的公共前缀:同一段 Prompt 每轮重算,你的账单因此差了 50 倍
大模型的公共前缀:同一段 Prompt 每轮重算,你的账单因此差了 50 倍
一段 4000 token 的系统提示词,跑一万次请求,不优化要 84 元,优化后 5.6 元。
代码一个字没改,只是把"会变的东西"挪到了后面。

文章目录
- 大模型的公共前缀:同一段 Prompt 每轮重算,你的账单因此差了 50 倍
- 一、痛点场景:三张真实的账单,都指向同一件事
- 二、它到底是什么:一句话、一个大白话、一个生活案例
- 三、为什么值得用:算三本账
- 四、它是怎么演进过来的:从"省显存"到"省一切"
- 五、怎么用:从改 Prompt 到改架构,五层落地
- 六、竞品对比:它和谁在抢同一笔预算
- 七、常用场景:哪些业务天生适合
- 八、面试高频题:12 个问题,从浅到深
- 九、收尾:一页纸的行动清单
一、痛点场景:三张真实的账单,都指向同一件事
场景一:Agent 每走一步,就把前面几万字重读一遍
你写了个代码助手,接的是某家 API。第一轮它读了 12 个文件、3 万 token 的上下文,回答得很漂亮。第二轮你只是补了一句"把那个函数改成异步的",账单上却又多了一次 3 万 token 的全价输入。
因为 Agent 的工作方式是:每调用一次工具,就把系统提示、工具说明、之前每一轮的回复和工具结果,整个重新发给模型。这些内容里,绝大部分上一轮早就发过了,一个字节都没变。但如果不做缓存,模型就要从头再算一遍 prefill。
一个跑了 20 步的 Agent 会话,同一段 3 万 token 的上下文被完整重算 20 次。20 次里,19 次是纯浪费。
场景二:一份合同问 100 个问题,付了 100 次"读合同"的钱
法务部门上传一份 2 万字的合同,然后连着问:违约责任怎么写的?付款节点在哪?有没有排他条款?……
100 个问题,100 次请求,每次请求都带着同一份 2 万字合同。模型把这份合同读了 100 遍,算了 100 次它对应的中间状态。而你为这 100 遍,一分不少地付了钱。
场景三:客服系统的长人设,是账单里最贵的一段
一个客服 Bot 的系统提示词写了 5000 token:品牌语气、业务边界、30 条 FAQ、工具调用规约、合规红线。每天 5 万次会话。
这段提示词每天被计算 5 万次。它从来不变化,但你为它付了 5 万次全价。

这三个场景看起来毫无关系,本质完全一样:输入里有一大段是从第 0 个 token 开始就完全相同的,但推理引擎每次都在重新计算它。
而且这不是"多花点钱"的问题。在大模型推理里,处理输入的那一步(prefill)是计算密集的,它同时决定了三件事:
| 你感受到的现象 | 背后的技术指标 | 不优化的后果 |
|---|---|---|
| 用户点了发送,转圈半天才出第一个字 | TTFT(首 token 延迟) | 128K 长文本首字要 13 秒 |
| 财务说这个月 API 费用又超了 | 输入 token 成本 | 重复前缀占比常常超过 80% |
| 一台 GPU 顶不住,要加机器 | prefill 吞吐(tokens/s) | 同一张卡每秒只能处理 6 万 token |
一个技术点,同时压住延迟、成本、容量三条线。这就是大模型的公共前缀,工程上叫 前缀缓存(Prefix Caching),商业 API 上叫 Prompt Caching / Context Caching。
它是 2023 年之后推理系统领域收益最大的一个优化,而且——最妙的地方在于——多数情况下你不需要改模型,只需要改 Prompt 的顺序。
二、它到底是什么:一句话、一个大白话、一个生活案例
2.1 一句话版本
如果两个请求从第 0 个 token 开始有一段完全相同的内容,那么这段内容对应的中间计算结果,可以直接从缓存里读,不用重算。
就这样。没有更多了。
2.2 专业解释:为什么"相同的前缀"一定产出"相同的中间结果"
要讲清楚这件事,得先看大模型推理的两个阶段。
阶段一:Prefill(预填充)
模型把整段输入一次性读进去,为每一个 token 计算两组向量:Key 和 Value。这一整套(每一层、每一个注意力头、每一个位置各一份)合起来叫 KV Cache。
这一步是纯矩阵计算,非常吃算力。屏幕上你一个字都还没看到,GPU 已经满载跑了好几秒。
阶段二:Decode(解码)
模型开始一个一个往外吐字。每吐一个 token,都要回头看前面所有 token 的 Key 和 Value——这就是它为什么要被缓存起来,否则每吐一个字都要把前文重算一遍,复杂度直接爆炸。

关键点在这里:decoder-only 的 Transformer 用的是因果注意力(causal attention)。计算第 N 个 token 的 Key/Value 时,模型只能看到第 1 到第 N 个 token,看不到后面。
这带来一个极强的性质:
前缀的 KV 是前缀 token 的确定性函数。只要开头这 M 个 token 一模一样,它们的 KV 就一定一模一样,和你后面写了什么完全无关。
所以:请求 A 是 [P][问题1],请求 B 是 [P][问题2]。P 是同一段 4000 token 的系统提示。那么 P 对应的 4000 份 KV,在 A 和 B 里是逐位相等的。
既然逐位相等,那就没必要算第二遍。第一次算完,存起来,第二次直接拿来用。
这就是前缀缓存的全部理论依据。它不近似、不降精度、不改输出——缓存命中的结果和完整重算的结果,在数学上是同一个东西。
2.3 大白话版本
你去一家餐厅,每次都要先报一遍"我是老客户,不吃香菜,微辣,米饭半碗"。
- 没有缓存:服务员每次都拿张新纸,从"我是老客户"开始一个字一个字记,记完才听你点菜。
- 有缓存:服务员认得你,你坐下他就把那张纸拿出来了,前面那段已经写好了,你直接说"今天来个鱼香肉丝"。
菜的味道完全一样,但快了很多,而且服务员不用为你那句"不吃香菜"重复收钱。
如果哪天你改口说"今天吃辣一点",服务员会从"微辣"那一行开始重写——前面"我是老客户、不吃香菜"依然有效。这就是只从第一个不同的 token 开始失效。
如果你把顺序换成"微辣,我是老客户,不吃香菜"——那服务员每次都要重写整张纸,因为第一行就变了。
这就是为什么"顺序"比"内容"更重要。
2.4 只有三条规则,但 90% 的坑都在这里
规则一:必须从第 0 个 token 开始匹配。
中间某一段相同,不算数。因为后面的 token 在算自己的 KV 时,把前面每一个 token 都"看进去了"。你从中间截一段出来,它在原句里的 KV 和在别处的 KV 根本不是同一个东西。
规则二:改一个 token,从它开始后面全部作废。
因为后面每个 token 的 KV 都依赖它。这是级联失效,不是局部失效。
规则三:匹配是逐 token 精确的,不是"语义相似"。
差一个空格、差一个标点、JSON 里两个 key 换了个顺序,都是不同前缀。没有模糊匹配这回事。

理解这三条,你就理解了后面所有的优化手段:所有技巧,本质上都是在"让不变的部分尽量长、尽量靠前,让变化的部分尽量短、尽量靠后"。
三、为什么值得用:算三本账
第一本账:钱
拿一个真实形状举例。系统提示词 4000 token,每次新增问题 200 token,跑 10000 次请求。假设输入价:缓存未命中 2 元/百万 token,命中 0.04 元/百万 token(这是某家厂商某档模型的公开报价,写文时的价格,具体以官网为准)。
不优化:
每次请求 = 4000 + 200 = 4200 token,全部按未命中计费
总输入 = 4200 × 10000 = 42,000,000 token
费用 = 42 × 2 元 = 84 元
开启前缀缓存后:
第 1 次:4200 token 全部未命中
第 2~10000 次:4000 token 命中 + 200 token 未命中
未命中总量 = 4200 + 9999 × 200 ≈ 2,004,200 token ≈ 2.0 M
命中总量 = 9999 × 4000 ≈ 39,996,000 token ≈ 40 M
费用 = 2.0 × 2 元 + 40 × 0.04 元
= 4 元 + 1.6 元
= 5.6 元
84 元降到 5.6 元,省了 93%。
注意这里有一个反直觉的结论:缓存命中单价便宜 50 倍,但真正的省钱幅度取决于"可变部分有多短"。 如果每次都新增 4000 token 的新内容,那命中率就只剩 50%,省钱幅度立刻打对折。
所以前缀缓存不是"开了就省 90%“,是"前缀占比越高越省”。这个认知差,是很多团队做完优化发现没效果的根本原因。
第二本账:延迟
某厂商在 2024 年 8 月上线硬盘缓存时的公开数据:一段 128K 的输入、且大部分内容重复的场景,首 token 延迟从 13 秒降到 500 毫秒。
26 倍。
原因是 prefill 是延迟的主要来源。输入越长,这一项占比越高。有厂商在自家 cookbook 里给过一组实测:1024 token 的短请求,命中缓存只快 7%;但 150K token 的长请求,TTFT 快了 67%。
输入越长,缓存越值钱。 这也解释了为什么 2025 年之后所有做长上下文、做 Agent 的团队,都把前缀缓存当成基础设施而不是优化项。
第三本账:吞吐
某厂商开源自家的推理引擎时给过一个数字:同一张卡跑 prefill,不算缓存命中,每秒处理 6 万 token;算上缓存命中,每秒 150 万 token。25 倍。
换个说法:原来需要 25 张卡扛的流量,现在 1 张卡就扛住了。这不是省钱的问题,这是能不能做得起的问题。
什么时候不划算
诚实地说,有三种情况收益很差:
- 前缀太短。 各家 API 都有最小可缓存长度(常见 1024 token,部分模型 2048)。低于这个长度,缓存根本不触发。自建引擎虽然没这个门槛,但块级匹配的最小单位(16 或 128 token)以下也是白搭。
- 每次请求都完全不一样。 一次性批处理、每个用户问完全无关的问题、每次生成随机种子都不同——命中率接近 0。
- 需要显式写入费的场景,读次数太少。 Anthropic 的 5 分钟缓存写入要收 1.25 倍基础输入价,读一次就回本;1 小时档写入 2 倍,要读两次才回本。如果一个前缀只会被用一次,别开。
四、它是怎么演进过来的:从"省显存"到"省一切"
前缀缓存不是突然出现的,它是 KV Cache 这条技术线走到一定阶段的必然产物。搞清楚这条线,面试时你能答出层次感。

第一代(2017—2022):KV Cache 本身
Transformer 自回归生成时,每生成一个 token 都要对前面所有 token 做注意力。如果不缓存,生成长度为 N 的序列,总计算量是 O(N²) 次重复计算。KV Cache 把算过的 Key/Value 存下来,把这一步降到 O(N)。
这个阶段的目标很单纯:让生成跑得动。缓存是"一次请求内部"的事,请求结束就扔。
第二代(2023):PagedAttention,把 KV Cache 变成"可以管理的资源"
问题来了:KV Cache 特别大,而且长度不定、碎片严重。传统做法要按最大长度预分配连续显存,浪费 60%—80%。
vLLM 在 2023 年提出 PagedAttention,借用操作系统的虚拟内存分页思想:把 KV Cache 切成固定大小的 block,用一张 block table 做逻辑到物理的映射。显存利用率从不到 40% 拉到 90% 以上,吞吐提升数倍。
这一步的历史意义被低估了。 它不只是省显存——它把 KV Cache 从"一块连续内存"变成了"一堆可以按内容寻址、可以共享、可以换出的块"。前缀缓存需要的所有基础设施,在这一步就位了。
第三代(2024 上):vLLM 自动前缀缓存(APC)
有了 block,就可以给 block 算哈希。vLLM 的做法很直接:
block_hash[i] = hash(block_hash[i-1] + tokens_in_block_i)
注意这个链式结构:每个块的哈希里,都揉进了它前面所有块的哈希。 这就是为什么它能保证"真正的前缀匹配"——一个块要被复用,必须是它前面每一个块都完全一致。
新请求进来,逐块查哈希,找到最长的一段"已经在池子里"的连续块,这部分直接复用,只算剩下的。
同期主流引擎陆续跟进:TensorRT-LLM、llama.cpp(slot 机制,在多个 slot 里找最长公共前缀)都有自己的实现。
第四代(2024 下):SGLang 的 RadixAttention,从"整块"到"逐 token"
vLLM 的 APC 有个限制:只缓存完整的块。如果你的共享前缀长度不是 16 或 128 的整数倍,尾巴上那几个 token 就白扔了。在块大小为 128 的配置下,一段 4096 token 的提示词可能只被缓存到 3968——末端的 128 个 token 每次都要重算。
SGLang 换了数据结构:用**基数树(radix tree)**存 KV,节点按 token 序列共享路径,支持 token 级粒度的最长前缀匹配,LRU 淘汰。
好处在多轮对话和 Agent 场景特别明显:会话历史是逐轮增长的,共享前缀的长度每轮都在变,很少刚好落在块边界上。基数树能吃下任意长度的共享路径,也能自然地处理"从同一段系统提示分叉出多个会话"这种树形结构。
公开 benchmark 里,多轮对话场景下 RadixAttention 相比块级 APC 有 2—5 倍的吞吐优势。
第五代(2024.8 起):把缓存从显存挪到硬盘、挪到整个集群
到这里,缓存还被困在单机单卡的显存里。显存多贵?一台 8 卡机器的显存加起来也就一两 TB,装不下多少历史上下文。
2024 年 8 月,DeepSeek 做了一件当时看起来很激进的事:把 KV Cache 落到分布式硬盘阵列上,并且默认对所有用户开启。
凭什么敢?因为它的 MLA 注意力结构把 KV 压得足够小(官方说法是相比标准 MHA 砍掉 90% 以上)。压得小,硬盘才装得下,读得回来才够快。
它同时定下了几条至今仍是行业共识的规则:
- 只有从第 0 个 token 开始相同才算命中,中间相同不算;
- 缓存以固定大小的块为单位(早期公告是 64 token,后续版本实测是 128);
- 缓存是"尽力而为",不保证 100% 命中;
- 不用了会自动清空,时间从几小时到几天;
- 存储不额外收费,只在命中时按命中价计费;
- 每个用户的缓存逻辑隔离。
再往后是 LMCache / Mooncake 这类专门的 KV 缓存层:把 KV 从显存卸载到 CPU 内存、本地 SSD、远端分布式存储,做成一个跨副本、跨机器的缓存池。这样任何一台 prefill 机器都能读到任何一段缓存,用户不再被"上次那台机器"绑死。
第六代(2025—2026):缓存感知调度、PD 分离、以及安全
缓存多了,新问题出现了:你有 20 个副本,请求被负载均衡随机打到 3 号机,但缓存存在 7 号机上,全废。
于是有了 前缀感知路由(prefix-aware routing / KV-aware routing):网关按前缀哈希做一致性路由,把同一个前缀的请求尽量打到同一台机器。NVIDIA Dynamo、llm-d、vLLM production-stack 都在做这件事。
同时,prefill 和 decode 拆到不同机器(PD 分离)成为主流架构。因为这两个阶段一个吃算力、一个吃带宽,混在一起互相干扰。
最后一个阶段是安全。2025 年 NDSS 的 PromptPeek、2026 年的 KVGov 等工作证明了:共享前缀缓存会泄漏信息。攻击者通过测量 TTFT 的差异,可以逐 token 地猜出别人的 prompt —— 在某些配置下成功率接近 100%。
对应的工程手段是 cache salt:给每个租户/信任组一个不可猜测的随机盐,混进第一个块的哈希里,让不同租户的缓存命名空间在密码学上不相交。vLLM 在 v1 引擎里把默认哈希换成了 SHA256,并支持按请求传 cache_salt。
这条演进线的规律很清楚:
省显存(PagedAttention)
→ 省算力(前缀缓存)
→ 省显存再省算力(分级存储、跨机共享)
→ 省调度损耗(缓存感知路由、PD 分离)
→ 管住副作用(多租户隔离、侧信道防护)
技术从"优化性能"走到"优化系统",最后一定要回到"治理副作用"。这条线在数据库、缓存、消息队列上重复过无数次。
五、怎么用:从改 Prompt 到改架构,五层落地
这一节是全文的核心。分五层,从最便宜的改法讲到最贵的改法。建议按顺序做,前两层通常能拿到 80% 的收益。

5.1 第一层:改 Prompt 的顺序(零成本,收益最大)
先看一个反例。这是很多团队真实的 Prompt 模板:
import datetime
from uuid import uuid4
def build_prompt_bad(user_question: str, doc: str) -> str:
# 反例:动态内容在最前面
return f"""当前时间:{datetime.datetime.now().isoformat()}
请求 ID:{uuid4().hex}
你是某公司的客服助手,请遵守以下规则:...(此处省略 5000 字)
以下是用户上传的文档:
{doc}
用户问题:{user_question}
"""
问题在哪?第一行就是每次都变的时间戳。 从第 0 个 token 开始就不一样,所以后面 5000 字的规则一个字都命中不了。命中率 0%。
内容一个字没改,只是把时间戳挪到最后:
import datetime
import json
from uuid import uuid4
def build_prompt_good(system_rules: str, doc: str, user_question: str) -> str:
# 正例:稳定 -> 半稳定 -> 动态,严格从上到下
return f"""{system_rules}
以下是用户上传的文档:
{doc}
用户问题:{user_question}
---
[元信息] 时间={datetime.datetime.now().isoformat()} 请求ID={uuid4().hex}
"""
同理,"半稳定"的内容(同一份文档)也应该放在"完全动态"的内容(每个用户的问题)之前。
这就是整套优化的第一原则:把内容按"变化频率"做降序排列。
变化频率:从不变化 > 每天变 > 每会话变 > 每请求变
排列顺序:最前 > 中间 > 后面 > 最后
改造前后的对比,一张表就够:
| 位置 | 改造前 | 改造后 | 可缓存比例 |
|---|---|---|---|
| 第 1 段 | 时间戳、请求 ID | 系统规则(5000 token) | 可缓存 |
| 第 2 段 | 系统规则(5000 token) | 工具定义(2000 token) | 可缓存 |
| 第 3 段 | 工具定义(2000 token) | few-shot 示例(1500 token) | 可缓存 |
| 第 4 段 | few-shot 示例 | 用户文档 | 同文档可缓存 |
| 第 5 段 | 用户文档 | 用户问题 | 不可缓存 |
| 第 6 段 | 用户问题 | 时间戳、请求 ID | 不可缓存 |
改造前可缓存比例 0%,改造后 90% 以上。代码改动量:挪几行。

5.2 第二层:把"看起来相等"的东西做成"真的相等"
前缀匹配是字节级的。有两个经典坑,几乎每个团队都会踩一次。
坑一:工具列表的顺序不稳定。
import json
# 反例:从 dict / set / 数据库查询拿工具定义,顺序不保证
tools = {t["name"]: t for t in load_tools_from_db()} # 没有 ORDER BY
prompt_tools = json.dumps(list(tools.values()), ensure_ascii=False)
# 今天和明天的顺序可能不同 -> 前缀从工具段就断了,后面全部失效
import json
def render_tools(tools: list[dict]) -> str:
# 正例:按 name 排序 + 紧凑分隔符 + 固定 key 顺序
ordered = sorted(tools, key=lambda t: t["name"])
return json.dumps(
ordered,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
sort_keys=True 和 separators=(",", ":") 这两个参数很关键。前者保证 key 顺序固定,后者去掉多余空格——JSON 序列化时空格的有无,在 token 层面就是不同的序列。
Go 和 Swift 的 map 遍历顺序是随机的,这个问题在跨语言服务里更常见。Anthropic 的文档专门提醒过:某些语言把 tool_use 转 JSON 时会打乱 key 顺序,导致缓存莫名失效。
坑二:浮点数、时间格式、UUID 格式不统一。
from decimal import Decimal
# 反例:同一个金额,两次序列化成不同字符串
f"{0.1 + 0.2}" # '0.30000000000000004'
f"{0.3}" # '0.3'
str(Decimal("12.50")) # '12.50'
str(Decimal("12.5")) # '12.5'
这类问题不会报错,只会让你的命中率莫名其妙地低。解决方法是在 Prompt 层做一次规范化(canonicalization),所有进 Prompt 的值先过一遍统一的格式化函数。
5.3 第三层:调用商业 API,怎么开、怎么看
DeepSeek:默认开启,看两个字段
不需要改代码,硬盘缓存对所有用户默认生效。你只需要在返回的 usage 里看两个数字:
{
"usage": {
"prompt_tokens": 4200,
"completion_tokens": 180,
"prompt_cache_hit_tokens": 4000,
"prompt_cache_miss_tokens": 200
}
}
命中率就是 hit / (hit + miss)。写个小的封装把它记下来:
from openai import OpenAI
client = OpenAI(api_key="...", base_url="https://api.deepseek.com")
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": SYSTEM_RULES}, # 稳定,放最前
{"role": "user", "content": question}, # 动态,放最后
],
)
u = resp.usage
hit = getattr(u, "prompt_cache_hit_tokens", 0)
miss = getattr(u, "prompt_cache_miss_tokens", 0)
rate = hit / (hit + miss) if (hit + miss) else 0.0
print(f"命中 {hit} / 未命中 {miss} / 命中率 {rate:.1%}")
OpenAI:自动,但要够长
前缀 ≥ 1024 token 自动生效,按 128 token 递增匹配。返回里有 cached_tokens:
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-5.2",
messages=[
{"role": "system", "content": LONG_SYSTEM_PROMPT}, # 4000 token
{"role": "user", "content": "退款政策是怎么写的?"},
],
)
cached = resp.usage.prompt_tokens_details.cached_tokens
total = resp.usage.prompt_tokens
print(f"缓存命中 {cached} / 总输入 {total} = {cached / total:.1%}")
多副本部署时,请求可能落到没有缓存的机器上。OpenAI 提供了 prompt_cache_key 参数,让共享同一前缀的请求尽量路由到同一台机器:
resp = client.chat.completions.create(
model="gpt-5.2",
messages=messages,
prompt_cache_key="tenant-42-support-bot", # 同一前缀的请求用同一个 key
)
Anthropic:显式打点,要算回本
Anthropic 不自动缓存,要用 cache_control 显式标记。这点很重要:它决定了你的成本模型不一样。
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": LONG_SYSTEM_PROMPT, # 5000+ token 的规则
"cache_control": {"type": "ephemeral"}, # 打点:到这里为止都缓存
}
],
messages=[
{"role": "user", "content": question},
],
)
print("写入缓存:", resp.usage.cache_creation_input_tokens)
print("读取缓存:", resp.usage.cache_read_input_tokens)
print("普通输入:", resp.usage.input_tokens)
计价倍率(相对基础输入价):5 分钟缓存写入 1.25 倍、读取 0.1 倍;1 小时缓存写入 2 倍、读取 0.1 倍。
所以回本点很明确:5 分钟档读 1 次就回本,1 小时档要读 2 次。如果一个前缀只用一次,开缓存反而更贵。 这跟 OpenAI、DeepSeek 的"读免费送、只收读的钱"是完全不同的成本结构,做预算时千万别混着算。
一个请求最多打 4 个断点,可以分层缓存:系统提示一个断点、few-shot 一个断点、检索文档一个断点。这样文档变了只失效文档之后的部分,前面的规则还在。
Gemini:隐式 + 显式两条路
Gemini 2.5 之后对 ≥1024 token 的公共前缀默认做隐式缓存;需要保证收益时,用 cachedContent 显式创建缓存对象并设置 TTL,适合"一份大文档反复问"的场景。
5.4 第四层:自建推理,引擎怎么配
先明确一件事:两个主流引擎的缓存结构不一样,配置重点也不一样。
vLLM
vLLM V1 引擎默认开启 APC,V0 需要显式加参数:
vllm serve Qwen/Qwen3-32B-Instruct \
--enable-prefix-caching \
--max-model-len 65536 \
--gpu-memory-utilization 0.90 \
--kv-cache-dtype fp8 \
--prefix-caching-hash-algo sha256 \
--port 8000
几个参数值得单独说:
--enable-prefix-caching:核心开关。V1 默认开,V0 必须显式加,加之前先确认版本。--gpu-memory-utilization 0.90:这个值直接决定缓存池有多大。调到 0.95 缓存更多但容易 OOM;0.85 更稳但命中率会掉。这是最需要按自己负载调的一个参数。--kv-cache-dtype fp8:KV 精度减半,等于缓存池容量翻倍。绝大多数场景精度损失在 1% 以内。--prefix-caching-hash-algo sha256:多租户必须用 sha256。非加密哈希(xxhash 之类)碰撞风险高,可能把 A 租户的块当成 B 租户的命中,属于数据泄漏级别的事故。
离线批量推理也能开:
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen3-32B-Instruct",
enable_prefix_caching=True, # 关键
max_model_len=32768,
gpu_memory_utilization=0.90,
kv_cache_dtype="fp8",
)
# 同一批里前缀相同的请求,后面的会命中前面的缓存
prompts = [SYSTEM_RULES + q for q in questions]
outputs = llm.generate(prompts, SamplingParams(temperature=0.2, max_tokens=512))
for o in outputs:
print(o.outputs[0].text[:80])
注意:vLLM 官方文档明确说过,部分混合注意力架构(比如带滑动窗口注意力的模型)不支持 APC,会打一条 warning 然后静默回退到无缓存模式。上线前一定去日志里
grep -i prefix确认真的开了。
SGLang
RadixAttention 默认开启,不用加参数:
python -m sglang.launch_server \
--model-path Qwen/Qwen3-32B-Instruct \
--tp 8 \
--context-length 65536 \
--kv-cache-dtype fp8_e4m3 \
--schedule-policy lpm \
--port 30000
--schedule-policy lpm(longest prefix match)是 SGLang 的一个巧思:调度时优先放行"能命中更长前缀"的请求,让缓存复用率在高压下也不塌。这个策略在流量打满时效果明显,因为默认的 FCFS 可能让一个即将命中 3 万 token 的请求排在一个啥都不命中的请求后面。
5.5 第五层:网关层——多副本、多租户的两件大事
单机跑通之后,真正的难点在集群。
问题一:负载均衡把缓存打散了
20 个副本,如果网关用轮询,同一个前缀的请求会散落到 20 台机器上,每台都只有 5% 的概率命中——等于没有缓存。
解法是按前缀哈希做一致性路由:
# 网关(如 llm-d / vLLM production-stack / Envoy+ext_proc)路由配置示意
apiVersion: v1
kind: ConfigMap
metadata:
name: prefix-aware-router
data:
routing:
strategy: prefix-hash # 不用 round-robin
hash_source: first_n_tokens # 取前 N 个 token 做哈希
hash_tokens: 512
virtual_nodes: 160 # 一致性哈希虚拟节点数,越大越均匀
fallback: least_pending # 哈希目标不可用时退化为最小排队
health_check:
path: /health
interval: 2s
这套东西的原理和 Redis Cluster 的 key slot 一样:把"同一份数据"稳定映射到"同一台机器"。区别只是这里的 key 是前缀哈希,数据是 KV Cache。
注意 hash_tokens: 512 这个值。取太少(比如只取前 16 个 token),大量不同前缀会撞到同一台机器,热点打爆;取太多,前缀稍有差异就被判成不同,路由又散了。经验值落在 256—1024 之间,按实际前缀长度分布调。
问题二:多租户共享缓存会泄漏
这一条在 2026 年已经是安全审计的必查项。
原理不复杂:命中缓存的请求 TTFT 明显更短。攻击者构造请求、测量延迟,就能判断"这段前缀有没有被别人算过",然后逐 token 迭代,把别人的 prompt 猜出来。NDSS 2025 的 PromptPeek 在 SGLang + Llama2-13B 上报告了 95%—99% 的重建成功率;vLLM 也为此分配了 CVE。
工程上的防线是缓存命名空间隔离:
# 网关在处理请求时,给每个租户注入一个不可猜测的 salt
import hashlib
import hmac
import os
CACHE_SALT_SECRET = os.environ["CACHE_SALT_SECRET"].encode() # 256 bit 随机值,放 KMS
def tenant_cache_salt(tenant_id: str) -> str:
"""不能用 tenant_id 本身当 salt,必须用 HMAC,否则攻击者可以自己算出来。"""
return hmac.new(CACHE_SALT_SECRET, tenant_id.encode(), hashlib.sha256).hexdigest()
然后把这个值透传给引擎(vLLM 的 cache_salt 请求参数):
from openai import OpenAI
client = OpenAI(base_url="http://gateway/v1", api_key="...")
resp = client.chat.completions.create(
model="qwen3-32b",
messages=messages,
extra_body={"cache_salt": tenant_cache_salt("tenant-42")},
)
隔离是有代价的。公开测量显示,完全分区会让 TTFT 上升 2%—39%(取决于模型大小和跨用户前缀重叠度)。所以务实的做法是分级:
| 场景 | 隔离策略 | 代价 |
|---|---|---|
| 同一企业内部,信任级别相同 | 共享缓存,不隔离 | 0 |
| 同一企业,不同部门/不同客户的数据 | 按租户 salt 隔离,但系统提示共享 | 低 |
| 对外 SaaS,多客户互不信任 | 完全隔离 + 独立命名空间 | 中 |
| 涉密/金融/医疗 | 物理隔离或独立部署 | 高 |
必须记住的一条:cache_salt 只是降低了风险,官方文档明确说它不是隔离边界。 真正敏感的数据,不要指望一个参数解决。
5.6 工程护栏:两个必须加的东西
在讲护栏之前,先看清楚敌人长什么样。下面这八个坑,是线上命中率掉下去的全部原因清单。

护栏一:前缀稳定性测试(防"无意破坏缓存")
最恶心的故障不是缓存没开,而是上周还好好的,这周命中率从 92% 掉到 8%,没人知道为什么。原因往往是某位同事在系统提示里加了一行日期,或者升级了某个 SDK 导致工具序列化格式变了。
解法:把"稳定前缀"的指纹写进单测。变了就红。
import hashlib
import json
def prefix_fingerprint(system_rules: str, tools: list[dict], fewshot: list[dict]) -> str:
"""稳定层的指纹:任何字节变化都会改变它。"""
payload = json.dumps(
{"system": system_rules, "tools": tools, "fewshot": fewshot},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
# 黄金值:一旦变化,说明缓存前缀被改动了,必须显式确认
GOLDEN = "a1b2c3d4e5f60718"
def test_prefix_is_stable():
fp = prefix_fingerprint(SYSTEM_RULES, TOOLS, FEWSHOT)
assert fp == GOLDEN, (
f"稳定前缀发生变化:{fp} != {GOLDEN}\n"
"如果不是有意修改,请检查:\n"
"1. 系统提示里是否混入了时间戳/请求ID/随机数\n"
"2. 工具列表是否按 name 排序\n"
"3. JSON 序列化参数是否一致(sort_keys/separators)\n"
"4. 版本升级是否改了默认的 prompt 模板"
)
配套一个"禁止清单"检查,把常见雷区做成 lint:
import re
FORBIDDEN_IN_PREFIX = [
(r"\d{4}-\d{2}-\d{2}", "日期"),
(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "IP 地址"),
(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}", "UUID"),
(r"(?i)\b(session|request)[_-]?id\b", "会话/请求 ID"),
]
def lint_prefix(text: str) -> list[str]:
"""检查稳定前缀里有没有混入动态内容。"""
hits = []
for pattern, name in FORBIDDEN_IN_PREFIX:
if re.search(pattern, text):
hits.append(name)
return hits
这个 lint 挂在 CI 里,比任何文档都管用。
护栏二:命中率监控(防"悄悄失效")
命中率是这条链路上最重要的单一指标。它掉下去,钱立刻开始烧。
import time
from collections import defaultdict
try:
from prometheus_client import Counter, Histogram
CACHE_HIT = Counter("llm_prefix_cache_hit_tokens_total", "命中缓存的输入 token", ["model", "tenant"])
CACHE_MISS = Counter("llm_prefix_cache_miss_tokens_total", "未命中的输入 token", ["model", "tenant"])
TTFT = Histogram("llm_ttft_seconds", "首 token 延迟", ["model", "cache"],
buckets=(0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30))
except ImportError: # 本地调试没有 prometheus_client 时降级
CACHE_HIT = CACHE_MISS = TTFT = None
class CacheMeter:
def __init__(self):
self.by_tenant = defaultdict(lambda: {"hit": 0, "miss": 0})
def record(self, tenant: str, model: str, hit: int, miss: int, ttft: float):
self.by_tenant[tenant]["hit"] += hit
self.by_tenant[tenant]["miss"] += miss
if CACHE_HIT:
CACHE_HIT.labels(model=model, tenant=tenant).inc(hit)
CACHE_MISS.labels(model=model, tenant=tenant).inc(miss)
TTFT.labels(model=model, cache="hit" if hit else "miss").observe(ttft)
def rates(self) -> dict:
out = {}
for tenant, v in self.by_tenant.items():
total = v["hit"] + v["miss"]
out[tenant] = v["hit"] / total if total else 0.0
return out
报警阈值建议这样定(都是经验值,按自己业务调):
| 指标 | 健康区间 | 预警 | 说明 |
|---|---|---|---|
| 单租户命中率 | > 80% | < 60% 持续 30 分钟 | 前缀被破坏或流量模式变了 |
| 缓存命中 token 占比 | > 70% | < 50% | 直接影响账单 |
| TTFT P99(命中) | 基线 -60% 以上 | 与未命中差距 < 20% | 缓存可能没真生效 |
| 缓存池淘汰速率 | 平稳 | 突增 | 容量不足或前缀过于分散 |
踩坑提醒:不要只盯着"命中率"这一个数。如果流量结构变了(比如突然来了一大批互不相关的短请求),命中率下降是正常的,报警会误报。要把"前缀去重后的数量"和"缓存池大小"一起看。
5.7 一个能跑的迷你实现:70 行看懂 prefix cache
看懂了原理,不如自己写一个。下面这段代码是块哈希 + LRU 的最小实现,跑起来能看到命中率是怎么被"前缀长度"和"复用次数"影响的。它不是生产代码,但把核心机制暴露得很清楚。
"""迷你前缀缓存:块哈希链 + LRU 淘汰,用来理解 vLLM APC 的核心机制。"""
import hashlib
from collections import OrderedDict
BLOCK = 4 # 演示用小块;真实引擎通常是 16 或 128
class PrefixCache:
def __init__(self, capacity_blocks: int = 128):
self.capacity = capacity_blocks
self.table: OrderedDict[str, int] = OrderedDict() # block_hash -> 物理块号
self.next_block_id = 0
self.computed_blocks = 0 # 实际需要计算的块数(用来算省了多少)
def _hash_chain(self, tokens: list[str]) -> list[str]:
"""链式哈希:每个块的 key 里揉进了它前面所有块的信息。"""
hashes, parent = [], ""
for i in range(0, len(tokens) - BLOCK + 1, BLOCK):
chunk = "|".join(tokens[i:i + BLOCK])
parent = hashlib.sha256(f"{parent}#{chunk}".encode("utf-8")).hexdigest()[:16]
hashes.append(parent)
return hashes
def touch(self, tokens: list[str]) -> int:
"""返回命中的 token 数;未命中的块算作"新计算"并写回缓存。"""
hashes = self._hash_chain(tokens)
hit_blocks = 0
# 1) 从头开始找最长的连续已缓存块
for h in hashes:
if h not in self.table:
break
self.table.move_to_end(h) # LRU:命中的放到队尾
hit_blocks += 1
# 2) 命中点之后的所有块,都是需要重新计算的
for h in hashes[hit_blocks:]:
if h in self.table:
self.table.move_to_end(h)
else:
self.table[h] = self.next_block_id
self.next_block_id += 1
self.computed_blocks += 1
# 3) 超出容量按 LRU 淘汰
while len(self.table) > self.capacity:
self.table.popitem(last=False)
return hit_blocks * BLOCK
def simulate(prefix_len: int, suffix_len: int, n_requests: int) -> dict:
cache = PrefixCache(capacity_blocks=4096)
prefix = [f"p{i}" for i in range(prefix_len)]
total_tokens = 0
hit_tokens = 0
for i in range(n_requests):
suffix = [f"q{i}_{j}" for j in range(suffix_len)]
tokens = prefix + suffix
hit = cache.touch(tokens)
hit_tokens += hit
total_tokens += len(tokens)
computed = cache.computed_blocks * BLOCK
return {
"prefix_len": prefix_len,
"suffix_len": suffix_len,
"requests": n_requests,
"hit_rate": hit_tokens / total_tokens,
"computed_ratio": computed / total_tokens,
}
if __name__ == "__main__":
print(f"{'前缀':>6} {'后缀':>6} {'请求数':>8} {'命中率':>9} {'实际计算占比':>13}")
for pl, sl, n in [(100, 20, 100), (100, 100, 100), (20, 100, 100), (400, 20, 1000)]:
r = simulate(pl, sl, n)
print(f"{r['prefix_len']:>6} {r['suffix_len']:>6} {r['requests']:>8} "
f"{r['hit_rate']:>8.1%} {r['computed_ratio']:>12.1%}")
运行结果是四组对照,能直接看出规律:
前缀 后缀 请求数 命中率 实际计算占比
100 20 100 82.5% 17.5%
100 100 100 49.5% 50.5%
20 100 100 16.5% 83.5%
400 20 1000 95.1% 4.9%
三条结论,全在数字里:
- 前缀越长、后缀越短,命中率越高(第一行 vs 第二行)。这就是为什么"把动态内容挪到最后"这么重要——它直接改变了后缀长度。
- 前缀短的时候,优化基本没用(第三行)。20 个 token 的前缀,命中率只有 16%,不值得折腾。
- 复用次数越多越值(第四行 vs 第一行)。1000 次请求下,命中率能到 95%。
这个脚本可以直接改成 ROI 测算工具:把你线上真实的"前缀长度分布 + 请求量"喂进去,估出来的命中率乘上价差,就是你能省的钱。做预算汇报的时候,比说"能省很多"有用得多。
六、竞品对比:它和谁在抢同一笔预算
前缀缓存不是一个孤立的技术,它是"降低 LLM 成本/延迟"这个大池子里的一种手段。搞清楚它和其他方案的关系,才知道什么时候该用它、什么时候该换别的。
6.1 五种降本路线的横向对比
| 方案 | 复用的到底是什么 | 命中条件 | 典型收益 | 主要风险 | 适用场景 |
|---|---|---|---|---|---|
| 前缀缓存 Prefix / Prompt Caching | 前缀的 KV 中间状态 | 从第 0 个 token 起逐 token 完全相同 | 输入成本降 50%—95%,TTFT 降 30%—80% | 前缀一改全废;多租户有侧信道风险 | 长系统提示、Agent、RAG、多轮对话 |
| 语义缓存 Semantic Cache | 最终答案 | 问题向量相似度超阈值 | 命中时省掉整次推理 | 相似≠相同,答错无人察觉;需要向量库 | 客服 FAQ、高频重复问 |
| 精确结果缓存 Redis / GPTCache | 最终答案 | query 字符串完全一致 | 命中时接近零成本 | 命中率天然低;数据可能过期 | 幂等查询、报表、确定性任务 |
| 上下文压缩 Compaction / 摘要 | 语义信息 | 不适用(是压缩不是复用) | 输入 token 直接减 50%—90% | 有信息损失;摘要本身要花钱 | 超长会话、超长文档 |
| 模型降级 / 蒸馏 | 不适用 | 不适用 | 单 token 价格降一个数量级 | 效果下降;需要评估和兜底 | 分类、抽取、简单问答 |
关键区别,用一句话记住:
- 前缀缓存是无损的,省的是"重复计算",输出和原来一模一样;
- 语义缓存是有损的,省的是"整次推理",可能答错;
- 上下文压缩是有损的,省的是"信息量",可能丢细节;
- 模型降级是有损的,省的是"模型能力"。
所以在企业项目里,正确的顺序是:先上前缀缓存(无损、零风险、改动最小),再考虑语义缓存(要评估准确率),最后才考虑压缩和降级。
很多人一上来就搞语义缓存,结果踩了"相似问题答错"的坑,反而把前缀缓存这个最稳的收益漏掉了。
6.2 主流实现的横向对比
| 实现 | 匹配粒度 | 数据结构 | 缓存放在哪 | 默认开启 | 多租户隔离 | 备注 |
|---|---|---|---|---|---|---|
| vLLM APC | 块级(只缓存完整块) | 块哈希链 + PagedAttention 块表 | GPU 显存 | V1 默认开,V0 需显式开 | cache_salt + SHA256 | 生态最广;块边界会浪费尾部 token |
| SGLang RadixAttention | token 级 | 基数树(radix tree) | GPU 显存 | 默认开 | 需自行在网关侧隔离 | 多轮/Agent 场景吞吐更优;支持 LPM 调度 |
| TensorRT-LLM | 块级 | 块哈希 + 显存池 | GPU 显存 | 需配置 | 支持 | NVIDIA 硬件上性能最好,绑定较深 |
| llama.cpp | slot 内最长公共前缀 | 多 slot 各自持有 KV | 显存/内存 | 默认行为 | 无 | 本地部署首选,粒度粗但够用 |
| LMCache | 复用引擎的块,做分级存储 | 引擎块 + 外部 KV 存储 | 显存 / CPU / SSD / 分布式 | 需部署 | 需自行设计 | 解决"跨副本共享",常配 vLLM |
| DeepSeek 硬盘缓存 | 块级(公告为 64 token,实测 128) | 分布式文件系统上的 KV 落盘 | 硬盘阵列 | 全量默认开 | 按 user 逻辑隔离 | 存储不额外收费;不保证 100% 命中 |
| OpenAI Prompt Caching | 块级(≥1024 token,128 递增) | 未公开 | 未公开 | 自动 | 组织间不共享;prompt_cache_key 辅助路由 | 无需改代码;折扣随模型代际变化 |
| Anthropic Prompt Caching | 显式断点 | 未公开 | 未公开 | 手动 | 组织间不共享 | 写入收费,需算回本点 |
| Gemini Context Caching | 隐式 + 显式缓存对象 | 未公开 | 未公开 | 隐式自动 | 项目级隔离 | 显式模式有存储费,按小时计 |
6.3 该选哪个:一张决策表
不用背上面的表,按这张图走一遍就行。

| 你的情况 | 建议 |
|---|---|
| 调商业 API,长系统提示,改不动基础设施 | 直接用厂商的 Prompt Caching,重点做 5.1 和 5.2 的 Prompt 改造 |
| 自建推理,多轮对话/Agent 为主 | SGLang(RadixAttention),配合 LPM 调度 |
| 自建推理,批处理/模板化 Prompt 为主 | vLLM(APC),块级匹配足够,生态更成熟 |
| 多副本集群,缓存命中率上不去 | 加前缀感知路由,这是"最后一公里" |
| 需要跨机器、跨进程共享大量历史上下文 | LMCache / Mooncake 这类 KV 存储层 |
| 对外 SaaS,多租户 | 无论选哪个,先做 salt 隔离和 TTFT 噪声处理 |
| 本地/边缘部署 | llama.cpp 的 slot 机制,配合 keep_alive 保活 |
七、常用场景:哪些业务天生适合
场景一:Agent / 工具调用(收益最大)
Agent 的每一轮都要重发:系统提示 + 工具定义 + 全部历史 + 工具返回结果。这部分内容的重复度极高,而且随着步数增加,前缀越来越长。
某厂商公开的数据是:Agent 流量里 96% 的输入不需要算第二遍。这不是夸张,因为第 20 步的输入里,前 19 步的内容全都在。
优化要点:工具定义按 name 排序;工具返回结果截断后再入历史(别把整页 HTML 塞进去);不要在历史里插入动态的"当前时间"。
场景二:RAG 问答
同一份文档被问 100 个问题,是最典型的形状。
优化要点:把"检索到的文档"放在"用户问题"之前;多文档场景下按固定顺序拼接(按 doc_id 排序,别按检索分数排——分数每次都可能微妙变化);如果文档集稳定,考虑用厂商的显式缓存对象(Gemini cachedContent、Anthropic 断点)把它单独固化。
场景三:长人设角色扮演 / 陪伴类应用
人设设定动辄几千 token,且永不变化,每次请求都带着。
优化要点:人设和世界观设定全部前置;用户的临时状态(好感度、时间、场景)放到最后;多轮对话本身天然形成长前缀,别随手截断历史——截断历史会让整段前缀失效,省下的 token 远不如重算的成本。
场景四:代码助手 / 仓库级分析
系统提示 + 代码规范 + 仓库结构 + 当前文件内容,全都重复。
优化要点:仓库级上下文(目录树、依赖图)放在前面,当前编辑的文件放后面;避免把光标位置、行号这类高频变化的元信息塞进前缀。
场景五:批量数据分析 / 同一份数据问很多问题
几十页的财报、一份几万行的日志,问 50 个问题。
优化要点:这份数据放最前,50 个问题依次追加。用离线批处理(Batch API)还能叠一层折扣。
场景六:多轮客服 / 工单处理
系统提示 + 知识库片段 + 工单历史。
优化要点:知识库片段按固定顺序拼接;工单状态(“已升级”“待回访”)这类会变的字段放最后。
场景七:模型评测 / A-B 测试
同一个 prompt 跑不同参数、不同模型版本。
优化要点:注意换模型版本会让缓存全部失效——权重变了,同样的输入算出来的 KV 就不是原来那套。所以做版本对比评测时,成本会明显高于稳态运行,做预算要留余量。
场景八:Few-shot 批量分类
同一组示例,几万条待分类文本。
优化要点:示例放最前且顺序固定,待分类文本放最后。示例本身不要为了"省 token"而删——它在前缀里,第二次开始就是命中价,删了反而亏。
八、面试高频题:12 个问题,从浅到深
Q1:前缀缓存和 KV Cache 是什么关系?
KV Cache 是单次请求内部的机制,避免解码时重复计算前文;前缀缓存是跨请求的机制,避免不同请求重复计算相同前缀。前缀缓存复用的对象就是 KV Cache。可以理解为:KV Cache 是"内存",前缀缓存是"内存的跨进程共享"。
Q2:为什么只有从第 0 个 token 开始相同才算命中?
因为因果注意力下,第 N 个 token 的 KV 依赖第 1 到第 N 个 token 的全部信息。中间某一段即使字面相同,它在前文不同的语境下算出的 KV 也是不同的。所以只有从开头连续相同,KV 才逐位相等。
Q3:为什么改一个字,后面全部失效?
级联依赖。第 K 个 token 变了,第 K+1 个 token 的 KV(它看过第 K 个)就变了,K+2 又看过 K+1……一直到结尾。所以是"从改动点开始全部失效",不是局部失效。
Q4:vLLM 的 APC 和 SGLang 的 RadixAttention 本质区别是什么?
匹配粒度不同。APC 是块级(block 内固定大小,只缓存完整块),用"块内容哈希 + 父块哈希"的链式 key 保证前缀性;RadixAttention 是 token 级,用基数树按 token 共享路径,能吃下任意长度的共享前缀。
代价也不同:APC 实现简单、和 PagedAttention 天然契合,但块尾会浪费;RadixAttention 更精细、多轮对话场景吞吐更高,但树结构的管理和并发控制更复杂。
Q5:块大小怎么影响收益?
块越大,元数据开销越小、查表越快,但尾部浪费越多(共享前缀长度不是块大小整数倍时,尾巴上那块用不上)。块越小,匹配越精细,但哈希表更大、查表更慢。16 和 128 是常见折中。
Q6:怎么估算前缀缓存能省多少钱?
三步:
- 统计线上请求的"前缀长度分布"和"每次新增内容长度";
- 算出理论命中率 = 可复用 token 数 / 总输入 token 数;
- 用命中价和未命中价的差额乘上命中量。
注意只能省输入侧,输出永远是全价。所以如果业务是"短输入长输出"(比如创作类),上限就很低。
Q7:为什么多副本部署时缓存会失效?怎么解?
每个副本的缓存是私有的。轮询负载均衡会把同一前缀的请求打到不同机器,每台都只命中自己那部分。解法是前缀感知路由:按前缀哈希做一致性路由,把同前缀请求固定到同一副本。或者用 LMCache 这类共享 KV 存储层,让缓存不属于任何单机。
Q8:多租户共享前缀缓存有什么安全问题?
TTFT 侧信道。命中缓存的响应明显更快,攻击者可以通过测量延迟判断某段前缀是否已被计算过,进而逐 token 重建他人的 prompt。已有 PromptPeek 等公开攻击,在未防护的 vLLM/SGLang 上成功率很高,vLLM 也分配了 CVE。
防护:per-tenant 的随机 cache_salt(必须用 HMAC 派生,不能用 tenant_id 本身)、租户级命名空间隔离、关闭跨租户的块共享、必要时对 TTFT 加噪声。注意 cache_salt 只是缓解,不是隔离边界。
Q9:为什么缓存命中只影响输入价格,不影响输出价格?
输出是逐 token 现场生成的,每个 token 都依赖前面刚生成的内容(包括采样随机性),不存在"上次算过"这回事。前缀缓存省掉的是 prefill 的算力,而 prefill 只发生在输入侧。所以 90% 的缓存折扣,作用在"输入成本"这个子项上,不是整张账单。
Q10:前缀缓存和语义缓存的区别?
前缀缓存是无损的、token 级的、进程/引擎内的,复用中间状态,输出与不缓存完全一致;语义缓存是有损的、语义级的、应用层的,复用最终答案,相似度阈值没调好会答错。
前者应该默认开,后者要谨慎评估。两者也不冲突,可以叠加。
Q11:LRU 淘汰有什么问题?什么场景下不够用?
LRU 假设"最近用过的还会再用"。但真实流量里存在长尾热点:一段几万 token 的公共前缀每天只用几十次,但每次都很贵;按 LRU 它很容易被短请求挤出去,导致反复重算。
改进方向:按"复用价值"加权(长度 × 预计复用次数)、对超高价值的公共前缀做 pin(钉住不淘汰)、分层存储(热的在显存、温的在内存、冷的在 SSD)。
Q12:线上命中率突然从 90% 掉到 10%,你怎么排查?
按这个顺序,基本 5 分钟能定位:
1. 看流量结构 -> 是不是来了一大批互不相关的新请求(正常波动)
2. 看前缀内容 -> 系统提示/工具定义最近有没有改动(最高频原因)
3. 看序列化 -> JSON key 顺序、分隔符、浮点格式有没有变(隐蔽但常见)
4. 看模型版本 -> 换模型/换权重会让所有缓存失效
5. 看部署 -> 是否扩容了新副本、路由策略是否变了(缓存被打散)
6. 看会话 -> 是否大量新开会话(session_id 变了,缓存空间换了)
7. 看容量 -> 缓存池是否被打满,淘汰速率是否突增
8. 看参数 -> temperature/top_p 等参数变化是否影响了请求分组
排查的抓手就是 5.6 里的那两个护栏:前缀指纹测试告诉你"是不是内容变了",分租户命中率监控告诉你"是谁的、什么时候变的"。
九、收尾:一页纸的行动清单
这篇文章讲的其实只有一句话:
模型只会重算它没算过的东西。你要做的,是让它少遇到"没算过的东西"。
落到执行上,按这个顺序做,投入产出比最高:
今天就能做(0 成本,收益 50% 以上)
- 检查系统提示第一行——有没有时间戳、请求 ID、用户名这类每次都变的东西
- 把内容按"变化频率"降序重排:系统规则 → 工具定义 → few-shot → 文档 → 用户问题 → 元信息
- 工具列表按 name 排序,JSON 用
sort_keys=True+ 紧凑分隔符 - 别随手截断对话历史,截断的代价是整段前缀失效
这周能做(1—2 天)
- 在代码里记录
cache_hit / cache_miss,按租户/接口维度出命中率看板 - 加一个前缀指纹单测,把当前稳定层的哈希钉住
- 算一次 ROI:用 5.7 的模拟脚本跑一遍你的真实前缀长度分布
这个月能做(架构级)
- 自建推理的,确认
--enable-prefix-caching真的生效(去日志里 grep) - 调
gpu_memory_utilization和kv_cache_dtype,把缓存池调大 - 多副本的,上前缀感知路由,别用轮询
- 多租户的,加
cache_salt,并做一次安全评审
最后提醒一句
前缀缓存最容易被忽略的一点是:它不是一个"打开开关"的功能,而是一种"Prompt 的组织纪律"。
开关打开只解决"引擎支不支持",命中率高低取决于你的 Prompt 写得稳不稳定。同一个小模型、同一个引擎,Prompt 组织得好,命中率 90%;组织得差,命中率 8%。差的那 82 个百分点,全在"顺序"这两个字上。
参考资料(均为公开资料,价格与版本以各厂商官网为准)
- vLLM 官方文档:Automatic Prefix Caching /
cache_salt/ PagedAttention - SGLang 论文与文档:RadixAttention、LPM 调度
- DeepSeek 2024-08 公告《DeepSeek API 创新采用硬盘缓存,价格降低一个数量级》及后续上下文缓存文档
- Anthropic 官方定价文档:Prompt Caching 倍率与 TTL
- OpenAI Cookbook:Prompt Caching 101 / 201
- NVIDIA Dynamo、LMCache、llm-d:KV 缓存分层与前缀感知路由
- NDSS 2025 PromptPeek、KVGov 等 KV 缓存侧信道研究
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。
更多推荐

所有评论(0)