SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流

9958 字
50 分钟
SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流

为什么先画地图,再追函数#

SGLang 的生产运行时并不是一个“收到 prompt,调用一次 model.forward(),返回 token”的大类。一次在线生成会跨过 HTTP 协议层、模板与分词层、进程间消息、调度器、KV Cache 管理、模型执行器、采样器和增量解码器。它们又被分散在 entrypoints/、managers/、model_executor/、mem_cache/、layers/ 等目录中。如果从某个长文件第一行顺读,最容易发生两件事:一是把相邻代码误认为同一层职责,二是钻进某个优化分支后失去请求主线。

因此,SGLang 源码系列的第一篇只回答四个问题:

  1. 外部请求在每一层变成什么对象;
  2. 哪个组件拥有请求、批次、KV 页和文本的权威状态;
  3. 哪些边界是函数调用,哪些边界必须经过进程间通信;
  4. 第一次读源码时应该沿哪条主干走,哪些高级分支先暂时关闭。

这不是功能清单。功能清单会把 RadixAttention、连续批处理、张量并行、CUDA Graph、投机解码、PD 分离平铺成一排名词;源码地图必须说明这些功能分别挂在主干的哪个位置,以及为什么不能放到别的位置。

SGLang 由 LMSYS 团队在 2023 年 12 月首先以论文《SGLang: Efficient Execution of Structured Language Model Programs》公开,后续版本发表于 NeurIPS 2024。最初系统由嵌入 Python 的前端语言和 SGLang Runtime 两部分组成,运行时的代表性机制是 RadixAttention 与压缩有限状态机。今天的仓库已经扩展为面向生产服务的推理框架,但“前端表达请求,运行时把请求编译成可高效执行的批次”这条主线没有变。

SGLang 论文中的前端与运行时分层
SGLang 论文中的前端与运行时分层

图源:SGLang 论文 Figure 1。这张图描述的是系统的原始抽象,而不是当前服务进程图:左边的语言前端负责表达生成程序,右边的运行时负责 KV 复用、结构化生成等优化。阅读当前源码时,需要把右侧“Runtime”继续展开成 Scheduler、TP Worker、ModelRunner、缓存池与注意力后端。

SGLang 解决的不是一次前向,而是一条在线服务流水线#

Transformer 推理看似只有两个阶段:prefill 处理整段输入,decode 每轮追加一个或多个 token。框架真正困难的地方不是公式,而是许多生命周期不同的状态必须同时正确推进:

  • HTTP 连接可能随时断开,但 GPU 上的请求不能继续占用显存;
  • 不同长度的请求共享一个连续批次,完成的请求要退出,新请求要立刻加入;
  • prompt 的一部分可能已经命中 Radix Cache,未命中的后缀才需要 prefill;
  • GPU Kernel 只接受张量、长度和页索引,不认识聊天消息、stop string 或 SSE;
  • token 已经采样出来,不代表字符已经可以安全输出,BPE 合并、UTF-8 边界和停止串都可能跨 token;
  • 多张 GPU 各持有一部分权重,但必须对同一批请求执行相同的控制流。

把这些约束压成一条主线,可以得到当前默认单副本、单 Tokenizer、张量并行部署的逻辑拓扑:

Client
│ HTTP / OpenAI-compatible request
▼
HTTP Server + TemplateManager
│ GenerateReqInput
▼
TokenizerManager 主进程
│ TokenizedGenerateReqInput ZMQ PUSH/PULL
▼
Scheduler ── ScheduleBatch ── TpModelWorker ── ModelRunner
│ │ │ │
│ │ └─ sampling └─ model / attention / KV tensor
│ └─ RadixCache + memory pools
│ BatchTokenIDOutput Scheduler 子进程(每个 TP rank 一份)
▼
DetokenizerManager 独立子进程
│ BatchStrOutput ZMQ PUSH/PULL
▼
TokenizerManager
│ JSON or SSE delta
▼
Client

图里有两类箭头。组件内部的实线可以是普通 Python 调用,例如 Scheduler 调用本进程里的 TpModelWorker.forward_batch_generation();跨进程边界则通过 ZeroMQ 传输消息对象。区分两者非常重要:函数调用可以共享 Python 对象引用,ZMQ 两端只能共享序列化后的事实。跨进程之后,接收方必须重新建立自己的状态,不能指望修改发送方的对象。

为什么不把所有步骤塞进一个进程?因为在线服务不是单一串行任务。Tokenize 和 detokenize 都会消耗 CPU,调度器必须及时准备下一轮批次,GPU 前向又在独立 stream 上运行。如果它们争夺同一个 Python 解释器和事件循环,字符串处理或网络背压就可能让 GPU 等待。SGLang 把首尾文本处理与中间调度执行拆开,使请求接入、GPU 推进、输出解码能够形成流水。

这里也要避免另一个误解:TpModelWorker 并不必然是“额外的 Worker 进程”。在默认路径中,每个 Scheduler rank 在自己的进程里构造一个 TpModelWorker,后者再持有 ModelRunner。Worker 表示设备执行职责,不等于它总有独立操作系统进程。进程边界要从启动代码和 IPC socket 判断,不能只看类名。

仓库与包结构:先找到真正的运行时主干#

当前仓库同时包含语言前端、服务运行时、Kernel、测试、基准和多种派生运行时。第一次阅读应该先锁定 python/sglang/srt/;srt 即 SGLang Runtime。与一条文本生成请求直接相关的目录可以压缩成下表:

