2026-06-08 23:08:57 +08:00
|
|
|
|
---
|
2026-06-09 23:15:17 +08:00
|
|
|
|
tags: [agentic-loop, claude-code, 状态机, 流式API, 工具执行]
|
|
|
|
|
|
create time: 2026-06-09 22:15
|
2026-06-08 23:08:57 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
# Agentic Loop: AI 自主循环的核心机制
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
## 概述
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
Claude Code 的核心不是一问一答,而是基于 `query()` 异步生成器的无限循环——每次迭代完成"思考-行动-观察"周期,支持流式 API 调用、工具并行执行、上下文压缩、错误恢复和多种终止条件。
|
|
|
|
|
|
|
|
|
|
|
|
## 正文
|
|
|
|
|
|
|
|
|
|
|
|
### 什么是 Agentic Loop
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
传统聊天机器人:你问一句,它答一句。
|
|
|
|
|
|
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考-行动-观察"周期。
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
|
|
|
|
|
A["开始循环"] --> B["上下文预处理"]
|
|
|
|
|
|
B --> C["流式 API 调用"]
|
|
|
|
|
|
C --> D{"有工具调用?"}
|
|
|
|
|
|
D -- 是 --> E["并行执行工具"]
|
|
|
|
|
|
E --> F["工具结果合并"]
|
|
|
|
|
|
F --> B
|
|
|
|
|
|
D -- 否 --> G["终止条件检查"]
|
|
|
|
|
|
G --> H["返回结果"]
|
|
|
|
|
|
G -- 需要恢复 --> B
|
|
|
|
|
|
```
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 循环的完整结构
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
#### 阶段 1: 上下文预处理(Pre-Processing Pipeline)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
在调用 API 之前,依次执行 5 个压缩/优化步骤:
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
|
|
|
|
|
A["messagesForQuery 原始消息"] --> B["applyToolResultBudget 工具结果预算截断"]
|
|
|
|
|
|
B --> C["snipCompactIfNeeded 历史 Snip 压缩"]
|
|
|
|
|
|
C --> D["microcompact 微压缩 工具结果摘要"]
|
|
|
|
|
|
D --> E["applyCollapsesIfNeeded 上下文折叠"]
|
|
|
|
|
|
E --> F["autocompact 自动压缩"]
|
|
|
|
|
|
F --> G["messagesForQuery 处理后消息"]
|
|
|
|
|
|
G --> H["发往 API"]
|
2026-06-08 23:08:57 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
#### 阶段 2: 流式 API 调用(Streaming Loop)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
`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,清空后重试
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
#### 阶段 3: 工具执行(Tool Execution)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
// 两种工具执行器(互斥)
|
|
|
|
|
|
const toolUpdates = streamingToolExecutor
|
2026-06-09 23:15:17 +08:00
|
|
|
|
? streamingToolExecutor.getRemainingResults() // 流式: 获取已完成的+等待中的
|
2026-06-08 23:08:57 +08:00
|
|
|
|
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
#### 阶段 4: 终止或继续
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续)。
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 终止条件(源码级)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
循环有多种终止路径,按触发时机排列:
|
|
|
|
|
|
|
|
|
|
|
|
| 终止原因 | 触发位置 | 机制 |
|
|
|
|
|
|
|----------|---------|------|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
| **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 恢复
|
|
|
|
|
|
|
|
|
|
|
|
当 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 恢复
|
|
|
|
|
|
|
|
|
|
|
|
当遇到 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)
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
#### 5. Token Budget 继续提示(token_budget_continuation)
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 模型降级(Fallback)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result: "Model fallback triggered"
|
2026-06-08 23:08:57 +08:00
|
|
|
|
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
|
|
|
|
|
3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
|
2026-06-09 23:15:17 +08:00
|
|
|
|
4. 生成系统消息: "Switched to {fallback} due to high demand for {original}"
|
2026-06-08 23:08:57 +08:00
|
|
|
|
5. 重新发起流式请求
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 状态机: State 对象
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
2026-06-09 23:15:17 +08:00
|
|
|
|
// src/query.ts -- State 类型定义
|
2026-06-08 23:08:57 +08:00
|
|
|
|
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`)避免循环。
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### Token Budget(实验性)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
- **continuation**: 未达到预算但超过阈值 -> 注入 nudge 消息,让 AI 加速收尾
|
|
|
|
|
|
- **diminishing_returns**: 检测到收益递减 -> 提前终止
|
2026-06-08 23:08:57 +08:00
|
|
|
|
- 预算数据来自 `createBudgetTracker()`,跨迭代累计
|
|
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 为什么不是"一次规划,批量执行"
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
> [!info] 设计选择的源码依据
|
|
|
|
|
|
> 源码揭示了为什么 Claude Code 选择逐步循环:
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
- **每一步都产生真实信息**: `runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
|
|
|
|
|
- **动态上下文管理**: 每轮迭代前都重新评估压缩需求(autocompact -> microcompact -> snip),基于最新的 token 计数
|
|
|
|
|
|
- **错误即时恢复**: 工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
|
|
|
|
|
- **用户可控**: `abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
|
|
|
|
|
|
- **成本控制**: Token Budget 在每轮终止前检查,防止 AI 无效循环
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
### 一个完整的迭代示例
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
> 用户: "帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-06-09 23:15:17 +08:00
|
|
|
|
迭代 1: 思考 -> 行动
|
|
|
|
|
|
预处理管道: applyToolResultBudget -> snipCompact -> microcompact -> applyCollapses -> autocompact
|
|
|
|
|
|
-> 上下文很短,无需压缩
|
2026-06-08 23:08:57 +08:00
|
|
|
|
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
|
|
|
|
|
工具执行: 返回 42 个文件路径
|
2026-06-09 23:15:17 +08:00
|
|
|
|
-> needsFollowUp = true
|
|
|
|
|
|
-> transition: { reason: 'next_turn' }, continue
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
迭代 2: 思考 -> 行动
|
2026-06-08 23:08:57 +08:00
|
|
|
|
预处理管道: 42 个文件结果仍在预算内
|
|
|
|
|
|
API 调用: 返回 tool_use(Grep, "import.*from")
|
|
|
|
|
|
工具执行: 在 15 个文件中找到 120 条 import
|
2026-06-09 23:15:17 +08:00
|
|
|
|
-> needsFollowUp = true
|
|
|
|
|
|
-> transition: { reason: 'next_turn' }, continue
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
2026-06-09 23:15:17 +08:00
|
|
|
|
迭代 3: 思考 -> 行动(多轮)
|
|
|
|
|
|
预处理管道: 120 条 Grep 结果触发 microcompact -> 摘要化
|
2026-06-08 23:08:57 +08:00
|
|
|
|
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
|
|
|
|
|
工具执行: 删除 5 条未使用导入
|
2026-06-09 23:15:17 +08:00
|
|
|
|
-> needsFollowUp = true
|
|
|
|
|
|
-> transition: { reason: 'next_turn' }, continue
|
2026-06-08 23:08:57 +08:00
|
|
|
|
|
|
|
|
|
|
迭代 4: 总结
|
|
|
|
|
|
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
2026-06-09 23:15:17 +08:00
|
|
|
|
-> needsFollowUp = false
|
|
|
|
|
|
-> Stop hooks 通过
|
|
|
|
|
|
-> Token Budget 检查通过(如果启用)
|
|
|
|
|
|
-> return { reason: 'completed' }
|
2026-06-08 23:08:57 +08:00
|
|
|
|
```
|
2026-06-09 23:15:17 +08:00
|
|
|
|
|
|
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
|
|
|
|
|
- [[multi-turn]] - 多轮对话管理与 QueryEngine 会话编排
|
|
|
|
|
|
- [[streaming]] - 流式响应机制与 SSE 事件处理
|
|
|
|
|
|
- [[../context/compaction]] - 上下文压缩三层策略
|
|
|
|
|
|
- [[../context/token-budget]] - Token 预算动态计算
|