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

从事大模型推理服务的时间久了,你会发现一个很现实的问题:单机改多机之后,瓶颈几乎不在模型本身,而在“请求到底怎么分”。流量从入口进来,是先去一台缓存还热着的机器,还是被某个负载均衡器机械地轮询到一台冷冰冰的节点上,结果直接决定首 token 延迟和整机吞吐能差多少倍。再往上走一层,当集群进入高水位,如果只在每台机器各自限流,全局视角的缺失会让系统在流量峰值到来时迅速失控。这篇就专门拆解分布式推理调度里最容易被忽视、又最影响稳定性的两块核心——缓存感知路由与全局准入控制。

文章适合正在把单机 vLLM、TGI 推理服务往多机集群扩展的工程师,也适合那些已经上了负载均衡、却发现节点负载不均匀、缓存命中率上不去、并发一高就开始雪崩的运维和后台同学。我不会只给结论,会把设计背后的逻辑、工程实现时的取舍,以及自己在实际环境里踩过的坑一起讲清楚。

1. 分布式推理调度的整体设计与分层思路

1.1 先判断自己是否需要上分布式调度

很多团队是从单机开始跑推理的。一张 80G 显存的卡装下 7B 或 14B 模型,用 vLLM 做 Continuous Batching,单机吞吐就能跑到几百甚至上千 tokens/s。这个阶段确实不需要复杂的调度,一个 HTTP 服务加一个引擎进程就够用。

但出现下面几个信号时,就该认真考虑分布式调度了。第一,单卡显存放不下权重加足够长的上下文,这时必须先做张量并行或流水线并行,把显存难题解决掉。第二,单机并发已经触顶,P99 延迟持续恶化,说明引擎内部的排队已经失控。第三,业务开始出现多租户,不同部门或不同用户共用同一批 GPU,如果还按单机各自限流,资源无法统一切分,优先级策略也没法统一落地。

我见过不少团队为了保证单机性能,先把所有服务均匀分布到多台机器上,结果节点之间的负载差异特别大。原因很简单,推理服务的资源消耗不是按请求数线性变化的,prompt 长度、生成长度、缓存命中情况都会让同样的请求在不同节点上产生完全不同的压力。所以分布式不是“机器多了就完事”,而是要把调度这件事单独拎出来设计。

1.2 路由层、配额层、执行层各管什么

把分布式推理调度拆开看,我习惯将它分层为三层,分别是路由层、配额层和执行层。每层职责各不相同,搞清楚了分层,后续做配置、做优化、做排障才有一个清晰的坐标系。

路由层解决的核心问题是“请求去哪台机器”。它和普通 Web 负载均衡有本质区别,因为推理请求的结果依赖上下文,而上下文会以 KV Cache 的形式留在实例显存里。一个带长 System Prompt 的请求如果被路由到了已缓存该前缀的节点,就能直接跳过前缀部分的 prefill;反之就要从零开始计算。路由算法不仅要考虑负载,还要考虑缓存亲和性。

配额层解决的核心问题是“请求现在能不能进”。它对应全局准入控制,本质是集群级资源门禁。单机有显存上限、队列上限,但只有集群统一视角才能决定一个请求是放行、排队、拒绝还是降级处理。配额层需要实时拿到各节点状态,再结合租户优先级做出决策。

执行层就是实际承接请求的推理引擎实例,比如 vLLM、TGI、SGLang 运行时。这一层的 PagedAttention、Continuous Batching、抢占与恢复机制在单机场景下已经被讨论很多了。在分布式调度里,执行层更重要的角色是被调度器指挥的单元,它需要向路由层和配额层汇报实时状态,包括显存余量、等待队列长度、当前累计 token 数、最近一段时间的缓存命中率。没有这些数据,上层调度就是瞎子摸象。

这样一分层,你会发现真正值得投入精力优化的,基本都是路由层和配额层。好的调度器不一定要大改推理引擎,而是在引擎外面套一层聪明的指挥系统。

