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 结束]
一次用户请求进来,这个循环可能转几轮到几十轮:读文件、改代码、跑测试、看报错、再改……直到模型不再要求调用工具。循环怎么设计,直接决定了:
- 延迟感 —— 是否边 stream 边渲染,甚至边 stream 边投机执行工具
- 可中断性 —— Ctrl+C 按下去,半截的 tool call 能不能收拾干净
- 崩溃恢复 —— 进程挂了,对话现场还在不在
- token 成本 —— 每轮带多少历史,什么时候压缩
- 行为上限 —— 模型失控死循环时,谁来踩刹车
同一个模型,换一套 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:242—Err(SteerInputError::NoActiveTurn(items)) => {—— 没有活跃 turn 时才走 spawn 分支。
codex-rs/core/src/tasks/mod.rs:320—self.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:2315—if 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:861—task.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:443—step-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.ts 里 maxRetries: 0),换成 Effect Schedule,分类时尊重 Retry-After,5xx 才重试:
packages/opencode/src/session/retry.ts:75—if (!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)。
逐点看:
- 每轮重载历史(
prompt.ts:1092)—— 这是”DB 即真相”的直接后果:loop 本身几乎不持有状态,内存里的msgs每轮都是全新的。进程在任意两步之间死掉,重启后从 DB 恢复即可续跑。 - 防御性
hasToolCalls(prompt.ts:1106-1109)—— 不信任 finish reason 的工程化落地:除了finish === "tool-calls",还要扫一遍 parts;被中断善后排掉的孤儿 tool call(isOrphanedInterruptedTool)除外。 - 退出条件的最后一个子句
lastUser.id < lastAssistant.id—— message id 单调递增,这个比较保证”assistant 的收尾是在回应最新一条 user 消息”,防止 steer 进来的新输入被旧收尾误判为已回答。 - 第 (4) 步之前,assistant message 已落库(
prompt.ts:1201)—— 先记账、后花钱。stream 中每个事件再持续写回(processor.ts:643),所以 DB 里永远是”进行中的真相”,不是事后补录。
对照阅读建议:看完这段,再去读 reference/claude-code/src/query.ts 的 queryLoop(L307 起),找同样的四个动作 —— 预处理、调模型、收 tool call、执行回填 —— 你会发现它们是同一个 loop,只是 Claude Code 把状态放在内存 State 里、把工具执行拆成了两种模式。
设计权衡
| 决策点 | Claude Code | Codex CLI | OpenCode |
|---|---|---|---|
| 继续信号 | 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) |
几个值得玩味的对立:
- 状态在哪:Claude Code 放内存(快、灵活,compact 直接改写),OpenCode 放 DB(每轮 O(history) 读库,换崩溃恢复与多 client 免费同步),codex 折中 —— 内存持有但每次调用全量重发,保证”history 与 rollout 永不同步丢失”。
- 谁掌握 loop:OpenCode 明明站在 AI SDK 上,却刻意只用它的单步能力,把多步循环、重试、步数上限全部收回自己手里 —— 与其配置框架,不如拥有循环。Claude Code 同理,连 SDK 自动重试都关掉自己写。
- 刹车哲学:硬刹车(maxTurns)、软刹车(prefill 提示)、不刹车(信任 budget + compact)三条路线都有人在生产环境用。
迁移到你自己的 agent
如果你要给自己的 agent 写一个 loop,这一章的可迁移清单:
- 骨架就是
while (true)+ “有 tool call 就继续”。不要信stop_reason/finish_reason—— 三家都在这上面踩过坑(query.ts:554、prompt.ts:1103)。以 tool_use block 的实际存在为准。 - tool call 一生成就落账,结果按发出顺序落账。codex 的
FuturesOrdered模式(并发执行、按序写回)可以直接抄:它同时保住延迟和历史的确定性。 - 把中断设计成 first-class 路径,而不是异常。取消信号要能传播到”正在 stream”和”正在执行工具”两种现场,并且半截 tool call 必须有善后 —— 回填一个 error result,否则下次调模型时 provider 的 tool_use/tool_result 配对校验会直接把请求打回来(
message-v2.ts:357就是干这个的)。 - 重试分两层:传输层 per-request 退避(感知 429/
Retry-After),语义层(prompt too long、max_output_tokens)在 loop 层做”改写上下文后继续” —— 并且一定要加 circuit breaker,防止恢复逻辑自己死循环(query.ts:1223的次数上限)。 - 决定你的状态住哪。想简单:内存;想崩溃可恢复、多终端同步:DB 每轮重载。这个决策会反向塑造你 loop 里每一行代码的写法。
- 留一个刹车。硬上限最简单;想优雅一点,opencode 的”prefill 最后通牒”是一行代码就能实现的方案(
prompt.ts:1281)。