vLLM vs SGLang:Prefix Cache 实现对比与选型指南

📅 发布时间:2026/10/3 4:09:54
vLLM vs SGLang:Prefix Cache 实现对比与选型指南
先说说我为什么会对“Prefix Cache”这个话题这么上心。去年下半年我们在生产环境里上线了一套基于多轮对话和 Agent 的在线服务模型用的是 7B 左右的规模请求里几乎每条都带着一大段固定的 system prompt 和 few-shot 示例。起初延迟勉强能看但并发一上来prefill 阶段的计算开销直接吃掉了一大截 GPU 算力TTFT 也开始跟着往上飙。当时团队里讨论的一件核心事情就是怎么把“重复计算的前缀”省掉。也就是那个时候我对比研究了 vLLM 和 SGLang 两个框架的 Prefix Cache 实现发现它们虽然解决的是同一个问题走的路线却截然不同vLLM 用哈希表做块级缓存SGLang 用 RadixTree 做 token 级前缀匹配。这个差异背后其实藏着对调度、显存管理、并发控制的一系列取舍搞懂了它你在选型和调参的时候就不会只靠猜。1. 为什么所有推理框架都在卷 Prefix Cache1.1 KV Cache一次计算多次复用先把最底层的机制讲清楚不然后面所有的对比都悬空。Transformer 模型在自回归解码时每一步都要计算当前 token 对之前所有 token 的注意力。为了避免每生成一个 token 就把整个序列重新算一遍框架会把历史 token 对应的 Key 矩阵和 Value 矩阵缓存下来这就是我们常说的 KV Cache。有了它解码阶段只需要拿新的 Query 去和缓存的 Key、Value 做注意力计算省掉了重复的矩阵乘法。但这里有个容易被忽视的前提KV Cache 是从左到右逐步累积的它天然对应“前缀”这个概念。如果两个请求的输入拥有完全相同的一段前缀那么这段前缀对应的 Key、Value 在数学上也是完全一致的。这意味着第一个请求算完这一段之后第二个请求完全可以直接抄作业不需要再为这段前缀做一遍 prefill。这就是 Prefix Cache 能带来收益的根本原因。我曾经给团队里刚入门的同学打过一个比方想象你在写一篇文章AI 帮你分析的时候每次都要先看你前面两页已经写好的内容才能开始给建议。如果它记得你上两页内容下次你只改了第三页它就不用重新读前两页了。KV Cache 就是这段“读过的内容”而 Prefix Cache 是决定这段内容能不能跨请求复用的索引系统。1.2 共享前缀场景多到你想象不到可能有人觉得“我每次发出去的 prompt 都不一样前缀复用哪来的机会”但实际上在大模型应用里共享前缀几乎是常态而不是特例。最典型的是多轮对话。后面每一轮的输入都会带上前面几轮的对话历史假设系统 prompt 固定那么第 N 轮和第 N1 轮的输入共享的不仅是系统 prompt还包括前 N 轮的所有内容。另一个典型场景是 Agent 应用。为了让模型知道它能调用哪些工具开发者通常会在 system prompt 里塞一大段工具 schema这段内容对同一 Agent 的所有请求都是相同的。RAG 场景也类似query 前面往往固定拼接一段检索指令和文档格式说明。哪怕是离线批量推理只要你的数据集用的是同一个 instructions 模板前缀就大概率重复。这些场景的共同特点是共享前缀越长重复计算越严重Prefix Cache 能省的 prefill 计算量就越大。有实测数据表明在长上下文多轮对话场景中命中 Prefix Cache 后 TTFT 能下降 50% 以上正常吞吐也能提升不少。这也是为什么 vLLM 和 SGLang 都愿意花大力气做这个功能而不仅仅是把它当成一个可选优化项。1.3 两个框架两种哲学既然前缀复用这么重要接下来就看实现层面了。vLLM 的整个显存管理体系建立在 PagedAttention 和逻辑块block之上KV Cache 是按固定大小的 block 分配的因此它做 Prefix Cache 时很自然地选择以 block 为单位生成哈希值再用哈希表来索引这些可复用的 block。而 SGLang 的 RadixAttention 从设计之初就把“前缀”当成一棵树来管理它用 RadixTree 存储所有请求的前缀 token 序列和对应的 KV Cache 位置匹配时按 token 粒度去树里找最长公共前缀。一个是“块级精确匹配”一个是“token 级最长前缀匹配”。这两条路各有各的适用边界也各有各的坑。下面我分别拆开讲。2. vLLM 的哈希表路线以 block 为粒度的精确命中2.1 一切从 PagedAttention 说起理解 vLLM 的 Prefix Cache必须先理解它为什么是 block 粒度的。vLLM 把连续的显存逻辑抽象成固定大小的 block默认每个 block 可以存放 16 个 token 的 KV 数据实际 token 数可以通过--block-size调整。每个请求的 KV Cache 不要求物理连续而是通过 block table 记录逻辑块到物理块的映射这就像操作系统里的分页内存管理。在这种设计下KV Cache 的最小管理单位天然就是 block。要做缓存索引最直接的想法就是把每个 block 的内容哈希一下然后放到一个全局哈希表里。计算请求前缀时也是一块一块地哈希再一块一块地去哈希表里查有没有相同内容的 block。这个思路和 CPU 缓存、磁盘缓存的思路基本一致工程上简单直接和 vLLM 现有的 block 分配器也能无缝衔接。vLLM 里负责这件事的核心类是PrefixCachingBlockAllocator它把哈希表直接嵌入到了原本的 block 管理流程中。分配 block 时优先复用哈希表中已经存在的、并且当前没有被引用的 block释放 block 时则把它重新放回哈希表供后续请求使用。整个过程对于上层调度器来说几乎是透明的这也是 vLLM 能快速迭代这个功能的原因之一。2.2 vLLM 是怎么算 block 哈希的拿到一个 blockvLLM 会对它包含的 token id 序列计算一个哈希值作为哈希表的 key。这里有一个非常关键的细节vLLM 并不是只对单个 block 的 token 序列做简单哈希而是采用了从底层往上逐 block 链式哈希的方式。也就是说后面的 block 在计算哈希时会把自己的 token 序列和 parent block 的哈希一起作为输入。这样做的目的是防止一种微妙的前缀混淆假设两个请求在某一段拥有相同的局部 token但它们之前的前缀不同如果不做链式处理这些局部相同的 block 可能会被误当成同一段缓存。实际代码里每个PrefixCachingBlock会维护_hash字段。只有当 block 被填满、且不会再被追加 token 时它才会被计算并写入缓存表。这个条件是 vLLM 实现里很关键的一点如果 block 还没满说明它的内容是“进行中”的后面可能继续追加 token此时把它暴露给其他请求使用太危险了。我基于源码逻辑简化一下它做的事情大概是这样# 简化自 vllm/block/prefix_caching_block.py 的核心思路 def compute_block_hash(parent_hash, block_id, token_ids): # 链式哈希父块哈希 当前块ID 当前块token序列 return hash((parent_hash, block_id, token_ids)) def should_cache_block(num_filled_tokens, block_size): # 只有已经填满的 block 才允许进入缓存表 return num_filled_tokens block_size请求进来之后调度器会通过get_common_computed_block_ids计算输入前缀的 block 哈希序列然后逐个去哈希表里比对。命中的 block 意味着该段 KV Cache 已经有人算过了可以直接复用不需要再分配新的显存做 prefill。2.3 哈希表方案的边界和代价哈希表的好处非常明显查询复杂度是 O(1)匹配过程极快而且实现、调试、并发控制都相对容易。但它的代价同样明显。第一缓存粒度只能是 block。如果两个请求的前缀内容相同但长度不是 block_size 的整数倍那么不足一个 block 的尾部就无法被复用这段 KV 还是要重新算。第二哈希表匹配要求“完全相等”某个 block 里的 token 序列只要有一个位置不同哈希就不同整个 block 就相当于 miss 了。对于特别长的共享前缀只要在非 block 边界处被截断vLLM 依然能复用前面的完整 block但最后一个 block 的收益就丢了。另一个容易被低估的问题是哈希冲突和误匹配。vLLM 虽然用哈希值做索引但在真正使用缓存时并不会只相信哈希值它还会进一步比对 token ids确保内容一致才会复用。这个设计我们后面会再提到它是保证正确性的兜底手段。从我实际经验来看vLLM 的 Prefix Cache 在“离线批处理 固定模板 前缀长度远超 block_size”这类场景下效果非常好。模板前缀往往整齐地落在 block 边界上block 哈希的命中率很高。但一旦进入在线交互场景请求前缀长度参差不齐尾部那一段无法命中的比例就会明显上升这时候 SGLang 的树方案就有它的优势了。3. SGLang 的 RadixTree 方案token 级最长前缀匹配3.1 RadixTree 到底是个什么结构SGLang 走的是另一条路。它把缓存索引做成了一棵 RadixTree基数树。这棵树的结构可以理解为每个节点存储一段连续的 token ids也就是一组 token所有请求的前缀按路径共享。根节点是空序列从根节点往下每个分支代表不同的字符序列分支。RadixTree 和普通前缀树的区别在于连续、唯一的分支会被压缩到一个节点里减少树的层数匹配时一次能跳过一串 token。举个例子假设有两个请求前缀分别是请求A: [system_prompt, 今天天气] 请求B: [system_prompt, 今天适合跑步]那么树里会有一个节点存[system_prompt]下面分裂出两个节点一个存[今天天气]另一个存[今天适合跑步]。如果又来一个请求C前缀是[system_prompt, 今天]那它匹配到公共部分[system_prompt]后会在该节点下继续匹配[今天]。由于[今天天气]和[今天适合跑步]的前两个 token“今天”是共同的树需要在该节点处做分裂把它拆成[今天]加两个后续分支节点。SGLang 的RadixCache实现里每个节点大概包含这些信息当前节点存的一段 token idskey、对应的 KV Cache 索引value、父节点指针、子节点字典、最近访问时间和引用计数。实际结构比这复杂但核心思想就是这个。3.2 match_prefix一次匹配拿到所有可复用位置SGLang 的前缀匹配核心是match_prefix接口。给定一个请求的完整 token 序列它从根节点开始沿着树逐节点比较返回最长能匹配到的前缀长度以及这段前缀对应的所有 KV Cache 索引。调度器拿到这些索引后就可以直接告诉模型执行器“这部分不要算直接把这个位置的 KV 张量拿过来用。”这套机制的灵活性在于它不要求前缀长度对齐任何 block 边界。即使共享前缀只有几个 token只要树里存了这段路径它就能精确复用。相比之下vLLM 在共享前缀不足一个 block 时连一点便宜都占不到。SGLang 在插入和删除时会做节点的分裂与合并。插入新请求时如果发现公共前缀在一个节点的中间位置结束而新请求的后续 token 和该节点的剩余部分不一致SGLang 会把该节点分裂成两个节点公共部分保留在上层分歧部分拆开。这个操作很像 LSM-Tree 或者 Git 的对象树本质上都是用空间换查询效率把公共前缀收敛到共享路径上。下面是一段演示树结构变化的简化描述请求1: [A, B, C, D] 请求2: [A, B, E, F] 插入请求1后树结构: 根 - [A,B,C,D] 插入请求2后树结构: 根 - [A,B] - [C,D] 和 [E,F]这里的节点[A,B]被两个请求共同引用它的 KV Cache 可以同时服务于请求1和请求2 的前半段这就是树结构带来的前缀共享。3.3 淘汰策略不是简单 LRU有缓存就得有淘汰否则显存迟早被历史请求占满。SGLang 的 RadixCache 采用的是一种近似 LRU 策略但它不是简简单单按时间戳排个序就完事而是深度结合了引用计数机制。每个 RadixNode 里维护了一个last_access_time最近被匹配过的节点会刷新这个时间戳。淘汰时SGLang 会倾向于优先淘汰那些“叶子节点”中访问时间最早的部分因为如果一个节点下面还有仍在使用的子树贸然删除它会导致整条路径不可用。但如果某个节点当前正在被某个请求的 KV Cache 引用比如刚被调度器分派出去它的引用计数会大于 0这种节点会跳过淘汰防止出现 KV 正在被 GPU kernel 读取时缓存却被回收的问题。这个细节非常重要。我在看源码时发现SGLang 会同时维护ref_count和访问时间淘汰操作必须同时满足“引用计数为 0”和“长时间未访问”才会执行。这样做虽然让淘汰逻辑比简单的 LRU 复杂不少但换来了安全性和命中率之间的平衡。要注意的是RadixTree 的查询和插入不是严格的 O(1)。匹配一个请求的前缀最坏情况下要遍历到树深处复杂度正比于匹配路径的长度。好在实际应用中每个节点下挂的子节点数量通常很少而且节点内部是一次性比较一串 token所以整体性能依然不错。我在长上下文场景里实际测过SGLang 的前缀匹配耗时远小于 prefill 的耗时几乎可以忽略不计。3.4 树方案的软肋RadixTree 也不是银弹。第一它的实现复杂度明显高于哈希表。节点分裂、合并、引用计数、并发访问控制每一个环节都需要仔细处理。框架本身替你处理好了但一旦你想深度定制或做二次开发需要付出的理解成本会高不少。第二在共享前缀非常短、甚至几乎没有前缀复用的场景下RadixTree 的维护和查询开销就是纯纯的额外负担。你插入了很多细碎的节点匹配时每次都走不到什么公共路径树的体积膨胀缓存的收益却趋近于零。这种场景下哈希表的简单高效反而更适合。第三SGLang 的 RadixTree 更适合“长前缀强复用”的工作负载比如多轮对话、Agent、工具调用这类应用。对这些应用来说每一轮的系统 prompt 和对话历史往往占据整个输入 token 数的一大半树上一次命中就能省掉很多 prefill 计算。反过来如果每个请求都是随机短文本几乎没有共享前缀那树方案的收益就非常有限。4. 正面PK一张表看清两个框架的取舍聊到这里两者的差异已经很清晰了。我做了一张对比表方便大家在实际选型时快速对照。对比维度vLLM Prefix CacheSGLang RadixCache核心数据结构哈希表block 哈希索引RadixTree基数树缓存粒度block默认 16 tokentoken 级一段连续 token 作为一个节点匹配方式求哈希后精确匹配整个 block从根节点开始匹配最长公共前缀查询复杂度O(1) 哈希查找O(前缀路径长度)插入/删除复杂度以 block 为单位逻辑相对简单涉及节点分裂/合并逻辑复杂前缀长度要求需要完整 block 才能入缓存任意长度前缀都可复用容量淘汰由 block 分配器统一管理近似 LRU 引用计数保护并发控制主要关注 block 与哈希表的锁粒度树结构本身的并发安全和节点引用最擅长场景固定模板、长度规整的批处理任务多轮对话、Agent、长共享前缀的交互场景最怕遇到的情况共享前缀不足一个 block或前缀长度参差随机短文本几乎无前缀可复用的场景这里有几个点值得展开讲一下。首先是“为什么 vLLM 不选择树结构”。核心原因在于 vLLM 的整个调度和显存管理都建立在 block 之上KV Cache 的分配、释放、拷贝都要走 block 表。如果在这个体系里引入 token 粒度的树结构意味着它需要额外维护一套从 token 到 block 的映射而且调度器在寻找可用 KV 空间时可能还要处理“某个 block 的前半段被缓存、后半段没有被缓存”这种碎片状态这会极大增加调度复杂度。vLLM 选择哈希表本质上是选择了和现有 block 管理最契合、工程上最稳健的方案。SGLang 从一开始就做 RadixAttentionKV Cache 的分配天然和前缀树绑定在一起。它的数据结构设计决定了它可以在 token 级别做精细的前缀共享而不必牺牲调度效率。这也是为什么 SGLang 在交互式场景里表现更激进的原因。但两者其实也不是绝对的“谁比谁好”。我在实际使用中的感受是如果你的工作负载前缀整整齐齐vLLM 的哈希表路线效率很高维护成本也低如果前缀长度参差不齐或者有大量交互式请求SGLang 的树方案能榨出更多复用价值。5. 部署与调优实战我更推荐场景化选型5.1 拿到框架后先看什么指标不管用哪个框架第一步永远是确认 Prefix Cache 真的在起作用而不是你以为它在起作用。vLLM 启动时加上--enable-prefix-caching之后你可以通过监控面板或日志观察请求的缓存命中情况。比较直接的指标是 prefix cache hit ratevLLM 在较新版本中支持通过 metrics 输出相关数据。如果命中率是零先不要急着怀疑框架多半是请求前缀构造有问题或者 KV cache 的显存预留太小导致缓存刚写进去就被淘汰了。SGLang 的 RadixCache 是默认行为没有显式的开关。你可以通过日志和 profiling 工具观察匹配到的前缀长度。它的日志体系里会有和 cache 相关的统计输出重点看hit_len和total_len的比例这个值就是你的前缀命中质量。我个人的观察习惯是先用一组完全相同的 prompt 压测看第二次请求的 TTFT 是不是显著低于第一次。如果这个差值没有出现基本可以排除框架问题回头查自己的请求构造和 tokenizer。5.2 影响命中率的三件“小事”有些人开了 Prefix Cache 但效果不明显我总结下来原因通常出在下面三件事上。第一请求的前缀构造不稳定。很多应用会在 system prompt 里动态拼上当前时间、随机用户 ID、或者日志序号。一旦这些内容出现在前缀的末尾哪怕只差一两个 token整段前缀的复用率都会被拉低。处理方法很简单把真正固定、且尽量长的内容放到 prompt 的最前面把动态内容尽量往后放让固定部分形成长而稳定的共享前缀。这个原则我在排障时反复验证过几乎每次都能立竿见影。第二vLLM 场景下要注意 block 对齐。vLLM 默认 block size 是 16如果你的共享前缀长度是比如 17 个 token那么前 16 个 token 可以命中多出来的 1 个 token 还是要重新计算。虽然损失不算大但在批量请求很多时这种反复出现的对齐损耗会累积。SGLang 没有这个问题但 SGLang 的树结构对一致性要求同样严格前缀末尾多了个空格或者换行符也会破坏匹配。第三显存预留和缓存的平衡。Prefix Cache 本质上是拿显存空间换计算时间如果 KV cache 的显存预留太少缓存条目很快就会被淘汰命中率自然上不去。vLLM 里配置gpu_memory_utilization时不能只看模型权重占了多少还要给 Prefix Cache 留出富余空间。一般来说如果工作负载有明显共享前缀可以把gpu_memory_utilization适当调大让 KV cache 块更多一些。SGLang 也有类似的显存分配参数道理相同。5.3 部署时这些参数值得试一下先声明我不是让你照抄参数模型规模、显存大小、请求模式不同参数差异会很大。以下是我在 7B~13B 模型、单卡 A100/A800 上实测过的一组基线可以参考后自行调整。vLLM 侧至少开这几个东西python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --gpu-memory-utilization 0.9 \ --max-num-seqs 128 \ --enable-prefix-caching \ --block-size 16--enable-prefix-caching是必须的显式开关--block-size可以根据你的平均前缀长度尝试增大到 32 或 64。增大 block size 会让单个 block 的复用颗粒变大、哈希条目减少但也会让“最后一个 block 不满导致无法缓存”的浪费更严重。这个参数需要结合请求日志里统计的 prompt 长度分布来调不能拍脑袋。SGLang 侧我主要关注启动时的并发参数和显存分配策略。它的默认配置对交互式负载已经比较友好我一般不会大改重点观察 RadixCache 的命中率和显存占用。如果前缀命中率很高但显存占用接近上限可以考虑调低 max 并发数或者调整缓存淘汰的阈值参数。还有一个很容易被忽略的点多机多卡部署时Prefix Cache 是每张卡本地维护的。如果负载均衡把请求分散到不同卡上同一个前缀可能在不同卡上各算一遍最终实际收益低于单机场景下的表现。这个问题在 SGLang 和 vLLM 都存在如果共享前缀非常明显可以考虑在做路由时优先把相同前缀的请求调度到同一张卡上。6. 踩坑实录我从这些坑里学到了什么6.1 vLLM 的“最后一个 block”陷阱这是我最开始踩过的一个坑也是社区里被讨论最多的问题之一。vLLM 的 Prefix Cache 只缓存完整填满的 block最后一个区块如果不足 block_size它是不会被写入哈希表的。原因我在前面也提过这个 block 的内容还没稳定接下来可能继续追加 token如果提前缓存了一旦后续追加导致内容变化哈希缓存就失效了。这个机制带来的直接后果是即使两个请求共享前缀长度完全相同只要这个长度不是 block_size 的整数倍尾部那一小段就必须重新计算。我见过有人因此觉得 vLLM 的 Prefix Cache 是“假”的其实不是它只是把缓存策略设计在了块粒度上。解决办法也很朴素如果场景允许尽量让固定系统提示词的长度对齐 block size。当然不可能每次都对齐所以更实际的做法是接受这个限制或者直接切到 token 粒度的 SGLang。6.2 tokenizer 的“隐形”破坏还有一次我们的请求在日志里看起来前缀完全一样可命中率就是上不去。排查到最后发现是AttenionMask前面有没有加入特定的 special token 导致的。模型不同tokenizer 对换行、空格和特殊 token 的处理方式也不同有些 tokenizer 会自动在文本前面加一个s或im_start有些则不会。你的请求如果一边是“直接消费 API 的 prompt”一边是“从别的框架传过来的 prompt”token 序列很可能在开头就分叉了。这提醒我前缀匹配是基于 token id 的逐位比对不是基于文本的视觉相似度。所以想最大化命中率不同请求之间的 prompt 必须真正经过同一个 tokenizer、同一个预处理链路生成不能靠“看起来一样”来判断。6.3 别用手动清缓存的方式“调优”SGLang 的 RadixCache 里一个节点可能同时被多个请求引用。KV Cache 是显存里的张量有引用计数保护框架会在合适的时机自动淘汰。我见过一些同学为了“给显存腾空间”手动清空 KV cache 或者重启引擎结果不仅缓存状态丢失还可能造成正在执行的请求被中断。做性能调优时应该通过框架提供的手段观测缓存状态不要尝试绕过引用计数去改底层缓存。同样的道理也适用于 vLLM。它的 block 分配器对缓存和被占用 block 是严格区分的如果你在 Kubernetes 层做 GPU 显存降级或者动态迁移很容易触发 block 状态不一致的问题。我建议在容器环境里部署推理服务时尽量保持稳定的显存生命周期不要频繁重启。6.4 长上下文下的显存“幻觉”最后说一个比较隐性但影响很大的问题Prefix Cache 命中并不等于显存占用变低。恰恰相反为了缓存尽可能多的前缀框架会占用更多显存来存储 KV 块。有些人在压测时发现开了 Prefix Cache 后吞吐反而没提升多少一看显存已经顶满batch size 被压缩后面请求的 decode 阶段反而变慢了。这个现象并不是反驳 Prefix Cache 的价值而是提醒你要全局地看待显存预算。Cache 收益主要在 prefill但如果它压缩了 decode 的并行度整体延迟和吞吐可能会失衡。遇到这种情况我的处理方法是实测一组不同gpu_memory_utilization配置找命中收益和 decode 并行度之间的甜点而不是盲目追求缓存空间最大化。说回选型。如果一定要我给一个倾向性建议我个人的习惯是离线离线批量推理、请求模板高度统一的场景优先用 vLLM省心且稳定在线交互、多轮对话、Agent 应用优先用 SGLang它把长前缀复用这件事做到了极致TTFT 的优化空间更大。当然框架迭代很快我这几条经验也只基于当前主流版本的观察你上线前还是要拿自己的数据跑一跑毕竟“命中率”三个字最诚实的解释永远来自生产环境里真实请求的统计。顺带分享一个小技巧在你做前缀命中率调优的时候可以在请求日志里按“前缀长度”维度做累计分析把那些超长前缀且重复度高的请求单独拎出来看看它们的命中路径到底在哪一层断掉。这个分析在 vLLM 和 SGLang 里都能通过日志间接实现花不了多少时间但往往能帮你快速定位到到底是提示词结构问题、tokenizer 问题还是参数配置问题。