Files
cs-note/claude-code-best/docs/conversation/the-loop.mdx
T
2026-06-08 23:08:57 +08:00

198 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)"
---
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
## 什么是 Agentic Loop
传统聊天机器人:你问一句,它答一句。
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
<Frame caption="Agentic Loop 循环示意">
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
</Frame>
## 循环的完整结构
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
在调用 API 之前,依次执行 5 个压缩/优化步骤:
```
messagesForQuery(原始消息)
↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
↓ microcompact() — 微压缩(工具结果摘要)
↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
↓ autocompact() — 自动压缩(超出阈值时触发)
messagesForQuery(处理后的消息)→ 发往 API
```
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
### 阶段 2:流式 API 调用(Streaming Loop)
`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中:
- **AssistantMessage** 被收集到 `assistantMessages[]` 数组
- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束)
- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
流式回调中的关键守卫:
- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试
### 阶段 3:工具执行(Tool Execution)
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
```typescript
// 两种工具执行器(互斥)
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
```
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
### 阶段 4:终止或继续
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
## 终止条件(源码级)
循环有多种终止路径,按触发时机排列:
| 终止原因 | 触发位置 | 机制 |
|----------|---------|------|
| **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` 限制 → 返回 |
## 继续条件(恢复路径)
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
### 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` 次。恢复耗尽后,暂扣的错误消息被释放。
### 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` 标志防止无限循环。
### 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. 已收集的 `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()`,跨迭代累计
## 为什么不是"一次规划,批量执行"
<Note>
源码揭示了为什么 Claude Code 选择逐步循环:
</Note>
- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
## 一个完整的迭代示例
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
```
迭代 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
迭代 2: 思考→行动
预处理管道: 42 个文件结果仍在预算内
API 调用: 返回 tool_use(Grep, "import.*from")
工具执行: 在 15 个文件中找到 120 条 import
→ needsFollowUp = true
→ transition: { reason: 'next_turn' }, continue
迭代 3: 思考→行动(多轮)
预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化
API 调用: 返回 3 个 tool_use(FileEdit, ...)
工具执行: 删除 5 条未使用导入
→ needsFollowUp = true
→ transition: { reason: 'next_turn' }, continue
迭代 4: 总结
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
→ needsFollowUp = false
→ Stop hooks 通过
→ Token Budget 检查通过(如果启用)
→ return { reason: 'completed' }
```