Skip to content
源码分析手册

agentic-loop:Claude Code 的推理循环是什么?

Claude Code 不是简单的“一问一答”聊天机器人,而是一个基于 Agentic Loop(代理循环)的推理引擎。当你在终端敲下回车时,后台启动的不是一段简单的脚本,而是一个严密的、具备自我纠错和工具调用的生命周期。

核心概念

本质上,它是围绕大语言模型(LLM)构建的一个 有状态的递归状态机

在传统的 Chat 模式中,用户输入 A,模型返回 B,会话结束。在 Agentic Loop 中,流程变为:用户输入 A -> 模型思考并决定调用工具 T -> 执行工具 T 并获得结果 R -> 将 R 喂回模型 -> 模型判断任务是否完成 -> 如果没完成,继续思考并调用下一个工具。

源码级拆解

Claude Code 的推理循环核心逻辑封装在 claude-code-opensource/src/query.tsqueryLoop 函数(内部以 while(true) 形式存在)中。

1. 循环的四个关键阶段

从源码视角看,每一轮循环(Turn)都经历以下阶段:

  1. Context Preparation(上下文预热): 在发起 API 请求前,系统会调用 autocompact(自动压缩)和 contextCollapse(上下文折叠)。这是为了确保长对话不会超过模型的 Token 限制。源码中 src/query.ts 会在循环开始时检查 tracking.compacted 状态,必要时先进行摘要提取。
  2. Model Sampling(模型采样): 调用 deps.callModel。Claude Code 使用流式输出(Streaming)。如果模型返回 stop_reason: 'tool_use',循环进入工具执行阶段;如果是 end_turn,循环准备终止。
  3. Tool Execution(工具执行): 通过 StreamingToolExecutor(流式工具执行器)实时拦截工具调用。如果权限允许,它会立即执行 bashFileEdit 等操作,并将结果封装成 tool_result
  4. State Transition(状态迁移): 工具执行后的结果被 pushstate.messages 数组中,并递增 turnCount。然后触发 continue 语句,直接跳回 while(true) 的顶端,开启下一轮推理。

2. 终止逻辑与安全阀

循环不会无限运行,Claude Code 设置了多重保险:

  • MAX_TURNS(最大轮次):在 src/QueryEngine.ts 中,maxTurns 是一个可选的约束(在 DreamTask.ts 等场景中默认定义为 30 轮)。一旦 turnCount 达到上限,系统会强制抛出错误并提示用户。
  • Token Budget(Token 预算):在 src/query.ts 中,系统会监控 taskBudgetRemaining。如果 Token 消耗殆尽,即使任务没完也会被迫停止。
  • User Interruption(用户干预):用户按下 Ctrl+C 会触发 AbortController,循环检测到 signal.aborted 后会平滑退出。

边界条件

  1. 工具调用不消耗轮次吗? 不,每一轮模型回复(无论包含多少个并行工具调用)都算作 1 个 Turn。
  2. Streaming 是伪流式吗? 不是。Claude Code 的工具执行器可以在模型还在吐字的时候,就开始预准备工具环境(如 StreamingToolExecutor),这极大提升了响应速度。
  3. 循环是原子的吗? 不是。每一次循环都会产生一次 API 交互和可能的磁盘 IO(工具执行)。这意味着如果你在循环中间杀掉进程,代码库可能处于“半修改”状态。

延伸阅读

  • 想看工具是怎么运行的?移步 claude-code-opensource/src/Tool.ts
  • 想看 Token 是如何被抠掉的?查看 claude-code-opensource/src/services/compact/autoCompact.ts

源码锚点

📄 src/query.ts — `queryLoop` 的定义与 `while(true)` 主体。L240-260 of 1730
typescript

async function* queryLoop(
  params: QueryParams,
  consumedCommandUuids: string[],
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
> {
  // Immutable params — never reassigned during the query loop.
  const {
    systemPrompt,
    userContext,
    systemContext,
    canUseTool,
    fallbackModel,
    querySource,
    maxTurns,
📄 src/QueryEngine.ts — 轮次计数 `turnCount` 的初始化与管理。L649-669 of 1296
typescript
                fileHistory: updater(prev.fileHistory),
              }))
            },
            message.uuid,
          )
        })
    }

    // Track current message usage (reset on each message_start)
    let currentMessageUsage: NonNullableUsage = EMPTY_USAGE
    let turnCount = 1
    let hasAcknowledgedInitialMessages = false
    // Track structured output from StructuredOutput tool calls
    let structuredOutputFromTool: unknown
    // Track the last stop_reason from assistant messages
    let lastStopReason: string | null = null
    // Reference-based watermark so error_during_execution's errors[] is
    // turn-scoped. A length-based index breaks when the 100-entry ring buffer
    // shift()s during the turn — the index slides. If this entry is rotated
    // out, lastIndexOf returns -1 and we include everything (safe fallback).
    const errorLogWatermark = getInMemoryErrors().at(-1)
📄 src/utils/messages.ts — `normalizeMessagesForAPI`,决定了每一轮喂给模型什么“记忆”。L1989-2018 of 5513
typescript
export function normalizeMessagesForAPI(
  messages: Message[],
  tools: Tools = [],
): (UserMessage | AssistantMessage)[] {
  // Build set of available tool names for filtering unavailable tool references
  const availableToolNames = new Set(tools.map(t => t.name))

  // First, reorder attachments to bubble up until they hit a tool result or assistant message
  // Then strip virtual messages — they're display-only (e.g. REPL inner tool
  // calls) and must never reach the API.
  const reorderedMessages = reorderAttachmentsForAPI(messages).filter(
    m => !((m.type === 'user' || m.type === 'assistant') && m.isVirtual),
  )

  // Build a map from error text → which block types to strip from the preceding user message.
  const errorToBlockTypes: Record<string, Set<string>> = {
    [getPdfTooLargeErrorMessage()]: new Set(['document']),
    [getPdfPasswordProtectedErrorMessage()]: new Set(['document']),
    [getPdfInvalidErrorMessage()]: new Set(['document']),
    [getImageTooLargeErrorMessage()]: new Set(['image']),
    [getRequestTooLargeErrorMessage()]: new Set(['document', 'image']),
  }

  // Walk the reordered messages to build a targeted strip map:
  // userMessageUUID → set of block types to strip from that message.
  const stripTargets = new Map<string, Set<string>>()
  for (let i = 0; i < reorderedMessages.length; i++) {
    const msg = reorderedMessages[i]!
    if (!isSyntheticApiErrorMessage(msg)) {
      continue

基于 Claude Code v2.1.88 开源快照的深度分析