
SGLang和vLLM是当前自部署LLM推理服务时绕不开的两个引擎。两者都宣称支持Prefix Cache前缀缓存但底层实现路线完全分叉SGLang用一棵RadixTree把前缀组织成可检索、可分裂合并的树形结构vLLM则把KV Cache切成固定大小的block然后用哈希表做内容寻址。这篇博文会把两条路线的设计动机、数据结构、调度耦合和实际表现放在一起对比重点说清楚“树”和“哈希表”这两种方案在Prefix Cache场景下各自的得失。无论你是在评估换引擎还是纯粹想看懂两者的缓存命中日志这篇文章都能提供一个完整的分析框架。1. 没有前缀缓存时推理引擎在重复烧钱1.1 Prefill阶段的“隐形浪费”LLM推理分成prefill和decode两个阶段。prefill阶段会把整段prompt一次性喂给模型算出每个位置的Key和Value存进KV Cachedecode阶段每生成一个token只基于已有KV Cache做一次自回归计算。问题在于如果两个请求共享同一个前缀第二个请求其实完全不需要重新计算这个前缀的KV但绝大多数朴素实现会傻乎乎地把整段prompt再算一遍。prompt越长这种浪费越离谱。对于一个64B模型一段1000 token的prompt prefill可能需要几秒而之后每个token生成只有几十毫秒的量级一旦请求频繁带同样的系统提示词或历史上下文prefill就是最大的瓶颈之一比decode更烧GPU算力。1.2 什么场景才会真正吃到缓存红利不是所有流量都有缓存价值。如果请求之间完全无关前缀缓存基本帮不上忙。真正有收益的场景很典型多轮对话同一session的后续请求天然以整个历史对话为前缀。用户每发一条消息新prompt 旧历史 新增内容 新问题。如果历史被缓存新的prefill只需要计算新增的几十个token。固定系统提示词很多SaaS场景会把角色设定、工具说明、安全规则作为固定前缀塞进每条请求。不同用户之间的前缀完全一致。few-shot示例固定示例集放在prompt头部不同query共享这些示例。RAG流程通常会把系统指令和检索指令放在prompt前面查询内容放在末尾这样前缀共享率非常高。这些场景有一个共同点prompt的大部分内容在多个请求之间保持不变只有尾部在变。这恰好是Prefix Cache能够发挥作用的先决条件。1.3 缓存索引路线树形索引 vs 内容寻址同样是缓存“历史上算过的KV Cache”实现上有两个核心问题一是怎么快速找到一个新请求能复用哪些缓存二是怎么管理这些缓存的生命周期和显存占用。两个引擎给出了两种完全不同的答案SGLang把可复用的前缀当作一棵树来维护树的每条路径表示一种前缀。请求到来时做一次最长前缀匹配命中路径上的节点就等于拿到了可用KV。vLLM把KV Cache切成固定大小的block每个block用内容哈希做索引。新请求只要某一段block的哈希命中了就直接复用这个物理block。这两种策略的差异会一路传导到显存分配、并发控制、调度器交互乃至最终的首token延迟。要理解SGLang和vLLM的真实差距得先明白这两种索引在工程上分别意味着什么。2. SGLang的RadixTree把前缀当作一本可检索的字典2.1 RadixCache在SGLang中的定位SGLang的核心思想是RadixAttention让注意力计算只处理“真正新增”的那部分token。这句话听起来简单落地时需要一套机制来记住“哪些前缀已经在树上、对应哪些KV cache”。这套机制就是RadixCache。它不是一个辅助优化而是SGLang调度系统的一部分请求到调度器之前SGLang会先用RadixCache做前缀匹配得到可复用的KV cache长度然后把“未命中部分”交给调度器调度器排好批次后RadixAttention只对新增部分执行attention。这一步设计的关键在于SGLang把缓存索引和计算调度放在同一个流程里而不是KV cache分配的附加品。因此它对缓存命中的感知非常精细一点前缀都能扣出来用。很多人在观察SGLang和vLLM的首token延迟差距时会单纯归因于“调度器写得好”实际上RadixCache能在调度前就帮请求“瘦身”才是更底层的原因。2.2 树节点到底存了什么RadixCache是一棵压缩前缀树也叫基数树。树上的每个节点代表一段连续的token序列路径从根出发按token id逐级向下展开。节点的核心字段大致有节点对应的token id序列可以是一个或多个token作为路径上的一个片段。子节点结构通常是按第一个token id索引的字典便于前缀匹配时快速跳转。KV cache句柄该节点对应的KV状态缓存在GPU显存中。引用计数refcount当前有多少运行中的请求正在引用该节点。只要refcount大于0该节点就不能被驱逐。最近访问时间和LRU字段用于缓存淘汰。既然是压缩树RadixCache还有一个关键操作节点分裂与合并。匹配时如果只命中了某个节点的一部分token就需要把该节点拆成“公共前缀部分”和“剩余部分”公共部分继续被多个请求共享剩余部分挂在新分支上。如果某个分支的所有子节点都合并成了唯一一条路径又会把这些节点压成一个节点减少树深度和匹配开销。这里有一个工程细节容易被忽略KV Cache是连续的一段张量节点分裂后公共部分对应的KV还是原来那段而新的分支需要重新分配显存。也就是说分裂操作本身不会复制旧的KV只是把索引关系重新挂接真正的显存开销只发生在新增路径上。这也是RadixTree能够做到“精细共享”而不产生大量重复计算的原因。2.3 最长前缀匹配的查找过程假设当前树里有A-B-C这条路径新请求是A-B-D。RadixCache的处理分三步从根开始沿token id逐节点匹配发现A和B都能匹配C与目标D不一致所以最长公共前缀是AB。将B节点分裂公共部分B保留原有的C分支挂在B下新请求的D分支也挂在B下。新请求注册为D分支的使用者D分支refcount加1等待prefill后写入KV Cache。下面用伪代码描述这个思想不代表SGLang源码逐行实现但逻辑本质一致def match_prefix(cache_tree, request_tokens): node cache_tree.root matched_len 0 while matched_len len(request_tokens): child node.children.get(request_tokens[matched_len]) if child is None: break # 压缩节点内部可能含多个token逐个比较 common common_prefix(child.tokens, request_tokens[matched_len:]) if common len(child.tokens): split_node(child, common) # 分裂节点 node child matched_len common if common len(child.tokens): break return matched_len, node def insert(cache_tree, request_tokens, kv_state, start_pos): # 从start_pos开始插入新节点绑定kv_state和refcount ...每次插入前都会先match_prefix所以树上的节点会随着请求模式不断分裂和合并。这种动态性解决了一个哈希表不太好做的事任意长度前缀的复用。它不要求前缀长度是16的倍数也不要求整段缓存必须完全一致只要新请求的一部分和历史上某个请求的路径重合这部分就能被复用。2.4 LRU驱逐与KV显存回收树再聪明显存还是有限的。没有引用的节点就是“僵尸”缓存需要按某种策略驱逐。SGLang的做法是LRU每个节点记录最后访问时间驱逐时优先清理refcount为0且最久没被访问的节点释放一个内部节点时如果它的子节点都已经refcount为0通常会一并释放子树把显存还给GPU。节点被引用时即使LRU排到它也不能动它这是保证正确性的底线。这里有一个容易被忽视的工程细节驱逐一个节点并不只是删掉一个对象而是要同时释放它对应的KV cache显存并且让仍引用该节点的请求不再访问它。SGLang通过refcount保证“正在用的缓存不驱逐”所以驱逐动作只会发生在安全边界内。这也是为什么RadixCache的refcount是整个方案正确性的基石。另外生成过程中的节点通常处于“活跃锁定”状态它的token序列还在增长不能被其他请求误用等这个请求结束或节点写满后才会释放给后续请求共享。2.5 RadixTree方案的收益边界RadixTree的优势非常明显匹配粒度细到token级任意共享前缀都能被利用配合RadixAttention之后首token延迟在重复前缀场景下可以低很多。但它也有不容忽视的代价树结构本身有内存开销节点分裂合并会频繁产生小对象不是零成本。每次请求都要做一次树搜索如果树很深且请求频繁查询开销不能忽略。并发场景下请求之间在树上做分裂合并时需要同步锁竞争可能成为瓶颈。多请求共享节点时一旦一个请求导致该节点分裂共享关系会改变处理逻辑要非常谨慎。这些代价决定了SGLang的RadixCache不是“免费午餐”而是用工程复杂度换搜索能力。它适合那些真正需要精细缓存复用、前缀结构复杂多变的负载。3. vLLM的哈希缓存把一切切成块再寻址3.1 从Block管理到自动前缀缓存vLLM的核心抽象是PagedAttention思路非常像操作系统的分页KV Cache被切成固定大小的block每个请求持有一张block table逻辑上连续的token对应物理上不连续的block。这样做的好处是显存利用率高、可以按需分配也能在多个请求之间共享相同内容的block。前缀缓存是这套block体系上的一个自然延伸。既然block是KV Cache的基本单位那判断两个请求能不能共享KV最粗暴的办法就是看它们的block内容是否一样。vLLM的做法就是为每个block计算一个哈希值只要两个block的token id序列完全一致它们的哈希就一致于是可以判定这两个block可共享。3.2 哈希缓存的工作流程vLLM的自动前缀缓存automatic prefix caching流程大致是这样新请求的prompt被切分成多个block每个block有固定长度默认常见是16个token具体可配置。对每个block根据其中的token id列表计算哈希值。查缓存表如果某个block的哈希已经存在且对应KV Cache仍在显存中就直接复用这个物理block不为它分配新显存。未命中的block照常分配新block完成prefill后写入KV Cache并登记哈希。每个block维护引用计数当所有引用它的请求结束后refcount归0等待缓存淘汰。用伪代码表达如下同样只表达思想不完全对应源码def compute_block_hash(token_ids): # 实践中会对block内token id序列做稳定哈希映射到64位哈希值 return hash(tuple(token_ids)) def get_or_allocate_block(block_tokens, block_allocator): h compute_block_hash(block_tokens) if h in block_allocator.cache and block_allocator.cache[h].tokens block_tokens: block_allocator.cache[h].refcount 1 return block_allocator.cache[h] else: block block_allocator.allocate_empty_block() block.tokens block_tokens block.hash h block.refcount 1 block_allocator.cache[h] block return block这里要注意vLLM不是只存哈希就完事。虽然哈希可以作为key快速查找但为了避免哈希冲突导致错误复用缓存里仍然保存了block的实际token id序列命中时要做一个内容比对。这一步是必要的尤其是token数量变大之后哈希碰撞概率虽然小但在大规模缓存上不是可以忽略不计的风险。双保险看起来多花一点存储但换来的是正确性。3.3 块对齐问题命中边界的现实哈希缓存最大的特点也是最大的限制匹配粒度是“一个完整block”。如果prompt前缀的长度不是block size的整数倍最后一个不满的block通常很难参与复用。举个例子。block size为16某个请求的前缀是1000 token的系统提示词后面跟一段不断变化的query。1000 62 * 16 8前62个完整block可以被哈希缓存命中最后8个token所在的block大概率随query变化。所以vLLM实际能复用的是前992个token的KV Cache剩下8个token以及之后的所有内容需要重新计算。相比之下如果使用RadixTree只要这1000个token在每个请求中完全一致SGLang就能精确复用这1000个token一行都不会浪费。对块大小为16的vLLM来说最坏情况会损失不到一个block长度15个token的精确匹配在极端情况下如果公共前缀长度本身只有1个token加15个block差距就比较明显但大部分真实场景中丢失一个块的前缀误差对总体收益影响有限。当然vLLM也在优化这一块。一些版本里它会尝试缓存不完整block的KV状态只要新请求中那个部分仍能形成同样内容的block就有机会复用。但本质上它的共享粒度仍然受block对齐约束这是哈希方案的结构性特点。3.4 为什么vLLM选择哈希而不是树站在vLLM的架构视角哈希方案几乎是“最省事又够用”的选择。vLLM的block table本身就是一张逻辑到物理的映射表哈希缓存只是在这张表上增加了一个按内容查询的索引不需要引入新的树形结构。并发安全更简单关键是能参与共享的block通常是一个内容不再变化的完整block正在生成中的活跃block因为还会追加token哈希会变化一般不参与共享等它写满后再登记进缓存。这比树的动态分裂合并容易处理得多。和调度器的耦合方式轻调度器在构建请求的block table时逐块做哈希查表命中的直接复用不命中的分配新block整个过程非常规整。vLLM的定位是通用生产级推理引擎要求在各种模型、各种张量并行配置下都能稳定工作。树形结构要做节点分裂合并再和各种GPU算子、显存分配器耦合复杂度和风险都会成倍上升。所以vLLM不是做不到更精细的前缀匹配而是在“工程可维护性”和“缓存最大化复用”之间做了选择。对大多数线上服务来说固定block哈希已经能吃到绝大部分缓存红利。4. 关键差异树搜索、块哈希与调度耦合4.1 匹配粒度任意前缀 vs 定长块这是两者最根本的分歧。一张表可以快速看清楚对比维度SGLang RadixTreevLLM Block Hash核心结构压缩前缀树哈希表 Block Table匹配粒度可变长度令牌序列固定大小Block前缀共享支持任意长度最长前缀支持完整Block的重复内容非整块前缀可以精确复用通常损失一个块内的部分前缀变更后的自适应分裂合并保留公共部分哈希重新计算整块以上可复用并发共享节点refcountBlock refcount缓存驱逐节点LRUBlock LRU调度器交互调度前做RadixAttention规划Block分配阶段查表复用举例来说线上请求的prompt是“固定模板A 用户可变内容 固定模板B”两个固定模板都被大量请求共享。这种情况下vLLM的哈希方案会在模板A的完整块上命中模板B如果恰好也是完整块也能命中。但假如“用户可变内容”的长度导致模板B在块边界上错位每块内容都混进了不同的用户内容那么vLLM几乎命中不了模板B而SGLang的树结构因为路径是整体匹配的只要“固定模板A”和“固定模板B”这两个片段在树上是连续的公共路径就有可能复用。实际工程里这种错位经常发生所以SGLang在复杂前缀模式下的表现理论上更灵活。4.2 内存管理粒度节点 vs BlockSGLang的树节点粒度可变一个节点可能只代表一段很短的路径也可能代表一长串token。这意味着共享单位可以非常小最小能共享几个token的KV Cache但也意味着KV显存可能被切成很多碎片每一段分别对应树上的一个节点节点分裂后KV cache的连续性也会被打破。vLLM的block粒度固定共享单位最小是一个block。好处是显存管理非常规整block的分配、释放、迁移都在统一框架下和PagedAttention天然匹配坏处是共享粒度比较粗不够灵活。另外vLLM的哈希缓存中block的物理位置可能被多个请求共享写入时只需要算一次后续请求直接引用物理内存效率很高。4.3 与调度器的耦合程度前置规划 vs 分配期复用SGLang的RadixCache在调度之前就参与了请求规划先做前缀匹配明确“这段KV已有不用算”再决定批次里每个请求真正需要多少计算量。这意味着SGLang可以在调度阶段根据不同请求的新增长度做更细致的组合比如把大量“新增长度很短”的请求排进一个batch最大限度减少prefill时间。vLLM的哈希缓存生效在block分配阶段调度器按请求构建block table逐块查哈希命中的block直接复用未命中的正常计算。它同样能减少prefill计算但缓存命中的粒度受block限制调度器对“精确新增量”的感知不如SGLang细。vLLM V1也在改进这块但整体设计仍然以block为主。4.4 并发与一致性问题两个方案都要面对并发请求同时访问缓存的问题但处理方式完全不同。SGLang的树结构在并发访问时可能存在多个请求同时走到同一个节点、同时对它发起分裂合并的情况。如果没有正确的锁或同步机制树的共享关系会被破坏。SGLang在实际实现中对这些树操作做了并发保护代价是可能在高并发下出现一定的锁竞争请求调度越密集树操作越频繁锁等待就越明显。vLLM的block哈希缓存则天然更容易做并发。已完成的block一旦创建内容不再变化哈希就是固定的不可变属性多个请求并发引用同一block时只是对refcount做原子增减不存在结构上的写冲突。只有在缓存淘汰和扩容时需要加锁但锁粒度很小。因此在极端高并发下vLLM的缓存路径通常更稳定、更少出现结构竞争。4.5 为什么说它们是“同题不同解”写到这里你应该能感受到这两个方案并非谁比谁高级而是各自长在各自的架构上。SGLang的RadixTree追求理论上限任意token前缀都能复用attention计算量无限接近“只算新增”在重复前缀复杂、多轮对话频繁的场景里优势很突出。vLLM的哈希缓存追求工程下限用最简单的block哈希换取一致的显存管理、稳定的并发表现、相对低的维护复杂度在大多数生产负载下已经足够好。这就好比一个是隧道扫描式的精确匹配一个是区块级的内容寻址。SGLang像一个做过功课的图书管理员能精确记住每本书里哪几页你已经看过vLLM像一个高效的仓储系统只记录每个标准货架上有什么货货架内容一致就整架复用。5. 实际部署怎么选场景、参数与踩坑5.1 多轮对话优先考虑SGLang如果你的服务是聊天机器人、Agent、代码补全这类高频多轮对话而且对首token延迟敏感SGLang的RadixAttention优势会很明显。同一session连续对话时它的精确前缀匹配能力能让大多数历史token完全跳过计算。尤其是用户对话过程中出现“修改上一轮内容”“插入新问题”这类操作SGLang仍然能复用公共路径部分vLLM在块对齐不理想时会丢掉一部分本该复用的缓存。如果选择SGLang部署时建议关注两个细节一是确认RadixCache相关的配置项被正确启用不同版本可能有开关变化二是观察批处理大小。SGLang的收益建立在chunked prefill和混合批次调度上如果max_num_batched_tokens太小prefill会被切得很碎RadixCache能识别的前缀虽然没变但调度收益会被打折扣。5.2 高并发API服务优先考虑vLLM如果你要对外提供高吞吐、多租户的OpenAI兼容API服务vLLM的工程生态非常成熟。它的block哈希方案在共享固定system prompt时收益稳定部署运维资料多遇到问题容易查到答案。多模型场景下vLLM的并发和显存管理也被验证得更充分配合单机多卡部署、tensor parallel等方式都比较顺手。vLLM部署时记得明确开启自动前缀缓存。早期版本需要显式设置--enable-prefix-caching部分版本默认关闭不开启的话哈希缓存是无效的很多人踩过这个坑。另外block大小和调度参数也可能影响缓存效果具体以你使用版本的官方文档为准。如果部署在rk3588这类边缘设备或者尝试纯CPU模式跑模型情况会更特殊。vLLM的CPU后端和较小的显存环境里前缀缓存空间非常有限缓存命中率可能远低于GPU集群环境这时候不要迷信Benchmark数字先看本机显存/内存容量够不够放缓存。SGLang在边缘设备上的资料相对少兼容性也需要实测确认。我的体感是边缘环境优先保证服务稳定性缓存优化放到其次。5.3 RAG与few-shot场景两者都能用看对齐程度RAG类场景通常是“固定系统提示词 检索结果变化 用户query”few-shot场景是“固定示例 不同query”。这类负载下如果固定前缀是block size整数倍对齐vLLM的哈希缓存能实现接近SGLang的效果如果固定前缀长度总是不对齐SGLang的精确匹配会更划算。我的建议是先统计请求的prompt结构。把公共前缀长度算出来看它和block size的余数关系。如果公共前缀长度在几千token量级差的这几token影响不大选vLLM即可如果公共前缀本身就二三十个token且从头到尾不能对齐vLLM可能一个块都命中不了这时候SGLang的树方案才有质的提升。5.4 常见的坑与排查思路我实际部署中遇到过的、以及社区里高频出现的问题整理成几个缓存命中率为0vLLM先检查自动前缀缓存是否打开SGLang检查RadixCache相关参数是否生效有的发布版本需要编译/运行时开关。显存不够导致缓存一直逐出Prefix Cache本质是用显存换计算显存越紧张缓存命中率越低。不是引擎不给力是缓存空间不够。可以先调低并发或增大显存再看命中率曲线。prefill被切碎如果max_num_batched_tokens设得太小长prompt的新增部分会被拆成多个chunk虽然正确性没问题但缓存利用率会被请求调度节奏稀释。前缀因prompt模板变化被打断服务端prompt模板只要加一个空格、换一个role标签就可能让整条路径的哈希或树匹配失效。排查时先冻结prompt模板再调整推理引擎参数。SGLang高并发下出现树相关锁等待如果CPU核数有限可以观察服务端吞吐和CPU占用率必要时通过增加线程/进程数做隔离。5.5 监控指标与调优参考无论选哪种方案都建议把“缓存命中token数 / 总prefill token数”作为核心指标这个指标通常能从日志或内置状态接口里看到。命中率低不代表引擎差要先看请求前缀本身是否可复用。如果命中率稳定但整体延迟仍然偏高接着看batch大小、cudagraph吞掉的时间、tokenizer开销等。Prefix Cache能解决的是“重复计算”问题解决不了“批次排队”和“算子效率”问题这两者别混为一谈。我在一些项目里见过团队执着地调缓存参数最后发现瓶颈其实在并发路由层缓存命中率已经很高了。先定位瓶颈在哪个环节再动参数效率会高很多。6. 最后一点经验我个人现在的工作习惯是小规模自用、Agent类多轮对话场景优先试SGLang它的精确匹配和RadixAttention组合确实能带来很直接的首token延迟下降对外提供标准API、依赖社区生态和运维稳定性时我倾向vLLM它的哈希缓存虽然在线性匹配粒度上不如树但胜在简单可控配合固定模板前缀能拿到90%以上的收益。如果你正在两个引擎之间犹豫先别急着看Benchmark数字回到你的负载上问三个问题请求之间有多少公共token公共前缀是不是基本对齐到块大小并发压力下你更怕显存碎片还是锁竞争这三个问题的答案基本就指向了最终选择。最后分享一个低成本验证技巧在切换引擎之前截取线上真实请求的prompt逐组做最长公共前缀分析算出“可复用前缀占比”。这个占比高且对不齐明显优先SGLang占比高且基本对齐vLLM足够占比本身就不高换引擎不如改prompt模板把公共内容尽量往头部挪、把变化内容压到尾部那比调任何缓存参数都见效快。