KV vs Prefix vs Prompt vs Semantic Caching

你以为“开了缓存”就一定又快又省钱,其实很多团队连自己开的是什么缓存都没搞清。KV 缓存、前缀缓存、提示缓存、语义缓存这四个名字听着像一回事,但踩坑方式完全不同。搞懂它们分别存什么、怎么命中、什么时候会害你多花钱,是把 LLM 跑顺的关键一步。

LLM 堆栈里,这四种组件都被叫做缓存,却各自存着完全不同的对象。

  • KV 缓存:存的是单次请求里,每一层注意力的 key / value 张量。
  • 前缀缓存:把这些 KV 张量搬到服务端共享池,用 token ID 的哈希链当键。
  • 提示缓存:云厂商包装好的“计费版前缀缓存”,读写按输入 token 价的不同倍数收费。
  • 语义缓存:存的是完整响应字符串,用嵌入向量的余弦相似度做模糊匹配键。

前三种都是“精确匹配”:命中与否只影响成本和延迟,不影响答案对不对。语义缓存则是“模糊匹配”:可能给你一个看起来很自信、但其实错的答案,而且 HTTP 状态码依然是 200。下面就按层拆开讲它们的原理、代码示例和生产隐患。

据一些云厂商公开数据,大规模启用前缀/提示缓存后,平均预填充成本可以下降 30%–60%,但不少团队因为提示里夹杂时间戳、trace id 等变量,实际命中率却不到 5%。

所有示例都在单机 CPU 上跑一个 3.6 亿参数模型,外加一个 Anthropic API 示例和一个基于 sentence-transformers 的小语义缓存。服务引擎内部的机制用伪代码说明逻辑,不在笔记本里完整复现。

transformers 库在 v5 改了缓存 API,下面代码都假设 v5 及以上:v4 用法是 DynamicCache() 无配置参数,且参数名是 torch_dtype 而不是 dtype。

  • 基础依赖:
    • pip install "transformers>=5.0" torch
  • 量化缓存示例:
    • pip install optimum-quanto
  • 语义缓存示例:
    • pip install sentence-transformers
  • 提示缓存示例:
    • pip install anthropic

1)KV 缓存:单次请求内的“记忆”

KV 缓存到底在存什么

预填充阶段,模型会为每个提示 token、在每一层计算 key 和 value 向量,并把它们存进缓存。解码阶段,每生成一个新 token,就用这些已经算好的 KV 做注意力计算,只为新 token 再追加一对 key-value,而不是每一步都重算整段历史。

查询向量(query)不会被缓存,原因是因果掩码:某个 token 的 query 只在它自己那一步用一次,之后就没人再读它了;而 key 和 value 会被后续所有 token 反复读取,所以才值得缓存。

不启用 KV 缓存时,每一步解码都要对“到目前为止的整段序列”做一次矩阵乘法,复杂度是 O(T²)。有了 KV 缓存,每一步只对新 token 做矩阵-向量乘法,复杂度接近 O(T)。

说实话,这种优化也不是免费的:虽然算力开销降下来了,但每一步都要从高带宽显存里把整块缓存搬出来,解码过程从“算力瓶颈”变成“内存带宽瓶颈”。注意力核本身跑得飞快,GPU 很多时间都在等内存。

KV 缓存如何随 token 增长

transformers 把缓存当一等公民暴露出来,你可以自己持有、检查、再传回去用。下面是一个最小示例:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

inputs = tokenizer("The capital of France is", return_tensors="pt").to(model.device)

past_key_values = DynamicCache(config=model.config)

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    past_key_values=past_key_values,
)

print(tokenizer.decode(out[0], skip_special_tokens=True))
print("prompt tokens:", inputs["input_ids"].shape[1])
print("total tokens:", out.shape[1])
print("cache length:", past_key_values.get_seq_length())
  • generate 通常会在内部创建和销毁缓存,对用户是透明的。
  • 这里我们手动构造 DynamicCache 并传入,生成结束后还能继续访问它。
  • get_seq_length() 返回缓存里记录了多少个 token 位置,等于“提示长度 + 生成 token 数 - 1”。
  • 最后一个 token 的 KV 已经算出来,但还没被任何后续 token 关注过。

DynamicCache 会随着生成动态增长,不预分配整块内存,短请求不会白占空间。这也是它被设为默认实现的原因。

