<aside> 🎯

这一页的目标很简单:把一个请求从「客户端发出」到「最后一个 token 回来」的全链路讲清楚,先把骨架搭起来。后面每一页讲的参数,都是往这副骨架上挂的肌肉。

</aside>

1. 进程模型:三类进程 + ZMQ

SGLang 把「接入层、调度计算层、解码回传层」拆成不同进程,进程间用 ZMQ 传消息,目的是让 CPU 侧的 tokenize、detokenize、HTTP 处理,不阻塞 GPU 的调度循环。

flowchart LR
  C["Client<br>HTTP / gRPC / Engine API"] --> H["HTTP Server<br>FastAPI + Uvicorn"]
  H --> T["TokenizerManager<br>主进程 asyncio<br>chat template / tokenize / 多模态预处理"]
  T -- "ZMQ 发送已 tokenize 请求" --> S["Scheduler<br>每个 TP rank 一个进程<br>调度 + 显存管理 + 前缀缓存"]
  S --> W["TpModelWorker → ModelRunner<br>forward / CUDA Graph / Sampler"]
  W --> S
  S -- "ZMQ 发送 token id 批" --> D["DetokenizerManager<br>增量解码 + 截掉命中的 stop"]
  D -- "ZMQ 发送文本批" --> T
  T --> H
  H -- "SSE 或 JSON" --> C

参数挂钩(都来自官方文档):

环节 相关参数 说明
接入 --host --port --enable-http2 --grpc-mode --api-key --admin-api-key --enable-http2 会把 ASGI 服务器从 Uvicorn 换成 Granian;--grpc-mode 用 gRPC 取代 HTTP
Tokenize --tokenizer-worker-num --tokenizer-backend --skip-tokenizer-init 高并发下 tokenizer 可能成为 CPU 瓶颈,可以增加 worker 或换 fastokens 后端;--skip-tokenizer-init 表示调用方直接传 input_ids
Detokenize --detokenizer-worker-num --stream-interval --incremental-streaming-output 控制解码回传的并行度与流式粒度
跨进程开销 --batch-notify-size 官方说明:多少个流式通知打包一次再让出事件循环,高并发下减少 asyncio 唤醒开销(默认 16)

2. 一个请求的完整时序

sequenceDiagram
  participant C as Client
  participant H as HTTP Server
  participant T as TokenizerManager
  participant S as Scheduler
  participant M as ModelRunner
  participant D as Detokenizer
  C->>H: POST /v1/chat/completions, stream=true
  H->>T: generate_request
  T->>T: chat template + tokenize + 参数校验
  T->>S: ZMQ 发送 tokenized 请求
  S->>S: 入 waiting_queue, 前缀匹配, 分配 KV
  loop 每个 step
    S->>M: ForwardBatch, prefill 或 decode
    M-->>S: logits → sample → next token
    S->>S: 追加 token, 判断结束, filter batch
    S->>D: token id 批, 每 stream-interval 个 token
    D->>T: 增量文本
    T-->>H: yield chunk
    H-->>C: SSE data
  end
  H-->>C: data: DONE

对应到「输入、输出」的文字版

输入侧要走四步。客户端发 HTTP 请求(原生 /generate、OpenAI 兼容 /v1/chat/completions、Anthropic 兼容 /v1/messages、Ollama 兼容接口都能接);TokenizerManager 套 chat template(--chat-template、--hf-chat-template-name、--default-chat-template-kwargs)、做 tokenize、填默认采样参数(--sampling-defaults 默认 model,即读模型的 generation_config.json);通过 ZMQ 发给 Scheduler,进入 waiting_queue;Scheduler 在 Radix Tree 里查前缀命中,决定这个请求真正需要算的 token 数,再由 PrefillAdder 在预算内把它装进 prefill batch。

计算侧分两步。ModelRunner 跑 prefill(又叫 extend),得到第一个 token;之后请求并入 running_batch,每个 decode step 多产 1 个 token。每步结束后 Scheduler 处理结果:追加 token、判断是否结束、把完成的请求从 batch 中滤掉并释放 KV。

