RequestStage、ReqTimeStats 与时间线证据#

扩展与调试这一节已经分别讲了 metrics、trace、exporter、watchdog 和 crash dump,但如果没有一章把它们重新放回同一条时间线,读者仍然很容易把这些能力看成几套互不相关的工具。req_time_stats.py 的价值正在这里:它不是“多打一层点”,而是给整条请求链建立了一套统一时间语义。

这意味着你在前面几节里见到的 queue time、TTFT、per-stage latency、trace slice 和 response_sent_to_client_time,并不是各自独立的指标名,而是同一条请求时间线上的不同切片。只有把这一点读稳,维护层才会真正像一本系统书,而不是 observability 工具箱。

先把它放回同一条请求时间线#

下面这张图的作用,不是再画一遍 request lifecycle,而是把那些最容易被误读成“互相平行”的时间字段重新放回同一条顺序线上:

flowchart LR
    Create["created"] --> Tok["TOKENIZE"]
    Tok --> APID["API_SERVER_DISPATCH"]
    APID --> Wait["PREFILL_WAITING / DECODE_WAITING"]
    Wait --> Fwd["PREFILL_FORWARD / DECODE_FORWARD"]
    Fwd --> Loop["DECODE_LOOP"]
    Loop --> Resp["response_sent_to_client"]
    Resp --> Done["finished"]

这张图最重要的作用,是强迫读者先接受一件事:很多看起来像不同观测口径的字段,其实只是同一条内部时间线被切成了不同片段。

RequestStage 真正提供的不是枚举,而是公共时序语言#

RequestStage 最值得注意的,不是“列出了很多阶段名”,而是这些阶段名本身已经长成了一套跨模块复用的时序语言。例如:

  • TOKENIZE
  • API_SERVER_DISPATCH
  • REQUEST_PROCESS
  • PREFILL_WAITING
  • PREFILL_FORWARD
  • PREFILL_CHUNKED_FORWARD
  • DECODE_WAITING
  • DECODE_FORWARD
  • DECODE_LOOP

再加上 disaggregation 相关的 bootstrap、prepare、transfer 阶段,这实际上已经是一套非常完整的请求阶段切片体系。它的意义不只是方便 trace 展示,而是让 tokenizer、scheduler、执行层和复杂部署路径在记录时间时不需要各发明一套词。

从工程阅读角度看,这一点比“阶段名有哪些”更重要。因为一旦阶段名统一,系统不同位置记录出来的时间就能被真正对齐;如果阶段名各自生长,后面的 metrics 和 trace 就很难自然汇合。

ReqTimeStats 像一条贯穿全书的隐藏主线#

这也是这章最值得写成正文而不是附录的原因。ReqTimeStats 会在很多关键位置出现:

  • TokenizerManager 在 created、tokenize、dispatch 前后更新它
  • Scheduler 在入队、等待、forward、retract、schedule 决策时继续更新它
  • 请求完成与真正发回客户端时又会补上尾部时间

也就是说,它不是 observability 侧独有的小工具,而是一条贯穿 request lifecycle、scheduler 和执行模型的隐藏主线。很多前文章节之所以能自然回扣到第 8 节,靠的就是这层统一时间语义。

最值得抓的不是字段多,而是这些 setter 形成了稳定边界#

ReqTimeStats 这棵树时,最稳的方式不是背字段,而是看这些时间入口如何把请求切成稳定阶段:

  • set_created_time()
  • set_tokenize_finish_time()
  • set_api_server_dispatch_time() / finish
  • set_wait_queue_entry_time()
  • set_forward_entry_time()
  • set_prefill_finished_time()
  • set_last_decode_finish_time()
  • set_response_sent_to_client_time()
  • set_finished_time()

它们并不像普通 setter 那样“只是改个值”。更准确的理解是:它们在源码里给不同层规定了明确的时间边界。只要这些边界稳住了,后面的 queue time、TTFT 和 trace 才有统一出处。

metrics 和 trace 为什么天然会在这里汇流#

很多人直觉上会把 metrics 和 trace 当成两套证据系统:一个更偏统计,一个更偏时序。源码里真正值得注意的地方是,它们其实共享同一套阶段语义。

ReqTimeStats 一方面会在合适的阶段直接去做:

  • observe_queue_time(...)
  • observe_per_stage_req_latency(...)

另一方面,在 trace 打开时又会通过:

  • trace_slice_start(...)
  • trace_slice_end(...)
  • trace_event(...)

把同样的阶段切片导向 trace 系统。这意味着 metrics 和 trace 在这里不是“并列能力”,而是同一条时间语义的两个外显面。也正因为如此,你在现场排障时不该把二者分别当成独立世界,而应先确认它们是不是在讲同一段阶段。

response_sent_to_client_time 之所以重要,是因为它拆开了两次完成#

这一点对整本书很关键。很多系统只记录 finished time,于是“系统内部完成”和“客户端真正看到完成”会被混成同一件事。response_sent_to_client_time 的存在说明 SGLang 在这件事上刻意做了更细的区分:

  • 一次完成是系统内部认为请求已经 finished
  • 另一次完成是响应真正送到了客户端

这在 streaming 场景下尤其重要。因为只要客户端看到的是增量输出,你就不能再把“内部处理结束”和“外部感知完成”简单合并成一个边界。第 8 节后面的很多延迟解释,其实都建立在这里的区分之上。

复杂路径之所以值得看,是因为它们也被纳入了同一时间语义#

这棵树另一个很像“成熟系统才会做”的地方,是它没有把时间线只留给快乐路径。retraction、disaggregation、fake output、quick finish 这些复杂场景也都进入了这套时间语义。这说明:

  • 这不是一套只服务正常请求的观测字段
  • 而是一套试图覆盖异常、优化和复杂部署人格的统一时间线

从技术书角度看,这一点非常值钱。因为很多人一遇到复杂场景就会默认“那些指标可能就不准了”。这里相反,SGLang 在源码里显式承认:复杂路径也应该被放回同一套时间坐标里。

最容易出现的三种误判#

第一,误把 queue time 当成某个 scheduler 局部指标。
更准确的理解是,它只是更长时间线中的一个切片。

第二,误以为 trace 和 metrics 各有自己独立的阶段定义。
源码表明两者在很大程度上共享 RequestStage 语义。

第三,误把 finished time 当成用户感知完成时间。
response_sent_to_client_time 的存在已经明确告诉你,两者不能混读。

真正拿这套时间线排障时,顺序应该很克制#

更稳的做法通常是:

  1. 先确认请求是否有完整的 created -> response_sent_to_client -> finished
  2. 再确认瓶颈主要落在 tokenize、dispatch、waiting、forward 还是 decode loop。
  3. 如果涉及 retract、disaggregation 或 quick finish,再确认是否进入了特殊阶段。
  4. 最后才把这些阶段和 trace、metrics、request logger 一起对齐。

这种顺序的价值在于,它先回答“卡在哪”,再回答“为什么卡”。如果一开始就同时打开所有观测面,你往往会得到很多信息,却很难得到稳定的阶段判断。

小结#

RequestStageReqTimeStats 真正补齐的,不是另一组指标,而是整本书维护层里最通用的一条时间语义主线:阶段名在这里统一,时间切片在这里落地,metrics 与 trace 也在这里重新对齐。

只要这张时间语义地图稳住了,后面再看 TTFT、queue time、placement signal 或 request log,就更不容易各说各话。