缓存大小直接决定了 GPU 上能并行多少条序列。它由模型结构固定,并且随 token 数线性增长,因为每一层、每个注意力头都要存一份 key 和 value 张量。以一个 700 亿参数 BF16 模型为例,128K 上下文的 KV 缓存大约要 40GB,已经接近 4-bit 量化权重的体积。

常见的“瘦身”手段包括:

  • 分组查询注意力(Grouped-query attention):一组 query 头共享同一份 key/value,缓存体积缩小、每字节 FLOPs 提升。

  • DeepSeek 系列的多头潜变量注意力:把整块缓存压缩成一组潜变量向量。
  • 缓存量化:用更低精度存 KV,换一点数值精度,换来约 2 倍容量,transformers 已经内置:
out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=20,
    cache_implementation="quantized",
    cache_config={"nbits": 4, "backend": "quanto"},
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
  • 这两个参数把默认缓存替换成量化缓存。
  • KV 以低精度存储,每次访问都要做量化/反量化。
  • 后端要求组大小能整除头维度,一些特殊架构会直接拒绝配置。
  • 对短上下文来说,额外开销可能让速度变慢,更适合“内存吃紧但延迟还能忍”的场景。

KV 缓存如何跨轮次重用

上面的例子都在一次 generate 调用里完成,请求结束后引擎会释放缓存块。假设你有 20 轮对话,在第 20 轮时,模型会重新预填充第 1–19 轮的所有内容,成本一分不少。

另一种玩法是:跨轮次把缓存留住。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

past_key_values = DynamicCache(config=model.config)
messages = []

questions = ["What is the capital of France?", "And its population?"]

for prompt in questions:
    messages.append({"role": "user", "content": prompt})

    inputs = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        return_tensors="pt", return_dict=True
    ).to(model.device)

    input_length = inputs["input_ids"].shape[1]
    outputs = model.generate(
        **inputs, do_sample=False,
        max_new_tokens=64,
        past_key_values=past_key_values,
    )

    completion = tokenizer.decode(outputs[0, input_length:], skip_special_tokens=True)
    messages.append({"role": "assistant", "content": completion})
    print(f"turn tokens in: {input_length} | cache now: {past_key_values.get_seq_length()}")

输出类似:

  • turn tokens in: 42 | cache now: 55
  • turn tokens in: 71 | cache now: 92

要点:

  • past_key_values 只在循环外创建一次,每轮都传进去,第一轮结束后不会被释放。
  • 每轮都重建完整消息列表并用 apply_chat_template 渲染,第二轮的提示包含第一轮所有内容加新问题。
  • 因为缓存里已经有第一轮的 token,模型只需要为“新增后缀”做预填充,虽然 input_length 在变大,真实预填充工作量却差不多。
  • 回答从生成结果里切片出来再塞回 messages,下一轮的提示就是上一轮的严格扩展。

这种重用只在“第二轮的 token 序列以第一轮为前缀且完全相同”时有效,一旦你编辑了更早的内容,缓存就作废了。我自己的经验是,一旦把这套逻辑搬进服务端,调试“为什么没命中缓存”的时间会远超你预期。

更深入的 KV 细节(包括预填充/解码拆分、内存公式、各步张量打印)可以看 RAG 系统课程第 12 部分。

2)前缀缓存:把 KV 变成“共享资产”

前缀缓存如何在引擎里工作

前缀缓存的核心行为是:请求结束后,服务引擎不再立刻释放 KV 块,而是把它们留在内存里,挂到一个索引表上,供后续请求查找。这就是所谓的“前缀缓存”。

索引必须遵守聊天循环的规则:只有“前面所有 token 完全相同”时才允许重用。以 vLLM 为例,它默认把 token 序列切成 16 个 token 一块的缓存块,每块用“父块哈希 + 当前块 token ID 哈希”链起来,形成唯一键。

父哈希滚入子哈希,使得块查找天然是“前缀查找”:只有前面所有块都匹配,当前块才算命中。调度器会按顺序遍历这些块,一旦遇到第一个未命中就停下,命中的块会把引用计数 +1,防止被驱逐;未命中之后的部分则重新分配并预填充。

前缀缓存的查找伪代码