路径主要职责第一遍要抓住的对象
srt/entrypoints/HTTP、OpenAI 兼容协议、离线 Engine、进程启动http_server.py、engine.py
srt/entrypoints/openai/chat/completion 等协议适配与响应格式serving 类,不先追 Pydantic 每个字段
srt/managers/请求、调度、批次、输出解码和 TP WorkerTokenizerManager、Scheduler、ScheduleBatch、TpModelWorker、DetokenizerManager
srt/mem_cache/请求到 token 映射、token 到 KV 页分配、Radix/HiCache树缓存和两级内存池的所有权
srt/model_executor/模型加载、批次转设备、CUDA Graph、真正前向ModelRunner、ForwardBatch
srt/layers/线性层、注意力、MoE、量化等模型积木统一接口怎样落到平台 Kernel
srt/models/各模型架构的前向定义与权重映射某个模型如何消费 ForwardBatch
srt/distributed/TP、PP、EP、DP 进程组与通信封装rank 与 group 的关系

managers/scheduler.py、schedule_batch.py 和 tokenizer_manager.py 都是数千行文件。长并不意味着应该从头读到尾。一个更稳定的方法是先找状态边界,再找主循环:

外部请求对象
GenerateReqInput
↓ tokenize
跨进程输入对象
TokenizedGenerateReqInput
↓ Scheduler.handle_generate_request
长期请求状态
Req
↓ get_next_batch_to_run
本轮调度状态
ScheduleBatch
↓ ForwardBatch.init_new
设备执行描述
ForwardBatch
↓ ModelRunner.forward + sample
批量 token 结果
GenerationBatchResult / BatchTokenIDOutput
↓ incremental decode
用户可见文本
BatchStrOutput

这些对象不是同一份请求不断加字段。它们分别服务于协议、长期调度、本轮执行和输出流。把它们拆开有两个好处:一是 GPU 执行层不必携带 HTTP 和字符串状态;二是 Scheduler 可以在很多轮前向之间保留 Req,但每轮只生成一个紧凑的 ScheduleBatch。

论文中的语言前端会把多个 gen、fork 和变量依赖组成数据流。它解释了为什么 SGLang 从一开始就关心“多个生成调用之间”的共享,而不只关心单次 completion。

SGLang 程序被展开为多条相互依赖的生成流
SGLang 程序被展开为多条相互依赖的生成流

图源:SGLang 论文 Figure 14。三条 stream 之间既有数据依赖,也会产生共享前缀。当前 OpenAI 兼容服务即使不直接暴露早期 DSL,这类结构仍广泛存在于多轮对话、并行采样、Agent 分支和共享系统提示词中,因而会继续影响缓存与调度设计。

一次请求穿过哪些进程#

以 sglang serve --model-path ... 的常规文本生成服务为起点,launch_server() 会先创建运行时子进程,再启动 HTTP 服务。当前源码在函数注释中明确给出三组件引擎:TokenizerManager 负责分词和请求入口,Scheduler 子进程负责排队、批处理和前向,DetokenizerManager 子进程负责把 token ID 还原为文本;HTTP Server、Engine 与单 TokenizerManager 位于主进程,组件间通过 ZMQ IPC 通信。

单 GPU 时,可以先把它理解成三个操作系统进程:

进程常驻对象权威状态为什么独立
主进程HTTP Server、TemplateManager、TokenizerManager外部请求、连接、请求 ID、prompt token、等待输出的异步状态网络、协议和分词属于 CPU 接入面
Scheduler 进程Scheduler、TpModelWorker、ModelRunnerwaiting/running 队列、Radix Cache、KV 内存池、模型权重、当前批次必须紧贴 GPU 执行并保持低抖动
Detokenizer 进程DetokenizerManager每个请求的增量解码状态、已安全输出的字符边界文本解码不能阻塞下一轮 GPU 调度

张量并行度大于 1 时,不能把“Scheduler 进程”仍想成只有一份。每个 TP rank 都需要执行对应权重分片的前向,也必须以一致顺序进入 NCCL 集合通信。rank 0 接收入口请求并把控制消息同步给其他 rank,各 rank 对等推进调度和执行;只有负责输出的 rank 把采样结果送向 Detokenizer。这个布局的核心不变量是:所有 TP rank 对本轮 batch 的形状和请求顺序达成一致,否则某个 rank 进入 all-reduce 而另一个 rank 走到别的分支,服务就会死锁。

多 Tokenizer、多数据并行副本、流水线并行、专家并行以及 prefill/decode 分离会在这张图上增加路由器、更多 Scheduler rank 或 KV 传输通道,但不会推翻主干。第一次阅读应从单 Tokenizer、dp_size=1、pp_size=1、无投机解码开始。源码中的高级分支并不是噪声,而是后续文章的主题;在第一遍同时展开它们,只会让主循环失焦。

ZMQ 在这里主要承担控制消息,而不是把模型张量在 Python 进程间来回搬运。典型方向是:

TokenizerManager --PUSH--> Scheduler
Scheduler --PUSH--> DetokenizerManager
Detokenizer --PUSH--> TokenizerManager

PUSH/PULL 是单向队列语义。请求从入口向执行流动,token 结果从执行向文本流动,最终回到入口处等待的异步生成器。管理操作如 flush cache、更新权重和 profile 还会使用独立通信器;它们不是普通生成数据流,不应混进第一条调用链。

前端入口:OpenAI API 如何变成内部请求#

HTTP 层首先解决的是协议问题,而不是模型问题。/v1/chat/completions 接收 messages、tools、stream、temperature 等 OpenAI 风格字段;原生 /generate 更接近 SGLang 的内部生成参数。两条入口最终都要形成统一的 GenerateReqInput,再交给 TokenizerManager.generate_request()。

