LLM Inference · Caching

LLM 中的 KV Cache、Prefix Caching、Prompt Caching 与 Semantic Caching — 一次讲透

你的输入 token 到底在哪里被重复计算、该怎么办:从第一性原理讲清四层缓存机制、它们的取舍、相互之间的作用,以及阻碍缓存复用的五个最常见问题。

Avi Chawla · 译文
KV, Prefix, Prompt and Semantic Caching in LLMs

把理解「你的输入 token 在哪里被重复计算、又能做些什么」所需的一切一次讲清。本文从第一性原理出发覆盖四层缓存机制、它们的取舍、相互之间的作用,以及阻碍缓存复用的五个最常见问题。


LLM 技术栈里有四样东西,各自存储四种不同的对象,而它们全都被称为 caching(缓存)。

前三者是精确匹配(exact-match)、对正确性无影响(correctness-neutral),所以一次 miss(未命中)让你付出的是金钱和延迟。第四者是模糊匹配(fuzzy-match),它会在返回 200 状态码的同时递给你一个错误答案。

所以今天,我们把四者逐一过一遍:每一层存的是什么,又是什么在悄无声息地破坏它。

本文的所有内容都跑在一台机器上(包括纯 CPU),用的是 360M 参数的模型。另有一个 Anthropic API 示例,以及一个基于 sentence-transformers 搭的小型语义缓存。凡是只存在于推理引擎内部的机制,我们就用伪代码走一遍逻辑,而不是假装它能在笔记本电脑上复现。

另外,transformers v5 里 cache API 的形态变了,下面的代码片段假定 v5 或更高版本。在 v4 上,等价写法是不带 config 参数的 DynamicCache(),以及用 torch_dtype= 而不是 dtype=

pip install "transformers>=5.0" torch

# only for the quantized cache example
pip install optimum-quanto

# only for the semantic cache example
pip install sentence-transformers

# only for the prompt caching example
pip install anthropic

1)KV Cache

在 prefill 阶段,模型为每个 prompt token 在每一层计算一个 key 向量和一个 value 向量,并把它们存起来。

随后的 decoding 阶段就对这些已存储的向量做 attention,并为每个新生成的 token 追加一对新的 KV,而不是每一步都重算整个序列。

Query 不会被缓存,原因在于因果掩码(causal masking)。一个 token 的 query 向量只用一次——就在处理该 token 的那一步——之后再也不会被读取。而它的 key 和 value 会被排在它之后的每一个 token 读取,所以这两个才是最值得保存的向量。

下面的视频展示了有和没有 KV caching 时的 LLM 推理过程:(原文此处内嵌视频,见文末原链接)

虽然这减少了每个 token 上的计算量,但每一步都得把整个 cache 从 HBM 加载进来,所以 decode 不再是计算受限(compute-bound),而是变成了内存带宽受限(memory bandwidth-bound)。

Attention kernel 的执行速度快于 cache 流入的速度,于是 GPU 在 decode 步的大部分时间都在等内存。

KV cache 随每个 token 增长

transformers 库把 cache 暴露为一等公民对象,你可以持有它、检视它、再把它传回去。

下面是一个最小化的代码演示:

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")
inputs = inputs.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))
"""The capital of France is Paris. It is the largest city in
France and the second-largest city in the European Union."""

>>> print("prompt tokens: ", inputs["input_ids"].shape[1])
"prompt tokens: 5"

>>> print("total tokens: ", out.shape[1])
"total tokens:  25"

>>> print("cache length: ", past_key_values.get_seq_length())
"cache length:  24"

通常你调用 generate 方法时,cache 在内部创建又销毁,对你不可见。这里我们自己构造一个 DynamicCache 传进去,这意味着生成结束后我们仍然持有对它的引用。

get_seq_length() 报告 cache 持有的 token 位置数。运行之后你会发现,输出等于 prompt 长度加上生成的 token 数,再减一。

最后一个 token 的 key 和 value 被计算了,但不会再被任何东西 attend 到。

这段代码表明 cache 为每个见过的 token 持有一个条目,并且每个 decode 步恰好增长一个条目。

默认使用 DynamicCache,是因为它随生成推进而增长、而不是预分配,这样短请求就不会占用一块永远用不上的内存。

