工具调用循环详解

📑 目录

专栏说明:本文是《Claude Code 代码全景解析》第 14 篇,属于"核心引擎"章节的第 4 篇。在前面的文章中,我们已经了解了 QueryEngine 的整体架构、消息系统的流转机制以及权限系统的设计。本文将深入最核心的工具调用循环,揭示 Claude Code 作为 AI Agent 的"心脏"是如何跳动的。


一、循环全貌:Agent 的心脏

如果说 QueryEngine 是 Claude Code 的躯体,那么**工具调用循环(Tool Use Loop)**就是它的心脏。每一次用户请求的处理,本质上都是这个循环的一次完整运转。理解了这个循环,你就理解了 Agent 的本质——模型思考 → 调用工具 → 观察结果 → 再次思考的循环往复。

Claude Code 的工具调用循环位于 src/query.ts 中,是一个典型的 while 循环结构,每一轮迭代对应一次模型与工具的交互回合。让我们先看看整个循环的全貌:

flowchart TD
    A["用户输入"] --> B["QueryEngine.submitMessage()"]
    B --> C["组装请求消息队列"]
    C --> D["调用 Anthropic API"]
    D --> E{"模型返回内容"}
    E -->|纯文本回答| F["直接回答用户
循环终止"] E -->|Tool Use Block| G["解析 Tool Call"] G --> H["Zod 参数验证"] H -->|验证失败| I["生成错误 tool_result"] H -->|验证通过| J["权限检查 canUseTool"] J -->|拒绝| K["生成拒绝 tool_result"] J -->|允许| L["路由到具体工具执行"] L --> M{"并发安全?"} M -->|是| N["并发执行"] M -->|否| O["串行执行"] N --> P["生成 tool_result"] O --> P P --> Q["结果回流到消息队列"] Q --> R["应用结果预算压缩"] R --> S{"终止条件?"} S -->|是| F S -->|否| C I --> Q K --> Q

这个循环的起点是 QueryEngine.submitMessage()src/QueryEngine.ts,第 380 行附近),它接收用户输入后,构建完整的请求上下文,包括系统提示词、历史消息和可用工具列表,然后进入 query() 函数的核心循环。

src/query.ts 第 554 行附近,有一个关键注释说明了循环的核心信号:

"Note: stop_reason === 'tool_use' is unreliable – it’s not always set correctly. Set during streaming whenever a tool_use block arrives – the sole loop-exit signal. If false after streaming, we’re done."

这意味着模型是否生成 tool_use 内容块,是决定是否继续循环的唯一信号,而不是依赖 API 返回的 stop_reason


二、模型决策阶段:LLM 如何选择工具

2.1 工具 Schema 的传递

在每次 API 调用前,Claude Code 会将可用工具的 Schema 注入到请求中。工具的定义位于 src/Tool.ts,每个工具都有严格的类型定义(第 340-490 行):

export type Tool<Input extends AnyObject = AnyObject, Output = unknown, ...> = {
  aliases?: string[]
  searchHint?: string
  call(args: z.infer<Input>, context: ToolUseContext, ...): Promise<ToolResult<Output>>
  description(input: z.infer<Input>, options: {...}): Promise<string>
  readonly inputSchema: Input
  readonly inputJSONSchema?: ToolInputJSONSchema
  outputSchema?: z.ZodType<unknown>
  isConcurrencySafe(input: z.infer<Input>): boolean
  isEnabled(): boolean
  isReadOnly(input: z.infer<Input>): boolean
  isDestructive?(input: z.infer<Input>): boolean
  maxResultSizeChars: number
  readonly strict?: boolean
  // ...
}

其中 inputSchema 是一个 Zod Schema,它定义了工具期望的参数结构。这个 Schema 会被转换为 JSON Schema 格式发送给 Anthropic API,模型就是依据这个 Schema 来决定如何构造 Tool Call 的参数。

