nano-vllm-omni · 提示 ← M08P03 →
#9 · Track A · 前端与 Orchestrator · 书 p.48

P02 · Orchestrator v1:线性多 stage 路由

你在写 vllm-omni 的大脑:三个 stage(thinker→talker→code2wav)各自吐输出,你要把它们正确串起来,并保证每个请求恰好结束一次。

⏱ 60 min 难度 中 实现 mine/p02_orchestrator.py 验收 ./check.sh p02 提示 A-9 · 参考解 C-9 solutions/p02_orchestrator.py 时序图 p02_flow源码对照

题面速览

前端 add_request ─► [request handler] ─► stage0.submit
stage0 / stage1 / stage2 的 get_output ─► reader×3 ─► ready_q ─► [dispatcher] ─► 输出给前端
                                                                     └─► stage(s+1).submit

验收要点:单请求 3 stage:stage0 文本与 stage2 音频都按序到达,唯一的 finished 是最后一条 · 30 个并发请求互不串扰、每个 stage 恰好收到每个请求一次 · 只有最后一个 stage 是 final 时 stage0 输出不泄露 · stage 在 submit() 内部就吐出第一条输出时不能丢 · 未知 request 的输出被丢弃计数、不会崩。

交互图

完整时序(架构图 + 每一步谁在动)见 p02_flow.html;与真实 vllm-omni 代码的逐项对照见 p02_vs_vllm_omni.html。

直觉与类比

工厂经理与秘书

dispatcher 是经理,只有他能改账本(request_states);每部电话(stage)配一个秘书(reader)守着,响了就把内容抄成纸条(打上 sid)丢进经理的收件箱(_ready_q)。经理只盯收件箱,来一张处理一张。 如果让经理自己轮流接电话(for s in stages: await s.get_output()),他会卡在一部永远不响的电话上:S1 要等 S0 的 "there" 被处理后才有任务,可 "there" 没人读——死锁。

框架视角 · T · SBWC · LIFE 工作表(P02 填好的)

问题 P02
T 几个进程 / 线程 / 事件循环? 题目已给定:orchestrator = 1 个事件循环;stage 是独立进程,但被 StageClient 包成 async 接口;不需要线程
S 共享状态?谁写? request_states(dispatcher 为主,handler 只负责注册)、dropped_outputs(dispatcher)
B 阻塞点? N 个 get_output()(互相独立、时间不可预测)、_req_q.get()、_ready_q.get()、_out_q.get()
W worker? reader × N(只搬运)、request_handler(注册 + submit 给 S0)、dispatcher(唯一决策者)
C 通道与消息格式 _req_q: ("add", rid, prompt);_ready_q: (sid, list[StageOutput])(fan-in);_out_q: OrchOutput
L 启停 start:N+2 个 create_task 并保存;shutdown:cancel + gather(return_exceptions=True)
I 公开 API add_request 只 put;next_output 只 get(带 timeout)
F 不变量 恰好一条终态且是最后一条;终态条件 final ⊆ finished;先注册再 submit;发给用户和转发是独立两件事;终态后清理
E 迟到 / 未知 / 异常 未知 id 丢弃计数、不抛异常;清理之后再到的消息按未知处理

易错点

逐步图:例 1 的状态演化

▶ final_stage_ids={0,2},forward_fn 原样返回 prev_data,add_request("r1", "hi there")
1. (request handler) add r1

注册 ReqState(无 await,不会被打断),submitted = {0},然后 await S0.submit("r1", "hi there")。

stage_data = {},finished = {}

2. S0: ("hi", fin=False)

stage_data = {0: ['hi']},finished = {}

0 ∈ final → 发 (0, 'hi', False);未完成,不转发。

3. S0: ("there", fin=True)

stage_data = {0: ['hi','there']},finished = {0}

0 ∈ final;{0,2} ⊄ {0} → 发 (0, 'there', False)(False!)

0 完成且 0 < 2 → forward_fn(1, 'hi there', ['hi','there']),submitted = {0,1},submit S1。

4. S1: (['HI','THERE'], fin=True)

stage_data[1] = [['HI','THERE']],finished = {0,1}

1 ∉ final → 不给用户;1 完成且 1 < 2 → forward_fn(2, …, [['HI','THERE']]),submitted = {0,1,2},submit S2。

5. S2: ("wav:HI", fin=False)

stage_data[2] = ['wav:HI'],发 (2, 'wav:HI', False)。

6. S2: ("wav:THERE", fin=True)

finished = {0,1,2},{0,2} ⊆ finished → 发 (2, 'wav:THERE', True),清理,return(2 == last_stage,本来也不会转发)。

7. S2: ("wav:LATE", fin=True)(假设迟到)

r1 已不在 request_states → dropped_outputs += 1,什么都不发。

三级提示

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

例 1:S0→S1→S2,final_stage_ids={0,2},forward_fn = lambda nxt, prompt, prev: prev。

await orch.add_request("r1", "hi there")
S0.submit("r1", "hi there")          -> S0 吐出 "hi", "there"(finished)
S1.submit("r1", ["hi", "there"])     -> S1 吐出 ["HI","THERE"](finished)(只有一块)
S2.submit("r1", [["HI", "THERE"]])   -> S2 吐出 "wav:HI", "wav:THERE"(finished)
# stage_id data finished
1 0 "hi" False
2 0 "there" ?(你来回答)
3 2 "wav:HI" False
4 2 "wav:THERE" True

stage 1 的输出一条也不会出现在用户侧。结束后 request_states == {},dropped_outputs == 0。

例 2:只有最后一个 stage 是 final(final_stage_ids={2},prompt "a b c")——用户只收到 wav:A(F)、wav:B(F)、wav:C(T)。S0 不是 final,但它的完成仍要触发向 S1 的转发——“给不给用户”和“要不要转发”是两件独立的事。

