负载均衡越"公平",大模型越慢:KV cache 前缀复用与"认路由"

账单上"缓存命中"的输入 token 为什么便宜,跟聊天助手聊到第三十轮为什么没有越来越慢,这两件事背后是同一个机制:模型把算过的前缀存在了显存里。可一旦把推理服务铺成多副本,K8s 那套为无状态后端设计的轮询会把同一个会话撒到不同的卡上,每一轮都从头算,副本越多命中率越低。讲清 KV cache 是什么、为什么前缀相同缓存就相同、会话黏性为什么不够,以及 Gateway API Inference Extension 和 llm-d 怎么把"挑哪个副本"这件事交给一个懂模型的调度器,让请求回到记得它的那张卡。


用 API 的人大概都注意过账单上一行叫”缓存命中”的输入 token,价格比普通输入便宜一大截。另一件事:跟聊天助手聊到第三十轮,客户端每一轮都把前面二十九轮原封不动地再发一遍,按理说模型每次都要从头把这几千个 token 读一遍,可回复并没有越来越慢。

这两件事背后是同一个机制:大模型会记住它刚算过的东西。而把这个机制放到 K8s 上跑成一个多副本服务时,会撞上一个反直觉的问题:负载均衡做得越”公平”,服务越慢。

先说模型记住了什么

Transformer 生成每个新 token 时,要”回头看”前面所有 token。注意力机制里,前面的每个 token 都要提供一对向量:K,用来被匹配;V,匹配上以后取走的内容。这两个向量只取决于这个 token 自己和它前面的 token,跟后面会生成什么无关。所以它们算一次就可以存起来,生成下一个 token 时直接查表,不必重算。这份存下来的东西就是 KV cache。

它不小。以老一点的 7B 模型、半精度为例,每个 token 的 K 和 V 加起来大约半 MB;上下文拉到三万多 token,光缓存就是 16 GB 显存,比模型权重本身还大。上一篇《大模型推理为什么要拆成”两段”》讲过,prefill 阶段就是一口气把整段提示词的 KV 算出来,decode 阶段再一个字一个字地吐。KV cache 是 prefill 的产物,decode 的口粮。

前缀复用:算过的不用再算

关键的一句话在上面已经埋下了:一个 token 的 K 和 V 只取决于它和它前面的 token。这意味着两个请求只要开头那段一模一样,开头那段的 KV cache 就一模一样,可以直接拿来用,第二个请求的 prefill 只需要算后面不一样的部分。

什么样的请求会共享开头?几乎所有真实流量:

  • 多轮对话:第 N 轮的提示等于前 N-1 轮加新的一句,前缀就是整个历史
  • 同一份长文档问不同的问题:文档在前,问题在后
  • 同一个系统提示词服务成千上万个用户:所有请求的前几百上千个 token 完全相同
  • Agent 循环:每一步都把系统提示、工具定义、全部历史重发一遍,只多了上一步的结果。前缀极长,新增极短,是前缀复用最理想的客户

两个请求开头相同,开头那段 KV cache 直接复用,只有后面不一样的部分需要重新算

vLLM 把这件事做成了 automatic prefix caching:KV cache 按固定大小的块管理(默认 16 个 token 一块),每个块的哈希由”它前面所有块的哈希加本块的 token”决定,新请求进来逐块比对,命中多少块就跳过多少块的 prefill。SGLang 用的是一棵基数树,思路一样:把 token 序列当路径,共享前缀就是共享树干。命中的效果直接体现在首 token 延迟上:五十轮对话聊到后面,每轮 prefill 只需要算新增的几十个 token,而不是几千个。

API 账单上的”缓存命中打折”,就是这件事在计费层的映射:服务方省掉了那部分算力,把折扣让给了你。

问题:缓存在那张卡上,不在”服务”上

单机上这一切很美好。麻烦出在把它铺成多副本的那一刻。

KV cache 存在某一张 GPU 的显存里,只有那个副本有。而 K8s 的 Service 是为无状态后端设计的:请求来了,轮询也好、挑连接数最少的也好,往哪个 Pod 转都一样。这个假设在推理服务上恰恰不成立。

