大模型的公共前缀:同一段 Prompt 每轮重算,你的账单因此差了 50 倍

一段 4000 token 的系统提示词,跑一万次请求,不优化要 84 元,优化后 5.6 元。
代码一个字没改,只是把"会变的东西"挪到了后面。

在这里插入图片描述

文章目录

一、痛点场景:三张真实的账单,都指向同一件事

场景一: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 张卡就扛住了。这不是省钱的问题,这是能不能做得起的问题。

什么时候不划算

诚实地说,有三种情况收益很差:

  1. 前缀太短。 各家 API 都有最小可缓存长度(常见 1024 token,部分模型 2048)。低于这个长度,缓存根本不触发。自建引擎虽然没这个门槛,但块级匹配的最小单位(16 或 128 token)以下也是白搭。
  2. 每次请求都完全不一样。 一次性批处理、每个用户问完全无关的问题、每次生成随机种子都不同——命中率接近 0。
  3. 需要显式写入费的场景,读次数太少。 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%

三条结论,全在数字里:

  1. 前缀越长、后缀越短,命中率越高(第一行 vs 第二行)。这就是为什么"把动态内容挪到最后"这么重要——它直接改变了后缀长度。
  2. 前缀短的时候,优化基本没用(第三行)。20 个 token 的前缀,命中率只有 16%,不值得折腾。
  3. 复用次数越多越值(第四行 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 RadixAttentiontoken 级基数树(radix tree)GPU 显存默认开需自行在网关侧隔离多轮/Agent 场景吞吐更优;支持 LPM 调度
TensorRT-LLM块级块哈希 + 显存池GPU 显存需配置支持NVIDIA 硬件上性能最好,绑定较深
llama.cppslot 内最长公共前缀多 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:怎么估算前缀缓存能省多少钱?

三步:

  1. 统计线上请求的"前缀长度分布"和"每次新增内容长度";
  2. 算出理论命中率 = 可复用 token 数 / 总输入 token 数;
  3. 用命中价和未命中价的差额乘上命中量。

注意只能省输入侧,输出永远是全价。所以如果业务是"短输入长输出"(比如创作类),上限就很低。

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 缓存侧信道研究

本文为原创文章,如需转载,请联系作者获得授权,并注明出处。

Logo

更多推荐