SGLang 源码完全拆解(二):入口、分词与流式输出如何闭环

8948 字
45 分钟
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 论文中的前端语言与运行时边界
SGLang 论文中的前端语言与运行时边界

图源:SGLang 论文 Figure 1。图中左侧的 Interpreter 不等于当前某一个 Python 类,它表达的是请求语义的拥有者;右侧 Runtime 则负责执行。映射到当前在线服务,OpenAI adapter、chat template 和 TokenizerManager 位于语义收敛一侧,Scheduler 及 GPU worker 位于执行一侧。DetokenizerManager 横跨两侧之间:它理解 tokenizer,却不理解 HTTP 响应格式。

本文源码以 SGLang 仓库提交 d88d5c3 为坐标。主分支会持续演进,类名和字段可能调整;比行号更稳定的是五段数据变换:

ChatCompletionRequest
-> GenerateReqInput
-> TokenizedGenerateReqInput
-> BatchTokenIDOutput
-> BatchStrOutput
-> ChatCompletionChunk / SSE

中间并不是对同一个大对象不断添字段。每次变换都会丢掉上游不再需要的语义,并加入下游执行所需的事实。这种“窄消息”设计是理解源码的第一把钥匙。

一次请求在入口层经历了哪些形态变化#

先用一条最小 chat completion 请求作为坐标:

{
"model": "served-model",
"messages": [
{"role": "system", "content": "回答要简洁。"},
{"role": "user", "content": "什么是连续批处理?"}
],
"temperature": 0.7,
"max_tokens": 128,
"stop": ["</answer>"],
"stream": true
}

外部协议把输入表达为角色消息、采样选项和流式开关。Transformer 不认识 system 或 user,GPU Kernel 更不认识 JSON。真正送入模型的只能是 token ID、位置、采样参数和少量运行时元数据。相反,客户端最终需要的是符合 OpenAI 协议的 choices[].delta.content,而不是内部 token ID 数组。

因此,入口与出口各做一次降维:

阶段主要对象新增的事实被丢弃或隐藏的细节
HTTP 解析ChatCompletionRequest已校验的协议字段原始 JSON 字节与字段顺序
协议适配GenerateReqInputprompt 或 input_ids、内部采样参数、ridOpenAI 响应类的组织方式
分词派发TokenizedGenerateReqInput确定的 token IDs、SamplingParams、路由信息chat role 与模板选择过程
调度输出BatchTokenIDOutput增量 decode IDs、完成原因、计数与 logprobGPU tensor、KV 物理位置
反分词BatchStrOutput稳定文本增量tokenizer 内部的字节拼接过程
协议封装ChatCompletionChunkdelta、finish_reason、usageScheduler 的队列与批次状态

这张表还能解释为什么不能让 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、停止条件合并、采样参数转换和响应模式选择。当前主线可以概括为:

async def create_chat_completion(request, raw_request):
processed = process_messages_and_template(request)
sampling = build_sampling_params(request, processed.stop)
internal = GenerateReqInput(
input_ids=processed.prompt_ids,
sampling_params=sampling,
stream=request.stream,
rid=request.rid,
return_logprob=request.logprobs,
image_data=processed.image_data,
# 省略工具、路由、多模态和可观测性字段
)
async for result in tokenizer_manager.generate_request(internal, raw_request):
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 论文中的结构化程序、分支与生成原语
SGLang 论文中的结构化程序、分支与生成原语

图源:SGLang 论文 Figure 2。图中的程序包含文本、图像、分支、gen 和正则约束。现代 OpenAI chat 接口虽然不直接暴露这些 SGLang 原语,却同样要把消息、工具和结构化约束编译成一条模型可消费的 prompt 与生成配置。入口层的价值就在于隔离“外部表达很多样”和“运行时输入必须规整”之间的差异。

协议适配完成后,GenerateReqInput 已经不再是 OpenAI 专属对象。原生 /generate 和 Engine API 也可以构造它。这样,协议差异在进入核心运行时之前汇合;后面的分词、状态登记、IPC 和结果聚合可以复用同一条主干。

