← 返回主页

Hybrid 模型的 prefix cache

两种记忆,
一张缓存

Qwen3-Next、Qwen3.5、Kimi Linear 这类 hybrid 模型同时有 attention 层和 recurrent 层(GDN、Mamba)。两种层记东西的方式完全不同,prefix cache 必须同时照顾两边。下面每一节都有一个可以动手的小实验,讲 vLLM、SGLang 和 Marconi 各自怎么做。

Wenxuan Xu2026-09-30vLLM 8fb16ea5a6SGLang f050ab194aMarconi · MLSys 2025

01

两种记忆

attention 可以截断,state 只能精确命中

attention 层给每个 token 留一块 KV。想从第 p 个 token 接着算,把 KV 截到 p 就行。

recurrent 层只有一个固定大小的 state,每读一个 token 就原地覆盖一次。它回不到过去:想从 p 接着算,必须事先在 p 处存过一份 state 的拷贝,也就是 checkpoint。

拷贝很贵。以一个 GDN 模型的配置为例,一个 GDN state 是 3.12 MiB,相当于 799 个 token 的 KV。

动手:往前读、存 checkpoint、再恢复实验 1

4 KiB
一个 token 的 attention KV:4 heads × 256 dim × K、V × bf16
3.12 MiB
一个 GDN state,大小和读了多少 token 无关
799
一个 state 等于这么多个 token 的 KV
0.4% vs 25%
Marconi 实测:state 和 KV block 后来真的被复用的比例

所以 hybrid 模型的命中长度,等于 attention 和 recurrent 两边都能恢复的公共位置。剩下的问题只有三个。

Admission · 在哪存

每 N 个 token 存一个,只在 chunk 边界存,还是只在分支点存?

存太密会占满显存。vLLM 观察到密集快照占掉 80% 的 pool。

Matching · 怎么对齐

attention 匹配到 2432,最近的 checkpoint 在 1792。

只能从 1792 恢复,多匹配的 640 个 token 要重算。

Eviction · 淘汰谁

KV 的大小正比于它省下的算力,state 不是。

代表 token 越多的 state 越值钱,LRU 会误删它们。

02

内存布局

一个大池子,还是两个池子

vLLM 让所有层共用一个 block pool。为了让同一个 block 既能装 attention KV,也能装一个 state,它把 attention 的 block 撑大:3.12 MiB ÷ 4 KiB ≈ 799,向上对齐到 128 的倍数就是 896。启动日志里的 Setting attention block size to 896 tokens 就是这么来的。

SGLang 分两个池:state 按请求分 slot,KV 按 token 分 page。attention 的粒度不受影响;代价是 state 池按比例静态切出去,每个 running request 还要预留 3 到 5 个 slot。

两种布局示意图
attention KVGDN state空闲为对齐 pad 掉的 11%
动手:共享前缀有多长,才缓存得住实验 2

03

vLLM · 匹配、写入、896

匹配取交集,写入看网格

匹配时,每种层一个 manager,各找各的命中长度,然后取所有层都能恢复的最长位置。候选长度只缩不涨,直到不再变化。

三个 manager 怎么找示意图

写入更受限。GDN 类模型只能用 align 模式:只有当一个 prefill chunk 恰好结束在 896 的倍数上,那里的 state 才会被冻结成能命中的 checkpoint。所以 scheduler 会主动把 chunk 切在 block 边界上。

问题出在它用哪个网格切。scheduler 读的是 cache_config.block_size,也就是所有 cache group 里最小的 block size。只要有一个更小 block 的 group,切分网格就和 GDN 网格对不上。例如 128 对 896 的配置就是这样。

实验台:896 案例与 PR #54076实验 3
预设
更小的 group block
修复方式
--prefix-match-unit
MTP
命中,可复用 合法 checkpoint prompt 尾部的 partial 条目 跨过了 block 边界,slot 为空 错位:hash 和 state 对不上 attention 匹配 读侧上限

简化模型:三个请求依次到达,前 S 个 token 相同,之后各不相同。按 vLLM align 模式的切分规则推演,假设密集保留,prefill 预算 2048,attention block 896。用来解释机制,不是 benchmark。

试试这几个预设

  • 不加 flag:chunk 结束在 2048、2304、2528,没有一个是 896 的倍数,slot 全空,三个请求都命中 0。
  • 只加 --block-size 896:896 能存下来,但读侧上限只有 896,命中卡在 896。
  • 两个都加:REQ2 在共享前缀交点 1792 断开,写下 1792;REQ3 才命中 1792。cache 是一步步暖起来的。
  • PR #54076:每跨过一个 block 边界都断开,REQ1 自己就写下 896 和 1792,REQ2 直接命中 1792。
  • 长 prompt 的错位:n = 4000 时,slot 2 冻结着 2048 处的 state,却被当成 2688 发布出去。REQ2 命中它,用的是过时的 state,输出会悄悄变错。

PR #54076 改了三处

① 切分网格

用 Mamba group 自己的 block size,不再用所有 group 的最小值。

今天 main 的 scheduler.py:443 仍是 cache_config.block_size。

② 边界强制断开

每跨过一个 block 边界都结束 chunk,从对齐位置开始也一样。

原来一个 chunk 能跨 k 个 block,中间 k − 1 个 slot 一直为空。

③ 回退范围

最后一个 block 的回退只对 eagle、eagle3、mtp 生效。

