← 返回资讯

SGLang 前缀缓存怎么用:Agent 与多轮对话的命中率

2026-09-15

有个场景挺典型:一个 Agent 服务换到 SGLang,图的就是前缀缓存,结果上线之后看日志,预填充的量跟换之前几乎没差。模型没错,参数没动错,缓存也没关——问题出在请求本身的形状上。

SGLang 官方文档里就记着这样一个具体案例,而且案例主角是 Claude Code。它会在 system prompt 的最前面插一段归属信息,形如 x-anthropic-billing-header: cc_version=<版本>.<每请求哈希>; cc_entrypoint=...; cch=<哈希>;。文档的原话是:这个每请求哈希是两轮之间第一个发生变化的 token,所以 radix 前缀缓存只能复用哈希之前那一小截,system prompt 加上整段对话历史每一轮都要重新预填充一遍。解决办法是设 CLAUDE_CODE_ATTRIBUTION_HEADER=0,文档还特意补了一句:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 关不掉它,那个只管自动更新、遥测和错误上报,归属头是另一条代码路径。

这个例子值得放在开头,因为它把前缀缓存最反直觉的一点讲明白了:缓存的收益不是由缓存配置决定的,是由你的 prompt 从第一个 token 开始长什么样决定的。下面按「机制 → 请求结构 → 调度 → 观测 → 分层」的顺序拆,参数和配置形式都照 SGLang 官方文档原文,文档没写的我会说明没写。

一、缓存是怎么组织的:只认前缀,从第一个 token 算起

机制的底子在 KV cache 那篇讲过:预填充把 prompt 的 key/value 算出来存进显存池,解码阶段往后追加。RadixAttention 改的是请求结束之后那一步——已经算好的前缀 KV 不立刻释放,按前缀关系组织成一棵 radix 树,公共前缀靠近根,各自分叉的部分挂在下面。新请求进来先在树上做最长前缀匹配,命中多少 token,这部分预填充计算就直接省掉。README 把这个能力列在 Fast Runtime 的第一条,是 SGLang 当招牌拎出来的特性,SGLang 是什么那篇有它在整个特性列表里的位置。

这里有三件事必须先钉住,不然后面的调优全是白费劲。

第一,它是前缀匹配,不是相似度匹配。第一个 token 不一样,后面一万个 token 完全相同也一个都命中不了。开头那个归属头的案例就是这个规则的极端演示——变化的内容不多,但位置在最前面,于是整条链废掉。

第二,它默认是开着的。文档里给的是关闭开关 --disable-radix-cache(说明写的就是 Disable RadixAttention for prefix caching),也就是说你不需要做任何事来启用它,你需要做事的是让请求能吃到它。

第三,匹配的颗粒跟 --page-size 有关,这个参数文档的说明只有一句「一页里的 token 数」,默认值是 1。默认按单 token 组织,这也是 SGLang 前缀匹配粒度细的来源。要不要动它涉及和注意力后端的配合,属于并发调优那一摊事,别为了缓存单独去拧它。

二、请求结构才是命中率的第一决定项

这一节是全文最该花时间的地方。前缀缓存的收益上限,在你写 prompt 组装代码的那一刻就定了,之后再怎么调参数都只能在这个上限底下腾挪。

一条可以直接拿去用的排序规则:越固定的内容放越前面,越易变的内容放越后面。具体到 Agent 服务,从前往后的顺序通常应该是这样:

  1. system prompt / 角色设定。这是最稳定的一块,理应第一个。前提是它自己别掺时间戳、请求 ID、会话 ID、随机 trace 信息。
  2. 工具定义块。Agent 的 tools schema 往往比 system prompt 还长,而且在一次会话内是完全不变的。但它有个隐患:如果你的工具列表是按可用性动态拼的,或者 schema 序列化时字段顺序不稳定(某些语言的字典序不保证),同一批工具每次拼出来的字符串就不一样,这块的缓存也就作废了。序列化工具定义时把键顺序固定下来,是一个成本极低、收益很实的动作。
  3. RAG 检索出来的文档块。这块能不能吃到缓存取决于检索结果的复用程度:多个问题共享同一批检索结果时,把文档放在问题前面,文档的预填充只算一次;如果每个问题的检索结果都不同,这块本来就没有复用余地,位置怎么排都一样。
  4. 对话历史。天然是追加式的,第 N 轮的 prompt 就是第 N−1 轮的完整前缀加上新的一问一答,正好对应树上一条往下延伸的路径。
  5. 本轮用户输入和任何易变字段。当前时间、请求 ID、AB 实验分组、用户画像里会变的部分,全部往末尾放。