TokenizerManager:入口状态的真正所有者#

TokenizerManager 这个名字容易造成两个误解:它既不只负责 tokenizer,也不拥有运行时请求的全部状态。更准确的定位是“入口请求协调器”。它拥有三类东西:

  1. tokenizer、可选的多模态 processor 以及输入规范化逻辑;
  2. rid -> ReqState 映射,用于把异步返回结果交给正确的 HTTP 协程;
  3. 通往 Scheduler 和 Detokenizer 返回通道的 IPC 端点。

ReqState 是入口侧的长期状态,核心字段可以缩成:

@dataclass
class ReqState:
out_list: list[dict]
finished: bool
event: asyncio.Event
obj: GenerateReqInput
dispatched: bool = False
abort_sent: bool = False
last_output_offset: int = 0
text: str = ""
text_chunks: list[str] = field(default_factory=list)
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 标记,同一个异常处理器无法判断应该“本地丢弃”还是“跨进程中止”。

可以把一条请求看成两个互不共享内存的状态机:

入口进程 ReqState
created -> dispatched -> receiving -> finished -> removed
\-> client_disconnected -> abort_sent -> removed
调度进程 Req
waiting -> running -> finished -> KV/cache accounting complete
\-> 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,还会影响前缀缓存能否安全复用。

当前实现的主判断可以压缩为:

if obj.input_embeds is not None:
require_radix_cache_disabled()
input_embeds = obj.input_embeds
input_ids = obj.input_ids
elif obj.input_ids is not None:
input_ids = obj.input_ids
else:
require_tokenizer_initialized()
input_ids = await tokenize_text(obj.text)
if obj.contains_mm_input():
mm_inputs, input_ids = await multimodal_process(...)
return TokenizedGenerateReqInput(
rid=obj.rid,
input_text=obj.text,
input_ids=input_ids,
input_embeds=input_embeds,
mm_inputs=mm_inputs,
sampling_params=validated_sampling_params,
stream=obj.stream,
...
)

为什么 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 时,可以把逻辑拓扑理解为:

HTTP coroutine
|
| await tokenize / normalize
v
TokenizerManager -- PUSH/message --> Scheduler input receiver
^ |
| | BatchTokenIDOutput
| BatchStrOutput v
+------------------------- 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 等返回元数据。

最直觉的流式实现是每轮执行:

delta = tokenizer.decode([new_token_id])
send(delta)

它对许多 tokenizer 都不正确。token 是模型词表单元,不保证恰好对应独立 Unicode 字符或最终字符串片段。常见反例包括:

  • 一个中文字符的 UTF-8 字节可能分散在多个 token 中;
  • byte-level BPE 的空格语义依赖相邻 token;
  • SentencePiece 的词首标记在孤立解码时可能产生不同空格;
  • special token 是否显示取决于整段解码选项;
  • tokenizer cleanup 可能根据相邻字符调整结果。

另一个看似正确的方案是每轮把全部 output IDs 从头 decode,再从上次字符串长度处切片。它保证上下文完整,却会让长度为 nn 的输出产生近似平方级工作:第 1 轮解码 1 个 token,第 2 轮解码 2 个,直到第 nn 轮解码 nn 个。

1+2+⋯+n=n(n+1)2=O(n2)1 + 2 + \cdots + n = \frac{n(n+1)}{2} = O(n^2)

式中的 nn 是输出 token 数。长输出和高并发下,这种 CPU 开销会直接拖慢首尾流水线,即使 GPU 已经完成采样,客户端仍要等反分词。

SGLang 的方案是在 DetokenizerManager 中为每个 rid 保存一个有限窗口状态,只重解码足够消除边界歧义的后缀。Scheduler 可以继续准备下一轮 GPU 工作,而独立 Detokenizer 进程在 CPU 上完成字符串恢复。这就是它被拆成进程,而不是塞进 Scheduler 热循环的主要原因。

