CHAPTER 1 / 5

Agent Loop 主循环

概念与为什么

LLM 本身是一个无状态函数:输入一串 messages,输出一串 tokens。它不会”做事”。把函数变成 agent 的,是外面那层循环 —— agent loop:

while (true) {
  response = await streamLLM(messages)
  toolCalls = collectToolUses(response)
  if (toolCalls.length === 0) break
  results = await executeTools(toolCalls)
  messages = [...messages, response, ...results]
}
flowchart TD
    A[用户请求] --> B[组装 messages]
    B --> C[stream LLM call]
    C --> D{输出里有 tool_use?}
    D -- 有 --> E[执行 tool calls]
    E --> F[tool results 追加进 messages]
    F --> C
    D -- 没有 --> G[turn 结束]

一次用户请求进来,这个循环可能转几轮到几十轮:读文件、改代码、跑测试、看报错、再改……直到模型不再要求调用工具。循环怎么设计,直接决定了:

同一个模型,换一套 loop,就是另一个产品。这就是为什么 harness 值得拆开看。

三家实现对比

先说三家共有的一个反直觉设计:判断”要不要再转一轮”,依据的不是 API 返回的 stop_reason / finish_reason,而是这一轮输出里有没有 tool call。Claude Code 也把停止原因视为不够可靠的信号,因此以工具调用的实际存在作为继续条件:

src/query.ts:554 — 该处说明停止原因字段并不总能正确反映工具调用;路径相对 reference/claude-code/,Claude Code 源码不开源,本文仅标注位置。

OpenCode 处理的是同一个坑的另一种形态 —— 有的 provider 明明输出了 tool call,finish 却报 "stop":

packages/opencode/src/session/prompt.ts:1103 — 原注释:“Some providers return ‘stop’ even when the assistant message contains tool calls. Keep the loop running so tool results can be sent back to the model.”

在这个共识之上,三家长出了三套完全不同的身体。

Claude Code:嵌套 async generator 栈

两个入口(交互式 REPL 的 onQuery、SDK 的 QueryEngine.submitMessage)都汇入同一个 async generator query(),核心是一个无界 while (true) —— 一次迭代 = 一次模型调用 + 它触发的全部工具执行。

src/query.ts:307 — agent loop 采用无界迭代,依靠显式的终止分支退出。

src/query.ts:659 — 模型响应通过异步生成器逐个内容块消费。

每轮迭代四件事:(1) context 预处理(microcompact/autocompact 可能先重写历史,见第 2 章);(2) stream 模型响应;(3) 收集 tool_use blocks —— 只要存在就继续,与 stop_reason 无关:

src/query.ts:834 — 发现任一工具调用内容块就会请求后续迭代,强制 loop 再运行一轮。

(4) 执行工具,有两套模式:streaming 期间用 StreamingToolExecutor 投机执行;否则用 runTools 按工具自报的并发安全性分批,安全批并发执行(上限 10):

src/query.ts:1382 — 此处在流式阶段的投机执行与非流式的批量工具执行之间分流。

src/services/tools/toolOrchestration.ts:101 — 批处理是否可并发由每个工具根据其输入自行声明。

重试是分层的。传输层:withRetry 手工实现(Anthropic SDK 的自动重试被显式关掉),指数退避、感知 429/529:

src/services/api/claude.ts:1781 — 此处关闭 SDK 的自动重试,改由应用层手工控制。

src/services/api/withRetry.ts:189 — 此处实现包含首次请求在内的受限重试循环。

语义层错误(prompt too long、max_output_tokens)则冒泡到 loop 层做”恢复式继续”,并带 circuit breaker 防止无限自救:

src/query.ts:1223 — 输出 token 达到上限时会进行有上限的续接(最多三次),而非立刻失败。

终止出口是一个枚举,用户中断是一等公民:

src/query.ts:1357 — 没有工具调用且停止钩子通过时,走正常完成出口。

src/query.ts:1051 — 用户在流式阶段中断时,终止状态沿整条异步生成器链传播。

src/query.ts:1705 — 每轮之间检查最大轮数,超过上限即终止。

Codex CLI:channel 驱动的 tokio task

Codex 的核心是 Rust,结构是三家中最”系统编程”的:client 把 Submission { id, op } 发进 channel,submission_loop 负责分发。