vLLM 在调度器内部跑这套逻辑,真正的张量管理由内存管理器负责。下面的伪代码只保留“如何决定能重用多少 token”这件事:

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """将 token 序列链式哈希为每块键。"""
    hashes, parent = [], hash(salt)

    # 仅完整块被哈希,尾部不完整块跳过。
    for start in range(0, len(token_ids) - BLOCK_SIZE + 1, BLOCK_SIZE):
        block = tuple(token_ids[start : start + BLOCK_SIZE])
        parent = hash((parent, block))
        hashes.append(parent)

    return hashes


def schedule(token_ids, cache):
    """返回可重用 token 数和需预填充的剩余 token。"""

    matched_blocks = 0

    for h in block_hashes(token_ids):
        if h not in cache:
            break                      # 首次未命中结束重用
        cache[h].ref_count += 1        # 锁定防止驱逐
        matched_blocks += 1

    reused_tokens = matched_blocks * BLOCK_SIZE
    to_prefill = token_ids[reused_tokens:]

    return reused_tokens, to_prefill
  • block_hashes 把 token 序列按 16 个一组切块,每块的键通过 hash((parent, block)) 递归折叠前面所有块。
  • 范围止于 len(token_ids) - BLOCK_SIZE + 1,尾部不完整块不进索引,每次请求都要重算。

  • schedule 顺序遍历块键,遇到第一个未命中就停,不会尝试“跳过一个块再匹配后面的块”,因为后续块键本身就依赖前缀。
  • ref_count += 1 表示这个块正在被某个请求使用,只有引用计数为 0 的块才允许被驱逐。

这里有个容易被忽略的细节:salt 参数。

  • 如果两个请求发来完全相同的文本,且 salt 一样,就会生成相同的块键,指向 GPU 里同一块 KV 张量,真正做到“多请求共享一份缓存”。
  • 如果你需要租户隔离,可以给每个租户传不同的 salt,这样即便文本相同,块键也不同,缓存不会跨租户共享。
  • 代价是内存占用和命中率下降,但隔离性更好。

transformers 里的“前缀复用”玩法

transformers 也支持“先预填充一段前缀,再在多个续写之间复用缓存”:

import copy
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, StaticCache

model_id = "HuggingFaceTB/SmolLM2-360M-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16, device_map="auto"
)

SHARED_PREFIX = """You are a careful assistant.
Answer in one short sentence."""

prompt_cache = StaticCache(config=model.config, max_cache_len=1024)

prefix_inputs = tokenizer(SHARED_PREFIX, return_tensors="pt").to(model.device)

# 预填充共享前缀,仅运行一次,无采样
with torch.no_grad():
    prompt_cache = model(**prefix_inputs, past_key_values=prompt_cache)
    prompt_cache = prompt_cache.past_key_values

questions = ["What is the capital of France?", "Name one ocean."]

for question in questions:
    inputs = tokenizer(SHARED_PREFIX + question, return_tensors="pt").to(model.device)

    # 每请求独立缓存副本
    past_key_values = copy.deepcopy(prompt_cache)

    outputs = model.generate(
        **inputs, past_key_values=past_key_values, do_sample=False
    )
    print(tokenizer.decode(outputs[0], skip_special_tokens=True))

关键点:

  • StaticCache 而不是 DynamicCache,因为要固定分配、方便复制。
  • model(...) 这次调用只做预填充,不做采样,返回的 past_key_values 就是“前缀缓存”。
  • 循环里每个问题都拼在同一个前缀后面,前缀 token ID 每次都一样,满足哈希链的前缀条件。
  • copy.deepcopy 给每个请求一份独立副本,因为 generate 会在缓存上原地追加,如果不复制,第一条问题的生成会“污染”第二条问题的前缀。
  • 真正的服务引擎不会复制张量,而是共享物理块、用引用计数管理,实现几乎零成本的重用。

驱逐策略与 RAG 的“反直觉”问题

块大小是个需要调的参数:

  • 块大:查表次数少、内存局部性好,但尾部浪费多。
  • 块小:共享更细粒度,但查表开销变大。

缓存和运行批次共用 GPU 内存池,缓存越大,可并行的序列就越少。vLLM 在压力下会按 LRU 驱逐未被引用的块。混合流量时,长共享前缀占用的块最多,一旦被驱逐,对命中率的打击也最大。

使用前缀缓存前,有两件事要想清楚:

  • 它只节省预填充时间,解码时间完全不变,不能把所有加速都算在它头上。
  • 哈希和查表本身有开销,如果你的提示几乎从不重复,基准测试里吞吐量可能还会略降。

