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.ts 的 queryLoop 函数(内部以 while(true) 形式存在)中。
1. 循环的四个关键阶段
从源码视角看,每一轮循环(Turn)都经历以下阶段:
- Context Preparation(上下文预热): 在发起 API 请求前,系统会调用
autocompact(自动压缩)和contextCollapse(上下文折叠)。这是为了确保长对话不会超过模型的 Token 限制。源码中src/query.ts会在循环开始时检查tracking.compacted状态,必要时先进行摘要提取。 - Model Sampling(模型采样): 调用
deps.callModel。Claude Code 使用流式输出(Streaming)。如果模型返回stop_reason: 'tool_use',循环进入工具执行阶段;如果是end_turn,循环准备终止。 - Tool Execution(工具执行): 通过
StreamingToolExecutor(流式工具执行器)实时拦截工具调用。如果权限允许,它会立即执行bash、FileEdit等操作,并将结果封装成tool_result。 - State Transition(状态迁移): 工具执行后的结果被
push到state.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 个 Turn。
- Streaming 是伪流式吗? 不是。Claude Code 的工具执行器可以在模型还在吐字的时候,就开始预准备工具环境(如
StreamingToolExecutor),这极大提升了响应速度。 - 循环是原子的吗? 不是。每一次循环都会产生一次 API 交互和可能的磁盘 IO(工具执行)。这意味着如果你在循环中间杀掉进程,代码库可能处于“半修改”状态。
延伸阅读
- 想看工具是怎么运行的?移步
claude-code-opensource/src/Tool.ts。 - 想看 Token 是如何被抠掉的?查看
claude-code-opensource/src/services/compact/autoCompact.ts。
源码锚点
- claude-code-opensource/src/query.ts:250 —
queryLoop的定义与while(true)主体。
📄 src/query.ts — `queryLoop` 的定义与 `while(true)` 主体。
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,- claude-code-opensource/src/QueryEngine.ts:659 — 轮次计数
turnCount的初始化与管理。
📄 src/QueryEngine.ts — 轮次计数 `turnCount` 的初始化与管理。
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)- claude-code-opensource/src/utils/messages.ts:49 —
normalizeMessagesForAPI,决定了每一轮喂给模型什么“记忆”。
📄 src/utils/messages.ts — `normalizeMessagesForAPI`,决定了每一轮喂给模型什么“记忆”。
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