02 - Claude Code Agent Loop

重点章节 — 本文档深入讲解 Claude Code 最核心的 Agent Loop(代理循环)机制。


一、概念解释

什么是 Agent Loop?

传统 LLM 的交互是单轮请求-响应模式:

用户提问 → LLM 回答 → 结束

但现实中的复杂任务(如"重构这个模块")需要多步操作:读取文件、理解代码、修改文件、运行测试。Agent Loop 将单轮交互升级为循环

用户提问 → LLM 思考 → 需要工具?→ 调用工具 → 结果喂回 LLM → 继续思考 → ... → 最终回答
                     ↑___________________________________________________|
                              循环直到任务完成

为什么需要 Agent Loop?

没有 Agent Loop有 Agent Loop
每次只能做一件事可以连续完成多步任务
无法使用工具可以调用文件读写、命令执行等工具
上下文在单轮内固定上下文随工具结果不断丰富
无法自我纠正可以根据工具反馈调整策略

核心设计哲学

Claude Code 的 Agent Loop 采用 while(true) 循环而非递归,原因:

  1. 状态管理更清晰 — 每次循环顶部解构状态,避免递归栈过深
  2. 资源控制更精确 — 可以精确追踪 token 消耗和迭代次数
  3. 错误恢复更简单continue 跳到下一次迭代,而非递归展开

二、核心流程图

while (true) — Agent Loop 主循环

文本块

工具调用块

思考块

只读工具

写操作工具

有工具结果

无工具调用

max_output_tokens 错误

prompt_too_long 错误

用户中断

四级压缩

applyToolResultBudget
工具结果预算裁剪

snipCompact
历史消息裁剪

microCompact
微压缩

contextCollapse
上下文折叠

autoCompact
自动压缩(超出阈值时触发)

② 系统提示构建
systemPrompt + userContext + systemContext

③ 调用 LLM API(流式)
Anthropic Messages API

④ 处理流式响应

yield 给 UI 渲染

收集待执行

内部保留

⑤ 工具执行阶段

partitionToolCalls
分区:并发安全/不安全

并行执行

串行执行

⑥ 状态更新 & 终止检查

state = {...更新} → continue

return(循环结束)

恢复循环

压缩后重试

yield 中断消息

① 消息准备阶段

用户输入消息


三、解决什么问题?

Agent Loop 解决的核心问题:让 LLM 能够自主完成需要多步操作、工具调用和自我纠正的复杂任务

具体来说:

  • 多步任务编排 — 一次用户请求可能需要读文件、搜索代码、编辑文件、运行测试等多个步骤
  • 工具调用与反馈 — LLM 的输出可能包含工具调用,执行后结果需要喂回 LLM 继续推理
  • 上下文溢出管理 — 长对话中 token 会超限,需要智能压缩
  • 错误恢复 — API 调用失败、token 超限、用户中断等场景需要优雅处理

四、核心代码详解

4.1 循环状态定义

// src/query.ts — 循环状态,每次迭代顶部解构
type State = {
  messages: Message[]                      // 当前消息列表
  toolUseContext: ToolUseContext            // 工具使用上下文
  autoCompactTracking: AutoCompactTrackingState | undefined  // 自动压缩追踪
  maxOutputTokensRecoveryCount: number     // max_output_tokens错误恢复计数
  hasAttemptedReactiveCompact: boolean     // 是否已尝试响应式压缩
  maxOutputTokensOverride: number | undefined  // 输出token覆盖值
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined      // 停止钩子是否激活
  turnCount: number                        // 当前轮次
  transition: Continue | undefined         // 上一次迭代为什么继续
}

设计要点:状态是不可变的 — 每次 continuestate = { ... } 创建新对象,而非就地修改。

4.2 主循环结构

