音乐
暂未播放
SGLang 源码完全拆解(三):Scheduler 请求状态机如何闭环
从入口消息进入调度器内部#
前两篇已经建立了两层坐标。第一篇 SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流 说明 HTTP Server、TokenizerManager、Scheduler 与 DetokenizerManager 分属哪条进程流水;第二篇 SGLang 源码完全拆解(二):入口、分词与流式输出如何闭环 沿 request ID(简称 rid)追踪了协议适配、分词、IPC、增量反分词与客户端断连。现在请求已经以 TokenizedGenerateReqInput 的形态抵达 Scheduler,接下来要回答的是:它怎样成为一条可以跨越数百轮 GPU 前向的长期请求,又怎样在结束时把每一份资源交还给正确的所有者。
SGLang 最早由 LMSYS 团队在 2023 年 12 月公开,论文后续发表于 NeurIPS 2024。论文强调 RadixAttention、缓存感知调度与结构化程序执行;当前生产源码在此基础上增加了分页 KV、分块 prefill、overlap、投机解码和 P/D 分离等大量分支。功能一直在扩展,但 Scheduler 请求状态机仍围绕四个问题运转:请求是否可被选入本轮、已经计算到哪个 token、持有哪些 KV 与缓存引用、是否已经形成不可逆的完成原因。