2. 缓存感知路由:把请求送到更容易命中缓存的地方

2.1 KV Cache 复用决定了路由策略为什么特殊

要理解缓存感知路由,先要看大模型推理里缓存的特殊之处。LLM 生成是逐 token 自回归的,前面所有 token 的 key 和 value 都会保存在 KV Cache 里。如果一个新请求的前缀和之前某个请求的前缀一致,那么前缀部分对应的 KV Cache 是可以直接复用的。

我用客服机器人场景举例。线上客服的 System Prompt 通常很长,可能有几千个 token,而且基本是每个请求都一样的公共前缀。如果路由算法完全不管缓存情况,把请求平均撒向所有节点,那每个节点都要为同一段 System Prompt 重新做一遍 prefill,算力被白白浪费。流量越大,这种浪费越明显。反过来,如果能让相同前缀的请求尽量扎堆到同一台机器上,缓存命中率会显著上升,首 token 延迟和整机吞吐都能得到明显改善。

市面上那些 prefix-aware 的路由方案,核心思路其实很朴素:在负载均衡之外,增加一个“尝试命中已有缓存”的维度,让请求去能复用缓存的节点,而不是漫无目的地散弹打鸟。

2.2 一致性哈希与动态候选池的取舍

缓存感知路由工程上主要有两条路线。

一条是基于一致性哈希。把请求的某个关键维度,比如模型版本 + 系统提示摘要,哈希成一个键,再用一致性哈希映射到一组节点。它的优势是结构简单,不依赖节点实时上报,节点增删对路由影响较小,很适合模型级的大前缀分流。缺点是粒度粗,不够灵活,一旦节点故障或某台过载,哈希策略很难快速调整,仍会把流量打向不健康节点。

另一条是基于实时上报的动态候选池。每个推理实例周期性地向路由网关上报自己缓存了哪些前缀、剩余容量、当前负载。路由网关维护一个“前缀 -> 候选节点列表”的索引,新请求来了先查索引,如果命中,就从候选节点里选一个当前负载允许的节点;没命中,就按普通负载均衡策略找一台空闲节点。

我实际项目里用的是混合策略。短前缀走动态候选池,实时性收益明显;模型级的大前缀走一致性哈希,避免路由层索引内存膨胀。需要注意,候选池不要做到细粒度到每个完整 Prompt 的级别。前缀数量一多,路由层内存会成百上千倍增长,索引更新时间也会拖垮转发性能。我建议索引到前缀摘要级别,而不是完整 Prompt。

提示:缓存感知路由的索引一定要设置 TTL 和淘汰机制。否则运行时间一长,陈旧的前缀信息会让候选池命中率虚高,实际转发时却因为节点已逐出缓存而白白重算。

2.3 路由失败与降级兜底策略

缓存感知路由最怕的就是“感知错误”。

举个例子,路由层认为节点 A 有某个前缀的缓存,于是把请求派过去。结果节点 A 因为显存压力把早期缓存逐出了,或者节点刚重启,缓存全部清空。这时请求到了节点上,还是要做完整 prefill,这次“缓存感知”不仅没有收益,反而多了一次无意义的定向转发。

针对这种情况,我做三级降级。第一级,路由层在转发时携带一个期望命中的前缀元信息,节点收到后自己确认缓存是否存在,并在响应里返回实际命中状态。第二级,节点周期上报最近一段时间的命中统计,让路由层知道哪些节点“说的比做的差”。第三级,全局监控汇总数据,如果某节点对某前缀的命中率持续偏低,路由层就把它从该前缀的候选池里摘除,直到它重新上报可用的缓存信息。

还有一类降级跟节点健康相关。节点负载高、准备做 KV Cache offload、或者打算进入 standby 状态时,应该主动向路由层发送一个“暂时别派新请求”的信号。这个信号不能当成可选项。否则流量高峰时,你会看到某台节点因为 preemption 风暴导致性能骤降,而路由层还在源源不断把请求派过去。