输出侧也是三步。token id 每 --stream-interval 个打包发给 Detokenizer,做增量解码(处理多字节字符、模型特殊 token);文本回到 TokenizerManager,经过 reasoning parser、tool call parser(--reasoning-parser、--tool-call-parser)整理成 API 格式;HTTP 层用 SSE 一块一块推给客户端,最后以 data: [DONE] 结束。如果请求没开 stream,则等全部生成完一次返回 JSON。

3. Scheduler 事件循环(骨架中的骨架)

flowchart TD
  A["recv_requests<br>从 ZMQ 收新请求"] --> B["process_input_requests<br>入 waiting_queue"]
  B --> C["get_next_batch_to_run"]
  C --> C1{"waiting_queue<br>有可 prefill 的请求?"}
  C1 -- "是" --> P["排序 + PrefillAdder 准入<br>组 prefill/extend batch"]
  C1 -- "否" --> DEC["对 running_batch 做 decode<br>显存不够则 retract"]
  P --> R["run_batch<br>ModelRunner.forward"]
  DEC --> R
  R --> PR["process_batch_result<br>追加 token / 判断结束 / 发给 Detokenizer"]
  PR --> A

默认是 prefill 优先:有新请求能装下,就先跑一个 prefill batch,完成后并入 running_batch。Overlap scheduler 默认开启(--disable-overlap-schedule 关闭):CPU 在处理第 N 步结果的同时,GPU 已经在跑第 N+1 步,把调度开销隐藏掉。代价是结束判断晚一拍,可能多算一个无效 token(会被丢弃)。❗官方文档确认投机解码 V2 worker 默认配合 overlap 使用,且目前 overlap 下只支持 --speculative-eagle-topk 1。

3.1 先把四个 batch 变量分清楚

第一次读 scheduler.py 时最容易晕的,是 waiting_queue、new_batch、running_batch、cur_batch、last_batch 这几个名字。它们其实都是 ScheduleBatch(或者一组 Req),区别只是「这个请求现在处在哪个阶段」。我自己的记法是把它们当成请求的几个座位:

变量 里面装的是谁 容易误解的点
waiting_queue 还没开始 prefill 的新请求,以及被 retract 回来的请求 它是一个会被反复重排的 list,每轮按 schedule policy 排一次序
new_batch 本轮要做 prefill / extend 的请求 叫 prefill_batch 其实更贴切
running_batch 已经 prefill 完、在 decode 的请求 叫 decode_batch 更贴切;KV 不够时从这里 retract
cur_batch 本轮真正丢给 GPU 的那个 batch 有 new_batch 就是它,没有才轮到 running_batch,这就是「prefill 优先」
last_batch 上一轮的 cur_batch 如果上一轮是 extend,本轮开头要先把它 merge 进 running_batch
chunked_req 正在被 chunked prefill 切块的那个长请求 它既不在 waiting_queue(已经开跑了),也不在 running_batch(还没 prefill 完),是单独挂在 Scheduler 上的

所以一个新请求的完整路径是 waiting_queue → new_batch(本轮)→ last_batch(下一轮开头)→ merge 进 running_batch;而已经在 decode 的请求,每一轮都是 running_batch → cur_batch → running_batch。把这条路径记住,get_next_batch_to_run 的代码就很好读了:

def get_next_batch_to_run(self):
    # 上一轮是 extend,说明有一批请求刚 prefill 完,先并进 decode 队伍
    if self.last_batch and self.last_batch.forward_mode.is_extend():
        self.running_batch.merge_batch(self.last_batch)
    # prefill 优先:能组出 prefill batch 就先跑它
    new_batch = self.get_new_batch_prefill()
    if new_batch:
        return new_batch
    # 否则过滤掉已结束的请求,必要时 retract,然后跑 decode
    return self.update_running_batch(self.running_batch)