整体架构:三进程 + ZMQ + GPU Worker
谁该读这一篇? 已经知道 SGLang 是什么,想搞清"一次请求在内部到底跨了几个进程、走了几条管道"的工程师。 前置阅读:
01-what-is-sglang.md、00-prerequisites.md§8 那张时序图。 耗时: 35 分钟 学完能: 1. 画出 SGLang 三进程的拓扑,说出每个进程的职责和它们之间的通道; 2. 解释为什么不合并成一个进程(GIL 锁、tokenize CPU 开销、容错隔离); 3. 在源码里找到三个进程的run_*_process入口; 4. 看到一个具体请求时,能默写"HTTP → ... → SSE" 完整链路上每一段的协议; 5. 区分单节点 / 多 TP / Router 多副本三种拓扑,知道每种的进程图长什么样。
1. 鸟瞰图
flowchart LR
subgraph CLI["客户端"]
U["openai / curl / sgl client"]
end
subgraph N0["机器节点(单 GPU 示例)"]
direction TB
subgraph P1["进程 #1: HTTP + TokenizerManager"]
HTTP["FastAPI / Granian<br/>HTTP server"]
TM["TokenizerManager<br/>tokenize / 状态机"]
end
subgraph P2["进程 #2: Scheduler"]
SCH["Scheduler<br/>RadixCache + policy"]
WORK["ModelWorker<br/>(同进程,持 GPU)"]
end
subgraph P3["进程 #3: DetokenizerManager"]
DT["DetokenizerManager<br/>增量 BPE 解码"]
end
GPU["GPU<br/>(model weights + KV)"]
end
U -- "HTTP POST" --> HTTP
HTTP -- "in-proc call" --> TM
TM -- "ZMQ<br/>TokenizedGenerateReqInput" --> SCH
SCH -- "CUDA call" --> WORK
WORK <-- "weights/KV" --> GPU
SCH -- "ZMQ<br/>BatchTokenIDOutput" --> DT
DT -- "ZMQ<br/>BatchStrOutput" --> TM
TM -- "SSE chunk" --> HTTP
HTTP -- "data: ..." --> U
三个进程的职责:
| 进程 | 入口 | 职责 |
|---|---|---|
| #1 HTTP + Tokenizer | http_server.py:launch_server(2335 行附近)tokenizer_manager.py:223 |
HTTP 收发、tokenize、维护 in-flight 请求状态 |
| #2 Scheduler + Worker | scheduler.py:run_scheduler_process(3837) |
RadixCache、调度策略、GPU 前向(同进程内调 ModelWorker,因为 GPU 不能跨进程共享) |
| #3 Detokenizer | detokenizer_manager.py:run_detokenizer_process(426) |
token id → 字符串增量解码 |
⚠️ 经常被问到的混淆点:ModelWorker 不是独立进程。它跟 Scheduler 同进程,因为 PyTorch 的 GPU context(CUDA stream、cudnn handle 等)跨进程切换代价巨大。 真正的"多 GPU"靠 TP(每个 TP rank 一个 Scheduler 进程)+ 一个共享 TokenizerManager / Detokenizer。
2. 为什么是三进程而不是一进程
合并的诱惑:少一次序列化、少几个 ZMQ 端口、调试简单。 但 SGLang 选择不合的核心理由:
2.1 Python GIL
Tokenize 是纯 CPU 工作(HuggingFace tokenizer 内部部分在 Rust,但 Python wrapper 有 GIL)。 如果跟 GPU 调度共线程,GIL 切换会让"GPU 在等 CPU"——SM 利用率掉点。
2.2 异步 I/O 与同步 forward 的天然冲突
- TokenizerManager 处于 asyncio 事件循环里(接 HTTP / SSE 流回写都是 await)。
- Scheduler 是同步 while True 循环(GPU forward 阻塞调用)。
让它俩共存于一个进程要么牺牲异步性、要么牺牲调度的紧凑性。分进程后各取所长。
2.3 显存隔离 / 故障域
GPU OOM、CUDA 异常、模型加载失败等故障局限在 Scheduler 进程内;TokenizerManager 能正确返回 503 而不是整个 server 崩。Detokenizer 同理。
2.4 多 worker / 多副本
TokenizerManager 可以是 N 个副本(通过多 worker HTTP server),Scheduler 通常 1 副本(每个 TP rank),Detokenizer 通常 1 副本。 分进程让这些数量配比成为部署参数。
3. 三个进程之间的协议:ZMQ + dataclass
进程间通信用 ZMQ socket(PUSH/PULL 模式为主),消息体是 io_struct.py 里的 dataclass,通过 pickle 序列化(性能足够,结构体不大)。
主要消息体(节选):
| 消息 | 流向 | 来源 |
|---|---|---|
TokenizedGenerateReqInput |
TM → Scheduler | io_struct.py:720 |
BatchTokenizedGenerateReqInput |
TM → Scheduler(批) | io_struct.py:814 |
BatchTokenIDOutput |
Scheduler → Detokenizer | io_struct.py:1083 |
BatchStrOutput |
Detokenizer → TM | io_struct.py:1155 |
几个值得留意的设计点:
流向是单向的 PUSH/PULL,不是 REQ/REP——避免任何一端慢下来反过来阻塞另一端。
TM 收到 BatchStrOutput 后,按 request_id 找到自己维护的 ReqState(tokenizer_manager.py:142),把字符串塞进 asyncio Queue,HTTP 那头的协程 await 拉取。
关键不变量:每个 request_id 在 TM 里都有一份 ReqState,从请求进来一直到结束都在;ZMQ 上的消息不带状态。
4. 一次请求的全链路时序(带源码索引)
sequenceDiagram
participant C as Client
participant H as HTTP server<br/>(http_server.py)
participant T as TokenizerManager<br/>(tokenizer_manager.py)
participant S as Scheduler<br/>(scheduler.py)
participant W as ModelWorker<br/>(model_runner.py)
participant D as DetokenizerManager<br/>(detokenizer_manager.py)
C->>H: POST /v1/chat/completions { stream: true }
H->>T: generate_request(GenerateReqInput)
Note over T: 1) build ReqState<br/>2) tokenize prompt<br/>3) ZMQ send TokenizedGenerateReqInput
T->>S: ZMQ PUSH
Note over S: 4) match_prefix on RadixCache<br/>5) add to waiting_queue
loop event_loop iteration
S->>S: get_next_batch_to_run()
S->>W: forward_batch_generation(batch)
W-->>S: next_token_ids
S->>D: ZMQ PUSH BatchTokenIDOutput
Note over D: incremental detokenize
D->>T: ZMQ PUSH BatchStrOutput
T->>T: ReqState[rid].text_queue.put(...)
T-->>H: yield SSE chunk (async)
H-->>C: data: {"choices":[...]}
end
Note over S: req finished → release KV<br/>tree_cache ref_count--
S->>D: BatchTokenIDOutput(finished=True)
D->>T: BatchStrOutput(finished=True)
T-->>H: yield SSE [DONE]
H-->>C: data: [DONE]
注意几个容易踩坑的细节:
-
TokenizerManager 是单点:所有请求的 ReqState 都在它的字典里。它崩了整个 server 等于挂。 生产里要么靠多 HTTP worker(每个 worker 一个 TM 副本,需要外层 router 路由)要么靠多副本部署 + 健康检查。
-
Detokenizer 是无状态批处理器:可以多副本并行(一个请求只走一个 Detokenizer,但不同请求可以分散)。
- Scheduler 必须严格按顺序处理某个请求的所有 token:不能并发,否则 token 序列乱。
5. 多 TP 拓扑
flowchart LR
subgraph N["机器节点(TP=4)"]
TM["TokenizerManager"]
DT["Detokenizer"]
subgraph TP0["TP rank 0"]
S0["Scheduler 0"]
G0["GPU 0"]
end
subgraph TP1["TP rank 1"]
S1["Scheduler 1"]
G1["GPU 1"]
end
subgraph TP2["TP rank 2"]
S2["Scheduler 2"]
G2["GPU 2"]
end
subgraph TP3["TP rank 3"]
S3["Scheduler 3"]
G3["GPU 3"]
end
end
TM --> S0
S0 -.NCCL.- S1
S1 -.NCCL.- S2
S2 -.NCCL.- S3
S0 --> DT
S0 -.持有 GPU 0.-> G0
S1 -.持有 GPU 1.-> G1
S2 -.持有 GPU 2.-> G2
S3 -.持有 GPU 3.-> G3
关键设计:
- TP rank 0 是"主":它有完整的请求队列、RadixCache 主副本,发起前向调用。
- 其他 rank 是"从":通过 NCCL 接收 hidden state、做 column/row 切分的部分 matmul、AllReduce 拼回。
- 只有 rank 0 跟 TokenizerManager / Detokenizer 直接通 ZMQ。
- 启动时
--tp 4自动起 4 个 Scheduler 进程,rank 用环境变量传。
代码入口:run_scheduler_process(3837 行附近),看 tp_rank 字段就明白。
TP 的工作机制本身在
05-distributed/01-tensor-parallelism.md,本章只画拓扑。
6. Router 多副本拓扑
flowchart LR
C[Client] --> R[sgl-router<br/>Rust]
R --> N1[节点 1<br/>SGLang server]
R --> N2[节点 2<br/>SGLang server]
R --> N3[节点 3<br/>SGLang server]
R <-. cache hit rate metrics .- N1
R <-. cache hit rate metrics .- N2
R <-. cache hit rate metrics .- N3
关键设计:
- Router 在前面,监听一个统一端口,做 cache-aware 路由(哪个节点对当前请求 prefix 命中最长,就路由过去)。
- 每个节点是独立的完整 SGLang server(自带自己的 RadixCache)。
- 节点之间不共享 KV(多副本独立显存)。
- Router 是用 Rust 写的(
sglang/rust/或sgl-router/),因为它要承受高 QPS。
Router 细节见
08-production-deployment/07-router.md。
7. 启动一个最小拓扑(命令行)
# 单 GPU、单节点
python -m sglang.launch_server \
--model-path Qwen/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 30000
# 4 卡 TP
python -m sglang.launch_server \
--model-path meta-llama/Llama-3.1-70B-Instruct \
--tp 4 \
--port 30000
# 加 router(多副本前)
python -m sglang.launch_server --model-path ... --port 30001 &
python -m sglang.launch_server --model-path ... --port 30002 &
sglang-router --worker-urls http://localhost:30001 http://localhost:30002 --port 30000
启动后这些进程的关系(用 ps -ef | grep sglang):
sglang.launch_server # 主进程,起 HTTP + TM
├── scheduler_tp0 # Scheduler #1(TP rank 0)
├── scheduler_tp1 # TP rank 1
├── scheduler_tp2 # TP rank 2
├── scheduler_tp3 # TP rank 3
└── detokenizer # Detokenizer
8. 三种典型故障与"该看哪个进程"
| 现象 | 第一看 | 可能原因 |
|---|---|---|
| 请求 500 / 完全没响应 | HTTP server 日志、TM 日志 | TM 卡死、ZMQ 没通 |
| 请求挂起、客户端 timeout | Scheduler 日志、nvidia-smi |
GPU OOM、NCCL hang、CUDA Graph 异常 |
| 输出乱码 / 截断 | Detokenizer 日志 | tokenizer 不匹配、BPE 状态污染 |
| 慢、TPOT 高 | Scheduler 指标 | cache miss 高、batch 太小、抢占多 |
这张表是排障 cheatsheet,记住。
9. 小结
- SGLang 三进程:HTTP+Tokenizer / Scheduler+Worker / Detokenizer,ZMQ 串联。
- ModelWorker 与 Scheduler 同进程(GPU context 不跨进程)。
- TP 时每个 rank 一个 Scheduler 进程,rank 0 与外界通信。
- Router 是更高一层的多副本前置,做 cache-aware 路由。
- 故障排查时先按"现象 → 该看哪个进程"对应表定位。
10. 自检
- 为什么 TM 和 Scheduler 不合一进程?
答案
三个独立原因:(a) **GIL** —— TM 的 tokenize 是 Python CPU 工作,和 Scheduler 的 GPU 调度共线程会抢 GIL,让 GPU 等 CPU;(b) **运行模型不同** —— TM 是 asyncio 事件循环(HTTP/SSE),Scheduler 是同步 while True(GPU forward 阻塞),合一就要牺牲一边;(c) **故障域隔离** —— GPU OOM / NCCL hang 只挂 Scheduler,TM 还能正常返回 503 而不是整 server 崩。- 同 batch 内一个请求的 token 能不能在两个 Detokenizer 副本并发处理?为什么?
答案
不能。Detokenize 必须**严格按 token 顺序**:BPE 解码状态机([`DecodeStatus`](../sglang/python/sglang/srt/managers/detokenizer_manager.py))维护 `read_offset` / `sent_offset` / `surr_offset`,token N+1 的解码依赖 N 之前积累的 byte buffer(多字节 UTF-8 / surrogate 处理)。 不同请求之间可以并发;同请求的不同 token 不行。这也是为什么 multi-tokenizer 模式下 Detokenizer 用 `SocketMapping` 按 rid 路由——保证同 rid 一定走同一进程。- 4 卡 TP 部署,rank 1 进程 crash 会怎样?
答案
NCCL 集合通信永远等不到 rank 1,rank 0/2/3 全部 hang 在 AllReduce 上 → 看起来是"GPU 利用率 100%、没产 token"。 主进程的 `SubprocessWatchdog` 会检测到 rank 1 进程退出,进而把整个 server 一起 fail(避免假活)。 K8s 看到主进程退出 → 重启整 Pod。**TP 没有 partial recovery**,一卡死全卡死。这也是为什么生产更倾向 "多副本 DP × 小 TP" 而不是 "单副本大 TP"。- Router 节点和后端节点是否共享 RadixCache?
答案
**不共享**。每个后端 server 各有自己的 RadixCache(持有真实 KV 张量在自己 GPU 上)。 Router 只持有每个后端的 **cache 摘要**(Bloom filter / radix tree 摘要),用来做近似 prefix 匹配做路由决策;它**没有 KV**。 后果:同 system prompt 在 N 副本之间各算一遍(除非 router 做 sticky)。Router 用 cache-aware 路由 + sticky session 缓解,但本质 KV 不共享。想真正共享要走 KV transfer(disagg)或共享 CPU 镜像(HiCache + 共享存储),目前都不便宜。- ZMQ 用的是哪种 socket pattern?为什么不用 REQ/REP?
答案
PUSH/PULL(单向流)。 不用 REQ/REP 的原因:REQ/REP 严格请求-应答配对,**任一边慢就阻塞另一边**——Detokenizer 慢一拍,Scheduler 就堵在等 ACK 上,整条流水线停摆。PUSH/PULL 是 fire-and-forget 单向,每端按自己节奏跑,背压靠队列水位和上层逻辑控制。 代价:没有应答,错误只能靠 watchdog + 心跳兜底,不能像 REQ/REP 那样立刻知道对端处理结果。11. 下一步
03-sglang-vs-vllm.md— 对比 vLLM 的进程拓扑(v1 也是多进程)。04-project-structure.md—sglang/python/sglang/srt/1000+ 文件的模块地图。03-code-walkthrough/01-server-entry.md— 从launch_server.py一步步进入。03-code-walkthrough/02-tokenizer-manager.md— TM 内部。03-code-walkthrough/03-scheduler.md— Scheduler 主循环。
上游源码:
sglang/python/sglang/launch_server.py、srt/entrypoints/http_server.py、srt/managers/。