调度器核心设计:调度器架构模式、调度策略与任务队列
调度器这东西,说白了就是整个系统的“交通指挥员”。你想想看,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"]
加权的好处是灵活。你可以根据模型大小、硬件性能、甚至业务优先级来动态调整权重。比如高峰期把重要模型的权重调高,低峰期再调回来。
任务队列设计
调度策略决定了“谁来做”,任务队列决定了“怎么做”。队列设计不好,调度器再牛也白搭。
队列的基本结构
我一般把任务队列设计成三层:
- 全局队列:所有请求先进来排队,按优先级排序。
- 模型队列:每个模型有自己的队列,调度器从全局队列里把任务分发到对应的模型队列。
- 实例队列:每个模型实例(比如 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 秒”,让用户有心理预期。
核心逻辑流程图
下面这张图是我自己画的一个调度器核心流程,你可以看看整个链路是怎么串起来的。
这张图里,请求从入口进来,先经过全局队列按优先级排序,然后调度策略决定分给哪个模型,再进入模型队列,最后落到具体的实例队列里执行。每一步都有它的职责,缺一不可。
小结一下
调度器设计没有银弹。集中式还是分布式,看规模。轮询还是加权,看场景。队列设计,看你对延迟和吞吐量的要求。
我个人建议:先跑起来,再优化。别一开始就想搞一个完美的调度器,那是不可能的。先用简单的轮询+单队列跑通,然后根据监控数据逐步迭代。我见过太多团队在调度器上花了三个月,结果业务需求变了,白干。
最后说一句:调度器是系统的“大脑”,但别忘了给大脑装监控。没有监控的调度器,就像闭着眼睛开车——你永远不知道下一秒会撞上什么。
公众号:蓝海资料掘金营,微信deep3321