P02 · Orchestrator v1:线性多 stage 路由
你在写 vllm-omni 的大脑:三个 stage(thinker→talker→code2wav)各自吐输出,你要把它们正确串起来,并保证每个请求恰好结束一次。
mine/p02_orchestrator.py
验收 ./check.sh p02
提示 A-9 · 参考解 C-9 solutions/p02_orchestrator.py
时序图 p02_flow源码对照
题面速览
- 在系统中的位置:API 进程里有一个 orchestrator 线程跑自己的 asyncio 事件循环,握着每个 stage 的 client(对端是 stage 子进程里的 EngineCore)。它要:接收前端提交的请求;读取每个 stage 的输出;决定输出是交给客户端、转发给下一个 stage,还是两者都做。
前端 add_request ─► [request handler] ─► stage0.submit
stage0 / stage1 / stage2 的 get_output ─► reader×3 ─► ready_q ─► [dispatcher] ─► 输出给前端
└─► stage(s+1).submit
- 需求:
- 单写者:每个 stage 一个 reader task 只负责搬运;唯一的 dispatcher 修改请求状态。asyncio 协作式调度,只要不在“读-改-写”中间 await,就不需要锁。
- 先注册后提交:
request_states[rid]必须在第一次await stages[0].submit()之前就存在——submit 可能让出控制权,stage 可能很快吐出第一条输出。 - 转发:stage s 对某请求 finished=True,且
s < max(final_stage_ids)时,用forward_fn(s+1, prompt, stage s 的全部输出)构造下一个 stage 的输入并提交。 - 输出与终态:只有 final_stage_ids 里的 stage 的输出给客户端(Qwen3-Omni 是 {0: 文本, 2: 音频})。每个请求恰好一条 finished=True,且是最后一条,条件是“所有 final stage 都完成”。
- 清理:发出终态后删除状态;之后到达的未知请求输出丢弃并计数(
dropped_outputs)。
验收要点:单请求 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 丢弃计数、不抛异常;清理之后再到的消息按未知处理 |
易错点
- 直接用
o.finished当OrchOutput.finished:用户收到两条 finished=True;若在第一条时就清理,S2 的所有输出都变成 dropped。 - 每来一块就转发(而不是等 stage 完成):S1 被同一请求 submit 多次,或 prev_data 不完整。
- 清理后忘了 return,继续往下读已删除的状态(KeyError)或继续转发。
- 先 submit 再注册:用户只看到 "there",S1 收到
["there"],S2 只吐 "wav:THERE",dropped_outputs == 1——不会卡死,数据静悄悄少了一块。 - 未知 id 用 setdefault 之类“顺手”建状态:泄漏,还可能把孤儿输出发给用户。
- task 里抛出的异常不会自动打印,task 安静地死了,表现为 next_output 超时——调试时检查
task.done() and task.exception()。
逐步图:例 1 的状态演化
注册 ReqState(无 await,不会被打断),submitted = {0},然后 await S0.submit("r1", "hi there")。
stage_data = {},finished = {}
stage_data = {0: ['hi']},finished = {}
0 ∈ final → 发 (0, 'hi', False);未完成,不转发。
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。
stage_data[1] = [['HI','THERE']],finished = {0,1}
1 ∉ final → 不给用户;1 完成且 1 < 2 → forward_fn(2, …, [['HI','THERE']]),submitted = {0,1,2},submit S2。
stage_data[2] = ['wav:HI'],发 (2, 'wav:HI', False)。
finished = {0,1,2},{0,2} ⊆ finished → 发 (2, 'wav:THERE', True),清理,return(2 == last_stage,本来也不会转发)。
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。
关键问题
- S0 的最后一块 "there" 自带 finished=True。发给用户的那条 OrchOutput 的 finished 应该是什么?由什么决定?
- S0 的最后一块既要给用户,又要转发给 S1。两件事都做吗?先做哪件有关系吗?
- S2 拿到的输入为什么是
[["HI","THERE"]]而不是["HI","THERE"]?“stage s 产出的全部数据”是哪个列表? - 为什么必须在
await stages[0].submit()之前把 ReqState 放进 request_states? - 请求清理之后又来一条它的输出,会发生什么?用 setdefault 顺手建 ReqState 会怎样?
- reader 为什么不自己处理输出,而要丢进
_ready_q交给唯一 dispatcher?正确性靠什么保证? 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_finalP02 不用。 - 三个队列:
_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详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- False。OrchOutput.finished 是请求级的,条件
final_stage_ids ⊆ st.finished;此时 finished={0} 不含 2。只有 S2 最后一块那一刻条件首次成立,“恰好一条且最后一条”自动满足。 - 都做。“sid ∈ final → 发给用户”和“o.finished 且 sid < last_stage → 转发”是两个独立的 if,不是 if/elif。先发给用户更自然(用户的文本不必等 S1 提交完);硬约束只有一个:如果这条已经是终态,发完就清理并 return,不能再转发。
forward_fn(s+1, st.prompt, st.stage_data[s])。stage_data[s] 是 dispatcher 每处理一条 stage s 输出就 append 一次攒出来的;S1 只吐了一块且这一块本身是 list,所以 S2 收到“一个元素的列表”。拆包/拼接是 forward_fn 的事,orchestrator 不关心 data 长什么样。- await 交还控制权;stage 可能在 submit 里就把第一条输出放进通道,reader 和 dispatcher 随即运行——rid 还没注册就会被当成未知 id 丢掉。dict 赋值是同步的,放在 await 前就没有窗口。
- 查不到 →
dropped_outputs += 1→ return。绝不能重建状态:重建的 ReqState 永远等不到终态(泄漏),还可能把孤儿输出发给用户。 - 三个 reader 各自改状态会在各自的 await 点交错(两个都看到“还没 done”各发一条,或一个刚清理另一个又写入)。所有修改放进一个 task 串行执行,等价于一把全局锁却不用写锁。
- 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)。
面试官追问 & 对照源码
- 如果 stage 有多个 replica,输出顺序还能保证吗?需要什么前提?(→ P04 的粘性绑定)
- 输出队列为什么可以无界、请求队列为什么应该有界?
- 把 dispatcher 换成每个请求一个 task 会怎样?好处与代价?
对照源码:engine/orchestrator.py:1099-1320 · :1912-2046 · :3062-3128 · :232-271(详见 源码对照页)