Claude Code 101|04|Agent Loop

Claude Code 的核心 Agent Loop 是模型与 Tool Runtime 的闭环:assistant 产生 tool_use,runtime 执行工具,再把 tool_result 注入下一轮模型调用。

Claude Code 101|04|Agent Loop

前几篇建立了三层边界:Agent Runtime 是容器,Input Pipeline 把输入编译成运行时事件,QueryEngine 把一次输入组织成可恢复的 conversation turn。现在进入更内层的问题:当一个 turn 确认需要模型参与后,模型和工具如何推进任务?

Claude Code 的核心不是“一次请求,一次回答”,而是一个 continuation / recovery 状态机。模型在 assistant message 中产生文本、thinking 或 tool_use;runtime 执行工具,把 tool_result 作为 user message 注入;模型基于观察继续推理;如果上下文过长、输出过长、工具失败、权限拒绝、hook 阻断或用户中断,runtime 再决定恢复、压缩、继续或终止。

这就是 Agent Loop。

工程问题

聊天机器人可以把模型输出当作最终结果。Coding agent 不行。用户说“修复测试”,模型往往需要先读文件、定位失败、编辑代码、运行测试、分析报错、再继续修改。每一步都依赖真实环境的观察。模型无法直接读取文件系统,也无法直接执行 shell;它只能提出行动意图。Runtime 必须把意图变成工具执行,再把观察转回模型上下文。

一个 naive loop 可能这样写:

while (true) {
  const assistant = await model(messages)
  if (!assistant.toolCalls.length) return assistant.text
  const results = await runTools(assistant.toolCalls)
  messages.push(assistant, toolResults(results))
}

这个形状能解释工具闭环,但解释不了 Claude Code 的复杂性。真实 Agent Loop 还要处理:

  • tool_use 是否需要权限;
  • 工具结果过大时如何压缩或替换;
  • streaming 中是否可以提前执行工具;
  • prompt too long、max output tokens、media size error 如何恢复;
  • stop hooks 是否阻止结束;
  • max turns、task budget、token budget 如何限制循环;
  • compact boundary 后如何保留消息链;
  • 用户中断时如何为未完成 tool_use 补 tool_result;
  • 哪些事件要流式 yield 给宿主,哪些只用于内部状态。

因此,Agent Loop 不是 while 循环那么简单。它是模型、工具、预算、恢复和事件协议共同构成的状态机。

概念边界

Agent Loop 位于 QueryEngine 内部、Tool Runtime 之上。

QueryEngine 负责 turn 生命周期:输入处理、prompt/context 前缀、transcript、SDK init、事件归一化和最终 result。它调用 src/query.tsquery() 进入模型-工具循环。query() 内部的 queryLoop() 维护 loop state,并不断 yield assistant、user、system、progress、stream event、tombstone、tool use summary 等内部消息。

Tool Runtime 则负责具体工具执行。src/Tool.ts 定义工具上下文和权限上下文;src/services/tools/toolOrchestration.ts 负责运行工具;src/services/tools/StreamingToolExecutor.ts 支持流式工具执行;src/utils/toolResultStorage.ts 负责工具结果预算和内容替换。

所以三者边界是:

QueryEngine
  owns turn lifecycle, transcript, SDK protocol, final result
        │
        ▼
Agent Loop in query.ts
  owns continuation / recovery state machine
        │
        ▼
Tool Runtime
  owns permissioned execution and observations

这条边界可以避免两个常见错误:把 loop 写成 SDK wrapper,或者把工具执行细节塞进模型调用函数。Claude Code 的 loop 更专注:它决定下一步是继续、恢复、压缩、执行工具,还是终止。

机制一:Loop state 显式保存 continuation 语义

src/query.tsState 类型说明 Agent Loop 不是无状态生成器。它包含 messagestoolUseContextautoCompactTrackingmaxOutputTokensRecoveryCounthasAttemptedReactiveCompactmaxOutputTokensOverridependingToolUseSummarystopHookActiveturnCounttransition

这些字段可以分成几类。

messages 是循环的消息链。每次 assistant 输出和 tool_result 都会影响下一轮模型输入。

toolUseContext 是工具执行上下文。它携带工具列表、MCP clients、permission mode、read file state、AbortController、AppState 更新函数等,使工具执行不需要全局状态。

autoCompactTrackinghasAttemptedReactiveCompactmaxOutputTokensRecoveryCountmaxOutputTokensOverride 是恢复相关状态。它们记录系统是否已经尝试过 compact 或 max output recovery,避免无限重试。