chat 请求比 completion 多一层模板转换。messages 是角色化结构,模型真正接收的是 token 序列;TemplateManager 根据模型聊天模板把 system、user、assistant 与工具描述拼成文本或模板化输入。这个步骤必须位于 Scheduler 之前,因为 GPU 调度只能按照 token 数预算资源,不可能按照“有三条 message”估算 KV 占用。

OpenAI 兼容层还负责把内部输出翻译回协议字段,例如 choices、delta、finish_reason 和 usage。反过来,Scheduler 不应该知道 SSE 的 [DONE] 标记。二者职责边界可以写成:

协议层:用户说了什么、希望怎样返回
运行时:有多少 token、这一轮算哪些 token、占哪些 KV 页

流式返回尤其能说明为什么入口层必须保留状态。HTTP handler 并不是调用一次同步 generate() 后等待完整字符串,而是异步迭代 TokenizerManager.generate_request() 产生的片段,把每个片段包装成 SSE。若客户端断开,入口层还要发出 abort,让 Scheduler 从 waiting queue 或 running batch 中删除请求并释放 KV。只停止网络发送而不反向取消,会留下“无人消费但仍在烧 GPU”的幽灵请求。

第一次跟代码可以从以下三个点开始:

  1. srt/entrypoints/http_server.py 中 /generate 或 OpenAI chat handler,观察协议对象如何构造;
  2. srt/entrypoints/engine.py 的 _launch_subprocesses(),确认各进程和 IPC 名称从哪里产生;
  3. TokenizerManager.generate_request(),从统一入口离开协议层。

不要一开始追完所有 endpoint。embedding、score、rerank 与生成共享进程骨架,但输出类型和模型前向不同;先把自回归生成主线走通,更容易识别后来出现的分支。

TokenizerManager:CPU 侧的入口总管#

TokenizerManager 的类注释只说“负责 tokenization”,真实职责远不止一次 tokenizer.encode()。它至少维护四类状态:

  • tokenizer 与多模态 processor,用来把文本或图像占位信息变成模型输入;
  • rid_to_state,把 request ID 映射到等待响应的 ReqState;
  • 发往 Scheduler、接收 Detokenizer 结果的 socket;
  • LoRA、权重更新、PD 分离、指标与管理 RPC 等控制状态。

核心方法 generate_request() 的稳定骨架可以精简为:

async def generate_request(obj, http_request=None):
obj.normalize_batch_and_arguments()
init_request_state(obj, http_request)
async with model_update_lock.reader_lock:
resolve_lora(obj)
tokenized_obj = await tokenize_one_request(obj)
await send_one_request(tokenized_obj)
async for response in wait_one_response(obj, http_request):
yield response

这不是源码逐字复制,而是保留控制顺序的伪代码。顺序背后有三个设计理由。

第一,先规范化批量参数,再创建状态。单请求和批请求在外部可以共用一个接口,但每个子请求仍要有独立 request ID 和完成状态,否则某个样本提前 EOS 时无法单独结束。

第二,分词后才把消息发给 Scheduler。Scheduler 接收的是 TokenizedGenerateReqInput,需要直接看到 input_ids、采样参数、LoRA 身份、多模态输入描述等运行时事实;它不应该再次解释 HTTP 文本。

第三,发送和等待是解耦的。_send_one_request() 只把请求推进执行流水线,_wait_one_response() 从 ReqState 的异步通道持续取结果。这样,一个主进程事件循环可以同时挂起很多 HTTP 请求,而不需要为每个请求占一条阻塞线程。

请求的身份在这里第一次成为系统主键。文本内容不能当主键:两个用户可以提交完全相同的 prompt,却有不同的采样参数、取消时机和流式连接。RadixAttention 可以让二者共享 KV 前缀,但请求生命周期仍必须由不同的 rid 管理。缓存身份与请求身份是两套键,混为一谈会导致取消、输出归属和统计全都出错。

TokenizerManager 还要处理“请求未成功送达 Scheduler”的异常窗口。它在入口就创建 rid_to_state;若分词、长度校验或 LoRA 解析提前失败,必须删除尚未投递的状态。若请求已经投递而后续 HTTP 侧出错,则应发送 abort 让 Scheduler 清理。这个细节说明,在线系统的资源释放不是一个 finally: del state 就能覆盖:跨进程之后,资源所有者已经改变,清理消息也必须跨过同一边界。

Scheduler:把请求、缓存与 GPU 批次放进同一个决策循环#

Scheduler 是整条运行时的控制中心。它不是简单的“按先来后到凑 batch”,而是同时拥有请求状态机、显存容量账本、前缀缓存、调度策略和 GPU 提交时序。当前类的注释是“管理一个 tensor-parallel GPU worker 的调度器”,这句话揭示了它的边界:Scheduler 决定执行什么,TP Worker 决定怎样在设备上执行。

初始化完成后,最值得先观察的长期状态是:

self.waiting_queue # 已到达、尚未进入可持续 decode 的请求
self.running_batch # 已完成 prefill、正在逐步 decode 的请求
self.last_batch # 上一轮提交的批次,overlap 路径需要它提交结果
self.tree_cache # 前缀缓存与请求缓存句柄
self.req_to_token_pool # request slot -> token 对应的 KV 位置
self.token_to_kv_pool_allocator # KV 物理页/槽位分配器

这里存在三个容易混淆的层次。

第一,Req 是长期请求。它保存 prompt、已生成 token、前缀命中长度、采样参数、停止条件、缓存节点以及完成原因。一条请求会跨越许多次 Scheduler 迭代。