图源:SGLang 论文 Figure 1。论文图把整个后端概括成 Runtime;在当前源码中,这个方框要继续展开成 Scheduler、ScheduleBatch、内存池、前缀缓存、模型执行器和结果处理器。本文只追普通自回归生成主线,并在必要处说明 chunked prefill、overlap 和 speculative decoding 为什么会让边界变复杂。
本文固定使用 SGLang 仓库提交 d88d5c。这很重要:主分支已经把部分结果提交逻辑从 scheduler.py 拆到 scheduler_components/batch_result_processor.py,若把不同版本的函数位置拼在一起,会得到一条实际上不存在的调用链。本文引用类名与不变量,不依赖容易漂移的行号。
先建立三层状态模型#
源码没有一个名为 RequestState.WAITING 的枚举。waiting、running 与 finished 是由“对象位于哪里”和“哪些字段已经提交”共同表达的逻辑状态。阅读时至少要分清三层:
| 层次 | 代表对象 | 生命周期 | 回答的问题 |
|---|---|---|---|
| 长期请求 | Req | 从接收 tokenized message 到最终回收 | 这条请求累计生成了什么、命中了什么、是否结束 |
| 本轮批次 | ScheduleBatch | 一次 extend 或 decode 前向 | 这一轮选谁、算哪些 token、使用哪些 KV 位置 |
| 物理资源 | request/token pool 与 tree cache | 可短于或长于 Req | 请求槽、KV 槽和可复用前缀由谁拥有 |
Req 不是模型输入 DTO 的简单复制。它是 Scheduler 内部的长期账本,核心字段可以按职责分组:
1class Req:2 rid: str3 origin_input_ids: array[int]4 output_ids: array[int]5 sampling_params: SamplingParams6
7 prefix_len: int8 last_node: object9 kv: ReqKvInfo10
11 finished_reason: BaseFinishReason | None12 to_finish: BaseFinishReason | None13 inflight_middle_chunks: int14 is_retracted: bool这里是按源码压缩后的结构,不是可直接运行的完整定义。origin_input_ids 是稳定输入,output_ids 按提交顺序只追加;prefix_len 与 last_node 记录缓存匹配;ReqKvInfo 保存请求槽、已保护缓存边界、已提交 KV 长度和已分配长度;finished_reason 表示完成已经生效,to_finish 表示完成意图尚待安全提交。
同一条 Req 可以先在 waiting_queue,再进入一次 extend ScheduleBatch,随后并入 running_batch,资源紧张时又被 retract 回 waiting。它的 Python 身份可以不变,但持有的物理资源会变化。因此状态机不能只画成容器之间的移动,还必须画上资源账本:
1TokenizedGenerateReqInput2 │ construct + validate3 ▼4 Req in waiting_queue5 │ admitted by PrefillAdder6 ▼7 Req in EXTEND ScheduleBatch ── chunk incomplete ──> parked chunked_req8 │ final prefill result committed9 ▼10 Req in running_batch11 │ each decode result commits token/KV12 ├── stop/length/abort ──> finished + release/checkpoint13 └── KV pressure ────────> retract + release + waiting_queue“finished”也不是离开列表那一刻才发生。结果处理器先把 token 写入 output_ids,再调用 update_finish_state() 形成 finished_reason,随后释放或缓存 KV;下一次 filter_batch() 才把该请求从活动批次移除。这个短暂窗口允许输出、指标与资源清理读取同一份完成证据。
可以用一个守恒式检查整个设计。设一条请求持有的运行时资源为 R,其中包括请求槽 Rreq、私有 KV Rkv、前缀树锁或引用 Rtree、多模态特征 Rmm 和输出侧状态 Rout:
R=Rreq+Rkv+Rtree+Rmm+Rout请求从一个逻辑状态转到另一个逻辑状态时,不要求所有分量同时归零,但必须明确每个分量是转移、保留、变为可驱逐缓存,还是释放。所谓“状态机正确”,本质是控制状态与资源所有权始终一致。
TokenizedGenerateReqInput 如何变成 Req#
Scheduler.handle_generate_request() 接收的已经不是原始文本,而是入口层确定好的 token、采样参数、多模态描述、LoRA 身份、优先级与返回选项。普通非 session 请求的骨架可以缩成:
1def handle_generate_request(msg):2 req = Req(3 rid=msg.rid,4 origin_input_text=msg.input_text,5 origin_input_ids=msg.input_ids,6 sampling_params=msg.sampling_params,7 stream=msg.stream,8 lora_id=msg.lora_id,9 priority=msg.priority,10 vocab_size=model_config.vocab_size,11 # 省略多模态、logprob、P/D 分离等参数12 )13 req.tokenizer = tokenizer14 init_req_max_new_tokens(req)15 validate_input_length(req)16 process_grammar_or_enqueue(req)这一步看似只是构造对象,实际划定了两个权威边界。第一,Scheduler 接受 input_ids 为计算事实,不再重新解释 chat template;第二,从此以后 token 计数、完成判断、KV 映射和调度优先级都以 Req 为准,入口层的 ReqState 只负责等待与响应聚合。
构造之后会先做不会消耗模型前向的验证:输入长度是否越界、logprob_start_len 是否合理、采样 mask 是否被当前后端支持、grammar 是否需要异步编译、多模态占位符展开后是否仍在限制内。为什么错误请求有时仍会被放进队列?因为某些失败路径使用统一的输出与回收协议:给 Req 设置 abort 原因,再让它通过便宜路径产生标准完成消息,比在每个校验分支各实现一套返回格式更容易保持一致。
不过,能够立即安全响应的错误会直接走 output streamer,不进入 GPU。判断标准不是“错误发生早晚”,而是请求是否已经把资源或异步工作交给其他组件。若 grammar 编译队列、缓存预取或多模态 transport 已经接手,就必须由对应组件参与收尾,不能简单 return 后丢掉引用。
_add_request_to_queue() 也不只是 append。在普通非 P/D 分离模式,它会完成优先级默认值或合法性检查、排队上限处理、可选 KV 预取,然后记录等待时间和到达时的已处理 token 计数:
1def _add_request_to_queue(req, is_retracted=False):2 validate_or_fill_priority(req)3 maybe_reject_when_queue_is_full(req)4 maybe_prefetch_kv(req)5 waiting_queue.append(req)6 req.time_stats.set_wait_queue_entry_time()7 req.arrival_processed_tokens = processed_tokens_counter最后两个字段服务于排队延迟与 aging。调度策略若只看静态优先级,持续到来的高优先级或高缓存命中请求可能让旧请求饥饿;保存进入队列时的进度坐标,策略才能估算等待期间系统已经完成了多少工作。
waiting queue:尚未运行不等于没有占用状态#
waiting queue 的含义是“尚未被当前 extend 批次接纳”,不是“完全没有准备工作”。队列中的请求可能已经完成 grammar 编译、发起层次缓存预取、获得 session 关联,甚至在 P/D 分离的 decode 节点上持有已传输的 KV。普通单机主线最简单,但源码必须兼容更宽的资源状态。
每次尝试构造新 prefill batch 时,Scheduler 先调用调度策略计算优先级,再让 PrefillAdder 做容量接纳。对每个候选请求,req.init_next_round_input(tree_cache) 会刷新完整输入并执行前缀匹配:
1full_untruncated_fill_ids = origin_input_ids + output_ids2prefix match -> prefix_len, last_node, cached token locations3extend span -> [prefix_len, extend_end)为什么要把 output_ids 也并入下一轮输入?第一次 prefill 时它通常为空;retract 后再次入队时,请求已经生成了一段输出,新一轮 extend 必须重建或加载“原 prompt + 已生成 token”的上下文,才能从相同位置继续解码。若只重新 prefill 原 prompt,模型会忘掉已经生成的后缀;若保留旧 KV 地址但这些地址已经释放,又会产生悬空引用。
前缀匹配最多通常只匹配到输入倒数第二个 token。最后一个 token 必须进入本轮计算,才能产生下一 token 的 logits;若整段输入全部只作为缓存读取而不执行任何新位置,就没有新的隐藏状态可供采样。返回 prompt logprob 时还要进一步限制最大可匹配长度,因为目标区间需要真正经过 logits 计算。

图源:SGLang 论文 Figure 3。蓝色路径表示被访问的缓存前缀,绿色表示新插入部分,红色表示被驱逐节点。对状态机而言,最关键的不是树形外观,而是节点引用计数:running 请求正在使用的节点不能被驱逐;waiting 请求只完成匹配还不等于已经永久锁住所有 KV。锁定时机若过早,会让大量排队请求压住缓存;若过晚,接纳过程中又可能发现命中节点已被驱逐。因此前缀匹配、容量检查与批次接纳必须由 Scheduler 串在同一个决策阶段。
PrefillAdder 同时检查至少三类预算:本轮 prefill token 数、request pool 槽位、KV pool 容量。用简化符号表示,候选集合 A 必须满足:
ei 是请求 i 本轮未缓存的 extend token 数,Bprefill 是计算预算,Brequest 是请求槽上限,最后一式则要求当前占用、新扩展 KV 与为未来 decode 预留的空间共同不越界。只检查第一式会造出“计算批次能跑,但写 KV 时溢出”的错误计划。
缓存感知排序试图让相近前缀的请求连续运行,减少反复驱逐。论文报告该策略在所测工作负载上平均达到最优缓存命中率的 96%,但它并不意味着所有线上请求都应该永久按最长前缀优先。生产调度还要加入优先级、公平性、LoRA 分组与等待 aging,否则热门前缀会持续挤压不相似请求。
从 waiting 到 running:一次 extend 提交改变了什么#
请求被 PrefillAdder 接纳后,会从 waiting_queue 移除并进入新的 ScheduleBatch。随后 prepare_for_extend() 分配 request pool 行和 KV 位置,构造本轮 input_ids、各请求的 extend 长度、序列长度与采样信息。此刻请求已经“在执行”,但还不能立即视为 running decode 请求,因为 GPU 结果尚未提交。
这个时间差可以分成四个时刻:
1admit allocate launch commit2 │ │ │ │3 ├─离开 waiting│ │ │4 ├─────────────┼─持有槽位/KV──┼─结果尚未可见─────┤5 │ │ │ └─追加首个输出 tokenScheduleBatch 是这段在途状态的载体。普通串行循环里,launch 后立刻等待结果,时间差较短;overlap 模式会在结果未提交时开始安排下一轮,因此 last_batch、结果队列和 inflight_middle_chunks 都不能省略。下一篇之后会单独拆 overlap,本篇只坚持一个原则:只有结果处理器完成 commit,Req.output_ids 与 finished_reason 才代表已发生事实。
普通生成请求的最后一段 prefill 不只计算 prompt KV,还从最后位置 logits 中采样第一个输出 token。结果处理器的主顺序是:
1req.output_ids.append(next_token_id)2req.update_finish_state()3
4if req.finished():5 release_kv_cache(req, tree_cache, checkpoint=True)6else:7 checkpoint_kv_cache(req, tree_cache)为什么首 token 要在 prefill 结果处理中写入,而不是等第一次 decode?因为 prefill 已经产生了下一 token 分布,再做一次 decode 才取首 token会重复计算或错开位置。提交后,未结束请求随 extend batch 在下一轮 get_next_batch_to_run() 中被过滤,再合并进 running_batch;已在首 token 就命中 EOS、长度上限或停止条件的请求不会进入持续 decode。
分块 prefill 会打破“一次 extend 后就 running”的直觉。长 prompt 可能被切成多段,中间 chunk 只提交 KV,不应采样用户可见 token,也不能流式输出。inflight_middle_chunks 记录尚未结清的中间段;chunked_req 把暂未完成的请求停放在独立位置。只有最后一段完成,结果处理器才追加首 token并允许它并入 running batch。
这里还隐藏着一个重要所有权转变:prefill 前命中的前缀属于 tree cache,请求新写出的后缀先属于活动请求;checkpoint 或完成释放时,适合复用的部分可以插入树,变成缓存持有。KV tensor 未必移动,变化的是“谁有权阻止它被回收”。
running batch:每轮 decode 怎样推进长期请求#
running_batch 保存已经完成 prefill、可以持续 decode 的请求集合。每轮 update_running_batch() 先过滤已完成或不应再执行的行,检查下一轮 KV 是否装得下,必要时 retract,再调用 prepare_for_decode() 更新张量元数据。普通非投机解码中,每个请求本轮扩展一个位置;投机解码可能一次提交多个被验证 token,但状态提交原则相同。
结果返回后,SchedulerBatchResultProcessor.process_batch_result_decode() 才推进长期状态。简化后的关键循环是:
1for req, accepted_ids in zip(batch.reqs, next_token_ids):2 if overlap and (req.finished() or req.is_retracted):3 continue4
5 req.output_ids.extend(accepted_ids)6 req.update_finish_state(len(accepted_ids))7
8 if req.finished():9 release_or_checkpoint_kv(req)10
11stream_output(batch.reqs)output_ids 采用只追加契约。refresh_fill_ids() 会根据长度判断完整输入里已经包含多少输出 token;如果某个优化在原地改写等长后缀,长度仍然一致,刷新逻辑却无法察觉内容变化,retract 后重建上下文就会静默错误。因此 speculative verification 可以决定“接受多少个”,但一旦提交到 output_ids,历史 token 不能随意改写。
一次 decode 前向还会同时推进三本账:
- 序列账:
output_ids增长,下一轮 position 与 sequence length 随之变化; - KV 账:新位置从已分配变成已提交,可能还存在预分配但未使用的尾部;
- 输出账:只有通过停止条件与增量解码边界的部分才能发给客户端。
三者不能简单用一个 len(output_ids) 代替。投机解码可能预留多个 KV 位置却只接受一部分;overlap 可能已经为下一轮分配空间,而上一轮 token 尚未在 CPU 提交;停止串可能在一组 accepted token 中间命中,后面的 token 已计算但不应对用户可见。源码中的 finished_len、kv_committed_len、kv_allocated_len 与 output streamer offset 正是在表达这些不同边界。
完成判断:谁有权宣布请求结束#
完成判断属于 Req,调用时机属于结果处理器。原因很直接:停止条件依赖请求自己的采样参数、tokenizer、已提交输出和 grammar 状态,但只有结果处理器知道新 token 已经从 GPU 结果变成可提交事实。模型执行器不能提前释放资源,HTTP 层也不能靠看到一段文本来猜 Scheduler 是否结束。
当前 update_finish_state(new_accepted_len) 的优先顺序值得逐项理解:
- 若
to_finish已设置,先把延迟完成意图转成finished_reason; - 检查越界或非法 token,防止 tokenizer 接收无效 ID;
- 检查 stop string 与 stop regex;
- 检查 EOS 和 stop token ID;
- 检查
max_new_tokens; - 检查 grammar 是否到达终态。
stop string 为什么排在 stop token 和长度上限之前?普通单 token decode 时差别不大,投机解码一次却可能接受多个 token。假设一组接受 token 先拼出停止串,稍后又遇到 EOS;若先以最后一个 EOS 结束,只裁掉 EOS,就可能把本应隐藏的停止串泄漏给客户端。finished_len 记录真正应显示到哪个输出 token,而不是盲目采用 len(output_ids)。
同理,停止条件若落在 max_new_tokens 之后,最终仍要把可见边界压回长度上限。完成原因不仅是一个给 API 显示的字符串,它决定输出裁剪、KV 可保留长度和统计口径。源码中的 _cap_finished_len_at_max_new_tokens() 就是在处理“一次接受多个 token 同时跨过多个边界”的冲突。
finished_reason 与 to_finish 的分离尤其关键。finished_reason is not None 会让 filter_batch() 直接移除该请求;若在事件循环中途处理 abort 时立即设置它,而这一轮仍有在途 GPU 结果,后续结果提交和输出确认可能再也看不到该请求。to_finish 先记录意图,等统一结果提交点调用 update_finish_state(),清理路径便能复用正常完成流程。
完成后的顺序通常是:收集必要的 routed-expert 或 indexer 输出,处理多模态特征,释放或 checkpoint KV,记录 completion time,最后由 output streamer 发送完成消息。随后下一次 ScheduleBatch.filter_batch() 才删除已完成行,并同步裁剪 req_pool_indices、seq_lens、采样状态与 speculative metadata。只从 reqs 列表删除而不裁剪张量,会让下一轮批次第 i 行指向错误请求。
Abort:从外部控制消息收敛到内部清理#
Abort 的困难不是设置一个布尔值,而是同一个 rid 可能位于多个位置:普通 waiting queue、grammar queue、正在分块的 chunked_req、running batch、overlap 的 last_batch 或 result queue;P/D 分离还会增加 bootstrap、prealloc 与 transfer queue。Scheduler.abort_request() 因此按可能的所有位置查找,而不是维护一个假定永远准确的单一字典位置。
对普通主线,abort 至少有三种处理方式。
waiting 请求:直接弹出并回传#
尚未开始模型计算的请求可以从 waiting_queue 直接移除。Scheduler 释放缓存预取句柄与多模态 transport,向 TokenizerManager 发送 abort 输出。此时通常没有 request pool 行和私有 KV,因此不需要伪造一次 GPU 前向。
1for req in matched_waiting_reqs:2 waiting_queue.remove(req)3 release_aborted_request(req)4 send_abort_output(req)“waiting 没有 KV”只对基础单机路径大体成立。decode 分离模式下,waiting 请求可能已经持有传输来的 KV,源码会额外调用 release_kv_cache();Mamba 状态、多模态特征和缓存预取任务也各有独立释放逻辑。判断是否释放资源应查看字段所有权,而不能只看队列名称。
running 请求:设置 to_finish,等待安全提交点#
正在 running 或仍位于 overlap 在途批次的请求不能粗暴删除。源码给它设置 FINISH_ABORT 到 to_finish:
1for req in collect_inflight_reqs():2 if matches(req, abort_message) and not req.finished():3 req.to_finish = FINISH_ABORT()这条请求通常还会经过一个结果提交点。update_finish_state() 先消费 to_finish,随后与自然停止共用 KV 释放、完成时间和输出回传路径。注释里所说“仍会跑一个 decode forward”并非为了得到有用 token,而是为了保持已经发出的批次、collective 顺序和资源账本闭合。尤其在 TP 或 overlap 下,单个 rank 擅自把请求从本轮张量中删除,可能让各 rank batch 形状不一致。
grammar 或 chunked 请求:保持中间账本可排空#
grammar 尚未准备好时,请求不在普通 waiting queue;实现可以把它改造成极短的 abort 请求,让统一输出路径返回错误。chunked prefill 更棘手:某个 chunk 可能已经 launch,inflight_middle_chunks 尚未归零。源码会记录 pending chunked abort,等在途 chunk 的统计和 KV 边界可安全处理后再释放。若立即把 chunked_req 设为 None,已经返回的结果会找不到所属请求,或者中间 chunk 计数永远无法减回零。
Abort 因而是一种跨组件资源回收协议。入口发送 AbortReq 只是提出取消;Scheduler 形成 FINISH_ABORT 并完成自己的账本;Detokenizer 与入口层看到最终消息后,再删除各自按 rid 保存的状态。TCP 断开本身不会自动完成后三步。
Retract:资源不足时为什么不是请求失败#
decode 每轮都要为每个活跃请求追加 KV。即使 prefill 接纳时预留了余量,实际输出长度仍不可预测;随着 running batch 推进,下一步可能无法为所有请求分配新位置。update_running_batch() 会在 prepare_for_decode() 之前调用 check_decode_mem(),容量不足时执行 retract_decode()。
Retract 的语义是“暂时把一部分请求从 running 退回 waiting,以后重建上下文继续”,并不等于 abort。基础流程如下:
1running Req2 │ choose victim3 ├─ release private KV without checkpointing4 ├─ release request pool row and cache lock5 ├─ reset prefix/allocation metadata6 ├─ keep origin_input_ids + committed output_ids7 └─ append back to waiting_queue释放时使用 checkpoint=False,因为 Scheduler 正是为了立刻获得空间。若先把受害请求的全部新 KV 插入 radix tree,再期待 LRU 稍后驱逐,内存可能仍无法及时腾出,而且额外树操作没有收益。实现还会按剩余请求数主动驱逐一定量的可驱逐缓存,使保留下来的 decode 请求至少能再前进若干步,而不是下一轮立刻再次触发 retract。
reset_for_retract() 会清零 prefix_len、last_node、request/KV 分配状态、批次计数与临时 logprob buffer,并把 is_retracted、retracted_stain 和 retraction_count 更新为相应值。它不会清空普通 token 输入与已提交 output_ids。重新入队后,init_next_round_input() 形成:
Tprompt 是原始输入 token,Tgenerated 是 retract 前已经提交并可能已经发送给客户端的输出 token,∥ 表示连接。Scheduler 对 Tresume 再做前缀匹配;仍在 tree cache 的部分可以命中,其余部分重新 extend。这样恢复不会改变用户已经看到的输出。
input_embeds 是例外。自定义 embedding 与后来生成的 token ID 不能简单拼成同一种输入表示;当前源码在 retract reset 时会丢弃已生成输出并从头开始,以保证 KV 索引与 embedding 长度一致。这也暴露了一个工程风险:流式输出若已经向客户端发送,内部重启生成必须同步重置发送 offset,否则可能把两次生成拼成一个看似成功的响应。阅读 retract 分支时,不能只验证 GPU 不 OOM,还要验证用户可见输出是否保持单调一致。
受害者选择默认参考已生成长度与原 prompt 长度,并可叠加 priority policy。直觉是保留已经生成更久的请求,因为撤回它会浪费更多重算工作;在相同生成进度下,较短 prompt 的恢复代价通常更小。源码将排序索引从末尾弹出,因此一定要连同排序方向一起读,不能看到 key 就断言谁先被撤回。
至少保留一个请求并非绝对保证。如果只剩最后一条请求仍无法为下一 token 分配 KV,继续执行只会在 allocator 中崩溃;当前实现会把它转成带 OOM 信息的 FINISH_ABORT,释放资源并返回服务错误。Retract 是容量调节手段,不可能让单请求上下文本身突破物理容量。
在 decode 分离模式下,retract 还可以先把 KV 备份到 host,之后恢复而不是重算;若 host backup 失败,请求可能被 abort。这个分支改变恢复成本,却不改变状态机原则:退回 waiting 前,设备 KV 的所有权必须解除,恢复所需事实必须由 token 序列或备份之一可靠保存。
正常完成、Abort 与 Retract 的资源账本#
三条路径最容易混淆之处在于,它们都会让请求离开当前 running batch,但对输出与缓存的语义不同:
| 路径 | 用户输出是否完成 | output_ids 是否保留 | 私有 KV | 前缀树/缓存 | 后续是否再入队 |
|---|---|---|---|---|---|
| 自然完成 | 是 | 保留到最终响应结束 | 释放 | 可 checkpoint 为可复用缓存 | 否 |
| Abort | 是,以 abort 原因结束 | 只保留清理所需边界 | 释放 | 通常按 abort 语义结束,不应留下错误状态 | 否 |
| Retract | 否 | 保留已提交输出 | 立即释放或先备份 | 重新匹配,不能继续持有旧锁 | 是 |
“释放 KV”也有两种不同结果。物理页可以回到 allocator,或者请求完成后的有效前缀被登记进 radix tree,仍占物理页但变成可驱逐缓存。后者不是泄漏:它失去运行请求的强引用后,可在新请求需要空间时被 LRU 驱逐。相反,若 request pool 行已经释放但树引用计数没有解锁,那才会形成不可驱逐的泄漏。

图源:SGLang 论文 Figure 8。左侧展示不同工作负载下的缓存命中率,右侧展示 RadixAttention、压缩 FSM 等组件对吞吐的贡献。它提示了状态机设计中的核心张力:完成后保留 KV 能提高后续命中,但运行请求又必须在内存压力下优先获得容量。Scheduler 通过“活动引用不可驱逐、完成缓存可驱逐、retract 私有 KV 立即释放”把两类目标放入同一内存池。
可以为每条请求做四项不变量检查:
- 若
Req在 running batch 中,则它的 request pool 行必须有效,所需 KV 映射必须可读; - 若
finished_reason已设置,则不会再为它安排新的有效 decode; - 若 retract 后回到 waiting,则不能继续持有旧 request pool 行或旧 tree lock;
- 若请求已向入口发送最终消息,则 Scheduler 最终不能再保留不可驱逐的请求私有资源。
这四项比“队列长度看起来正常”更能发现泄漏和 use-after-free。
用伪代码串起状态机#
下面的伪代码省略 grammar、beam、P/D 分离和 overlap,却保留状态迁移与提交顺序:
1def on_generate(msg):2 req = build_and_validate_req(msg)3 waiting_queue.append(req)4
5
6while True:7 ingest_control_messages() # generate / abort / management8
9 # 先让上轮完成的 extend 请求具备进入 running 的资格10 merge_committed_extend_into_running()11
12 prefill = build_prefill_batch(waiting_queue, running_batch)13 if prefill is not None:14 result = run_forward(prefill)15 for req, token in zip(prefill.reqs, result.next_tokens):16 if req.is_final_prefill_chunk:17 req.output_ids.append(token)18 req.update_finish_state()19 release_if_finished_else_checkpoint(req)20 stream_outputs(prefill.reqs)21 continue22
23 remove_finished_rows(running_batch)24 if not decode_capacity_ok(running_batch):25 victims = running_batch.retract_decode()26 for req in victims:27 waiting_queue.append(req)28
29 if running_batch:30 prepare_for_decode(running_batch)31 result = run_forward(running_batch)32 for req, accepted in zip(running_batch.reqs, result.accepted_tokens):33 req.output_ids.extend(accepted)34 req.update_finish_state(len(accepted))35 release_if_finished(req)36 stream_outputs(running_batch.reqs)真实 get_next_batch_to_run() 通常优先尝试新 prefill;若没有可运行的新 batch,再推进 running decode。所谓“prefill 优先”并非无条件让长 prompt 独占 GPU:PrefillAdder 的 token 预算、chunking、batch_is_full、prefill delayer 与 mixed chunk 策略共同约束干扰。下一篇会专门拆这些调度策略。
这段伪代码还刻意把 remove_finished_rows() 放在下一轮。结果处理、输出与资源释放需要访问刚完成的 Req;立即从所有结构消失,反而会让收尾缺少证据。批次过滤是一个同步更新 reqs 与各行 tensor 的结构操作,应当发生在明确的调度边界。
关键源码阅读与调试路径#
第一遍建议关闭 overlap、投机解码、P/D 分离和 chunked prefill,只发一条短请求。沿固定 rid 观察以下函数:
1Scheduler.handle_generate_request2 -> Scheduler._add_request_to_queue3 -> Req.init_next_round_input4 -> Scheduler.get_new_batch_prefill5 -> PrefillAdder.add_one_req6 -> ScheduleBatch.prepare_for_extend7 -> SchedulerBatchResultProcessor.process_batch_result_prefill8 -> Scheduler.get_next_batch_to_run # merge into running9 -> Scheduler.update_running_batch10 -> ScheduleBatch.prepare_for_decode11 -> SchedulerBatchResultProcessor.process_batch_result_decode12 -> Req.update_finish_state13 -> ScheduleBatch.filter_batch不要一开始进入 Transformer layer。每站记录这些字段就能验证控制流:
| 观察点 | 字段 | 应满足的关系 |
|---|---|---|
| 刚入队 | len(origin_input_ids)、len(output_ids) | 输出通常为 0 |
| 前缀匹配后 | prefix_len、extend_end | 0 <= prefix_len < extend_end |
| extend 分配后 | request pool index、KV allocated len | 所有批内行都有合法映射 |
| prefill 提交后 | output_ids[-1]、finished_reason | 首 token 已提交或请求已结束 |
| decode 准备后 | seq_lens、out cache location | 每个活跃请求有本轮新位置 |
| decode 提交后 | output/KV committed length | 仅按已接受 token 推进 |
| 完成后 | finished_len、finish reason | 可见边界不超过长度上限 |
| 过滤后 | batch size 与所有行 tensor | 第一维保持一致 |
可以用符号搜索而非固定行号导航:
1rg -n "class Req|def (init_next_round_input|update_finish_state|reset_for_retract)" \2 python/sglang/srt/managers/schedule_batch.py3
4rg -n "def (handle_generate_request|get_next_batch_to_run|get_new_batch_prefill|update_running_batch|abort_request)" \5 python/sglang/srt/managers/scheduler.py6
7rg -n "def process_batch_result_(prefill|decode)" \8 python/sglang/srt/managers/scheduler_components/batch_result_processor.py为了验证 retract,可以临时使用源码中的测试开关或人为缩小 KV 容量,但应固定采样,使重算前后 token 序列可比较。至少验证三点:已发送文本没有重复,retract 前的 output_ids 被恢复路径保留,旧 request pool 行已经释放且重新接纳后得到的行可以不同。
为了验证 abort,应覆盖 waiting、running 和 chunked prefill 三个时刻。记录 abort 消息到达时请求所在容器、to_finish 何时变成 finished_reason、最终输出是否只发送一次、KV available size 是否恢复。只测试 waiting abort 会漏掉最危险的在途结果竞态。
内存问题要同时看三组数字:request pool 可用行数、KV allocator 可用 token/page 数、tree cache 中可驱逐 token 数。request pool 耗尽与 KV pool 耗尽会触发不同路径;大量缓存页存在也不代表能释放,因为活动引用锁住的节点不可驱逐。
设计边界与常见误读#
误读一:Req 自己存着 waiting/running 枚举#
基础源码主要用容器归属表达调度状态,用字段表达完成与资源边界。同一个 Req 可以在 retract 后回到 waiting。调试“它是什么状态”时,要同时查看 waiting_queue、running_batch、last_batch、result queue 和 chunked_req,不能只打印对象字段。
误读二:prefill 完成后,首 token 还没生成#
生成模型的最后一个 prompt 位置已经产生下一 token logits。普通生成路径会在 prefill 结果处理中追加首个输出 token。后续 decode 才是一轮轮以已生成 token 推进 KV。把首 token 错放到第一次 decode 会造成 position 与 KV 长度整体错一位。
误读三:finished 后立刻从 batch 删除最干净#
完成请求仍要参与结果输出、finish reason 映射、KV 释放、指标记录和可能的缓存 checkpoint。统一在下一调度边界过滤,能让这些动作读取一致状态;关键是过滤时同步裁剪所有按 batch 行排列的 metadata。
误读四:Abort 可以复用 Retract#
Abort 是终止,必须给上游明确完成原因且不能再次调度;retract 是暂停,必须保留恢复所需 token 或 KV 备份并重新入队。二者都释放设备资源,但输出协议和持久状态完全不同。
误读五:Retract 后从原位置继续,不需要 prefill#
如果设备 KV 已释放且没有可恢复备份,模型无法只凭最后一个 token 继续。请求必须把 prompt 与已生成 token 组成完整上下文,再通过缓存命中或重算恢复 KV。output_ids 保留的是语义历史,不是注意力状态本身。
误读六:tree cache 与 running KV 是两块固定显存#
SGLang 让缓存和活动请求共享物理池。区别主要在所有权与可驱逐性:活动请求依赖的 KV 不可驱逐,完成前缀缓存可以驱逐。这样空闲容量能自动转成缓存,流量上升时缓存又能让位给 batch。
误读七:check_decode_mem 只是一个估算提示#
它是 allocator 前的容量门。若不满足就必须缩小 batch、retract 或 abort;继续 prepare_for_decode() 会把可恢复的调度压力变成分配失败。容量检查与实际 allocator 必须使用一致的 token/page 口径,特别是投机解码一次可能需要多个位置。
边界条件#
下面几类情况最容易破坏状态机假设:
max_new_tokens=0的 prefill-only 请求不会进入普通 decode;- prompt 首轮就命中停止或错误条件,可能从未进入 running;
- speculative decoding 一轮接受多个 token,完成位置可能位于接受序列中间;
- overlap 中请求已经逻辑完成,但旧批次仍有一轮 overshoot 结果返回;
- chunked prefill 被 abort 时,中间 chunk 的计数必须先排空;
input_embedsretract 无法直接与生成 token IDs 拼接;- P/D 分离的 waiting 请求可能已经持有 KV 或 host backup;
- beam group 可能不支持普通 retract,只能整组 abort;
- session 请求的缓存与多模态历史有独立生命周期,不能按普通请求全部清空。
这些分支看似零散,其实都在挑战同一条规则:一个逻辑请求可以跨多轮与多组件存在,但任何物理资源在任一时刻只能有明确所有者。
小结与后续路线#
SGLang Scheduler 没有把请求状态机压进一个枚举,而是让 Req、ScheduleBatch 和资源池共同表达状态。TokenizedGenerateReqInput 到达后被固化为长期 Req;waiting 阶段完成验证、缓存匹配与容量候选准备;extend 结果提交首 token 后,请求才具备进入 running decode 的条件;每轮 decode 都按“分配、执行、提交、完成检查、输出”的顺序推进。
正常完成、Abort 与 Retract 是三条不同的资源收敛路径。正常完成产生最终输出,并可把有效 KV 变成可驱逐缓存;Abort 产生终止原因,通过 to_finish 在安全提交点复用统一清理;Retract 保留语义历史、释放设备状态并重新入队。只要分清“token 已提交”“KV 已分配”“KV 已提交”“文本已发送”四条边界,就能解释大部分看似复杂的条件分支。
SGLang 源码系列当前进度如下:
| 序号 | 主题 | 状态 |
|---|---|---|
| 一 | 整体架构、进程拓扑与请求数据流 | 已完成 |
| 二 | Entrypoints、TokenizerManager 与 DetokenizerManager | 已完成 |
| 三 | Scheduler 请求状态机 | 本篇 |
| 四 | PrefillAdder、调度策略与 chunked prefill | 下一篇 |
| 五 | KV 内存池与当前 Radix Cache 源码 | 待写 |
| 六 | Overlap Scheduler | 待写 |
| 七 | ForwardBatch、TpModelWorker 与 ModelRunner | 待写 |
| 八 | Attention Backend | 待写 |
| 九 | CUDA Graph 与编译路径 | 待写 |
| 十 | Sampling、Grammar 与结构化输出 | 待写 |
| 十一 | 投机解码 | 待写 |
| 十二 | TP 与 PP | 待写 |
| 十三 | DP Attention、EP 与 MoE | 待写 |
| 十四 | Prefill/Decode 分离与分布式 KV | 待写 |
下一篇将把本篇刻意压缩的“接纳”过程完全展开:SchedulePolicy 如何排序 waiting queue,PrefillAdder 怎样同时核算 prefill token、request slot 与未来 decode KV,长 prompt 为什么要 chunk,以及 mixed chunked prefill 怎样在同一 forward 中容纳 extend 与 decode。
参考资料#
- SGLang: Efficient Execution of Structured Language Model Programs(arXiv)
- SGLang 论文(NeurIPS 2024)
- SGLang 官方 GitHub 仓库
- Scheduler 源码(固定提交)
- Req 与 ScheduleBatch 源码(固定提交)
- PrefillAdder 与调度策略源码(固定提交)
- Batch Result Processor 源码(固定提交)
- Mini-SGLang 官方结构说明
- RadixAttention 完全拆解:用前缀树自动复用 KV Cache
- 连续批处理完全拆解:Orca 的迭代级调度与选择性批处理
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



