TokenizerManager、Scheduler 与 DetokenizerManager 的职责边界#
这一节把最关键的 manager 边界讲清楚。它们不是简单的功能模块拼盘,而是一组为了把请求主链拆成可维护 handoff 点而设计出来的运行时边界。
这一节解决什么问题#
第三章已经把请求主链走通了,但还留下一个更基础的问题:为什么非得拆成 TokenizerManager、Scheduler 和 DetokenizerManager 三个 manager?如果只是“功能不同”这么简单,其实完全可以塞进一个大 process 里慢慢分函数写。
这一节真正要回答的是:
- 三个 manager 各自托管了什么状态;
- 为什么这些状态不能都堆在同一个对象里;
- 后面读调度、回包和调试时,应该先回到哪一个边界上。
一张图先看三条边界#
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#
如果把这三层合并到一起,至少会马上失去三类边界:
- API server 侧请求状态与 runtime 侧排队状态的边界;
- token 级运行时输出与文本级返回输出的边界;
- 入口控制逻辑与 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 | 接收端 socket | ZMQ 类型 |
|---|---|---|---|
| 请求入 scheduler | send_to_scheduler (PUSH) | recv_from_tokenizer (PULL) | PUSH/PULL |
| token ids 入 detokenizer | send_to_detokenizer (PUSH) | recv_from_scheduler (PULL) | PUSH/PULL |
| 文本结果回 tokenizer | send_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)TokenizerManager 的 handle_loop 则跑在 asyncio 里,持续从 recv_from_detokenizer 拉结果并回填 rid_to_state。Scheduler 的事件循环最复杂,因为它还要同时轮询多条边(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_list,event.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先 bindscheduler_input_ipc_name(收 tokenizer 请求的端点)Scheduler先 binddetokenizer_ipc_name(向 detokenizer 发 token ids 的端点)TokenizerManager后 connectscheduler_input_ipc_name(向 scheduler 发请求)TokenizerManager先 bindtokenizer_ipc_name(收 detokenizer 回来结果的端点)DetokenizerManager后 connectdetokenizer_ipc_name(从 scheduler 收 token ids)DetokenizerManager后 connecttokenizer_ipc_name(向 tokenizer 回文本结果)
所以实际的 bind 先后顺序是:Scheduler bind 两条边 → TokenizerManager bind 一条边 → DetokenizerManager 全部 connect。
Engine._launch_subprocesses 怎样协调这个顺序#
Engine._launch_subprocesses(...) 是统一的进程装配入口。它用两种机制来保证顺序:
第一种:显式 barrier
scheduler 进程和 detokenizer 进程在初始化完成、socket 绑定好之后,会向主进程发送就绪信号(通过 mp.Barrier 或 Pipe)。主进程等到这两个信号都到齐以后,才继续完成 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 内部会重试连接。但如果 TokenizerManager 在 Scheduler bind 完之前就开始发送请求,消息会在 ZMQ 内部队列里堆积,直到连接建立才被消费。更危险的情况是 scheduler 进程崩溃或初始化卡住:此时 TokenizerManager 发出的请求永远不会被接收,服务表面上启动成功,但所有请求都会超时。
这也是为什么 _launch_subprocesses 里有明确的就绪信号同步,而不是简单地 time.sleep(1) 等一下。
小结#
这一节真正要建立的是一个简单但稳定的判断:
TokenizerManager托管请求状态和接入边界;Scheduler托管运行时工作单元和调度边界;DetokenizerManager托管返回链里的文本化边界。
只要这个三层判断稳住,后面第四章剩下的 IPC 拓扑、第五章的调度与内存,以及第八章的调试路径都会更容易落到正确层上。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。