值得单独说一句的是动态注入当前时间这个习惯。很多 system prompt 模板里会写「今天是 {date}」,分辨率到日还好,一天之内所有请求共享一个值;一旦分辨率到分钟或秒,每个请求的 prompt 都不同,而它的位置又在最前面,效果和开头那个归属头完全一样。真需要给模型报时,放到最后一条用户消息里,或者降到日级别。

多轮对话还有一个容易被忽略的破坏源:对历史做原地改写。有些实现会在轮数超过阈值时做摘要压缩、或者裁掉中间几轮以省上下文。每次改写都会把树上那条路径从改写点开始整段作废,后续所有轮次都得从改写点重新预填充。这不是说不能压缩,而是压缩的频率要算进成本里——一次性压缩换后续多轮复用,通常划得来;每轮都重排一遍历史,就是在跟缓存对着干。

推理模型场景还有一个专门的开关。--strip-thinking-cache 的说明是:请求结束时跳过把推理模型输出(thinking 加答案)写进 radix 树,只保留 prompt 前缀。文档明确标了这是 opt-in 的、会改变缓存内容。什么时候值得开?如果你的服务里 thinking 段又长又基本不会被下一轮完整复用(比如每轮都会把前一轮的思考过程丢掉只留结论),那把它写进树里就是纯占地方,挤掉的是真正有复用价值的前缀。反过来如果你的多轮实现会把完整的思考过程带进下一轮,那就别开。

还有个多模态的开关 --enable-prefix-mm-cache,文档的说明是启用前缀多模态缓存,并注明目前只支持 mm-only。这个限定条件写得很简短,具体边界官方文档这一句没有展开,上线前建议自己在目标版本上验一遍,别按推测用。

三、缓存满了淘汰谁:命中率掉下来通常是在这一层

请求结构对了,命中率还是不稳,下一个要看的就是淘汰。显存池是有限的,池子满了 radix 缓存必须回收空间,「先淘汰谁」就成了真问题,SGLang 把这个决定权交给了你。

文档对回收过程的描述里有几条限制比策略表更重要。能被考虑淘汰的只有可淘汰的叶子节点:KV 还在、没有被在途请求锁住、并且没有被仍持有 KV 的子节点遮蔽的节点;根节点永远不可淘汰。策略给候选打分,分数最低的先淘汰;一个叶子被淘汰后它的父节点可能变成新叶子重新进入候选集,所以淘汰是从一条分支的末梢往根的方向逐步推进的。

最该记住的一句是:策略只负责打分。它不决定要释放多少,也不能把任何节点钉在显存里——如果把其他所有东西都回收掉还不够,被策略保护的节点照样会被淘汰。指望靠换策略来保证某个热前缀常驻,方向就错了。

可选策略用 --radix-eviction-policy 指定,默认 lru

  • lru(默认):先淘汰最久未使用的前缀。文档说这是大多数负载的正确选择,因为前缀复用本身就是随时间衰减的。
  • lfu:先淘汰命中次数最少的,再按最近最少使用。适合「一小撮 prompt 的复用远多于其余」,并且你希望它们能扛过一阵一次性流量的冲击。
  • slru:把缓存切成试用段和保护段,前缀进来先进试用段、命中够次数才升到保护段,试用段的一律先于保护段被淘汰。相当于给「一次性前缀能挤掉老前缀的程度」设了个硬下限。
  • priority:按插入该前缀的那个请求的优先级来,一个被多个请求命中的节点保留其中最高的优先级。要注意它有前置条件——文档写得很清楚,如果你没开优先级调度,所有节点优先级都是 0,这个策略就等价于 lru。想用它得配合 --enable-priority-scheduling

所有策略在打分打平时都退回最近最少使用的顺序。另外文档提了一句冷知识:fifomrufilo 也在策略注册表里,但没有开放到命令行,只有扩展了选项列表的外部代码才能用到,定位是实验而非生产。

目前只有 slru 带参数,通过 --radix-eviction-policy-config 传一个 json 对象,键是所选策略自己的键:

python3 -m sglang.launch_server \
  --model-path MODEL_PATH \
  --radix-eviction-policy slru \
  --radix-eviction-policy-config '{"protected_threshold": 4}'

protected_threshold 是升入保护段所需的命中次数,文档给的默认值是 2(版本可能变,以你那个版本的 --help 为准)。调高让晋升更难,保护段就更小、更贴近真正的热前缀;调到 1 则任何被复用过一次的前缀都晋升,效果接近带一次宽限的 lru。这个配置项有个友好的设计:写错的键不会被静默忽略,启动时直接报错,比如把 protected_threshold 拼成 protected_treshold 会抛 TypeError: SLRUStrategy.__init__() got an unexpected keyword argument 'protected_treshold'。另有一条兼容性约束:实验性的 Rust 树内核后端(SGLANG_UNIFIED_RADIX_TREE_CORE_BACKEND=rust)不支持这个 config,它只从策略名构造策略,两个一起传会启动失败。