更棘手的是 RAG 工作负载。

rag

RAG 的提示通常包含:系统指令 + 检索到的文档块 + 用户查询。文档块每次检索都可能变化,顺序也可能不同。哪怕两次请求检索到的是同一批文档,只要顺序不一样,在链式哈希下就完全无法共享。

rag-order

“那我把每个文档块单独预填充,再把缓存拼起来不就行了?”——听上去很美,但会被位置编码和跨块注意力打脸:

pos-encoding

  • 简单拼接 KV 张量会导致位置编码错位,每块都以为自己从位置 0 开始。
  • 块与块之间没有正确的交叉注意力,边界处必须部分重算,而不是“无脑拼接”。

开源社区已经给出了一些更聪明的方案。比如 LMCache 的 CacheBlend:

lmcache

  • 它不是简单拼接块缓存,而是允许在任意位置重用 KV。
  • 只重算“对全注意力偏差最大”的少量 token,用这部分来恢复跨块注意力和位置编码。
  • 输出质量可以做到和“完全预填充”几乎一致。

相较于全量重算,首 token 延迟可以提升约 2–3 倍,重算成本还能和“从慢存储拉缓存块”并行。它已经支持 vLLM,可以自动从提示里识别块边界,即便文档顺序不同也能复用。

lmcache-2

仓库地址:https://github.com/LMCache/LMCache

3)提示缓存:云厂商包装好的“计费前缀缓存”

提示缓存的接口与计费

托管模型不会把块表和驱逐策略暴露给你,而是提供一个更高层的“提示缓存”能力,再配上几个控制参数。底层存的仍然是 KV 张量,而不是提示文本,命中规则也还是“渲染后的上下文前缀必须完全一致”。

prompt-cache

渲染后的上下文里还包含了提供商侧的系统内容,所以从外部看,最小缓存长度和失效规则会显得有点“黑盒”。下面用 Anthropic 的接口演示一下:

import anthropic

client = anthropic.Anthropic()   # 从环境变量读取 ANTHROPIC_API_KEY

LONG_INSTRUCTIONS = "You are a precise technical editor. " * 400


def ask(question: str):
    return client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system=[
            {
                "type": "text",
                "text": LONG_INSTRUCTIONS,
                "cache_control": {"type": "ephemeral"},   # 上方内容可缓存
            }
        ],
        messages=[{"role": "user", "content": question}],
    )


for question in ["Summarize section 3.", "Now rewrite it for a beginner."]:
    resp = ask(question)
    u = resp.usage
    print(
        f"write={u.cache_creation_input_tokens} "
        f"read={u.cache_read_input_tokens} "
        f"uncached={u.input_tokens}"
    )

输出类似:

  • write=2823 read=0 uncached=14
  • write=0 read=2823 uncached=17

只有一行和缓存直接相关:"cache_control": {"type": "ephemeral"}

  • 这个标记挂在你想“覆盖”的最后一个块上,写入一条缓存条目,覆盖从请求开头到该块的所有内容。
  • 用户消息在标记下面,每次都变,所以不会被缓存。
  • usage 里的计数器能帮你确认缓存是否真的在工作:第一次调用写入非零、读取为零;第二次相反,说明指令部分按输入费率的 0.1 倍计费。
  • 如果两者都为零,通常意味着“前缀长度低于模型的最小缓存长度”,这时不会报错,只是没用上缓存。

直觉上,如果你把 cache_control 挪到用户消息那一层,读取计数几乎永远是 0,因为那一块每次都在变。

提示缓存的经济学:什么时候值回票价

Anthropic 的定价是:

  • 写入缓存:按基础输入费率的 1.25 倍计费。
  • 读取缓存:按基础输入费率的 0.1 倍计费。

OpenAI 的新模型也采用了类似的倍数结构。也就是说,你为“写入”付了一笔溢价,希望在后续多次“读取”中把这笔钱赚回来。

有用户反馈,在一个内部知识库问答系统里,把长系统指令和公司政策放进提示缓存,平均每条会话能复用 5–10 次,整体输入成本下降了约 40%。但一旦频繁改系统提示,缓存就几乎废掉。