pendingToolUseSummaryturnCount 是可观测性与边界控制。长工具执行可以生成摘要;turn count 则防止模型-工具循环无限推进。

transition 记录上一轮为什么继续,用于区分正常 tool_use continuation、compact recovery、token recovery 等路径。

type LoopState = {
  messages: RuntimeMessage[]
  tools: ToolRuntimeContext
  turn: number
  transition?: ContinueReason
  recovery: {
    autoCompact?: AutoCompactState
    maxOutputRetries: number
    reactiveCompactAttempted: boolean
    outputTokenOverride?: number
  }
  pending: {
    toolUseSummary?: Promise<RuntimeMessage | null>
    stopHookActive?: boolean
  }
}

显式 state 的价值在于,Loop 可以把“为什么继续”变成工程对象,而不是靠递归调用或异常控制流隐式表达。Agent 的可恢复性正来自这些状态字段。

机制二:上下文投影发生在每一轮模型调用前

每次进入模型前,Agent Loop 都要重新决定模型应该看到什么。src/query.ts 会从 compact boundary 后取消息,处理工具结果预算,拼接 user context 和 system context,构造 API messages,并计算 token warning / token budget。它还会根据 feature gate 和错误路径触发 reactive compact、context collapse 或 snip compact 等策略。

这说明上下文不是 turn 开始时一次性生成的。工具执行会产生新观察,compact 会改变消息边界,工具结果预算可能用占位内容替换大输出,stop hook 和 attachment 也可能插入消息。每一轮模型调用都必须重新投影 context。

async function prepareModelInput(state: LoopState): Promise<ModelRequest> {
  const afterBoundary = getMessagesAfterLastCompact(state.messages)
  const resultBudgeted = await applyToolResultBudget(afterBoundary)
  const normalized = normalizeForModel(resultBudgeted)

  return {
    system: buildSystemPrompt(state),
    messages: prependAndAppendContext(normalized, state),
    tools: state.tools.schemas(),
    model: state.tools.options.mainLoopModel,
    budget: computeTurnBudget(state),
  }
}

这个机制带来的工程启发是:Agent Loop 不能把 messages 当作 append-only log 直接发给模型。消息链是 transcript 的基础,但 model-visible context 是经过预算和边界治理后的投影。二者有关联,但不是同一个对象。

机制三:tool_use / tool_result 是控制权交接协议

Claude Code 的模型调用返回 assistant message。assistant message 里可能包含文本,也可能包含 tool_use block。只要存在 tool_use,Loop 就不能结束;它必须把控制权交给 Tool Runtime。

src/query.ts 从 assistant message 中提取 tool_use block,src/services/tools/toolOrchestration.ts 执行工具,结果被包装成 tool_result block,再通过 createUserMessage() 进入消息链。这个设计符合 Anthropic messages 协议:工具观察不是隐藏变量,而是一条 user role message。

这个协议有三个关键性质。

第一,模型和执行环境解耦。模型只提出 { name, input },runtime 负责找到工具、校验输入、检查权限、执行、处理错误和压缩输出。

第二,观察可审计。tool_result 带着 tool_use_id 回到消息链,后续模型推理能明确知道哪个工具返回了什么。

第三,失败也是观察。权限拒绝、工具错误、用户中断都可以被编码为 is_error 的 tool_result,而不是让 loop 崩溃。

async function continueAfterAssistant(state: LoopState, assistant: AssistantMessage) {
  state.messages.push(assistant)

  const toolUses = assistant.content.filter(isToolUse)
  if (toolUses.length === 0) {
    return decideTerminalOrRecovery(state, assistant)
  }

  const observations = await state.tools.run(toolUses, {
    canUseTool: state.tools.permissions,
    abortSignal: state.tools.abort.signal,
  })

  state.messages.push({
    role: 'user',
    content: observations.map(toToolResultBlock),
  })

  state.turn += 1
  return { type: 'continue', reason: 'tool_result' }
}

这里最容易被低估的是 user role。tool_result 以 user message 形式回到模型上下文,意味着 runtime 是模型的“环境代理”。模型在下一轮不是读取宿主变量,而是读取一条明确的环境观察。

机制四:Loop 的结束由 runtime 判定

很多人以为 Agent Loop 是否结束取决于模型 stop reason。Claude Code 的设计更谨慎。模型 stop reason 只是输入之一,真正的结束由 runtime 综合判断。