选策略的态度,文档写得比大多数项目都克制:先用 lru,只在有实测缓存命中率的情况下才改lfuslru 在流量有稳定热集、而纯按时间的策略老是把它冲掉时才有帮助;当前缀热度会随时间迁移时它们是有害的——一个攒够命中次数的前缀,在它不再有用之后仍然保留着那份优势。

四、调度策略与命中率的关系

淘汰决定「留什么」,调度决定「以什么顺序喂进去」,后者同样影响命中率。

--schedule-policy 默认是 fcfs(先来先服务),文档列出的可选值还有 lpmrandomdfs-weightlofpriorityrouting-key。跟缓存直接相关的是 lpm——调优文档在「其他可试的选项」里明确写了:如果负载里有很多共享前缀,可以试 --schedule-policy lpmlpm 就是最长前缀匹配的缩写,它会重排请求顺序来促成更多缓存命中,代价是引入更多调度开销。

这条要按它写的方式理解:lpm 不会让你原本命中不了的请求命中,它做的是把前缀相近的请求凑到一起处理,减少「命中的前缀刚被别的请求挤掉又得重算」这种浪费。所以它的收益前提仍然是你已经有共享前缀;请求之间本就毫无重叠时,它只剩下开销。

还有两个参数会间接影响命中率。--schedule-conservativeness 控制调度的保守程度,文档的口径是:token usage 长期低于 0.9 而队列里又有请求,说明服务端收新请求太保守,可以往下调(文档给的例子是 0.3);反过来频繁看到 KV cache pool is full. Retract requests. 这类告警,就往上调(例子是 1.3),偶尔出现(一分钟一次的频率)是正常的。这跟缓存的关系在于:请求被 retract 意味着它占的 KV 被吐出来,之后重新跑,缓存的账也跟着重算一遍。另一个是 --retraction-policy,默认 length(保持既有行为,先 retract 输出短、输入长的请求),可选 priority(先 retract 低优先级请求,方向与优先级调度一致)——如果你已经用了 priority 淘汰策略,把这两处对齐会少一些互相打架。

五、命中率怎么观测

前面所有判断都建立在「你能看到命中率」上,不然就是盲调。文档给了两个入口。

服务端指标--enable-metrics 启用 Prometheus 指标。淘汰策略那页明确写了缓存命中率的计数器是通过这个开关暴露出来的,也就是说要按命中率做决策,这个开关是前提。如果你开了 DP attention 或者想按 TP rank 分别看,还有 --enable-metrics-for-all-schedulers,文档说不加它的话所有指标看起来都来自 TP 0。

单请求级--enable-cache-report,说明是「在每个 openai 请求的 usage.prompt_tokens_details 里返回被缓存的 token 数」,默认关闭。这个比聚合指标更有用的地方在于它能定位——把某一类请求的 cached token 数和 prompt 总长度对照,你就能看出是哪一段没命中。查开头那种「某个字段插在了前面」的问题,这是最直接的手段:拿同一会话连续两轮的返回值一比,如果第二轮的 cached token 数只有一个很小的值,就说明有东西在很靠前的位置变了。

日志侧token usage 要注意别误读。它反映的是 KV 池的内存利用率(文档说超过 0.9 算利用得好),不是缓存命中率。池子被留存的前缀占满,token usage 一样高,但那些前缀有没有被命中是另一回事。这两个数得分开看,吞吐优化那篇讲的瓶颈定位同理,指标的语义搞混了,后面的结论全歪。

关于命中次数的统计口径,淘汰策略那页有三条限定值得抄下来,因为它直接影响 lfuslru 的行为:命中次数不会在分块预填充的步骤中递增、不会为已被淘汰的节点递增、在 write-back 模式的 HiCache 下也不递增。第三条尤其要留意——如果你同时开了分层缓存的 write-back 写策略又选了 lfu,打分的输入本身就是不完整的,这个组合上线前务必自己验。

六、显存装不下的时候:分层缓存

显存池就那么大,前缀留存和并发容量是在抢同一块地方。SGLang 的答案是把缓存往下延伸一层,也就是 HiCache。

先说清文档现状:本地这份 HiCache 页面基本是个索引,正文只有三条指向子页面的链接(最佳实践、设计、运行时挂载卸载),细节不在这一页里。这页自己的元描述把定位说明白了——三级 KV 缓存(GPU、CPU、存储),面向长上下文与多轮推理,支持 Mooncake、3FS、NIXL 后端。想深入得去官方文档站看那三个子页面,我这里只能把启动参数这一层照抄清楚,具体调法和边界以那几页为准。

