nano-vllm-omni · 提示 ← P01P05 →
#12 · Track A · 前端与 Orchestrator · 书 p.57

P04 · StagePool:粘性绑定、两阶段 abort、故障隔离

每个 stage 可以有多个 replica:请求一旦落在某个 replica 上就必须一直在那(KV 在那);客户端断开要 abort;某个 replica 进程死了,只能让绑定在它上面的请求失败。

⏱ 60 min 难度 中 实现 mine/p04_stage_pool_abort.py 验收 ./check.sh p04 提示 A-12 · 参考解 C-12 solutions/p04_stage_pool_abort.py

题面速览

① partial = processor.collect_partial(rid)    # 只读:拿到已生成的部分文本,用于最后一条输出
② for pool: await pool.abort([rid])           # 引擎侧真正停止;失败就抛出,不进入 ③
③ processor.commit_abort(rid)                 # 提交:丢弃 detokenizer 状态
return OrchOutput(rid, partial, finished=True, error="abort")

③ 先于 ②:引擎 abort 失败时 processor 已忘了这个请求,引擎却还在产出 token——孤儿输出。同一 replica 上的多个请求要合并成一次 abort 调用(一次 IPC)。

验收要点:round-robin + 粘性 · evict 返回受影响请求、之后不再分配到死 replica、无存活时抛 NoLiveReplica · abort 按 replica 分组、每个 replica 只调用一次 · 两阶段顺序 collect → 各 stage abort → commit · abort 失败时不 commit、保留绑定以便重试 · 死 replica 只让绑定在它上面的请求失败。

直觉与类比

行李寄存柜台(粘性绑定)

火车站有 3 个寄存柜台(replica)。第一次来按顺序分到某个柜台(round-robin),行李(KV cache)就存在那。之后续存、取、退,必须回同一个柜台。某个柜台关门了(evict),就只在开着的柜台之间轮;关门时要通知在那儿存过东西的旅客(handle_dead_replica),并把登记本上这几行划掉——否则下次有人拿着旧登记去找,只会找到一扇关着的门。

取消一张拆给好几家工厂的订单(两阶段 abort)

① 拍照(collect_partial,只读):先把做好的半成品拍下来;② 通知每家工厂停工(await abort):电话打不通就停下,别销账;③ 销账(commit_abort,不可逆):所有工厂确认停工后才把订单从账本删掉;最后发“已取消”通知附上照片。先销账再打电话、电话又没打通:工厂继续做,货送回来账本上查无此单(孤儿输出),想重试照片也找不回来了。

框架视角 · T · SBWC · LIFE

P04
T 全部跑在 orchestrator 同一个事件循环里;replica 是别的进程,但被 ReplicaClient.abort 包成可 await 的接口。没有线程,不需要锁
S 每个池:alive、bindings: dict[rid → replica]、round-robin 计数器;orchestrator 侧 PartialProcessor 里的部分结果。单写者
B 唯一的让出点是 await replica.abort(ids);pick / bound / release / evict 都是同步方法,彼此不会交错
W 没有新 worker:调用方是 P02 的 request_handler(处理 abort)和 dispatcher(发现 replica 死亡)
C add 和 abort 走同一条控制队列,“先 add 后 abort”的顺序不会乱;每次 replica 调用是一条 IPC,按 replica 合并
L 一个绑定的一生:pick 创建 → 正常结束 release / abort 成功后释放 / evict 时删除。三条路都要有,否则 bindings 只增不减
I abort_request 要么返回恰好一条 error="abort" 终态,要么抛异常且世界可重试;handle_dead_replica 对每个受影响请求恰好一条错误终态
F ① 绑定存在 ⇔ 那个 replica 上(可能)还有这个请求的状态;② 不可逆的 commit 永远排在所有可能失败的 abort 之后;③ pick 永不返回死 replica
E 无绑定 id → 静默忽略;部分 replica 失败 → 已成功的释放、失败的保留;全死 → NoLiveReplica;await 期间绑定可能已被删 → 释放时容忍“已经没了”

易错点

逐步图:例 1 + 例 2 的状态表(对存活列表取模)

▶ p = StagePool(1, [A, B, C])
1. 初始

alive = TTT,bindings = {},rr = 0

2. pick x / y / z / w

x → 0(rr 1),y → 1(rr 2),z → 2(rr 3),w → 0(rr 4)

bindings = {x:0, y:1, z:2, w:0}

3. pick x(粘性命中)

已绑定且活着 → 原样返回 0,不推进 rr(仍是 4)。p.bound("q") → None(只读,不创建绑定)。

4. evict 1

alive = TFT;返回 ['y'],bindings = {x:0, z:2, w:0}

5. pick y / pick v

live = [0, 2]:y → live[4 % 2] = 0(rr 5);v → live[5 % 2] = 2(rr 6)

bindings = {x:0, z:2, w:0, y:0, v:2}

6. abort([x, z, w, ghost, v])

按绑定分组:A.abort([x, w]);C.abort([z, v])——每个 replica 恰好一次;ghost 无绑定忽略。

之后 x/z/w/v 的 bound() 都是 None,bindings = {y:0}

7. 失败语义(另一个池 q = [A, B],B.abort 抛错)

