跳转至

第 5 章:Scheduler——把显存、并发和生命周期变成队列问题

一、为什么 handler 不能直接 NewLlamaServer

一个请求要用模型,并不等于应该为它启动一个新进程。真实约束包括:

  • 同一个模型已经在内存中,应该复用。
  • 多个请求可能共享 runner 的 slots。
  • 新模型加载前,可能必须卸载旧模型释放显存。
  • CPU/GPU 混合卸载要依据设备能力决定。
  • 自动 context/batch 可能需要在 OOM 后降低再试。
  • keep_alive 决定 runner 是否在空闲后保留。

这些都是跨请求的状态,所以放在 server/Scheduler 中集中管理。Scheduler 的主要类型和循环见 server/sched.go

二、核心对象

LlmRequest
  ├── model / options / keepAlive
  ├── completion / embedding / vision 等请求属性
  ├── response channel
  └── runner channel

Scheduler
  ├── pending requests
  ├── loaded runners
  ├── completed requests
  ├── systemInfo / GPU snapshot
  └── wake/signal channels

runnerRef
  ├── model
  ├── llama.LlamaServer
  ├── session expiry
  ├── busy slots
  └── memory placement

准确字段会随版本变化,但设计关系稳定:请求是短生命周期,runner 是长生命周期,Scheduler 负责把二者绑定和解绑。

三、调度循环

stateDiagram-v2
    [*] --> Pending
    Pending --> ReuseLoaded: 找到可用 runner
    Pending --> Load: 没有 runner 或需换模型
    Load --> Ready: 进程启动并加载成功
    Load --> RetryWithSmallerContext: 自动参数导致内存不足
    RetryWithSmallerContext --> Ready
    Load --> Failed: 无法加载
    Ready --> Running: 分配 slot
    ReuseLoaded --> Running
    Running --> Completed: 请求完成/取消
    Completed --> KeepAlive: keep_alive 未到期
    Completed --> Unload: 过期或回收
    KeepAlive --> Running: 新请求复用
    Unload --> Pending: 资源释放后继续排队

Scheduler.Run 启动处理循环;processPending 负责决定谁可以被加载/运行,processCompleted 负责释放、更新状态和继续排队。读源码时要特别区分“请求完成”和“runner 卸载”:两者不是同一事件。

四、getRunner 的等待语义

scheduleRunner 不是同步调用 getRunner 返回一个对象,而是等待 runnerCherrCh。这种 channel 设计表达了一个重要事实:runner 可能要等,等待期间 handler 可以阻塞当前请求,但不会阻塞整个 HTTP server。

如果队列达到 OLLAMA_MAX_QUEUE,请求会在进入真正加载前被拒绝;如果 runner 已加载但没有 slot,Scheduler 可以让请求保持 pending,而不是重新启动模型。

五、模型匹配与复用

Scheduler 会用模型标识和关键 options 计算 runner key。复用不能只看模型名,因为以下参数可能改变 runner 的兼容性:

  • context length
  • batch size / parallel slots
  • flash attention 与 KV cache 类型
  • draft model / speculative decoding
  • projector、adapter、vision 配置
  • context shift

这就是为什么 schedulerModelKeyeffectiveContextresolveContextShift 等函数值得单独读:它们把“请求参数”翻译成“运行时可复用性”。

六、显存不足时的自动降级

当用户没有显式设置 num_ctxnum_batch 时,Scheduler 可以预测显存并选择一个自动值。如果加载失败,reduceAutoNumCtxForLoadOOM 会尝试降低自动 context;这只对自动值生效,避免悄悄改变用户明确指定的资源要求。

显式 num_ctx
  → 失败就报告,不能擅自改语义

自动 num_ctx
  → 预测/加载失败
  → 降低 context
  → 重新计算 batch / VRAM
  → 再尝试

这是一种“可用性优先但尊重显式配置”的降级策略。

七、keep-alive 与回收

请求结束时 runner 通常进入可复用状态,附带一个过期时间。keep_alive 为 0 时立即卸载;正值则保留;某些默认值由 envconfig.KeepAlive() 控制。

回收不仅释放进程,还要更新:

  • loaded model 列表
  • GPU/VRAM 占用
  • /api/ps 结果
  • 等待中的请求可否继续

八、为什么 Scheduler 是独立包内的状态机

如果每个 handler 自己管理 runner,会出现:

  • /api/chat/api/embed 各自加载同一模型。
  • 一个请求卸载了另一个请求仍在使用的进程。
  • OOM 重试策略不一致。
  • API 取消时没有可靠地停止子进程。

Scheduler 让这些动作具有单一所有者;handler 只提交 LlmRequest 意图。这也是 Ollama 从“命令行启动一个模型”成长为“多客户端本地服务”的关键一步。

九、阅读练习

按以下顺序阅读,比直接扫 900 行调度代码更有效:

  1. LlmRequestSchedulerrunnerRef 类型。
  2. InitSchedulerRun
  3. getRunner 的请求入队处。
  4. processPending 中“复用 vs 加载”的分支。
  5. load 中设备探测、OOM 降级和 runner 启动。
  6. processCompletedexpireRunner