缓存感知路由与全局准入控制:大模型推理分布式调度核心
第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阶段一个批次可能跑几百毫秒批间间隙队列会瞬间堆积又瞬间清空。你在t0时刻采集到的负载数据在t2秒后路由决策时可能已经完全不代表节点的真实状态了。所以一个常见误区是把单机调度器里那套看队列长度、看显存余量的逻辑直接搬到分布式路由层。单机调度器是实时感知状态的因为它和推理引擎在同一个进程里分布式路由层做决策时用的是另一台机器上报的历史快照这两种信息的时效性完全不是一个级别。分布式调度层的核心难题是必须在信息不完整、状态可能过期的前提下做决策而且这个决策要在几十毫秒内完成。换句话说大模型推理的分布式调度本质上是在追求用确定的路由规则去对冲不确定的节点状态。这条路子的关键就是让路由决策尽可能多地利用那些变化没那么快、但对结果影响巨大的信息——比如KV Cache的缓存位置。2. 缓存感知路由让请求主动去找已经认识它的节点2.1 缓存命中的收益到底有多大先算笔账先说个大概的量级感受。一个7B参数的模型处理一段2000 token的promptprefill阶段大概要跑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 阶段二路由决策目标毫秒级完成通过第一道准入检查后请求进入路由模块。路由模块做四件事对prompt做前缀分析计算前缀哈希。在本地缓存的位置表中查找最长匹配得到候选节点列表。对候选节点做可用性过滤剔除不健康节点、容量不足节点。用评分函数对候选节点打分选出最优目标节点。整个链路下来预算应该控制在10ms以内。如果超过这个量级说明你的位置表太大或者匹配算法太慢建议考虑对位置表做分层索引——比如热点前缀放内存哈希表冷门前缀走Radix Tree。4.3 阶段三目标节点的二次准入与执行为什么路由决策之后还需要节点本地做二次准入因为路由层拿到的状态是过去的快照。在路由决策完成到请求真正到达节点之间的这段时间节点可能已经满载甚至出现故障。所以在节点侧推理引擎需要根据当前真实的队列长度、显存余量做最终决定接收、排队还是拒绝。这个二次准入和单机调度器是联动的。我在上一篇讲单机调度时提到本地队列有多种优先级队列二次准入就是决定这个请求进入哪个队列、是否要抢占已有请求的资源。分布式路由层的一次准入和节点本地的二次准入是粗粒度拦截细粒度裁决的关系缺一不可。4.4 失败处理路由决策错了怎么办实战中一定会遇到这种情况路由层把请求分给了节点A但请求到达时A已经扛不住了返回拒绝。这时候如果客户端直接重试又打到AA继续拒绝来回几次故障被放大成雪崩。正确的做法是客户端的重试必须设计成更换节点的重试。也就是说请求被拒绝后客户端需要向路由层重新发起一次路由请求并且明确告知刚才选的节点已经失败请排除该节点。或者在网关层做一层自动重路由——第一次路由失败后网关自动把请求二次路由到次优节点。但要注意重试次数必须严格控制否则一次故障能引发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、能控制负载、能容忍故障的分布式调度中间层。做这一层没有捷径就是在一次次的流量冲击和故障复原中把每个判断条件调到最稳妥的位置。