TokenizerManagerSchedulerDetokenizerManager 的职责边界#

这一节把最关键的 manager 边界讲清楚。它们不是简单的功能模块拼盘,而是一组为了把请求主链拆成可维护 handoff 点而设计出来的运行时边界。

这一节解决什么问题#

第三章已经把请求主链走通了,但还留下一个更基础的问题:为什么非得拆成 TokenizerManagerSchedulerDetokenizerManager 三个 manager?如果只是“功能不同”这么简单,其实完全可以塞进一个大 process 里慢慢分函数写。

这一节真正要回答的是:

  1. 三个 manager 各自托管了什么状态;
  2. 为什么这些状态不能都堆在同一个对象里;
  3. 后面读调度、回包和调试时,应该先回到哪一个边界上。

一张图先看三条边界#

flowchart LR
    A["TokenizerManager<br/>请求接入 / 状态宿主 / tokenization"] --> B["Scheduler<br/>Req / queue / batch / runtime gate"]
    B --> C["DetokenizerManager<br/>token ids -> text delta"]
    C --> A

这张图更强调边界职责,而不是流向。三个 manager 不是主链上的三个“顺路函数”,而是三层不同的状态托管面。

TokenizerManager 站在 API server 一侧#

TokenizerManager 的初始化顺序已经很能说明它的定位:

  • ServerArgs
  • 初始化 tokenizer / multimodal processor
  • 建 IPC 通道
  • 建运行时状态
  • 建日志、LoRA 和 weight update 相关状态
  • 最后建 request dispatcher

这意味着它托管的不只是 tokenize,还包括:

  • rid_to_state
  • 请求日志
  • LoRA 状态
  • request metrics
  • 从 detokenizer 回来的结果收口

所以 TokenizerManager 更像 API server 一侧的“请求状态与边界协调者”,而不是单纯的 tokenizer 封装。

Scheduler 站在真正的 runtime 工作面#

Scheduler 的初始化字段和 mixin 则说明,它承担的是另一类工作:

  • rank / parallel state
  • scheduling policy
  • running batch / waiting queue
  • cache / memory / grammar / profiler / metrics 相关 mixin

这说明 Scheduler 关心的不是请求表面的样子,而是:

  • 哪些请求能进 queue;
  • 哪些请求能成 batch;
  • 哪些请求需要被 abort / retract / 放进 grammar queue;
  • 哪些请求占着什么样的 runtime 资源。

这也是为什么第三章里 Req 的构造发生在这里,而不是更前一层。

DetokenizerManager 站在返回链上#

DetokenizerManager 的边界是三者里最窄的,但也最容易被低估。它只做一件事:把 scheduler 输出的 token ids 重新变成文本增量,再把结果送回 tokenizer manager。

它不负责:

  • 请求接入;
  • waiting queue / batch 决策;
  • route 层响应格式。

但它负责增量 decode、字符串边界、不完整字符和 stop trim。换句话说,它是返回链里的“文本化边界”。

为什么不能把三层合并成一个大 manager#

如果把这三层合并到一起,至少会马上失去三类边界:

  1. API server 侧请求状态与 runtime 侧排队状态的边界;
  2. token 级运行时输出与文本级返回输出的边界;
  3. 入口控制逻辑与 batch / cache / grammar 决策的边界。

当前拆法的好处在于:

  • TokenizerManager 负责“接请求、托状态、接结果”;
  • Scheduler 负责“排队、塑形、驱动执行”;
  • DetokenizerManager 负责“把 token 结果变成文本结果”。

这套拆法并不追求抽象优雅,而是把最容易一起膨胀的职责先分开。

后面读代码时先回到哪一层#

这三层边界一旦稳定下来,后面的阅读顺序也会更清楚:

  • 问题发生在请求接入、rid_to_state、streaming 收口:先看 TokenizerManager
  • 问题发生在 queue、batch、timeout、grammar、资源占用:先看 Scheduler
  • 问题发生在文本增量、字符边界、stop trim:先看 DetokenizerManager

