音乐
暂未播放
SGLang 源码完全拆解(二):入口、分词与流式输出如何闭环
从总览进入第一条可执行主线#
上一篇 SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流 建立了全局地图:HTTP Server 与 TokenizerManager 位于入口进程,Scheduler 在设备侧维护请求与 KV 状态,DetokenizerManager 则把 token ID 恢复为可流式发送的文本。本篇沿着其中最靠近用户的一条闭环继续向下追:一条 OpenAI chat 请求怎样变成内部消息,生成结果又怎样回到 Server-Sent Events(SSE)字节流。
这条路径看起来只是“分词与反分词”,实际上同时承担四类一致性:
- 协议一致性:
messages、工具定义、停止条件和采样参数必须被翻译为运行时可执行的语义; - 身份一致性:同一个 request ID(下文简称
rid)必须贯穿入口状态、调度状态、解码状态和 HTTP 连接; - 增量一致性:服务端只能发送新增且已经稳定的文本,不能重复字符,也不能把半个 UTF-8 字符提前发出;
- 回收一致性:客户端断开、模板报错、调度中止和正常结束都必须回收各自进程拥有的状态。
SGLang 最早由 LMSYS 团队在 2023 年 12 月公开,论文后续发表于 NeurIPS 2024。论文中的系统由前端语言和运行时两部分组成;当前生产服务代码已经远比论文原型复杂,但“前端把结构化调用收敛成运行时消息,运行时异步返回增量结果”的边界仍然成立。