我习惯把节点状态抽象成一张状态机:Healthy,Backoff,Quiescing,Re-register。路由层只给 Healthy 节点分配新请求,Backoff 和 Quiescing 状态只接收短请求或不接收请求。状态机代码不复杂,但能把大量隐性故障挡在门外。

2.4 最小实现伪代码参考

缓存感知路由的决策逻辑并不复杂,写一个最小实现也就几十行。核心是对每个请求计算稳定的前缀哈希,查候选池,再结合节点负载决策。

class CacheAwareRouter: def __init__(self, prefix_index, node_registry): self.index = prefix_index self.registry = node_registry def route(self, request): prefix_hash = stable_hash( model=request.model_version, prompt_prefix=request.prefix_system_prompt ) candidates = self.index.lookup(prefix_hash) alive = [ n for n in candidates if n.state == NodeState.HEALTHY and n.last_report_age() < 15 ] if not alive: # 没有可用缓存节点,退回普通负载均衡 target = self.registry.least_loaded() self.index.bind(prefix_hash, target) else: target = min(alive, key=lambda n: (n.load_score(), n.pending_tokens)) return target, prefix_hash

这段伪代码里最关键的是 stable_hash 和 last_report_age。前者保证相同前缀请求始终能映射到同一批候选节点,后者保证路由层不会把请求派给已经失联的节点。实际生产环境我会再加一层本地 LRU 缓存,把前缀哈希和候选节点列表缓存下来,避免每个请求都打一次路由索引服务。

3. 全局准入控制:给整个集群设一道真正的门禁

3.1 单机限流在分布式场景下的盲区

单机限流的常规做法是按 QPS、并发连接数或显存余量设置阈值,超了就拒绝。这种方案在单节点场景下问题不大,但到了集群里就会暴露致命盲点:每台机器只看到自己的局部水位,看不到集群整体。

举个例子,你有 8 个节点的推理集群,每台节点设置了并发 40 的硬上限。某个时间点,5 台节点活跃请求已经到 38 左右,另外 3 台只有 10。这时候来了 50 个新请求,单机限流并不知道其中 30 个应该优先送到那 3 台空闲节点。如果 40 是硬顶,那么这 50 个请求只有 3 台节点能接收一小部分,其余全部被拒。集群明明还有大量空闲容量,却表现为整体过载。

你还可能想到用“每台机器调低阈值”来给路由层留空间。实际上这也没有用,因为你无法靠预设固定阈值来“猜”集群真实可用容量。token 长度、排队情况、缓存命中率每时每刻都在变,固定阈值要么过度保守导致资源闲置,要么过于激进导致雪崩。所以分布式推理场景必须有集群级的准入控制,也就是全局配额。

3.2 用 Runtime Tokens 做配额计量,而不是请求数

全局准入控制最核心的一步,是找到正确的计量单位。

QPS 不适合做配额,因为一个 10 token 的请求和一个 5000 token 的长文档请求,资源消耗完全不是一个量级。按显存用量计量也不够直观,因为显存里还包含模型权重和框架开销,和引擎实际压力并不完全线性。

我在分布式配额系统里用的主计量单位是 Runtime Tokens,也就是引擎当前正在处理的 token 总量。把它拆开看,是三部分的累加:所有活跃请求已生成 token 的占用、KV Cache 分配出去的 token 数、以及排队中请求预计需要消耗的 prefill token 数。

用 Runtime Tokens 做配额的好处是,它直接反映推理引擎的真实压力。你给整个集群设置一个总 Runtime Token 配额,再给各个租户设置子配额,当某通道耗尽配额,新请求就在准入层被拦下,而不是冲进节点继续堆积。这种方式对长短差异极大的推理负载特别友好。

3.3 准入决策不是非黑即白

