缓存感知路由与全局准入控制:大模型推理分布式调度核心
2026/9/24 21:08:37 网站建设 项目流程

第5篇。前面几篇把单机调度吃透了——队列怎么排、优先级怎么抢、KV Cache页面怎么换入换出,那套东西在单进程里跑其实是确定性的,所有状态都在自己内存里,调度器想怎么摆布都行。但生产环境很少允许你用一台机器扛所有流量,一旦把服务拆到多节点,问题性质就变了:请求从客户端到网关,最终路由到哪台机器,不是单机调度器能决定的,而是由一层分布式的调度逻辑决定的。如果这层路由做得糙,单机内调度器再精细也是白搭。这篇要聊的就是这个夹在入口和节点之间的分布式调度层,核心是两个词:缓存感知路由全局准入控制

1. 为什么传统负载均衡策略在大模型推理这里失灵了

1.1 请求之间出现了"上下文亲和性",这是微服务时代没见过的

做过几年后端的人都熟悉负载均衡那套:Round Robin、Least Connections、一致性哈希,选一个节点把请求丢过去。这套东西在无状态服务里很好用,节点与节点之间除了数据库之外没有太多共享状态,谁接请求都一样。但大模型推理完全不同,KV Cache给请求之间制造了一种微服务架构里不存在的"上下文亲和性"

我举个例子你就明白了。假设集群里有三台推理节点,A节点刚才处理过一个关于"如何用Pytorch实现分布式训练"的超长请求,它的KV Cache里缓存了这段前缀的全部中间状态。现在又来一个请求,prompt恰好是以同样的话题开头的长文本。如果负载均衡器把这个请求路由到A节点,缓存直接命中,模型可以从缓存之后的token继续算,TTFT可能只要原来的五分之一甚至更少;如果路由到B或C节点,所有前缀都得从头算一遍,prefill阶段老老实实吃满算力。

这就是KV Cache亲和性。它意味着请求和节点之间不再是无差别的,而是存在一种"认识不认识"的关系。传统负载均衡器在设计时从来没考虑过这种维度,它们看的是连接数、CPU水位、响应延迟,唯独不看"这个节点手头是否已经有你要的数据"。

这个差别在普通Web服务里可能只是快慢问题,在LLM推理里是量级差别。prefill是计算密集型的,特别是长prompt场景下,整段prompt的KV Cache推演代价非常高。而带缓存命中的请求几乎可以从decode阶段开始,计算量直接被砍掉一大块。负载均衡器在毫秒级做出的一个粗略决策,可能直接决定了这个请求是120ms返回还是1200ms返回。

1.2 节点状态是高速变化的,"迟到的负载数据"等于没数据

传统负载均衡依赖的健康检查和负载上报都是周期性采样的,比如每5秒上报一次CPU、内存、连接数。在慢速变化的场景里这个周期没问题,但LLM推理节点不是这样的。

推理节点上的KV Cache是实时写入、实时淘汰的。一个刚处理完超长请求的节点,显存里可能刚刚释放了一大片缓存,也可能刚刚被一段新的长对话塞满。队列长度更是以百毫秒为周期在剧烈波动——decode阶段一个批次可能跑几百毫秒,批间间隙队列会瞬间堆积又瞬间清空。你在t=0时刻采集到的负载数据,在t=2秒后路由决策时可能已经完全不代表节点的真实状态了。

所以一个常见误区是:把单机调度器里那套"看队列长度、看显存余量"的逻辑直接搬到分布式路由层。单机调度器是实时感知状态的,因为它和推理引擎在同一个进程里;分布式路由层做决策时用的是另一台机器上报的历史快照,这两种信息的时效性完全不是一个级别。分布式调度层的核心难题,是必须在信息不完整、状态可能过期的前提下做决策,而且这个决策要在几十毫秒内完成。

换句话说,大模型推理的分布式调度,本质上是在追求"用确定的路由规则,去对冲不确定的节点状态"。这条路子的关键,就是让路由决策尽可能多地利用那些"变化没那么快、但对结果影响巨大"的信息——比如KV Cache的缓存位置。

2. 缓存感知路由:让请求主动去找"已经认识它的"节点

2.1 缓存命中的收益到底有多大,先算笔账