值得注意的是,Claude Code 支持**工具延迟加载(Deferred Loading)**机制。对于大量 MCP 工具,不会一次性将所有工具的 Schema 注入提示词,而是通过 ToolSearch 工具让模型按需发现。src/Tool.ts 第 444 行定义了 shouldDefer 属性:

"When true, this tool is deferred (sent with defer_loading: true) and requires ToolSearch to be used before it can be called."

相反,alwaysLoad 属性(第 469 行)则确保某些关键工具在首轮就一定可见:

"When true, this tool is never deferred – its full schema appears in the initial prompt even when ToolSearch is enabled."

2.2 并行 Tool Call 的支持

现代 LLM(尤其是 Claude 3.5 Sonnet 及更新的模型)支持在一次响应中发起多个并行的 Tool Call。Claude Code 充分利用了这一特性。在 src/query.ts 第 830 行附近,模型返回的 assistant message 会被解析出所有 tool_use 类型的 content block:

const msgToolUseBlocks = message.message.content.filter(
  content => content.type === 'tool_use'
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
  toolUseBlocks.push(...msgToolUseBlocks)
  needsFollowUp = true
}

当检测到 tool_use block 时,needsFollowUp 被置为 true,这告诉外层循环:本轮结束后需要继续下一轮,将工具结果回传给模型。

2.3 决策流程

模型如何决策调用哪个工具,本质上是一个"函数选择"问题。Anthropic API 将工具的 descriptioninputSchema 作为提示词的一部分,模型根据当前任务上下文推断出最合适的工具组合。Claude Code 中模型决策的大致流程如下:

flowchart LR
    A["用户请求 + 历史消息 + 系统提示"] --> B["Anthropic API"]
    B --> C{"模型推理"}
    C -->|"需要工具辅助"| D["生成 tool_use block"]
    C -->|"可直接回答"| E["生成 text block"]
    D --> F["Tool Use Block 包含:
- tool name
- tool_use_id
- input JSON"] E --> G["循环终止
返回结果"]

模型在一次响应中可以同时调用多个工具,这些 Tool Call 通过唯一的 tool_use_id 标识,后续的工具结果需要携带对应的 tool_use_id 进行匹配。


三、Tool Call 解析:从模型输出到可执行调用

3.1 提取 Tool Use Block

当模型返回的流式响应中检测到 tool_use 类型的内容块时,Claude Code 会将其收集起来。在 src/query.ts 的流式处理循环中(第 750-830 行),assistant message 被逐步构建:

for await (const message of deps.callModel({...})) {
  // ...
  if (message.type === 'assistant') {
    assistantMessages.push(message)
    const msgToolUseBlocks = message.message.content.filter(
      content => content.type === 'tool_use'
    ) as ToolUseBlock[]
    if (msgToolUseBlocks.length > 0) {
      toolUseBlocks.push(...msgToolUseBlocks)
      needsFollowUp = true
    }
    // 如果是流式工具执行,立即将工具加入执行队列
    if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
      for (const toolBlock of msgToolUseBlocks) {
        streamingToolExecutor.addTool(toolBlock, message)
      }
    }
  }
}

这里的关键设计是流式工具执行(Streaming Tool Execution)。传统的做法是等模型完整返回后再执行工具,但 Claude Code 使用了 StreamingToolExecutorsrc/services/tools/StreamingToolExecutor.ts),它可以在模型还在生成后续 token 时,就提前开始执行已经解析出的 Tool Call,从而显著减少整体延迟。

3.2 参数解析和 Zod 验证

模型生成的参数是 JSON 格式,但模型的输出并不总是严格符合 Schema。因此,每个 Tool Call 在执行前都必须经过严格的参数验证。这个验证发生在 src/services/tools/toolExecution.tscheckPermissionsAndCallTool 函数中(第 650-720 行):

// Validate input types with zod (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  let errorContent = formatZodValidationError(tool.name, parsedInput.error)
  // ... 构建错误 tool_result
}

注释非常直白地指出了现实:" surprisingly, the model is not great at generating valid input "——模型并不擅长生成完全符合 Schema 的有效输入。