关键点:

  • 读取只能命中你之前写入过的断点;
  • 写入只会发生在你显式标记的那一层;
  • 每次调用都会从最近的断点往前回溯有限数量的块(Anthropic 限制为 20 块),超过这个范围就不再命中。

prompt-window

还有一种玩法是“预填充语料库”:提前把一批常用文档喂给模型,让它们进缓存,后续查询直接复用。这在一些高频 FAQ 或固定模板场景里很有用。RAG 课程第 13 部分里有详细的回本计算和实现示例。

4)语义缓存:用嵌入“猜”你要什么

语义缓存的基本思路

前面三种缓存都只是在“省预填充算力”,模型本身还是要跑一遍。语义缓存则更激进:

  1. 对输入提示做一次嵌入;
  2. 在历史提示的嵌入里做最近邻搜索;
  3. 如果相似度超过阈值,直接返回之前存好的响应,完全跳过 LLM 调用。

semantic-cache

所以它缓存的是“输入 + 输出文本”,而且每次请求都要做一次嵌入计算,即便最后没命中也一样。下面是一个极简实现:

import numpy as np
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")


class SemanticCache:
    def __init__(self, threshold=0.95):
        self.threshold = threshold
        self.vectors = np.empty((0, encoder.get_sentence_embedding_dimension()))
        self.prompts, self.responses = [], []

    def _embed(self, text):
        return encoder.encode([text], normalize_embeddings=True)[0]

    def lookup(self, prompt):
        vec = self._embed(prompt)
        if len(self.prompts) == 0:
            return None, 0.0, vec
        scores = self.vectors @ vec           # 余弦相似度,向量已归一化
        best = int(np.argmax(scores))
        if scores[best] >= self.threshold:
            return self.responses[best], float(scores[best]), vec
        return None, float(scores[best]), vec

    def store(self, prompt, response, vec):
        self.vectors = np.vstack([self.vectors, vec])
        self.prompts.append(prompt)
        self.responses.append(response)


cache = SemanticCache(threshold=0.95)


def answer(prompt, call_model):
    hit, score, vec = cache.lookup(prompt)
    if hit is not None:
        return hit, f"HIT  (score {score:.3f})"
    response = call_model(prompt)             # 昂贵路径
    cache.store(prompt, response, vec)
    return response, f"MISS (best {score:.3f})"


fake_model = lambda p: f""

for q in [
    "How do I reset my password?",
    "How can I reset my password?",
    "Is the API rate limited?",
]:
    _, status = answer(q, fake_model)
    print(f"{status}  {q}")

输出类似:

  • MISS (best 0.000) How do I reset my password?
  • HIT (score 0.961) How can I reset my password?
  • MISS (best 0.112) Is the API rate limited?

类里的每个方法,都对应生产环境里要做的几个判断:

  • normalize_embeddings=True 把向量单位化,这样点积就是余弦相似度,不同长度的提示也能公平比较。
  • lookup 把嵌入向量一并返回,answer 在未命中时可以直接拿来存储,避免重复算嵌入。
  • 示例里用的是线性搜索,真实系统要换成近似最近邻索引(如 HNSW、IVF),并调好召回率。
  • store 在未命中时直接把“模型响应”塞进缓存,没有任何正确性验证,这就是最大风险来源。

语义缓存的“隐形炸弹”

下面这段代码能直观展示风险:

pairs = [
    ("How do I reset my password?", "How can I reset my password?"),
    ("Is the API rate limited?",     "Is the API not rate limited?"),
    ("Refund policy for annual plans", "Refund policy for monthly plans"),
]

for a, b in pairs:
    va, vb = encoder.encode([a, b], normalize_embeddings=True)
    print(f"{float(va @ vb):.3f}   {a!r}  vs  {b!r}")

输出类似:

  • 0.961 'How do I reset my password?' vs 'How can I reset my password?'
  • 0.952 'Is the API rate limited?' vs 'Is the API not rate limited?'
  • 0.887 'Refund policy for annual plans' vs 'Refund policy for monthly plans'

解释一下:

  • 第一对是同义句,共享答案没问题。
  • 第二对只多了一个“not”,答案应该完全相反。
  • 第三对是不同计费周期,很多公司这两种套餐的退款政策差别很大。

但三组相似度都很高,第一和第二组只差 0.009,远远不足以在真实流量中可靠区分。你可以:

  • 把阈值调高,命中率会大幅下降,但嵌入成本还在;
  • 把阈值调低,命中率上去,错误答案率也一起上去。