先说个大概的量级感受。一个7B参数的模型,处理一段2000 token的prompt,prefill阶段大概要跑2000个forward step,在单张A100上大概需要几百毫秒到一秒出头,取决于实现和量化方式。而如果这段prompt的KV Cache完全命中,网络和哈希查找的开销大概在几十毫秒,之后直接从decode开始,段首延迟可以压缩到原来的十分之一甚至更低。

更关键的是吞吐。KV Cache命中不只是让单个请求变快,它还释放了prefill占用的算力。在一个时间窗口内,如果50%的请求都能命中缓存,那么被节省下来的算力可以全部用于decode,整体吞吐能上涨非常可观的比例。这也是为什么所有主流推理框架都在KV Cache复用上投入巨大——它不是优化,是刚需。

把这个逻辑放在集群里就自然得出一个结论:缓存命中应该成为路由决策的第一优先级。与其把请求发给一个看起来负载很低但前缀必定缓存miss的节点,不如发给一个缓存命中但当前队列略长的节点。前者是铁定要吃满prefill开销,后者可能只需要很小的decode代价。

2.2 前缀索引和缓存位置表:路由层必须知道"缓存在哪"

问题来了:路由层怎么知道哪个节点的缓存里有哪个前缀?

这是缓存感知路由最核心的数据结构设计。目前业界普遍的做法是前缀树(Radix Tree)索引。每个推理节点在处理完一个请求后,会把prompt的前缀分解成树上的一段段路径,叶子节点关联对应的KV Cache对象和token数量。节点把自己这棵前缀树的核心摘要——哪些前缀路径有缓存、路径多长、对应多少token——同步给路由层。

路由层拿到的是一张全集群的缓存位置表:哪个前缀路径、在哪个节点、缓存了多少token。当新请求进来,路由层对请求的prompt做同样的前缀分析,去缓存位置表里查最长匹配。匹配的路径越长,缓存命中收益越大;如果多个节点都有匹配,再综合其他负载因素做打分。

这里有个细节值得注意,前缀匹配不是只查一个精确哈希。不同请求的prompt可能开头一致、后半段不同,所以匹配的是最长公共前缀。如果你的数据结构和路由算法只支持整段prompt的精确匹配,那你只能吃到少部分收益——长对话场景中多轮不一致的尾巴会导致整段缓存失效。用Radix Tree做前缀匹配,才能在"多长前缀算命中"和"匹配成本"之间找一个好的平衡点。

2.3 一个可行的路由评分函数

我见过一些团队的第一版缓存感知路由做得过于简单:只要缓存命中就固定路由到那个节点,完全不看其他因素。这在低并发时没问题,但一旦某个节点因为长期命中而积压了大量请求,反而会把缓存优势吞掉。

合理的做法是多目标打分,缓存命中作为强权重但不作为唯一因素。我贴一个当时在项目里用的伪代码思路,你可以作为参考起点:

def route_score(node_state, request): # cache_hit_len: 前缀匹配的token长度,0表示未命中 # node_state.queue_len: 当前排队请求数 # node_state.gpu_util: 当前GPU利用率(0~1) # 核心思想:缓存命中收益与被匹配长度正相关 cache_bonus = request.cache_hit_len / MAX_PREFIX_LEN # 归一化到0~1 load_penalty = min(node_state.queue_len / MAX_QUEUE, 1.0) + node_state.gpu_util * 0.3 # W_CACHE和W_LOAD的比值很关键,我们最终用的是 3:1 score = W_CACHE * cache_bonus - W_LOAD * load_penalty return score

实际用的时候,你会遇到一个绕不开的因素:缓存命中节点如果正在打满,是硬路由过去让它排队,还是路由到空闲节点重新prefill?我的经验是,优先设一个"缓存命中但负载过重"的熔断阈值——如果队列深度超过某个值,宁可舍弃命中,把请求路由到空闲节点。因为缓存命中带来的延迟优势会被排队等待完全抵消,而且队列过长还会拖垮那个节点的整体吞吐。这个阈值没有固定公式,跟模型大小、显存容量、请求长度分布都有关系,线上得反复调。

2.4 路由决策必须放在边缘网关,而不是放在中心节点

