vLLM与SGLang前缀缓存对决:哈希表与RadixTree实现解析

发布时间:2026/10/3 10:04:56
vLLM与SGLang前缀缓存对决:哈希表与RadixTree实现解析 做LLM推理服务时间长了你会对“算力被浪费”这件事特别敏感。我第一次用vLLM部署一个带超长系统提示词的对话服务时就发现了一个很讽刺的现象100个并发用户每个人发的请求里都带着同一段1500 token的系统提示词而这段提示词的KV cache在每一次请求里都被重新计算一遍。一个7B模型同样前缀的prefill单张A100上差不多要额外烧掉一两百毫秒攒成一整天的量非常可观。后来我换到SGLang试了一下同样场景下缓存命中率直接拉满prefill开销肉眼可见地降了下去。同样一件事情两个主流框架用了两种完全不同的数据结构去解决vLLM用哈希表管理KV块SGLang用RadixTree管理前缀节点。这篇文章我不打算念官方文档而是从数据结构层面把这两套Prefix Cache的实现差异彻底讲明白顺便把我在实际部署中踩过的坑和调优经验一并分享出来。1. 为什么需要Prefix Cache先算一笔重复计算的账1.1 一次请求到底在重复计算什么大模型解码分为两个阶段prefill和decode。prefill阶段模型要一次性处理你发给它的全部输入token每个token都要对之前所有token做attention计算复杂度随序列长度近似二次增长。假设输入长度是2000 token那光prefill就要算约200万次attention位置2000×2000/2这还不算中间的FFN、LayerNorm这些计算。问题就在这真实业务中的输入有很大一部分是高度重复的。系统提示词、few-shot示例、对话历史、Agent场景里反复出现的工具说明和上下文这些内容在不同请求之间几乎是原样复用的。但默认情况下模型并不知道它们是重复的每个新请求来了照算不误。于是你会在监控面板上看到一种奇怪的现象GPU利用率很高但有效产出低得可怜大量算力花在了“同一个句子的第二十次prefill”上。我做一个简单的量化对比。一个13B模型hidden size是5120层数40保存一个token的KV cache大约需要 2×40×5120 409,600 个float16数值也就是约0.8MB。一段1500 token的系统提示词KV cache差不多就要1.2GB。100个在线用户人手一份一模一样的1.2GB就是120GB的预计算量和显存开销重复产生。Prefix Cache做的就是抓住这个重复一旦某个前缀算过一次后面对它有共同前缀的请求直接复用不重算。1.2 Prefix Cache缓存的是什么东西这里容易被误解先澄清一下缓存的是KV cache不是中间隐藏层状态。Transformer在prefill阶段会把每个输入位置经过QKV投影后得到的Key和Value向量存起来后续每个新token做attention时都只需要去读取前面所有token的KV向量不需要重新跑一遍前面的网络层。所以KV cache本身就是为“增量计算”而生的天然适合缓存。但KV cache有几个特点让缓存实现变得棘手。第一它必须和序列前缀一一对应前缀不同同一个位置的KV值就不同。第二前缀长度是不固定的可能是3个token也可能是3000个token。第三多个请求可以共享同一个前缀但共享之后可能在任意位置分叉。第四显存是有限的缓存不能无限积累必须有一套淘汰机制。这四个问题本质上就指向了“如何对任意长度前缀做内容寻址和共享”而内容寻址与共享恰恰是数据结构的核心战场。vLLM选择了用哈希表在块级别解决SGLang选择了用RadixTree在token级别解决各有各的道理。2. vLLM的哈希表方案块级内容寻址的工程实现2.1 从PagedAttention到块状KV CachevLLM的核心创新之一是PagedAttention思路很像操作系统的分页内存管理。KV cache不再为每个请求分配一整段连续显存而是切成固定大小的物理块默认是16个token一块通过block table把逻辑块映射到物理块。这样做的好处是显存碎片少分配和释放都非常快而且天然支持非连续存储。Prefix Cache就是在PagedAttention之上长出来的。因为KV cache已经被切成块了那最简单的复用单位自然也是“块”如果两个请求的前16个token完全一样它们对应第一个块里的KV cache就应该一样那就没必要算两遍只需要让两个请求的block table都指向同一个物理块就行。这个思路很直觉但也带来一个关键问题怎么快速判断两个块里的内容完全一样2.2 块哈希给每个KV Block做内容指纹vLLM的答案是哈希。每个block在计算完KV cache之后会基于这个块里的token id生成一个哈希值用哈希值作为key去查一张全局哈希表就可以O(1)判断这个块是不是已经在缓存里。但这里有一个非常容易踩坑的细节哈希不能只对“当前块内的token”做。原因在于Transformer的KV计算依赖于上下文同一个token出现在序列的不同位置经过前面几层自注意力之后它对应的KV向量是完全不同的。如果哈希只基于块内token那两段不同上下文里恰好出现相同token块时哈希会误判为同一个块一旦复用就是严重的精度错误。vLLM实际的实现是让块哈希形成一条哈希链第n个块的哈希值由“前一个块的哈希值”加上“当前块的token序列”共同计算。你可以理解为每个块的哈希都隐含了整个前缀的指纹这样两个块只有在全量前缀都一致的情况下才会命中同一个哈希。这个设计非常关键如果你自己写类似的哈希缓存千万不要只对局部内容哈希。2.3 命中、共享与写时复制当一个新请求进入vLLM调度器系统会从头开始逐个计算它输入token对应的块哈希再去哈希表里查物理块。假设请求的前96个token已经由其他请求算过而一个块是16 token那前6个块都会命中缓存请求只需要从第7个块开始真正执行prefill。这6个命中块会被直接挂到新请求的block table上同时物理块的引用计数1。如果两个请求共享了前N个块但之后要“分叉”麻烦就来了。以对话场景为例请求A和请求B共享前512个token之后A继续生成内容B也要继续生成内容但两者生成的内容不同。B在往“共享块”后面写新的KV时不能直接写原来的物理块否则会把A的数据污染。vLLM在这里使用写时复制Copy-on-Write在分叉点新分配一个物理块把原来的KV数据拷贝过来再写入新的KV同时把原共享块的引用计数-1。这个机制保证了共享安全但也意味着分叉频繁时会有额外的拷贝开销。2.4 缓存淘汰与开启方式vLLM不是无限缓存它给每个缓存块记录了最后访问时间在显存吃紧或空闲物理块不足时会优先淘汰那些没有被任何活跃请求引用的缓存块近似LRU策略。要注意的是只要一个块还被某个正在运行的请求引用即使它在缓存里躺了很久也不能被淘汰。启用这块功能在不同版本下有所不同。较老的vLLM版本需要显式加--enable-prefix-caching参数而新版本在某些条件下可能默认启用具体以你安装版本的vllm serve --help输出为准。实测中我建议你自己先确认一下启动日志里有没有“prefix caching”相关字样不要想当然以为默认开了。3. SGLang的RadixTree方案把前缀缓存组织成一棵树3.1 RadixTree长什么样SGLang走的是另一条路用基数树RadixTree直接管理所有历史请求的前缀。你把它想象成一颗“前缀索引树”根节点是空序列从根往下每条路径代表一个已经计算过的token前缀每个节点保存一段连续的token序列和对应的KV cache位置。公共前缀越长的请求在树上的路径就越靠近越能共享上层的节点。举一个最直观的例子。假设缓存里已经有两条请求请求1内容写一首关于夏天的诗请求2内容写一首关于秋天的诗它们共享“写一首关于”这段前缀之后在“夏天”和“秋天”处分开。那RadixTree中会有一个共享节点“写一首关于”下面挂着两个子节点子节点里再展开后续的“的诗”等内容。当第三条请求“写一首关于冬天的诗”到达时系统沿着树找到“写一首关于”这个节点发现子节点只能匹配到“夏天”或“秋天”的开头匹配不上“冬天”于是直接在共享节点下新建一条分支即可前8个token直接命中缓存不需要重算。3.2 最长前缀匹配比块级缓存更灵活vLLM的块级哈希缓存就像一把固定尺度的卡尺只能按16 token的边界去卡。如果共享前缀长度是1500 token那它能精确复用1492个token的整块部分剩下8个token没法简单复用因为最后一个块不完整不能直接拿去拼接。SGLang的RadixTree则没有这个烦恼节点可以任意切分匹配可以精细到单个token级别共享前缀是1500个token就复用1500个一个token都不会浪费。更关键的是匹配策略。RadixTree在查找时执行的是最长前缀匹配从根开始沿着每一位token向前推进尽可能走得深。如果某个节点只能匹配一半就把这个节点分裂成两个一个保留共享部分另一个挂上没有匹配上的后缀。这种“按实际匹配长度动态分裂”的能力让SGLang在共享前缀不整齐、请求路径千奇百怪的Agent场景下表现得特别从容。3.3 插入、分裂、淘汰与并发保护新请求的匹配结果无非三种完全命中某条路径、命中一部分、完全没命中。完全命中的情况下新请求直接把路径上对应的KV cache引入自己的计算过程不需要额外prefill。命中一部分时就需要做节点分裂然后从未命中位置开始做prefill并把新算出来的token作为新分支插入树中。完全没命中则从根直接挂一条新路径。淘汰策略上RadixTree每条路径和节点都会记录最后访问时间和节点大小token数实际淘汰时采用LRU策略优先淘汰最久没被访问且没有被活跃请求占用的节点。为了线程安全访问树时会对节点加锁并标记引用状态防止一个请求刚匹配完节点、还没来得及用它时节点就被另一个并发请求淘汰掉。这块并发控制是实现里最精细、最容易出bug的地方SGLang的工程团队在这里花了不少功夫。3.4 缓存感知调度RadixTree带来的另一个隐藏收益是调度器可以“感知缓存”。SGLang的调度逻辑在决定下一步要执行哪些请求时会先用RadixTree对每个等待中的请求做一遍前缀匹配预估这次调度能命中多少缓存token、实际需要新prefill多少token。基于这个预估值调度器可以把那些“缓存命中高、剩余计算量小”的请求优先塞进当前batch从而在同样的batch budget下塞进更多请求、摊薄GPU的空转率。这一点是vLLM的经典实现相对缺失的。vLLM的调度器主要看显存块够不够、token预算够不够它不会因为一个请求的前缀命中率高而优先调度它。所以在高并发、多路复用的场景下SGLang不仅缓存复用粒度更细调度配合也更主动等于把分歧从数据结构层面一路拉到了调度层面。4. 两种方案的核心差异从数据结构到工程取舍4.1 一张表看懂核心差异拿我自己部署过的一台8卡A100测试机为例我用同一批请求压测两个框架整理下来大概是这样对比维度vLLM哈希表SGLangRadixTree核心数据结构全局哈希表key是块哈希链基数树节点保存token片段缓存粒度固定Block默认16 token不固定按实际前缀动态切分可到token级前缀匹配能力只能按块边界匹配整块任意长度最长前缀匹配非整块共享容易浪费最后几个不齐的token基本零浪费并发共享方式引用计数 写时复制节点访问锁 引用标记淘汰策略块级LRU活跃引用保护路径/节点级LRU活跃引用保护调度联动无专门缓存感知调度缓存感知调度按命中率决定batch实现复杂度中等顺势扩展PagedAttention较高树操作与并发控制更复杂典型场景通用吞吐优先、前缀重复度不高强前缀共享、Agent、多轮长对话4.2 命中率差在哪一个1500 token前缀的实例我们用数字说话。假设所有请求共享一段1500 token的提示词vLLM的块大小是16前1492 token是完整的93个块这些能命中剩下8个token因为没填满一个完整块如果所有请求都恰好在这里结束可能还能复用但只要请求后面还要接不同的用户输入这几个token就得重新计算。也就是说在最常见的场景下vLLM有约0.5%的前缀浪费看似不多但如果前缀本身更长、分叉点更密集浪费会被放大。SGLang则可以把“1500 token共享前缀”完整地保存为一个节点或一条路径。新请求到了直接从第1501个token开始计算。尤其在Agent场景里前缀往往是由“系统提示 工具描述 历史多轮消息”拼接出来的长度不规整分叉点也飘忽不定。这种情况下RadixTree的优势会从“微小的边界浪费”变成“数量级上的prefill省省省”。4.3 显存分配与碎片化的取舍vLLM的块级方案在显存管理上更接近传统的内存池所有块大小一致分配回收都是常数时间加上虚拟分页物理碎片非常少。这对长时间运行的高吞吐服务来说很友好几乎不会出现“显存明明还有但因为碎片分配不出可用块”的情况。SGLang的RadixTree虽然节点逻辑大小不固定但KV存储本身仍是在一块预先分配好的显存池里按token数划片本质上还是通过类似连续分配的机制管理。好处是空间利用率高坏处是更细粒度的分配和释放会带来一定的碎片管理压力长时间运行后需要更仔细地监控显存占用。SGLang在设计上做了不少优化但相比之下综合内存卫生还是vLLM的块级方案更省心。4.4 实现复杂度与维护成本从工程角度来看vLLM选择哈希表是“顺势而为”。它本来就靠PagedAttention把KV切块了块哈希只是给每个块打标签改动相对收敛出问题也容易定位。SGLang则需要在调度和块管理之间维持一棵随时可能分裂、合并、淘汰的树还要考虑多请求并发时的锁竞争实现复杂度明显上了一个台阶。但这不代表vLLM的方案就是“更好实现”的廉价版。哈希表的O(1)查找建立在哈希质量够好、碰撞足够少的前提上vLLM需要小心设计哈希函数避免不同前缀出现相同哈希。RadixTree的复杂度则主要体现在正确性上节点分裂错一个位置、淘汰错一个活跃节点、锁范围没覆盖对都可能引发显存错乱或请求精度异常。这也是为什么SGLang的早期版本更新频繁很多commit都在修RadixCache的边界问题。5. 部署实测与调优两组场景的真实差异和踩坑记录5.1 场景A强前缀复用的Agent服务我选SGLang我接过一个Agent类项目每个请求都会携带一份非常长的工具定义和数据上下文系统提示词3000多token然后才是用户当轮的问题。前一轮对话还需要带上历史记录前缀长度轻松突破4000 token。刚开始用vLLM部署开了prefix cache但压测一上来就发现prefill时间不降反升查了很久才意识到虽然大部分前缀能命中块缓存但多轮对话中每轮新增的内容长度不一块边界经常对不齐最终总有那么几百个token要陪着每次请求一起重算。后来我把同一个模型切成SGLang。它每轮都会在RadixTree里把所有共享前缀完整复用prefill的计算量降到了原来的三分之一左右。调度器还能同时安排更多缓存命中率高的等待请求进入decode阶段整体TPOT和TTFT都比我调优过的vLLM更稳。如果你的业务里前置提示词特别长、多轮分叉特别频繁、Agent工具调用特别多SGLang这条路明显走得更顺。5.2 场景B短请求高吞吐的通用Server我留vLLM另一个场景是通用问答和短文本生成。请求之间几乎没有公共前缀每条请求就是几十到一两百个token多的是用户随手发的碎片化问题。这种场景下vLLM的块级哈希表几乎没有额外负担哈希计算成本低块分配回收快调度器逻辑也简单直接SGLang的RadixTree虽然也能跑但那套节点分裂、路径维护和感知调度带来的开销放在短请求高并发里就成了不太划算的“奢侈品”。我在同一批短查询流量上各跑了一轮vLLM的吞吐和首token延迟都略胜SGLang。这也很符合两个项目的定位vLLM的社区迭代多年对通用高吞吐推理打磨得极好SGLang则更像为“复杂结构化前缀共享”而生的特种兵。选型时别盲目跟风“谁热用谁”先看前缀重不重复再看前缀整不整齐。5.3 常见问题与排障经验速查部署过程中我遇到了不少问题整理成一张速查表希望帮你少走弯路。现象可能原因排查与解决缓存命中率始终为0vLLM老版本没开--enable-prefix-caching或请求前缀有多余空格、换行导致“看似相同实际不同”先确认启动参数再对输入做一遍字节级对比显存明明够还老是重复prefill缓存块太少或者刚写入就被快速淘汰请求分叉太频繁调大gpu-memory-utilization观察缓存命中指标确认是否有足够多的共享前缀SGLang显存增长异常长尾的唯一前缀缓存堆积占用大量KV池检查LRU淘汰是否生效必要时降低max-running-requests限制活跃请求数多卡或多节点下缓存效果不对前缀缓存索引在分布式环境下同步有延迟或范围不对核对框架版本和分布式并行方式确认是张量并行还是数据并行前者KV在卡上分片后者卡间不共享缓存内容切换服务后首次请求特别慢缓存是进程内存态的重启即清空预热服务启动后先发几个代表性的前缀请求把热门前缀灌进缓存同一个块哈希在多个请求间复用后结果异常多半是手动改过KV cache或用了投机采样一类的高级特性优先排查自定义采样参数和投机解码是否与prefix cache冲突分享一个我亲测有效的习惯每次部署完先用几个和线上流量结构相似的前缀请求做一次“预热”再拿监控指标里的缓存命中率去做回归对比。如果预热后命中率依然很低基本可以断定是前缀本身不一致或者功能没开启而不是框架的缓存策略出了问题。另外vLLM和SGLang都提供了Prometheus相关的指标把prefix_cache_hit_rate这类指标接入现有监控比事后翻日志高效得多。6. 最后说点我自己的体会我在实际项目里来回切换过这两个框架最大的感受是哈希表和RadixTree并不是谁替代谁的关系它们是在不同约束条件下做出的工程选型。vLLM的哈希表方案把“简单、稳定、通用”放在首位用块级缓存换取极低的工程复杂度SGLang的RadixTree则是把“前缀复用效率”推到极致用更精密的树结构和感知调度去满足多轮对话、Agent这类高碎片化前缀场景。如果你让我给一个选择建议我会说先量化你的前缀长度和重复度再决定站队。前缀又长又碎优先考虑SGLang前缀短、请求杂、以吞吐为先vLLM依然是更稳妥的默认选项。最后再留一个小技巧不管用哪一种框架都别忽略输入预处理。请求里的空格、换行、BOS标记不一致前缀缓存都会直接失配这是我在生产环境里遇到过的最隐蔽、也最容易让人怀疑框架有bug的问题。