codex-rs/core/src/session/handlers.rs:712 — 每个 session 的驱动起点:pub(super) async fn submission_loop(,消费 channel 里的 op。

用户输入到达时做一次分流:当前有活跃 turn,就把输入 steer 进正在跑的 turn(挂为 pending input,不打断);没有,才 spawn 新任务 —— 并且新任务会先把旧任务 abort 掉(原因:Replaced)。

codex-rs/core/src/session/handlers.rs:242Err(SteerInputError::NoActiveTurn(items)) => { —— 没有活跃 turn 时才走 spawn 分支。

codex-rs/core/src/tasks/mod.rs:320self.abort_all_tasks(TurnAbortReason::Replaced).await; 一个 session 同一时刻只有一个活跃任务。

codex-rs/core/src/tasks/mod.rs:402 — 每个任务跑在自己的 tokio::spawn( 里,带独立的 CancellationToken

flowchart LR
    U[Op::UserInput] --> S{有活跃 turn?}
    S -- 有 --> P[挂入 pending input<br/>mid-turn steering]
    S -- 没有 --> A[abort 旧任务 Replaced]
    A --> T[tokio::spawn RegularTask]
    P --> L[run_turn loop]
    T --> L

agent loop 本体在 run_turn:每轮先 drain 掉 steer 进来的 pending input,然后克隆全量历史作为模型输入,发起一次 sampling request。

codex-rs/core/src/session/turn.rs:227 — loop 本体:loop {,一次 iteration = 一次 sampling request。

codex-rs/core/src/session/turn.rs:268 — 每次调用都重新序列化全量历史:sess.clone_history().for_prompt(...),而不是只发增量。

流消费阶段的工具调度很讲究:tool call 一完整(OutputItemDone)就立刻写进历史,同时 spawn 一个执行 future 推入 FuturesOrdered;流结束后统一按序取结果、写回历史 —— 并发执行,但严格按模型发出的顺序落账:

codex-rs/core/src/session/turn.rs:2137 — 工具 future 收集起来而不是就地 await:in_flight.push_back(tool_future);

codex-rs/core/src/session/turn.rs:1910 — 工具结果写回历史,供下一次 sampling request 使用:sess.record_conversation_items(...)

codex-rs/core/src/tools/parallel.rs:133 — 并发门控用一把 RwLock:支持并发的工具拿 read 锁并行跑,不支持的拿 write 锁独占。

继续信号与两家殊途同归:任何 tool call 都会置 needs_follow_up;另外服务端可以显式说”还没完”:

codex-rs/core/src/stream_events_utils.rs:350-356 — tool call item 完成即生成执行 future(L350),并 output.needs_follow_up = true;(L356)。

codex-rs/core/src/session/turn.rs:2315if let Some(false) = end_turn { needs_follow_up = true; } —— server 端 end_turn=false 也会强制再来一轮。

中断是层级的 CancellationToken(turn → sampling → tool),取消后给 100ms 优雅退出窗口,再硬 abort:

codex-rs/core/src/tasks/mod.rs:861task.cancellation_token.cancel(); …100ms 超时后 task.handle.abort();

codex-rs/core/src/session/turn.rs:2034 — 流读取被取消,统一汇入 CodexErr::TurnAborted 一条路径。

重试限定在单次 sampling request 内,带传输降级;UsageLimitReached 这类错误不可重试,直接结束 turn:

codex-rs/core/src/responses_retry.rs:48 — 可重试的流错误按 provider 配置退避重连,WebSocket 失败可降级回 HTTPS。

一个值得注意的”没有”:codex 没有 max-iteration 防护。在 codex-rs/core/src 全仓 grep max_iterations|max_steps|iteration_limit 均无结果;turn.rs:353 的注释原话是 “we shouldn’t worry about being in an infinite loop” —— loop 只靠模型 end_turn、auto-compact、用户中断和 session token budget 来约束。

OpenCode:数据库即真相

OpenCode 的 loop 是三家里最小的,但有一个激进选择:每一轮迭代都从 SQLite 重新加载全部历史

packages/opencode/src/session/prompt.ts:1070 — prompt 提交后直接进 loop:return yield* loop({ sessionID: input.sessionID })

packages/opencode/src/session/prompt.ts:1088 — loop 本体:while (true) {

packages/opencode/src/session/prompt.ts:1092 — 每轮重载历史:let msgs = yield* MessageV2.filterCompactedEffect(sessionID)

每轮做:持久化一条新的 assistant message → 把历史转成 provider 的 ModelMessage[]processor.process 发起一次 streamText。注意它故意不用 AI SDK 的多步能力(不传 stopWhen):一次调用就是一步,loop 完全自己掌控。

packages/opencode/src/session/prompt.ts:1201 — streaming 开始前先把 assistant message 落库:yield* sessions.updateMessage(msg)

packages/opencode/src/session/llm.ts:280 — 每步是一次全新的 streamText({ 调用。

工具以 AI SDK tool() 的形式注册,execute 里跑 Effect、过 permission ask、触发 plugin hooks —— 所以 tool call 的派发由 AI SDK 完成,opencode 只消费 fullStream 事件流,并且每个事件到达即持久化:

packages/opencode/src/session/tools.ts:102 — AI SDK 的 tool call 最终落进这个 execute(args, options) { 包装。

packages/opencode/src/session/processor.ts:643 — write-through streaming:Stream.tap((event) => handleEvent(event)),每个 delta 都落 DB + 发 event bus。

packages/opencode/src/session/processor.ts:443step-finish 时把 finish reason 写到 assistant message 上:ctx.assistantMessage.finish = value.reason —— 这正是 loop 退出判断所读的字段。

中断模型走 Effect fiber:interrupt 触发善后,把没跑完的 tool part 标记为 aborted;下次组装历史时,这些”孤儿 tool call”会被回填成 error result,保证 provider 要求的 tool_use/tool_result 配对不变量不炸:

packages/opencode/src/session/processor.ts:588 — 被中断的 tool call 标记 error: "Tool execution aborted",

packages/opencode/src/session/message-v2.ts:357 — 历史转换时回填:errorText: "[Tool execution was interrupted]",

重试同样从 SDK 手里收回来(llm.tsmaxRetries: 0),换成 Effect Schedule,分类时尊重 Retry-After,5xx 才重试:

packages/opencode/src/session/retry.ts:75if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined —— 不可重试的错误直接放行给上层。

step 上限的实现很”hacky 但有效”:接近上限时,prefill 一条 assistant 消息(MAX_STEPS_PROMPT)告诉模型”这是最后一步,收个尾”:

packages/opencode/src/session/prompt.ts:1281...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])

代码走读

三选一,走读 OpenCode —— 它是三者中把骨架摊得最平的一个(单一函数、MIT 可引用、TypeScript 对全栈读者友好)。Claude Code 的 queryLoop 是嵌套 generator,控制流被 yield 切成几段;codex 的 run_turn 分层最工整,但 Rust 有阅读门槛。

以下节选自 prompt.ts:1088-1130(省略日志与旁支,完整代码见链接):

while (true) {
  yield* status.set(sessionID, { type: "busy" })

  // (1) 每一轮都从 DB 全量重载历史
  let msgs = yield* MessageV2.filterCompactedEffect(sessionID)

  const { user: lastUser, assistant: lastAssistant, ... } = MessageV2.latest(msgs)
  if (!lastUser) throw new Error("No user message found in stream...")

  // (2) 有的 provider 有 tool call 也报 "stop",所以显式检查 parts
  const hasToolCalls = lastAssistantMsg?.parts.some(
    (part) => part.type === "tool" && !part.metadata?.providerExecuted
              && !isOrphanedInterruptedTool(part),
  ) ?? false

  // (3) 退出:最后的 assistant 正常收尾、无 tool call、且比最后的 user 新
  if (
    lastAssistant?.finish &&
    !["tool-calls"].includes(lastAssistant.finish) &&
    !hasToolCalls &&
    lastUser.id < lastAssistant.id
  ) {
    break
  }

  step++
  // (4) 持久化新 assistant message → 转 ModelMessages → processor.process(streamText)
  ...
}

packages/opencode/src/session/prompt.ts:1088 — 原始代码锚点(节选范围 L1088–L1130)。

逐点看:

  1. 每轮重载历史(prompt.ts:1092)—— 这是”DB 即真相”的直接后果:loop 本身几乎不持有状态,内存里的 msgs 每轮都是全新的。进程在任意两步之间死掉,重启后从 DB 恢复即可续跑。
  2. 防御性 hasToolCalls(prompt.ts:1106-1109)—— 不信任 finish reason 的工程化落地:除了 finish === "tool-calls",还要扫一遍 parts;被中断善后排掉的孤儿 tool call(isOrphanedInterruptedTool)除外。
  3. 退出条件的最后一个子句 lastUser.id < lastAssistant.id —— message id 单调递增,这个比较保证”assistant 的收尾是在回应最新一条 user 消息”,防止 steer 进来的新输入被旧收尾误判为已回答。
  4. 第 (4) 步之前,assistant message 已落库(prompt.ts:1201)—— 先记账、后花钱。stream 中每个事件再持续写回(processor.ts:643),所以 DB 里永远是”进行中的真相”,不是事后补录。

对照阅读建议:看完这段,再去读 reference/claude-code/src/query.tsqueryLoop(L307 起),找同样的四个动作 —— 预处理、调模型、收 tool call、执行回填 —— 你会发现它们是同一个 loop,只是 Claude Code 把状态放在内存 State 里、把工具执行拆成了两种模式。

设计权衡

决策点Claude CodeCodex CLIOpenCode
继续信号tool_use block 存在即继续,明示不信 stop_reason(query.ts:554)任何 tool call 或 server end_turn=false(turn.rs:2315)finish === "tool-calls" + 防御性 parts 扫描(prompt.ts:1113)
历史策略内存 State 每轮重建,compact 可重写(query.ts:1716)每轮 clone_history() 全量重发(turn.rs:268)每轮从 SQLite 全量重载(prompt.ts:1092)
工具并发投机 streaming 执行 + 安全分批,上限 10(toolOrchestration.ts:101)spawned futures + RwLock 门控,FuturesOrdered 保序(parallel.rs:133)委托 AI SDK step 内执行,loop 不管并发(tools.ts:102)
中断模型AbortController 贯穿 generator 链,区分 streaming/tools 两阶段(query.ts:1051)层级 CancellationToken,100ms 优雅窗后硬 abort(tasks/mod.rs:861)Effect fiber interrupt + 孤儿 tool call 善后回填(message-v2.ts:357)
重试分层手工 per-request 退避 + loop 层语义恢复(claude.ts:1781, query.ts:1223)per-sampling-request 退避 + WebSocket→HTTPS 降级(responses_retry.rs:48)Effect Schedule,尊重 Retry-After,5xx 才重试(retry.ts:75)
死循环防护硬上限 maxTurns(query.ts:1705)无显式上限,靠 budget/compact/中断(grep 无 max_iterations)软上限:prefill MAX_STEPS_PROMPT 让模型自己收尾(prompt.ts:1281)
崩溃恢复以内存态为主tool call 完成即落 history,turn 取消也不丢(stream_events_utils.rs:350)每 delta 落 DB,天然可恢复(processor.ts:643)

几个值得玩味的对立:

迁移到你自己的 agent

如果你要给自己的 agent 写一个 loop,这一章的可迁移清单:

  1. 骨架就是 while (true) + “有 tool call 就继续”。不要信 stop_reason / finish_reason —— 三家都在这上面踩过坑(query.ts:554prompt.ts:1103)。以 tool_use block 的实际存在为准。
  2. tool call 一生成就落账,结果按发出顺序落账。codex 的 FuturesOrdered 模式(并发执行、按序写回)可以直接抄:它同时保住延迟和历史的确定性。
  3. 把中断设计成 first-class 路径,而不是异常。取消信号要能传播到”正在 stream”和”正在执行工具”两种现场,并且半截 tool call 必须有善后 —— 回填一个 error result,否则下次调模型时 provider 的 tool_use/tool_result 配对校验会直接把请求打回来(message-v2.ts:357 就是干这个的)。
  4. 重试分两层:传输层 per-request 退避(感知 429/Retry-After),语义层(prompt too long、max_output_tokens)在 loop 层做”改写上下文后继续” —— 并且一定要加 circuit breaker,防止恢复逻辑自己死循环(query.ts:1223 的次数上限)。
  5. 决定你的状态住哪。想简单:内存;想崩溃可恢复、多终端同步:DB 每轮重载。这个决策会反向塑造你 loop 里每一行代码的写法。
  6. 留一个刹车。硬上限最简单;想优雅一点,opencode 的”prefill 最后通牒”是一行代码就能实现的方案(prompt.ts:1281)。