第 7 章:工具调用循环(Agentic Loop)
# 第 7 章:工具调用循环(Agentic Loop)
> 自主性的本质:给 LLM 一个工具,它会用你没预料到的方式完成任务。
---
## 7.1 核心问题
前六章打下了所有基础:
- 第 4 章:工具的抽象与执行(`Tool` 接口、`buildTool()`)
- 第 5 章:流式 LLM 调用(`callModel()`)
- 第 6 章:多轮消息历史管理
但这三块还是独立的零件。现在要把它们焊接在一起:**让 LLM 自主决定何时调用工具、调用哪个工具、用结果继续思考,直到完成任务。**
这就是 Agentic Loop —— Agent 的心跳。
单轮对话是:`用户问 → LLM 答`。
Agentic Loop 是:`用户问 → LLM 决策 → 调用工具 → 看结果 → 再决策 → 调用工具 → ... → 给出最终答案`
一个典型的 "列出项目中所有 TODO" 请求,背后可能发生:
```
用户:找出项目里所有未完成的 TODO
→ LLM:我来用 grep 搜索
→ 调用 Bash("grep -r 'TODO' src/")
→ 结果:找到 12 处,其中 3 处在 auth/ 目录
→ LLM:让我读一下具体文件看看
→ 调用 Read("src/auth/login.ts")
→ 结果:文件内容
→ LLM:好的,我整理一下
→ 最终回复:[结构化的 TODO 清单]
```
每一次工具调用都是一次独立的 API 往返。Agentic Loop 的核心职责就是:**协调这些往返,管理状态,处理错误,直到 LLM 说"我完成了"。**
---
## 7.2 原理讲解
### 7.2.1 Loop 的驱动信号:`needsFollowUp`
一个常见误解:Agentic Loop 通过检测 `stop_reason === "tool_use"` 来决定是否继续。
Claude Code 的注释明确写道:
```typescript
// Note: stop_reason === 'tool_use' is unreliable -- it's not always set correctly.
// Set during streaming whenever a tool_use block arrives — the sole loop-exit signal.
let needsFollowUp = false
```
真正的驱动信号是 **`needsFollowUp`** ——一个布尔值,在 **流式响应**中每发现一个 `tool_use` 块就设为 `true`:
```typescript
// 流式处理 assistant 消息时:
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true // ← Loop 继续的信号
}
```
Loop 的结构是:
```
while (true) {
调用 API(流式)
收集 assistantMessages 和 toolUseBlocks
if (!needsFollowUp) {
// LLM 没有要调用的工具,任务完成(或中途退出)
break // return { reason: 'completed' }
}
// 执行所有 toolUseBlocks → 得到 toolResults
// 把 assistantMessages + toolResults 追加到 messages
// 继续下一轮
}
```
### 7.2.2 并行工具调用
同一轮 LLM 响应可以包含**多个** `tool_use` 块,例如:
```json
{
"role": "assistant",
"content": [
{ "type": "text", "text": "我同时读这三个文件:" },
{ "type": "tool_use", "id": "tu_a", "name": "Read", "input": {"file_path": "a.ts"} },
{ "type": "tool_use", "id": "tu_b", "name": "Read", "input": {"file_path": "b.ts"} },
{ "type": "tool_use", "id": "tu_c", "name": "Bash", "input": {"command": "ls src/"} }
]
}
```
Claude Code 通过 `runTools()` 并行执行所有工具(使用 `Promise.all` 语义),然后将全部结果打包为一条 `user` 消息:
```json
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "tu_a", "content": "..." },
{ "type": "tool_result", "tool_use_id": "tu_b", "content": "..." },
{ "type": "tool_result", "tool_use_id": "tu_c", "content": "..." }
]
}
```
**重要**:每个 `tool_use` 必须有对应的 `tool_result`,哪怕工具执行失败。失败的结果通过 `is_error: true` 标记,内容是错误消息。这避免了 API 因 `tool_use`/`tool_result` 不配对而拒绝请求。
### 7.2.3 流式工具执行(Streaming Tool Execution)
Claude Code 有一个关键优化:`StreamingToolExecutor`。
传统做法是:等 LLM **全部回复**完毕后,再执行工具。
`StreamingToolExecutor` 的做法是:**边接收流边并行启动工具执行**——
```
流式接收: [text_delta][text_delta][tool_use block 1 complete!][text_delta][tool_use block 2 complete!]
↓ ↓
工具执行: 立即启动 tool_1 立即启动 tool_2
```
工具在 LLM 还在"打字"的同时就开始执行了。当流结束时,工具可能已经完成,大幅降低延迟。
```typescript
// 每收到一个完整的 tool_use 块,立即注册执行
streamingToolExecutor.addTool(toolBlock, message)
// 流结束后,收集所有(已完成或还在跑的)结果
for await (const update of streamingToolExecutor.getRemainingResults()) {
yield update.message // 工具结果消息
}
```
### 7.2.4 循环终止条件
Agentic Loop 有多种退出路径,每种都有对应的 `reason`:
| 退出原因 | `reason` 值 | 触发条件 |
|---------|-------------|---------|
| 任务完成 | `'completed'` | LLM 回复没有 `tool_use`(`needsFollowUp = false`) |
| 用户中断 | `'aborted_streaming'` / `'aborted_tools'` | AbortController 触发 |
| 达到轮次上限 | `'max_turns'` | `turnCount > maxTurns` |
| 上下文过长 | `'prompt_too_long'` | 压缩失败后仍超长 |
| 模型错误 | `'model_error'` | API 抛出异常 |
| Hook 阻止 | `'hook_stopped'` / `'stop_hook_prevented'` | 生命周期 Hook 拦截 |
| 上下文阻塞上限 | `'blocking_limit'` | Token 超过硬阻塞阈值(禁用自动压缩时)|
`maxTurns` 是防止无限循环的"安全网"——即使 LLM 一直想调用工具,也最多迭代 `maxTurns` 次。
### 7.2.5 错误恢复策略
Agentic Loop 有多层容错机制,这是它区别于简单 `while(true)` 的地方:
#### 错误类型 1:`max_output_tokens`(输出被截断)
LLM 在说话说到一半时被截断了(写代码写了一半)。Loop 自动注入恢复提示:
```typescript
const recoveryMessage = createUserMessage({
content: `Output token limit hit. Resume directly — no apology, no recap of what you were doing.`
+ ` Pick up mid-thought if that is where the cut happened.`,
isMeta: true, // 不显示给用户
})
// 继续迭代,最多重试 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 次
```
#### 错误类型 2:`prompt_too_long`(上下文超长)
按优先级尝试三种恢复:
1. **Context Collapse drain**(CONTEXT_COLLAPSE 特性):先提交已暂存的压缩
2. **Reactive Compact**:触发一次完整的对话历史压缩
3. 若均失败:展示错误,退出循环
#### 错误类型 3:工具执行失败
工具执行失败不触发 Loop 退出,而是将错误作为 `tool_result`(标记 `is_error: true`)反馈给 LLM,让 LLM 自行决定如何处理(重试、换方法或告知用户)。
#### 错误类型 4:模型降级(Model Fallback)
当首选模型过载时,自动切换到降级模型重试当前请求,并清理孤立的 `tool_use` 块(防止旧 ID 泄露到重试请求中)。
### 7.2.6 Loop 的完整状态机
```
┌─────────────────────────────────────────────────────────────────┐
│ queryLoop(params) │
│ │
│ state = { messages, toolUseContext, turnCount: 1, ... } │
│ │
│ while (true) { │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ PREPARE │ │
│ │ 1. getMicrocompact(messages) → 清理过时工具输出 │ │
│ │ 2. getAutoCompact(messages) → 超限时压缩历史 │ │
│ │ 3. buildSystemPrompt() → 注入上下文 │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ STREAM API │ │
│ │ for await (message of callModel(messages)) { │ │
│ │ if tool_use block → toolUseBlocks.push() │ │
│ │ → needsFollowUp = true │ │
│ │ → streamingToolExecutor.add() │ │
│ │ } │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ DECIDE │ │
│ │ if (!needsFollowUp) { │ │
│ │ ├─ 错误恢复?→ state=recovery; continue │ │
│ │ ├─ Stop hooks?→ 可能 continue 或 return │ │
│ │ └─ return { reason: 'completed' } ◄─── 正常退出 │ │
│ │ } │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ needsFollowUp = true │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ EXECUTE TOOLS │ │
│ │ runTools(toolUseBlocks) → toolResults │ │
│ │ ├─ 并行执行所有 tool_use │ │
│ │ ├─ 失败 → is_error: true(不退出循环) │ │
│ │ └─ 中断 → return { reason: 'aborted_tools' } │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ UPDATE STATE │ │
│ │ messages = [...messages, ...assistantMsgs, │ │
│ │ ...toolResults, ...attachments] │ │
│ │ turnCount++ │ │
│ │ if (turnCount > maxTurns) → return 'max_turns' │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ continue (下一轮) │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 7.3 Claude Code 源码细节
### 7.3.1 `query()` vs `queryLoop()` vs `QueryEngine`
Claude Code 有两套 Agentic Loop 入口,职责分工清晰:
```
QueryEngine.submitMessage() ← SDK / 无头模式(程序调用)
│ 处理:会话初始化、System Prompt、slash 命令预处理
└─► query(params)
└─► queryLoop(params) ← 核心循环(REPL 和 SDK 共用)
```
- **`QueryEngine`**(`src/QueryEngine.ts`):高层封装,管理会话生命周期(`mutableMessages`、`readFileState`、权限追踪)。SDK 调用者(如 VS Code 插件、CI 系统)使用这一层。
- **`query()`**(`src/query.ts`):中间层,将 `queryLoop` 包装为 AsyncGenerator,处理 slash 命令生命周期通知。
- **`queryLoop()`**:无状态的核心循环——纯粹的"调用 → 工具 → 调用"状态机。每一轮通过 `state = { ... }; continue` 更新状态,而不是修改变量。
### 7.3.2 `State` 对象:Loop 的可变心脏
`queryLoop` 通过一个不可变 `State` 对象在迭代间传递状态:
```typescript
type State = {
messages: Message[] // 当前消息历史
toolUseContext: ToolUseContext // 工具执行上下文(含权限、缓存等)
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // max_output_tokens 已重试次数
hasAttemptedReactiveCompact: boolean // 防止压缩无限循环
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<...> | undefined // 后台生成工具摘要
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined // 记录上一轮的转换原因(用于测试断言)
}
```
"继续迭代"的惯用写法:
```typescript
// 例:max_output_tokens 恢复
state = {
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
// ...其他字段
transition: { reason: 'max_output_tokens_recovery', attempt: count + 1 },
}
continue // 下一轮迭代
```
这种写法的好处:`transition.reason` 使测试代码可以精确断言"哪条恢复路径被触发了",而无需检查消息内容。
### 7.3.3 `yieldMissingToolResultBlocks()`:孤立 `tool_use` 的清理
当流式响应中断(用户 Ctrl+C、网络故障)时,已发出的 `tool_use` 块可能没有对应的 `tool_result`。
```typescript
function* yieldMissingToolResultBlocks(
assistantMessages: AssistantMessage[],
errorMessage: string,
) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = /* 从 assistantMessage 提取 tool_use 块 */
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{
type: 'tool_result',
content: errorMessage, // 错误描述作为结果内容
is_error: true,
tool_use_id: toolUse.id, // 配对原来的 tool_use
}],
})
}
}
}
```
这确保了消息历史始终保持 API 所要求的完整性:**每个 `tool_use` 一定有配对的 `tool_result`**。
### 7.3.4 `countToolCalls()` 与 `maxTurns` 保护
```typescript
// 在每轮 tool 执行后(更新 state 之前)
const nextTurnCount = turnCount + 1
if (maxTurns && nextTurnCount > maxTurns) {
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
turnCount: nextTurnCount,
})
return { reason: 'max_turns' }
}
```
`maxTurns` 是防止 Agent 失控的最后防线。Claude Code 默认值为 `undefined`(无限制),但 SDK 调用者可以设置,CI 场景通常设为 `10`~`20`。
---
## 7.4 最小化产出物
> 代码骨架位于 `../chapters/07/src/`,参考实现位于 `../chapters/07/solution/`。
> **前置条件**:需要 `ANTHROPIC_API_KEY` 环境变量(`npm start` 需要)。
### 本章要实现什么
在 `../chapters/07/src/loop.ts` 中完成 Agentic Loop。
**接口规范**(已提供,不要修改):
```typescript
export type LoopResult = {
reason: 'completed' | 'max_turns' | 'error' | 'aborted'
finalMessages: Anthropic.MessageParam[]
turnCount: number
totalInputTokens: number
totalOutputTokens: number
}
export function toAnthropicTools(): Anthropic.Tool[]
export async function executeAllTools(
toolUseBlocks: Anthropic.ToolUseBlock[],
onToolStart?: (name: string, input: unknown) => void,
onToolEnd?: (name: string, result: { content: string; isError?: boolean }) => void,
): Promise<Anthropic.ToolResultBlockParam[]>
export async function agenticLoop(
initialMessages: Anthropic.MessageParam[],
systemPrompt?: string,
maxTurns?: number,
opts?: { onText?; onToolStart?; onToolEnd?; signal? }
): Promise<LoopResult>
```
**你需要实现**:
1. `toAnthropicTools()`:调用第 4 章的 `getToolSchemas()`,转为 `Anthropic.Tool[]` 格式
2. `executeAllTools()`:`Promise.all` 并行执行所有 tool_use 块,每个调用第 4 章的 `TOOL_REGISTRY`
3. `agenticLoop()`:
- 用第 6 章的 `ConversationManager` 管理消息历史
- `while(true)` 循环:调用第 5 章的 `streamQuery`(带工具列表)
- 无 tool_use → `return { reason: 'completed' }`
- 有 tool_use → `executeAllTools` → 追加 tool_result → 继续
- 超过 maxTurns → `return { reason: 'max_turns' }`
**关键约束**:
- 必须 import 并复用第 4 章的 `TOOL_REGISTRY`、第 5 章的 `streamQuery`、第 6 章的 `ConversationManager`
- 工具执行失败不退出循环,以 `is_error: true` 的 `tool_result` 反馈给 LLM
### 验收
```bash
cd docs/chapters/07
npm install
npm test
```
卡住时查看 `../chapters/07/solution/loop.ts`。
---
## 7.5 本章小结
本章完成了 Coding Agent 最核心的一跳——**让 LLM 自主驱动工具调用循环**:
1. **`needsFollowUp` 是真正的 Loop 驱动信号**,而不是 `stop_reason`。`stop_reason === "tool_use"` 并不可靠,Claude Code 在流式接收时检测 `tool_use` 块并设置 `needsFollowUp = true`。
2. **并行工具执行**:同一轮 LLM 响应中的多个 `tool_use` 块被并行执行(`Promise.all`),减少等待时间。每个 `tool_use` 必须有配对的 `tool_result`,失败也不例外。
3. **Streaming Tool Execution**:Claude Code 的 `StreamingToolExecutor` 在 LLM 流式输出时就开始执行工具,进一步降低端到端延迟。
4. **多层错误恢复**:`max_output_tokens` → 自动注入恢复提示重试;`prompt_too_long` → 依次尝试 context collapse、reactive compact;工具失败 → 作为 `is_error: true` 的 `tool_result` 反馈给 LLM 自行处理。
5. **`maxTurns` 是安全网**:防止 LLM 陷入工具调用无限循环。生产环境中这个值至关重要,设太小会中断合理任务,设太大会带来 API 成本失控风险。
6. **`State` 对象模式**:Claude Code 通过不可变 `State` + `state = {...}; continue` 的惯用写法管理迭代状态,使每条恢复路径的触发原因(`transition.reason`)都可精确断言。
7. **真正的组合**:`loop.ts` 通过 import 复用第 4 章的 `TOOL_REGISTRY`、第 5 章的 `streamQuery`、第 6 章的 `ConversationManager`,本章只新增了 Agentic Loop 的调度逻辑,没有重复实现任何已有功能。
至此,前七章共同构成了一个完整的 Coding Agent 骨架:**进程 → 终端 UI → CLI 路由 → 工具 → LLM → 消息历史 → Agentic Loop**。下一章将在这个骨架上加装"安全阀"——权限与安全仲裁系统。
---
## 下一章
→ [第 8 章:权限与安全仲裁](08-permissions)