三层 Turn Loop:谁有资格决定继续
你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。
本页解决的问题
先给结论「三层 Turn Loop:谁有资格决定继续」要解决的关键问题是什么?
你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
任务壳 RegularTask
值班班长。问这一趟还活着吗。
轮次 run_turn
当班司机。问这一轮还要再采吗。
采样 sampling
检票口。问这一条流结束了吗。
空。插话先坐这里,当前采样看不见。
- 空闲则 spawn RegularTaskturn_input.rs L242
- 任务壳发 TurnStarted 后进 loopregular.rs L76
- 轮次开头按开关排 pendingturn.rs L305
- 采样 run_sampling_requestturn.rs L381
- 工具当时挂上 futurestream_events_utils.rs L326
- Completed 之后再 drainturn.rs L2539
- 重算 needs_follow_upturn.rs L423
- stop hook 带 prompt 则 continueturn.rs L525
- 任务壳再问 has_pending_inputregular.rs L86
- 忙碌则 Steered 写入 pendingturn_input.rs L207
你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」。
忙的时候其实叠了几件事。模型调了工具。你中途补了一句「测试用 pytest」。它写完助手消息后,Stop hook 说还没跑 linter,于是又采了一次样。
这几件事如果塞进同一个 while,就只能靠几个布尔抢出口。谁先检查、谁能打断谁,会变成口头约定。少一层,就少一个干净插口:插话、续跑和流重试会搅在一起。
Codex 拆成三层。任务壳 RegularTask::run 决定这一趟还要不要再开一轮。轮次 run_turn 决定工具续跑、插话和 hook 要不要继续。采样层只把一次模型流收到 Completed。
对外入口 start_or_steer_turn 自己不看会话空不空闲。返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。空闲就 spawn RegularTask,忙碌就 Steered 写入 pending。三层循环从任务壳才开始转。
出处:codex-rs/core/src/codex_thread.rs 第 333 至 344 行 · codex-rs/core/src/session/turn_input.rs 第 1 至 9 行
任务壳发一次 TurnStarted,然后只要队列里还有待处理输入,就再调一次 run_turn。第二次进去时 next_input 为空,新消息从 input_queue 取。turn_id 钉死,界面不会再闪一次「新的一轮开始了」。
出处:codex-rs/core/src/tasks/regular.rs 第 76 至 90 行
轮次层把采样回来的两件事合成一个布尔:model_needs_follow_up || has_pending_input。为真就自己 continue。为假才跑 stop hook。hook 带 prompt 拦收工,这一层自己再转,任务壳和采样层都还没退。
出处:codex-rs/core/src/session/turn.rs 第 423 行 · 第 500 至 525 行
采样层自己还有两圈。外圈处理可重试错误。内圈消费一条 SSE 流。工具调用不等 Completed:OutputItemDone 当时就会挂上 future。流收到 Completed,先 drain_in_flight,再把结果交回 run_turn。
出处:codex-rs/core/src/stream_events_utils.rs 第 326 至 327 行 · codex-rs/core/src/session/turn.rs 第 2539 至 2584 行 · 第 2749 行
三层按「谁有资格决定继续」切开。采样层只看见这一次流,能重试,不能收整轮。轮次层看见工具、pending、预算和 hook,能续采样,不能重发 TurnStarted。任务壳看见任务还在、队列里是否还有活。
这不随文件怎么拆而变。换个语言重写,该问的还是三个问题:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。
RegularTask 和 run_turn 都看 pending。同一句用户话如果两处都取,会转两圈。如果只留一处,就会把 hook 和后到消息挤进同一个出口。
两处问的是两件不同的事。
轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。
去掉轮次层那一问:模型写出最终答案后,run_turn 会去跑 stop hook 并 break,中途那句「测试用 pytest」只能等任务壳再进一次 run_turn。功能上还能补上,只是多一次函数返回,stop hook 会在插话进模型之前先跑一轮。
去掉任务壳那一问:run_turn 因 should_stop 返回后,任务直接结束。队列里后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务。TurnStarted 会再闪一次,回放里变成两个 turn 桶。
stop hook 返回 block 且带 prompt,控制权留在轮次层。采样层早已返回。任务壳还在等这次 run_turn。block 是轮次层内部续跑。stop 是轮次层把控制权交回任务壳。
出处:codex-rs/core/src/session/turn.rs 第 509 至 537 行 · codex-rs/hooks/src/events/stop.rs 第 67 至 74 行
源码没有单独写「为什么要问两次」,下面从实现反推。hook 续跑时控制权留在轮次层,任务壳看不见。hook 放行后,任务壳才有机会接手「收工瞬间又来的那一句」。两个问题发生在不同时刻,所以要问两次。这跟具体语言、具体 hook 协议无关。
DSH:按语义切成 Turn、Step、Inbox
DSH 的外圈是 kick:while (await this.turn()) {}。turn() 自己再套一层 while (true),每一圈先 preStep,再 step()。第三层不是第三条 while。Inbox 是两条数组:next-turn 和 next-step。调用方在入队时选 followup、steer 还是 inject。
两边都叫三层,切分维度不同。DSH 按语义:Turn 是一轮完整工作,Step 是一次模型请求加工具,Inbox 是说话时机。Codex 按生命周期:任务壳问这一趟还活着吗,轮次问这一轮还要再采吗,采样问这一条流结束了吗。DSH 因此能从 session 事件重放两条队列。Codex 因此能把流重试、取消、end_turn 各自关在采样层,TurnStarted 只闪一次。
Claude Code:单层 while 加状态袋
主循环在 query.ts。可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋。needsFollowUp 只由助手消息里的 tool_use 块点亮。没有 follow-up 时,同一层接着做压缩、stop hook、进入下一 turn。turnCount 加一,transition 写成 next_turn,回到 while (true) 顶部。
续跑、压缩、stop hook 收成同一袋状态。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。Codex 把这三件事分给三层,DSH 把插话分给 Inbox,两边都不必在同一个布尔上抢门。
把任务壳那一问改成恒为假
模型开始调工具时,你补了一句「顺便列出当前目录」。先按三层推演:任务壳、轮次、采样各转几圈,这句后续由哪一层取走。
然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id。
while 加三个布尔。
「先玩一遍 · 一句话在三层里各转几圈」的能力藏在每次交接里
「你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「忙的时候其实叠了几件事。模型调了工具。你中途补了一句「测试用 pytest」。它写完助手消息后,Stop hook 说还没跑 linter,于是又采了一次样」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- 空闲则 spawn RegularTask turn_input.rs L242
- 任务壳发 TurnStarted 后进 loop regular.rs L76
- 轮次开头按开关排 pending turn.rs L305
成功路径不能代表系统可靠
用「然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 一句话在三层里各转几圈」走到「采样 sampling」
「先玩一遍 · 一句话在三层里各转几圈」先把问题落在「同一条输入:看控制权落在哪一层,各层圈数怎么加 播放 单步 重置 剧本 工具续跑 中途插话 Stop hook 切法 三层 合成一层 换剧本看哪一层加圈。合成一层之后,插话和 hook 抢同一扇门。 0 任务壳 0 轮次 0 采样 0 工具」上;到了「采样 sampling」,讨论继续推进到「检票口。问这一条流结束了吗。 候车凳 pending 空。插话先坐这里,当前采样看不见。 在飞的工具 逻辑轨迹 · 动画每一步对应源码里的哪一段 空闲则 spawn RegularTask turn_input.rs L242 任务壳发 TurnStarted 后进 loop regular.rs L76 轮次开头按开关排 pending turn.rs L305 采样 run_sampling_request tu…」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 一句话在三层里各转几圈」:同一条输入:看控制权落在哪一层,各层圈数怎么加 播放 单步 重置 剧本 工具续跑 中途插话 Stop hook 切法 三层 合成一层 换剧本看哪一层加圈。合成一层之后,插话和 hook 抢同一扇门。 0 任务壳 0 轮次 0 采样 0 工具
- 「采样 sampling」:检票口。问这一条流结束了吗。 候车凳 pending 空。插话先坐这里,当前采样看不见。 在飞的工具 逻辑轨迹 · 动画每一步对应源码里的哪一段 空闲则 spawn RegularTask turn_input.rs L242 任务壳发 TurnStarted 后进 loop regular.rs L76 轮次开头按开关排 pending turn.rs L305 采样 run_sampling_request tu…
- 「最后的要点」:工具当时挂上 future stream_events_utils.rs L326
最后的「最后的要点」把讨论落到「工具当时挂上 future stream_events_utils.rs L326」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。