预计阅读 8 分钟

整体架构:三进程 + ZMQ + GPU Worker

谁该读这一篇? 已经知道 SGLang 是什么,想搞清"一次请求在内部到底跨了几个进程、走了几条管道"的工程师。 前置阅读: 01-what-is-sglang.md00-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 找到自己维护的 ReqStatetokenizer_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]

注意几个容易踩坑的细节

  1. TokenizerManager 是单点:所有请求的 ReqState 都在它的字典里。它崩了整个 server 等于挂。 生产里要么靠多 HTTP worker(每个 worker 一个 TM 副本,需要外层 router 路由)要么靠多副本部署 + 健康检查。

  2. Detokenizer 是无状态批处理器:可以多副本并行(一个请求只走一个 Detokenizer,但不同请求可以分散)。

  3. 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. 自检

  1. 为什么 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 崩。
  1. 同 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 一定走同一进程。
  1. 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"。
  1. 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 + 共享存储),目前都不便宜。
  1. ZMQ 用的是哪种 socket pattern?为什么不用 REQ/REP?
答案 PUSH/PULL(单向流)。 不用 REQ/REP 的原因:REQ/REP 严格请求-应答配对,**任一边慢就阻塞另一边**——Detokenizer 慢一拍,Scheduler 就堵在等 ACK 上,整条流水线停摆。PUSH/PULL 是 fire-and-forget 单向,每端按自己节奏跑,背压靠队列水位和上层逻辑控制。 代价:没有应答,错误只能靠 watchdog + 心跳兜底,不能像 REQ/REP 那样立刻知道对端处理结果。

11. 下一步

上游源码:sglang/python/sglang/launch_server.pysrt/entrypoints/http_server.pysrt/managers/