我见过一种方案,是把路由决策集中到一个中央调度服务里,所有请求都先打到这个服务,再由它分发到推理节点。这种方案在早期集群规模小、QPS低的时候还能跑,但很快就成了性能和可靠性瓶颈。

更合理的架构是把缓存位置表下发到边缘网关或路由代理。网关可以在本地缓存这份索引,请求进来时直接在本地做前缀匹配和打分,省掉一次RPC交互。缓存位置表本身是最终一致的,几秒内更新一次就够了——因为缓存位置的变化不像负载数据那么高频。这样路由决策的时间可以控制在毫秒级,而且网关之间天然水平扩展,不存在单点问题。

3. 全局准入控制:别让每个节点都以为"自己还能扛"

3.1 局部过载和全局过载的差别,往往就是一场雪崩的起点

缓存感知路由解决的是"请求去哪儿"的问题,但它不解决"集群是否已经超卖"的问题。这是准入控制要做的事。

单机场景下,过载控制是每个节点本地做:队列满了就拒绝新请求。但分布式场景下有个陷阱叫局部视角盲区。想象一个场景,集群有20个节点,每台节点的准入阈值是队列深度不超过32。某一瞬间来了500个并发请求,如果路由算法比较均匀,每个节点分到25个请求,此时没有任何节点超过本地阈值,所有请求都被放进队列。看起来集群在正常工作,但实际上每一个节点都在满负荷排队,综合延迟迅速飙升,最终全部超时,客户端开始重试——这就是典型的群体性过载,没有单一节点触发保护,但整体已经不可用了

全局准入控制要解决的问题正是这个:在整个集群维度设置一个总闸门,而不是依赖每个节点各自把关。这个总闸门要回答的问题是,"当前整个集群还能承载多少并发请求"。如果已经接近上限,新来的请求应该在入口直接排队或拒绝,而不是继续往里涌。

3.2 全局计数器的实现选型:一个现实的经验对比

实现全局准入控制,绕不开的一个问题是:怎么维护一个全集群统一的并发计数信号量。我整理了几种方案的对比:

方案一致性强度典型延迟适用规模风险点
Redis原子操作(INCR/DECR)最终一致0.1~1ms中等集群Redis故障、网络抖动时计数不准
etcd事务(乐观锁)强一致1~5ms小规模、高可靠场景延迟偏高,QPS高时锁竞争
路由层本地批量计数 + 定期同步最终一致亚毫秒(本地)大规模集群同步窗口内可能超发
中心化调度器持有全局状态强一致RPC开销大不推荐大流量单点瓶颈、故障放大

我最终采用的是混合模式:网关本地维护一份"配额簿",里面记录每个网关分配到的并发额度,网关消耗本地额度,定期向协调层申请补充。协调层维护全局剩余额度。这样既避免了每个请求都打Redis,又能通过额度分配机制削减群体性过载的风险。

这个方案的灵感其实来自传统控制系统的令牌桶,只不过桶分散到了每个网关实例上。你可能会问:这样不就有超发风险吗?确实有——某个网关在两次同步之间可能超额消耗。但实际中这里有个很重要的观察:推理请求从进入到真正开始执行,中间还隔着排队和调度,所以准入控制并不需要精确到单个请求,只需要在时间窗口内把总量压在上限附近。最终一致性足够,反而强一致方案会把整个系统拖慢。

3.3 准入控制里的优先级与配额:别让低优先级流量饿死高优先级任务

全局准入控制不只是数个数。生产环境里,一个集群往往是多个业务方共用的:有的是线上实时请求,对延迟极其敏感;有的是批量离线任务,可容忍排队。如果准入控制是一视同仁的FIFO,高优先级请求会被低优先级批量任务堵在队列后面,这是任何业务方都无法接受的。

所以在全局准入控制器里,至少要维护两张表:优先级调度表租户配额表

优先级调度这块,我的做法是给每个优先级分配独立的排队区,全局准入控制器在发放额度时按优先级加权。例如P0请求可以占用70%的容量,P1占30%,P2只能使用空闲容量。这个权重不是写死的,而是根据业务SLO动态调整——如果P0的TP99开始恶化,就动态收紧P1的配额,把更多容量让给P0。