这只是我自己的观察:不少团队在早期试验语义缓存时,会被“命中率”这个数字迷惑,以为命中越高越好,却没同步监控“错误命中率”,结果线上 FAQ 系统开始给出逻辑相反的回答。

不同产品给的默认阈值从 0.75 到 0.97 都有,说明这件事高度依赖你的具体业务和容错空间。语义缓存从设计上就不是 100% 可靠的,因为嵌入本身就会把一些“语义上关键的差异”压缩掉。

四种缓存的对比与一个“第五层”

summary-table

  • KV 缓存:单请求内的注意力状态,完全精确,未命中只多花算力。
  • 前缀缓存:把 KV 状态跨请求共享,仍然精确匹配,主要省预填充时间。
  • 提示缓存:云厂商在自家硬件上跑前缀缓存,并对“可重用部分”单独计费。
  • 语义缓存:基于嵌入相似度缓存“输入+输出文本”,命中时直接跳过模型,有可能给出错误答案。

还有一种“第五层”也值得一提:精确匹配响应缓存。它只在“请求字节完全相同”时返回缓存答案,同时缓存输入和输出,没有误判风险。建议先测一下“字节级完全重复”的比例,再考虑要不要上语义缓存。这一层也有自己的坑,比如版本回滚、策略更新等,欢迎你在实践中慢慢挖掘。

生产环境中的实战建议

如何避免把缓存白白浪费掉

很多团队开了缓存,却发现命中率惨不忍睹,常见原因包括:

  • 提示前面塞了各种变量:时间戳、请求 ID、用户名、A/B 标记等,导致后续所有块都无法重用。
  • 工具 schema 放在系统提示前面,一改顺序就让整段缓存失效。
  • 在 Anthropic / OpenAI 里频繁切换“是否启用搜索”“是否返回引用”“是否启用思考模式”等配置,这些都会重写提示文本,直接分裂缓存。
  • 对话历史做“摘要”时,把前缀整个重写了一遍,下一次调用又得重新预填充全历史。

bad-prefix

更稳妥的做法:

  • 把稳定内容放在前面,把高频变化的内容放在后面,并在边界处设置缓存标记。
  • 工具 schema 尽量固定位置,不要在系统提示前后来回挪。
  • 做历史摘要时,尽量“原地截断工具输出”,保证前缀字节级别不变,这样缓存还能命中。

truncate

还有几个容易忽略的点:

  • 缓存条目是绑定模型的,一旦你切到更便宜的模型,之前的缓存基本作废,需要重新预填充。

model-switch

  • 想精确知道两段提示从哪一 token 开始不一样,应该直接比较 token ID,而不是看日志文本。比如:
messages_turn_1 = [{"role": "user", "content": "What is the capital of France?"}]
messages_turn_2 = [
    {"role": "system", "content": "Today is Tuesday."},
    {"role": "user", "content": "What is the capital of France?"},
]

# 默认 tokenize=True,返回 token id 列表
a = tokenizer.apply_chat_template(messages_turn_1)
b = tokenizer.apply_chat_template(messages_turn_2)

shared = 0
for x, y in zip(a, b):
    if x != y:
        break
    shared += 1

print(f"shared prefix: {shared} tokens of {len(a)} and {len(b)}")
print(
    f"first divergence at index {shared}: "
    f"{a[shared:shared+8]} vs {b[shared:shared+8]}"
)

输出会告诉你:

  • 共享前缀只有 3 个 token;
  • 在索引 3 处开始分叉;
  • 第一轮其实被模板自动填了一个默认 system 消息,第二轮则是你显式写的 system 消息。

肉眼看日志,两轮提示好像差不多,但在 tokenizer 眼里已经完全不同。调缓存命中率时,直接看 token ID 是最省心的办法。

一位朋友在调试前缀缓存时,光靠肉眼看日志,愣是没发现模板自动插入的系统提示,结果命中率始终卡在 0%。换成 token 对比后,问题 10 分钟就解决了。

四层缓存的“认知地图”

可以把这四层理解成同一个思想在不同层面的投影:

  • KV 缓存:保存单次请求内的注意力状态。
  • 前缀缓存:请求结束后保留这些状态,供后续请求查找。
  • 提示缓存:云厂商在自家硬件上跑前缀缓存,并对重用部分单独计费。
  • 语义缓存:完全不同的路子,用嵌入相似度缓存“输入+输出文本”,命中时跳过模型调用,有可能给出错误答案。

