claude-code/docs/agentic-design/01-agent-loop.md
Claude 808d5a61b3
docs: Add comprehensive agentic design documentation (2.5-hour learning session)
新增 docs/agentic-design/ 教育文档,全中文+英文技术术语,覆盖:

- README.md: 总索引,整体架构图,阅读指南
- 00-codebase-tour.md: 代码库全景,递归覆盖所有重要目录
- 01-agent-loop.md: Query loop 状态机,token budget,compaction
- 02-tool-system.md: Tool interface,权限检查,并发执行,sibling abort
- 03-multi-agent-coordination.md: Agent 类型,coordinator 4 阶段,XML 通信
- 04-permission-system.md: Permission mode,rule system,YOLO 分类器,denial tracking
- 05-context-and-memory.md: System prompt 分层,compaction,auto-dream 后台巩固
- 06-feature-gating.md: Compile-time feature,runtime flags,Bun dead-code elimination

总计 ~2.5 小时阅读,每篇含 Design Decision 专栏和常见误解纠正

https://claude.ai/code/session_017Vdqo9B8eTXiEDcqnv9DxM
2026-03-31 16:07:29 +00:00

8 KiB
Raw Blame History

核心 Query LoopAgent 的心脏

为什么需要 Loop?

想象一个简单的"一问一答"的 AI用户问 → API 回复 → 完成。但 agent 不一样:

  • 用户问:"帮我修复 bug"
  • Agent 读取文件 → 分析问题 → 尝试修复 → 运行测试 → 测试失败 → 再次修改 → 再次测试 → 成功

这个过程需要多轮往返,每一轮中 Agent 决定"下一步应该执行哪个 tool"。这就是 Query Loop


核心概念:状态机

Query loop 本质上是一个状态机,在以下几个状态间循环:

START
  ↓
QUERY (提交 prompt 给 Claude API)
  ↓
TOOL_USE (API 返回 tool_use block包含 tool 名 + 输入)
  ↓
执行 Tool (BashTool, FileEditTool, 等等)
  ↓
RESULT (Tool 返回结果)
  ↓
提交结果给 Claude (作为 user 消息)
  ↓
QUERY (继续循环)
  ↓
END (API 返回 stop_reason="end_turn" 或其他终止条件)

代码位置

关键文件:query.ts (~1700 行)

这个文件包含:

  • Loop 的完整实现 (使用 AsyncGenerator)
  • 状态跟踪
  • Tool 调用的编排
  • Streaming 支持
  • Compaction 触发
  • 错误处理和恢复

你应该从 query.ts 的函数签名开始:

export function* query(...): AsyncGenerator<QueryMessage | QueryEvent, ...>

它返回一个 AsyncGenerator,每次 yield 都是一个"事件"API 消息、tool 调用、错误等)。


Loop 的生命周期

阶段 1: 初始化

