跳到主要内容

Codex 回合循环 —「采样→工具→回灌」的心跳

这章讲什么: Codex 怎么把「模型说话 → 执行工具 → 把结果喂回模型」串成一个会自动压缩上下文、能并发跑工具、能被中途打断的循环。读懂这一章,整个 agent 就「活」起来了。

1. 它要解决的小问题

大模型一次只能「说一段话」。可「给函数加测试并跑通」这种活,需要多步:先读文件 → 再写补丁 → 再跑测试 → 看结果对不对 → 可能再改。模型自己做不到这些副作用,它只能请求「帮我跑 cargo test」。

所以需要一个外层循环,扮演模型的「手」:

  • 把当前对话历史发给模型(采样);
  • 看模型这轮要的是工具调用还是一句话;
  • 是工具就执行,把输出回灌进历史,然后再采样;
  • 是一句话就收工

这个循环在 Codex 里叫 a turn(一个回合),实现于 run_turn

2. 思路/直觉:一个回合 = 多次「采样请求」

关键直觉:「一个回合」不等于「一次模型调用」。一个回合内部可能向模型采样很多次,每次叫一个 sampling request

turn.rs 顶部的注释把契约说得很清楚:每次采样,模型回的要么是 ①一批函数调用,要么是 ②一句助手消息。

┌──────────── 一个回合(run_turn)────────────┐
│ │
│ 采样 #1 → 模型要 shell 工具 → 执行 → 回灌 │
│ 采样 #2 → 模型要 apply_patch → 执行 → 回灌 │
│ 采样 #3 → 模型只回一句话「改好了」→ 结束 │
│ │
└──────────────────────────────────────────────┘

判定收工的规则极简单:这轮模型没要任何工具(也没有积压的用户输入)→ 回合结束。 源码里就是 needs_follow_up 这个布尔:任一工具调用或积压输入会把它置 true,于是循环 continue 再采样一次;否则 break

3. 主线走一遍(从任务到循环)

一次用户输入先被包成一个 RegularTask,它的 run 很薄——真正的活在 run_turn,外面只套了一层「若还有积压输入就再跑一次 run_turn」的小循环:

// codex-rs/core/src/tasks/regular.rs:73 —— RegularTask::run 的尾部
loop {
let last_agent_message = run_turn(/* … */).await?;
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return Ok(last_agent_message); // 没有积压输入 → 整个 task 结束
}
next_input = Vec::new(); // 把你在模型运行时插的话带进下一轮
}

这段说明了一个细节:你可以在模型还在跑时继续打字,这些「pending input」会在合适的时机被排进历史。

4. 回合循环的骨架(run_turn 内部)

run_turn 是个大函数(2000+ 行,含大量分支),但主干是一个 loop。剥掉错误处理和遥测,核心节奏是:

run_turn:
run_pre_sampling_compact() ① 进循环前:若历史已超阈值,先压缩
loop {
drain 积压用户输入 → 记进历史 ② 把 pending input 并进历史
step_context = 捕获本步上下文 ③ 冻结这一步看到的工具/上下文视图
result = run_sampling_request(...) ④ 流式采样 + 执行工具(见 §5)
if 需要压缩(token 超限) { ⑤ 中途自动压缩,然后 continue
run_auto_compact(...); continue;
}
if !needs_follow_up { ⑥ 模型没再要工具 → 收工
run 停止钩子; break;
}
// 否则 continue,进入下一次采样
}

对应真实源码的几处锚点:

  • 进循环前压缩——run_pre_sampling_compact(&sess, &turn_context, &mut client_session)(turn.rs:156)。失败若是 TurnAborted 直接返回,否则发个错误事件后收场。
  • 主循环——loop { … } 始于 turn.rs:225
  • 采样一步——run_sampling_request(...)(turn.rs:284 调用、:1072 / :1897 定义),返回 SamplingRequestResult { needs_follow_up, last_agent_message }(结构体见 turn.rs:1313)。
  • 中途压缩判定——if needs_follow_up && (… || token_limit_reached) { run_auto_compact(...); continue; }(turn.rs:346)。
  • 收工——if !needs_follow_up { … break; }(turn.rs:371)。

一句话记住:run_turn 的循环体 = 一次采样请求;循环退出 = 模型不再要工具。 压缩是插在循环里的「续命」步骤。

5. 一次采样请求里发生了什么(run_sampling_request)

这是循环里最密集的一步。它做两件并行的事:流式收模型输出 + 一边收一边触发工具

直觉: 模型的回复是流式的(一段段 ResponseEvent 推过来)。Codex 不等整段收完,而是边收边处理:文本增量实时转发给前端显示;一旦某个工具调用项收完(OutputItemDone),立刻把它的执行 future 推进一个有序的并发队列(FuturesOrdered),让工具开始跑,同时继续收模型后面的输出。

真实源码的关键锚点:

// codex-rs/core/src/session/turn.rs:1912 —— 建立流式连接
let mut stream = client_session.stream(prompt, &turn_context.model_info, /* … */).await??;
// :1926 —— 工具调用的「在飞」队列,保证回灌顺序与模型给出的顺序一致
let mut in_flight: FuturesOrdered<BoxFuture<'static, CodexResult<ResponseInputItem>>> =
FuturesOrdered::new();

收到一个完成的输出项时,交给 handle_output_item_done,它若是工具调用就返回一个 tool_future,被推进 in_flight:

// codex-rs/core/src/session/turn.rs:2059 —— 一个输出项收完了
let output_result = handle_output_item_done(&mut ctx, item, previously_streamed_item).await?;
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future); // 工具开始并发执行
}
if let Some(agent_message) = output_result.last_agent_message {
last_agent_message = Some(agent_message); // 记下这轮的助手发言
}
needs_follow_up |= output_result.needs_follow_up; // 有工具 → 需要再采样一轮

所以「采样请求」结束后,needs_follow_up 已经反映了「这轮是否产生了工具调用」,run_turn 据此决定 continue 还是 break

这里的巧妙处:

  • 流式 + 有序并发。 多个工具能并行跑,但 FuturesOrdered 保证它们的输出按模型给出的顺序回灌——既快又不打乱因果。
  • 采样连接复用。 ModelClientSession回合级的,缓存了 Responses API 的 WebSocket 与粘性路由;同一回合的多次采样/重试复用同一连接(注释见 turn.rs:217-218client.rs:255-266)。跨回合则不复用,避免重放旧状态。

6. 工具调用怎么落地(回灌的另一半)

上面 tool_future 跑的就是一次工具执行。路径是 ToolRouter(决定调哪个 handler)→ 具体 handler(shell / apply_patch / MCP …)→ ToolOrchestrator(统一做审批 + 沙箱 + 失败重试)。

模型给的工具调用项
│ ToolRouter::dispatch_tool_call_with_terminal_outcome

对应 handler(如 ShellHandler)
│ ToolOrchestrator::run

审批 → 选沙箱 → 执行 → (被拒可升级) → 输出


包成 FunctionCallOutput,回灌进历史 → 下一次采样
  • 路由入口:ToolRouter::dispatch_tool_call_with_terminal_outcome(tools/router.rs:188)。
  • 编排(审批/沙箱/重试):ToolOrchestrator::run(tools/orchestrator.rs:134)。这一层是第 3 章的主角。

工具的输出最终变成一个 function-call-output 类型的 ResponseItem,被记进 ContextManager(历史),于是下一次采样时模型就「看到」了命令结果——这就是回灌

7. 续命:token 超限时的自动压缩

回合可能很长(反复跑测试、读大文件),历史会撑爆上下文窗口。Codex 的做法不是报错退出,而是就地压缩:把旧历史换成一段模型生成的摘要,腾出空间继续。

  • 触发点之一在循环里:当 needs_follow_uptoken_limit_reached 时调用 run_auto_compact(...) 然后 continue(turn.rs:346)。
  • 压缩逻辑在 compact.rs:用 SUMMARIZATION_PROMPT 让模型把历史浓缩成摘要,新历史以 SUMMARY_PREFIX 开头(compact.rs:49-50)。
  • 还有「进循环前压缩」run_pre_sampling_compact(turn.rs:797),在拼下一次请求前先看要不要瘦身。

直觉:压缩 = 给 agent 做「记忆整理」。它把「我们之前读了哪些文件、跑了哪些命令、得到什么结论」压成几段话,丢掉逐字细节,让长回合不至于因上下文爆掉而中断。

8. 边界与坑

  • run_turn 极重。 它把压缩、钩子(hooks)、技能/插件注入、计划模式(plan mode)、多 agent 邮箱抢占等全塞进同一个循环,分支极多。本章只抽了主干;真要改它得做好读 2000 行的准备。
  • 「一轮一项」是经验而非保证。 注释明说模型可能一次返回多个项,只是实践中通常一项(turn.rs:134-140);代码用 FuturesOrdered 正是为应对多项并发。
  • 压缩有损。 摘要会丢逐字细节,极长会话里偶发「模型忘了早先约定」属于这一机制的固有代价。

9. 代码地图

主题文件符号
回合循环主体codex-rs/core/src/session/turn.rsrun_turn
一次采样请求codex-rs/core/src/session/turn.rsrun_sampling_requestSamplingRequestResult
输出项处理codex-rs/core/src/session/turn.rshandle_output_item_done(stream_events_utils)
任务包装codex-rs/core/src/tasks/regular.rsRegularTaskSessionTask::run
工具路由codex-rs/core/src/tools/router.rsToolRouter::dispatch_tool_call_with_terminal_outcome
采样连接codex-rs/core/src/client.rsModelClientSessionstream
进循环前压缩codex-rs/core/src/session/turn.rsrun_pre_sampling_compact
中途自动压缩codex-rs/core/src/session/turn.rs / compact.rsrun_auto_compactSUMMARIZATION_PROMPT