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

> grok 的主循环不在「Agent 对象」里，而在 shell 的 SessionActor。这一章讲清「Agent 对象」与「会话 actor」的分工，以及 process_conversation_turn 那个核心 loop：一次用户输入如何驱动模型与工具来回多轮，直到收敛。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/agent-loop
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[消息生命周期](https://daiw.org/manual/grok-build/message-lifecycle)那张图的第 2 步——「开一轮 turn」——是整个 agent 的心脏。这一章把它拆开。

## 先分清两个东西：Agent 对象 vs 会话 actor

读源码时最容易迷路的一点：**主循环不在 `xai-grok-agent` 里**。

- `xai-grok-agent` 造的是一个 **`Agent` 对象**（`agent.rs`）：definition + 渲染好的 system prompt + `Arc<ToolBridge>` + 各种策略（压缩、提醒）+ 后端托管工具清单。它构造完基本**不可变**，是「这个会话配置成什么样」的载体，本身不驱动任何东西。
- 真正的**循环**在 `xai-grok-shell` 的 **`SessionActor`**（`src/session/acp_session.rs`，实现拆在同级的 `acp_session_impl/` 目录里）。它是一个**单线程 actor**（`!Send`，跑在 tokio `LocalSet` 上），一个 `run_session` 命令循环（`run_loop.rs`）收 `SessionCommand`，把 turn 派出去跑。

记住这个分工：**Agent 对象是「配置」，SessionActor 是「引擎」。**

## 一次输入到一个 turn

你发一句话，走这条链（每一跳在源码里是独立文件，职责清晰）：

1. `SessionCommand::Prompt` → `queue_input`（`prompt_queue.rs`）把输入压进 `pending_inputs` 队列；
2. `maybe_start_running_task`（`notification_drain.rs`）：若当前没有 running task，就把队首提升为运行中（顺带处理合并排队消息、hook 拦停、编辑锁等情况），由 `AgentTask::new_prompt` 用 `spawn_local` 起一个 turn 任务；
3. `handle_prompt` → `process_conversation_turn_with_recovery` → **`process_conversation_turn`**（都在 `turn.rs`，核心 `loop` 在它包着的 `process_conversation_turn_inner` 里）；
4. 跑完，结果经 completion 通道回到 `run_session`，`handle_turn_end` 收尾，再看队列里有没有下一条。

<Callout type="info">
  **一个「turn」不等于「一次模型调用」。** 一个 turn 内部会和模型来回很多次——模型每要求调一次工具，就得把结果喂回去再问一遍。turn 结束的定义是：**模型不再要求工具、给出最终答复**。
</Callout>

## 核心 loop：`process_conversation_turn`

它返回 `TurnOutcome`（`Completed` / `Cancelled` / `MaxTurnsReached` / `StationarityEnded`）。循环体每一圈大致是：

```mermaid
flowchart TB
  S[圈开始] --> ST{相同工具调用<br/>连发超限?}
  ST -->|是| SE[StationarityEnded]
  ST -->|否| DR[排空打断 + 注入待发提醒]
  DR --> AC{自动压缩?<br/>用量≥85%}
  AC -->|是| CP[压缩后重建请求] --> BR
  AC -->|否| BR[构建工具清单 + ConversationRequest]
  BR --> SM[run_turn_via_sampler]
  SM --> RC[记账 + 提交响应项到 chat-state]
  RC --> BRANCH{模型还要调工具吗?}
  BRANCH -->|否| TG[TodoGate 检查] --> DONE[finalize → Completed]
  BRANCH -->|是| EX[execute_tool_calls 执行工具] --> FB[结果写回 chat-state] --> S
```

几个关键点：

- **采样前先查压缩**：`check_auto_compact_needed()`（`session/compaction.rs`）在圈顶跑，估算用量达到上下文窗口的 85%（默认阈值）就先压缩再采样（详见[上下文压缩](https://daiw.org/manual/grok-build/compaction)）。
- **调模型**：`run_turn_via_sampler`（`sampler_turn.rs`）最终调的是[下一章](https://daiw.org/manual/grok-build/sampler-actor)那个采样器 actor 的 `submit_and_collect_with_metadata`——`submit_and_collect` 的加强版，多带回 doom-loop 信号。它还会等一个「流已排空」的屏障（`STREAM_DRAIN_TIMEOUT`，5 秒），确保流式增量都落到 UI 后再处理工具调用。
- **分支**是循环的灵魂：
  - 模型**没再要工具** → 跑 **TodoGate**（这道闸默认关闭，需经远端设置 `todo_gate_enabled` 打开；开着时若还有未完成 todo 且没超次数上限——默认每个 prompt 2 次——就注入 `<system-reminder>` 逼它 `continue`）→ 再排空一次打断 → 否则 `finalize` 收尾、返回 `Completed`。
  - 模型**要工具** → `execute_tool_calls` 执行（详见[工具系统](https://daiw.org/manual/grok-build/tool-contract)），结果写回 `chat_state`，`continue` 再来一圈。
- **安全网**：
  - `MaxTurnsReached`（`--max-turns` 上限）、工具后 `check_preflight_overflow()` 再查一次溢出；
  - 鉴权失败自动刷新重试（`RefreshAuthAndResubmit`）、上下文超长自动压缩重投（`CompactAndResubmit`）；
  - 瞬时故障（流停滞、传输错误、可重试的 5xx）在采样器自己的重试用尽后，turn 层再按 2 / 10 / 30 秒退避重投（`RetryTransient`，只对交互式主会话开启：每步最多 3 次、每个 prompt 最多 10 次、一次故障期最长 10 分钟）；
  - 「动作停滞」检测：模型连续发出逐字相同的工具调用，先注入提醒，再超过硬阈值就以 `StationarityEnded` 静默收尾（阈值见[打断与防失控](https://daiw.org/manual/grok-build/interjection-doom-loop)）；
  - 输出撞上 `max_tokens` 被截断时，还有一套可配置的 length salvage，注入「接着写」的提醒让模型续上。

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

那本的 [agent-loop](https://daiw.org/manual/llm-to-agent/agent-loop) 讲的最小实现，本质是一个 `while` 循环：问模型 → 有工具就执行 → 喂回去 → 再问。grok 的循环骨架**一模一样**，但外面裹了一圈生产级的东西：自动压缩、TodoGate、打断排空、鉴权重试、瞬时重投、溢出兜底、动作停滞检测、turn 上限。**教学实现让你看清心跳；生产实现让你看清心跳周围要挂多少监护仪。**

---

上一篇：[一条消息的生命周期](https://daiw.org/manual/grok-build/message-lifecycle) · 下一篇：[采样器 · actor 模型](https://daiw.org/manual/grok-build/sampler-actor)