DFlash 和 DSpark 用自己的 KV,不再回退。对 MTP 没有影响。

情况例子不修会怎样修后(社区部署数据)
网格一致padding 之后 block size 相等的常规配置没有问题无变化,两组 A/B 零差异
有更细的 groupDFlash drafter 816 对 Mamba 1648;GLM-5.3 drafter 1152 对 2304;128 对 896要么 state 从不落地,复用为 0;要么错位发布,命中过时的 state,输出悄悄变错16.7K prompt 复用 0 → 100%,TTFT 8.18 s → 0.41 s;cold 与 warm 的 |Δlogprob| 0.25–0.36 → 0.02–0.04

状态:9-30 仍是 open,没有 maintainer review,pre-run-check 失败。数据来自 PR 描述和评论区的部署报告,不是 CI 结果;一位评论者撤回了自己的数字。

vLLM 正在一步步吸收 Marconi 的 admission 思路

2026-06
#37898

attention 命中而 Mamba 没命中,就在共享前缀交点补存 state

2026-06
#45845

Mamba group 支持稀疏保留,每 k 个 token 留一个

2026-07
#47782

稀疏保留时仍然保住交点 checkpoint

2026-08
#52216

稀疏保留成为默认,间隔 0 只留语义 checkpoint

2026-09
#57382

交点 checkpoint 可以不在 block 网格上

同期的默认值变化:所有 Mamba 模型默认 align(#50991);all 模式 deprecated(#55041),GDN 从来没支持过 all;KDA 能在一次 prefill 内导出中间 state(#52789),GDN 还没有。淘汰仍是一条全局 LRU。

04

Marconi 与 SGLang · radix tree

树上的 state 挂在哪里

SGLang 和 Marconi 都用 radix tree。边上存 KV,节点上可以挂一份 state。匹配时沿树往下走,只能停在挂着 state 的位置;attention 多匹配的部分要重算。

三种 admission 策略的区别,在于什么时候往树上挂 state:

  • Marconi:只在分支点和请求结束时存。分支点要等第二个请求来才发现,第三个起才命中。
  • SGLang no_buffer:每个 chunk 边界、prompt 末尾、请求结束都存,位置精确,但要关掉 overlap。
  • SGLang extra_buffer:chunk 边界按 64 对齐,再加上分支点;decode 每 256 个 token 存一次,所以请求结束时只缓存到最后一次存的位置。

淘汰:LRU 只看最近用没用过。Marconi 的 FLOP-aware 还看这个 state 代表多少 token,越长越值钱。

实验台:发请求,看树长出来实验 4
Admission
淘汰
系统提示 文档 问题 模型回答 state checkpoint 已淘汰,只剩 KV(tombstone)

同一串请求,三种策略的总账

教学模型:token 长度为示意,chunked prefill 每块 1024 token,只限制 state 数量,KV 视为足够。试试:选 Marconi,把上限调到 5,再在 LRU 和 FLOP-aware 之间切换。LRU 会丢掉文档 A 分支点上的 state,R5 只能从 1,024 恢复;FLOP-aware 保住它,R5 从 2,560 恢复。

SGLang 实现上的几个细节

  • 延迟 CoW:命中时只记下源 slot,forward 时在 mamba 层之前把 state 拷到请求自己的 slot。树里的 checkpoint 永远不被原地修改。
  • tombstone:内部节点可以只丢 state、保留 KV。节点分裂时,新父节点也是 tombstone,因为 state 不能切分。
  • 现状:UnifiedRadixCache 对所有模型默认,树核心默认用 Rust;旧的 MambaRadixCache 已删除(#40313)。淘汰策略可选 lru、lfu、slru、priority、tlru,仍没有 FLOP-aware。
  • 投机解码:verify 不推进持久 state,每个 draft token 的 state 先写进 intermediate_ssm,verify 后把被接受的那一步拷回。
05

放在一起看

同一个抽象,三种取舍

三者都收敛到同一件事:attention 决定上界,recurrent state 决定能不能落地。差别在 checkpoint 的密度、位置策略,以及显存怎么分。

维度vLLMSGLangMarconi
state 放哪和 KV 共用一个 pool,block 撑大到 896独立的请求级 slot 池radix tree 节点
checkpoint 密度chunk 边界 ∩ block 网格,默认稀疏保留chunk 边界、请求结束、每 256 token每个序列最多 2 个
分支点attention 命中而 Mamba 未命中时推断交点branching seqlen(extra_buffer),SWA 也有投机插入 radix tree
CoW只在 partial tail 命中时每次命中都拷一份无
淘汰一条全局 LRU每组件 LRU 或 TLRU,内部节点 tombstonerecency + α · FLOPs/byte
显存预算一个 pool,自动分配按比例静态切分,或 unified memory无
GDN 加 MTP 的 prefix caching,目前 SGLang 更稳。vLLM 更通用,但 align 模式对 block 几何很敏感。

SGLang 更好的地方

  • attention 粒度不受影响,短前缀也能命中
  • checkpoint 位置不依赖 block 网格
  • 内部节点可以只丢 state、保留 KV

vLLM 更好的地方

  • 一个 pool 自动分配,并发上限更高
  • full、SWA、Mamba 统一框架,connector 生态全
  • 稀疏保留、交点 checkpoint、partial hit 都有

这是读代码和公开数据得出的判断,没有在同一个 workload 上做过对比实测。