调度器核心设计:调度器架构模式、调度策略与任务队列

调度器这东西,说白了就是整个系统的“交通指挥员”。你想想看,Ollama 背后挂着好几个模型,有的模型跑在 GPU 上,有的跑在 CPU 上,有的模型大得像头牛,有的模型小得像只猫。谁来决定哪个请求该交给哪个模型?什么时候该排队?什么时候该插队?嗯,这就是调度器要干的事。

我个人习惯把调度器设计分成三个维度来看:架构模式(你怎么部署调度器)、调度策略(你怎么分配任务)、任务队列(你怎么管理等待)。咱们一个一个聊。

调度器架构模式:集中式 vs 分布式

先聊架构。我见过不少团队在这个问题上翻车,所以咱们得把话说清楚。

集中式调度器

集中式调度器,就是整个集群里只有一个调度中心。所有请求都先打到它这里,它来决定谁去处理。

优点很明显:

  • 全局视角,决策最优。你能看到所有节点的负载、所有模型的排队情况。
  • 实现简单,逻辑集中。说白了就是单点控制,代码好写,调试方便。
  • 一致性容易保证。不会出现两个调度器抢资源的情况。

缺点也很致命:

  • 单点瓶颈。调度器挂了,整个系统就瘫了。
  • 性能上限。所有请求都要过它,吞吐量受限于单机能力。
  • 延迟增加。请求多绕了一圈,多了一次网络开销。

我在项目中遇到过一个小团队,他们用集中式调度器跑三个模型,一开始挺顺。后来业务量上来,调度器成了瓶颈,CPU 跑到 90% 以上,请求排队时间比模型推理时间还长。嗯,这就是典型的“调度器把自己调度死了”。

分布式调度器

分布式调度器,就是每个节点都有自己的调度能力,或者通过一致性哈希、Gossip 协议等方式协同决策。

优点:

  • 高可用。一个节点挂了,其他节点照样干活。
  • 水平扩展。加机器就能加调度能力。
  • 低延迟。请求可以直接打到最近的节点,不用绕路。

缺点:

  • 实现复杂。你要处理分布式一致性问题、脑裂问题、数据同步问题。
  • 全局视角弱。每个节点只能看到局部信息,决策可能不是最优的。
  • 调试困难。出了问题,你很难定位是哪个调度器做了错误决策。

我个人建议:如果你的模型数量少于 5 个,节点少于 10 台,集中式调度器完全够用。别为了“分布式”而分布式,那是给自己找麻烦。但如果你要管理几十个模型、上百台节点,那就老老实实上分布式吧。

架构对比表

维度 集中式 分布式
实现难度
可用性 低(单点故障)
决策质量 全局最优 局部最优
扩展性
适用场景 小规模、模型少 大规模、模型多

调度策略:轮询、最少连接、加权

架构定下来之后,接下来就是策略。策略这东西,没有绝对的好坏,只有合不合适。

轮询(Round Robin)

轮询是最简单的策略。请求来了,按顺序轮流分配给各个模型实例。第一个给 A,第二个给 B,第三个给 C,然后回到 A。

优点?实现简单,代码就几行。缺点?它不关心每个实例的实际负载。如果 A 模型正在处理一个大请求,B 模型闲着,轮询还是会往 A 上扔请求。说白了就是“瞎子分配”。

我曾经在一个聊天机器人项目里用过纯轮询,结果发现有的节点忙得要死,有的节点闲得发慌。后来我加了个健康检查,如果节点负载超过阈值就跳过它,这才好一些。

最少连接(Least Connections)

最少连接策略,就是每次把请求分配给当前活跃连接数最少的那个实例。这个比轮询聪明一点,因为它考虑了实时负载。

但这里有个坑:活跃连接数少,不代表它真的闲。比如某个模型正在处理一个超大请求,虽然只有一个连接,但 CPU 已经跑满了。你这时候再给它分配新请求,它反而更慢。

我的经验:最少连接策略适合请求处理时间比较均匀的场景。如果请求大小差异很大,建议结合请求的预估处理时间来加权,而不是只看连接数。

加权调度(Weighted Scheduling)

加权调度是我个人比较喜欢的方式。你可以给每个模型实例设置一个权重,比如 GPU 节点权重设高一点,CPU 节点权重设低一点。调度器按照权重比例来分配请求。