全局准入控制尽量不要做成“要么放行,要么拒绝”的开关。我把准入决策设计成四种动作:允许、排队、拒绝、降级。

允许,就是下游资源充足直接放入。排队,适合下游资源趋紧但请求有优先级保障的场景,放进队列等待,等有配额或节点完成一轮 batch 再出队。拒绝,用于超出系统承受总量且不是高优的请求,直接返回 429 或业务可理解的限流提示。降级,则针对不追求实时性的请求,比如离线日志分析、文档批处理,在准入层就给一个更低的优先级,或者切换到异步可离线执行的路径。

这四种动作组合起来,才能做到全局准入而不僵化。如果只有允许和拒绝,高峰期就是硬生生切断流量,服务体验会很差。

工程实现上,准入决策器建议独立成一个无状态模块,只根据当前集群状态和请求元信息做决策,不负责转发。转发仍由路由层负责。这样划分后,配额中心可以用 Redis 或 etcd 存集群水位数据,决策器可以水平扩展,不会成为请求链路中的性能瓶颈。

3.4 动态配额与公平性保护

全局配额最忌讳的就是把总量写死。上线初期大家都很谨慎,配额设置得非常保守,结果 GPU 利用率长期在低位;业务高峰又不敢轻易调高,怕出问题。

我建议把配额计算做成动态的。以节点实测容量为基础,结合当前排队长度、平均请求长度,每隔 20 到 30 秒自动调整一次总配额。同时设置一个缓冲水位,当集群水位超过 80% 时自动收紧配额,低于 50% 时自动放宽。这样系统既能在突发流量来临时从容应对,也不会在低谷期浪费算力。

另外,动态配额不等于无限度增长。我会再设一个绝对硬顶,哪怕集群看起来还有大量空闲显存,也不允许单租户无限扩张。这个硬顶按节点数和推理引擎的保守吞吐估算,宁可损失一部分理论吞吐,也要保证稳定性优先。

公平性方面,也要防止高优租户被忽略。建议每个租户设置“保底配额”和“弹性配额”。保底配额保证该租户的基本流量优先通过,弹性配额在集群有余量时按比例分配。经过这几个维度调整之后,准入控制才不会变成只保护总体、牺牲个别的粗糙门禁。

4. 可落地的实操过程与配置参考

4.1 最小化改造路径:从单机集群到分布式调度雏形

实操第一步,先把单机推理服务改成可观测、可汇报的状态。我习惯在每个 vLLM 实例旁边加一个轻量级 sidecar 进程,这个进程周期读取引擎的 Prometheus 指标,提取关键字段,比如 GPU 缓存使用率、运行中请求数、等待中请求数、平均 prompt token、平均生成 token,然后统一上报到路由中心和配额中心。

第二步,搭建路由网关。网关先不急着做太复杂的缓存感知,先做三种基础路由:轮询、最小连接、基于实例负载的最优选择。跑两三天,把集群基础水位数据养起来,再逐步叠加缓存感知逻辑。

第三步,引入全局配额模块。这一步推荐直接复用团队的配置中心或元数据存储,比如 etcd 或 Redis,保存集群级总配额、租户子配额、当前水位。配额模块做成无状态服务,方便横向扩展。

按照这三步走,可以用最小改动把单机 vLLM 集群改造成分布式调度雏形。改造过程中最容易被忽略但最关键的,是状态同步延迟。如果上报周期超过 10 秒,那么在高峰期的快速变化里,路由和配额看到的都是过期数据,调度决策的准确性会被大打折扣。我一般把上报周期设为 2 到 5 秒,路由中心内部再对每个节点做一次滑动窗口平滑,避免瞬时抖动导致路由震荡。

4.2 路由键设计:实战中最容易反噬的地方

路由键设计是做缓存感知时最容易出问题的地方。很多团队第一次上线时,直接用用户 ID 或会话 ID 做哈希键,结果就是命中率怎么调都上不去。