// src/query.ts — 核心循环(简化版,保留关键逻辑)
async function* queryLoop(
  params: QueryParams,
  consumedCommandUuids: string[],
): AsyncGenerator<StreamEvent | Message, Terminal> {

  // 不可变参数 — 循环中永不修改
  const { systemPrompt, userContext, systemContext, canUseTool, fallbackModel } = params
  const deps = params.deps ?? productionDeps()

  // 可变跨迭代状态
  let state: State = {
    messages: params.messages,
    toolUseContext: params.toolUseContext,
    maxOutputTokensOverride: params.maxOutputTokensOverride,
    autoCompactTracking: undefined,
    maxOutputTokensRecoveryCount: 0,
    hasAttemptedReactiveCompact: false,
    turnCount: 1,
    pendingToolUseSummary: undefined,
    transition: undefined,
  }

  // ========== 主循环 ==========
  while (true) {
    let { toolUseContext } = state  // 每次迭代顶部解构状态
    const { messages, autoCompactTracking, turnCount, ... } = state

    // --- ① 消息准备阶段 ---
    let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]

    // 工具结果预算裁剪(防止工具返回过大内容撑爆上下文)
    messagesForQuery = await applyToolResultBudget(messagesForQuery, ...)

    // 历史消息裁剪(移除过旧的中间消息)
    if (feature('HISTORY_SNIP')) {
      const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
      messagesForQuery = snipResult.messages
    }

    // 微压缩(小粒度的上下文压缩)
    const microcompactResult = await deps.microcompact(messagesForQuery, toolUseContext, ...)
    messagesForQuery = microcompactResult.messages

    // 上下文折叠(合并相似消息)
    if (feature('CONTEXT_COLLAPSE')) {
      const collapseResult = await contextCollapse.applyCollapsesIfNeeded(messagesForQuery, ...)
      messagesForQuery = collapseResult.messages
    }

    // --- ② 构建系统提示 ---
    const fullSystemPrompt = asSystemPrompt(
      appendSystemContext(systemPrompt, systemContext)
    )

    // --- ③ 自动压缩检查 ---
    const { compactionResult } = await deps.autocompact(
      messagesForQuery, toolUseContext, { systemPrompt, userContext, systemContext }, ...
    )
    // 如果压缩发生,用压缩后的消息替换
    if (compactionResult) {
      messagesForQuery = compactionResult.summaryMessages
      // ... 压缩后处理
    }

    // --- ④ 调用 LLM API(流式) ---
    yield { type: 'stream_request_start' }  // 通知UI:API请求开始

    const stream = deps.streamMessages({  // 流式调用 Anthropic API
      messages: normalizeMessagesForAPI(messagesForQuery, ...),
      systemPrompt: fullSystemPrompt,
      model: getCurrentModel(...),
      tools: toolUseContext.options.tools,
      ...
    })

    // --- ⑤ 处理流式响应 ---
    let toolUseMessages: ToolUseBlock[] = []
    for await (const event of stream) {
      if (event.type === 'content_block_start') {
        if (event.content_block.type === 'tool_use') {
          toolUseMessages.push(event.content_block)  // 收集工具调用
        }
      }
      yield event  // 实时yield给UI渲染(文字流式显示)
    }

    // --- ⑥ 工具执行 ---
    if (toolUseMessages.length > 0) {
      // 分区执行:只读工具并行,写操作串行
      for await (const update of runTools(
        toolUseMessages, assistantMessages, canUseTool, toolUseContext
      )) {
        if (update.message) {
          yield update.message  // yield工具结果给UI
          state.messages = [...state.messages, update.message]  // 不可变更新
        }
        if (update.newContext) {
          toolUseContext = update.newContext
        }
      }

      // --- 更新状态,继续循环 ---
      state = {
        ...state,
        messages: [...state.messages, /* 新的工具结果消息 */],
        toolUseContext,
        turnCount: turnCount + 1,
        transition: /* 继续原因 */,
      }
      continue  // ← 回到while(true)顶部,开始下一轮
    }

    // --- ⑦ 无工具调用,循环结束 ---
    return { type: 'stop', reason: 'end_turn' }
  }
}

4.3 函数签名与生成器模式

// src/query.ts — 注意这是一个 AsyncGenerator
export async function* query(
  params: QueryParams,
): AsyncGenerator<
  StreamEvent | RequestStartEvent | Message | TombstoneMessage | ToolUseSummaryMessage,
  Terminal   // 最终返回值
> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  // 循环正常结束后,通知已消费的命令
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