DetokenizerManager 的增量解码状态机#

每个活跃请求对应一个 DecodeStatus:

@dataclass
class DecodeStatus:
decoded_text: str
decode_ids: list[int]
surr_offset: int
read_offset: int
sent_offset: int = 0

surr_offset 是 surrogate context(代理上下文)的起点,read_offset 是已经确认可作为上下文的 token 边界,sent_offset 则是已经发给上游的字符位置。它们属于两种坐标系:前两个以 token 为单位,最后一个以解码后字符串字符位置为单位。把它们混成一个 offset,是实现增量解码时最常见的错误之一。

设累计 token 序列为 TT,代理起点为 ss,读取边界为 rr,tokenizer 的解码函数为 D(⋅)D(\cdot)。每轮计算两段文本:

Ssurr=D(T[s:r])S_{\text{surr}} = D(T[s:r])Sread=D(T[s:])S_{\text{read}} = D(T[s:])

如果 tokenizer 在共同前缀上的解码稳定,那么新增文本为:

ΔS=Sread[∣Ssurr∣:]\Delta S = S_{\text{read}}[|S_{\text{surr}}|:]

SsurrS_{\text{surr}} 提供必要的左侧上下文,SreadS_{\text{read}} 包含同一上下文和新 token;从后者裁掉前者的字符长度,得到的才是增量。这里不能用新增 token 数去切字符串,因为 token 数与字符数没有固定比例。

对应源码主干可以整理为:

for rid in batch.rids:
state = get_or_create_decode_status(rid)
state.decode_ids.extend(batch.decode_ids[rid])
surr_ids = state.decode_ids[state.surr_offset:state.read_offset]
read_ids = state.decode_ids[state.surr_offset:]
surr_text = tokenizer.decode(surr_ids)
read_text = tokenizer.decode(read_ids)
new_text = read_text[len(surr_text):]
if request_is_running and text_is_complete(new_text):
emit(new_text)
state.surr_offset = state.read_offset
state.read_offset = len(state.decode_ids)
elif request_is_running:
emit(printable_prefix(new_text))
# 不提交 token 偏移,下一轮带更多 token 重试
else:
emit(trim_stop_and_take_unsent_tail(...))
delete_decode_status(rid)

当 new_text 以 Unicode replacement character � 结尾时,当前 token 后缀很可能还没有组成完整字符。实现只取可打印前缀,但不推进 surr_offset 和 read_offset,下一轮加入更多 token 后重新解码同一窗口。sent_offset 记录已经临时发送过多少可打印内容,用于避免重试时重复发送。

这个机制维护三个不变量:

  1. 已提交的 decoded_text 不包含不完整字符;
  2. 每个稳定字符最多向上游发送一次;
  3. 若边界尚不稳定,保留足够 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_tokensScheduler它拥有权威输出 token 计数
EOS / stop token IDScheduler新采样 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 或错误状态。

普通 FSM 与压缩 FSM 都在 token 选择层工作
普通 FSM 与压缩 FSM 都在 token 选择层工作

图源: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)O(n^2) 字符串问题。Python 字符串不可变,若每轮都执行 full_text += delta 并复制整段文本,长输出会反复搬运已有字符。ReqState 先把小块追加到 text_chunks,只在需要完整结果时一次 join 物化。对真正的增量流,外部只需要当前 delta,不必每轮重建全文。

原生 /generate 的 SSE 主干十分直接:

async def stream_results():
async for out in tokenizer_manager.generate_request(obj, request):
yield b"data: " + json_encode(out) + b"\n\n"
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,而是一组按所有权执行的动作:

HTTP/TokenizerManager
- 停止向断开的连接写数据
- 发送 AbortReq
- 最终删除 rid_to_state,释放 LoRA 引用等入口资源
Scheduler
- 从 waiting/running 状态移除请求
- 结束或标记本轮执行
- 释放请求槽与不可保留的 KV 所有权
- 产生带 abort finish reason 的最终消息
DetokenizerManager
- 输出必要的最终状态或忽略迟到数据
- 删除 DecodeStatus

