背景:一个越来越慢的 AI 助手
Qwen3.6 35B 是近期最火的开源大模型之一,写代码的能力超过了很多几百B参数的模型,我在 M4 32GB Mac mini 上用 LM Studio 部署了它,用它跑 Claude 来写程序。
结果发现,第一轮对话飞快。但当我开始多轮对话——你问我答、来回讨论改代码——每聊一轮,等待回复的时间就变长一点。到第 4、5 轮,速度慢到没法用了。翻了一圈 GitHub,我发现遇到同样问题的人不少:lmstudio-bug-tracker#1697、mlx-engine#290、mlx-vlm#999、mlx-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 的缓存可能比整个系统分配给缓存的配额还大。
不同模型的缓存类型也不一样:
| 模型类型 | 缓存类型 | 存储内容 |
|---|---|---|
| 标准 Transformer | KVCache | keys + values (每层) |
| 滑动窗口 Transformer | RotatingKVCache | keys + values + 环形缓冲区 |
| Mamba/SSM | ArraysCache | 隐藏状态 (非 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 之后,推理服务器变成了有状态的:它记住了之前请求的计算结果,新请求可以复用这些结果。这带来了几个深远的影响:
- 多轮对话变得流畅:不用每轮都从头"读"聊天记录
- Agent 工作流变得可行:Claude Code 每一步都要带大量上下文,没有 Prompt Cache 根本跑不动
- 服务器重启变成"灾难":缓存丢失意味着所有用户都要重新"冷启动"
- 缓存命中率变成核心指标:命中率低 = 服务器在做无用功 = 用户在等
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)
这有两个好处:
- 断点续传:如果 prefill 中途断开(客户端超时、服务器重启),下次可以从断点继续
- 前缀复用:在消息边界(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?
| 特性 | safetensors | pickle |
|---|---|---|
| 安全性 | 不执行任意代码 | 可执行任意代码 |
| 零拷贝加载 | 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 不是银弹,有一些限制需要注意:
- 内存开销:每个缓存条目都有内存占用,需要根据系统 RAM 合理配置
- 量化损失:4/8-bit KV 量化会轻微影响输出质量
- Block 对齐:Block 级别的缓存需要 token 数量是 block_size 的倍数
- 混合模型限制:Mamba 层的状态是累积的,无法安全地回退到 "prompt only" 状态
- SSD 读取延迟:10-200ms,取决于条目大小(但仍远快于重新计算)
理想的缓存命中率应该是多少?
在典型的 agentic 编程场景中(Claude Code 风格):
- 系统提示:每次请求都相同,命中率 100%
- 工具定义:每次请求都相同,命中率 100%
- 对话历史:每一轮都在增长,但前缀可以复用
- 用户消息:每次不同,无法命中
理想情况下,缓存命中率应该 > 90%。因为大部分 token 来自系统提示、工具定义和之前的对话历史,只有当前轮的用户消息是新的。
缓存命中率低意味着什么?
缓存命中率低是一个需要立即处理的故障。 可能的原因:
- 缓存被意外清除:重启服务、内存不足导致大量淘汰
- 缓存配置不当:缓存空间太小,条目被频繁淘汰
- 请求模式变化:用户开始发送大量不同的 prompt,前缀不再共享
- 缓存 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 不是可选的优化——它是让多轮对话真正可用的基础设施。
参考
参考文章
- claude.com/blog/lesson…
- docs.vllm.ai/en/latest/d…
- docs.sglang.io/docs/advanc…
- www.zansara.dev/posts/2025-…
相关项目
- vllm-mlx:本文分析的项目,在 Apple Silicon 上运行的 vLLM 实现
- omlx:另一个 mlx 推理优化项目
- mlx-vlm:适配苹果芯片的视觉语言模型框架
- mlx-lm:Apple 的语言模型框架
- mlx-engine:LM Studio 的底层引擎