举个例子:

# 加权轮询示例
instances = [
    {"name": "gpu-node-1", "weight": 5},
    {"name": "gpu-node-2", "weight": 5},
    {"name": "cpu-node-1", "weight": 2},
    {"name": "cpu-node-2", "weight": 2},
]

def weighted_round_robin(instances, current_index):
    total_weight = sum(i["weight"] for i in instances)
    # 按权重比例分配
    for i, inst in enumerate(instances):
        if current_index % total_weight < inst["weight"]:
            return inst
        current_index -= inst["weight"]

加权的好处是灵活。你可以根据模型大小、硬件性能、甚至业务优先级来动态调整权重。比如高峰期把重要模型的权重调高,低峰期再调回来。

任务队列设计

调度策略决定了“谁来做”,任务队列决定了“怎么做”。队列设计不好,调度器再牛也白搭。

队列的基本结构

我一般把任务队列设计成三层:

  1. 全局队列:所有请求先进来排队,按优先级排序。
  2. 模型队列:每个模型有自己的队列,调度器从全局队列里把任务分发到对应的模型队列。
  3. 实例队列:每个模型实例(比如 GPU 上的一个副本)有自己的执行队列,真正干活的地方。

为什么要分三层?因为不同层有不同的关注点。全局队列关注优先级和公平性,模型队列关注模型负载,实例队列关注并发控制。

队列的优先级设计

我曾经踩过一个坑:所有请求都放在同一个队列里,结果一个耗时 30 秒的大模型请求把后面所有小模型请求都堵死了。用户体验直接崩了。

后来我改成了多级优先级队列:

class PriorityQueue:
    def __init__(self):
        # 三级优先级
        self.high = deque()   # 实时交互请求
        self.medium = deque() # 普通推理请求
        self.low = deque()    # 批量处理请求
    
    def enqueue(self, request):
        if request.priority == "high":
            self.high.append(request)
        elif request.priority == "medium":
            self.medium.append(request)
        else:
            self.low.append(request)
    
    def dequeue(self):
        # 高优先级队列优先,但防止低优先级饿死
        if self.high:
            return self.high.popleft()
        if self.medium and random.random() < 0.7:
            return self.medium.popleft()
        if self.low and random.random() < 0.3:
            return self.low.popleft()
        return None

注意:优先级队列要防止“饿死”现象。低优先级的请求如果永远得不到处理,用户会骂娘的。我一般会加一个“老化机制”——请求在队列里等得越久,它的优先级就越高。

队列的背压机制

还有一个容易被忽略的点:背压(Backpressure)。当所有队列都满了,新请求怎么办?

我见过最粗暴的做法是直接拒绝。但更好的做法是:

  • 限流:在入口处限制并发请求数,超过阈值就返回 429。
  • 降级:把高精度模型换成低精度模型,或者把大模型换成小模型。
  • 排队告知:告诉用户“你的请求正在排队,预计等待 X 秒”,让用户有心理预期。

核心逻辑流程图

下面这张图是我自己画的一个调度器核心流程,你可以看看整个链路是怎么串起来的。

调度器核心流程 请求入口 全局队列(优先级排序) 调度策略(加权/最少连接) 模型A队列 模型B队列 模型C队列 实例A-1 / A-2 实例B-1 / B-2 实例C-1 / C-2 推理结果返回

这张图里,请求从入口进来,先经过全局队列按优先级排序,然后调度策略决定分给哪个模型,再进入模型队列,最后落到具体的实例队列里执行。每一步都有它的职责,缺一不可。

小结一下

调度器设计没有银弹。集中式还是分布式,看规模。轮询还是加权,看场景。队列设计,看你对延迟和吞吐量的要求。

我个人建议:先跑起来,再优化。别一开始就想搞一个完美的调度器,那是不可能的。先用简单的轮询+单队列跑通,然后根据监控数据逐步迭代。我见过太多团队在调度器上花了三个月,结果业务需求变了,白干。

最后说一句:调度器是系统的“大脑”,但别忘了给大脑装监控。没有监控的调度器,就像闭着眼睛开车——你永远不知道下一秒会撞上什么。


公众号:蓝海资料掘金营,微信deep3321