为什么入口不能发完 AbortReq 就立刻无条件删除状态?消息在途时仍可能返回一个最终结果或 abort 确认;过早删除会让返回路径找不到 rid,并掩盖 Scheduler 是否真正完成清理。当前实现区分“派发前失败”和“派发后失败”:前者直接移除本地状态,后者发送 abort 并保留必要状态,直到调度返回路径闭合。

重复 rid 也会在 _init_req_state() 被拒绝。若允许两个并发连接共享同一 rid,返回消息无法判断该唤醒哪一个等待者,abort 也可能杀掉错误请求。rid 不是方便日志查看的可选标签,而是分布式生命周期的主键。

用断点与日志跑通一个请求#

阅读这条链路最有效的方法不是从 tokenizer_manager.py 第一行读到最后一行,而是固定一个短请求,只跟同一个 rid。先关闭多模态、batch、投机解码和多 Tokenizer worker,用单并发流式请求建立基线。

建议准备一条容易观察 token 边界的输入,例如要求模型输出中英文与 emoji 混合的短句,并显式设置停止串。记录每一站的对象类型、rid、token 数、文本增量和完成原因:

OpenAI adapter
ChatCompletionRequest
-> GenerateReqInput
TokenizerManager.generate_request
-> _init_req_state
-> _tokenize_one_request
-> _send_one_request
Scheduler boundary
TokenizedGenerateReqInput
-> ... forward and sampling ...
-> BatchTokenIDOutput
DetokenizerManager
-> _decode_batch_token_id_output
-> BatchStrOutput
TokenizerManager.handle_loop
-> _handle_batch_output
-> ReqState.event.set
-> _stream_one_response
OpenAI serving layer
internal result
-> ChatCompletionChunk
-> SSE data frame

第一轮只观察对象,不进入 Transformer layer。可以用符号搜索建立导航:

Terminal window
rg -n "class (GenerateReqInput|TokenizedGenerateReqInput|BatchTokenIDOutput|BatchStrOutput)" \
python/sglang/srt/managers/io_struct.py
rg -n "def (generate_request|_tokenize_one_request|_send_one_request|handle_loop)" \
python/sglang/srt/managers/tokenizer_manager.py
rg -n "class DecodeStatus|def (_decode_batch_token_id_output|trim_matched_stop)" \
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 恢复错误
最终 chunkfinish_reason.matched 与裁剪结果停止判断与显示语义不一致
请求结束后三处状态是否都消失入口、Scheduler 或 Detokenizer 发生泄漏

验证增量正确性时,不要只看屏幕上“像不像”。把所有 SSE content delta 按顺序连接,记为 SstreamS_{\text{stream}};再用同样参数发一个非流式请求并固定随机性,得到 SfinalS_{\text{final}}。在生成 token 序列相同的前提下,应满足:

Sstream=SfinalS_{\text{stream}} = S_{\text{final}}

如果不相等,先定位是 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 和正常完成怎样收敛到同一套资源账本。

参考资料#

  1. SGLang: Efficient Execution of Structured Language Model Programs(arXiv)
  2. SGLang 论文(NeurIPS 2024)
  3. SGLang 官方仓库
  4. SGLang OpenAI-Compatible APIs 官方文档
  5. OpenAI chat 协议适配源码(固定提交)
  6. SGLang HTTP Server 与原生流式响应源码(固定提交)
  7. TokenizerManager 源码(固定提交)
  8. DetokenizerManager 源码(固定提交)
  9. SGLang IPC 数据结构源码(固定提交)
  10. SGLang Engine 与进程启动源码(固定提交)
  11. Hugging Face Tokenizer decode 与 batch_decode API
  12. HTML Living Standard:Server-sent events

文章分享

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

SGLang 源码完全拆解(二):入口、分词与流式输出如何闭环
https://pinghaoyang.com.cn/aigc/posts/sglang-request-lifecycle/
作者
平昊阳
发布于
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 天前

文章目录