当验证失败时,formatZodValidationError 函数(src/utils/toolErrors.ts,第 56-110 行)会将 Zod 错误转换为人类可读且对 LLM 友好的错误消息:

export function formatZodValidationError(toolName: string, error: ZodError): string {
  const missingParams = error.issues
    .filter(err => err.code === 'invalid_type' && err.message.includes('received undefined'))
    .map(err => formatValidationPath(err.path))

  const unexpectedParams = error.issues
    .filter(err => err.code === 'unrecognized_keys')
    .flatMap(err => err.keys)

  const typeMismatchParams = error.issues
    .filter(err => err.code === 'invalid_type' && !err.message.includes('received undefined'))
    .map(err => ({ param: formatValidationPath(err.path), expected: err.expected, received: ... }))
  // 构建可读的错误信息...
}

这个函数会分类处理三种常见错误:

  • 缺少必需参数The required parameter \file_path` is missing`
  • 未预期的参数An unexpected parameter \content` was provided`
  • 类型不匹配The parameter \line` type is expected as `number` but provided as `string``

这些错误信息会被包装成 tool_result 回传给模型,让模型有机会修正参数后重新调用。

3.3 工具查找与别名支持

src/services/tools/toolExecution.tsrunToolUse 函数中(第 275-330 行),系统首先尝试通过工具名查找对应的工具定义:

let tool = findToolByName(toolUseContext.options.tools, toolName)

// 如果找不到,检查是否是已弃用工具的别名调用
if (!tool) {
  const fallbackTool = findToolByName(getAllBaseTools(), toolName)
  if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
    tool = fallbackTool
  }
}

findToolByName 函数(src/Tool.ts,第 340-350 行)支持通过主名称或别名查找工具:

export function findToolByName(tools: Tools, name: string): Tool | undefined {
  return tools.find(t => toolMatchesName(t, name))
}

export function toolMatchesName(tool: { name: string; aliases?: string[] }, name: string): boolean {
  return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

如果工具根本不存在(既不是主名称也不是别名),系统会生成一个错误 tool_result

<tool_use_error>Error: No such tool available: unknown_tool_name</tool_use_error>

3.4 输入值的业务验证

通过 Zod 类型验证后,还有一个可选的业务级验证步骤——validateInputsrc/services/tools/toolExecution.ts,第 720-760 行):

const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)
if (isValidCall?.result === false) {
  // 返回业务验证失败的 tool_result
}

与 Zod 的类型验证不同,validateInput 是工具特定的业务逻辑验证。例如,文件读取工具可能会检查文件路径是否在工作目录范围内,网络工具可能会验证 URL 格式等。这种分层验证设计确保了安全性——类型正确不等于调用合法。


四、工具执行阶段:从决策到行动

4.1 权限检查

在工具真正执行之前,必须通过权限系统的审查。canUseTool 函数(在 QueryEngine 中被包装为 wrappedCanUseTool)是这一关卡的入口。src/QueryEngine.ts 第 243-270 行展示了权限检查的包装逻辑:

const wrappedCanUseTool: CanUseToolFn = async (tool, toolUseContext, toolUseID, input) => {
  const result = await canUseTool(tool, toolUseContext, toolUseID, input)
  if (result.behavior === 'deny') {
    this.permissionDenials.push({
      tool_name: sdkCompatToolName(tool.name),
      tool_use_id: toolUseID,
      tool_input: input,
      // ...
    })
  }
  return result
}

权限检查的结果有三种可能:

  • allow:直接执行,无需用户确认
  • ask:弹出权限对话框,等待用户确认
  • deny:拒绝执行,生成拒绝原因的 tool_result

权限决策的详细流程涉及规则匹配、Hook 系统、分类器等多个子系统,这在专栏前面的文章中已有详细分析。

4.2 并发与串行执行

Claude Code 的工具执行有一个精妙的并发控制机制。在 src/services/tools/toolOrchestration.ts 中,partitionToolCalls 函数(第 75-100 行)将多个 Tool Call 分组为"可并发"和"需串行"两类:

