Prompt Cache:高性能的 LLM 状态层

189 阅读28分钟

背景:一个越来越慢的 AI 助手

Qwen3.6 35B 是近期最火的开源大模型之一,写代码的能力超过了很多几百B参数的模型,我在 M4 32GB Mac mini 上用 LM Studio 部署了它,用它跑 Claude 来写程序。

结果发现,第一轮对话飞快。但当我开始多轮对话——你问我答、来回讨论改代码——每聊一轮,等待回复的时间就变长一点。到第 4、5 轮,速度慢到没法用了。翻了一圈 GitHub,我发现遇到同样问题的人不少:lmstudio-bug-tracker#1697mlx-engine#290mlx-vlm#999mlx-vlm#344。大家的诉求都一样:LM Studio 底层的 mlx-lm 技术栈(Apple 专门为自家芯片优化的 AI 框架)缺少 Prompt Cache。

mlx-vlm 的 Prompt Cache 已经在开发中了(mlx-vlm#1103), omlx 已经实现了一套非常完整的 Prompt Cache 系统。下面我来拆解它们是怎么做的。

问题:为什么多轮对话会越来越慢?

要理解这个问题,我们需要先理解 LLM 推理的两个阶段:

Prefill 阶段:处理输入

当你发送一条消息时,模型需要"理解"你的输入。这个过程叫 prefill——把输入的所有 token 一次性喂给模型,计算每一层的注意力,生成 KV cache。

输入: [t1, t2, t3, ..., tN]
      ↓
模型逐层计算注意力
      ↓
输出: KV cache (每一层的 Key 和 Value 矩阵)

Prefill 的特点是计算密集——需要处理所有输入 token,但只需要跑一次。

Decode 阶段:生成输出

Prefill 完成后,模型开始一个 token 一个 token 地生成回复。每生成一个 token,都需要用到之前所有 token 的 KV cache。

Decode 的特点是访存密集——每生成一个 token 都要读取整个 KV cache,但计算量很小。

多轮对话的问题

现在问题来了。在多轮对话中,每一轮的输入都包含之前所有轮次的对话历史

第1轮: [system_prompt, user_msg_1]
第2轮: [system_prompt, user_msg_1, assistant_reply_1, user_msg_2]
第3轮: [system_prompt, user_msg_1, assistant_reply_1, user_msg_2, assistant_reply_2, user_msg_3]
...

如果没有 Prompt Cache,每一轮都要重新 prefill 整个对话历史。 第 10 轮对话的 prefill 长度可能是第 1 轮的 10 倍甚至更多。

这就是我在 LM Studio 上遇到的问题——不是模型变慢了,而是每一轮都要重新计算之前已经算过的东西。

Prompt Caching is everything

KV Cache 是什么?

KV Cache 存的是 Transformer 模型内部的"记忆"——Key 和 Value 矩阵。

在 Transformer 的自注意力机制中,每个 token 都会被编码成一对 K 和 V 向量:

输入 token x
    │
    ├── K = W_k · x  (Key:   "我能提供什么信息?")
    └── V = W_v · x  (Value: "我的实际内容")

Attention(Q, K, V) = softmax(Q · K^T / √d_k) · V

K 和 V 包含了 token 的语义信息和上下文关系。缓存这些 KV,下次就不用从原始 token 重新计算了。

缓存形状:(batch, n_kv_heads, seq_len, head_dim)

以 Llama-7B 为例,1024 tokens 的 KV cache 占用约 512MB(float16)。这就是为什么缓存管理如此重要——一个大 prompt 的缓存可能比整个系统分配给缓存的配额还大。

不同模型的缓存类型也不一样:

模型类型缓存类型存储内容
标准 TransformerKVCachekeys + values (每层)
滑动窗口 TransformerRotatingKVCachekeys + values + 环形缓冲区
Mamba/SSMArraysCache隐藏状态 (非 KV)
混合模型 (Jamba)KVCache + ArraysCache两者都有

vllm-mlx 的缓存系统需要同时支持这些类型,每种类型都有自己的序列化器。

KV Cache 和 Prompt Cache 是两回事

很多人会把这两个概念搞混,但它们的目的完全不同。

KV Cache 是模型推理时的"工作记忆"。当你让模型生成回复时,它每生成一个 token,都要记住之前所有 token 的信息。这些信息就是 KV Cache——每一层的 Key 和 Value 矩阵。它是短暂的,请求结束就释放。

Prompt Cache 是"长期记忆"。它把之前已经计算过的 KV Cache 持久化保存下来,下次遇到相同的前缀时直接复用。它是跨请求的——一个请求存下来的缓存,下一个请求可以用。

打个比方:

  • KV Cache 就像你写字时的草稿纸——写完就扔
  • Prompt Cache 就像你的笔记本——之前写过的东西不用重写,翻到那一页就行

Prompt Cache推理时的 KV Cache
存什么之前请求的 KV 状态当前请求正在生成的 token 的 KV
什么时候读新请求的 prefill 开始前每个 generation step
主要效果降低 TTFT(首次回复时间)降低 per-token 延迟
粒度整个 prompt 前缀(几百/几千 tokens)单个 token
生命周期跨请求持久化仅当前请求,结束就释放

Prompt Cache 的效果

Prompt Cache 把 TTFT 从随对话轮次线性增长变成了常数。

有了 Prompt Cache,多轮对话的 TTFT 变成了常数:

第1轮: [sys, user1] → prefill 100 tokens → 存入 cache
第2轮: [sys, user1, asst1, user2]
       ↑ 前缀匹配命中 [sys, user1, asst1]
       → 只需 prefill [user2] (50 tokens)
       → asst1 不需要 prefill,因为它已经被缓存了!

这不是理论上的优化。Claude 在他们的工程博客中提到,Prompt Caching 是 Claude Code 性能的关键——在 agentic 编程场景中,每一轮对话都包含大量的上下文(代码、文件内容、工具调用结果),如果没有 prompt cache,每一轮的 prefill 时间会让人无法接受。

Prompt Cache 让推理服务器变成"有状态"的

Prompt Cache 不仅仅是降低 TTFT——它从根本上改变了推理服务器的性质。

没有 Prompt Cache 的时候,推理服务器是无状态的:每个请求都是独立的,服务器不记得之前的请求。每次都要从头计算。

有了 Prompt Cache 之后,推理服务器变成了有状态的:它记住了之前请求的计算结果,新请求可以复用这些结果。这带来了几个深远的影响:

  1. 多轮对话变得流畅:不用每轮都从头"读"聊天记录
  2. Agent 工作流变得可行:Claude Code 每一步都要带大量上下文,没有 Prompt Cache 根本跑不动
  3. 服务器重启变成"灾难":缓存丢失意味着所有用户都要重新"冷启动"
  4. 缓存命中率变成核心指标:命中率低 = 服务器在做无用功 = 用户在等

Claude 在他们的博客中说得很直接:Prompt Caching is everything。对于 agentic 场景,它不是优化,而是必需品。

Prompt Cache 是怎么做到的?三个关键时机

vllm-mlx 通过 hook 进 mlx-lm 的推理流程,在三个时机保存缓存。这套机制的核心是 monkey-patch——在不修改 mlx-lm 源码的情况下,插入自己的逻辑。

时机 1:Prefill 完成时(prompt_cache_save)

mlx-lm 的 BatchGenerator._process_prompts 负责执行 prefill。vllm-mlx 用 monkey-patch 在这个方法返回前插入缓存保存逻辑:

# scheduler.py
_orig_process_prompts = batch_gen._process_prompts

def _patched_process_prompts(prompts):
    batch = _orig_process_prompts(prompts)  # 执行原始 prefill
    for e, uid in enumerate(batch.uids):
        if batch.num_tokens[e] == 0:  # prefill 刚完成,generation 还没开始
            prompt_cache_save(uid, batch.extract_cache(e))  # 捕获 KV 快照
    return batch

batch_gen._process_prompts = _patched_process_prompts  # 替换原始方法

batch.num_tokens[e] == 0 是关键判断——它表示这个请求的 prefill 刚完成,还没有开始生成 token。此时 cache 中只有 prompt tokens 的 KV。

这个时机的缓存保证了:即使 generation 失败或中断,prompt 的 KV 也不会丢失。

时机 2:Generation 完成后(_cleanup_finished)

当模型生成完所有 token 后,vllm-mlx 会提取包含生成 token 的完整 KV cache:

# scheduler.py
# generation 完成后,从 response 中提取完整 cache
if hasattr(response, "prompt_cache"):
    raw_cache = response.prompt_cache()
    request._extracted_cache = raw_cache

# 在 _cleanup_finished 中存入 prefix cache
# key = prompt + 生成的 token
full_token_sequence = list(request.prompt_token_ids) + list(request.output_token_ids)
self.memory_aware_cache.store(
    full_token_sequence,       # ← 包含 assistant 回复!
    request._extracted_cache,
    evict_prefixes=False,
)

这是最关键的时机——它让生成的 token 也被缓存下来。下一轮对话可以直接命中包含上一轮 assistant 回复的缓存,跳过对它的 prefill。

代码注释写得很清楚:

# "Key includes both prompt and output tokens for multi-turn chat caching"
# "Extract cache for future reuse (critical for agentic multi-turn)"

时机 3:Prefill 过程中(mid_prefill_save)

对于超长 prompt(比如几万 tokens 的代码库),prefill 会被分成多个 chunk。每完成一个 chunk,中间状态会被保存:

# scheduler.py
prefix_tokens = list(request.prompt_token_ids[:total_cached])
self.memory_aware_cache.store(prefix_tokens, reconstructed)

这有两个好处:

  1. 断点续传:如果 prefill 中途断开(客户端超时、服务器重启),下次可以从断点继续
  2. 前缀复用:在消息边界(prefix_boundary)处保存,方便不同后缀的请求复用

三个时机的配合:

时间线:
  prefill 开始 ──→ chunk 1 完成 ──→ chunk 2 完成 ──→ prefill 完成 ──→ generation ──→ 结束
                      │                 │                 │                            │
                      ▼                 ▼                 ▼                            ▼
               mid_prefill_save   mid_prefill_save   prompt_cache_save         _cleanup_finished
               存入 KV[0:N]       存入 KV[0:M]       存入 KV[0:prompt]         存入 KV[0:prompt+output]

优化一:链式Block——分页管理

前面讲的缓存都是以"整个 prompt"为单位的。但实际场景中,两个请求往往只有部分前缀相同——比如同一个系统提示,但不同的用户消息。如果只能整条缓存,就浪费了共享的机会。

vllm-mlx 的解决方案是把缓存切成固定大小的 Block(默认 64 tokens),然后用链式哈希把它们串成一棵前缀树。这样,相同前缀的 Block 可以跨请求共享,不同后缀的 Block 各自独立。

为什么需要 Block?

你可能会问:按 token 逐个匹配前缀不是更精确吗?为什么要切成固定大小的 Block?原因有三:

1. 用 Block 做前缀匹配,效果一样好

Block 的哈希是链式的——每个 Block 的哈希包含了它前面所有 Block 的信息。所以只要哈希链匹配,就能确认前缀匹配。不需要逐 token 比较。

Block 0: hash_0 = H("root", [t1,t2,t3,t4])
Block 1: hash_1 = H(hash_0, [t5,t6,t7,t8])

如果两个请求的 Block 0 哈希相同 → 前 4 个 token 相同 → 前缀匹配

2. 按块管理便于磁盘上的前缀检索

如果按 token 粒度存储,磁盘上的文件会非常碎片化(每个 token 一个文件?还是按任意长度分段?)。按固定大小的 Block 存储,文件大小可预测,磁盘 I/O 对齐,前缀检索只需要按 Block 哈希查表,不需要扫描 token 序列。

3. 按块管理可以做内存 Block 池

固定大小的 Block 可以预分配一个内存池——启动时一次性分配 N 个 Block 的内存,运行时通过池来分配和回收。这避免了频繁的 malloc/free,减少了内存碎片,分配和释放都是 O(1)。

Free Block Queue:O(1) 的 LRU

vllm-mlx 的 Block 管理借鉴了 vLLM 的设计,使用双向链表实现 O(1) 的 LRU 淘汰:

fake_head ◄── [Block_LRU] ◄── [...] ◄── [Block_MRU] ◄── fake_tail
                  ▲                                          │
              popleft()                                  append()
              (分配)                                     (归还)

每个 Block 有固定的 token 容量(默认 64 tokens)。Block 的状态:

@dataclass
class CacheBlock:
    block_id: int                    # 物理索引
    ref_count: int = 0               # 引用计数(0=空闲,>1=共享)
    block_hash: Optional[bytes]      # 链式哈希
    prev_free_block: Optional        # 双向链表指针
    next_free_block: Optional
    cache_data: Optional[List]       # 实际的 KV 张量

一个常见的疑问:Block 不是有顺序的吗(对应 token 位置),LRU 会打乱顺序吗?

答案是:Free Block Queue 里的 Block 是"空白的",不包含任何有意义的数据。 LRU 排序决定的是"下次分配时优先用哪个物理 Block",而不是 token 的顺序。

已分配的 Block 按 BlockTable 里的顺序排列 token,空闲的 Block 按 LRU 排列复用优先级。两者互不干扰。

当 prefix cache hit 命中了一个空闲 Block 里的数据时,系统会从队列中间取出这个 Block(remove() 操作,O(1)),重新分配给请求。

链式哈希:前缀树的变种

Block 的哈希不是简单的内容哈希,而是链式哈希——每个 Block 的哈希依赖于它的父 Block:

def compute_block_hash(parent_hash, token_ids):
    hasher = hashlib.sha256()
    if parent_hash:
        hasher.update(parent_hash)      # 包含父哈希
    else:
        hasher.update(b"vllm-mlx-root") # 首个 Block 的固定种子
    hasher.update(bytes(str(tuple(token_ids)), "utf-8"))
    return hasher.digest()

这意味着:

  • 相同前缀 → 相同哈希链 → 可以共享物理 Block
  • 不同前缀 → 不同哈希链 → 各自独立
会话 A: [t1, t2, t3, t4, t5, t6]  (block_size=4)
  Block 0: hash_0 = SHA256("root" + [t1,t2,t3,t4])
  Block 1: hash_1 = SHA256(hash_0 + [t5,t6])

会话 B: [t1, t2, t3, t4, t7, t8]  (共享前 4 个 token)
  Block 0: hash_0 = SHA256("root" + [t1,t2,t3,t4])  ← 相同!
  Block 1: hash_1' = SHA256(hash_0 + [t7,t8])        ← 不同

结果: Block 0 被两个会话共享 (ref_count=2)

多个会话的缓存不是线性的链表,而是一棵前缀树(Trie)

           [sys]                    ← 公共根节点
           /    \
     [sys,user1]  [sys,user2]       ← 不同用户的分支
        /    \
  [sys,user1,  [sys,user1,   asst1,user2] asst1,user3]        ← 同一用户不同话题

树的每个节点存储对应前缀的 KV cache。共享前缀的多个会话在树中共享从根到分支点的路径,各自独占分支点之后的部分。

100 个会话共享 1000 token 的系统提示,只需要存一份 KV cache,节省 ~99% 的内存。

Copy-on-Write:高效的前缀共享

当多个请求共享同一个 Block 时,如果某个请求需要修改 Block(比如生成新 token),vllm-mlx 使用 Copy-on-Write(COW) 策略:

def _cow_copy_block(self, source_block):
    new_block = self.allocate_block()
    new_block.cache_data = source_block.cache_data  # 浅拷贝引用
    source_block.ref_count -= 1
    return new_block

只有在真正需要修改时才复制 Block。MLX 的数组是不可变的,所以多个 Block 可以安全地共享相同的张量引用。

优化二:多级缓存——让缓存容量更大

token 太多了不好管理也不利于磁盘存储和检索,Block 设计解决了这些问题,但还有一个现实问题:内存是有限的。一个 10000 token 的 prompt,KV cache 可能占几百 MB。如果同时有几十个用户在聊天,内存很快就满了。

vllm-mlx 的解决方案是多级缓存:热数据放内存,冷数据放 SSD,实在没有就重新计算。被淘汰的缓存不是直接丢弃,而是"降级"到下一层——这样下次需要的时候还有机会找回来。

┌─────────────────────────────────────────────────────────────────┐
│                        请求到达                                  │
└─────────────────────────┬───────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  第一层: MemoryAwarePrefixCache (RAM)                           │
│  - LRU 淘汰,基于内存压力(非条目数量)                          │
│  - 自动检测可用 RAM,默认 20%                                    │
│  - O(1) 精确匹配,O(log N) 前缀/超序列/LCP 匹配                 │
│  - 可选 KV 量化(4/8 bit)压缩 2-4 倍                           │
└─────────────────────────┬───────────────────────────────────────┘
                          │ 淘汰时溢出到 SSD
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  第二层: SSDCacheTier (NVMe/SSD)                                │
│  - 冷存储,存放被淘汰的 KV cache Block                          │
│  - 链式哈希索引,与内存 Block 共享同一套前缀匹配逻辑            │
│  - 异步写入线程,不阻塞请求                                      │
│  - 原子写入(temp-file + rename),崩溃安全                      │
└─────────────────────────┬───────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  第三层: 重新计算(兜底)                                        │
│  - 内存和 SSD 都没有命中时,从头计算                             │
└─────────────────────────────────────────────────────────────────┘

第一层:内存缓存 (MemoryAwarePrefixCache)

最热的数据放在内存里——这是 TTFT 优化的关键路径。

为什么是"内存感知"而不是"条目数量感知"?

传统的缓存系统按条目数量淘汰(比如最多保留 100 个条目)。但 Prompt Cache 的条目大小差异巨大——一个 100 token 的缓存条目可能只有几 MB,而一个 10000 token 的条目可能有几百 MB。

如果按条目数量淘汰,一个大条目可能把所有小条目都挤出去,导致内存使用不均匀。vllm-mlx 选择按内存使用量淘汰:

# memory_cache.py
_DEFAULT_MEMORY_PERCENT = 0.20  # 默认使用 20% 的可用 RAM
_MIN_MEMORY_BYTES = 100 * 1024 * 1024  # 最少 100MB

系统启动时会自动检测可用 RAM,计算缓存内存上限。如果检测失败(比如没有安装 psutil),会 fallback 到 8GB 系统的 20%。

内存估算:不触发 lazy eval

MLX 的数组是惰性求值的——访问 .nbytes 会强制求值整个计算图,导致 VRAM 尖峰。vllm-mlx 用 shape + dtype 元数据估算内存,避免这个问题:

def _array_memory(arr):
    if hasattr(arr, "shape") and hasattr(arr, "dtype"):
        dtype = arr.dtype
        if hasattr(dtype, "size"):
            return math.prod(arr.shape) * dtype.size

四种匹配策略

缓存查找不是简单的 key 精确匹配。vllm-mlx 实现了四种匹配策略,按优先级排列:

新请求: [sys, user1, asst1, user2]  (200 tokens)

1. 精确匹配 (O(1))
   完全相同的 token 序列 → 直接返回完整 KV cache

2. 前缀匹配 (O(log N))
   缓存的序列是请求的前缀(更短)
   [sys, user1][sys, user1, asst1, user2] 的前缀
   → 返回 [sys, user1]KV,只需计算 [asst1, user2]

3. 超序列匹配 (O(log N))
   缓存的序列以请求开头(更长)
   截断多余的 token,返回匹配部分

4. 最长公共前缀匹配 (O(log N))
   缓存的序列与请求有共同前缀(不同后缀)
   找到最长的公共前缀,截断后返回

为什么需要这么多匹配策略?因为多轮对话的缓存命中不是简单的精确匹配。每一轮对话的 prompt 都比上一轮长,但前缀是相同的。前缀匹配让我们可以复用之前轮次的 KV cache。

O(log N) 的秘密:排序索引

前缀匹配需要找到"缓存中哪个条目是当前请求的前缀"。暴力扫描是 O(N),但 vllm-mlx 用了一个巧妙的方法:

# memory_cache.py
self._sorted_keys: list[tuple[int, ...]] = []

它维护了一个排序的 token 序列列表。利用 tuple 的字典序特性,前缀 P 总是排在它的扩展 T 之前。这样用 bisect 就能在 O(log N) 时间内找到前缀:

idx = bisect.bisect_left(sorted_keys, tokens_key)
# 往前扫描找最长前缀
for i in range(idx - 1, -1, -1):
    cached_key = sorted_keys[i]
    if tokens_key[:len(cached_key)] == cached_key:
        return self._entries[cached_key]  # 找到了!

KV 量化:用精度换空间

对于超长 prompt(比如 100K tokens 的代码库),KV cache 可能超过内存限制。vllm-mlx 支持 KV 量化——把 float16 的 KV 压缩到 8-bit 甚至 4-bit:

# memory_cache.py
kv_quantize: bool = False
kv_bits: int = 8        # 4 或 8 bit
kv_group_size: int = 64
kv_min_quantize_tokens: int = 256  # 只量化长序列

8-bit 量化可以把内存减半,4-bit 减为 1/4。代价是轻微的精度损失——对于 prompt cache 来说,这个 trade-off 通常是值得的。

第二层:磁盘缓存 (SSDCacheTier)

内存是有限的。当内存缓存满了,被淘汰的条目去哪?

传统做法是直接丢弃。但 vllm-mlx 选择了一个更聪明的方案:溢出到 SSD

按 Block 存储,用链式哈希索引

SSD 缓存与内存的 Block 缓存共享同一套链式哈希逻辑。每个 Block 的哈希包含父 Block 的信息,形成前缀树:

Block 0: hash_0 = H("root", [t1,t2,t3,t4])
Block 1: hash_1 = H(hash_0, [t5,t6,t7,t8])
Block 2: hash_2 = H(hash_1, [t9,t10])

SSD 上按 Block 存储 safetensors 文件,索引记录每个 Block 的哈希和文件路径。查询时只需要比较哈希链,不需要读取实际数据:

查询 [t1,t2,t3,t4, t5,t6,t7,t8, t9,t10,t11,t12]:
  1. 计算 hash_0 = H("root", [t1,t2,t3,t4]) → 查索引 → 命中 ✓
  2. 计算 hash_1 = H(hash_0, [t5,t6,t7,t8]) → 查索引 → 命中 ✓
  3. 计算 hash_2' = H(hash_1, [t9,t10,t11,t12]) → 查索引 → 未命中 ✗
  → 返回前 2 个 Block 的 KV,剩余 4 个 token 需要重新计算

safetensors:安全高效的张量存储

为什么用 safetensors 而不是 pickle?

特性safetensorspickle
安全性不执行任意代码可执行任意代码
零拷贝加载mmap 直接映射需要反序列化
跨框架numpy/torch/jax 通用框架绑定
部分读取可只读取单个张量必须全量加载

每个 Block 存为一个 safetensors 文件:

cache_dir/
├── blocks/
│   ├── hash_0.safetensors    ← Block 0 的 KV (所有层)
│   ├── hash_1.safetensors    ← Block 1 的 KV (所有层)
│   └── ...

safetensors 的文件格式:8 字节 header 长度 + JSON header(描述张量形状、dtype、偏移)+ 原始二进制数据。

异步溢出,不阻塞请求

当内存缓存淘汰一个 Block 时,它不会同步写磁盘——那会阻塞请求。相反,它把 Block 放入一个异步队列:

# memory_cache.py
def _evict_lru(self):
    tokens_key, entry = self._entries.popitem(last=False)  # LRU 淘汰
    if self._ssd_tier is not None:
        self._ssd_tier.enqueue_spill(tokens_key, entry.cache, entry.memory_bytes)

后台 writer 线程从队列中取出 Block,写入磁盘。如果队列满了(默认 64 个),新 Block 直接丢弃——宁可丢缓存也不能阻塞请求

写入过程使用 temp-file + rename 策略保证崩溃安全:

def _write_entry(self, tokens_key, cache_layers, memory_bytes):
    tmp_dir = entry_dir + ".tmp"
    # 写所有文件到 tmp_dir
    # ...
    os.rename(tmp_dir, entry_dir)  # POSIX 原子操作

SSD 缓存的查询流程

当内存缓存未命中时,系统会查询 SSD 的链式哈希索引(不读磁盘数据):

# scheduler.py
ssd_candidate = self.memory_aware_cache.check_ssd(request.prompt_token_ids)
if ssd_candidate is not None:
    request.cache_hit_type = "ssd_pending"
    request._ssd_candidate = ssd_candidate

如果 SSD 有匹配的 Block,请求被标记为 "ssd_pending"。在调度阶段,系统会尝试从 SSD 提升这些 Block 到内存:

# scheduler.py - _try_promote_ssd_pending()
# 1. 检查 RAM 预算
if current_memory + memory_bytes > max_memory:
    continue  # 预算不够,降级为 miss

# 2. 预留 RAM(防止多请求竞争导致内存抖动)
current_memory += memory_bytes

# 3. 从磁盘读取
cache_layers = ssd_tier._read_entry(tokens, file_path)

# 4. 存入内存缓存
memory_aware_cache.store(tokens, cache_layers)

关键设计:先预留 RAM 再读磁盘。如果先读磁盘再检查预算,多个请求可能同时读了大文件,结果 RAM 装不下。预留机制保证只有一个请求能成功提升,其他请求自动降级为 miss。

崩溃恢复

服务器启动时,SSD 缓存需要做一致性检查——索引和磁盘文件可能不一致(比如上次崩溃导致只写了一半):

def reconcile(self):
    # Phase 1: 索引有但文件没了 → 删索引
    # Phase 2: 文件有但索引没了 → 删文件

损坏的 Block 不会直接删除,而是移入 quarantine 目录——保留证据便于调试。

第三层:重新计算(兜底)

如果内存和 SSD 都没有缓存命中,只能从头计算。这是最慢的路径,但也是最可靠的——不依赖任何缓存。

淘汰机制

缓存空间是有限的,淘汰机制的设计直接影响缓存命中率。

内存缓存的淘汰使用 LRU + 内存压力:

while (current_memory + entry.memory_bytes > max_memory
       or len(entries) >= max_entries) and entries:
    self._evict_lru()  # 淘汰最久未用的条目

被淘汰的条目不是直接丢弃,而是溢出到 SSD:

def _evict_lru(self):
    tokens_key, entry = self._entries.popitem(last=False)
    if self._ssd_tier is not None:
        self._ssd_tier.enqueue_spill(tokens_key, entry.cache, entry.memory_bytes)

还有一个优化:前缀子集淘汰。当存入一个新条目时,如果已有条目是新条目的严格前缀,旧条目会被驱逐——因为新条目已经包含了旧条目的所有信息。

SSD 缓存的淘汰也有容量限制(默认 10GB)。超过限制时,按 LRU 淘汰:

def _enforce_capacity(self):
    while entry_count > max_entries or total_bytes > max_size_bytes:
        lru = self._index.get_lru(limit=1)  # 按访问时间排序,取最久未用的
        shutil.rmtree(data/{file_path})      # 删除磁盘文件
        self._index.delete_entry(tokens)     # 删除索引

Block 的淘汰通过 Free Block Queue 的 LRU 顺序实现。当需要分配新 Block 但队列为空时,从队列头部(LRU 位置)取出 Block,清除其缓存数据,重新分配。

完整的请求生命周期

把所有组件串起来,一个请求的完整缓存流程是这样的:

1. 请求到达
   │
2. 内存缓存查找 (MemoryAwarePrefixCache.fetch)
   │
   ├── 精确命中? → 直接返回,TTFT ≈ 0
   ├── 前缀命中? → 返回前缀 KV,只需 prefill 剩余部分
   ├── 超序列命中? → 截断后返回
   ├── LCP 命中? → 截断后返回
   └── 未命中
         │
3. SSD 缓存查找 (check_ssd)
   │
   ├── 命中? → 标记为 "ssd_pending"
   └── 未命中 → 标记为 "miss"4. 调度阶段 (_schedule_waiting)
   │
   ├── "ssd_pending" → 尝试从 SSD 提升到内存
   │   ├── RAM 预算足够? → 读取磁盘,存入内存,标记为 "ssd_hit"
   │   └── 预算不够? → 降级为 "miss"
   │
   └── "miss" → 进入 prefill 队列
         │
5. Prefill 执行
   │
   ├── 计算 KV cache
   ├── prompt_cache_save 存入内存缓存
   └── 开始 generation
         │
6. Generation 完成
   │
   ├── _cleanup_finished 提取完整 KV cache(prompt + output)
   ├── 存入内存缓存(key = prompt + output tokens)
   └── 下一轮可以命中这个缓存!

安全保护:多租户隔离

Prompt Cache 本质上是共享资源——多个用户的请求可能命中同一个缓存条目。这带来了一个安全问题:用户的对话内容会不会泄露给其他用户?

问题:共享前缀 = 共享 KV

假设两个用户都用同一个系统提示:

  • 用户 A 的 prompt: [sys, user_A_private_question]
  • 用户 B 的 prompt: [sys, user_B_private_question]

如果缓存只按 token 序列匹配,用户 A 的 [sys] 缓存会被用户 B 复用。这本身没问题——系统提示本来就是公共的。但如果缓存粒度更粗(比如整个 prompt),或者匹配逻辑有 bug,就可能泄露用户 A 的私有内容。

mlx-vlm 的解决方案:租户隔离哈希

mlx-vlm 的 APC(Automatic Prefix Caching)实现中,有一个 tenant_scoped_hash 函数:

def tenant_scoped_hash(tenant: str, payload_hash: int = 0) -> int:
    """Create a stable SHA-256-based salt for tenant-scoped caching."""
    hasher = hashlib.sha256()
    hasher.update(tenant.encode("utf-8"))
    if payload_hash:
        hasher.update(payload_hash.to_bytes(8, "big"))
    return int.from_bytes(hasher.digest()[:8], "big")

不同租户的哈希链完全独立——即使两个租户处理完全相同的 token 序列,它们的 block hash 也不会相同。这意味着:

  • 租户 A 的缓存对租户 B 不可见
  • 不会因为 token 序列相同而误命中其他租户的缓存
  • 租户标识被嵌入哈希中,无法逆向推导

磁盘层面的隔离

除了哈希隔离,磁盘缓存也可以按命名空间隔离:

cache_root/
├── model_a/          ← 模型 A 的缓存
│   └── blocks/
├── model_b/          ← 模型 B 的缓存
│   └── blocks/
└── tenant_x/         ← 租户 X 的独立缓存
    └── blocks/

每个租户/模型有自己的子目录,物理隔离。

多模态场景的额外考虑

对于视觉语言模型,图像内容也需要参与哈希。mlx-vlm 的 hash_image_payload 函数会哈希图像的实际像素数据或来源标识——确保相同文本但不同图像的请求不会误命中。

多模态场景的缓存

对于视觉语言模型(VLM),缓存还需要处理图像。vllm-mlx 的 MLLMCache 扩展了缓存机制:

  • 图像内容哈希:用图像内容的 SHA-256 作为缓存 key 的一部分
  • Vision embedding 缓存:缓存视觉编码器的输出,跳过编码步骤(节省 ~1-2 秒/图)
  • KV cache 缓存:与文本 prompt 的缓存机制相同

这对于多模态对话尤其重要——图像编码是最耗时的步骤之一。

如何评测缓存效果

Prompt Cache 的效果完全取决于缓存命中率。一个 99% 命中率和 50% 命中率的系统,性能差距可能是 10 倍。

限制和 Trade-off

Prompt Cache 不是银弹,有一些限制需要注意:

  1. 内存开销:每个缓存条目都有内存占用,需要根据系统 RAM 合理配置
  2. 量化损失:4/8-bit KV 量化会轻微影响输出质量
  3. Block 对齐:Block 级别的缓存需要 token 数量是 block_size 的倍数
  4. 混合模型限制:Mamba 层的状态是累积的,无法安全地回退到 "prompt only" 状态
  5. SSD 读取延迟:10-200ms,取决于条目大小(但仍远快于重新计算)

理想的缓存命中率应该是多少?

在典型的 agentic 编程场景中(Claude Code 风格):

  • 系统提示:每次请求都相同,命中率 100%
  • 工具定义:每次请求都相同,命中率 100%
  • 对话历史:每一轮都在增长,但前缀可以复用
  • 用户消息:每次不同,无法命中

理想情况下,缓存命中率应该 > 90%。因为大部分 token 来自系统提示、工具定义和之前的对话历史,只有当前轮的用户消息是新的。

缓存命中率低意味着什么?

缓存命中率低是一个需要立即处理的故障。 可能的原因:

  1. 缓存被意外清除:重启服务、内存不足导致大量淘汰
  2. 缓存配置不当:缓存空间太小,条目被频繁淘汰
  3. 请求模式变化:用户开始发送大量不同的 prompt,前缀不再共享
  4. 缓存 key 不匹配:token 序列有微小差异导致无法命中

vllm-mlx 提供了详细的缓存统计:

class SSDCacheStats:
    spill_count: int        # 溢出到 SSD 的条目数
    ssd_hits: int           # SSD 命中次数
    ssd_misses: int         # SSD 未命中次数
    reload_latency_sum: float  # SSD 读取延迟
    promotion_failures: int # 提升失败次数(RAM 预算不够)

建议:把缓存命中率作为核心监控指标,设置告警阈值。一旦命中率低于 80%,立即排查原因。

展望:还有哪些可以改进的?

Prompt Cache 的设计远没有结束。以下是几个值得探索的方向:

1. 会话级别的缓存删除

当前的缓存是内容寻址的——按 token 序列的哈希索引,而不是按会话 ID。这意味着你无法说"删除用户 A 的所有缓存",因为缓存条目不关联到具体用户。

对于隐私敏感的场景(医疗、金融),需要支持:

  • 按会话 ID 关联缓存:每个缓存条目记录它属于哪个会话
  • 批量删除:会话结束时,删除该会话关联的所有缓存条目
  • TTL(过期时间):缓存条目自动过期,防止长期驻留

vllm-mlx 的 SSD 缓存支持 retention_seconds 配置,可以设置条目的最大存活时间。但内存缓存还没有这个能力。

2. 跨服务器的缓存共享

在多机部署场景中,每个服务器的缓存是独立的。如果用户在服务器 A 上聊了 10 轮,然后请求被路由到服务器 B,B 上没有缓存,TTFT 会突然变高。

解决方案:

  • 分布式缓存:用 Redis 或共享文件系统存储缓存
  • 请求亲和性:同一个用户的请求总是路由到同一个服务器
  • 缓存预热:服务器启动时从共享存储加载热缓存

3. 压缩和量化

当前的 KV cache 用 float16 存储,每个 token 每层占 2 字节。对于超长 prompt(100K+ tokens),缓存可能超过内存限制。

vllm-mlx 已经支持 4/8-bit 量化,但还有更多空间:

  • 稀疏化:只缓存重要的 token 的 KV(注意力权重高的)
  • 蒸馏:用更小的模型近似大模型的 KV
  • 增量压缩:只缓存相邻 token 之间的差异

4. 缓存命中率的自动化运维

手动监控缓存命中率太原始了。理想的状态是:

  • 自动告警:命中率低于阈值时自动通知
  • 自动诊断:分析命中率低的原因(是淘汰太频繁?还是请求模式变了?)
  • 自动调参:根据负载动态调整缓存大小、淘汰策略

总结

Prompt Cache 的本质,就是让模型拥有记忆——记住之前"读"过的内容,下次直接复用,也把推理服务器从"无状态"变成了"有状态"。对于运维者来说,缓存命中率应该和 QPS、延迟一样,成为核心监控指标

在 Prompt Cache 的加持下,多轮对话的 TTFT 从随轮次线性增长变成了常数。 第 1 轮和第 10 轮的等待时间几乎一样——因为第 10 轮只需要 prefill 当前轮的用户消息,前面 9 轮的所有内容(系统提示、工具定义、历史对话、assistant 回复)都从缓存中复用。

对于正在使用 mlx 生态的用户,mlx-vlm#1103 正在把这项能力带进来。而对于所有构建 LLM 推理系统的人来说,Prompt Cache 不是可选的优化——它是让多轮对话真正可用的基础设施。

参考

参考文章

相关项目

  • vllm-mlx:本文分析的项目,在 Apple Silicon 上运行的 vLLM 实现
  • omlx:另一个 mlx 推理优化项目
  • mlx-vlm:适配苹果芯片的视觉语言模型框架
  • mlx-lm:Apple 的语言模型框架
  • mlx-engine:LM Studio 的底层引擎