# run_turn 主循环 · 一轮对话是怎么转起来的

> 一轮对话是一个 RegularTask 反复调用 run_turn。run_turn 先做开轮准备，再进入循环：取出用户插话、捕获本次请求的 step、流式采样并边流边派发工具，最后按“还要不要继续”“上下文满没满”“Stop hook 放不放行”决定再来一次、压缩后再来，还是收尾；可重试的流错误在采样函数内部就地退避重试。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/turn-loop
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# run_turn 主循环 · 一轮对话是怎么转起来的

[一条消息的生命周期](https://daiw.net/manual/codex-source/message-lifecycle)画过全景，[上一篇](https://daiw.net/manual/codex-source/session-and-turn-context)讲了一轮开始时配置怎样冻结。这一篇钻进心脏：`codex-rs/core/src/session/turn.rs` 里的 `run_turn`。先分清两个词：**一轮（turn）**是用户一次输入引发的全部工作，对外有一个 turn ID；**一次采样请求（step）**是向模型发一次 Responses 请求。一轮里通常有好几个 step——模型每要一次工具，结果就得送回去再问一次。

## 用户看到的样子

模型干活时，你在 TUI 里直接按 `Enter` 发送，新指令会注入当前这一轮；按 `Tab` 则排到下一轮；按 `Esc` 或 `Ctrl+C` 中断这一轮（按键说明见[斜杠命令](https://daiw.net/manual/codex/slash-commands)）。网络抖动时状态栏会出现 `Reconnecting... 2/5` 这样的提示。通过 app-server 编程时，对应的是 `turn/start`、`turn/steer`、`turn/interrupt`，一轮以 `turn/completed` 通知结束（见 [SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)）。

## 从任务到 run_turn

`turn/start` 最终在内核里变成一个 `RegularTask`（任务体系见[任务类型与 Plan 模式](https://daiw.net/manual/codex-source/tasks-and-plan-mode)）。它先发出 `TurnStarted`，领走会话启动时预热好的模型连接（见[模型客户端](https://daiw.net/manual/codex-source/model-client)），然后进入一个外层循环：

```rust
        loop {
            let last_agent_message = run_turn(
                Arc::clone(&sess),
                Arc::clone(&ctx),
                next_input,
                &mut mcp_startup_requirements,
                prewarmed_client_session.take(),
                cancellation_token.child_token(),
            )
            .instrument(run_turn_span.clone())
            .await?;
            // Terminal errors are already reported. Let task completion preserve pending
            // input instead of restarting the failed turn for that same input.
            if ctx.terminal_error.lock().await.is_some() {
                return Ok(last_agent_message);
            }
            if !sess.input_queue.has_pending_input(&sess.active_turn).await {
                return Ok(last_agent_message);
            }
            next_input = Vec::new();
        }
```

（`codex-rs/core/src/tasks/regular.rs:104`）

外层循环兜的是一个竞态：`run_turn` 已经决定收尾，用户恰好又插了一句话。这时不开新的一轮，而是用空输入再跑一次 `run_turn`，由它从队列里取出那句话。若本轮已经报过终止性错误，就不再重跑，留在队列里的输入交给任务收尾时处理。

## 开轮准备

`run_turn`（`codex-rs/core/src/session/turn.rs:163`）进循环前要做五件事：

1. **预采样压缩**：`run_pre_sampling_compact` 先看上一轮是否换了模型：压缩兼容哈希 `comp_hash` 变了，或换到窗口更小、已装不下现有历史的模型，就用旧模型先压缩一次；再看 token 是否已到阈值（见[上下文压缩](https://daiw.net/manual/codex-source/compaction)）。压缩失败时，用户这次的输入照样写进历史，再报错结束，输入不会丢。
2. **找出依赖、捕获首个 step**：从用户输入里找出点名的插件、`mcp://` 提及和技能声明的 MCP 依赖，作为本轮必须启动的 MCP 服务器，然后捕获第一个 `StepContext`。
3. **记录上下文差异**：`record_context_updates_and_set_reference_context_item` 在没有基准时（第一轮、或压缩清掉了基准）注入完整的初始上下文，否则只追加有变化的部分（见[上下文与历史](https://daiw.net/manual/codex-source/context-history)）。
4. **技能与插件注入**：用户点名的技能说明、插件能力摘要变成注入项；再跑积压的 SessionStart hooks。
5. **写入用户输入**：`run_hooks_and_record_inputs` 逐条过 UserPromptSubmit hook，被拦下的输入只留下 hook 附加的上下文；如果全部被拦，本轮直接结束。放行的输入先写进历史，随后记下本轮模型（`previous_turn_settings`，供下一轮判断是否换过模型），再写入技能与插件的注入项。

## 主循环

```mermaid
flowchart TB
  S[RegularTask 调用 run_turn] --> P[开轮准备<br/>预采样压缩 · 首个 step<br/>上下文差异 · 用户输入写入历史]
  P --> L[循环顶部]
  L --> Q{这时可以取插话吗}
  Q -->|可以| QI[取出 pending_input<br/>过 hook 后写入历史]
  Q -->|带输入的首圈<br/>或压缩后续做| C
  QI --> C[准备本次 step<br/>首圈复用 · 之后重新捕获]
  C --> W[追加世界状态差异<br/>从历史生成本次输入]
  W --> R[run_sampling_request<br/>流式采样 · 边流边派发工具<br/>可重试错误就地重试]
  R -->|出错| E{错误类型}
  E -->|被中断| X1[返回 TurnAborted<br/>由任务收尾]
  E -->|其它| X2[发 Error 事件<br/>结束本轮]
  R -->|成功| F{needs_follow_up<br/>模型要继续或有新输入}
  F -->|是且 token 超限| K[run_auto_compact<br/>中途压缩] --> L
  F -->|是| L
  F -->|否| H{Stop hook<br/>要求继续吗}
  H -->|是| L
  H -->|否| D[可选的轮末压缩<br/>返回最后一条回复]
```

每一圈的顺序是：先看能不能取插话。带着新输入开轮时，第一圈不取，保证这次输入先被单独采样；中途压缩后如果模型还要继续，那一圈也不取，让它先把压缩前没做完的事续上。取出的插话同样要过 UserPromptSubmit hook 再写进历史。接着准备 step：第一圈没有插话时直接用开轮时捕获的那个，之后每圈重新捕获；有插话时还要先把其中点名的插件、MCP 服务器并进启动要求。然后追加世界状态的差异、必要时追加推理强度的配置项，从历史生成本次输入，交给 `run_sampling_request`。

采样成功后，决定走向的是这几行：

```rust
                let needs_follow_up = model_needs_follow_up || has_pending_input;
                let token_limit_reached = token_status.token_limit_reached;
                // ...
                let should_roll_over = needs_follow_up
                    && (sess.take_new_context_window_request().await || token_limit_reached);
```

（`codex-rs/core/src/session/turn.rs:563`）

`needs_follow_up` 为真有两个来源：模型发了工具调用（或服务端明确返回 `end_turn: false`），或者采样期间队列里来了新输入。要继续、而上下文又到了自动压缩阈值，就先 `run_auto_compact` 再 `continue`；阈值怎么算见[上下文压缩](https://daiw.net/manual/codex-source/compaction)。不需要继续时，Stop hook 还能把这一轮“拽回来”：它返回阻止并附上提示词，这条提示就作为新消息写进历史，循环接着转。都放行后，若配置了 `model_post_turn_compact_threshold_percent`（默认 0，即关闭）且用量越过这个百分比，会在收尾前再压缩一次，然后 `break`，返回最后一条助手消息，任务据此发出 `TurnComplete`。

错误分支同样在这个 `match` 里。被中断（`TurnAborted`）原样返回，由任务收尾；图片无效单独给出“请移除图片”的提示；其余错误发出 `Error` 事件后 `break`，注释写着“let the user continue the conversation”——这一轮失败了，线程还活着。

## 一次采样请求

`run_sampling_request` 组装 `Prompt`（历史、工具清单、基础指令、输出 schema），调用 `try_run_sampling_request` 消费模型流。流里的事件大致分三类：`OutputItemAdded` 与各种 delta 转成界面上的“条目开始”和增量事件；`OutputItemDone` 表示一个输出项完整了，交给 `handle_output_item_done`；`Completed` 记下 token 用量后结束这次请求。

```rust
                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;
                // ...
                if let Some(false) = end_turn {
                    needs_follow_up = true;
                }
                break Ok(SamplingRequestResult {
                    needs_follow_up,
                    last_agent_message,
                });
```

（`codex-rs/core/src/session/turn.rs:2700`）

`handle_output_item_done` 先把完整的输出项写进历史，是工具调用就立刻派发、把 future 放进 `FuturesOrdered`，模型的后续输出与工具执行同时进行；流结束后 `drain_in_flight` 按调用顺序收齐结果写回历史，下一圈采样就能看到（并行规则见[工具系统总览](https://daiw.net/manual/codex-source/tool-architecture)）。收尾时再补发 `TokenCount` 和本轮累计的 `TurnDiff`。还有一个提前结束的出口：多 agent 协作时，若模型刚输出一段 commentary 或推理，而收件箱里有别的 agent 发来的信件，就提前结束这次请求、标记需要继续，让下一次请求带上新信件（功能开关 `defer_mailbox_preemption` 可以关掉这个行为）。

## 出错了怎么办

`try_run_sampling_request` 返回错误后，`run_sampling_request` 先挑出不重试的：上下文超窗时把用量标记为已满，额度用尽时刷新限额快照，然后都直接返回。其余交给 `handle_response_stream_error`，它先看 `CodexErr::retry_delay`：认证、配额、策略类错误返回 `None`，终止；流中断、超时、限流、服务端错误、连接失败等给出延迟，优先用服务端建议的时间，否则从 200 毫秒起倍增、带 10% 抖动（`codex-rs/async-utils/src/backoff.rs:12`）。

```rust
    if retry_state.retries >= max_retries
        && client_session.try_switch_fallback_transport(
            &turn_context.session_telemetry,
            turn_context.model_info(),
        )
    {
        sess.send_event(
            turn_context,
            EventMsg::Warning(WarningEvent {
                message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
            }),
        )
        .await;
        retry_state.retries = 0;
        return Ok(());
    }
```

（`codex-rs/core/src/responses_retry.rs:99`）

重试预算是提供方的 `stream_max_retries`，默认 5 次、上限 100（`codex-rs/model-provider-info/src/lib.rs:64`）。用完之后如果正在走 WebSocket，就退到 HTTPS、计数清零再来一轮；界面上的 `Reconnecting... n/m` 就是这里发的，发布版会隐藏 WebSocket 的第一次重试提示。连接直接失败（`ConnectionFailed`）另有一条路：默认开启的 `unbounded_connection_retries` 让普通会话无限等网络恢复，间隔从 5 秒倍增到 60 秒封顶，内部会话与 Amazon Bedrock 不走这条路。

重试时不会清空已经收到的内容。完整的输出项在 `OutputItemDone` 时已写进历史，已派发的工具在流出错后也会被 `drain_in_flight` 收齐，所以重试请求从历史重新生成输入，接着断点往下做；万一某个调用没有配对的输出，生成输入时的规范化会补一条内容为 `aborted` 的输出（`codex-rs/core/src/context_manager/normalize.rs`），历史始终合法。

## 打断与插话

插话走 `Op::TurnInput` 的 `StartOrSteer` 模式：有正在运行的普通任务，`steer_input` 就把输入连同受理顺序号压进本轮 `TurnState` 的 `pending_input`，下一圈循环顶部取出；审查任务和压缩任务不接受插话。中断走 `Op::Interrupt`：取消令牌一触发，流读取处的 `or_cancel` 立刻返回 `TurnAborted`，未完成的工具被 abort，历史里记一条 `<turn_aborted>` 提示（`agents.interrupt_message` 默认开启），再发出 `TurnAborted` 事件。具体的收尾顺序见[任务类型与 Plan 模式](https://daiw.net/manual/codex-source/tasks-and-plan-mode)。

## 和《从 LLM 到 Coding Agent》对照

[Agent Loop](https://daiw.net/manual/llm-to-agent/agent-loop) 的最小实现是“问模型，有工具就执行，结果喂回去，再问”的 `while` 循环，Codex 的骨架一模一样：`needs_follow_up` 就是“这次回复里有没有工具调用”。区别在骨架外面的东西：[边流边执行](https://daiw.net/manual/llm-to-agent/streaming-tool-execution)对应 `FuturesOrdered` 加 `drain_in_flight`；[中断与转向](https://daiw.net/manual/llm-to-agent/interrupt)区分的硬中断与转向，在这里是 `Op::Interrupt` 与 steer 两条独立的路。[错误恢复](https://daiw.net/manual/llm-to-agent/error-recovery)提醒重试前要清理流了一半的“孤儿消息”，Codex 的做法是只把完整的输出项写进历史、缺配对的调用在生成输入时补上 `aborted` 输出，于是重试可以直接接着已有进度做。对照 [Grok Build 的主循环](https://daiw.net/manual/grok-build/agent-loop)：两者都在采样前查压缩，Grok Build 按上下文窗口的 85% 触发，Codex 默认按窗口的 90% 触发，还另有一道 95% 的硬上限。

---

上一篇：[Session 与 TurnContext · 会话状态与每轮配置](https://daiw.net/manual/codex-source/session-and-turn-context) · 下一篇：[模型客户端 · Responses API 的流式调用](https://daiw.net/manual/codex-source/model-client)