为什么用 AsyncGenerator?

  • yield 实时推送流式事件给 UI(文字逐字显示)
  • return 表示循环最终结果
  • 调用方可以用 for await...of 消费事件流
  • 支持中途取消(.return() 方法)

4.4 工具编排(并发 vs 串行)

// src/services/tools/toolOrchestration.ts — 工具分区执行
export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  assistantMessages: AssistantMessage[],
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
  let currentContext = toolUseContext

  // partitionToolCalls: 将工具分为可并发/不可并发两组
  for (const { isConcurrencySafe, blocks } of partitionToolCalls(
    toolUseMessages, currentContext
  )) {
    if (isConcurrencySafe) {
      // 只读工具(Glob, Grep, Read等)→ 并行执行
      for await (const update of runToolsConcurrently(blocks, ...)) {
        yield { message: update.message, newContext: currentContext }
      }
    } else {
      // 写操作工具(Edit, Write, Bash等)→ 串行执行
      for await (const update of runToolsSerially(blocks, ...)) {
        yield { message: update.message, newContext: update.newContext }
      }
    }
  }
}

4.5 错误恢复策略

// src/query.ts — 多种错误恢复路径

// 1. max_output_tokens 错误:LLM 输出被截断
if (isWithheldMaxOutputTokens(msg)) {
  if (state.maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
    state = {
      ...state,
      maxOutputTokensRecoveryCount: state.maxOutputTokensRecoveryCount + 1,
      maxOutputTokensOverride: /* 增加输出限制 */,
      transition: 'max_output_tokens_recovery',
    }
    continue  // 重新请求,增加输出长度
  }
}

// 2. prompt_too_long 错误:输入超长
if (isPromptTooLongMessage(msg)) {
  // 先尝试压缩
  if (!state.hasAttemptedReactiveCompact) {
    state = { ...state, hasAttemptedReactiveCompact: true }
    continue  // 压缩后重试
  }
  return { type: 'error', reason: 'prompt_too_long' }  // 无法恢复
}

// 3. API 临时错误:指数退避重试
// src/services/api/withRetry.ts 自动处理

4.6 终止条件汇总

条件行为
LLM 不调用工具(end_turn)return { type: 'stop' } — 正常结束
用户发送中断信号yield 中断消息,添加 tool_result 错误
maxTurns 达到上限return { type: 'stop', reason: 'max_turns' }
token 预算耗尽return { type: 'stop', reason: 'budget_exhausted' }
AbortController 被 abort抛出 AbortError,退出循环
prompt_too_long 不可恢复return { type: 'error', reason: 'prompt_too_long' }

五、示例代码:简化版 Agent Loop

以下是一个教学用的简化版 Agent Loop,帮助学生理解核心概念:

// simplified-agent-loop.ts — 教学用简化版
// 演示 Agent Loop 的核心循环机制

// ========== 消息类型定义 ==========
type TextContent = { type: 'text'; text: string }
type ToolUseContent = { type: 'tool_use'; id: string; name: string; input: any }
type ToolResultContent = { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }

type Message = {
  role: 'user' | 'assistant'
  content: (TextContent | ToolUseContent | ToolResultContent)[]
}

// ========== 工具定义 ==========
interface Tool {
  name: string
  description: string
  execute(input: any): Promise<string>
}

// 示例工具:文件读取
const readFileTool: Tool = {
  name: 'read_file',
  description: '读取文件内容',
  async execute(input: { path: string }) {
    return `文件 ${input.path} 的内容:console.log("hello")`
  }
}

// 示例工具:文件编辑
const editFileTool: Tool = {
  name: 'edit_file',
  description: '编辑文件',
  async execute(input: { path: string; old_text: string; new_text: string }) {
    return `已将 ${input.path} 中的 "${input.old_text}" 替换为 "${input.new_text}"`
  }
}

const tools: Tool[] = [readFileTool, editFileTool]