决定一张 GPU 上能装下多少请求的,正是 cache。它的大小由模型结构决定,并随 token 数线性增长,因为每一层都要为每个 KV head 持有一份 key 和 value 张量。

对 BF16 的 70B 模型来说,单个 128K 上下文的 cache 约 40 GB,相当于整个模型以 4-bit 权重量化后的大小。

有若干办法可以压缩它。比如 Grouped-query attention(GQA,分组查询注意力)让一组 query head 共享一个 key/value head,从而缩小 cache,并提高每加载一字节数据能换来的 FLOPs。

DeepSeek 系的多头潜在注意力(Multi-head Latient Attention, MLA)则把整件事压缩进一个潜在向量。

cache 量化(quantization)拿一点数值精度换大约翻倍的容量,transformers 也实现了它:

# requires: pip install optimum-quanto
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))

两个参数就把默认 cache 换成了量化版。

KV 值以降低的精度存储,这节省了内存,代价是每次访问都要量化和反量化。

后端还要求 group size 能整除模型的 head dimension,所以一个不常见的架构可能直接拒绝这个配置。

在短上下文上,这点开销可能反而让事情更慢而不是更快,所以它最适合内存吃紧的时候用。

cache 随请求一起释放

上面这一切都发生在一次调用内部。请求结束时引擎会释放这些 block,于是一段 20 轮的对话在第 20 轮要把第 1 到 19 轮全部重新 prefill 一遍,付全价。

自己把 cache 跨轮次保活,就能看到另一种做法:

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:
    # Add to the history
    messages.append({"role": "user", "content": prompt})

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

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

    # decode
    completion = tokenizer.decode(outputs[0, input_length:], skip_special_tokens=True)

    # Append to message history
    messages.append({"role": "assistant", "content": completion})
    print(f"turn tokens in: {input_length} | cache now: {past_key_values.get_seq_length()}")

# Output:
"turn tokens in: 42 | cache now: 55"
"turn tokens in: 71 | cache now: 92"

复用之所以成立,是因为第二轮的 token 序列以第一轮的 token 序列开头——逐位完全一致。只要你改动历史中更早的任何内容,cache 就失效。

在上面这个代码演示里,cache 属于一个进程里的一个 Python 变量。而在推理引擎里,它属于一个由成千上万个请求共同查询的共享池。接下来就讲这个。


2)Prefix Caching

上面讨论的共享池来自一处行为上的改变。

请求结束时,引擎把它的 KV block 留在内存里而不是释放掉,并保留索引,好让后来的请求能找到它们。这就是 prefix caching。

这个索引必须执行与聊天循环里相同的规则:只有当前面的 token 完全一致时,复用才有效。

vLLM 的做法是默认以 16 个 token 为单位存储 cache,并用「父 block 的哈希加上 block 内部的 token ID」再取哈希来标识每个 block。

把父哈希链进子 block,就把 block 查找变成了前缀查找,因为一个 block 只有在它之前的所有内容都匹配上时才会匹配。

调度器按顺序遍历传入的 block,在第一个 miss 处停下。一次命中会让该 block 的引用计数加一,这也让它在有请求正在使用期间不被驱逐。

从 miss 往后的一切都会得到全新分配和一次全新的 prefill。

查找代码

vLLM 在它的调度器里跑这套逻辑,外面裹着持有实际张量的内存管理。

下面的代码只保留决定复用的两个部分:把 token 序列变成 block key 的函数,以及遍历这些 key、算出前缀中有多少可以跳过 prefill 的函数。

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """Chain-hash a token sequence into per-block keys."""

    hashes, parent = [], hash(salt)

    # Only complete blocks are hashed. A partial tail block is skipped.
    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):

    """Return how many tokens are reusable, and allocate the rest."""

    matched_blocks = 0

    for h in block_hashes(token_ids):
        if h not in cache:
            break                      # first miss ends all reuse
        cache[h].ref_count += 1        # pin it against eviction
        matched_blocks += 1

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

    return reused_tokens, to_prefill

刚才讨论的代码里还有一件重要的事:

BLOCK_SIZE = 16

def block_hashes(token_ids, salt=None):
    """Chain-hash a token sequence into per-block keys."""

    hashes, parent = [], hash(salt)

    # Only complete blocks are hashed. A partial tail block is skipped.
    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

注意上面函数里的 salt 参数。