第二,ScheduleBatch 是本轮批次。它描述这一轮要执行的请求集合、forward mode、每个请求要扩展的 token 数、request pool 索引、KV 位置和采样信息。下一轮即使还是同一批请求,也会生成或更新不同的本轮状态。

第三,memory pool 是物理资源账本。req_to_token_pool 提供“请求的逻辑 token 位置映射到哪个 KV 位置”的二维关系;token_to_kv_pool_allocator 管理这些 KV 位置背后的设备存储。Radix tree 保存的是可共享前缀到 KV 位置的索引关系,而不是再复制一份 K/V tensor。

普通循环:收消息、选批次、执行、提交结果#

关闭 overlap 后,event_loop_normal() 可以简化为:

while not gracefully_exit:
ingest_requests()
plan = get_next_batch_to_run(
running_batch=running_batch,
last_batch=last_batch,
)
running_batch = plan.running_batch
batch = plan.batch_to_run
if batch:
result = run_batch(batch)
process_batch_result(batch, result)
else:
on_idle()
last_batch = batch

这六个动作就是阅读 Scheduler 的锚点。

  • ingest_requests() 把 ZMQ 收到的类型化消息交给 dispatcher;生成请求最终进入 handle_generate_request(),再变成 Req 放进 waiting queue。
  • get_next_batch_to_run() 先处理上一轮状态和缓存事件,再决定优先做新请求的 extend/prefill,还是推进 running batch 的 decode。
  • run_batch() 把调度对象交给 TP Worker,触发前向与采样。
  • process_batch_result() 把新 token 提交回请求状态,检查完成条件,发送输出并维护缓存。
  • on_idle() 不只是 sleep,还会做自检和某些延迟初始化或清理。
  • last_batch 让“上一轮已经发出但结果尚未在 CPU 完成处理”的状态有明确归属。

extend 是源码里很重要的术语。它比 prefill 更宽:一个新请求可以从零处理完整 prompt,也可以先命中 pp 个缓存 token,只对剩余 ee 个 token 做扩展;分块预填充还会让同一请求多次 extend。因而,不能看到 ForwardMode.EXTEND 就假设“这是第一次处理整段 prompt”。

为什么 prefill 与 decode 要在一个循环里协调#

prefill 通常是大矩阵乘,计算密集;decode 每个请求每步只有少量新 token,却要读取大量权重和历史 KV,更偏带宽瓶颈。若一直让长 prompt 抢占 GPU,running batch 的 token 间延迟会恶化;若永远只服务 decode,新请求又会饿死,首 token 延迟持续增长。

SGLang 的调度器需要在三个预算下选择请求:

∑iei≤Bprefill,Nrunning≤Brequest,Kused+Kreserved≤Kcapacity\sum_i e_i \le B_{\text{prefill}}, \qquad N_{\text{running}} \le B_{\text{request}}, \qquad K_{\text{used}} + K_{\text{reserved}} \le K_{\text{capacity}}

eie_i 表示请求 ii 本轮需要 extend 的 token 数,BprefillB_{\text{prefill}} 是 prefill token 预算,NrunningN_{\text{running}} 与 BrequestB_{\text{request}} 分别是活动请求数和请求槽上限,KK 系列变量表示 KV 容量。真实代码还要叠加 chunked prefill、LoRA 分组、grammar、Mamba 状态、投机 token、PD 分离和多模态 encoder 等约束,但基础问题始终是:能不能为本轮计算和后续 decode 留出足够状态。

只看“当前还有多少空闲 KV”会低估风险。新 prefill 请求跑完后马上要进入 decode,未来每轮都会追加 KV;Scheduler 因此维护新 token 估计和保留比例。在压力下,它可能缩小新 batch、分块处理长 prompt,或者 retract 一部分请求让出资源。资源不足必须在 launch 前解决,因为 GPU 已经开始写 KV 后再发现没有槽位,就从可调度问题变成了执行错误。

overlap 循环:把 CPU 决策藏到 GPU 前向后面#

默认高性能路径通常不会严格按“前向完成,再在 CPU 处理结果,再准备下一批”的顺序串行。event_loop_overlap() 维护一个结果队列:第 tt 轮 GPU 工作发出后,CPU 可以处理第 t−1t-1 轮结果并准备第 t+1t+1 轮,形成软件流水。

时间 ─────────────────────────────────────────>
GPU: [ forward t ] [ forward t+1 ] [ forward t+2 ]
CPU: [result t-1 + schedule t+1]
[result t + schedule t+2]

收益不是减少模型 FLOPs,而是隐藏 Python 调度、批次整理和 GPU 到 CPU 的小结果拷贝。代价是状态出现“已计划、已发出、尚未提交”三个时刻。源码里的 cur_batch、last_batch、结果队列、future token 映射和 stream/event 屏障都服务于这个时间差。若第一次就从 overlap 路径理解状态,很容易把尚未完成的 token 当成已提交 token;调试时先加 --disable-overlap-schedule,让控制流恢复串行,更容易建立正确模型。

Radix Cache 为什么属于 Scheduler,而不是 Attention Kernel#

新请求入队后,Scheduler 会对 token 序列做最长前缀匹配。命中的 KV 位置直接成为请求已有状态,未命中的后缀才进入 extend。请求完成后,其 KV 不必立刻清零;可复用部分留在 radix tree 中,内存紧张时再按策略驱逐。

RadixAttention 中前缀插入、分裂、共享与驱逐的生命周期
RadixAttention 中前缀插入、分裂、共享与驱逐的生命周期