例 3:未知 request。add 之前 S1 先冒出 StageOutput("ghost", "?", True);正常跑完 r1;r1 终态之后 S2 又迟到 StageOutput("r1", "wav:LATE", True)。结果:用户侧没有多余消息;request_states == {};dropped_outputs == 2。

关键问题

  1. S0 的最后一块 "there" 自带 finished=True。发给用户的那条 OrchOutput 的 finished 应该是什么?由什么决定?
  2. S0 的最后一块既要给用户,又要转发给 S1。两件事都做吗?先做哪件有关系吗?
  3. S2 拿到的输入为什么是 [["HI","THERE"]] 而不是 ["HI","THERE"]?“stage s 产出的全部数据”是哪个列表?
  4. 为什么必须在 await stages[0].submit() 之前把 ReqState 放进 request_states?
  5. 请求清理之后又来一条它的输出,会发生什么?用 setdefault 顺手建 ReqState 会怎样?
  6. reader 为什么不自己处理输出,而要丢进 _ready_q 交给唯一 dispatcher?正确性靠什么保证?
  7. final_stage_ids={0} 时 last_stage 是多少?S1、S2 会被 submit 吗?

细节

  • StageOutput(request_id, data, finished) 是 stage→orchestrator,get_output() 一次返回 list[StageOutput](可能混着多个请求),没有 stage_id——哪个 stage 来的只有读它的 reader 知道。OrchOutput(request_id, stage_id, data, finished, error) 是 orchestrator→用户,只为 final stage 生成。
  • last_stage = max(final_stage_ids):管线只需跑到最后一个 final stage。
  • ReqState:submitted 已提交的 stage;finished 已报告完成的 stage;stage_data[s] stage s 的全部 data(按到达顺序);pending_final P02 不用。
  • 三个队列:_req_q(add → handler)、_ready_q(readers → dispatcher)、_out_q(dispatcher → next_output)。next_output(timeout=None) 表示一直等。
L2思路与常见坑思路拆分与常见坑(中度剧透)
task 数量 做什么
_reader(sid, client) 每个 stage 一个 死循环 await client.get_output(),把 (sid, batch) 放进 _ready_q。只搬运,不碰状态
_request_handler() 1 从 _req_q 取 add 消息,调用 _handle_add
_dispatcher() 1 从 _ready_q 取一批,按顺序对每条调用 _handle_output(sid, o)
  • shutdown():cancel 所有 task,再 gather(..., return_exceptions=True)(否则 “Task was destroyed but it is pending”)。
  • reader 带上 sid:一个 stage 的输出经过“一个 reader → 一个 FIFO 队列 → 一个 dispatcher”,同一 stage 内顺序天然保持。
  • _handle_output 的决策顺序(先写成注释再填代码):① 查状态,没有就 drop 计数并返回;② 追加 stage_data[sid];③ 若 o.finished,sid 加入 finished;④ 若 sid 是 final:算出请求级 done,发一条 OrchOutput;done 的话清理并立刻 return;⑤ 若 o.finished 且 sid < last_stage:forward_fn 构造输入、标记 submitted、submit 给 sid+1。
  • 先改状态再 await:dispatcher 里的 await _out_q.put(...) 和 await stages[nxt].submit(...) 都是让出点;在任何 await 之前把 stage_data / finished / submitted 更新好。
  • add 路径:add_request 只把 ("add", rid, prompt) 放进 _req_q;真正的注册+提交在 handler 里做,注册排在第一个 await 之前。
  • P03 会继承这个类并只覆盖 _handle_add / _handle_output——把三个循环和 cleanup 写成独立方法。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. False。OrchOutput.finished 是请求级的,条件 final_stage_ids ⊆ st.finished;此时 finished={0} 不含 2。只有 S2 最后一块那一刻条件首次成立,“恰好一条且最后一条”自动满足。
  2. 都做。“sid ∈ final → 发给用户”和“o.finished 且 sid < last_stage → 转发”是两个独立的 if,不是 if/elif。先发给用户更自然(用户的文本不必等 S1 提交完);硬约束只有一个:如果这条已经是终态,发完就清理并 return,不能再转发。
  3. forward_fn(s+1, st.prompt, st.stage_data[s])。stage_data[s] 是 dispatcher 每处理一条 stage s 输出就 append 一次攒出来的;S1 只吐了一块且这一块本身是 list,所以 S2 收到“一个元素的列表”。拆包/拼接是 forward_fn 的事,orchestrator 不关心 data 长什么样。
  4. await 交还控制权;stage 可能在 submit 里就把第一条输出放进通道,reader 和 dispatcher 随即运行——rid 还没注册就会被当成未知 id 丢掉。dict 赋值是同步的,放在 await 前就没有窗口。
  5. 查不到 → dropped_outputs += 1 → return。绝不能重建状态:重建的 ReqState 永远等不到终态(泄漏),还可能把孤儿输出发给用户。
  6. 三个 reader 各自改状态会在各自的 await 点交错(两个都看到“还没 done”各发一条,或一个刚清理另一个又写入)。所有修改放进一个 task 串行执行,等价于一把全局锁却不用写锁。
  7. last_stage = 0,S0 完成就是终态,S1/S2 永远不会被 submit。

骨架要点:_handle_add = 建 ReqState → 注册 → submitted.add(0) → await stages[0].submit;_handle_output 按 L2 的 ①–⑤;_cleanup(rid) = request_states.pop(rid, None)。

面试官追问 & 对照源码

对照源码:engine/orchestrator.py:1099-1320 · :1912-2046 · :3062-3128 · :232-271(详见 源码对照页)