// ========== 模拟 LLM API ==========
async function callLLM(messages: Message[]): Promise<Message> {
  // 模拟:第一次调用返回工具调用,第二次返回最终答案
  const hasToolResult = messages.some(m =>
    m.content.some(c => c.type === 'tool_result')
  )

  if (!hasToolResult) {
    // 第一步:LLM 决定调用工具
    return {
      role: 'assistant',
      content: [
        { type: 'text', text: '我需要先读取文件内容。' },
        { type: 'tool_use', id: 'call_1', name: 'read_file', input: { path: 'index.ts' } }
      ]
    }
  } else {
    // 第二步:LLM 根据工具结果给出最终答案
    return {
      role: 'assistant',
      content: [
        { type: 'text', text: '根据文件内容,我已完成分析。文件看起来正常。' }
      ]
    }
  }
}

// ========== 核心 Agent Loop ==========
async function agentLoop(userMessage: string): Promise<string> {
  let messages: Message[] = [
    { role: 'user', content: [{ type: 'text', text: userMessage }] }
  ]

  let maxTurns = 10  // 防止无限循环
  let turnCount = 0

  // ★ 主循环 — 核心的 while(true) 结构
  while (turnCount < maxTurns) {
    turnCount++
    console.log(`\n--- 第 ${turnCount} 轮迭代 ---`)

    // ① 调用 LLM
    const response = await callLLM(messages)
    messages.push(response)
    console.log('LLM 响应:', response.content.map(c =>
      c.type === 'text' ? `文本: ${c.text}` :
      c.type === 'tool_use' ? `工具调用: ${c.name}` : ''
    ).join(' | '))

    // ② 提取工具调用
    const toolCalls = response.content.filter(
      (c): c is ToolUseContent => c.type === 'tool_use'
    )

    // ③ 检查终止条件:无工具调用则结束
    if (toolCalls.length === 0) {
      console.log('\n✓ LLM 未调用工具,循环结束')
      const finalText = response.content
        .filter((c): c is TextContent => c.type === 'text')
        .map(c => c.text)
        .join('\n')
      return finalText
    }

    // ④ 执行工具
    const toolResults: ToolResultContent[] = []
    for (const toolCall of toolCalls) {
      const tool = tools.find(t => t.name === toolCall.name)
      if (!tool) {
        toolResults.push({
          type: 'tool_result',
          tool_use_id: toolCall.id,
          content: `未知工具: ${toolCall.name}`,
          is_error: true
        })
        continue
      }

      console.log(`  执行工具: ${tool.name}(${JSON.stringify(toolCall.input)})`)
      const result = await tool.execute(toolCall.input)
      console.log(`  工具结果: ${result}`)
      toolResults.push({
        type: 'tool_result',
        tool_use_id: toolCall.id,
        content: result
      })
    }

    // ⑤ 将工具结果添加到消息列表(喂回 LLM)
    messages.push({
      role: 'user',  // 注意:工具结果以 user 角色发送
      content: toolResults
    })

    // ⑥ continue — 回到 while 循环顶部
  }

  return '达到最大迭代次数'
}

// ========== 运行示例 ==========
async function main() {
  console.log('=== 简化版 Agent Loop 演示 ===\n')
  const result = await agentLoop('帮我检查 index.ts 文件')
  console.log('\n=== 最终结果 ===')
  console.log(result)
}

main().catch(console.error)

运行方式:

# 保存为 simplified-agent-loop.ts,用 ts-node 或 bun 运行
bun run simplified-agent-loop.ts

预期输出:

=== 简化版 Agent Loop 演示 ===

--- 第 1 轮迭代 ---
LLM 响应: 文本: 我需要先读取文件内容。 | 工具调用: read_file
  执行工具: read_file({"path":"index.ts"})
  工具结果: 文件 index.ts 的内容:console.log("hello")

--- 第 2 轮迭代 ---
LLM 响应: 文本: 根据文件内容,我已完成分析。文件看起来正常。

✓ LLM 未调用工具,循环结束

=== 最终结果 ===
根据文件内容,我已完成分析。文件看起来正常。

六、深入讲解:设计哲学与工程取舍

6.1 为什么用 while(true) 而非递归?

递归方式:
  function agentTurn() {
    const response = callLLM()
    if (hasToolUse(response)) {
      executeTools(response)         // 每次递归增加一层调用栈
      return agentTurn()              // ← 递归调用
    }
    return response                   // 递归展开
  }