图源:SGLang 论文 Figure 3。方框节点承载 token 片段与 KV 位置,分叉表达公共前缀,橙色虚线表示驱逐。树在 CPU 侧维护索引,K/V tensor 留在设备内存池;Attention Kernel 只按上游给出的地址取数。

这解释了缓存为什么放在 Scheduler 侧:是否命中、是否锁定节点、为新后缀分配多少页、内存不足时驱逐谁,都会改变可调度批次。若把决策藏进 Kernel,Scheduler 在 launch 前就无法知道资源是否足够。RadixAttention 的完整匹配、节点分裂、引用计数与 LRU 机制可参考站内的 RadixAttention 完全拆解;本篇只保留它与调度主循环的接口位置。

ModelWorker 与 ModelRunner:执行边界在哪里#

Scheduler 选出 ScheduleBatch 后,run_batch() 不直接调用某个模型类,而是把任务交给 TpModelWorker。这层适配看起来多余,实际上隔离了三类变化:调度批次怎样变成设备批次、多种生成算法怎样组织前向、分布式 rank 怎样返回结果。

关键转换发生在 ForwardBatch.init_new():

ScheduleBatch
- Python Req 列表
- 调度与缓存状态
- extend/decode 模式
│
▼
ForwardBatch
- input_ids / positions
- batch size / sequence lengths
- KV locations / page metadata
- sampling metadata
- multimodal or speculative metadata

ForwardBatch 是模型执行契约。模型层不应遍历 Scheduler 的 waiting queue,也不应自行向 radix tree 申请节点;它接收已经决定好的 token、位置和内存映射。这个窄接口使同一个调度器可以切换 FlashInfer、FlashAttention、Triton 或平台专用 attention backend,也使模型实现只需消费统一的 forward context。

TpModelWorker.forward_batch_generation() 的基础路径是:构造 ForwardBatch,调用 ModelRunner.forward(),在最后一个 pipeline rank 上计算 logits 并采样,把 next_token_ids 和必要统计封装成 GenerationBatchResult。投机解码、扩散语言模型和 pipeline parallel 会在此处分叉,因为它们对“一次 forward 产生什么结果”的定义不同;普通自回归路径仍是理解其他分支的基准。

ModelRunner 才真正拥有模型权重、设备、attention backend、CUDA Graph runner、forward stream 和采样器。它的 forward() 又不是直接一行 self.model(...):先初始化或复用 attention metadata,选择 eager、编译或 graph replay 路径,再执行模型,最后形成 logits 处理所需输出。源码阅读时应把它拆成四问:

  1. ForwardBatch 的哪些字段在 CPU 构造,哪些 tensor 在何时搬到 GPU;
  2. attention backend 如何把公共元数据转换成具体 Kernel 的参数;
  3. 当前 batch 是否满足 CUDA Graph 的静态形状与地址约束;
  4. 哪些 hidden state 真正需要经过词表投影和采样。

Scheduler 与 ModelRunner 的分工可以用一句话固定下来:Scheduler 编译“本轮计划”,ModelRunner 执行“设备程序”。前者的错误通常表现为错请求、错预算、错 KV 所有权;后者的错误通常表现为错形状、错地址、错 stream 同步或数值异常。调试时先判断错误属于哪一类,会比在两个巨型文件之间来回搜索有效得多。

TP Worker:多卡如何保持同一条控制流#

Tensor Parallel 把一个算子沿 hidden dimension 或输出通道切到多张 GPU。以列并行线性层为例,各 rank 持有不同列的权重分片并产生不同输出分片;行并行层则在局部矩阵乘之后通过 all-reduce 合并。单个 rank 只有局部 tensor,却不能只接收“与自己有关的请求”:每个 rank 必须对同一个 batch、同一组 token 位置执行对应分片。

因此,SGLang 的 TP 执行不是 rank 0 准备一个完整模型再把大 tensor 复制给其他进程。更合理的控制流是:

rank 0 接收 TokenizedGenerateReqInput
├─ 同步控制消息与请求顺序到其他 TP rank
├─ rank 0 Scheduler -> local TpModelWorker -> local ModelRunner
├─ rank 1 Scheduler -> local TpModelWorker -> local ModelRunner
└─ rank n Scheduler -> local TpModelWorker -> local ModelRunner
│
└─ NCCL collectives inside sharded layers

各 rank 也要维护等价的 KV 分配决策和 radix tree 操作。KV tensor 本身按 head 或模型结构分片,但 token 前缀、请求顺序和逻辑页关系必须一致。论文对 TP RadixAttention 的描述正是:每张 GPU 持有分片 KV,树操作相同,因此前缀管理不需要额外为每次节点操作增加同步协议。工程实现仍必须保证所有 rank 以相同输入顺序运行相同控制分支。

TpModelWorker 的价值在这里变得清楚。它拥有 ModelRunner,也持有 TP/PP group、随机种子与采样路径。最后一个 pipeline rank 才有完整的最终 hidden state 来形成 logits;其他 pipeline rank 返回中间 tensor。普通 TP 没有 pipeline stage 之分时,各 rank 通过层内集合通信得到一致结果,但仍只应由指定 rank 向输出流水线发送 token,避免重复响应。

分布式源码最危险的错误不是 Python exception,而是 collective 不对称:某个 rank 因条件判断提前 return,其他 rank 已进入 all-reduce,进程会一直等待。阅读多卡分支时应不断检查三个不变量:

  • 所有 rank 是否看到相同的请求数和 batch 顺序;
  • 所有 rank 是否以相同次数、相同顺序进入 collective;
  • 只有输出 rank 执行的逻辑,是否被明确放在 is_last_rank 或 rank 0 条件中,并且不包含其他 rank 需要参加的 collective。

