/v1/chat/completionsScheduler#

这章解决什么问题#

如果你只从 API 表面看 SGLang,很容易把 /v1/chat/completions 理解成一个普通的 FastAPI 路由:接收一个 JSON,请求进入模型,最后吐出字符串。这个理解能帮助你调用接口,但几乎不能帮助你读源码。因为从源码角度看,这条链路真正重要的不是 HTTP,而是请求对象怎样一步步从“协议形态”收缩成“运行时形态”。

更准确地说,这一章主要回答四个问题:

  1. OpenAI-compatible 请求是在什么地方结束协议层处理的。
  2. 统一的 runtime 请求对象是怎样建立起来的。
  3. TokenizerManager 在这条链里到底承担了什么职责。
  4. 请求到了 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 前”中转站,而是一条正式边界。ReqStateTokenizedGenerateReqInput 都从这里分化出来。

最后,再把这一章最关键的对象演化过程单独拿出来看:

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

这一段代码至少做了四层工作:

  1. 处理 messages,应用 chat template,并把 multimodal 数据、stop strings、tool constraints 收集起来。
  2. 调用 request.to_sampling_params(...),把 OpenAI 风格参数压成内部 sampling 参数集合。
  3. 处理 LoRA、routed_dp_rankpriorityrouting_keyreasoning 这些运行时信号。
  4. 最后构造 GenerateReqInput

这里有一个很重要但容易被忽略的设计点:GenerateReqInput 已经不再关心“我原来是不是 chat completion”。它只保留 runtime 真正需要的字段,例如:

  • textinput_ids
  • sampling_params
  • stream
  • return_logprob
  • routed_dp_rank
  • custom_logit_processor
  • modalities 以及 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 ,里面不仅有 eventfinished,还有:

  • 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_idsinput_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

所以这里其实有两步不同性质的动作:

  1. _tokenize_one_request:把“还带着原始输入形态的请求”变成“已经拿到 input_ids 和 multimodal 处理结果的请求”;
  2. _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_dispatcherTokenizedGenerateReqInput 会被派发到 Scheduler.handle_generate_request

这里是真正的第三次对象收缩,也是最重要的一次:scheduler 会把 recv_req 组装成 Req。从代码可以看到,这一步补上的已经不只是输入和 sampling 参数,而是整个调度生命周期需要的状态:

  • return_logprobtop_logprobs_numstream
  • lora_id
  • require_reasoning
  • return_hidden_statesreturn_routed_experts
  • routed_dp_rankdisagg_prefill_dp_rank
  • priority
  • routing_key
  • time_stats
  • metrics_collector

更重要的是,Req 一旦建立,后面的真正 gate 才开始出现。对读者来说,这里至少要记住四个关键关口:

  1. multimodal token 展开和长度膨胀;
  2. validate_input_length(...) 对 prompt 长度的正式校验;
  3. logprob_start_len 的修正和边界检查;
  4. 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 直接做完所有事情”的写法。它故意把链路切成:

  1. route / serving 层:处理协议与 API 语义;
  2. tokenizer manager 层:处理状态托管、分词、tokenized object 构造和结果收口;
  3. scheduler 层:处理真正的运行时工作单元、排队和后续执行链。

这么切至少有三个直接好处。

第一,协议层和 runtime 层不会耦得太死。OpenAI-compatible 接口以及其他请求表面,至少可以共用同一组底层 manager、scheduler 和 execution primitives。
第二,ReqState 这类 API server 侧状态可以和 scheduler 侧的 Req 明确分层。
第三,streaming、session、abort、grammar queue、multimodal token 展开这些分叉不会都堆在同一层里。

代价也很明显:对象会多一层,跨层追踪一条请求时需要同时理解 GenerateReqInputTokenizedGenerateReqInputReqStateReq 四种人格。也正因为这个代价存在,这章才值得单独写。

调试这条链时应该先看哪里#

如果你是在排障,这条链也给出了一条非常实用的阅读顺序:

  1. 先确认协议问题是不是已经在 OpenAIServingBase.handle_requestOpenAIServingChat._convert_to_internal_request 被消化掉了;
  2. 再确认 GenerateReqInput 是否已经包含你预期的 sampling_paramsstream、routing 和 multimodal 信息;
  3. 然后确认 TokenizerManager 是否成功建立了 ReqState ,并且把 tokenized object 送进 scheduler;
  4. 最后再看 scheduler 里的 Req 是否成功进入 grammar / queue / batch 路径。

这条顺序的价值在于,它能帮你区分:问题到底发生在“协议解释”、“状态托管”、“调度接管” 还是后续的“结果收口”。

小结#

这一条链路最重要的结构不是 HTTP,而是对象的连续收缩与重建:

ChatCompletionRequest -> GenerateReqInput -> ReqState established -> TokenizedGenerateReqInput -> Req

如果你后面要继续追一次请求为什么排不上、为什么 stream 卡住、为什么 tool response 不符合预期,最稳的起点通常都不是直接扎进 model_runner。更高效的做法,是先回到这条对象变化顺序,确认请求到底在哪一层发生了变化、又在哪一层被真正接管。