这个判断对后面读调试章节尤其重要,因为很多线上现象并不是“整个系统都坏了”,而是某一层边界的职责出了偏差。

IPC 通道是怎样连接三个 manager 的#

三个 manager 跑在不同的进程里,进程间通信靠 ZeroMQ。每条通信边都有方向性:一端 bind(绑定),另一端 connect(连接)。下图是默认单 tokenizer 拓扑下三条 ZMQ 边的全貌:

┌─────────────────────────────────────────────────────┐
│  TokenizerManager(主进程)                           │
│                                                     │
│  send_to_scheduler  ──PUSH──►  recv_from_tokenizer  │
│                                                     │
│  recv_from_detokenizer  ◄──PUSH──  send_to_tokenizer│
└─────────────────────────────────────────────────────┘
         ▲                            │
         │                            │
  PULL   │                      PUSH  │
         │                            ▼
┌────────┴────────────┐    ┌──────────────────────────┐
│  Scheduler(子进程) │    │ DetokenizerManager(子进程)│
│                     │    │                           │
│  recv_from_tokenizer│    │  recv_from_scheduler      │
│  send_to_detokenizer├───►│  send_to_tokenizer        │
└─────────────────────┘    └──────────────────────────┘

三条 ZMQ 边、六个 socket、一个环形方向:

发送端 socket接收端 socketZMQ 类型
请求入 schedulersend_to_scheduler (PUSH)recv_from_tokenizer (PULL)PUSH/PULL
token ids 入 detokenizersend_to_detokenizer (PUSH)recv_from_scheduler (PULL)PUSH/PULL
文本结果回 tokenizersend_to_tokenizer (PUSH)recv_from_detokenizer (PULL)PUSH/PULL

socket 命名与路径#

每条边的端点由 PortArgs 集中命名,不是散落在各 manager 里的硬编码字符串。三条核心 IPC 边对应三个字段:

# sglang/srt/utils.py  PortArgs
@dataclass
class PortArgs:
    tokenizer_ipc_name: str            # detokenizer → tokenizer
    scheduler_input_ipc_name: str      # tokenizer  → scheduler
    detokenizer_ipc_name: str          # scheduler  → detokenizer
    rpc_ipc_name: str                  # RPC 控制通道
    ...

PortArgs.init_new(...) 会在进程拉起前为每条边生成路径。非 DP attention 场景下,路径是本机 IPC socket:

ipc:///tmp/sglang_{hash}_tokenizer
ipc:///tmp/sglang_{hash}_scheduler_input
ipc:///tmp/sglang_{hash}_detokenizer

{hash} 由端口号和随机数共同生成,用来避免同机多实例之间的端点冲突。开启 DP attention 以后,IPC 路径会切换成 tcp://127.0.0.1:{port} 形式,因为 DP attention 场景下需要跨 worker 通信,本机 domain socket 不够用。

消息序列化#