第一次阅读不需要立刻追 NCCL 封装。先在 tp_size=1 下理解 ScheduleBatch -> ForwardBatch -> ModelRunnerOutput,再把 TP 看成“同一设备程序的多份分片副本”,之后进入 distributed/parallel_state.py 和各类 parallel linear layer,认知负担会小很多。

DetokenizerManager:为什么输出解码要独立成进程#

模型产生的是整数 token ID,用户需要的是连续文本。两者并非逐 token 一一对应:BPE 或 SentencePiece token 可能只代表一个词的片段,一个 Unicode 字符可能跨多个字节,空格清理还可能改变相邻 token 的拼接结果。对每个新 token 单独调用 decode([token_id]) 再拼字符串,会在中文、emoji、组合字符和某些 tokenizer 上产生乱码或错误空格。

DetokenizerManager 为每个 request ID 保存 DecodeStatus,其中包括已解码 token、读取偏移和已安全输出的文本。收到 BatchTokenIDOutput 后,它不是从头把全序列重复发送,而是用一小段前文作为 surrogate context,重新解码必要窗口,比较新旧边界,只产生新增的稳定后缀。

其事件循环非常窄:

def event_loop(self):
while True:
recv_obj = sock_recv(self.recv_from_scheduler)
output = self._request_dispatcher(recv_obj)
if output is not None:
sock_send(self.send_to_tokenizer, output)

这是从当前 detokenizer_manager.py 摘出的关键循环。它故意不碰调度、KV 和模型状态,只做类型分发、增量解码与回传。窄职责使 Detokenizer 可以在 Scheduler 已经推进下一轮 forward 时处理上一轮文本。

停止条件也被分到合适层次。EOS token、最大生成 token 数等纯 token 条件可以由 Scheduler 判断;stop string 必须在文本形成后确认,因为停止串可能跨越多个 token。真正命中后还要根据 no_stop_trim 决定保留还是裁掉停止文本。这个边界与 vLLM 的停止条件与增量解码 中的原理相同,但进程组织不同:SGLang 把 detokenization 明确放进独立 Manager 进程,避免它占用 Scheduler 的 Python 时间。

输出返回 TokenizerManager 后,rid_to_state 找到正确的等待者,把文本片段投递给对应异步生成器;HTTP 层再封装为 SSE delta。请求完成时,入口侧状态、Detokenizer 的 decode status、Scheduler 的 Req、request pool slot 与不可再复用的临时资源都要分别清理。它们不在同一进程,因而不存在一个能直接“删除所有对象”的总析构函数;完成消息和 abort 消息本身就是分布式垃圾回收协议。

用一段伪代码串起端到端控制流#

把前面的组件折叠到最小主线,可以得到下面的端到端伪代码。它省略高级功能,但每一步都能映射到真实文件:

# Main process: HTTP + TokenizerManager
async def serve(req):
external = parse_openai_request(req)
internal = apply_chat_template(external)
tokenized = tokenize(internal)
send_to_scheduler(tokenized)
async for text_delta in wait_by_request_id(tokenized.rid):
yield encode_sse(text_delta)
# Scheduler process: control plane + device execution
while True:
for message in recv_requests():
dispatch_by_type(message) # generate / abort / control
plan = get_next_batch_to_run(
waiting_queue,
running_batch,
radix_cache,
kv_allocator,
)
if plan.batch is None:
on_idle()
continue
forward_batch = ForwardBatch.init_new(plan.batch, model_runner)
result = tp_worker.forward_batch_generation(
plan.batch,
forward_batch=forward_batch,
)
update_requests_and_cache(plan.batch, result)
send_token_ids_to_detokenizer(result)
# Detokenizer process: output plane
while True:
token_batch = recv_from_scheduler()
text_batch = incrementally_decode(token_batch)
send_to_tokenizer_manager(text_batch)

这个伪代码的重点不是函数名,而是所有权转移:外部请求归入口层,tokenized request 进入 Scheduler 后由 Req 接管,ScheduleBatch 只代表一轮,ForwardBatch 只描述设备执行,token ID 结果离开 Scheduler 后又由 Detokenizer 和入口层接管文本生命周期。

还可以沿数据形态验证每个边界:

边界输入输出不应泄漏过去的状态
OpenAI adaptermessages / toolsGenerateReqInputGPU 页号
TokenizerManager文本与生成参数TokenizedGenerateReqInputradix node
Scheduler长期 Req 集合ScheduleBatchSSE 与字符串 buffer
TP WorkerScheduleBatchForwardBatch / resultwaiting queue 策略
ModelRunnertensor 与执行 metadatalogits / sampled tokenHTTP finish reason 格式
Detokenizertoken ID 与 decode state文本增量KV 物理地址

如果在源码中发现某一层开始直接修改本应由另一层拥有的真值,就要格外警惕。例如 ModelRunner 若直接决定请求完成并释放 radix node,会绕过 Scheduler 的队列与缓存账本;HTTP handler 若直接释放 KV,会跨进程触碰不存在的 Python 对象。成熟推理框架的大部分复杂性,正是在守住这些边界的同时支持越来越多功能。

从目录到断点:一条可执行的源码阅读路径#

SGLang 主分支演进很快,按固定行号写阅读笔记很快会失效;类名、核心方法与消息形态比行号稳定。下面这条路径刻意关闭大多数高级功能,一次只跟一个 request ID。

第一步:只看进程怎样启动#