绑定 {m:0, n:1, o:0} → await q.abort(["m","n","o"]):A.abort(["m","o"]) 成功 → 释放 m、o;B.abort(["n"]) 抛 RuntimeError → n 保留,异常向上传播,可以重试。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1、例 2 见上方逐步图。

例 3:两阶段 abort 与死 replica。三个 stage 池 s0/s1/s2,"r9" 分别绑在 T0、K1、W0;processor 里 r9 的部分文本 "Hello wor":

out = await abort_request([s0, s1, s2], proc, "r9")
调用顺序:collect_partial("r9") -> T0.abort(["r9"]) -> K1.abort(["r9"]) -> W0.abort(["r9"]) -> commit_abort("r9")
out == OrchOutput("r9", "Hello wor", finished=True, error="abort")

换一组池:s0 = [T0],s1' = [K0],K0.abort 会抛 RuntimeError
await abort_request([s0, s1'], proc, "r9")   # -> 抛出 RuntimeError
调用顺序:collect_partial -> T0.abort(["r9"]) -> K0.abort(["r9"]) 抛错    # 没有 commit_abort
s0.bound("r9") is None(成功,已释放);s1'.bound("r9") == 0(保留,可重试)

死 replica:d = StagePool(2, [W0, W1]),a,b,c 依次 pick 到 0,1,0。handle_dead_replica(d, 0) → a、c 各一条 OrchOutput(..., None, finished=True, error="stage 2 replica 0 died");d.bound("b") == 1;d.live_replicas() == [1];再 d.evict(1) 后 d.pick("new") 抛 NoLiveReplica。error 文字由你决定,非空、能说明原因即可。

关键问题

  1. 绑定表是什么形状?pick 对已绑定和未绑定的请求分别做什么?粘性命中时计数器要前进吗?
  2. round-robin 只在存活 replica 间轮转:“对存活列表取模”还是“对全部取模再跳过死的”?evict 后表现有什么不同?
  3. evict 为什么要删掉绑定而不是只标记死亡?
  4. abort(ids) 里 5 个 id 分布在 2 个 replica 上,应该产生几次调用?无绑定的 id 呢?没有 id 的 replica 要不要调用?
  5. 例 2 中 A 成功、B 抛错:哪些绑定释放、哪些保留?为什么“先释放再 await”是错的?
  6. 为什么 collect_partial 必须在引擎 abort 之前、commit_abort 必须在之后?
  7. abort_request 对一个根本没绑定 r9 的池调用 pool.abort(['r9']) 会怎样?需要在外面先判断吗?

细节

  • replica id = 下标;live_replicas() 按下标升序返回。
  • bound 只读(没有就 None,不创建);pick 可能创建;release 是正常结束时删除,对不存在的 id 不报错。
  • 本题的 OrchOutput(request_id, data, finished, error) 没有 stage_id 字段;abort 的终态 error 固定是 "abort";死 replica 的终态 data 为 None。
  • 两阶段和数据库的 prepare/commit 是同一种思路:不可逆的那一步放在最后。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • 状态:alive: list[bool]、bindings: dict[str, int]、round-robin 计数器;全部同步方法,唯一的 await 在 abort 里。
  • pick:已绑定且活着 → 返回;否则取存活列表,空则 NoLiveReplica;live[rr % len(live)],rr += 1,写绑定。
  • evict:标记死亡 → 找出所有绑在它上面的 request → 删除 → 返回列表。
  • StagePool.abort:① 用绑定表分组成 {replica: [ids]},无绑定跳过;② 对每组 await replicas[rep].abort(ids),这一组成功返回之后再释放这组的绑定;③ 任一组抛错就传出去——已成功的组已释放,失败组和尚未处理的组保留。
  • abort_request:collect → 对每个 pool 顺序 await pool.abort([rid])(不要 try/except 吞错)→ commit → 返回终态。
  • handle_dead_replica:pool.evict(replica) + 每个受影响请求一条终态错误输出。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. dict[request_id, replica_id]。已绑定且活着 → 返回;否则轮转选择并写入。粘性命中不推进计数器(推进也不算错,但分配不均)。
  2. 建议对存活列表取模:evict 后自动只在活着的里面均匀轮转;另一种写法要处理“跳过”且分布会偏。
  3. 绑定代表“这个请求在那个 replica 上有状态”;replica 死了状态就没了,保留绑定会让 pick 返回死 replica、abort 调用死进程。删掉后这些请求已由 handle_dead_replica 发了错误终态。
  4. 2 次(每个有 id 的 replica 一次);无绑定的 id 忽略;没有 id 的 replica 不调用。
  5. A 那组(m、o)释放,B 那组(n)保留,异常抛出。先释放再 await:失败时绑定已经没了,重试无从下手。
  6. collect 在前:commit 会丢掉前端状态,之后拿不到部分文本;commit 在后:只有引擎都确认停止,前端才能安全地忘记这个请求。commit 在前 + abort 失败 = 引擎还在产出、前端不认识它(孤儿输出),而且用户已经收到 abort 终态。
  7. 该池分组为空,不会产生任何 replica 调用,直接返回。不需要外面判断。

面试官追问 & 对照源码

对照源码:engine/stage_pool.py:1279-1339 · engine/stage_pool.py:333 · engine/orchestrator.py:1464-1515 · engine/orchestrator.py:645-731