the-loop.mdx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. ---
  2. title: "Agentic Loop:AI 自主循环的核心机制"
  3. description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
  4. keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
  5. ---
  6. {/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
  7. ## 什么是 Agentic Loop
  8. 传统聊天机器人:你问一句,它答一句。
  9. Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
  10. 这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数(第 241 行)。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
  11. <Frame caption="Agentic Loop 循环示意">
  12. <img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
  13. </Frame>
  14. ## 循环的完整结构
  15. `queryLoop()` 的每次迭代(`src/query.ts:307` `while(true)`)包含以下阶段:
  16. ### 阶段 1:上下文预处理(Pre-Processing Pipeline)
  17. 在调用 API 之前,依次执行 5 个压缩/优化步骤:
  18. ```
  19. messagesForQuery(原始消息)
  20. ↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
  21. ↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
  22. ↓ microcompact() — 微压缩(工具结果摘要)
  23. ↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
  24. ↓ autocompact() — 自动压缩(超出阈值时触发)
  25. messagesForQuery(处理后的消息)→ 发往 API
  26. ```
  27. 每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
  28. ### 阶段 2:流式 API 调用(Streaming Loop)
  29. `deps.callModel()` 发起流式请求(第 659 行),返回一个 AsyncGenerator。在流式过程中:
  30. - **AssistantMessage** 被收集到 `assistantMessages[]` 数组
  31. - **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
  32. - **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束)
  33. - 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
  34. 流式回调中的关键守卫:
  35. - `backfillObservableInput()`(第 763 行)—— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
  36. - 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone(第 717 行),清空后重试
  37. ### 阶段 3:工具执行(Tool Execution)
  38. 如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
  39. ```typescript
  40. // 两种工具执行器(互斥)
  41. const toolUpdates = streamingToolExecutor
  42. ? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
  43. : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
  44. ```
  45. 工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
  46. ### 阶段 4:终止或继续
  47. 每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
  48. ## 7 种终止条件(源码级)
  49. | 终止原因 | 触发位置 | 机制 |
  50. |----------|---------|------|
  51. | **completed** | 第 1360 行 | AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
  52. | **blocking_limit** | 第 646 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
  53. | **aborted_streaming** | 第 1054 行 | `abortController.signal.aborted` → 为未完成的 tool_use 生成合成 tool_result → 返回 |
  54. | **model_error** | 第 999 行 | `callModel()` 抛出异常 → 生成错误消息 → 返回 |
  55. | **prompt_too_long** | 第 1178 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
  56. | **image_error** | 第 980/1178 行 | 图片尺寸/大小错误 → 直接返回 |
  57. | **stop_hook_prevented** | 第 1282 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
  58. ## 4 种继续条件(恢复路径)
  59. 循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
  60. ### 1. 正常工具循环
  61. `needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → `continue`
  62. ### 2. max_output_tokens 恢复(第 1191-1255 行)
  63. 当 AI 输出被截断时(`apiError === 'max_output_tokens'`):
  64. - **首次**:尝试将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K),无 meta 消息,静默重试
  65. - **后续**:注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次
  66. - 恢复耗尽后,暂扣的错误消息被释放
  67. ### 3. Prompt-Too-Long 恢复(第 1088-1186 行)
  68. 当遇到 413 错误时,有两个恢复阶段:
  69. - **Context Collapse Drain**(第 1097 行):提交所有已暂存的折叠,释放空间后重试。如果上一轮已经是 collapse_drain_retry 则跳过
  70. - **Reactive Compact**(第 1123 行):触发即时压缩,生成摘要后重试。`hasAttemptedReactiveCompact` 防止无限循环
  71. ### 4. Stop Hook 阻塞重试(第 1285-1308 行)
  72. Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
  73. ## 模型降级(Fallback)
  74. 当主模型不可用时(`FallbackTriggeredError`,第 897 行):
  75. 1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
  76. 2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
  77. 3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
  78. 4. 生成系统消息:"Switched to {fallback} due to high demand for {original}"
  79. 5. 重新发起流式请求
  80. ## 状态机:State 对象
  81. 每次迭代的状态通过 `State` 类型(第 204 行)传递:
  82. ```typescript
  83. type State = {
  84. messages: Message[] // 当前对话消息
  85. toolUseContext: ToolUseContext // 工具上下文(含权限)
  86. autoCompactTracking: AutoCompactTrackingState // 压缩跟踪
  87. maxOutputTokensRecoveryCount: number // 输出截断恢复计数
  88. hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩
  89. maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖
  90. pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要
  91. stopHookActive: boolean | undefined // Stop hook 是否激活
  92. turnCount: number // 轮次计数
  93. transition: Continue | undefined // 上一次继续的原因
  94. }
  95. ```
  96. 每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。
  97. ## Token Budget(实验性)
  98. 当 `TOKEN_BUDGET` feature 启用时(第 1311 行),循环在终止前会检查 token 消耗:
  99. - **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
  100. - **diminishing_returns**:检测到收益递减 → 提前终止
  101. - 预算数据来自 `createBudgetTracker()`,跨迭代累计
  102. ## 为什么不是"一次规划,批量执行"
  103. <Note>
  104. 源码揭示了为什么 Claude Code 选择逐步循环:
  105. </Note>
  106. - **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
  107. - **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
  108. - **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
  109. - **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1018、1048、1488 行),用户按 ESC 可以优雅中断
  110. - **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
  111. ## 一个完整的迭代示例
  112. > 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
  113. ```
  114. 迭代 1: 思考→行动
  115. 预处理: 无需压缩(上下文很短)
  116. API 调用: 返回 tool_use(Glob, "**/*.ts")
  117. 工具执行: 返回 42 个文件路径
  118. → needsFollowUp = true, continue
  119. 迭代 2: 思考→行动
  120. 预处理: 42 个文件结果仍在预算内
  121. API 调用: 返回 tool_use(Grep, "import.*from")
  122. 工具执行: 在 15 个文件中找到 120 条 import
  123. → needsFollowUp = true, continue
  124. 迭代 3: 思考→行动(多轮)
  125. 预处理: 120 条 Grep 结果触发 microcompact → 摘要化
  126. API 调用: 返回 3 个 tool_use(FileEdit, ...)
  127. 工具执行: 删除 5 条未使用导入
  128. → needsFollowUp = true, continue
  129. 迭代 4: 总结
  130. API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
  131. → needsFollowUp = false
  132. → Stop hooks 通过
  133. → return { reason: 'completed' }
  134. ```