src/query.ts 里有 needsFollowUp 一类逻辑:如果 assistant message 里有 tool_use,就必须继续。除此之外,stop hooks、max turns、prompt too long、media size error、max output tokens recovery、reactive compact、abort signal、task budget 和 token budget 都可能改变终止路径。

例如,模型没有 tool_use 并不一定立即结束。Stop hook 可能要求继续或阻断;max output tokens 可能触发 recovery,让 loop 用更高输出上限继续;prompt too long 可能触发 compact 后重试;API retry 可能产生 system event;预算命中则应返回错误结果而不是继续。

type LoopDecision =
  | { type: 'continue'; reason: 'tool_result' | 'compact' | 'max_output_recovery' }
  | { type: 'terminal'; status: 'success' }
  | { type: 'terminal'; status: 'error'; error: LoopError }
  | { type: 'abort'; reason: 'user' }

async function decideNext(state: LoopState, event: LoopEvent): Promise<LoopDecision> {
  if (state.abort.signal.aborted) return { type: 'abort', reason: 'user' }
  if (state.turn >= state.maxTurns) return { type: 'terminal', status: 'error', error: 'max_turns' }
  if (event.hasToolUse) return { type: 'continue', reason: 'tool_result' }
  if (await shouldRecoverFromContext(event, state)) return { type: 'continue', reason: 'compact' }
  if (await stopHookRequestsContinuation(event, state)) return { type: 'continue', reason: 'stop_hook' as never }
  return { type: 'terminal', status: 'success' }
}

工程上,这意味着 Agent Loop 的终止条件必须集中管理。否则系统会出现“模型觉得结束了,但工具还没回填”“UI 显示结束了,但 recovery 还在跑”“SDK 收到错误后断开,但 loop 实际继续”的不一致。

机制五:Streaming tool execution 优化延迟,但提高状态复杂度

Claude Code 引入 src/services/tools/StreamingToolExecutor.ts,说明工具执行不一定等到完整 assistant message 结束后才开始。当流式输出中已经出现完整可执行的 tool_use,runtime 可以提前启动工具。

这带来直接的体验收益:模型还在输出后续内容时,耗时工具已经开始执行,用户等待时间减少。对长命令、文件搜索、Web 请求、MCP 调用尤其明显。

但 streaming tool execution 也带来更复杂的状态要求。

第一,工具输入必须完整后才能执行。部分 JSON 或未完成 block 不能启动。

第二,工具结果顺序必须可恢复。即使多个工具并发执行,回填给模型的 tool_result 仍要和 tool_use_id 对齐。

第三,权限流程不能被绕过。提前执行不代表提前授权;权限请求仍然要进入同一套 canUseTool 和 permission context。

第四,用户中断时要处理悬空 tool_use。如果 assistant 已经提出工具但 runtime 没能完成执行,需要生成错误 tool_result,避免下一轮模型看到不完整协议。

type StreamingToolState = {
  started: Set<string>
  completed: Map<string, ToolObservation>
  pendingPermission: Set<string>
}

async function onStreamBlock(block: ContentBlock, state: StreamingToolState, ctx: ToolContext) {
  if (!isCompleteToolUse(block)) return
  if (state.started.has(block.id)) return

  state.started.add(block.id)
  void runOneTool(block, ctx).then(result => {
    state.completed.set(block.id, result)
  })
}

这个机制说明,Agent Loop 既是推理循环,也是并发控制器。为了降低延迟,它必须在模型流、工具执行流和 UI 事件流之间保持一致性。

机制六:Recovery 是 loop 的正常分支

成熟 Agent Loop 不能把所有失败都交给外层 try/catch。Claude Code 在 src/query.ts 中把多个恢复路径放进 loop state:prompt too long、max output tokens、reactive compact、context collapse、media size error、API fallback、tool result budget、auto compact tracking 等。

这类恢复路径的共同特点是:它们不是“任务失败”,而是“当前投影失败”。例如 prompt too long 说明当前上下文无法进入模型,但经过 compact 后可能继续;max output tokens 说明本轮输出被截断,但提高上限或改变 continuation 策略后可能继续;工具结果过大说明观察需要被替换或存储,而不是整轮失败。