从 python/sglang/srt/entrypoints/engine.py 的 Engine._launch_subprocesses() 开始,回答:

  • Scheduler 和 Detokenizer 用什么 multiprocessing context 启动;
  • PortArgs 为哪些方向生成 IPC 地址;
  • 主进程怎样等待各 Scheduler rank 完成模型加载与 CUDA Graph warmup;
  • 子进程异常时 watchdog 怎样让整个服务退出,而不是留下半套运行时。

这一步先不看模型。建议画出 PID、rank、GPU ID、ZMQ 地址四列对应表。很多“请求发出后没有结果”的问题,本质不是模型卡住,而是消息被送到错误 IPC 端点,或某个子进程已经退出。

第二步:给一个请求 ID 画状态时间线#

依次在这些函数附近设置断点或日志:

TokenizerManager.generate_request
TokenizerManager._send_one_request
Scheduler.handle_generate_request
Scheduler.get_next_batch_to_run
Scheduler.run_batch
Scheduler.process_batch_result
DetokenizerManager.handle_batch_token_id_out
TokenizerManager._handle_batch_output

记录同一个 rid 在每处对应的对象类型、input IDs 长度、output IDs 长度、是否 finished。第一轮通常是 extend/prefill,随后进入 decode;若 prompt 命中缓存,观察 prefix_len 与需要 extend 的长度如何变化。不要同时发并发请求,先确保单请求状态线完全闭合。

第三步:拆开“请求槽”和“KV 槽”#

在 schedule_batch.py 的 prepare_for_extend() 与 prepare_for_decode() 周围观察:

  • req_pool_indices 如何给批内请求分配 request slot;
  • 每个 token 的 KV 位置如何写进 request-to-token 映射;
  • decode 新 token 如何追加 KV 位置;
  • 请求结束后,request slot 何时释放,KV 内容何时只是变成可驱逐缓存而非立即清空。

两个 pool 的生命周期不同。request slot 表示正在服务的逻辑请求,完成后必须回收;KV 页可以在请求完成后继续作为 radix cache 命中来源。把二者都称为“显存块”会丢掉最关键的所有权差异。

第四步:进入 ModelRunner,但先用 eager#

关闭 CUDA Graph 和 overlap,沿下面的调用链跟一次短 prompt:

Scheduler.run_batch
-> TpModelWorker.forward_batch_generation
-> ForwardBatch.init_new
-> ModelRunner.forward
-> model.forward
-> logits processor
-> sampler

在每层只记录 tensor shape、dtype、device 和关键索引,不要立刻进入每个 Transformer layer。先验证总 token 数、positions、sequence lengths、KV locations 和需要采样的 logits 行一致。框架级 bug 经常发生在模型前后,而不是注意力公式内部。

第五步:一次只恢复一个优化开关#

推荐顺序是 eager 串行、overlap、CUDA Graph、chunked prefill、prefix cache、structured output、speculative decoding、TP/PP/DP/EP。每恢复一个开关,比较对象和时序发生了什么变化:

  • overlap 增加的是在途状态和 stream 屏障;
  • CUDA Graph 增加的是静态 buffer、capture size 与 replay;
  • prefix cache 改变的是已计算前缀和 KV 所有权;
  • chunked prefill 改变的是一条 prompt 需要多少轮 extend;
  • speculative decoding 改变的是一轮可能接受多个 token,以及验证失败后的提交规则;
  • 并行策略改变的是权重、KV 或 token 在 rank 间的分布与通信。

这样读源码,每个新机制都能表述为“它在哪个既有边界增加了哪些状态”,不会重新背一套孤立流程。

可以直接用 rg 建立导航,而不是依赖网页搜索行号:

Terminal window
rg -n "class (TokenizerManager|Scheduler|TpModelWorker|ModelRunner|DetokenizerManager)" \
python/sglang/srt
rg -n "def (generate_request|get_next_batch_to_run|run_batch|process_batch_result)" \
python/sglang/srt

阅读时应固定一个 commit。SGLang 主分支文件拆分和功能合入频繁;如果类或方法发生迁移,先用符号搜索重新定位,不要因为旧笔记中的行号失效就误判机制已经删除。

与 vLLM 的架构坐标对齐#

前面的 vLLM 系列已经从 EngineCore、Scheduler、KVCacheManager 一直走到 GPUModelRunner 和输出处理。SGLang 与 vLLM 解决的是同一类在线推理问题,但组件边界并非逐项同名。用已有坐标做映射,比重新背术语更有效。

问题vLLM V1 的主要对象SGLang 的主要对象边界差异
请求接入API server、AsyncLLM、EngineCoreClientHTTP Server、TemplateManager、TokenizerManagerSGLang 的 TokenizerManager 同时保存入口请求状态
核心调度EngineCore 内的 SchedulerScheduler 进程SGLang Scheduler 与本地 TP Worker 结合更紧
本轮计划SchedulerOutputScheduleBatch都把长期请求编译成本轮工作,但字段与提交方式不同
KV 管理KVCacheManager、block pool、prefix hashreq/token pools、tree cache、RadixAttentionSGLang 将 radix tree 与缓存感知调度作为核心组织方式
设备执行Executor、Worker、GPUModelRunnerTpModelWorker、ModelRunnerSGLang 默认路径由 Scheduler 直接调用本进程 Worker
注意力适配Attention Backend + metadata builderattention backend + ForwardBatch metadata都用统一层接口隔离具体 Kernel
文本输出前端 OutputProcessor / detokenizer独立 DetokenizerManager,再回 TokenizerManagerSGLang 明确增加一段输出进程流水