租户配额这块是为了防止某个团队把公共集群彻底挤占。每类租户有一个全局配额上限,即使集群总体还有余量,超配的租户也会在入口被拦下。这里有个容易被忽略的细节:配额检查应该在路由决策之前做,否则你可能花了不少时间查询缓存位置、做完路由打分,最后发现租户配额度不够,白白浪费一次路由计算。

3.4 全局准入控制器自身的高可用:这个系统不能成为新的单点

把准入控制放在关键路径上,非常容易引入新的单点。一旦协调器挂了,所有新请求都无法通过准入检查,整个推理服务就瘫痪了。这个风险在设计之初就必须想清楚。

我的应对策略是两级降级。第一级,协调器正常工作时,网关严格执行全局配额;第二级,如果网关发现协调器不可达,自动降级为本地准入控制,即按照节点上报的本地负载做粗粒度限制。虽然降级后失去了全局视角,但至少不会让整个服务完全不可用。还有一个附加机制是准入控制旁路——对于内部监控探活请求和紧急故障恢复请求,可以直接绕过准入控制,确保系统永远保留一条"救命通道"。

4. 一条请求的分布式调度全链路,从路由到执行是这么协同的

4.1 阶段一:网关入口处的第一道准入检查

请求到达网关时,先不急着做缓存感知路由。第一件事是快速失败检查:租户配额是否还有余额、请求是否有明显异常(比如prompt长度超过模型的max_seq_len)、当前集群是否处于熔断状态。这些检查全部是本地状态,不需要远程调用,目标是把明显无效的流量在入口直接拦掉。说白了,这里要干的是"低成本过滤",把路由决策的算力留给真正有价值的请求。

这个阶段还有一个容易被忽视的点:请求排队应该发生在准入检查之后、路由决策之前。如果并发太高,网关本地先排队,避免把所有请求一股脑丢给路由模块。网关本地的排队深度其实就是整个集群背压机制的第一级缓冲。

4.2 阶段二:路由决策(目标:毫秒级完成)

通过第一道准入检查后,请求进入路由模块。路由模块做四件事:

  1. 对prompt做前缀分析,计算前缀哈希。
  2. 在本地缓存的位置表中查找最长匹配,得到候选节点列表。
  3. 对候选节点做可用性过滤(剔除不健康节点、容量不足节点)。
  4. 用评分函数对候选节点打分,选出最优目标节点。

整个链路下来,预算应该控制在10ms以内。如果超过这个量级,说明你的位置表太大或者匹配算法太慢,建议考虑对位置表做分层索引——比如热点前缀放内存哈希表,冷门前缀走Radix Tree。

4.3 阶段三:目标节点的二次准入与执行

为什么路由决策之后还需要节点本地做二次准入?因为路由层拿到的状态是"过去的快照"。在路由决策完成到请求真正到达节点之间的这段时间,节点可能已经满载甚至出现故障。所以在节点侧,推理引擎需要根据当前真实的队列长度、显存余量,做最终决定:接收、排队还是拒绝。

这个二次准入和单机调度器是联动的。我在上一篇讲单机调度时提到,本地队列有多种优先级队列,二次准入就是决定这个请求进入哪个队列、是否要抢占已有请求的资源。分布式路由层的一次准入,和节点本地的二次准入,是粗粒度拦截+细粒度裁决的关系,缺一不可。

4.4 失败处理:路由决策错了怎么办

实战中一定会遇到这种情况:路由层把请求分给了节点A,但请求到达时A已经扛不住了,返回拒绝。这时候如果客户端直接重试,又打到A,A继续拒绝,来回几次,故障被放大成雪崩。

正确的做法是客户端的重试必须设计成"更换节点"的重试。也就是说,请求被拒绝后,客户端需要向路由层重新发起一次路由请求,并且明确告知"刚才选的节点已经失败,请排除该节点"。或者在网关层做一层自动重路由——第一次路由失败后,网关自动把请求二次路由到次优节点。但要注意,重试次数必须严格控制,否则一次故障能引发3倍以上的流量冲击。

我踩过的坑是,早期把重试逻辑放在路由层,导致一个失败请求反复触发路由计算,缓存位置表在重试过程中被大量无效查询冲刷。后来改成客户端单次重试+路由层排除失败节点,效果好很多。

5. 跑了一年之后,我踩过的三个实实在在的深坑

5.1 缓存感知路由带来的"热节点"问题