async function recoverOrFail(error: LoopError, state: LoopState): Promise<LoopDecision> {
  if (error.type === 'prompt_too_long' && !state.recovery.reactiveCompactAttempted) {
    state.messages = await compactForContinuation(state.messages)
    state.recovery.reactiveCompactAttempted = true
    return { type: 'continue', reason: 'compact' }
  }

  if (error.type === 'max_output_tokens' && state.recovery.maxOutputRetries < 3) {
    state.recovery.maxOutputRetries += 1
    state.recovery.outputTokenOverride = increaseOutputLimit(state)
    return { type: 'continue', reason: 'max_output_recovery' }
  }

  return { type: 'terminal', status: 'error', error }
}

Recovery 需要上限。没有 retry ceiling,Agent 可能在失败投影之间无限循环;没有 recovery,Agent 又会在可修复问题上过早失败。成熟 loop 必须同时设计“能恢复”和“会停止”。

机制七:Loop 同时生产内部状态和宿主事件

query() 是 async generator。它不只是返回最终 assistant message,而是在执行过程中 yield 多种消息和事件。QueryEngine 会消费这些事件,更新 mutableMessages、写 transcript、统计 usage、转换 SDKMessage、处理 compact boundary、捕获 structured output 和 result subtype。REPL 则会把类似事件渲染到终端。

这说明 Agent Loop 有双重职责:推进内部状态,同时为宿主提供可观测事件。

内部状态需要精确:messages、turn count、transition、tool results、compact boundary、usage。宿主事件需要可理解:assistant 文本、工具开始、工具完成、进度、错误、重试、权限请求、最终结果。

如果只返回最终结果,用户会在长任务中失去信心,也无法干预权限和中断。如果把所有内部事件原样暴露,SDK 和 UI 又会被实现细节绑死。因此 QueryEngine 和 REPL 都承担事件适配层。

async function* runLoop(state: LoopState): AsyncGenerator<LoopEvent, TerminalState> {
  while (true) {
    yield { type: 'request_start', turn: state.turn }

    const assistant = await sampleModelStreaming(state, event => yieldStream(event))
    yield { type: 'assistant', message: assistant }

    const decision = await continueOrFinish(state, assistant)
    if (decision.type === 'terminal') return decision

    yield { type: 'transition', reason: decision.reason }
  }
}

伪代码里的 yieldStream 在 TypeScript 中不能这样直接写,但它表达了架构意图:Loop 是一个可观察状态机,而不是黑盒函数。

Claude Code 作为 Agent Loop 案例

从 Agent Runtime 的角度看,Claude Code 的 Agent Loop 不是“调用模型直到没有工具”的几行代码,而是 continuation / recovery 状态机。它同时处理模型流、工具执行、工具结果回填、结构化消息、预算治理、compact、stop hooks、用户中断和错误恢复。

这也是 Agent Loop 的核心价值:它把模型推理和环境行动组织成一个可继续、可中断、可恢复、可观察的循环,而不是一次性 API 请求。

工程启发

第一,把 Agent Loop 写成状态机,而不是递归函数。显式保存 turn count、transition、recovery attempts、pending tool summary 和 abort signal,才能处理真实失败路径。

第二,把 tool_use / tool_result 当作协议。工具执行结果应回到消息链,而不是藏在宿主变量里。这样模型、transcript、resume 和 SDK 都能看到同一条事实链。

第三,把终止决策放在 runtime。模型 stop reason 很重要,但不能单独决定任务结束。工具、hooks、预算、compact、recovery、abort 都可能改变下一步。

第四,把 recovery 设计为主路径。上下文过长、输出过长、工具结果过大、API retry 都是 Agent 日常,不是罕见异常。每种恢复都需要上限和可观测事件。

第五,谨慎引入 streaming tool execution。它能显著改善延迟,但必须保证权限、顺序、去重、中断和 tool_use_id 对齐。否则系统会出现非常难调试的竞态。

第六,把内部事件和外部协议分层。Loop 可以 yield 丰富内部事件,但 SDK 和 UI 应该看到稳定、语义化的事件,而不是直接依赖内部实现细节。

小结

Claude Code 的 Agent Loop 可以概括为:Context Projection → Model Sampling → Tool Execution → Observation Injection → Continuation Decision → Recovery or Terminal。这个闭环让模型输出不再只是答案,而是可能触发真实环境动作的下一步意图;runtime 则把这些意图变成受权限、预算、恢复和可观测性治理的执行轨迹。

这篇承接 QueryEngine:QueryEngine 管 turn 生命周期,Agent Loop 管 turn 内部如何推进。下一篇会进入 Context Engineering:当 Loop 每一轮都要重新调用模型时,Claude Code 如何把 session state、工具说明、memory、文件观察和系统规则投影成 model-visible context,并在预算限制下保持可继续推理。