当你调用 query() 时:

  • messages 数组初始化(包含 system prompt + 历史消息)
  • token_budget 初始化(计算剩余 token
  • Compaction 检查(如果历史太长,自动压缩)
  • Abort controller 初始化(支持中途停止)

阶段 2: 发送 Prompt

// 构建消息数组
let messages = [system_prompt, ...history];

// 调用 Claude API使用缓存
let response = await claude.messages.create({
  messages,
  system: system_prompt,
  max_tokens: remaining_budget,
  stream: true,  // 重要:流式输出
  ...config
});

Streaming 很关键——API 会逐步返回:

  1. First chunk: 可能包含 message.start
  2. Content blocks: text_delta, tool_use block 开始
  3. Stop reason: 表示本轮结束

阶段 3: 处理 Streaming

当 API 返回 tool_use block 时loop 收集完整的参数,然后:

接收 tool_use block
  ↓
验证 tool 名称是否存在
  ↓
检查权限 (useCanUseTool hook)
  ↓
执行 tool (StreamingToolExecutor)
  ↓
Yield tool 结果给消费者
  ↓
添加到 messages 作为 user 消息

阶段 4: 工具执行

Tool 执行由 services/tools/StreamingToolExecutor.ts 处理:

  • 并发:最多 5 个 tool 同时运行
  • 顺序输出:结果按调用顺序返回(即使执行顺序不同)
  • Sibling abort:一个失败会 cancel 兄弟 tool

阶段 5: 检查终止条件

每一轮后loop 检查是否应该继续:

stop_reason == "end_turn"?      → 正常结束
stop_reason == "max_tokens"?    → Token 超限,触发 compaction
consecutive_errors >= 3?        → 连续错误,放弃
max_turns_reached?              → 超过最大轮数
abort_signal?                   → 用户中断

Token BudgetContext Window 的有限性

Context window 是有限的(比如 200K tokens。这里的逻辑

available_tokens = context_window_size - system_prompt_tokens - reserved_buffer

每一轮后:
  usage = response.usage (API 返回的实际使用)
  remaining = available_tokens - usage.input_tokens - usage.output_tokens

  if remaining < threshold:
      触发 compaction (压缩历史)

  if remaining < min_required:
      拒绝继续,返回错误

Threshold 通常设得比较激进,比如还剩 20% 时就开始压缩,以避免在真的耗尽时措手不及。

相关文件:query/tokenBudget.ts


Compaction自动历史压缩

当 token 预算吃紧时loop 会自动压缩历史:

  1. Analyzer phase: 扫描历史消息,找出"长输出消息"(比如代码块)
  2. Prioritize: 对话越早,压缩优先级越高
  3. Compress: 使用 Claude API 总结这段历史,生成 200 行以内的 summary
  4. Replace: 把原始的 20 条消息替换为 1 条 summary 消息

这保证了 loop 可以持续运行,即使初始历史很长。


Streaming 与 Tool Call 的交织

这是一个复杂的地方。API 可能返回:

message {
  content: [
    { type: "text", text: "让我先读文件..." },
    { type: "tool_use", id: "...", name: "FileReadTool", ... },
    { type: "text", text: "现在我看到问题了..." },
    { type: "tool_use", id: "...", name: "FileEditTool", ... },
  ]
}

所以一条消息中可能混合了文本和 tool call。Loop 需要:

  • 收集每个 tool_use block 的完整输入参数(可能分段到达)
  • 在 tool_use 结束时立即执行(不等待消息全部返回)
  • 继续接收后续的文本或 tool call
  • 当消息完全接收后,将所有 tool 结果作为一条 user 消息发回

这使得 interleaved thinking 成为可能:模型可以一边思考一边调用工具。


Design Decision 专栏

为什么用 AsyncGenerator 而不是 callback?

不好的设计callback

query(messages, {
  onMessage: (msg) => {...},
  onToolCall: (tool, input) => {...},
  onError: (err) => {...},
  onProgress: (progress) => {...},
})

问题:

  • 4 种不同的 callback难以理解执行顺序
  • 消费者需要管理状态机来追踪"当前在哪个阶段"
  • 测试困难

更好的设计Generator

for await (const event of query(messages, config)) {
  if (event.type === 'message') {...}
  else if (event.type === 'tool_use') {...}
  else if (event.type === 'error') {...}
}

优点:

  • 单一的 for await...of 循环,清晰的顺序
  • 每个 event 都是一个联合类型,编译器可以帮你检查
  • 可以在 loop 中添加复杂的条件逻辑abort, timeout 等)
  • 易于测试(可以模拟一个生成事件的 generator

Generator 让"一系列事件"变得一级公民。

为什么需要 Compaction?

不做 compaction

  • 100 轮 agent 工作后messages 数组有几千条消息
  • 提交给 API 时prompt cache hit 率下降(因为前缀不再是"静态部分"
  • Token 浪费在重复的对话历史上

做 compaction

  • 定期将"已经达成共识的历史部分"总结成一条消息
  • 保持 prompt cache 的"静态前缀"有效
  • 节省 token延长 agent 能工作的轮数

常见误解

误解 1"Query loop 就是不断问 Claude"

实际Loop 实现的是一个两层状态机

  • 外层Claude API 的请求/响应循环
  • 内层Tool 的执行和结果收集

tool 执行完全不涉及 Claude API 的额外调用(除非 tool 本身调用 API

误解 2"Compaction 会丢失信息?"

实际Compaction 使用 Claude 自己来总结历史,所以关键信息被保留。丢失的只是"冗余的解释"或"中间尝试",这些对后续工作没用。

误解 3"Token budget 追踪是精确的?"

实际API 返回的 usage 是经过舍入的(某些模型),所以我们的估计可能有 ±5% 的误差。这就是为什么我们用激进的阈值20% 时就开始压缩)而不是等到 100% 才反应。


关键要点

  1. Query loop 是异步生成器yield 事件流而不是回调
  2. 循环的关键状态转移QUERY → TOOL_USE → RESULT → QUERY
  3. Token budget 必须主动管理compaction 是自动压缩的关键
  4. Streaming 支持 interleaved thinking,一条消息中可以混合文本和 tool call
  5. Compaction 会定期触发,保持 history 可控,并维护 prompt cache 的有效性

深入阅读

  • query.ts:完整 loop 实现(必读)
  • query/tokenBudget.tsToken 追踪逻辑
  • services/tools/StreamingToolExecutor.tsTool 执行编排
  • query/stopHooks.ts:终止条件检查

下一步:去了解 Tool 系统是如何设计的,使得这个 loop 可以灵活地执行任意 tool。