缓存感知路由有个副作用:越热的缓存越容易被命中,越被命中就越热。某些高频前缀会集中在少数节点上,导致这些节点的流量远高于其他节点,而其他节点虽然空闲,却因为缓存miss而持续被冷落。

这个问题的本质是路由的exploration-exploitation矛盾——既要利用已有缓存(exploitation),又要避免把负载集中在热点节点(exploration)。我的解决思路有两个:一是给热门前缀配置多副本站点,让同一份前缀的缓存同时存在于多个节点;二是引入"带概率的探索机制",比如5%的请求故意路由到空闲但缓存miss的节点,避免部分节点空转到无缓存可用的状态。这个比例要控制得很小心,太高浪费算力,太低起不到均衡作用。

5.2 全局准入控制器拖慢了所有请求的"隐形延迟"

全局准入控制最容易被低估的问题是延迟开销。在一版设计中,我对每个请求都走了一次Redis的原子计数操作,结果发现平均每个请求的时间多了接近2ms。在低QPS时无所谓,但高并发下这2ms的同步等待会显著拉低整体吞吐。

后来我把同步模型改成了异步批量上报,并且允许准入门控在请求路径上用本地缓存计数做放行决策。效果立竿见影,请求路径上的额外开销从毫秒降到了微秒级。这个经验就是——全局准入控制器的计数值不需要实时精确,只需要在窗口内有统计意义。把"精确同步"换成"统计同步",性能与准确性的平衡点就找到了。

5.3 缓存失效引发的"群体重算"雪崩

最后一个坑,也是让我印象最深的:某个节点的显存不足,触发了批量KV Cache淘汰。这时候恰好有一堆请求的前缀都在这个节点上,路由层的位置表还没来得及更新,继续把大量请求路由过来。结果就是这些请求全部缓存miss,全部必须从头prefill,节点瞬间被计算打满,队列暴涨,最终雪崩。

这个问题在分布式缓存系统里其实很常见,大名是惊群效应。后来我的对策是:节点在批量驱逐缓存之前,先向路由层发送一个"缓存失效预告",让路由层暂时把这个节点的缓存命中权重调低,等驱逐完成再恢复。这个预告必须提前几秒发出,给路由层充分的时间切换流量。另一个辅助手段是缓存平滑淘汰——不要一次性驱逐所有冷缓存,而是分批进行,每次淘汰一批,观察节点负载稳定后再淘汰下一批。

6. 留给运维团队的指标清单

如果你准备在自己的集群里落地这套分布式调度,我强烈建议至少监控以下几类指标:

指标类别具体指标说明
路由质量缓存命中率(按前缀长度分层)核心指标,直接决定平均prefill成本
路由质量热节点偏差度标准方差,衡量请求分布是否均匀
准入控制准入拒绝率过高说明容量规划偏紧,长期为0说明配额偏松
准入控制队列等待时间区分P0/P1/P2分别统计
系统稳定性缓存驱逐频率过高说明显存配置不合理
系统稳定性重试率超过5%就要排查路由决策的正确性

其中一个最重要的指标是我反复强调的:按前缀长度分层的缓存命中率。只看一个总体命中率是远远不够的,你必须知道哪些长度段的前缀命中率高、哪些低。如果1k以内的前缀命中率高,但4k以上的超长前缀命中率低,说明你的业务特征是长prompt经常变化,这时候就更需要路由层做深度的最长前缀匹配,而不是简单的前缀精确匹配。

另外,压测时别只测"集群满负荷"这一个场景。多测几组灰度场景:比如某几个节点同时异常、某类请求流量突然翻倍、某个租户配额突然用满。这些才是分布式调度真正接受考验的时刻,也是你能提前发现组合性故障的唯一途径。

我个人的体会是,缓存感知路由和全局准入控制,本质上是把"请求分配"从一门艺术变成了一门工程。前者解决的是性能倍增问题,后者解决的是稳定性兜底问题。两者缺一不可——没有缓存感知,集群在物理上就是低效的;没有全局准入,集群在逻辑上就是脆弱的。而把这两个机制串起来的,正是一个能理解KV Cache、能控制负载、能容忍故障的分布式调度中间层。做这一层没有捷径,就是在一次次的流量冲击和故障复原中,把每个判断条件调到最稳妥的位置。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询