From 2d07ffd0ce4e3a763dd71b7b62ead2d9006e7e0f Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Mon, 20 Apr 2026 00:17:25 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E9=87=8D=E5=86=99=20Agent=20Loop?= =?UTF-8?q?=EF=BC=8C=E4=BB=8E=E6=BA=90=E7=A0=81=E8=B5=B0=E8=AF=BB=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=AE=BE=E8=AE=A1=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除所有源码行号、TypeScript 类型定义和函数签名, 聚焦循环四个阶段的设计考量、错误恢复哲学和状态管理原理, 增加"为什么不是批量执行"的设计论证。 Co-Authored-By: Claude Opus 4.6 --- docs/conversation/the-loop.mdx | 245 ++++++++++++++------------------- 1 file changed, 107 insertions(+), 138 deletions(-) diff --git a/docs/conversation/the-loop.mdx b/docs/conversation/the-loop.mdx index 7edd8085f..07287e3fc 100644 --- a/docs/conversation/the-loop.mdx +++ b/docs/conversation/the-loop.mdx @@ -1,197 +1,166 @@ --- -title: "Agentic Loop:AI 自主循环的核心机制" -description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。" -keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"] -sourceRef: "3ec5675 (2026-04-08)" +title: "Agent Loop" +description: "理解 Claude Code 的核心循环机制——AI 如何自主决定工具调用、处理错误、管理上下文,直到任务完成。" +keywords: ["Agentic Loop", "tool_use", "状态机", "auto-compact", "streaming"] --- -{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */} - ## 什么是 Agentic Loop -传统聊天机器人:你问一句,它答一句。 +传统聊天机器人:你问一句,它答一句。 Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。 -这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。 +这背后的机制叫做 **Agentic Loop**(智能体循环)。它是一个"思考→行动→观察"的不断循环,直到任务完成或遇到终止条件。 Agentic Loop 循环图 -## 循环的完整结构 +### 为什么需要循环而非一次回答 -`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段: +因为软件工程任务本质上是**探索性**的。AI 不可能在第一步就知道所有信息: -### 阶段 1:上下文预处理(Pre-Processing Pipeline) +- 它需要先读代码才能知道怎么改 +- 它需要先运行命令才能知道结果 +- 它需要先搜索才能找到相关文件 +- 它需要先修改才能验证是否正确 -在调用 API 之前,依次执行 5 个压缩/优化步骤: +每一步工具执行都产生**真实信息**——命令输出、文件内容、错误信息——这些是 AI 在执行前不可能预知的。因此,AI 必须在每一步后根据新信息重新决策。 + +## 循环的四个阶段 + +每次循环迭代包含四个阶段,形成一个完整的"感知→决策→执行→反馈"周期。 + +### 阶段一:上下文预处理 + +在调用 API 之前,系统会依次检查和处理上下文。这是一个串行管道,每一步的输出是下一步的输入: ``` -messagesForQuery(原始消息) - ↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars) - ↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature) - ↓ microcompact() — 微压缩(工具结果摘要) - ↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature) - ↓ autocompact() — 自动压缩(超出阈值时触发) -messagesForQuery(处理后的消息)→ 发往 API +原始消息 + → 工具结果截断(单条输出过长时截断) + → 历史压缩(Snip 压缩旧消息) + → 微压缩(工具结果摘要化) + → 自动压缩(对话接近 token 上限时触发 AI 摘要) +处理后的消息 → 发往 API ``` -每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。 +**设计考量**:为什么是串行管道而非一次性处理?因为每个步骤释放的 token 数会影响下一步的决策。例如,如果 Snip 压缩已经释放了足够的 token,自动压缩就不需要触发了。 -### 阶段 2:流式 API 调用(Streaming Loop) +### 阶段二:流式 API 调用 -`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中: +系统以流式方式调用 Claude API。流式传输不是"锦上添花"——它是核心设计决策: -- **AssistantMessage** 被收集到 `assistantMessages[]` 数组 -- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true` -- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束) -- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复 +- **用户体验**:用户看到 AI 逐字输出,而非等待数秒后一次性显示 +- **工具并行执行**:AI 在流式输出过程中就可能发出工具调用,系统可以立即开始执行,不必等流结束 +- **可取消性**:用户随时可以中断正在进行的流式响应 -流式回调中的关键守卫: -- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性 -- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试 +### 阶段三:工具执行 -### 阶段 3:工具执行(Tool Execution) +如果 AI 请求了工具调用,系统执行工具并将结果回传。这里有两个关键设计: -如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具: +**并行执行**:当 AI 在一次响应中请求多个独立工具调用时(如同时读两个文件),系统并行执行它们。这直接减少了用户等待时间。 -```typescript -// 两种工具执行器(互斥) -const toolUpdates = streamingToolExecutor - ? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的 - : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext) -``` +**权限检查**:每个工具执行前都经过权限验证。危险操作(如执行 shell 命令)需要用户确认,安全操作(如读文件)可以自动放行。 -工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。 +### 阶段四:终止或继续 -### 阶段 4:终止或继续 +每次迭代结束时,系统判断是否需要继续: -每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续): +| 条件 | 结果 | +|------|------| +| AI 请求了工具调用 | 继续(下一轮迭代) | +| AI 只返回文本,没有工具调用 | 终止(任务完成) | +| 用户中断 | 终止(用户取消) | +| 达到最大 turn 数 | 终止(安全限制) | +| Token 预算耗尽 | 终止(成本控制) | -## 终止条件(源码级) +## 错误恢复:自愈的状态机 -循环有多种终止路径,按触发时机排列: +Agentic Loop 不是"正常路径走完就结束"的简单循环。它包含了多层错误恢复机制,使系统在各种异常情况下都能优雅处理。 -| 终止原因 | 触发位置 | 机制 | -|----------|---------|------| -| **blocking_limit** | 第 686 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 | -| **image_error** | 第 1021 行 | `ImageSizeError` / `ImageResizeError` 异常 → 直接返回 | -| **model_error** | 第 1040 行 | `callModel()` 抛出不可恢复异常 → 生成错误消息 → 返回 | -| **aborted_streaming** | 第 1095 行 | `abortController.signal.aborted`(流式阶段)→ 为未完成的 tool_use 生成合成 tool_result → 返回 | -| **prompt_too_long** | 第 1219/1226 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 | -| **completed** | 第 1308 行 | API 错误(限流、认证失败等)导致无法继续 → 返回 | -| **stop_hook_prevented** | 第 1323 行 | Stop hook 返回 `preventContinuation: true` → 返回 | -| **completed** | 第 1401 行 | 正常完成:AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 | -| **aborted_tools** | 第 1559 行 | `abortController.signal.aborted`(工具执行阶段)→ 返回 | -| **hook_stopped** | 第 1564 行 | 工具执行期间 hook 返回 `shouldPreventContinuation` → 返回 | -| **max_turns** | 第 1755 行 | 轮次计数超过 `maxTurns` 限制 → 返回 | +### 输出截断恢复 -## 继续条件(恢复路径) +当 AI 的响应被 token 上限截断时(AI 话说了一半被切断): -循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径: +1. **首次截断**:静默提升输出 token 上限,重试 +2. **仍然截断**:注入提示消息让 AI "接着说",最多重试 3 次 +3. **恢复耗尽**:将截断的响应作为最终结果返回 -### 1. 正常工具循环(`next_turn`) -`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → state 重新赋值 → `continue` +### 上下文过长恢复 -### 2. max_output_tokens 恢复(`max_output_tokens_escalate` / `max_output_tokens_recovery`) -当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复: -- **提升阶段**(`max_output_tokens_escalate`):首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。 -- **恢复阶段**(`max_output_tokens_recovery`):提升后仍然截断时,注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。 +当对话历史超过 API 的 token 限制时(413 错误): -### 3. Prompt-Too-Long 恢复(`collapse_drain_retry` / `reactive_compact_retry`) -当遇到 413 错误时,按优先级尝试两种压缩策略: -- **Context Collapse Drain**(`collapse_drain_retry`):提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。 -- **Reactive Compact**(`reactive_compact_retry`):如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。 +1. **压缩重试**:即时压缩对话历史,生成摘要后重试 +2. **压缩后仍过长**:返回错误信息,让用户决定如何处理 -### 4. Stop Hook 阻塞重试(`stop_hook_blocking`) -Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。 +关键设计:系统通过标志位防止无限循环——每种恢复路径只尝试一次,不会在"压缩→失败→压缩"之间死循环。 -### 5. Token Budget 继续提示(`token_budget_continuation`) -当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。 +### 模型降级 -## 模型降级(Fallback) +当主模型不可用时(过载、维护等): -当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支): +1. 已收集的响应被保留为历史记录 +2. 自动切换到备用模型 +3. 通知用户发生了降级 +4. 从中断点继续,而不是从头开始 -1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered" -2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400 -3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel` -4. 生成系统消息:"Switched to {fallback} due to high demand for {original}" -5. 重新发起流式请求 +## 状态管理 -## 状态机:State 对象 +每次迭代的状态是不可变更新的——系统创建新的状态对象而非就地修改。状态中包含: -每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递: +- **对话消息**:当前所有消息的数组 +- **压缩跟踪**:压缩操作的累计状态 +- **恢复计数**:各种错误恢复已尝试的次数 +- **继续原因**:上一轮为什么继续(用于检测和避免循环) -```typescript -// src/query.ts — State 类型定义 -type State = { - messages: Message[] // 当前对话消息 - toolUseContext: ToolUseContext // 工具上下文(含权限) - autoCompactTracking: AutoCompactTrackingState | undefined // 压缩跟踪 - maxOutputTokensRecoveryCount: number // 输出截断恢复计数 - hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩 - maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖 - pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要 - stopHookActive: boolean | undefined // Stop hook 是否激活 - turnCount: number // 轮次计数 - transition: Continue | undefined // 上一次继续的原因 -} -``` - -每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。 - -## Token Budget(实验性) - -当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗: - -- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾 -- **diminishing_returns**:检测到收益递减 → 提前终止 -- 预算数据来自 `createBudgetTracker()`,跨迭代累计 +**设计考量**:状态中记录"继续原因"是一个关键的防循环机制。系统可以在后续迭代中检查"上一轮是因为压缩重试而继续的",从而避免在同一个恢复路径上反复尝试。 ## 为什么不是"一次规划,批量执行" - -源码揭示了为什么 Claude Code 选择逐步循环: - +一个自然的疑问是:为什么不先让 AI 规划好所有步骤,然后一次性批量执行? -- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息 -- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数 -- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略 -- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断 -- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环 +答案在于软件工程的**不确定性**: + +- **每步结果影响下一步**:搜索结果决定了要改哪些文件,修改后的编译结果决定了是否需要进一步调整 +- **错误需要即时修正**:如果某步失败,AI 需要立即调整策略,而非继续执行无效计划 +- **用户可能中途干预**:循环架构允许用户随时打断和修正方向 + +这不是说 AI 不做规划——事实上系统内置了规划模式(Plan Mode)用于复杂任务。但规划的结果仍然是逐步执行的,每一步都有机会根据新信息调整。 ## 一个完整的迭代示例 > 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们" ``` -迭代 1: 思考→行动 - 预处理管道: applyToolResultBudget → snipCompact(HISTORY_SNIP feature) → microcompact → applyCollapses(CONTEXT_COLLAPSE feature) → autocompact - → 上下文很短,无需压缩 - API 调用: 返回 tool_use(Glob, "**/*.ts") - 工具执行: 返回 42 个文件路径 - → needsFollowUp = true - → transition: { reason: 'next_turn' }, continue +迭代 1: 探索 + AI: 先找到所有 TypeScript 文件 + 工具: Glob("**/*.ts") → 返回 42 个文件 + 决策: 需要进一步分析 → 继续 -迭代 2: 思考→行动 - 预处理管道: 42 个文件结果仍在预算内 - API 调用: 返回 tool_use(Grep, "import.*from") - 工具执行: 在 15 个文件中找到 120 条 import - → needsFollowUp = true - → transition: { reason: 'next_turn' }, continue +迭代 2: 分析 + AI: 搜索这些文件中的 import 语句 + 工具: Grep("import.*from") → 在 15 个文件中找到 120 条 import + 决策: 结果太多,需要进一步筛选 → 继续 -迭代 3: 思考→行动(多轮) - 预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化 - API 调用: 返回 3 个 tool_use(FileEdit, ...) - 工具执行: 删除 5 条未使用导入 - → needsFollowUp = true - → transition: { reason: 'next_turn' }, continue +迭代 3: 精确修改 + AI: 分析哪些 import 未被使用,删除它们 + 上下文预处理: 120 条结果被微压缩为摘要 + 工具: FileEdit × 3 → 删除 5 条未使用导入 + 决策: 需要验证 → 继续 -迭代 4: 总结 - API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入" - → needsFollowUp = false - → Stop hooks 通过 - → Token Budget 检查通过(如果启用) - → return { reason: 'completed' } +迭代 4: 验证与总结 + AI: 验证修改后编译通过 + 工具: Bash("tsc --noEmit") → 编译通过 + 决策: 任务完成 → 终止 ``` + +注意这个过程中的关键特征: +- AI 在每一步后根据结果自主决定下一步 +- 上下文在迭代过程中动态调整(微压缩被触发) +- 用户全程无需介入 + +## 接下来 + +- **流式响应** — 理解流式传输的设计细节和用户体验考量 +- **多轮对话** — 跨迭代的上下文管理和会话持久化 +- **上下文压缩** — 深入理解自动压缩的触发条件和策略 +- **工具系统** — 了解 AI 可以调用哪些工具及其设计