2.4 RadixAttention 与多轮命中轨迹 本节摘要:PagedAttention 把缓存存得干干净净,却没回答"谁该复用谁"。RadixAttention 接的是这一棒:它用一棵基数树把请求的前缀组织起来,新请求到来时沿着树走,能匹配上的那段前缀直接命中已有 KV,只重算匹配不上的尾巴。这一节先讲基数树怎么管前缀,再点明它和 PagedAttention 是"块管理"与"前缀组织"的互补,最后完整复盘一个五轮对话的命中轨迹,把"命中多少、重算多少"摊成表、画成线。 前缀之间也能复用吗 多轮对话里有个被长期忽视的事实:第二轮发给模型的,不只是用户新打的那句话,而是"系统提示词 + 第一轮用户 + 第一轮助手 + 第二轮用户"一整串。第三轮更长是把前两轮全带上。
本节摘要:PagedAttention 把缓存存得干干净净,却没回答"谁该复用谁"。RadixAttention 接的是这一棒:它用一棵基数树把请求的前缀组织起来,新请求到来时沿着树走,能匹配上的那段前缀直接命中已有 KV,只重算匹配不上的尾巴。这一节先讲基数树怎么管前缀,再点明它和 PagedAttention 是"块管理"与"前缀组织"的互补,最后完整复盘一个五轮对话的命中轨迹,把"命中多少、重算多少"摊成表、画成线。
多轮对话里有个被长期忽视的事实:第二轮发给模型的,不只是用户新打的那句话,而是"系统提示词 + 第一轮用户 + 第一轮助手 + 第二轮用户"一整串。第三轮更长是把前两轮全带上。换句话说,每一轮请求的开头,几乎都是上一轮的完整复刻,只有末尾那句是新的。
如果缓存只在单请求内有效(2.1 的 KV Cache 本来就是这么用的),那第二轮就得把前面整段重算一遍 Prefill——第一轮的算力白付了第二遍。这正是第 1 章说的"边界外重复"在对话场景的具象。要让这段历史在请求之间活下来,得有一套机制记住"哪些前缀的 KV 已经算过、谁又用到了同一段"。
RadixAttention 的做法是基数树(radix tree)。把每个请求的 token 序列当成一条路径,路径上的每个节点存一段连续的 KV;多个请求若共享开头,就在树上汇到同一个节点,节点的 KV 只算一次、被所有共享者引用。新请求来了,沿树逐 token 往下走,能走到哪说明哪段命中,走不动的那个分叉点之后才是要新算的。
得把这节和 2.3 的关系讲透,否则容易混。PagedAttention 管的是"显存里的块怎么分配、怎么映射"——它是底层的块分配器,职责是"别浪费、能共享、随用随取"。RadixAttention 管的是"哪些前缀值得留、新请求该匹配哪段、命中后怎么记账"——它是上层的缓存策略,职责是"让复用发生在请求之间、提高命中率"。
打个比方:PagedAttention 是仓库的货架管理系统,决定货怎么摆、空格怎么用;RadixAttention 是仓库的检索系统,决定新来的单子能不能直接调出历史货、调哪批。没有货架系统,检索到了也无块可放;没有检索系统,货架摆得再整齐也只知道"有货"不知道"谁能共用"。SGLang 里两者实际是协同实现的:基数树决定前缀边界,底层仍用分页式物理块承载命中到的 KV。所以它们是互补,不是替代——一个解决效率,一个解决发现。
基数树相比普通前缀表还有一个优势:分支友好。多轮里用户若"修改上一句重发"(典型见于 Agent 调试、对话回溯),普通线性前缀就断了;基数树让不同分支各走各的路径,公共祖先照常命中,分叉点之后各自新算。这让缓存不再脆,面对真实多变的对话流也能稳住命中率。

