Session、timeout 与 abort 分叉#
3.1 和 3.2 讲的是最小主链:请求怎样进入 runtime,结果又怎样回到协议表面。但真实系统里并不只有这条标准路径。session、timeout 和 abort 都会改变请求对象的命运,而且它们改变的不是“输出样式”,而是请求到底还能不能沿主链继续走下去。
这一节只处理三类正式分叉:
- session 会改写
Req的构造方式; - timeout 会改写请求结束的阶段;
- 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 上构造一条新的请求。这里最重要的不是实现细节,而是它允许和禁止什么:
- streaming session 只允许简单 append,不允许
replace、drop_previous_output或非零offset; - 非 streaming session 可以按
replace或offset改写前缀; - 如果引用的旧
rid不存在,直接 abort; - 如果要 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_time 和 timeout,再由 maybe_reap(...) 定期回收超时 session。
第二层是请求在 scheduler 中的 timeout。这一层又分成两种:
Scheduler._abort_on_waiting_timeout:请求还在 waiting queue;Scheduler._abort_on_running_timeout:请求已经进入 running batch。
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 收口。
这三类分叉真正改写了什么#
这三类分叉真正改写的是三个基础问题:
- 请求对象是怎样被构造的;
- 请求是在 waiting 还是 running 阶段被截断的;
- 返回链里看到的 finish reason 是主动完成还是异常终止。
如果把它们混在 3.1 或 3.2 里,读者会很难分清“主链自然结束”和“分叉提前终止”的区别。
调试这类分叉时先看哪里#
更稳的顺序通常是:
- 先确认请求有没有带
session_params; - 再看它是在 scheduler 之前被 API server abort,还是在 scheduler 里被 waiting/running timeout 截断;
- 如果是 session 请求,再确认它引用的旧
rid是否有效,以及旧请求是否 finished; - 最后再看 finish reason 和时间统计是不是和实际阶段匹配。
这里最容易犯的错误,是把 “session append 失败”、“waiting timeout” 和 “running timeout” 都粗暴看成一种 abort。实际上它们发生在不同层、不同阶段,也会留下不同的证据。
小结#
这一节补的不是几条“异常路径”,而是请求路径里的三个正式边界:
- session 改写
Req的构造方式; - timeout 改写请求的终止阶段;
- abort 改写主链的继续条件。
理解了这些边界,后面看调试章节里的 finish reason、latency 和 request log,才不会把所有中断都误读成一个问题。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。