Session、timeout 与 abort 分叉#

3.13.2 讲的是最小主链:请求怎样进入 runtime,结果又怎样回到协议表面。但真实系统里并不只有这条标准路径。session、timeout 和 abort 都会改变请求对象的命运,而且它们改变的不是“输出样式”,而是请求到底还能不能沿主链继续走下去。

这一节只处理三类正式分叉:

  1. session 会改写 Req 的构造方式;
  2. timeout 会改写请求结束的阶段;
  3. abort 会从 API server、client 或 scheduler 不同方向切断主链。

一张图先看分叉#

先把这几条分叉压成一张图:

flowchart TB
    A["标准生成请求"] --> B["进入 tokenizer / scheduler 主链"]
    A --> C["带 session_params"]
    C --> D["Session.create_req<br/>复用前缀 / append / replace"]
    A --> E["client disconnect / explicit abort"]
    E --> F["TokenizerManager.abort_request"]
    A --> G["waiting timeout / running timeout"]
    G --> H["Scheduler abort paths"]

这张图里最重要的一点是:session、timeout 和 abort 都不是主链末尾的补丁,而是会直接改写请求路径和对象状态的正式分叉。

session 改写的是 Req 的构造方式#

session 相关逻辑真正站在 scheduler 一侧。请求进入 Scheduler.handle_generate_request 以后,如果带了 session_params,就不会再走普通 Req 构造路径,而是先根据 session_id 决定后续分叉:

  • session_id 为空:按普通请求处理;
  • session_id 存在且 session 仍然活着:进入 Session.create_req
  • session_id 存在但找不到:直接构造 abort。

这意味着 session 不是“多带几个参数”,而是把请求如何从 TokenizedGenerateReqInput 变成 Req 这件事本身改掉了。

Session.create_req(...) 真正在做什么#

Session.create_req 的职责,是在已有 request history 上构造一条新的请求。这里最重要的不是实现细节,而是它允许和禁止什么:

  1. streaming session 只允许简单 append,不允许 replacedrop_previous_output 或非零 offset
  2. 非 streaming session 可以按 replaceoffset 改写前缀;
  3. 如果引用的旧 rid 不存在,直接 abort;
  4. 如果要 append 的旧请求还没有 finished,也会拒绝继续构造新请求。

它的约束骨架很清楚:

if self.streaming:
    if session_params.replace:
        abort_message = "Streaming sessions do not support replace."
elif session_params.replace:
    ...
else:
    if session_params.rid is not None and not last_req.finished():
        abort_message = "Session request is appending to a request that hasn't finished."

后面真正发生的事情,是把旧请求的 origin_input_ids、已生成 output,再加上这次新输入,重新拼成新的 Req。所以 session 的本质不是“共享一个 session_id”,而是“共享一段历史上下文”。

timeout 改写的是请求结束的阶段#

timeout 不是单点机制,而是两层。

第一层是 session 生命周期本身的 timeout。SessionController.open / close / maybe_reap 会为 session 维护 last_active_timetimeout,再由 maybe_reap(...) 定期回收超时 session。

第二层是请求在 scheduler 中的 timeout。这一层又分成两种:

waiting timeout 的代码形状很直接:

deadline = time.perf_counter() - timeout_s
for req in self.waiting_queue:
    if 0 < req.time_stats.wait_queue_entry_time < deadline:
        self.send_to_tokenizer.send_output(
            AbortReq(
                finished_reason={
                    "type": "abort",
                    "message": "Request waiting timeout reached.",
                },
                rid=req.rid,
            ),
            req,
        )

running timeout 则不是把请求从 waiting queue 中删掉,而是给已经在跑的 request 打上 FINISH_ABORT。这两条路径都叫 timeout,但它们对应的是两种完全不同的系统状态:

  • 还没开始算就超时;
  • 已经在算,但运行时间超出阈值。

这也是后面看 finish reason 时必须分清的第一层边界。

abort 是怎样跨层传播的#

abort 也有两条主要来源。

第一条来自 API server 一侧。TokenizerManager.abort_request 会把 AbortReq 发给 scheduler。当 client 断开连接时,TokenizerManager 也会通过 background task 或 request.is_disconnected() 触发这条路径。

第二条来自 scheduler 一侧。Scheduler.abort_request 会:

  • 从 waiting queue 里直接删除请求;
  • 把 abort 结果送回 tokenizer manager,清理 API server 一侧状态;
  • 再让 grammar queue 也处理这次 abort。

因此 abort 不是单向控制,而是一条跨层协作路径:API server 可以发起 abort,scheduler 也会把 abort 的结果再发回 API server 收口。

这三类分叉真正改写了什么#

这三类分叉真正改写的是三个基础问题:

  1. 请求对象是怎样被构造的;
  2. 请求是在 waiting 还是 running 阶段被截断的;
  3. 返回链里看到的 finish reason 是主动完成还是异常终止。

如果把它们混在 3.13.2 里,读者会很难分清“主链自然结束”和“分叉提前终止”的区别。

调试这类分叉时先看哪里#

更稳的顺序通常是:

  1. 先确认请求有没有带 session_params
  2. 再看它是在 scheduler 之前被 API server abort,还是在 scheduler 里被 waiting/running timeout 截断;
  3. 如果是 session 请求,再确认它引用的旧 rid 是否有效,以及旧请求是否 finished;
  4. 最后再看 finish reason 和时间统计是不是和实际阶段匹配。

这里最容易犯的错误,是把 “session append 失败”、“waiting timeout” 和 “running timeout” 都粗暴看成一种 abort。实际上它们发生在不同层、不同阶段,也会留下不同的证据。

小结#

这一节补的不是几条“异常路径”,而是请求路径里的三个正式边界:

  • session 改写 Req 的构造方式;
  • timeout 改写请求的终止阶段;
  • abort 改写主链的继续条件。

理解了这些边界,后面看调试章节里的 finish reason、latency 和 request log,才不会把所有中断都误读成一个问题。