从 /v1/chat/completions 到 Scheduler#
这章解决什么问题#
如果你只从 API 表面看 SGLang,很容易把 /v1/chat/completions 理解成一个普通的 FastAPI 路由:接收一个 JSON,请求进入模型,最后吐出字符串。这个理解能帮助你调用接口,但几乎不能帮助你读源码。因为从源码角度看,这条链路真正重要的不是 HTTP,而是请求对象怎样一步步从“协议形态”收缩成“运行时形态”。
更准确地说,这一章主要回答四个问题:
- OpenAI-compatible 请求是在什么地方结束协议层处理的。
- 统一的 runtime 请求对象是怎样建立起来的。
TokenizerManager在这条链里到底承担了什么职责。- 请求到了 scheduler 以后,是在哪一步真正变成运行时工作单元
Req。
沿着这条链读,更稳的做法不是把它看成五个彼此独立的函数,而是看成一组按顺序演化的请求对象:
ChatCompletionRequest -> GenerateReqInput -> ReqState established -> TokenizedGenerateReqInput -> Req
一张图先看完整主线#
如果先不陷进局部代码细节,这条链更适合先压成“分层边界”来看:
flowchart TB
subgraph Protocol["协议层"]
A["/v1/chat/completions<br/>ChatCompletionRequest"]
end
subgraph APIServer["API server / serving 层"]
B["OpenAIServingBase.handle_request"]
C["OpenAIServingChat._convert_to_internal_request"]
D["GenerateReqInput"]
end
subgraph Bridge["TokenizerManager 边界层"]
E["ReqState"]
F["TokenizerManager._tokenize_one_request"]
G["TokenizedGenerateReqInput"]
end
subgraph Runtime["Scheduler / runtime 层"]
H["Scheduler.handle_generate_request"]
I["Req"]
J["grammar / queue / batch"]
end
A --> B --> C --> D
D --> E
D --> F --> G
G --> H --> I --> J这张图最值得记住的一点是:TokenizerManager 不是简单的“协议后、scheduler 前”中转站,而是一条正式边界。ReqState 和 TokenizedGenerateReqInput 都从这里分化出来。
最后,再把这一章最关键的对象演化过程单独拿出来看:
flowchart TD
A["ChatCompletionRequest<br/>OpenAI 请求语义"]
B["GenerateReqInput<br/>统一 runtime 请求"]
C["ReqState<br/>API server 状态宿主"]
D["TokenizedGenerateReqInput<br/>scheduler 输入"]
E["Req<br/>runtime 工作单元"]
A --> B
B --> C
B --> D
D --> E把这两张图放在一起看,这一章的主线就比较清楚了:请求先以协议对象进入 serving 层,再变成统一的 runtime 请求,最后以 Req 的身份进入 scheduler。
路由层只做分发,不做真正的请求编排#
/v1/chat/completions 的入口相当薄。openai_v1_chat_completions
只负责把 ChatCompletionRequest 交给 app.state.openai_serving_chat.handle_request。这说明第一层设计意图非常明确:HTTP route 不承担“编排请求”的责任,它只是把协议对象移交给 OpenAI-compatible serving 层。
再往下一层看,OpenAIServingBase.handle_request
做的事情也仍然是协议适配器该做的事情,而不是 runtime 该做的事情:
- 校验请求是否合法;
- 记录原始 OpenAI 请求日志;
- 调用子类把协议对象转换成内部对象;
- 按
stream分到 streaming 或 non-streaming 路径。
这一层重要的不是逻辑复杂,而是边界清楚。事实 是:SGLang 没有在 FastAPI route 里直接拼 prompt、直接调 scheduler、再直接回包。它先把外部协议压到一个统一的 serving 模板里。推断 是:这样做是为了让 chat、completion、embedding、responses 这些入口在接入阶段复用同一套处理框架。
入口代码只有这几行:
@app.post("/v1/chat/completions")
async def openai_v1_chat_completions(request, raw_request):
return await raw_request.app.state.openai_serving_chat.handle_request(
request, raw_request
)再往下一层,handle_request(...) 的骨架如下:
error_msg = self._validate_request(request)
adapted_request, processed_request = self._convert_to_internal_request(
request, raw_request
)
if hasattr(request, "stream") and request.stream:
return await self._handle_streaming_request(...)
return await self._handle_non_streaming_request(...)这两段代码足够说明:这里在做请求接入和协议层转换,而不是在做运行时编排。
真正的协议收缩发生在 GenerateReqInput#
聊天请求从 OpenAI 形态落到内部形态,真正的收缩点是 OpenAIServingChat._convert_to_internal_request
。
这一段代码至少做了四层工作:
- 处理 messages,应用 chat template,并把 multimodal 数据、stop strings、tool constraints 收集起来。
- 调用
request.to_sampling_params(...),把 OpenAI 风格参数压成内部 sampling 参数集合。 - 处理 LoRA、
routed_dp_rank、priority、routing_key、reasoning这些运行时信号。 - 最后构造
GenerateReqInput。
这里有一个很重要但容易被忽略的设计点:GenerateReqInput 已经不再关心“我原来是不是 chat completion”。它只保留 runtime 真正需要的字段,例如:
text或input_idssampling_paramsstreamreturn_logprobrouted_dp_rankcustom_logit_processormodalities以及 multimodal 载荷
所以这一步不只是“给 scheduler 准备参数”。到了 GenerateReqInput,外部 API 的大部分表面差异已经被收拢,剩下的是 runtime 可以直接理解的一组统一字段。
构造 GenerateReqInput 的关键字段如下:
adapted_request = GenerateReqInput(
**prompt_kwargs,
sampling_params=sampling_params,
stream=request.stream,
routed_dp_rank=effective_routed_dp_rank,
priority=request.priority,
routing_key=self.extract_routing_key(raw_request),
custom_logit_processor=request.custom_logit_processor,
)这段代码值得看的不是字段多,而是字段类型已经明显偏向 runtime 侧,而不是 OpenAI 请求侧。
TokenizerManager 是 API server 和 runtime 之间的桥#
接下来,请求进入 TokenizerManager.generate_request
。单看名字,它像一个分词进程;结合代码看,它承担的是 API server 和 scheduler 之间的桥接职责。
先看它做的事:
normalize_batch_and_arguments()统一输入形态;- 设置默认优先级;
- 检查 rid 是否与在途请求冲突;
- 初始化请求统计与 trace;
- 记录接收日志;
- 校验 LoRA 和 pause 状态;
- 最后才进入 tokenize 与发送阶段。
这里最关键的一步,不是 tokenize,而是状态初始化。TokenizerManager 会先在 rid_to_state 里给请求建一个 ReqState
,里面不仅有 event 和 finished,还有:
out_list:等待回传的输出片段;time_stats:整条链的时间戳与观测指标;- 流式输出增量状态;
- logprob、top-logprobs、token ids 等累积结果。
换句话说,TokenizerManager 不是把请求 tokenize 完就扔给 scheduler,而是先在 API server 一侧把状态托住,再进入 tokenization 和发送流程。
ReqState 的轮廓本身就说明了这一点:
@dataclasses.dataclass
class ReqState:
out_list: List[Dict[Any, Any]]
finished: bool
event: asyncio.Event
obj: Union[GenerateReqInput, EmbeddingReqInput]
time_stats: APIServerReqTimeStats
text: str = ""
output_ids: List[int] = dataclasses.field(default_factory=list)它既保存结果增量,也保存观测信息,还保存和原始请求对象的关联。这说明它属于 API server 侧生命周期管理,而不只是 tokenize 过程的附属状态。
真正的 tokenize 发生在哪里#
如果只看 _create_tokenized_object(...),很容易误以为 tokenization 本身就在这个函数里完成了。但从源码顺序看,真正做这件事的是 TokenizerManager._tokenize_one_request
。
它的关键骨架其实很清楚:
if obj.input_embeds is not None:
input_ids = obj.input_ids
elif obj.input_ids is not None:
input_ids = obj.input_ids
else:
input_ids, token_type_ids = await self._tokenize_texts(input_text, ...)
if self.mm_processor and obj.contains_mm_input():
mm_inputs = await self.mm_processor.process_mm_data_async(...)
if mm_inputs and mm_inputs.input_ids is not None:
input_ids = mm_inputs.input_ids
return self._create_tokenized_object(
obj, input_text, input_ids, input_embeds, mm_inputs, token_type_ids
)这段代码把一件很关键的事讲清楚了:_tokenize_one_request 不是“调一下 tokenizer”这么简单,而是请求输入形态真正被规范化的地方。文本、input_ids、input_embeds 和 multimodal 输入,都是在这里被收敛到同一条后续路径上的。
TokenizedGenerateReqInput 不是简单数据搬运,而是第二次对象收缩#
在真正发送给 scheduler 之前,请求会先经过 TokenizerManager._tokenize_one_request
。这一步才是真正把文本、多模态输入和 input_ids 整理清楚的地方:如果调用方传的是 text,这里会做 tokenize;如果是 multimodal 请求,这里还会触发 mm_processor 处理并回填 input_ids / token_type_ids。只有这些输入被准备好之后,才会进入 TokenizerManager._create_tokenized_object
,把 GenerateReqInput 包装成 TokenizedGenerateReqInput
。
所以这里其实有两步不同性质的动作:
_tokenize_one_request:把“还带着原始输入形态的请求”变成“已经拿到input_ids和 multimodal 处理结果的请求”;_create_tokenized_object:把这些结果包装成 scheduler 可以直接消费的TokenizedGenerateReqInput。
第二步的意义,不是“换个 dataclass 装一装”,而是把前一层抽象好的 runtime 请求,进一步压成 scheduler 可以直接消费的格式:
- 文本或 prompt 变成了
input_ids sampling_params被实例化、normalize()并verify()- session、LoRA、routing、logprob、hidden states、reasoning 等控制字段被明确挂进 tokenized 对象
time_stats直接从ReqState传下来
所以从这里开始,request object 已经不再是“一个待解释的统一请求”,而是“一个已经过校验、分词、参数规整、可被调度器直接接管的执行前对象”。
而 _create_tokenized_object(...) 更像最后一道包装工序:
tokenized_obj = TokenizedGenerateReqInput(
input_text,
input_ids,
mm_inputs,
sampling_params,
obj.return_logprob,
obj.logprob_start_len,
obj.top_logprobs_num,
obj.stream,
rid=obj.rid,
routed_dp_rank=obj.routed_dp_rank,
priority=obj.priority,
routing_key=obj.routing_key,
)如果说 GenerateReqInput 是统一后的请求形态,那 TokenizedGenerateReqInput 已经更像调度器入口参数包。
真正把边界跨过去的代码其实非常短:
tokenized_obj = await self._tokenize_one_request(obj)
state = self.rid_to_state[obj.rid]
self._send_one_request(tokenized_obj)
async for response in self._wait_one_response(obj, state, request):
yield response这里最值得看的是顺序:先建状态,再 tokenize,再发送,最后才等待后续阶段把结果带回来。
Scheduler 才开始把请求变成运行时实体#
进入调度器以后,第一层分发发生在 Scheduler.init_request_dispatcher
:TokenizedGenerateReqInput 会被派发到 Scheduler.handle_generate_request
。
这里是真正的第三次对象收缩,也是最重要的一次:scheduler 会把 recv_req 组装成 Req。从代码可以看到,这一步补上的已经不只是输入和 sampling 参数,而是整个调度生命周期需要的状态:
return_logprob、top_logprobs_num、streamlora_idrequire_reasoningreturn_hidden_states、return_routed_expertsrouted_dp_rank、disagg_prefill_dp_rankpriorityrouting_keytime_statsmetrics_collector
更重要的是,Req 一旦建立,后面的真正 gate 才开始出现。对读者来说,这里至少要记住四个关键关口:
- multimodal token 展开和长度膨胀;
validate_input_length(...)对 prompt 长度的正式校验;logprob_start_len的修正和边界检查;- grammar manager 分流,或者最终
_add_request_to_queue(req)入队。
所以“到了 scheduler”并不等于“已经进入 waiting queue”。更准确地说,请求到了这里,才第一次以 Req 的身份接受真正的运行时 gate。
这一点也能从 Req 的构造代码里直接看出来:
req = Req(
recv_req.rid,
recv_req.input_text,
recv_req.input_ids,
recv_req.sampling_params,
stream=recv_req.stream,
routed_dp_rank=recv_req.routed_dp_rank,
priority=recv_req.priority,
routing_key=recv_req.routing_key,
time_stats=recv_req.time_stats,
)这里最值得注意的不是字段拷贝本身,而是:到了这一步,scheduler 已经不再面对“请求输入”,而是在面对“一个会被排队、会进入 grammar queue、会经历 batch shaping 的运行时实体”。
这也是这一章最核心的事实:真正被 scheduler 排队和塑形的,不是 GenerateReqInput,也不是 TokenizedGenerateReqInput,而是 Req。
这章为什么先停在 Scheduler#
严格来说,一次请求的完整闭环还没有结束。后面还有 detokenizer、streaming chunk 组装、non-streaming 响应收口、日志和指标回写这些尾部逻辑。但对这一节来说,看到 Req 被 scheduler 接管就够了。至于结果怎样跨过 DetokenizerManager、怎样变成 streaming chunk 或完整响应,放到下一节 3.2 Streaming 与回包组装 再展开更合适。
为什么链路要被切成这样#
如果你把这章一路读下来,会发现 SGLang 明显没有选择“一个大 handler 直接做完所有事情”的写法。它故意把链路切成:
- route / serving 层:处理协议与 API 语义;
- tokenizer manager 层:处理状态托管、分词、tokenized object 构造和结果收口;
- scheduler 层:处理真正的运行时工作单元、排队和后续执行链。
这么切至少有三个直接好处。
第一,协议层和 runtime 层不会耦得太死。OpenAI-compatible 接口以及其他请求表面,至少可以共用同一组底层 manager、scheduler 和 execution primitives。
第二,ReqState 这类 API server 侧状态可以和 scheduler 侧的 Req 明确分层。
第三,streaming、session、abort、grammar queue、multimodal token 展开这些分叉不会都堆在同一层里。
代价也很明显:对象会多一层,跨层追踪一条请求时需要同时理解 GenerateReqInput、TokenizedGenerateReqInput、ReqState 和 Req 四种人格。也正因为这个代价存在,这章才值得单独写。
调试这条链时应该先看哪里#
如果你是在排障,这条链也给出了一条非常实用的阅读顺序:
- 先确认协议问题是不是已经在
OpenAIServingBase.handle_request或OpenAIServingChat._convert_to_internal_request被消化掉了; - 再确认
GenerateReqInput是否已经包含你预期的sampling_params、stream、routing 和 multimodal 信息; - 然后确认
TokenizerManager是否成功建立了ReqState,并且把 tokenized object 送进 scheduler; - 最后再看 scheduler 里的
Req是否成功进入 grammar / queue / batch 路径。
这条顺序的价值在于,它能帮你区分:问题到底发生在“协议解释”、“状态托管”、“调度接管” 还是后续的“结果收口”。
小结#
这一条链路最重要的结构不是 HTTP,而是对象的连续收缩与重建:
ChatCompletionRequest -> GenerateReqInput -> ReqState established -> TokenizedGenerateReqInput -> Req
如果你后面要继续追一次请求为什么排不上、为什么 stream 卡住、为什么 tool response 不符合预期,最稳的起点通常都不是直接扎进 model_runner。更高效的做法,是先回到这条对象变化顺序,确认请求到底在哪一层发生了变化、又在哪一层被真正接管。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。