P03 · Orchestrator v2:async_chunk 预热 + 终态守门
开启 async_chunk 后 stage 之间走 connector 直传,orchestrator 不再转发数据;但控制面和数据面是两条路,完成信号可能乱序——音频 stage 先说“我完了”,talker 还没报告完成。
mine/p03_async_chunk_orchestrator.py
验收 ./check.sh p03
提示 A-10 · 参考解 C-10 solutions/p03_async_chunk_orchestrator.py
题面速览
- 在系统中的位置:thinker 每 decode 一个 token 就把向量经 connector 发给 talker,talker 每攒够一个窗口就把 codec 发给 code2wav——数据不经过 orchestrator。orchestrator 只剩两件事:请求到达时预热下游 stage(提交占位请求,让它在 scheduler 里等第一个 chunk),以及守门终态。
- 为什么预热:等 thinker 产出第一个 chunk 才提交 talker,就多一次 “stage→orchestrator→stage” 往返;预热后第一个 chunk 一落地 talker 就能开始。
- 为什么守门:
时间 →
stage2 (audio, final): ... wav5(fin) ← 控制面上先到
stage1 (talker): ............... (fin) ← 晚到(它的 IPC 慢了一拍)
如果在 wav5(fin) 时就发 finished 并清理:
- 清理会回收 stage1 仍在使用的资源(SHM、绑定)
- stage1 的完成消息成了“未知请求”的输出
- 规则:最后一个 final stage 完成、但
submitted ⊄ finished时,把终态消息存进pending_final;等最后一个已提交 stage 也完成时再发出。仍然要求恰好一条终态且为最后一条。 - 需求清单:add:注册状态 → 把 0..last 都标记为 submitted → 先提交 stage 0(真实 prompt),再提交下游(
prewarm_fn(sid, prompt)占位)· 不做任何转发 · 清理后到达的输出丢弃计数,状态不复活。本题直接继承你的 P02,覆盖_handle_add和_handle_output。
验收要点:每个 stage 只被提交一次、下游拿到 prewarm 占位输入 · 非 final 的 stage1 迟 200ms 才报告完成时终态必须等它 · 20 个请求 + 迟到完成:每个请求恰好一个终态且是最后一条 · 清理后到达的输出被丢弃、状态不复活。
直觉与类比
P02:每道工序做完一整批零件交给车间主任,主任搬到下一道工序。P03:工序之间装了传送带(connector),上一道每做好一个零件就直接放上去——流水线并行起来。车间主任不再搬零件,只剩两件事:① 开工时通知所有工序“有新订单,准备接传送带”(prewarm);② 收工时确认每一道工序都停机了,才给客户签收单(终态守门)。
货物走物流(数据面),“已发货/已完成”走短信(控制面)。最后一站的签收短信可能比中转站的“已出库”短信先到。你一看到“已签收”就把订单归档删除,几秒后中转站的短信到了你却找不到这张订单;更糟的是归档时你可能已经把中转站还在用的托盘(共享内存、绑定)收回了。
框架视角 · 只有两处变了
| 和 P02 相比 | |
|---|---|
| T | 不变:同一个事件循环里的 task。新增的 connector 是 stage 进程之间的边界,orchestrator 不碰它 |
| S | ReqState 多用一个字段 pending_final;submitted 的含义变成“预热过的所有 stage”;stage_data 不再需要 |
| B / W / C | 完全不变:reader × N、request_handler、dispatcher、三个队列——所以 P03 能直接继承 P02 |
| L / I | 不变 |
| F | 变了:add 时一次性标记 submitted;终态条件多了 submitted ⊆ finished;非 final stage 的完成事件也可能触发发送(放行 pending_final) |
| E | 清理后再到的输出照样丢弃计数,绝不能因为 S1 的迟到完成消息而重新建出状态 |
易错点
- 沿用 P02 只标
{0}:S2 最后一块到达时{0} ⊆ {0,2}成立,终态提前发出并清理;S1 迟到的完成报告变成 dropped。 - 非 final stage 完成时不检查 pending_final:用户永远收不到终态,next_output 超时,request_states 泄漏。
- 存进 pending_final 的同时也发了出去,或发 pending 后忘记清理:终态重复或状态泄漏。
- 直接沿用 P02 的
_handle_output:S0 完成时会调用 forward_fn 并 submit S1——S1 被 submit 两次,第二次输入是 None。 - 未知 id 时复活状态(setdefault):新 ReqState 的 submitted 为空,门形同虚设,迟到输出被发给用户、出现在终态之后,状态永久泄漏。
逐步图:例 1(talker 的完成报告迟到)
注册 → submitted = {0,1,2}(在第一次 await 之前)→ S0.submit("r1", "hi there") → S1.submit("r1", "warm1:8") → S2.submit("r1", "warm2:8")。每个 stage 只 submit 一次。
finished = {};all_final = F;all_done = F → 发 (0,'hi',F)。
finished = {0};all_final = F → 发 (0,'there',F)。不转发。
finished = {0} → 发 (2,'wav:HI',F)。
finished = {0,2};all_final = T;all_done = {0,1,2} ⊆ {0,2} = F
→ 构造 (2,'wav:THERE',T) 存进 pending_final,什么都不发,return。
finished = {0,1,2};S1 不是 final,但 all_done 成立且 pending 不为空 → 发出 (2,'wav:THERE',T),清理。
状态已清理 → dropped_outputs == 1,不复活。
三级提示
L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)
例 1 见上方逐步图。
例 2:正常顺序(S1 在 S2 之前完成):S0 hi → (0,'hi',F);S0 there(T) → (0,'there',F);S1 tok(T) → 无;S2 wav:HI → (2,'wav:HI',F);S2 wav:THERE(T) → (2,'wav:THERE',T),随即清理。不需要 pending_final:最后一个 final stage 完成的那一刻所有已提交的 stage 也都完成了。
例 3:最后完成的 final stage 是 S0:
| 到达 | 用户收到 | finished |
|---|---|---|
| S0 ("hi", False) | (0,'hi',False) | {} |
| S2 ("wav:HI", True) | (2,'wav:HI',False) | {2} |
| S1 (None, True) | (无) | {1,2} |
| S0 ("there", True) | (0,'there',True),清理 | — |
终态消息的 stage_id 不一定是 last_stage——它是“让条件第一次成立的那条输出”所在的 stage。
关键问题
- P02 里 submitted 是随转发一点点增长的;P03 里 add 时就要包含 0..last。沿用 P02 只标 {0},例 1 在哪一步出错、错成什么样?在第一次 await 之前标完有什么好处?
- 例 1 第 4 行:最后一个 final stage 完成但 S1 还没完成。用户应该收到什么?要存什么、存在哪?
- 非 final stage 报告完成时 handler 要做什么?什么条件下什么都不用做?
- “所有 final stage 都完成”和“所有已提交的 stage 都完成”分别写出来。各自控制什么?
- 非 final stage 的 data 会出现在用户侧吗?stage_data 在本题还有用吗?
- 继承 P02 时哪些可以原封不动复用?
- pending_final 已存好之后,又来一条非 finished 的 S1 输出,怎么处理?
细节
- 构造函数里
forward_fn=lambda *_: None只是为了满足父类签名。 - 提交顺序:先 S0(真实 prompt),再 S1、S2……(占位),范围
0..last_stage。 - 两道门:OrchOutput.finished 的取值仍是 P02 的“所有 final stage 都完成”;但 finished=True 的消息何时发出多了一个条件
submitted ⊆ finished。 pending_final存的是已构造好的 finished=True 的 OrchOutput,之后原样发出。- 如果你的 P02 把这两步写死在 dispatcher 里而不是独立方法,先回去重构 P02。
L2思路与常见坑思路拆分与常见坑(中度剧透)
- add 路径:建 ReqState → 注册 →
submitted = {0..last}(全部同步)→await S0.submit(prompt)→ 依次await S_k.submit(prewarm_fn(k, prompt))。 - 输出路径决策表(先查状态、未知就 drop;若 finished 就加入 st.finished):
| 这条输出来自 | all_final(final ⊆ finished) | all_done(submitted ⊆ finished) | 动作 |
|---|---|---|---|
| final stage | 否 | — | 发 OrchOutput(..., finished=False) |
| final stage | 是 | 是 | 发 finished=True 的消息,清理 |
| final stage | 是 | 否 | 构造 finished=True 的消息,存进 pending_final,不发 |
| 非 final stage | — | 是,且有 pending_final | 发 pending_final,清理 |
| 非 final stage | — | 其他 | 什么都不做 |
submitted是守门的依据:少一个 stage,门就少一道。在第一次 await 之前一次性标完,和 P02“先注册再 submit”是同一个原则。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- 只标 {0} 时,S2 的 "wav:THERE" 到达那一刻 finished={0,2},
{0} ⊆ {0,2}成立,终态立即发出并清理——正是本题要防止的“提前终态”;随后 S1 的完成报告变成未知 id,dropped_outputs == 1。在第一次 await 前标完,则无论哪个 await 点插进来的输出,看到的都是完整的 submitted。 - 什么都不发。构造 finished=True 的 OrchOutput,存进
st.pending_final,直接 return。 - 把 sid 加进 finished 后检查
submitted ⊆ finished且pending_final is not None:都成立就发出 pending 并清理。pending 为空(还有 final stage 没完成,例 2 的 S1)时什么都不做。 all_final = final_stage_ids ⊆ finished:决定 OrchOutput.finished 的值。all_done = submitted ⊆ finished:决定 finished=True 的消息立即发出(并清理)还是先扣在 pending_final。- 不会;非 final stage 的输出只用来更新 finished。stage_data 不需要。
- start / shutdown / add_request / next_output / reader / dispatcher / request handler /
_cleanup全部复用;只换_handle_add和_handle_output。 - 它不是 finished,不改变任何集合,落入“什么都不做”。
例 1 状态演化(✓ = finished=True):
| 事件 | submitted | finished | all_final | all_done | pending | 发给用户 |
|---|---|---|---|---|---|---|
| add | {0,1,2} | {} | None | |||
| S0 hi | {0,1,2} | {} | F | F | None | (0,hi,F) |
| S0 there ✓ | {0,1,2} | {0} | F | F | None | (0,there,F) |
| S2 wav:HI | {0,1,2} | {0} | F | F | None | (2,wav:HI,F) |
| S2 wav:THERE ✓ | {0,1,2} | {0,2} | T | F | (2,wav:THERE,T) | — |
| S1 ✓ | {0,1,2} | {0,1,2} | T | T | → 发出 | (2,wav:THERE,T),清理 |
| S2 wav:EXTRA | — | — | dropped += 1 |
骨架注意:_cleanup 在 await self._out_q.put 之后——无界队列的 put 不会真正挂起,即使挂起,状态也已一致(pending 已取出)。
面试官追问 & 对照源码
- 为什么过早发终态会导致资源问题?(提示:清理会回收上游仍在写的 SHM)
- 如果 prewarm 的占位 prompt 长度依赖上游结果怎么办?
对照源码:engine/orchestrator.py:1954-1967 · :2036-2040 · :2711-2870