如果你只记住一件事,那就是:前三种缓存命中失败,最多是“慢一点、贵一点”;语义缓存命中失败,可能是“快而错”,而且错得很隐蔽。

这一套判断方法在不同项目里反复验证都挺有用,建议你收藏下来,做新系统设计或排查性能问题时拿出来对照一遍。如果你正准备上线一个 RAG 或多轮对话系统,这篇内容往往比问身边人要靠谱得多。

常见问题

Q:怎么判断我该用前缀缓存还是提示缓存?

A:如果你自己控制推理引擎(比如用 vLLM、TGI),优先考虑前缀缓存;如果完全依赖云厂商 API,就只能用提示缓存。前缀缓存的好处是:你能掌控块大小、驱逐策略和租户隔离方式,调优空间更大;提示缓存则是“开箱即用”,但黑盒较多,主要通过计费统计和 usage 字段来侧面观察效果。实操建议是:自托管场景先在压测环境里打开前缀缓存,观察预填充时间和 GPU 利用率,再决定是否上线;云 API 场景则先在一小部分流量上标记系统提示为可缓存,监控 cache_read / cache_creation 的比例和总成本变化。

Q:语义缓存的相似度阈值应该设多少比较合适?

A:没有一个通用的“正确数字”,但可以用一个可操作的流程来选。先从 0.9 左右起步,采集一批真实请求,对每次命中进行人工抽样检查,记录“命中但答案不该复用”的比例;再把阈值往上调到 0.93、0.95,重复同样的抽样,画出“命中率 vs 错误命中率”的曲线。判断依据是:在你能接受的错误率上限(比如 1% 或 3%)下,选择命中率最高的那个阈值。额外提醒:不同业务线(客服 FAQ、代码补全、搜索重写)应该分别调参,不要共用一个阈值。

Q:KV 缓存量化会不会明显影响模型输出质量?

A:在 4bit 或 8bit 的 KV 量化下,多数主流模型在常见任务上的质量损失非常有限,远小于权重量化带来的影响。原因是:KV 只在推理时短暂存在,不参与训练更新,而且注意力本身对轻微噪声有一定鲁棒性。判断是否可接受的做法是:在你的关键任务上跑一组 A/B 测试,对比未量化和量化缓存的输出差异(可以用自动评估 + 人工抽样结合),同时监控首 token 延迟和吞吐变化。如果上下文较短、延迟要求极高,量化带来的反量化开销可能得不偿失,这种场景可以只在长上下文或低优先级请求上启用。

Q:为什么我开了提示缓存,usage 里 cache_read 还是一直是 0?

A:最常见的原因是“缓存断点放错了位置”或“前缀每次都在变”。比如你把 cache_control 标在用户消息上,而用户问题每次都不同,自然不会命中;或者系统提示里包含时间戳、实验标记等动态内容,导致渲染后的前缀字节级别不一致。排查建议:先把系统提示简化成完全静态文本,只在这一段上打缓存标记,连续发几次相同问题,看 cache_creation 和 cache_read 是否按预期变化;再逐步把真实配置项加回去,一旦命中率掉到 0,就说明是新加的那一项破坏了前缀一致性。

Q:RAG 系统里前缀缓存几乎命不中,还有必要折腾吗?

A:如果你的检索结果高度动态、文档顺序经常变化,传统链式哈希前缀缓存的收益确实有限。但仍有几种值得尝试的优化:一是把系统指令和工具 schema 固定下来,至少让这部分前缀能被缓存;二是对高频文档块做“预填充+缓存”,配合像 LMCache 这类支持任意位置重用的方案;三是对检索结果做排序规范化(比如按文档 ID 排序),减少“同一批文档顺序不同”的情况。判断是否值得做的标准很简单:在压测环境里打开这些优化,比较首 token 延迟和 GPU 利用率,如果收益低于你维护这套机制的工程成本,就可以果断关掉,把精力放在检索质量和模型选择上。

写到这里,其实每一层缓存都不只是“省点钱”的小技巧,而是你和模型之间的一种长期协作方式。哪一层最适合你,只有在真实流量和真实约束下跑过一轮,才会有答案。