当两个请求发送完全相同的文本时,它们产生完全相同的 block key,于是指向 GPU 内存里同一批物理 KV block。这些张量只有一份,两个请求都读它。

两个请求来自同一个应用时,这正是你想要的行为。

但当它们来自不同客户时,这可能需要一个决策。把每个租户独有的值作为 salt 传入,会改变第一个父哈希,于是相同的文本现在为每个租户产生不同的 key,他们的请求永远不会落在同一批 block 上。

这样每个租户都得到自己的一份拷贝,代价是内存和命中率,换来的是隔离。

在 transformers 中的实现

transformers 允许你 prefill 一次 prompt,然后把得到的 cache 复用到若干个不同的续写上。

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")
prefix_inputs = prefix_inputs.to(model.device)

# Prefill the shared prefix exactly once. No token is sampled here.
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")
    inputs = inputs.to(model.device)

    # each request gets its own copy
    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))

驱逐对命中率的影响

如上所述,只有完整的 block 才会被索引,所以尾部不完整的 block 每次都被重算。

这意味着 block size 需要恰当调优:

驱逐会降低命中率,意料之中。

cache 和正在运行的批次从同一块 GPU 内存池取用,所以更大的 cache 意味着更少的并发序列;在压力之下,vLLM 会按最近最少使用(LRU)丢弃无引用的 block。

混合流量会让情况更糟,因为长的共享前缀占用的 block 最多,而丢掉它们才是真正疼的。

开启这个功能之前,你应该知道两件事:

还有第三个问题,它取决于工作负载,对 RAG 影响最大。

一个 RAG prompt 包含系统指令、然后是检索到的 chunk、最后是查询,而 chunk 每个请求都在变、顺序也在变。两个检索到相同文档但顺序不同的请求,在链式哈希下完全不共享任何东西。

把每个 chunk 单独 prefill 再把各段 cache 缝在一起,是不行的。

缝合起来的张量带着错误的位置编码。没有任何 chunk 曾 attend 到其他 chunk。而且每个 chunk 都在它自认为是位置零的地方贡献了自己的 attention sink。要让这套可行,需要在边界处做部分重算,而不是简单拼接。

顺便说一句,开源界已经有现成的解决方案。

LMCache(开源)实现了 CacheBlend:它不是把各段 chunk cache 首尾相接粘起来,而是让它们在任意位置可复用,只重算一小部分 token——选出来的依据是预先算好的值与完整 attention 本会产出的值偏差最大的地方。

这个子集恢复了跨 chunk 的 attention 并修正了位置编码,因此输出保持在完整 prefill 的质量水平。

相比全部重算,这带来了约 2–3 倍的首 token 时间(TTFT)改善,且重算成本与从较慢存储取回缓存 chunk 的过程做了流水线并行。

它可以插进 vLLM 并从你的 prompt 里读出 chunk 边界,所以即使检索到的文档每次以不同顺序到来,检索类流量也能被复用。

仓库在这里:https://github.com/LMCache/LMCache


3)Prompt Caching

在托管模型上,你看不到任何 block 表或驱逐策略。你得到的是一份基于服务商自己前缀复用的价目表,外加两个控制旋钮。

被缓存的对象仍然是 KV 张量,不是你的 prompt 文本,而且它依然要求在完全渲染后的上下文上做精确前缀匹配。

渲染后的上下文包含你从未写过的、服务商侧的系统内容,这也是最小长度和失效规则从外面看起来很随意的一部分原因。

下面用代码演示 prompt caching 的一个版本:

import anthropic

client = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the environment

# Must clear the model's minimum cacheable length or nothing is cached at all.
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"},   # everything above is cacheable
            }
        ],
        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}"
    )

# Output:
"write=2823  read=0     uncached=14"
"write=0     read=2823  uncached=17"

这段代码里只有一行真正涉及 cache。

你在哪里指定 cache_control,决定了请求的哪一部分会为它写入一条缓存条目,而 usage 计数器告诉你之后的调用有没有把那条条目读回来。

凭直觉(也如上文所讨论的),如果我们把 cache_control 下移到用户消息上,读取计数将永远是零,因为被标记的 block 每次调用都在变。

Prompt caching 的经济学

Anthropic 对写入条目收基础输入价的 1.25 倍、读取收 0.1 倍,若想让条目保留更久还有更高的写入乘数。OpenAI 在其当前模型上采用同样的两个乘数。

