做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 Cache
vLLM的核心创新之一是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(哈希表) | SGLang(RadixTree) |
|---|---|---|
| 核心数据结构 | 全局哈希表,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 常见问题与排障经验速查
部署过程中我遇到了不少问题,整理成一张速查表,希望帮你少走弯路。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 缓存命中率始终为0 | vLLM老版本没开--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的问题。