理论讲完,用一段真实可感的轨迹把它钉死。背景设定:一个用户把一份 Python 项目丢给代码助手,连问五个递进的问题。超参取整数便于核算:系统提示词 200 token,每轮用户提问 80 token,每轮助手回答 150 token。关键是多轮协议会把全部历史拼进下一轮请求。
服务跑着 RadixAttention,基数树初始为空。用户开始第一轮,没有任何历史可复用——这是必然的开局,首轮总是全量 Prefill。
逐轮记录"本轮请求总长度、能在基数树上命中的前缀长度、必须新算的尾部长度"。前缀命中靠树匹配:请求从左往右走,树上有节点就命中,走到第一个树里没有的 token 就停。
| 轮次 | 本轮请求总 token | 命中前缀长度 | 需重算长度 | 重算占比 |
|---|---|---|---|---|
| 1 | 280(系统200+用户80) | 0 | 280 | 100% |
| 2 | 510(历史430+用户80) | 430 | 80 | 15.7% |
| 3 | 740(历史590+用户80) | 590 | 80 | 10.8% |
| 4 | 970(历史740+用户80) | 740 | 80 | 8.2% |
| 5 | 1200(历史890+用户80) | 890 | 80 | 6.7% |
注意一个反直觉但漂亮的点:需要重算的长度从头到尾都是 80,就是那句新提问。总量从 280 涨到 1200,靠的全是命中前缀在长。所以单轮 Prefill 成本被钉死,对话越长越便宜。
这表就是"复用经济学"最直白的账本。第一轮付了全额,是获客成本;之后每一轮,前面的所有算力都被"回收"成可直接命中的 KV,新增支出只剩当前一句话。把五轮的重算长度加起来(280+80×4=600),若不命中则要付 280+510+740+970+1200=3700——命中把 Prefill 总量砍到约六分之一。基数树在这里的价值,不是存了多少,而是"让每一轮的旧货都能被下一轮免单调出"。
把场景拧一下,能看清命中率对共享的依赖。变式一:第二轮用户改成"撤回上一句、换个问法"(对话回溯)。这时请求前缀在第二轮用户处分叉,基数树保留原分支、新开一条,公共祖先(系统+第一轮用户+第一轮助手)仍命中,只是新分支的尾部要重算——命中长度从 430 掉到 350 左右,但没归零,分支友好性体现出来。
变式二:换一个全新用户,只共享系统提示词。他的第一轮命中只有 200(系统),重算 80,占比仍低;但若系统提示词很短,跨用户共享的收益就薄。这说明 RadixAttention 的最大红利来自"长而稳定的公共前缀"——系统人设长、few-shot 样例固定、或同一份长文档被多人追问时,命中率最高。反过来,每个请求前缀都不同的流量(如完全独立的随机提问),它几乎无能为力,此时 2.5 的压缩比命中更实在。
命中轨迹里有个没说破的前提:基数树只增不减时,它占的缓存会随会话积累无限膨胀。一个服务跑一整天,成千上万请求的前缀节点堆在树上,显存再大也兜不住。所以生产环境里 RadixAttention 必须配淘汰——按节点最近被引用的时间排序,最冷的末梢先砍;被多个请求共享的祖先节点因为引用多,天然排在后头。淘汰和 PagedAttention 的块池是联动的:树上一个节点被驱逐,它对应的物理块引用计数减一,归零才真正释放。
这里埋着和第 3 章的接口。本章所有命中都活在"单个推理进程的内存里",进程一重启,基数树清空,全部命中归零,下一批请求又得从头 Prefill。这正是平台级缓存要解决的痛点:把命中从进程内存里搬出来,做成跨进程、跨时间、甚至跨机器持久的那一层。本节的五轮轨迹,到了第 3 章会被重画成"即便服务重启、即便换了个用户节点,只要前缀相同仍命中"的云化版本。懂了单机里树怎么长、怎么淘汰,才看得懂平台级那套为什么值得做成一门计费的生意。
RadixAttention 把"复用"从单请求内推到了请求之间,靠的是前缀的组织与发现。但它仍旧受显存总量约束:基数树里存的节点越多,占的缓存越大,树再聪明也存不下无限历史。当对话长到缓存见顶,就得做取舍——要么淘汰冷前缀,要么把缓存本身做小。这正是 2.5 的三条压缩路要接的最后一棒。到那节你会看到,GQA/MQA、量化、稀疏滑窗,本质上都是在"命中率"和"显存预算"之间重新切蛋糕。