图源:SGLang 论文 Figure 1。图中左侧的 Interpreter 不等于当前某一个 Python 类,它表达的是请求语义的拥有者;右侧 Runtime 则负责执行。映射到当前在线服务,OpenAI adapter、chat template 和 TokenizerManager 位于语义收敛一侧,Scheduler 及 GPU worker 位于执行一侧。DetokenizerManager 横跨两侧之间:它理解 tokenizer,却不理解 HTTP 响应格式。
本文源码以 SGLang 仓库提交 d88d5c3 为坐标。主分支会持续演进,类名和字段可能调整;比行号更稳定的是五段数据变换:
1ChatCompletionRequest2 -> GenerateReqInput3 -> TokenizedGenerateReqInput4 -> BatchTokenIDOutput5 -> BatchStrOutput6 -> ChatCompletionChunk / SSE中间并不是对同一个大对象不断添字段。每次变换都会丢掉上游不再需要的语义,并加入下游执行所需的事实。这种“窄消息”设计是理解源码的第一把钥匙。
一次请求在入口层经历了哪些形态变化#
先用一条最小 chat completion 请求作为坐标:
1{2 "model": "served-model",3 "messages": [4 {"role": "system", "content": "回答要简洁。"},5 {"role": "user", "content": "什么是连续批处理?"}6 ],7 "temperature": 0.7,8 "max_tokens": 128,9 "stop": ["</answer>"],10 "stream": true11}外部协议把输入表达为角色消息、采样选项和流式开关。Transformer 不认识 system 或 user,GPU Kernel 更不认识 JSON。真正送入模型的只能是 token ID、位置、采样参数和少量运行时元数据。相反,客户端最终需要的是符合 OpenAI 协议的 choices[].delta.content,而不是内部 token ID 数组。
因此,入口与出口各做一次降维:
| 阶段 | 主要对象 | 新增的事实 | 被丢弃或隐藏的细节 |
|---|---|---|---|
| HTTP 解析 | ChatCompletionRequest | 已校验的协议字段 | 原始 JSON 字节与字段顺序 |
| 协议适配 | GenerateReqInput | prompt 或 input_ids、内部采样参数、rid | OpenAI 响应类的组织方式 |
| 分词派发 | TokenizedGenerateReqInput | 确定的 token IDs、SamplingParams、路由信息 | chat role 与模板选择过程 |
| 调度输出 | BatchTokenIDOutput | 增量 decode IDs、完成原因、计数与 logprob | GPU tensor、KV 物理位置 |
| 反分词 | BatchStrOutput | 稳定文本增量 | tokenizer 内部的字节拼接过程 |
| 协议封装 | ChatCompletionChunk | delta、finish_reason、usage | Scheduler 的队列与批次状态 |
这张表还能解释为什么不能让 Scheduler 直接返回 OpenAI JSON。一个 Scheduler batch 可以同时容纳原生 /generate、OpenAI chat、completion、结构化输出和内部健康检查请求。若设备侧直接理解每一种外部协议,批调度热路径会被网络协议绑死;增加一种 API 就可能影响 GPU 执行进程。SGLang 让 Scheduler 只返回协议无关的生成事实,再由入口侧组装外部响应。
也不能让 HTTP handler 自己管理 KV 或判断每个 token 的完成状态。KV 页与 running batch 的权威状态属于 Scheduler;跨进程直接修改会产生双重释放或孤儿请求。入口只保存“如何把结果交给等待者”的状态,调度器保存“如何继续执行”的状态。
OpenAI 协议层:把外部语义收敛为内部请求#
OpenAI chat 路径的工作不只是把字段重命名。它需要完成消息规范化、工具描述处理、chat template、停止条件合并、采样参数转换和响应模式选择。当前主线可以概括为:
1async def create_chat_completion(request, raw_request):2 processed = process_messages_and_template(request)3 sampling = build_sampling_params(request, processed.stop)4
5 internal = GenerateReqInput(6 input_ids=processed.prompt_ids,7 sampling_params=sampling,8 stream=request.stream,9 rid=request.rid,10 return_logprob=request.logprobs,11 image_data=processed.image_data,12 # 省略工具、路由、多模态和可观测性字段13 )14
15 async for result in tokenizer_manager.generate_request(internal, raw_request):16 yield adapt_to_openai_chunk(result)这段伪代码刻意把真实文件中的兼容分支压缩掉,但保留了职责顺序。重点有三个。
第一,chat template 属于模型接口的一部分。相同的 messages 对 Llama、Qwen 或其他模型可能渲染成完全不同的控制 token 序列。模板还要决定何时加入 generation prompt、工具定义放在哪里、是否允许继续最后一条 assistant 消息。模板不匹配时,模型看到的角色边界就会错位;这不是“输出格式不漂亮”,而是模型输入本身改变了。
第二,模板渲染和普通文本分词并不总是同一条路径。当前 serving_chat.py 会根据 tokenizer 特性选择两种方式:若文本往返编码可能丢掉控制 token,就直接调用 apply_chat_template(..., tokenize=True) 得到 prompt_ids;否则可以先渲染文本,再并行编码。前一种情况下,后续 TokenizerManager 收到的已经是 input_ids,不会把同一段 prompt 再编码一遍。
第三,停止条件必须在这里保留原始语义。OpenAI stop 是字符串或字符串数组,模型配置还可能给出 EOS 和其他 stop token。字符串停止条件不能简单提前编码成一个 token ID:同一字符串可能有多种 token 切分,停止串也可能跨 token 边界。协议层把这些条件写入内部采样参数,Scheduler 与 Detokenizer 再各处理自己能可靠判断的部分。

图源:SGLang 论文 Figure 2。图中的程序包含文本、图像、分支、gen 和正则约束。现代 OpenAI chat 接口虽然不直接暴露这些 SGLang 原语,却同样要把消息、工具和结构化约束编译成一条模型可消费的 prompt 与生成配置。入口层的价值就在于隔离“外部表达很多样”和“运行时输入必须规整”之间的差异。
协议适配完成后,GenerateReqInput 已经不再是 OpenAI 专属对象。原生 /generate 和 Engine API 也可以构造它。这样,协议差异在进入核心运行时之前汇合;后面的分词、状态登记、IPC 和结果聚合可以复用同一条主干。
TokenizerManager:入口状态的真正所有者#
TokenizerManager 这个名字容易造成两个误解:它既不只负责 tokenizer,也不拥有运行时请求的全部状态。更准确的定位是“入口请求协调器”。它拥有三类东西:
- tokenizer、可选的多模态 processor 以及输入规范化逻辑;
rid -> ReqState映射,用于把异步返回结果交给正确的 HTTP 协程;- 通往 Scheduler 和 Detokenizer 返回通道的 IPC 端点。
ReqState 是入口侧的长期状态,核心字段可以缩成:
1@dataclass2class ReqState:3 out_list: list[dict]4 finished: bool5 event: asyncio.Event6 obj: GenerateReqInput7 dispatched: bool = False8 abort_sent: bool = False9 last_output_offset: int = 010 text: str = ""11 text_chunks: list[str] = field(default_factory=list)12 output_ids: list[int] = field(default_factory=list)event 是生产者与消费者之间的门铃。后台 handle_loop() 从 Detokenizer 方向收到批量结果,按 rid 找到状态,将结果放入 out_list 并唤醒等待协程;generate_request() 产生的异步生成器被唤醒后取走列表,再把结果交给 HTTP 层。它不是每来一个 token 就创建一条从 Scheduler 到 socket 的专用连接,而是用共享返回通道加 rid 做多路复用。
dispatched 与 abort_sent 解决失败窗口。请求状态往往在分词前就已经登记:如果模板、长度校验或多模态处理在派发前报错,Scheduler 从未见过该请求,入口只需删除本地状态;如果请求已经派发,入口异常就必须发 AbortReq,让 Scheduler 释放 KV 和批次状态。没有 dispatched 标记,同一个异常处理器无法判断应该“本地丢弃”还是“跨进程中止”。
可以把一条请求看成两个互不共享内存的状态机:
1入口进程 ReqState2created -> dispatched -> receiving -> finished -> removed3 \-> client_disconnected -> abort_sent -> removed4
5调度进程 Req6waiting -> running -> finished -> KV/cache accounting complete7 \-> aborted --------------------^两条状态机只通过消息对齐。rid 是关联键,finished_reason 和 AbortReq 是状态转换证据。源码阅读时若只盯着其中一条,就会产生“删除 rid_to_state 是否已经释放显存”这样的错误问题;答案是否定的,本地字典删除不会跨进程释放任何 KV。
从文本到 TokenizedGenerateReqInput#
generate_request() 的主路径先调用 normalize_batch_and_arguments(),再初始化 ReqState,随后对单请求调用 _tokenize_one_request()。输入可以是三种形态:文本、token IDs 或 input embeddings。三者不能被当成等价的 Python 容器,因为它们处在不同的语义阶段。
text还需要 tokenizer 决定 token 边界和特殊 token;input_ids已经承诺了确切模型词表编号,入口不应擅自重编码;input_embeds绕过 embedding lookup,还会影响前缀缓存能否安全复用。
当前实现的主判断可以压缩为:
1if obj.input_embeds is not None:2 require_radix_cache_disabled()3 input_embeds = obj.input_embeds4 input_ids = obj.input_ids5elif obj.input_ids is not None:6 input_ids = obj.input_ids7else:8 require_tokenizer_initialized()9 input_ids = await tokenize_text(obj.text)10
11if obj.contains_mm_input():12 mm_inputs, input_ids = await multimodal_process(...)13
14return TokenizedGenerateReqInput(15 rid=obj.rid,16 input_text=obj.text,17 input_ids=input_ids,18 input_embeds=input_embeds,19 mm_inputs=mm_inputs,20 sampling_params=validated_sampling_params,21 stream=obj.stream,22 ...23)为什么 input_embeds 默认要求关闭 radix cache?Radix cache 的匹配键主要来自 token 序列。两条请求即使 input_ids 相同,也可能注入不同 embedding;若只按 token 键复用前一条请求的 KV,就会把不同向量输入误判成同一前缀。除非缓存键同时包含足够的 embedding 身份信息,最安全的默认行为就是禁止这种复用。
多模态输入又多一层:processor 可能把图像占位符扩展为一串视觉 token,并生成与这些位置对应的特征。此时“先对原始 prompt 普通分词”不一定正确,所以源码会根据 processor 能力决定由谁产生最终 input_ids。最终通过 IPC 的不是原始图片 URL 和一句模糊 prompt,而是已经建立对应关系的 token、媒体特征与元数据。
TokenizedGenerateReqInput 仍然保留 input_text,但调度执行的权威输入是 input_ids。文本可用于日志、输出解释或某些扩展;Scheduler 计算前缀、序列长度和 KV 位置时不能重新从文本猜 token。到这个边界为止,所有影响模型计算的输入都必须确定下来。
采样参数也在入口侧变成经过校验的 SamplingParams,而不是任意字典。这样可以在占用 GPU 资源之前拒绝非法温度、越界 top-k、互相冲突的约束或过长上下文。错误越早暴露,清理路径越短;已经进入 waiting queue 后再发现协议字段无效,会平白增加一次跨进程往返。
IPC 边界:消息怎样进入 Scheduler#
_send_one_request() 在派发前处理共享内存或多模态特征句柄,记录时间戳,将少数不能直接走消息编码的字段包裹起来,然后调用 _dispatch_to_scheduler()。成功派发后才把入口 ReqState.dispatched 设为真。这个先后顺序不是装饰:标记必须反映 Scheduler 是否可能已经持有资源。
默认 Python 前端路径通过进程间通信把 TokenizedGenerateReqInput 送入 Scheduler。单 Tokenizer worker 时,可以把逻辑拓扑理解为:
1HTTP coroutine2 |3 | await tokenize / normalize4 v5TokenizerManager -- PUSH/message --> Scheduler input receiver6 ^ |7 | | BatchTokenIDOutput8 | BatchStrOutput v9 +------------------------- DetokenizerManager这里是异步单向流水,不是逐步 RPC。入口把请求送出后,不会阻塞在“Scheduler 收到请回复确认”的调用栈上,而是在本地 ReqState.event 上等待真正输出。好处是 Scheduler 可以把许多请求连续收进 waiting queue,再按自己的批处理节奏执行;代价是每个阶段都必须显式管理身份、背压、异常和清理。
消息对象里不应夹带只能在本进程使用的对象,例如 asyncio Future、打开的文件或 HTTP Request。跨进程传递的是可序列化事实。大块多模态特征若直接 pickle,会产生额外复制,因此当前代码存在共享内存和虚拟内存传输路径:消息携带句柄,真正数据由约定的生命周期管理。这是优化细节,但它遵守同一原则——跨边界共享的是可验证的所有权协议,而不是 Python 引用本身。
批请求会被拆成多个独立 rid,也可封装为 BatchTokenizedGenerateReqInput 一次派发。外部批次不等于 GPU 永久绑定批次:Scheduler 仍可根据长度、缓存命中和资源把请求组织进不同轮次。入口的 batch 语义回答“结果怎样汇总给调用者”,Scheduler batch 回答“这一轮 GPU 执行谁”,二者不应混为一谈。
截至本文固定的提交,SGLang 还包含可选 Rust 前端路径。它可以替代一部分 Python HTTP、tokenize 与 detokenize 热路径,并改变具体传输实现;默认 Python 链路中的核心契约仍有参考价值:请求必须在设备调度前完成语义收敛,输出必须按 rid 回到连接所有者。阅读 Rust 分支时,应寻找与 GenerateReqInput、tokenized wire schema 和返回帧对应的协议,而不是强行寻找同名 Python 调用栈。
返回路径:Token ID 为什么不能直接变成文本增量#
Scheduler 完成一轮 forward 和采样后,会为一批请求构造 BatchTokenIDOutput。与名字相比,更重要的是它携带了增量反分词所需的上下文:
rids:批内每一行属于哪个请求;decode_ids:本次需要并入解码状态的 token ID;decoded_texts、read_offsets:已知文本和可读取边界;finished_reasons:请求是否结束,以及匹配了哪一种停止条件;skip_special_tokens、spaces_between_special_tokens:tokenizer 解码选项;- prompt、completion、cached token 数与可选 logprob 等返回元数据。
最直觉的流式实现是每轮执行:
1delta = tokenizer.decode([new_token_id])2send(delta)它对许多 tokenizer 都不正确。token 是模型词表单元,不保证恰好对应独立 Unicode 字符或最终字符串片段。常见反例包括:
- 一个中文字符的 UTF-8 字节可能分散在多个 token 中;
- byte-level BPE 的空格语义依赖相邻 token;
- SentencePiece 的词首标记在孤立解码时可能产生不同空格;
- special token 是否显示取决于整段解码选项;
- tokenizer cleanup 可能根据相邻字符调整结果。
另一个看似正确的方案是每轮把全部 output IDs 从头 decode,再从上次字符串长度处切片。它保证上下文完整,却会让长度为 n 的输出产生近似平方级工作:第 1 轮解码 1 个 token,第 2 轮解码 2 个,直到第 n 轮解码 n 个。
1+2+⋯+n=2n(n+1)=O(n2)式中的 n 是输出 token 数。长输出和高并发下,这种 CPU 开销会直接拖慢首尾流水线,即使 GPU 已经完成采样,客户端仍要等反分词。
SGLang 的方案是在 DetokenizerManager 中为每个 rid 保存一个有限窗口状态,只重解码足够消除边界歧义的后缀。Scheduler 可以继续准备下一轮 GPU 工作,而独立 Detokenizer 进程在 CPU 上完成字符串恢复。这就是它被拆成进程,而不是塞进 Scheduler 热循环的主要原因。
DetokenizerManager 的增量解码状态机#
每个活跃请求对应一个 DecodeStatus:
1@dataclass2class DecodeStatus:3 decoded_text: str4 decode_ids: list[int]5 surr_offset: int6 read_offset: int7 sent_offset: int = 0surr_offset 是 surrogate context(代理上下文)的起点,read_offset 是已经确认可作为上下文的 token 边界,sent_offset 则是已经发给上游的字符位置。它们属于两种坐标系:前两个以 token 为单位,最后一个以解码后字符串字符位置为单位。把它们混成一个 offset,是实现增量解码时最常见的错误之一。
设累计 token 序列为 T,代理起点为 s,读取边界为 r,tokenizer 的解码函数为 D(⋅)。每轮计算两段文本:
Ssurr=D(T[s:r])Sread=D(T[s:])如果 tokenizer 在共同前缀上的解码稳定,那么新增文本为:
ΔS=Sread[∣Ssurr∣:]Ssurr 提供必要的左侧上下文,Sread 包含同一上下文和新 token;从后者裁掉前者的字符长度,得到的才是增量。这里不能用新增 token 数去切字符串,因为 token 数与字符数没有固定比例。
对应源码主干可以整理为:
1for rid in batch.rids:2 state = get_or_create_decode_status(rid)3 state.decode_ids.extend(batch.decode_ids[rid])4
5 surr_ids = state.decode_ids[state.surr_offset:state.read_offset]6 read_ids = state.decode_ids[state.surr_offset:]7
8 surr_text = tokenizer.decode(surr_ids)9 read_text = tokenizer.decode(read_ids)10 new_text = read_text[len(surr_text):]11
12 if request_is_running and text_is_complete(new_text):13 emit(new_text)14 state.surr_offset = state.read_offset15 state.read_offset = len(state.decode_ids)16 elif request_is_running:17 emit(printable_prefix(new_text))18 # 不提交 token 偏移,下一轮带更多 token 重试19 else:20 emit(trim_stop_and_take_unsent_tail(...))21 delete_decode_status(rid)当 new_text 以 Unicode replacement character � 结尾时,当前 token 后缀很可能还没有组成完整字符。实现只取可打印前缀,但不推进 surr_offset 和 read_offset,下一轮加入更多 token 后重新解码同一窗口。sent_offset 记录已经临时发送过多少可打印内容,用于避免重试时重复发送。
这个机制维护三个不变量:
- 已提交的
decoded_text不包含不完整字符; - 每个稳定字符最多向上游发送一次;
- 若边界尚不稳定,保留足够 token 上下文供下一轮重新解释。
为了提高批量流式场景的 CPU 效率,当前实现会按 (skip_special_tokens, spaces_between_special_tokens) 对批内请求分组,再调用 batch_decode;空 token span 直接返回空串。若不同请求的解码标志不同,它们不能粗暴塞进同一次共享参数的 batch decode,否则某些 special token 会被错误保留或删除。
decode_status 是有上限的 LimitedCapacityDict,默认容量由 SGLANG_DETOKENIZER_MAX_STATES 控制。上限防止异常请求使 CPU 内存无限增长,但也引入显式失败模式:若并发活跃状态超过容量,最旧状态可能被驱逐,后续增量无法继续,代码会报错并提示增大容量。这个字典容量不是 GPU 最大并发数的同义词;它只约束 Detokenizer 保留的 CPU 解码状态。
请求完成时,Detokenizer 不再等待下一轮来修复边界,而是物化剩余文本、按照完成原因裁掉匹配的 stop,再输出从 sent_offset 开始的尾部,最后删除该 rid 的 DecodeStatus。它随后构造 BatchStrOutput,把 rids、finished_reasons、计数、logprob 等字段原样带回,只将 token 解码字段替换或补充为 output_strs。
停止条件:Token 语义与字符串语义的分界#
停止条件至少分成三类,不能全部塞进同一层:
| 条件 | 最可靠的判断位置 | 原因 |
|---|---|---|
max_new_tokens | Scheduler | 它拥有权威输出 token 计数 |
| EOS / stop token ID | Scheduler | 新采样 ID 可直接与 token 集合比较 |
| stop string | 需要文本状态参与 | 字符串可能跨 token,单个 ID 不足以判断 |
一个停止串 END 可能是单个 token,也可能拆成 E + ND,还可能因为前导空格而有不同编码。只把 tokenizer.encode(END) 的某一种结果当停止 token 序列,会漏掉合法文本匹配。反过来,如果等 HTTP 层拿到 SSE 片段后才搜索停止串,停止串的一部分可能已经泄漏给客户端,无法撤回。
SGLang 的完成原因随 BatchTokenIDOutput 一起到达 Detokenizer,其中 matched 可以是字符串或整数 token ID。trim_matched_stop() 根据匹配类型处理不同数据:字符串在最终文本中定位,token ID 则在 token 列表尾部裁剪。no_stop_trim 决定匹配内容是否保留。把匹配证据放入完成原因还有一个好处:出口层不必重新猜测为什么结束,能够稳定映射为 OpenAI 的 stop、length 或错误状态。

图源:SGLang 论文 Figure 4。图中有限状态机决定每一步允许哪些 token,属于采样约束;Detokenizer 决定这些 token 何时形成稳定字符,属于文本恢复。二者都会接触“输出约束”,但状态空间不同:FSM 状态是语法节点,DecodeStatus 是 token/字符边界。结构化输出即使保证 JSON 语法正确,也不能省略增量反分词。
还有一个细节:停止匹配与显示裁剪不是同一件事。Scheduler 负责决定“请求不应继续生成”,Detokenizer 负责决定“匹配内容是否应该出现在最终文本”。这种分工让设备控制流和用户可见文本各自有唯一责任人。
流式 SSE:把内部输出还原为协议事件#
TokenizerManager.handle_loop() 持续从 Detokenizer 返回通道读取消息。对于 BatchStrOutput,它按 rid 查找 ReqState,更新 token 计数、完成原因、logprob 和文本,再把准备好的字典放入 state.out_list,最后设置 state.event。等待中的异步生成器被唤醒后原子地取走当前列表并清空 event。
这里使用列表而不是单槽位,是因为生产者可能在 HTTP 协程重新获得事件循环调度之前连续放入多个 chunk。若只保存“最新输出”,中间 token IDs、logprob 或文本增量会丢失。启用增量 streaming 时,_coalesce_streaming_chunks() 会把排队的多个 delta 合并后再交付;非增量模式则可以只关心最新累计视图。
当前实现还专门避免一个隐蔽的 O(n2) 字符串问题。Python 字符串不可变,若每轮都执行 full_text += delta 并复制整段文本,长输出会反复搬运已有字符。ReqState 先把小块追加到 text_chunks,只在需要完整结果时一次 join 物化。对真正的增量流,外部只需要当前 delta,不必每轮重建全文。
原生 /generate 的 SSE 主干十分直接:
1async def stream_results():2 async for out in tokenizer_manager.generate_request(obj, request):3 yield b"data: " + json_encode(out) + b"\n\n"4 yield b"data: [DONE]\n\n"OpenAI chat 路径还会把内部 text 解析为 reasoning、tool call 或普通 content delta,再构造 ChatCompletionChunk。SSE 只是传输帧:每个事件以 data: 开头,以两个换行结束;[DONE] 表示应用层流结束。HTTP chunk 边界不保证等于 token 边界,SSE event 边界也不应被当作单字符边界。
首个流式事件可能只携带 assistant role,后续事件携带 content,最后一个事件给出 finish_reason;若请求要求 usage,还可能有专门统计 chunk。这些都是 OpenAI adapter 的协议职责。BatchStrOutput 不应提前长成这套结构,否则原生接口和其他协议会被迫理解 OpenAI 的选择数组与角色字段。
非流式请求复用同一内部返回管线,只是不在中间结果到达时向外 yield,而是在完成后物化全文并返回一个 JSON 响应。因此,“非流式”不代表 Scheduler 一次性生成全部 token;自回归循环仍逐轮推进,差别只在入口侧何时暴露中间状态。
Abort 与断连:分布式资源回收协议#
客户端关闭 TCP 连接时,Python 对象不会自动跨进程析构。入口可能知道连接断了,Scheduler 却仍在生成;如果不发送控制消息,请求会继续占用 KV、batch slot 和算力,直到自然命中停止条件。
SGLang 在等待输出时周期性检查 request.is_disconnected()。发现断连后,TokenizerManager.abort_request(rid) 构造 AbortReq 并派发给 Scheduler;abort_sent 防止重复发送。流式响应还注册了 background abort task,覆盖响应生成器被取消等路径。当前代码中 background task 会稍作延迟再检查 rid 是否仍存在,目的是让正常完成的清理先发生,避免把刚刚完成的请求误报为 abort。
完整回收不是单个 del,而是一组按所有权执行的动作:
1HTTP/TokenizerManager2 - 停止向断开的连接写数据3 - 发送 AbortReq4 - 最终删除 rid_to_state,释放 LoRA 引用等入口资源5
6Scheduler7 - 从 waiting/running 状态移除请求8 - 结束或标记本轮执行9 - 释放请求槽与不可保留的 KV 所有权10 - 产生带 abort finish reason 的最终消息11
12DetokenizerManager13 - 输出必要的最终状态或忽略迟到数据14 - 删除 DecodeStatus为什么入口不能发完 AbortReq 就立刻无条件删除状态?消息在途时仍可能返回一个最终结果或 abort 确认;过早删除会让返回路径找不到 rid,并掩盖 Scheduler 是否真正完成清理。当前实现区分“派发前失败”和“派发后失败”:前者直接移除本地状态,后者发送 abort 并保留必要状态,直到调度返回路径闭合。
重复 rid 也会在 _init_req_state() 被拒绝。若允许两个并发连接共享同一 rid,返回消息无法判断该唤醒哪一个等待者,abort 也可能杀掉错误请求。rid 不是方便日志查看的可选标签,而是分布式生命周期的主键。
用断点与日志跑通一个请求#
阅读这条链路最有效的方法不是从 tokenizer_manager.py 第一行读到最后一行,而是固定一个短请求,只跟同一个 rid。先关闭多模态、batch、投机解码和多 Tokenizer worker,用单并发流式请求建立基线。
建议准备一条容易观察 token 边界的输入,例如要求模型输出中英文与 emoji 混合的短句,并显式设置停止串。记录每一站的对象类型、rid、token 数、文本增量和完成原因:
1OpenAI adapter2 ChatCompletionRequest3 -> GenerateReqInput4
5TokenizerManager.generate_request6 -> _init_req_state7 -> _tokenize_one_request8 -> _send_one_request9
10Scheduler boundary11 TokenizedGenerateReqInput12 -> ... forward and sampling ...13 -> BatchTokenIDOutput14
15DetokenizerManager16 -> _decode_batch_token_id_output17 -> BatchStrOutput18
19TokenizerManager.handle_loop20 -> _handle_batch_output21 -> ReqState.event.set22 -> _stream_one_response23
24OpenAI serving layer25 internal result26 -> ChatCompletionChunk27 -> SSE data frame第一轮只观察对象,不进入 Transformer layer。可以用符号搜索建立导航:
1rg -n "class (GenerateReqInput|TokenizedGenerateReqInput|BatchTokenIDOutput|BatchStrOutput)" \2 python/sglang/srt/managers/io_struct.py3
4rg -n "def (generate_request|_tokenize_one_request|_send_one_request|handle_loop)" \5 python/sglang/srt/managers/tokenizer_manager.py6
7rg -n "class DecodeStatus|def (_decode_batch_token_id_output|trim_matched_stop)" \8 python/sglang/srt/managers/detokenizer_manager.py断点或日志至少回答下列问题:
| 观察点 | 应检查的事实 | 异常意味着什么 |
|---|---|---|
| 协议适配后 | input_ids 是否已由 chat template 产生 | 模板路径与预期不同,或 prompt 被重复编码 |
_init_req_state 后 | rid_to_state[rid] 是否唯一 | request ID 冲突或批请求拆分错误 |
| 派发前后 | dispatched 何时变为真 | 失败清理可能误用本地删除或 abort |
| Detokenizer 首次收到 | read_offset 与 decode_ids 长度 | Scheduler 输出窗口不完整或边界错位 |
| 每次文本输出 | 拼接全部 delta 是否等于最终文本 | 重复发送、漏发或 Unicode 恢复错误 |
| 最终 chunk | finish_reason.matched 与裁剪结果 | 停止判断与显示语义不一致 |
| 请求结束后 | 三处状态是否都消失 | 入口、Scheduler 或 Detokenizer 发生泄漏 |
验证增量正确性时,不要只看屏幕上“像不像”。把所有 SSE content delta 按顺序连接,记为 Sstream;再用同样参数发一个非流式请求并固定随机性,得到 Sfinal。在生成 token 序列相同的前提下,应满足:
Sstream=Sfinal如果不相等,先定位是 token 序列已经不同,还是相同 token 在 detokenize/SSE 阶段被重复或遗漏。前者属于采样与确定性问题,后者才属于本篇链路。把两类问题混在一起,会浪费大量时间追错误模块。
断连测试也应覆盖三个时刻:请求尚在 waiting queue、已经进入 decode、刚产生最终输出但客户端尚未读完。观察 AbortReq 是否只发送一次、Scheduler 是否结束请求,以及迟到结果是否被安全处理。只测试“生成中间按 Ctrl+C”无法覆盖派发前失败和完成竞态。
性能分析则分成四段时间:协议/模板、tokenization、Scheduler 排队与执行、detokenization/响应派发。TTFT 变高不等于 GPU prefill 变慢。若 Scheduler 已产生首 token,而 TokenizerManager.handle_loop() 长时间未被事件循环调度,瓶颈在 CPU 返回路径;若 _tokenize_one_request() 前已经耗时很久,应检查 chat template、多模态下载或请求排队。以阶段时间戳而不是总延迟猜原因,才能避免错误优化 GPU Kernel。
设计取舍、边界条件与常见误读#
误读一:TokenizerManager 就是一个 tokenizer 包装器#
它还拥有入口 ReqState、请求等待与结果聚合、IPC 派发、断连检测、LoRA 引用和部分可观测性状态。分词只是主路径中的一步。若修改它的并发逻辑,需要同时考虑 HTTP 协程和返回 handle_loop(),不能只跑 tokenizer 单元测试。
误读二:一个输出 token 对应一个 SSE chunk#
Scheduler 可按 stream_interval 或内部策略降低输出频率;事件循环拥塞时,多个内部 chunk 还会被合并。一个 token 也可能暂时解不出稳定字符。正确契约是 SSE 提供有序文本增量,不承诺 token 与网络帧一一对应。
误读三:stream=false 会使用另一套推理路径#
设备侧仍然进行相同的自回归调度。区别主要在入口侧:流式请求不断暴露增量,非流式请求等待完成后返回累计结果。两者若在相同生成配置下出现 token 差异,应先查采样、batch 时序和确定性,而不是把差异归因于 JSON 响应方式。
误读四:看到 stop string 就应该立即从当前 chunk 删除#
当前 chunk 可能只包含停止串前半部分,也可能包含与其他文本相同的前缀。可靠实现需要跨 chunk 文本状态,并在完成原因明确后裁剪匹配部分。过早发送不可撤回,过早裁剪则可能吞掉普通文本。
误读五:删除入口状态就等于完成 abort#
入口字典、Scheduler 请求、KV 页和 Detokenizer 状态属于不同进程。跨进程回收依赖 AbortReq 和最终状态消息。只删 rid_to_state 最多能避免入口内存泄漏,无法停止 GPU 工作。
误读六:所有请求都会经过 Python Detokenizer#
若启动时跳过 tokenizer 初始化,系统可以直接返回 token IDs;多 Tokenizer/Detokenizer worker 会改变 socket 路由;可选 Rust 前端还会替代部分 Python 热路径。判断实际链路必须同时查看启动参数、Engine 进程创建和 IPC 初始化,不能只因为仓库里存在一个类就断言它一定运行。
为什么采用独立进程,而不是线程#
tokenization、增量 decode 和协议组装都消耗 CPU,并可能进入 Python 或 tokenizer 库的不同执行路径。拆进程可以隔离 Python 解释器与故障域,让 Scheduler 在 Detokenizer 工作时继续推进 GPU。不过,拆分也带来代价:每条消息需要序列化或句柄传输,每个进程要维护自己的状态,完成与 abort 变成分布式协议。
这不是“进程一定比线程快”的普遍结论,而是流水线隔离与 IPC 成本之间的选择。高并发下若 Detokenizer 饱和,增加 GPU 并不能改善文本到达延迟;若消息很小且 CPU 充足,独立进程能把 GPU 控制线程从字符串工作中解放出来。启用更多 tokenizer/detokenizer worker 时,还要确保 http_worker_ipc 等路由字段把结果送回拥有相应 ReqState 的 worker。
为什么保留两份状态#
ReqState 与 DecodeStatus 看似都按 rid 保存文本,实际关注点不同:前者服务于 API 等待者,保存累计结果、事件和协议元数据;后者服务于 tokenizer 边界,保存 token 窗口与字符提交位置。若强行合并,Detokenizer 就必须访问主进程 asyncio 对象,或主进程必须承担全部解码,都会破坏进程边界。
两个状态都需要文本字段并不等于无意义复制。DecodeStatus.decoded_text 是生成稳定 delta 所需的内部账本,ReqState.text_chunks 是最终响应与非流式聚合账本。可以分别优化表示,但不能让一个进程依赖另一个进程的可变 Python 对象。
边界条件清单#
真实部署至少要考虑这些不走“普通英文短句”快乐路径的情况:
- 空 prompt、已经预分词的
input_ids、跳过 tokenizer 初始化; - 中文、组合字符、emoji、byte fallback 与 tokenizer 清理空格;
- stop string 跨 token、多个 stop 同时可能匹配、保留停止文本;
- chat template 自带 BOS/EOS,避免再次添加 special token;
- 客户端在 waiting、running、finished 三个窗口断开;
- batch 中某个子请求先结束,其他请求继续;
- 多个输出在一次 event 唤醒前到达;
- Detokenizer 状态容量不足和迟到消息;
- 多 Tokenizer worker 下结果返回错误 worker;
- 多模态 placeholder token 超出普通词表范围。
当前 Detokenizer 会把负数或超词表的占位 ID 钳到安全 ID,避免某些 tokenizer 对非法编号抛出异常。它依赖这些占位符只出现在 surrogate context,不应改变“read 文本减 surrogate 文本”所得的增量。这个例子说明源码中的防御逻辑往往依赖局部不变量;只复制一行 clamp 到别处,而不了解窗口差分关系,可能掩盖真正的数据错误。
小结与后续路线#
SGLang 的入口与出口并不是薄薄一层 HTTP 胶水,而是一套异步请求协议。OpenAI adapter 将角色消息、工具和模板收敛成 GenerateReqInput;TokenizerManager 完成剩余 tokenization、建立 ReqState 并派发 TokenizedGenerateReqInput;Scheduler 返回与外部协议无关的 BatchTokenIDOutput;DetokenizerManager 用代理上下文恢复稳定文本增量,生成 BatchStrOutput;入口再按 rid 唤醒等待者,并封装为 JSON 或 SSE。
整条链路可以用四个不变量检验:影响模型计算的 token 在进入 Scheduler 前已经确定;入口状态与调度状态通过 rid 对齐;已发送文本不会重复也不会包含未完成字符;任何结束路径最终都能让各进程释放自己拥有的状态。只要这四点成立,具体 IPC 后端、worker 数量和协议适配器都可以演进。
下一篇将进入 Scheduler 请求状态机,沿 TokenizedGenerateReqInput -> Req 继续追踪一条请求怎样进入 waiting queue、何时变成 running batch、如何判断 finished,以及 abort、OOM retract 和正常完成怎样收敛到同一套资源账本。
参考资料#
- SGLang: Efficient Execution of Structured Language Model Programs(arXiv)
- SGLang 论文(NeurIPS 2024)
- SGLang 官方仓库
- SGLang OpenAI-Compatible APIs 官方文档
- OpenAI chat 协议适配源码(固定提交)
- SGLang HTTP Server 与原生流式响应源码(固定提交)
- TokenizerManager 源码(固定提交)
- DetokenizerManager 源码(固定提交)
- SGLang IPC 数据结构源码(固定提交)
- SGLang Engine 与进程启动源码(固定提交)
- Hugging Face Tokenizer decode 与 batch_decode API
- HTML Living Standard:Server-sent events
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