function partitionToolCalls(toolUseMessages: ToolUseBlock[], toolUseContext: ToolUseContext): Batch[] {
  return toolUseMessages.reduce((acc: Batch[], toolUse) => {
    const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
    const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
    const isConcurrencySafe = parsedInput?.success
      ? (() => {
          try {
            return Boolean(tool?.isConcurrencySafe(parsedInput.data))
          } catch {
            return false  // 保守策略:出错则视为不安全
          }
        })()
      : false
    // 将连续的安全工具归为一组并发执行
    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1]!.blocks.push(toolUse)
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] })
    }
    return acc
  }, [])
}

这里的核心判断依据是每个工具的 isConcurrencySafe 方法。例如,文件读取工具(Read)通常是并发安全的,因为它只读取而不修改状态;而文件写入工具(Write/Edit)则不是并发安全的,因为它们会修改文件系统状态。

并发执行的上限由环境变量控制(src/services/tools/toolOrchestration.ts,第 10-14 行):

function getMaxToolUseConcurrency(): number {
  return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}

默认最大并发数为 10。

4.3 流式工具执行器

StreamingToolExecutorsrc/services/tools/StreamingToolExecutor.ts)是 Claude Code 执行效率的关键优化。它维护了一个工具执行队列,并支持以下核心行为:

  1. 即时入队:当流式响应中解析出 tool_use block 时,立即加入执行队列
  2. 并发控制:检查当前执行状态,决定是否立即启动新工具
  3. 结果缓冲:完成的工具结果按原始顺序缓冲,确保输出有序
  4. 错误级联:如果一个并发执行的 Bash 工具出错,会自动取消其他并行的 Bash 工具(第 45-50 行)
export class StreamingToolExecutor {
  private tools: TrackedTool[] = []
  private siblingAbortController: AbortController  // Bash 错误时取消兄弟进程

  addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
    // 解析工具,判断并发安全性,加入队列
    // 立即触发 processQueue()
  }

  private canExecuteTool(isConcurrencySafe: boolean): boolean {
    const executingTools = this.tools.filter(t => t.status === 'executing')
    return executingTools.length === 0 ||
      (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  }
}

当流式响应完成后,query.ts 会调用 getRemainingResults() 消费所有未完成的工具结果,确保每个 tool_use 都有对应的 tool_result

4.4 超时控制与用户中断

每个工具的执行都受到 abortController 的控制。当用户按下 ESC 键中断时,QueryEngine.interrupt() 会触发 abortController.abort(),信号会传递到所有正在执行的工具。

src/services/tools/toolExecution.tsrunToolUse 函数中(第 340-380 行),每次执行前都会检查中断信号:

if (toolUseContext.abortController.signal.aborted) {
  logEvent('tengu_tool_use_cancelled', { ... })
  const content = createToolResultStopMessage(toolUse.id)
  content.content = withMemoryCorrectionHint(CANCEL_MESSAGE)
  yield {
    message: createUserMessage({
      content: [content],
      toolUseResult: CANCEL_MESSAGE,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  }
  return
}

4.5 沙箱与执行隔离

Bash 工具等可能执行任意命令的工具,其执行受到多层沙箱保护。具体的沙箱机制涉及进程隔离、工作目录限制、环境变量控制等,这些在专栏前面的权限系统文章中已有详述。值得强调的是,工具层面的 isDestructive 标记(src/Tool.ts)让系统能够识别哪些工具执行了不可逆操作(如删除文件、覆盖文件、发送网络请求),从而在权限检查时给予更高的安全审查级别。


五、结果回流:让模型"看到"工具输出

5.1 工具结果格式化为消息

工具执行完成后,其结果必须被格式化为 Anthropic API 可接受的 tool_result 内容块。在 src/services/tools/toolExecution.ts 中,processToolResultBlock 函数(或 processPreMappedToolResultBlock)负责将工具的原始输出转换为标准的 ToolResultBlockParam

export async function processToolResultBlock<T>(
  tool: { name: string; maxResultSizeChars: number; mapToolResultToToolResultBlockParam: ... },
  toolUseResult: T,
  toolUseID: string,
): Promise<ToolResultBlockParam> {
  const toolResultBlock = tool.mapToolResultToToolResultBlockParam(toolUseResult, toolUseID)
  return maybePersistLargeToolResult(
    toolResultBlock,
    tool.name,
    getPersistenceThreshold(tool.name, tool.maxResultSizeChars),
  )
}

一个标准的 tool_result 包含以下字段:

  • type: 'tool_result'
  • tool_use_id: 对应 tool_use 的唯一标识
  • content: 工具输出内容(字符串或内容块数组)
  • is_error: 是否为错误结果(可选)

5.2 结果压缩:避免上下文爆炸

工具输出可能非常庞大(例如读取大文件、执行产生大量输出的命令)。如果直接将完整结果回传给模型,会导致上下文窗口迅速耗尽。Claude Code 实现了一套精密的结果持久化与压缩机制,核心代码位于 src/utils/toolResultStorage.ts

5.2.1 单个工具结果限制

每个工具有自己的 maxResultSizeChars 属性。当结果超过阈值时,maybePersistLargeToolResult 函数会将结果写入磁盘文件,并向模型返回一个预览:

async function maybePersistLargeToolResult(
  toolResultBlock: ToolResultBlockParam,
  toolName: string,
  persistenceThreshold?: number,
): Promise<ToolResultBlockParam> {
  const size = contentSize(content)
  const threshold = persistenceThreshold ?? MAX_TOOL_RESULT_BYTES
  if (size <= threshold) {
    return toolResultBlock  // 未超限,直接返回
  }
  // 超限:持久化到磁盘,返回预览
  const result = await persistToolResult(content, toolResultBlock.tool_use_id)
  if (isPersistError(result)) return toolResultBlock  // 持久化失败则回退
  const message = buildLargeToolResultMessage(result)
  return { ...toolResultBlock, content: message }
}

持久化后的消息格式如下(src/utils/toolResultStorage.ts,第 130-140 行):

<persisted-output>
Output too large (128KB). Full output saved to: /path/to/session/tool-results/abc123.txt

Preview (first 2KB):
[预览内容]
...
</persisted-output>

5.2.2 消息级别聚合预算

除了单个工具结果的限制,Claude Code 还引入了消息级别的聚合工具结果预算(Per-Message Budget)。在 src/utils/toolResultStorage.tsenforceToolResultBudget 函数中(第 380-550 行),系统会计算每个 API 消息中所有 tool_result 的总大小:

export async function enforceToolResultBudget(
  messages: Message[],
  state: ContentReplacementState,
  skipToolNames: ReadonlySet<string> = new Set(),
): Promise<{ messages: Message[]; newlyReplaced: ToolResultReplacementRecord[] }> {
  // 收集候选 tool_result
  const candidatesByMessage = collectCandidatesByMessage(messages)
  // 按消息分组检查预算
  for (const candidates of candidatesByMessage) {
    const { mustReapply, frozen, fresh } = partitionByPriorDecision(candidates, state)
    // ... 对超限的消息中的最大结果进行替换
  }
}

这个机制的关键设计是状态冻结:一旦某个 tool_result 被决定替换或保留,该决策会在整个会话中保持不变,以确保 prompt cache 的稳定性。状态通过 ContentReplacementState 跟踪:

export type ContentReplacementState = {
  seenIds: Set<string>        // 已见过的 tool_use_id
  replacements: Map<string, string>  // tool_use_id -> 替换后的预览字符串
}

5.3 空结果与错误结果的特殊处理

5.3.1 空结果

某些工具可能产生空输出(例如静默成功的 shell 命令)。直接返回空字符串给模型可能导致问题——某些模型会将空 tool_result 误认为是回合边界,从而提前终止输出。src/utils/toolResultStorage.ts 第 200-210 行对此做了防御性处理:

if (isToolResultContentEmpty(content)) {
  logEvent('tengu_tool_empty_result', { toolName: sanitizeToolNameForAnalytics(toolName) })
  return {
    ...toolResultBlock,
    content: `(\${toolName} completed with no output)`,
  }
}

5.3.2 错误结果

当工具执行出错时,错误信息需要被格式化为模型能理解的格式。src/utils/toolErrors.tsformatError 函数(第 8-26 行)提供了统一错误格式化:

export function formatError(error: unknown): string {
  if (error instanceof AbortError) {
    return error.message || INTERRUPT_MESSAGE_FOR_TOOL_USE
  }
  if (!(error instanceof Error)) {
    return String(error)
  }
  const parts = getErrorParts(error)
  const fullMessage = parts.filter(Boolean).join('\n').trim() || 'Command failed with no output'
  // 超过 10000 字符则截断
  if (fullMessage.length <= 10000) {
    return fullMessage
  }
  const halfLength = 5000
  const start = fullMessage.slice(0, halfLength)
  const end = fullMessage.slice(-halfLength)
  return `\${start}\n\n... [\${fullMessage.length - 10000} characters truncated] ...\n\n\${end}`
}

错误结果被标记为 is_error: true,这样模型就知道这是一个失败的调用,可能需要重试或调整策略。

5.4 结果回流到消息队列

工具结果最终被格式化为 user 类型的消息,追加到消息队列中。在 src/query.ts 中,流式执行器完成的工具结果会被收集并归一化:

for (const result of streamingToolExecutor.getCompletedResults()) {
  if (result.message) {
    yield result.message
    toolResults.push(
      ...normalizeMessagesForAPI([result.message], toolUseContext.options.tools)
        .filter(_ => _.type === 'user'),
    )
  }
}

这些 toolResults 随后与 assistantMessages(包含 tool_use block)一起,构成完整的"请求-响应"回合,作为下一轮循环的输入。

结果回流的整体流程可以用下图表示:

flowchart TD
    A["工具执行完成"] --> B["原始输出"]
    B --> C{"结果大小检查"}
    C -->|空结果| D["注入占位文本
'(tool completed with no output)'"] C -->|超过阈值| E["持久化到磁盘
返回预览消息"] C -->|正常大小| F["直接格式化"] D --> G["构建 tool_result block"] E --> G F --> G G --> H{"是否有错误?"} H -->|是| I["标记 is_error=true"] H -->|否| J["标准结果"] I --> K["追加到消息队列"] J --> K K --> L["应用消息级预算压缩"] L --> M["准备下一轮 API 调用"]

六、循环终止条件:何时结束

工具调用循环不是无限运行的,它会在满足特定条件时优雅终止。src/query.tssrc/QueryEngine.ts 中定义了多种终止条件:

6.1 模型不再调用工具

最常见的终止情况是模型决定不再调用任何工具,而是直接给出最终答案。这在 src/query.ts 第 1050-1060 行附近判断:

if (!needsFollowUp) {
  // 没有 tool_use,本轮自然结束
  const lastMessage = assistantMessages.at(-1)
  // 处理可能的恢复逻辑(prompt-too-long、media error 等)
}

needsFollowUpfalse 时,说明模型返回的 assistant message 中没有 tool_use block,循环可以结束。

6.2 达到最大轮次限制

为了防止循环无限进行,Claude Code 支持设置 maxTurns 参数。src/QueryEngine.ts 第 1000-1020 行检查了这个限制:

if (message.attachment.type === 'max_turns_reached') {
  yield {
    type: 'result',
    subtype: 'error_max_turns',
    is_error: true,
    num_turns: message.attachment.turnCount,
    errors: [`Reached maximum number of turns (${message.attachment.maxTurns})`],
  }
  return
}

当轮次达到上限时,系统会返回一个带有 error_max_turns 子类型的错误结果。

6.3 用户中断

当用户按下 ESC 键或发送中断信号时,abortController 被触发。src/query.ts 第 1030-1060 行处理了这种情况:

if (toolUseContext.abortController.signal.aborted) {
  if (streamingToolExecutor) {
    // 消费剩余结果,生成合成的 tool_result
    for await (const update of streamingToolExecutor.getRemainingResults()) {
      if (update.message) { yield update.message }
    }
  }
  // 返回中断结果
  return { reason: 'aborted_streaming' }
}

6.4 预算耗尽

Claude Code 支持按美元金额设置查询预算。src/QueryEngine.ts 第 980-1000 行检查费用限制:

if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
  yield {
    type: 'result',
    subtype: 'error_max_budget_usd',
    is_error: true,
    errors: [`Reached maximum budget ($${maxBudgetUsd})`],
  }
  return
}

6.5 结构化输出重试超限

当使用结构化输出(JSON Schema)模式时,如果模型多次无法生成符合 Schema 的输出,系统会在重试次数达到上限后终止。src/QueryEngine.ts 第 1000-1015 行:

const currentCalls = countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME)
const callsThisQuery = currentCalls - initialStructuredOutputCalls
const maxRetries = parseInt(process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5', 10)
if (callsThisQuery >= maxRetries) {
  yield {
    type: 'result',
    subtype: 'error_max_structured_output_retries',
    is_error: true,
    errors: [`Failed to provide valid structured output after ${maxRetries} attempts`],
  }
  return
}

6.6 执行错误

当查询过程中发生不可恢复的错误时(例如 API 调用失败、网络中断等),循环也会终止。src/query.ts 第 990-1010 行的 catch 块处理了这种情况:

catch (error) {
  logError(error)
  yield* yieldMissingToolResultBlocks(assistantMessages, errorMessage)
  yield createAssistantAPIErrorMessage({ content: errorMessage })
  return { reason: 'model_error', error }
}

七、总结

Claude Code 的工具调用循环是一个精心设计的、多层次的系统,它将 LLM 的推理能力与外部工具的执行能力无缝衔接。让我们回顾一下整个循环的核心要点:

  1. 循环驱动:由模型是否生成 tool_use block 驱动,而非 stop_reason
  2. Schema 约束:Zod Schema 既用于 API 参数描述,也用于客户端严格验证
  3. 流式优化StreamingToolExecutor 让工具在模型还在生成响应时就开始执行
  4. 并发控制:通过 isConcurrencySafe 智能分组,安全地并行执行独立工具
  5. 结果压缩:多层级的大小限制(单个工具 + 消息级聚合)防止上下文爆炸
  6. 优雅终止:支持轮次限制、预算限制、用户中断等多种终止条件

从源码层面看,这个循环的每一行代码都体现了工程上的深思熟虑——从防御性编程("模型不擅长生成有效输入")到性能优化(流式执行),从资源管理(结果持久化)到用户体验(权限控制)。

理解了这个循环,你就掌握了 Claude Code 作为 AI Agent 的核心工作原理。下一篇文章,我们将深入探讨 query.ts 中更底层的 API 调用与流式处理机制。


参考源码位置汇总

源码文件关键行号范围说明
src/QueryEngine.ts第 180-420 行QueryEngine 类定义与 submitMessage 入口
src/QueryEngine.ts第 620-900 行主循环消息处理与状态管理
src/QueryEngine.ts第 980-1020 行终止条件检查(预算、轮次)
src/query.ts第 550-850 行核心循环:API 调用与 tool_use 解析
src/query.ts第 950-1060 行循环终止与恢复逻辑
src/Tool.ts第 340-490 行Tool 类型定义与工具查找函数
src/services/tools/toolOrchestration.ts第 1-120 行工具并发/串行调度
src/services/tools/StreamingToolExecutor.ts第 1-220 行流式工具执行器
src/services/tools/toolExecution.ts第 275-500 行Tool Call 解析与权限检查
src/services/tools/toolExecution.ts第 650-720 行Zod 参数验证
src/utils/toolErrors.ts第 1-110 行错误格式化与 Zod 错误转换
src/utils/toolResultStorage.ts第 1-140 行结果持久化阈值与工具
src/utils/toolResultStorage.ts第 180-550 行消息级预算强制执行