我的建议是,路由键只取业务里稳定且可复用的前缀特征,再加上模型版本。举个例子,客服机器人请求里有 System Prompt、知识库检索结果、历史对话,只有 System Prompt 和知识库前缀可能被大量复用,历史对话则属于用户私有内容。所以路由键应该设计为模型版本 + System Prompt 摘要 + 知识库版本。这样既保证相同业务的请求能扎堆到同一批节点,又不会把用户维度掺进哈希键里导致缓存失效。

线下测试时很难找出这类问题,因为测试集通常比较整齐,缓存命中率看着都挺高。到了线上,真实流量里的请求特征忽变,问题才会暴露出来。

4.3 存储配额状态的一个小示例

全局配额状态我用 etcd 保存,结构大概是下面这样:

/inference/global/quota/total 40000 /inference/tenant/biz_a/quota 18000 /inference/tenant/biz_b/quota 12000 /inference/tenant/offline/quota 6000 /inference/tenant/default/quota 4000 /inference/cluster/watermark 31250

写入逻辑是通过 sidecar 周期更新 watermark,配额决策模块读取后与 total 对比,再根据各租户已用配额决定是否放行。之所以选 etcd,是因为它天然支持 watch,配额模块可以订阅总配额变化,不用频繁轮询。如果团队没有 etcd,用 Redis 也不差,只是要多处理一层分布式锁或者原子递增的问题。

4.4 关键参数速查表

下面给一组我用下来的参数,方向可以参考,具体数值要结合自己的模型和压测场景调整。

参数项推荐值或公式说明
状态上报周期2~5 秒太长会让调度决策滞后,太短会增大额外开销
集群总配额各节点最大可承载 Runtime Tokens 之和乘以 0.8预留 20% 缓冲,给排队和波动留空间
租户子配额总配额按业务优先级加权拆分避免多个租户互相挤占
排队等待上限5~10 秒超过即返回 429 或提示重试
前缀候选池 TTL300 秒防止索引内容长期不更新
动态配额调整周期20~30 秒结合实时水位调整
冷节点补偿窗口30 分钟新节点上线后强制分担一部分流量预热缓存

单节点最大可承载 Runtime Tokens 精确计算起来比较复杂,我的做法是直接压测。在目标模型和最大上下文长度条件下,逐步加并发,观察 P99 延迟和 GPU 利用率,找到延迟拐点,再把这个拐点对应的 running tokens 写入配置,作为节点容量基准。

注意:不同模型、不同上下文长度下,容量数据差异极大。同一个节点跑 7B 短上下文和 70B 长上下文,容量参数完全不可复用。所以关键参数最好按“模型版本 + 部署形态”单独维护,而不是全局一套参数打天下。

5. 踩过的坑与排查技巧实录

5.1 缓存命中率低得离谱,排查后发现是路由键太粗

第一次上线缓存感知路由时,我的命中率怎么调都上不去,始终在 20% 左右。查了很久才发现,路由层使用的哈希键包含了用户 ID,而不是稳定的系统前缀。用户 ID 一变,哈希结果就完全不同,哪怕业务请求带的 System Prompt 一字不差,也没有办法映射到同一组候选节点。

这个问题在日志里很难直接定位。你看到的表象是“候选池命中率 70%,节点实际命中率 20%”,中间差了 50 个点。实际上就是路由键设计错了。解决办法是把路由键改成业务稳定前缀特征加模型版本。如果业务确实按用户隔离,缓存不能跨用户复用,那就只按前缀路由,不要把用户维度掺进哈希键里。

5.2 准入控制误伤长请求,放行逻辑不能只按数量

另一个印象很深的坑,和准入控制的排队机制有关。当时我们设了一个逐批放行的逻辑:每 1 秒从队列放行 500 个请求。上线后发现,长文档总结任务老是被打回 429。

