OpenAI Codex · 循环骨架 三层 Turn Loop:谁有资格决定继续 你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。 一句话速览 你看到的是一轮对话。内部叠了任务壳、轮次、采样三��循环。各层只回答自己那一个问题,控制权每次只在一层。
课程目标 读完能说清三件事。第一,一条用户输入在任务壳、轮次、采样里各转几圈,每层何时进入、何时退出。第二,插话、工具续跑、stop hook 分别由哪一层接住。第三,为什么 pending 要问两次。
先玩一遍 · 一句话在三层里各转几圈
同一条输入:看控制权落在哪一层,各层圈数怎么加
播放 单步 重置
剧本
工具续跑 中途插话 Stop hook
切法
三层 合成一层
换剧本看哪一层加圈。合成一层之后,插话和 hook 抢同一扇门。
0 任务壳 0 轮次 0 采样 0 工具
任务壳 RegularTask 值班班长。问这一趟还活着吗。 轮次 run_turn 当班司机。问这一轮还要再采吗。 采样 sampling 检票口。问这一条流结束了吗。
候车凳 pending 空。插话先坐这里,当前采样看不见。 在飞的工具
逻辑轨迹 · 动画每一步对应源码里的哪一段
空闲则 spawn RegularTask turn_input.rs L242 任务壳发 TurnStarted 后进 loop regular.rs L76 轮次开头按开关排 pending turn.rs L305 采样 run_sampling_request turn.rs L381 工具当时挂上 future stream_events_utils.rs L326 Completed 之后再 drain turn.rs L2539 重算 needs_follow_up turn.rs L423 stop hook 带 prompt 则 continue turn.rs L525 任务壳再问 has_pending_input regular.rs L86 忙碌则 Steered 写入 pending turn_input.rs L207
点播放,看一句话在三层里各转几圈。
教学示意:圈数按这一条预设剧本推演,行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 按谁有资格决定继续切开
它解决什么问题 你让 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 行
任务壳 RegularTask 问:这一趟还活着吗。进入:spawn 之后。退出:run_turn 返回且队列为空。
轮次 run_turn 问:这一轮还要再采吗。进入:任务壳调用。退出:无续跑且 stop hook 放行。
采样 sampling 问:这一条流结束了吗。进入:run_sampling_request。退出:Completed 且 drain 完工具。 重试留在本层。取消和流中断走 Err。控制权还没回到轮次。
TurnStarted 只发一次
教学化结构图:外层保住这一趟还活着,中层决定要不要再采,内层只收完这一条流。
任务壳发一次 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 。任务壳看见任务还在、队列里是否还有活。 这不随文件怎么拆而变。换个语言重写,该问的还是三个问题:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。
思路二 · pending 问两次,夹住 stop hook
它解决什么问题 RegularTask 和 run_turn 都看 pending。同一句用户话如果两处都取,会转两圈。如果只留一处,就会把 hook 和后到消息挤进同一个出口。
思路是什么 两处问的是两件不同的事。 轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn 」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。
采样返回 工具已 drain
轮次问 pending 这一轮还要再采吗
有货:自己 continue 任务壳还在等这次 run_turn
无货:跑 stop hook block 续跑,stop 才 break
任务壳再问 pending 这一趟还要再进 run_turn 吗
教学化分流图:两次判断夹住 stop hook,问的是两个时刻的两个问题。
去掉轮次层那一问:模型写出最终答案后, 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 行 两次判断夹住 stop hook,所以要问两次。
为什么长期成立 源码没有单独写「为什么要问两次」,下面从实现反推。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 只闪一次。 两侧均已核对源码 · 2026-08-22 · DSH · Inbox
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,两边都不必在同一个布尔上抢门。 两侧均已核对源码 · 2026-08-22
课堂练习
01
把任务壳那一问改成恒为假 模型开始调工具时,你补了一句「顺便列出当前目录」。先按三层推演:任务壳、轮次、采样各转几圈,这句后续由哪一层取走。 然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id 。
Takeaway: 三层各自的终止条件写成三个函数。插话只进 pending。stop hook 只进轮次循环。任务壳只在轮次返回后再看队列。不要收成一个 while 加三个布尔。