想象一个三副本的推理服务。某用户的对话第一轮落在副本 A,A 老老实实把系统提示和第一轮算完存好。第二轮来了,Service 按轮询送去副本 B,B 的显存里什么都没有,从头 prefill;第三轮送去 C,还是从头。三个副本各自存着一份完全一样的前缀,占三份显存,而每轮请求的命中率大致只有三分之一。副本越多、越”公平”,命中率越低。这就是标题那句反直觉的话:调度器越是把请求平均摊开,就越是把状态和请求拆散。

同一个会话的三轮请求:轮询把它们撒到三张卡上各算一遍,认路由让它们回到同一张卡

有人会想到老办法,会话黏性:按 cookie 或者用户 ID 把同一个人黏在同一个副本上。它能解决多轮对话,但解决不了另外几种情况。系统提示词是跨用户共享的,黏性看不见;RAG 里同一份文档被不同用户查,黏性也看不见。黏住的副本一旦满载或者挂掉,要么排长队,要么全部重来。黏性是按”人”分,缓存是按”内容的前缀”分,两者根本不在一个维度上。

认路由:把”挑哪个副本”交给懂模型的人

K8s 社区的答案是 Gateway API Inference Extension。核心改动只有一个:网关在转发前,先把请求交给一个叫 Endpoint Picker 的组件问一句”这个请求该去哪”。网关本身还是 Envoy 那套,通过 ext-proc 协议把决策权外包出去;InferencePool 这个 CRD 描述一组跑同一模型的副本,Endpoint Picker 就在这组副本里挑。

llm-d 是这套框架上的一个完整实现,Red Hat、Google、IBM、CoreWeave、NVIDIA 一起发起,今年三月进了 CNCF Sandbox。它的调度器给每个副本打分,最重要的一项就是前缀命中分:这个请求的前缀有多少已经躺在该副本的显存里。这个分有两种算法:

  • 近似模式:调度器自己记账。它记着最近把哪些前缀送去了哪个副本,新请求来了按字符数估一下 token、查一下这本账。轻量,不依赖外部组件,但账本和真实状态可能脱节,比如副本因为显存压力把某段前缀驱逐了,调度器并不知道
  • 精确模式:订阅 vLLM 发出的 KV cache 事件,哪块进了、哪块被逐出,维护一张全局索引,知道每一个 token 块此刻在哪个副本上。代价是多一个索引组件和一条事件通道

光看命中分也不行。一个热门的系统提示词会让所有请求都想挤进同一个副本,把它打爆。所以命中分要和负载分一起算:队列里排着多少请求,显存里 KV cache 占了多少。综合最高的那个副本才是去处。这是一个明确的权衡:尽量让请求回到”记得它”的副本,但如果那个副本已经喘不过气,宁可换一个从头算。

调度器的记分牌:前缀命中只是其中一项,队列和显存占用会把它拉下来

一个具体的画面

把 agent 场景放进这套机制里过一遍。一个编码 agent 跑二十步,每步请求都带着同一份系统提示、同一套工具定义和越来越长的历史。没有认路由的时候,二十步在三个副本之间打转,大约三分之二的步骤要从头 prefill 几万个 token,每一步用户都得等首字。有了认路由,第一步落在哪,后面十九步都跟着去,每步只算上一步新增的那一小段。用户的感受是”越聊越快”,服务方的感受是同样的卡能扛更多并发。

我的看法

“负载均衡”这个词,在推理服务上需要重新定义。传统的均衡假设后端无状态,公平就是平均;推理副本是有状态的,它的状态就是显存里那些算过的前缀,这时候”公平”反而是破坏。真正该均衡的不是请求数,而是把”状态和请求放在一起”之后的负载。

这不是新故事。数据库分片要按 key 路由,CDN 用一致性哈希让同一个 URL 落到同一个边缘节点,都是”把请求送到有它数据的地方”。K8s 的 Service 抽象在无状态世界里工作得太好了,好到大家忘了它是有前提的。大模型推理把这个前提戳破了,Inference Extension 和 llm-d 就是补上去的那一块。下次看到账单上”缓存命中”那一行,可以想一想:在服务端,你的请求刚刚被送回了它熟悉的那张卡。

评论