排查后发现,放行逻辑是按请求数量计算的,没有考虑请求长度。大量短请求快速占满了 Runtime Token 配额,长文档请求进队列后一直等不到位。后来我把放行逻辑改成按配额出队:队列里每个请求按预估 token 长度占一个权重,每轮优先凑满剩余配额,同时给长请求保留一个最低比例配额。改完之后,长短请求的完成率才恢复正常。

这个坑说明,配额系统里“请求数”和“资源消耗”之间并不是等价关系,任何按数量做的策略,在长短差异极大的推理负载面前都会失真。

5.3 扩容后新节点吃不到流量,冷启动问题要主动破

扩容三台新节点之后,我发现新节点的流量一直很低。原因很直接:新节点没有缓存,如果路由策略里缓存优先权重过高,新节点就分不到流量。这又形成一个恶性循环——新节点没流量,就没法积累缓存;没有缓存,路由又不会优先派流量。

我一度以为这是正常冷启动现象,结果大促前扩容时,新节点一直低负载,老节点却爆掉。后来我在路由策略里加了一个冷节点补偿因子:新加入节点在最初 30 分钟内,路由权重按比例抬升,强制承担一部分流量来预热缓存;预热期结束后,再自然回归正常路由逻辑。这个技巧看起来简单,但每次扩容和大促前都能派上大用场。

5.4 常见问题速查表

现象可能原因排查思路
节点间负载不均衡路由键设计不合理、新节点冷启动检查路由键特征,观察节点状态机,添加冷启动补偿
缓存命中率持续偏低路由键粒度不匹配、索引 TTL 过短对比候选池命中率和节点实际命中率
高优请求被低优挤垮配额模型只按请求数未按 token 分配改为 Runtime Tokens 配额并设置租户子配额
集群高峰期大面积 429总配额设置过低、动态调整滞后观察水位曲线,调整动态配额参数
某节点反复重启/性能衰退路由层未感知节点进入 Backoff 状态将节点状态机同步落地并让路由层准确消费
调度器自身 CPU 占用过高候选池索引过大、更新太频繁限制索引 TTL、增加本地 LRU 缓存、必要时降级为哈希路由

6. 项目后续还能往这些方向延伸

分布式推理调度这篇先聊到路由和准入这两条主线,实际上往后可以做的方向还很多。

一个方向是把前缀缓存从单机内推进到统一缓存服务层,让跨节点共享缓存变成现实。目前各家实现大多还是节点本地缓存,路由层只能做亲和性调度,没法跨节点搬运缓存。如果做成共享前缀缓存服务,模型的 prefill 负担会进一步下降,但随之而来的缓存一致性、跨节点传输开销、显存池统一管理都会成为新课题。

另一个方向是把准入控制从“逻辑水位限流”升级为“多目标优化”。现在这套门禁解决的核心是防过载,未来还可以加入成本和时间维度,比如根据实例规格、请求排队时间、GPU 价格权重,实时决定是把请求调度到高吞吐实例还是低延迟实例。这个方向需要更强的预测能力,但带来的收益是实打实的成本优化。

我自己目前比较关注的是把调度数据沉淀下来做容量预测。集群里每个请求的前缀命中情况、生成长度分布、租户配额消耗趋势,都是很有价值的样本。利用这些数据,可以对未来的分钟级流量做预判,提前调整配额和路由策略,而不仅仅是等出了问题再被动干预。

最后分享一个实际体会:分布式调度这种系统,最怕不是“功能不够”,而是“策略太多且没有回退空间”。我建议每加一条策略,都要配套对应的监控指标和一键回退开关。先跑最粗的负载均衡把上下行链路养稳定,再逐步叠加缓存感知、全局配额、动态调整。每次调整都留下一份可回退的配置基线,线上出了任何问题,我们都能快速回到稳定状态。毕竟调度系统的目标是让服务更稳,而不是让系统更复杂到没人敢动。

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

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

立即咨询