参数清单(默认值取自文档,以你所用版本为准):

  • --enable-hierarchical-cache:总开关,默认关闭。
  • --hicache-ratio:主机侧 KV 缓存池相对设备池的大小比例,默认 2.0
  • --hicache-size:直接以 GB 指定主机侧池大小,设了就覆盖 ratio,默认 0
  • --hicache-write-policy:写策略,默认 write_through,可选 write_backwrite_through_selective
  • --hicache-io-backend:CPU 与 GPU 之间搬 KV 的 IO 后端,默认 kernel,可选 directkernel_ascend
  • --hicache-mem-layout:主机内存池布局,默认 page_first,可选 layer_firstpage_first_directpage_first_kv_splitpage_head
  • --hicache-storage-backend:第三层存储后端,默认无。内置的有 filemooncakehf3fsnixlaibrix;还可以用 dynamic 配合 --hicache-storage-backend-extra-config 指定自定义后端的名字、模块路径和类名。
  • --hicache-storage-prefetch-policy:控制从存储后端预取何时停止,默认 timeout,可选 best_effortwait_complete

从这份清单能读出的取舍是清楚的:多加一层就多一道搬运,收益是能留住更多前缀,代价是命中远层时要把 KV 搬回显存,而这个搬运的快慢由 IO 后端、内存布局、存储后端共同决定。预取策略那三个选项也是同一个权衡的不同侧面——wait_complete 要等预取完成,best_effort 不等,延迟和命中率在这里是对立的。所以分层缓存不是「开了就更快」,它把一个显存容量问题换成了一个 IO 问题。你的前缀本来就装得下,开它没有意义。

另外如果你在跑预填充-解码分离部署,还有个单独的开关 --disaggregation-decode-enable-radix-cache:在解码服务侧启用 radix 缓存,缓存 KV 前缀以避免重复传输。文档同时列了它的不兼容项——与 --enable-hisparse、投机解码、以及 --disaggregation-transfer-backend fake 不兼容。上了 PD 分离的团队这条要单独确认。

还有一个扩展点 --radix-cache-backend,说明是「先前通过 register_radix_cache_backend 注册过的 radix 缓存后端名」,省略这个参数就走内置的默认缓存选择链。这是留给自己写后端的口子,不填就好。

七、一份上线前的自查清单

按顺序过,越前面的收益越大:

  1. 拿线上真实请求抽一批,把同一会话连续两轮、以及不同会话之间的公共前缀长度算出来,占平均输入长度的比例是多少。这个数是一切判断的地基。
  2. 把 prompt 组装代码从第一个字符开始通读一遍,找出所有每请求都变的字段(时间戳、请求 ID、trace、随机分组、动态拼的工具列表),确认它们全部在末尾。
  3. 确认工具定义的序列化是稳定的,键顺序固定。
  4. --enable-cache-report,用同一会话连续两轮的 usage.prompt_tokens_details 验证第二轮真的命中了长前缀,而不是只命中一小截。
  5. --enable-metrics,把缓存命中率做成面板。在这个数据出来之前不要动淘汰策略。
  6. 有共享前缀再考虑 --schedule-policy lpm,并盯着调度开销。
  7. 前面都做完、显存确实成为留存瓶颈,再评估分层缓存。

八、说句实话:缓存不是免费的

前缀缓存的成本是实打实的:那棵 radix 树占的是显存池里本来可以给并发用的空间,插入、匹配、淘汰打分都有 CPU 开销,分层之后还多一层搬运。这些成本跟你的前缀重叠比例无关,它们在任何负载上都要付。收益跟重叠比例强相关,重叠比例接近零的时候,收益也接近零。

也就是说,在无重叠的负载上它是净开销,这不是一句谦虚的话。如果你的服务是面向公众的开放对话、以单轮为主、每个人第一句话都不一样,前缀缓存在你这儿基本是空转的,那你该关注的是并发和调度,不是缓存参数。

反过来,如果你是 Agent 或者长 system prompt 的内部助手,那开头那个 Claude Code 归属头的案例就是你最该做的功课——先确认没有任何东西污染你的 prompt 头部,这一步的收益通常比后面所有参数加起来都大,而且不花钱。那些淘汰策略、调度策略、分层缓存,都是在请求结构已经对了之后才开始起作用的旋钮。顺序搞反了,参数怎么调都是在一个很低的上限底下打转。

最后提醒一句边界。本文所有参数和默认值都来自官方文档当前版本,SGLang 迭代很快,选项集合和默认值都可能变,上线前用你那个版本的 --help 对一遍。命中率这件事更是只能自己量——别人的重叠比例跟你的没有可比性,SGLang 与 vLLM 的选型也是同一个道理,任何文章能给你的只是「因为什么机制、在什么形状的负载上可能有差别」,具体差多少,得在你自己的 prompt 分布上跑出来。

算完账发现自建推理不划算?

先用托管端点把业务跑起来,量上来了再回头算自建的平衡点。

去试用

这个页面有问题?

提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。