循环方式(Claude Code采用):
  while (true) {
    const response = callLLM()       // 同一层调用栈
    if (hasToolUse(response)) {
      executeTools(response)          // 执行工具
      continue                        // ← 直接回到顶部
    }
    return response                   // 直接退出
  }

循环的优势:

  1. 栈深度恒定 — 10次工具调用不会产生10层递归栈
  2. 状态集中管理State 对象在循环顶部统一解构
  3. 终止条件清晰 — 所有 return/continue 点一目了然
  4. 资源可追踪 — 每次循环都可以检查 token 预算

6.2 为什么用 AsyncGenerator?

// AsyncGenerator 的优势
async function* query(params): AsyncGenerator<Event, Terminal> {
  while (true) {
    yield { type: 'stream_start' }       // ← 实时推送给UI
    for await (const chunk of stream) {
      yield chunk                         // ← 流式推送每个token
    }
    if (toolCalls) {
      for await (const result of runTools(toolCalls)) {
        yield result.message              // ← 推送每个工具结果
      }
      continue
    }
    return terminal                       // ← 最终结果
  }
}
  1. 实时性yield 让 UI 可以即时显示文字和工具进度
  2. 可取消 — 调用方可以 .return().throw() 中止循环
  3. 背压控制 — 消费方可以控制消费速度

6.3 四级压缩策略

当上下文接近上限时,Claude Code 逐级压缩:

消息列表: msg1, msg2, msg3, ..., msg100
token 接近上下文窗口上限

第一级: snipCompact
移除过旧的中间消息(保留首尾消息)
如: 移除 msg3~msg50,保留 msg1,2 和 msg51~100

第二级: microCompact
小粒度压缩:将连续的短消息合并为摘要
如: msg51~msg55 合并为一条摘要消息

第三级: contextCollapse
折叠相似的上下文块
如多次文件读取只保留最新版本

第四级: autoCompact
完整压缩:调用 LLM 生成整个对话的摘要
阈值: 上下文窗口 - 13000 buffer tokens
连续失败上限: 3次

6.4 QueryDeps 依赖注入

// src/query/deps.ts — 依赖注入,方便测试
type QueryDeps = {
  streamMessages: (...) => AsyncIterable     // LLM API 调用
  uuid: () => string                         // UUID 生成
  autocompact: (...) => Promise<...>         // 自动压缩
  microcompact: (...) => Promise<...>        // 微压缩
  // ...
}

// 生产环境使用真实依赖
function productionDeps(): QueryDeps { ... }

// 测试时可以注入 mock
const testDeps: QueryDeps = {
  streamMessages: mockStreamMessages,
  uuid: () => 'test-uuid',
  autocompact: mockAutoCompact,
  // ...
}

这种设计让核心循环可以独立测试,不需要真实的 API 调用。

6.5 完整数据流

while(true)

text

tool_use

并发安全

不安全

无工具调用

用户输入 '修复bug'

QueryEngine.createQueryTurn()

query(messages, systemPrompt, canUseTool, toolUseContext)

messages → 压缩 → normalizeForAPI → callLLM()

stream response

yield UI

tool_use blocks

partitionToolCalls()

并行执行
Glob, Grep

串行执行
Edit, Bash

tool_results

messages += tool_results

state = {...更新}

return type: stop

UI 显示最终回答


七、关键文件索引

文件行数职责
src/query.ts1729核心 agent loop 主循环
src/QueryEngine.ts1295对话生命周期管理
src/services/tools/toolOrchestration.ts~200工具分区并发编排
src/services/tools/toolExecution.ts~150单个工具执行
src/services/api/claude.ts~500LLM API 流式调用
src/services/api/withRetry.ts~100API 重试(指数退避)
src/services/compact/autoCompact.ts~200自动上下文压缩
src/services/compact/compact.ts~300压缩核心逻辑
src/services/compact/microCompact.ts~150微压缩
src/query/config.ts~100QueryConfig 不可变配置
src/query/deps.ts~50依赖注入类型定义
src/query/tokenBudget.ts~80Token 预算控制
src/cost-tracker.ts~100Token 成本追踪

更多推荐