三条边上传输的对象都通过 send_pyobj / recv_pyobj 发送,底层是 Python pickle。每条边传输的是定义好的 Python dataclass,而不是裸字节流:

  • tokenizer → scheduler:TokenizedGenerateReqInput(含 token ids、参数、rid
  • scheduler → detokenizer:BatchTokenIDOutput(含 token ids 批次、finish reason、时间戳)
  • detokenizer → tokenizer:BatchStrOutput(含文本增量、token ids、时间统计)

事件循环对应关系#

每个 manager 都有一个事件循环轮询自己的 recv socket。DetokenizerManager 最简单,主循环只有三行:

recv_obj = self.recv_from_scheduler.recv_pyobj()
output = self._request_dispatcher(recv_obj)
if output is not None:
    self.send_to_tokenizer.send_pyobj(output)

TokenizerManagerhandle_loop 则跑在 asyncio 里,持续从 recv_from_detokenizer 拉结果并回填 rid_to_stateScheduler 的事件循环最复杂,因为它还要同时轮询多条边(recv_from_tokenizer、RPC 通道等)并推进调度步。

rid_to_state 是什么#

TokenizerManager 上有一个字典:

self.rid_to_state: Dict[str, ReqState] = {}

它是 API server 侧请求状态的唯一宿主。每进来一条请求,就会在这里插入一条记录;请求完成被消费以后,再从这里删掉。它的 key 是 rid(request id,一个 UUID 字符串),value 是 ReqState

ReqState 的字段#

@dataclasses.dataclass
class ReqState:
    out_list: List[dict]       # 已返回但尚未被 yield 走的输出片段队列
    finished: bool             # 请求是否已完成(scheduler 侧已发出 finish reason)
    event: asyncio.Event       # 有新片段到达时 set,等待器 await 这个事件
    obj: GenerateReqInput      # 原始请求参数(含 sampling params、tools 等)

这四个字段分别承担四类职责:

  • out_list:环形缓冲的角色。detokenizer 把新文本增量写进来,_wait_one_response 把它消费走。streaming 模式下每个片段对应一条 SSE chunk,non-streaming 模式下所有片段拼完才构造一条完整响应。
  • finished:终止信号。scheduler 发出 finish reason 以后,_handle_batch_output 会把它置为 True,告诉等待器不必再等下一条 event
  • event:进程内通知机制。每次有新片段写入 out_listevent.set() 就唤醒正在 await state.event.wait() 的协程。协程消费完以后调用 event.clear() 清除信号,再次进入等待。
  • obj:透传容器。tokenizer manager 没有把请求参数全量拷贝给 scheduler,但自己仍然需要原始参数(比如构造 logprob、tool call 格式),所以一直挂在这里。

streaming 过程中的状态变化#

以一条有三个增量的流式请求为例,rid_to_state["req_abc123"] 在各阶段的快照:

阶段 1:请求刚进来,等待第一个 token

ReqState(
    out_list=[],
    finished=False,
    event=<asyncio.Event not set>,
    obj=GenerateReqInput(text="你好", sampling_params=...),
)

阶段 2:第一个增量到达(detokenizer 回了 “你”)

ReqState(
    out_list=[
        {"text": "你", "output_ids": [15165], "meta_info": {...}},
    ],
    finished=False,
    event=<asyncio.Event set>,   # 已被 set,等待器即将被唤醒
    obj=...,
)

阶段 3:请求完成,最后一个增量到达

ReqState(
    out_list=[
        {"text": "好", "output_ids": [3749], "meta_info": {"finish_reason": "stop", ...}},
    ],
    finished=True,               # scheduler 侧已结束
    event=<asyncio.Event set>,
    obj=...,
)

阶段 4:所有片段被 _wait_one_response 消费完,条目从字典里删除

# rid_to_state.pop("req_abc123") 已经调用

non-streaming 请求的过程完全相同,唯一的差别是 serving 层的消费方式:_handle_non_streaming_request 只调用一次 __anext__(),拿到的是所有片段拼完之后的完整结果,而不是逐片段 yield。

为什么状态放在 TokenizerManager 而不是 Scheduler#

Scheduler 处于独立子进程,它只维护请求在 runtime 侧的调度状态(Req 对象、waiting queue、running batch)。一旦请求完成,这些状态都会被释放。API server 侧的协程需要一个能在同进程内 await 的信号量(asyncio.Event),以及一个能累积增量结果的 list,而跨进程的 ZMQ socket 没法提供这种进程内的 asyncio 通知语义。所以请求状态必须在 TokenizerManager 这一侧维护,而不是放在 scheduler。

启动顺序与初始化依赖#

三个 manager 跑在三个进程里,但它们的 ZMQ socket 有 bind/connect 的顺序依赖:只有 bind 端先起来,connect 端才能成功连接。

谁先 bind,谁后 connect#

在默认 IPC 模式下(非 DP attention):

  • Scheduler bind scheduler_input_ipc_name(收 tokenizer 请求的端点)
  • Scheduler bind detokenizer_ipc_name(向 detokenizer 发 token ids 的端点)
  • TokenizerManager connect scheduler_input_ipc_name(向 scheduler 发请求)
  • TokenizerManager bind tokenizer_ipc_name(收 detokenizer 回来结果的端点)
  • DetokenizerManager connect detokenizer_ipc_name(从 scheduler 收 token ids)
  • DetokenizerManager connect tokenizer_ipc_name(向 tokenizer 回文本结果)

所以实际的 bind 先后顺序是:Scheduler bind 两条边 → TokenizerManager bind 一条边 → DetokenizerManager 全部 connect。

Engine._launch_subprocesses 怎样协调这个顺序#

Engine._launch_subprocesses(...) 是统一的进程装配入口。它用两种机制来保证顺序:

第一种:显式 barrier

scheduler 进程和 detokenizer 进程在初始化完成、socket 绑定好之后,会向主进程发送就绪信号(通过 mp.BarrierPipe)。主进程等到这两个信号都到齐以后,才继续完成 TokenizerManager 的 IPC 连接,再把整个 engine 交给 HTTP server。

第二种:进程启动顺序

_launch_subprocesses 的调用顺序可以看到,scheduler 进程是第一个被 mp.Process.start() 的:

# Engine._launch_subprocesses(简化版,保留关键顺序)

# 1. 生成 PortArgs,命名所有端点
port_args = PortArgs.init_new(server_args)

# 2. 先启动 Scheduler 子进程(bind 端先起来)
scheduler_proc = mp.Process(
    target=run_scheduler_process,
    args=(server_args, port_args, ...),
)
scheduler_proc.start()

# 3. 再启动 DetokenizerManager 子进程
detokenizer_proc = mp.Process(
    target=run_detokenizer_process,
    args=(server_args, port_args, ...),
)
detokenizer_proc.start()

# 4. 等待子进程完成 socket bind 并发出就绪信号
# (通过 pipe 或 event 同步)
scheduler_ready_event.wait()
detokenizer_ready_event.wait()

# 5. 最后初始化 TokenizerManager(connect 端最后建立)
tokenizer_manager = TokenizerManager(server_args, port_args, ...)

整个初始化依赖链如下:

PortArgs 命名所有端点
         │
         ▼
Scheduler 启动 → bind scheduler_input, detokenizer IPC 端点
         │
         ▼  (就绪信号)
DetokenizerManager 启动 → connect detokenizer IPC 端点
         │
         ▼  (就绪信号)
TokenizerManager 初始化 → connect scheduler_input IPC 端点
                        → bind tokenizer IPC 端点
         │
         ▼
HTTP server 开始监听(uvicorn)

如果顺序乱了会发生什么#

ZMQ 的 connect 调用本身不会立即失败——ZMQ 内部会重试连接。但如果 TokenizerManagerScheduler bind 完之前就开始发送请求,消息会在 ZMQ 内部队列里堆积,直到连接建立才被消费。更危险的情况是 scheduler 进程崩溃或初始化卡住:此时 TokenizerManager 发出的请求永远不会被接收,服务表面上启动成功,但所有请求都会超时。

这也是为什么 _launch_subprocesses 里有明确的就绪信号同步,而不是简单地 time.sleep(1) 等一下。

小结#

这一节真正要建立的是一个简单但稳定的判断:

  • TokenizerManager 托管请求状态和接入边界;
  • Scheduler 托管运行时工作单元和调度边界;
  • DetokenizerManager 托管返回链里的文本化边界。

只要这个三层判断稳住,后面第四章剩下的 IPC 拓扑、第五章的调度与内存,以及第八章的调试路径都会更容易落到正确层上。