两者最值得对照的不是“谁有某功能”,而是状态在哪里提交。vLLM V1 强调 EngineCore 生成 SchedulerOutput,Executor 返回 ModelRunnerOutput,再由 Scheduler 更新长期请求;SGLang 的 Scheduler 直接持有本地 TpModelWorker,run_batch() 与 process_batch_result() 前后包住设备执行。两种设计都在避免 GPU 层直接修改用户协议状态,只是进程边界和对象命名不同。

调度抽象也不同。vLLM V1 倾向于把 prefill、decode 与投机验证统一成“本轮为每个请求调度多少 token”的预算;SGLang 源码显式区分 extend 与 decode 的 ForwardMode,围绕 waiting queue 和 running batch 组织状态,同时在条件允许时把新 extend 与已有 running 请求混合执行。不能把 vLLM 的 num_scheduled_tokens 机械替换成 SGLang 某个同名单字段,需要回到各自批次构造过程理解。

缓存方面,二者今天都支持分页 KV 与前缀复用,但历史出发点不同。vLLM 的 PagedAttention 首先解决物理连续分配造成的碎片与预留浪费;SGLang 的 RadixAttention 首先把跨请求的共享前缀组织成可匹配、可驱逐的树。当前实现已经互相吸收许多工程思想,但源码中的中心数据结构仍反映各自演化路线。PagedAttention 的分页动机可参考 PagedAttention 完全拆解,SGLang 的树缓存则对应前文的 tree_cache。

因此,读完 vLLM 后进入 SGLang,不需要重新学习 Transformer、连续批处理或分页注意力。需要重新建立的是三张图:进程拓扑、状态所有权、调度提交时序。底层原理可以复用,框架边界必须重新核对。

后续路线图#

SGLang 源码系列将沿“入口与状态机 → 调度与缓存 → 设备执行 → 高级并行”的顺序推进。已经有独立文章讲过的 RadixAttention 数学与树操作不会原样重写,后续只补当前源码中的 pool、cache handle、调度协作与层次缓存实现。

序号主题要解决的源码问题状态
一整体架构、进程拓扑与请求数据流建立全局模块地图和对象边界本篇
二Entrypoints、TokenizerManager 与 DetokenizerManagerOpenAI 请求、模板、IPC、流式输出和 abort 如何闭环下一篇
三Scheduler 请求状态机Req 如何在 waiting、running、finished 之间迁移待写
四PrefillAdder、调度策略与 chunked prefilltoken/KV 预算如何决定谁进入下一批待写
五KV 内存池与当前 Radix Cache 源码两级 pool、前缀命中、锁定、驱逐怎样协同待写
六Overlap SchedulerCPU 调度如何与 GPU 前向重叠,如何避免在途状态错乱待写
七ForwardBatch、TpModelWorker 与 ModelRunner调度计划怎样编译成设备输入待写
八Attention BackendFlashInfer、FlashAttention 等后端如何消费统一 metadata待写
九CUDA Graph 与编译路径动态批次如何进入静态捕获尺寸与 replay buffer待写
十Sampling、Grammar 与结构化输出logits mask、采样、FSM 与流式结果怎样衔接待写
十一投机解码草稿、验证、接受 token 与缓存提交如何接入主循环待写
十二TP 与 PP模型分片、rank 控制流和 pipeline 中间 tensor 如何传递待写
十三DP Attention、EP 与 MoE请求路由、注意力数据并行和专家通信如何组合待写
十四Prefill/Decode 分离与分布式 KV两类实例如何路由请求、传输 KV 并处理失败待写

这份路线图不是文件目录的逐章翻译。每一篇都围绕一个可观察的不变量展开:请求状态不丢、KV 所有权不重叠、batch 预算不越界、rank collective 不分叉、输出文本不提前泄漏。源码结构会变化,这些系统约束比具体行号稳定。

小结#

SGLang 当前文本生成主干可以压成三段进程流水:主进程中的 HTTP Server 与 TokenizerManager 接收协议请求并生成 tokenized message;Scheduler 子进程维护请求、Radix Cache 与 KV pool,把长期状态编译成 ScheduleBatch,再通过 TpModelWorker 和 ModelRunner 执行设备程序;DetokenizerManager 独立恢复文本,结果回到入口异步状态并流给客户端。

第一次读源码最重要的不是记住全部功能,而是守住四个对象边界:GenerateReqInput 表达外部请求,Req 表达长期调度状态,ScheduleBatch 表达一轮决策,ForwardBatch 表达设备执行契约。沿这四次形态变化设置断点,就能把数万行运行时重新压回一条可验证的数据流。

下一篇将深入入口与出口:从 OpenAI chat 请求、模板和分词开始,跟踪 ZMQ 消息如何进入 Scheduler,再解释增量 detokenize、SSE、停止串和客户端断开如何组成完整生命周期。

参考资料#

  1. SGLang: Efficient Execution of Structured Language Model Programs(arXiv)
  2. SGLang: Efficient Execution of Structured Language Model Programs(NeurIPS 2024)
  3. SGLang 官方 GitHub 仓库
  4. SGLang Engine 进程启动源码
  5. SGLang HTTP Server 源码
  6. TokenizerManager 源码
  7. Scheduler 源码
  8. ScheduleBatch 与 Req 数据结构源码
  9. TpModelWorker 源码
  10. ModelRunner 源码
  11. DetokenizerManager 源码
  12. Mini-SGLang 官方结构说明

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流
https://pinghaoyang.com.cn/aigc/posts/sglang-arch-overview/
作者
平昊阳
发布于
2026-10-12
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
212
分类
26
标签
291
总字数
2,407,536
运行时长
0 天
最后活动
0 天前

文章目录