这笔溢价会在后续请求中收回,因为 TTL 内被复用的一切都免于重算。

一次读取只能找到某个更早的请求写入过的条目,而写入只发生在你放置的断点处。

每次调用都会检查你的断点,未命中时会向后回溯有限个 block 去找一次更早的写入。

Anthropic 把这个上限设为 20 个 block,所以两次调用之间若塞进超过 20 个 block 的对话,就会把上一次写入推出范围,命中随之停止。


4)Semantic Caching

上面三种技术节省的都是 prefill 工作,模型照常运行。

语义缓存则把进来的 prompt 做 embedding,在已存储的 prompt 上跑最近邻搜索,相似度超过阈值就直接返回存储的响应。

这就是它既省输出 token 也省输入 token 的原因。也是每个请求都必须承担一次 embedding 往返的原因——包括所有 miss。

下面是几行代码实现的一个能跑的语义缓存演示:

# requires: pip install sentence-transformers
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           # cosine sim, vectors are unit length
        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)             # the expensive path
    cache.store(prompt, response, vec)
    return response, f"MISS (best {score:.3f})"

# Stand in for the model so this runs without an API key.
fake_model = lambda p: f"<answer for {p!r}>"

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}")


# Output:
"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?"

上面这个类的每个方法都对应一个你在生产环境必须做的决策:

下面的代码展示了最后一点:

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'

尽管并不匹配,三组的得分却挤在一起。同义改写和否定之间只差不到百分之一分,这个边际太薄了,撑不住真实流量。

这本身不是一项完全可靠的技术,因为有些失败(如上面演示的)可以绕过任何阈值取值——它们源自 embedding 所表征的东西。


四项技术回顾

上面讨论的四项技术中有三项对正确性无影响,它们的 miss 只体现在成本和延迟上,别无其他。

语义缓存的工作方式不同,所以命中率在这里不是该报告的正确指标。

还有第五层,用得较少:精确匹配的响应缓存,当请求逐字节一致时返回存储的答案。它像语义缓存一样同时省输入和输出,却不带任何误报风险,因为它根本不做相似度匹配。在伸手拿 embedding 之前,先量一量你的逐字节重复率。当然它也有问题——现在你应该能自己指出来了,发在回复里吧。


生产环境的要点

每项技术都有一些失败点,在生产环境使用前应当记下:

要精确判断两个 prompt 从哪里开始不匹配,直接比较它们的 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 is the default and returns a plain list of token ids
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}: {a[shared:shared+8]} vs {b[shared:shared+8]}")

# Output:
"""
shared prefix: 3 tokens of 35 and 26
diverges at index 3
  turn 1: [2683, 418, 253, 11173, 9042, 14260] You are a helpful AI assistant
  turn 2: [11814, 314, 27758, 30, 2, 198] Today is Tuesday.<|im_end|>
"""

两个在你的日志里看起来一模一样的 prompt,可能差在一个序列起始(BOS)token、一个行尾换行、或一份重新序列化的工具 schema 上。

比较 token ID 而不是渲染后的文本,能找到复用中断的确切下标,把两侧各几个 ID 解码出来,通常就能定位到确切的文本。

上面这次运行展示了一个常见情形。

第一轮没有指定 system 消息,于是聊天模板填入了模型的默认值,两个 prompt 在下标 3 处就不一样了,因此毫无复用可言。


前三层其实是同一个思想,应用在三个不同的范围上。

语义缓存的工作方式则不同。它以 embedding 相似度为键存储响应文本,所以一旦命中,它完全跳过模型,输入输出 token 一起省。命中也可能是错的,而且错的时候照样以正常的成功状态返回。

轮到你了:这四层里,哪一层耗费了你最多的调试时间?


就到这里!

如果你喜欢这篇教程:

来找我 → @_avichawla

我每天分享 DS、ML、LLM 和 RAG 的教程与洞察。

文章来源

作者:Avi Chawla(@_avichawla,Daily Dose of DS 联合创始人)

原文:KV, Prefix, Prompt and Semantic Caching in LLMs, clearly explained(X Article,2026-08-28)

数据:57.7 万次阅读 · 1158 赞 · 2118 收藏 · 157 转发

相关链接:LMCache / CacheBlend