数据截至 (上游 commit f1087ff151b0)
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-218、client.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:227)。 - 编排(审批/沙箱/重试):
ToolOrchestrator::run(tools/orchestrator.rs:125)。这一层是第 3 章的主角。
工具的输出最终变成一个 function-call-output 类型的 ResponseItem,被记进 ContextManager(历史),于是下一次采样时模型就「看到」了命令结果——这就是回灌。
7. 续命:token 超限时的自动压缩
回合可能很长(反复跑测试、读大文件),历史会撑爆上下文窗口。Codex 的做法不是报错退出,而是就地压缩:把旧历史换成一段模型生成的摘要,腾出空间继续。
- 触发点之一在循环里:当
needs_follow_up且token_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.rs | run_turn |
| 一次采样请求 | codex-rs/core/src/session/turn.rs | run_sampling_request、SamplingRequestResult |
| 输出项处理 | codex-rs/core/src/session/turn.rs | handle_output_item_done(stream_events_utils) |
| 任务包装 | codex-rs/core/src/tasks/regular.rs | RegularTask、SessionTask::run |
| 工具路由 | codex-rs/core/src/tools/router.rs | ToolRouter::dispatch_tool_call_with_terminal_outcome |
| 采样连接 | codex-rs/core/src/client.rs | ModelClientSession、stream |
| 进循环前压缩 | codex-rs/core/src/session/turn.rs | run_pre_sampling_compact |
| 中途自动压缩 | codex-rs/core/src/session/turn.rs / compact.rs | run_auto_compact、SUMMARIZATION_PROMPT |