SGLang 前缀缓存实战避坑:RadixAttention 如何让 8000 个重复 token 变成瞬时命中
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
凌晨 1 点 47 分,小周盯着监控面板上跳动的红字,手心发凉。他在 SGLang 上部署的客服机器人,白天跑得好好的,一到晚高峰,GPU 利用率冲到 99%,首 token 延迟却从 800ms 飙到 4 秒。更离谱的是,日志显示每分钟有几百个请求,前面 8000 个 token 一字不差——都是同一条系统提示词加公司知识库,唯一的差异是最后一句用户问题。可他的 GPU 像第一次见到这些文字一样,把同一段话从头到尾算了一遍又一遍。
那晚他做了个实验:把重复的前缀只算一次,其余请求直接"续接",服务立刻松了下来。这就是 SGLang 前缀缓存(RadixAttention)在做的事,也是这篇文章想带你亲手验证的东西。
每次重算 8000 个 token,钱和时间都烧在了哪
传统 KV 缓存为什么不顶用?因为它的粒度是"整条请求"——一条对话算完,KV 缓存只属于这一条,别的请求碰都碰不到。多轮对话里,用户每次追问,前面几轮历史原样重算;批量测试里,所有请求共享的 system prompt 被当成普通文本,一视同仁地重算。这就像客人每次点菜,后厨都从洗菜切菜开始,哪怕 90% 的食材你早就备好了。
往深里说:KV 缓存本质是"中间计算结果"。LLM 生成第 100 个 token 时,必须依赖前 99 个 token 的注意力结果。前缀一样,中间结果就该一样。旧方案不是不懂这个道理,而是没有一个高效的数据结构去做"按前缀查找、按前缀复用"——查得慢、存得乱、回收难,索性不算了。
我突然想起小区里的快递代收点
解法其实藏在快递代收点里。一栋 100 户的楼,快递员如果每单都从总仓单独跑一趟,就是传统做法;代收点把去同一栋楼的包裹拼成一车,前半程共享,到楼下再按门牌分开派送——前缀相同,前段路径就共享,只有最后的分叉各自负责。按门牌分拣用的就是一棵树:1 号楼 → 3 单元 → 502 室,路径重合得越多,共享的路段越长。
把"门牌号"换成 token 序列,把"共享路段"换成 KV 缓存,你就得到了 RadixAttention:一棵以 token 序列为路径的树,路径重合的部分只存一份 KV 缓存。
从问题倒推设计:这棵树是怎么长出来的
与其背概念,不如跟着四个问题把它推出来。
问题一:怎么按前缀找缓存?用树。从根往下,每个节点存一段 token 序列和对应的 KV 索引,查找就是沿着树一路吃下与请求相同的 token。
问题二:前缀只重合一半怎么办?节点分裂。假设树上已存了 "Hello",新请求是 "Helicopter",两者重合 "Hel" 三个 token。匹配停在节点中间时,把节点劈成两段:父节点存 "Hel",两个子节点分别挂 "lo" 和 "icopter"。这样共享边界被精确暴露出来,后续请求的匹配效率才高。源码里这一步是_split_node,在match_prefix匹配到节点中途时触发。
问题三:缓存会不会撑爆显存?两把锁。一是淘汰规则:只有"没有子节点的叶子"才允许被回收,因为它不可能再是任何其他请求的前缀;回收时按最近访问时间建堆,从最久没碰的开始,典型的 LRU 思路。二是引用计数:每个节点有lock_ref,正被某个请求引用的节点被锁住,宁可暂时不回收,也不能让请求中途读不到数据。两个机制配合,inc_lock_ref/dec_lock_ref在请求进出时增减计数。
问题四:什么时候写进树?请求结束那一刻。cache_finished_req把"输入 + 输出"的完整 token 序列连同 KV 索引一起挂到树上,下一次同样的前缀就能直接命中。
核心实现就在python/sglang/srt/mem_cache/radix_cache.py,几百行代码,值得一读。
三步跑起来:命令行里的第一个缓存命中
RadixAttention 默认开启,你不需要配置任何参数。
第一步,启动服务:
python -m sglang.launch_server \ --model-path meta-llama/Llama-3.1-8B-Instruct \ --port 30000第二步,用同一段 system prompt 连发两个请求(curl 或 OpenAI SDK 都行)。第一个请求让服务把前缀算一遍并缓存。
第三步,看命中率:
curl -s localhost:30000/metrics | grep cache_hit_rate第二个请求发出后,sglang:cache_hit_rate会明显抬升。这个指标定义在python/sglang/srt/observability/metrics_collector.py,它就是你验证缓存生效的仪表盘。
一个 99% 的数字,背后是真金白银
回到小周的客服场景:8000 token 的系统提示,加 50 token 的用户问题。前缀匹配能命中 8000/8050 ≈ 99% 的 prefill 计算。prefill 是首 token 延迟的大头,这 99% 的重复劳动被省掉后,首 token 延迟几乎只由那 50 个新 token 决定——从秒级掉到百毫秒级。这是纯算术,不含任何营销水分,你用自己的模型就能复现。
需要说清前提:倍数取决于前缀重合度和序列长度,序列越长、重复越多,收益越大。SGLang 论文在共享前缀的多轮对话场景中报告了数倍的吞吐提升,与这个算术的走向一致。
翻车现场:命中率上不去,先查这三处
坑 1:命中率恒为 0症状:cache_hit_rate一直是 0。 原因:prompt 里混入了每轮都变的 token——时间戳、随机数、请求 ID,或者 chat template 里每轮都变的属性头。官方文档就记录过 Claude Code 的 attribution 头导致 GLM 每次都要整段重算的案例。 解法:检查 prompt 里有没有每轮都变的字段,去掉,或挪到共享前缀之后。
坑 2:改了 --page-size 后命中率反而降症状:显存省了,命中率也跌了。 原因:page_size影响匹配的对齐粒度,某些后端(如滑动窗口注意力路径)要求page_size=1,此时树更碎、命中更难。 解法:没有特殊需求别动page_size,保持默认。
坑 3:误关了缓存症状:服务正常,但命中率为 0。 原因:--disable-radix-cache在某些场景(批量 OCR、每请求都换新图)确实是正确选择,但很多人把它用在了重复前缀很高的场景。另外上下文并行、HiSparse 等特性与缓存互斥,启动时会直接报错,逼你二选一。 解法:每个请求前缀都不同才关;重复前缀多,必须开着。遇到互斥报错,按提示决定取舍。
缓存之外,它解决的是"重复劳动"这类共性问题
RadixAttention 的意义不止于前缀缓存。它的树结构、锁引用、LRU 驱逐,本质是一套"中间结果复用"的通用基础设施:LoRA 场景用extra_key隔离不同 adapter 的缓存,避免串味;HiCache 把缓存从显存扩展到主机内存;跨节点共享缓存也在演进中。理解了这棵树,SGLang 的缓存体系你就理解了一大半。
想深入,三份材料够用:核心源码python/sglang/srt/mem_cache/radix_cache.py、指标定义python/sglang/srt/observability/metrics_collector.py,以及docs/cookbook下各模型卡片里关于 radix cache 的调优笔记。
今晚就花三分钟
把服务跑起来,连发两个同前缀请求,看一眼命中率数字的变化。你不用改一行代码——SGLang 已经把这棵树的根埋好了,你要做的只是确认它真的在为你干活。等数字动起来的那一刻,你就明白小周那晚为什么松了一口气。
【免费下载链接】sglangSGLang is a high-performance serving framework for large language models and multimodal models.项目地址: https://gitcode.com/GitHub_Trending/sg/sglang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考