# 心脏：查询循环与流式处理引擎

查询循环（queryLoop）是 Claude Code 的核心驱动——一个 `while(true)` 循环反复执行"调用 API → 处理响应 → 执行工具 → 再次调用"。本章深入这个循环的内部结构，包括流式 Token 处理、工具调用调度和终止条件判断。

> **🌍 行业背景**：`while(true)` 的 Agent Loop 是所有 LLM Agent 框架的标准模式——LangChain 的 AgentExecutor、AutoGPT 的 main loop、Cursor 的 Agent Mode 都采用类似结构。这不是 Claude Code 发明的模式，而是 AI Agent 的基本范式。Claude Code 的差异化不在循环本身，而在循环**内部**的三个工程决策：五层渐进式上下文压缩、流式工具并行执行（StreamingToolExecutor）、以及精细的并发安全分类机制。本章重点分析这三个决策的设计逻辑和 trade-off。

---

## 引子：心脏的收缩与舒张

人的心脏每分钟跳动 60-100 次。每一次心跳分为两个阶段：**收缩期**（把血液泵出去）和**舒张期**（让血液流回来）。Claude Code 的查询循环做着完全相同的事：

- **收缩期**：把消息泵到 Anthropic API，等待 AI 回应
- **舒张期**：处理回应中的工具调用，执行工具，把结果"回流"到消息历史

`while(true)` —— 每一轮循环就是一次心跳。当 AI 不再调用工具时（"最终回答"），心脏停止跳动，这轮对话结束。

> **🔑 OS 类比：** 查询循环就像手机的**主屏幕循环**——你点开一个 App → App 处理你的操作 → 返回结果 → 你再点下一个。queryLoop 反复执行"调用 AI → 处理回复 → 执行工具 → 再次调用 AI"，和你与手机的交互节奏完全一样。
>
> 💡 **通俗理解**：查询循环就像**快递分拣流水线**——收件（接收消息）→ 分拣（AI 判断要做什么）→ 装车（调用工具）→ 送达（拿到结果）→ 签收（返回给 AI 继续判断）→ 等下一单。流水线不停转，直到所有快递都送完（AI 给出最终回答，不再调用工具）。

---

## 1. 循环的骨架

`query.ts` 中的 `queryLoop()` 是一个 **AsyncGenerator** 函数——它不是一次性返回结果，而是像水龙头一样持续输出事件流。调用者（QueryEngine）通过 `for await` 消费这些事件。

每次循环迭代（一次"心跳"）包含以下步骤：

```
┌─── 一次心跳 ──────────────────────────────────────────┐
│                                                        │
│  1. 上下文压缩前处理（5+1 层压缩机制）                   │
│     a. applyToolResultBudget()  — 裁剪工具结果大小     │
│     b. snipCompactIfNeeded()    — 剪掉中间不重要片段   │
│     c. microcompact()           — 消除冗余内容         │
│     d. applyCollapsesIfNeeded() — 折叠旧对话段         │
│     e. autocompact()            — 超阈值自动全文压缩   │
│                                                        │
│  2. Token 预算检查                                     │
│     超出硬限制？→ 直接终止，返回 blocking_limit        │
│                                                        │
│  3. 调用 API（收缩期）                                 │
│     callModel() → Anthropic API，流式请求              │
│                                                        │
│  4. 流式处理                                           │
│     for await (message of stream):                     │
│       ├── text_delta → 实时显示给用户                  │
│       ├── tool_use  → 加入待执行队列                   │
│       │              → StreamingToolExecutor 立即开始   │
│       └── error     → 判断是否可恢复                   │
│                                                        │
│  5. 后采样 Hooks                                       │
│     executePostSamplingHooks()                         │
│                                                        │
│  6. 工具执行（舒张期）                                 │
│     runTools() → 并行执行所有工具                      │
│       → 每个工具经过 canUseTool() 权限检查             │
│       → 工具结果 → 新的 UserMessage                    │
│                                                        │
│  7. Stop Hooks                                         │
│     handleStopHooks()                                  │
│                                                        │
│  8. 判断：还有工具调用？                               │
│     ├── 是 → continue（下一次心跳）                    │
│     └── 否 → return（心脏停跳，本轮结束）              │
│                                                        │
└────────────────────────────────────────────────────────┘
```

### 退出条件

心脏不会永远跳下去。九种情况会让循环终止：

| 退出原因 | 含义 | 类比 |
|----------|------|------|
| `stop` | AI 正常结束，没有更多工具调用 | 正常心跳结束 |
| `max_turns` | 达到最大轮次限制 | 心率过高，强制休息 |
| `aborted_streaming` | 用户按 Ctrl+C 中断 | 外科手术中人为停止 |
| `blocking_limit` | Context window 满了 | 心脏负荷过重 |
| `model_error` | API 调用失败 | 心律不齐 |
| `image_error` | 图片处理失败 | 异物阻塞 |
| `token_budget_stop` | TOKEN_BUDGET 达到 90% 或出现 diminishing returns | 马拉松跑够了 |
| `stop_hook_prevented` | Stop Hook 阻止继续 | 裁判叫停 |
| `max_output_tokens_exhausted` | max_output_tokens 恢复 3 次后仍失败 | 急救失败 |

### TOKEN_BUDGET：+500k 背后的续命机制

当用户在消息末尾附加 `+500k`（或其他数字），Claude Code 不只是"多跑一会儿"——它启用了一套完整的**客户端续命策略**。

**工作原理**：

1. **解析预算**：用户输入被 `parseTokenBudget()` 正则解析，提取 token 数量，创建 `BudgetTracker`
2. **Turn 结束时判断**：当模型正常 `stop`（没有更多工具调用）时，`checkTokenBudget()` 介入——这发生在后置控制的**最后一步**
3. **注入 meta message**：如果 token 使用未达 90%（`COMPLETION_THRESHOLD = 0.9`）且未出现 diminishing returns，系统注入一条 `isMeta: true` 的 user message，内容为："Stopped at X% of token target (Y / Z). Keep working — do not summarize."（源码：`utils/tokenBudget.ts:72`）
4. **模型以为用户在催**：这条 meta message 对 UI 不可见，但模型看得到——它会以为用户在催促继续，于是开始下一轮工作
5. **Diminishing returns 早停**：当 `continuationCount >= 3` 且连续两次的 delta token 增量都低于 500（`DIMINISHING_THRESHOLD`），系统判定模型在"原地踏步"，强制停止，即使预算还没用完

> 💡 **通俗理解**：+500k 就像马拉松的补给站机制——每跑完一段（一个 turn），系统看你还有体力（token 预算），就递水给你让你继续跑（注入 meta message）。如果发现你开始原地踏步（diminishing returns），就算水站还有水也不给了——强制让你停下来，避免浪费。

**⚠️ taskBudget 与 TOKEN_BUDGET 是不同概念**（源码 `query.ts:193-197` 注释明确区分）：
- `taskBudget` 是 API 层面的 **server-side** 预算（`output_config.task_budget`），由 Anthropic 服务端执行限制
- `TOKEN_BUDGET` 是 client-side 的 **continuation policy**，由本地 Claude Code 执行。两者可以同时存在

### 后置控制的精确三段顺序

心跳结束后的处理不是一步判断，而是**严格按顺序执行的三段流程**（源码 `query.ts` 第 1119-1355 行）：

1. **错误恢复**（第 1119-1256 行）：检查 prompt-too-long（→ 触发 reactiveCompact 重试）和 max-output-tokens（→ 先升级到 64K 重试，失败则注入 meta message "Output token limit hit. Resume directly — no apology, no recap..."，最多恢复 3 次）
2. **Stop Hooks**（第 1258-1306 行）：运行 `handleStopHooks()`，业务逻辑决定是否阻断继续
3. **TOKEN_BUDGET 判断**（第 1308-1355 行）：最后才检查 token 预算——只有前两步都没有终止循环，才轮到预算判断

**为什么顺序重要**：先救回失败的调用（错误恢复），再看业务规则是否阻断（Stop Hooks），最后才判断是否值得继续（TOKEN_BUDGET）。如果先判断预算，可能在模型因 max-output-tokens 截断时误判"模型已完成"。

**更深的动机：防止 death spiral 死循环攻击**。源码 `query.ts:1119-1183` 注释明确写道："No recovery — surface the withheld error and exit. Do NOT fall through to stop hooks: ... Running stop hooks on prompt-too-long creates a death spiral: error → hook blocking → retry → error → ..."

> 💡 **death spiral 是什么？** "死亡螺旋"——一类特殊的自损攻击/bug：错误触发 hook 注入更多上下文 → 上下文更满 → 下次调用又 prompt-too-long → 又触发 hook → 又注入...如此循环，每次都让问题更糟直到系统崩溃。源码作者在这里专门防了一类"恶意/无意的 hook 配置把系统拖入反复压缩的无限循环"的可能性。

这个防御动机揭示了一个深层设计原则：**后置控制三段不是随意编排的顺序，而是对特定攻击/故障模式的系统性防御**。错误恢复必须在 Stop Hooks 之前执行（免得 hook 被无限触发），Stop Hooks 必须在 TOKEN_BUDGET 之前（免得业务阻断被预算续命绕过）——每一段的位置都有安全含义。

---

## 2. 上下文压缩：心脏跳动前的"血液净化"

每次心跳**开始之前**，系统先对消息历史做压缩处理。这就像心脏泵血之前，血液要先经过肺部净化一样。

为什么需要压缩？因为 context window（上下文窗口）是有限的——最大约 200,000 token。对话越长、工具调用越多，累积的 token 越多。如果不压缩，很快就会撑满。

五层预处理压缩机制从轻到重依次执行（另有第六层 reactiveCompact 在 API 返回 413 错误时触发，见本节末尾）：

> 📌 **关于"五层"vs"六套"vs"三层"的口径说明**：本章使用 "**五层 in-call 压缩 + 第六道防线 reactiveCompact**" 的叙事，因为本章聚焦的是 query 主循环内部的压缩逻辑，而 reactiveCompact 是事后兜底（API 返回 413 时才触发，不在主循环路径上）。其他章节有不同的口径：
> - **Q02 上下文压缩为什么需要六套机制** 把"五层 in-call + reactiveCompact"合起来叫**"六套压缩机制"**，强调完整的防御体系
> - **Part5 第一章** 用"**三层压缩**"的高度抽象（轻量裁剪 / 中度压缩 / 重度全文压缩），是面向批判性分析的简化口径
>
> 三种说法对应**同一系统的不同抽象粒度**，不是矛盾。本章用"五层"是工程视角，Q02 用"六套"是系统视角，Part5 用"三层"是分类视角。

### 第一层：工具结果裁剪 (applyToolResultBudget)

**比喻**：你的行李箱有限，先把最大件的衣服折叠得更紧凑。

每个工具的结果有大小上限（`maxResultSizeChars`）。超过上限的部分被截断。这是最轻量的压缩——只砍大的，不动小的。

### 第二层：历史片段剪裁 (snipCompactIfNeeded)

**比喻**：一本日记写了几百页，把中间那些"今天没什么事"的页面撕掉。

标记为 `HISTORY_SNIP` 的消息片段被移除。这些通常是中间过程中不再重要的内容。

### 第三层：微压缩 (microcompact)

**比喻**：把文档里重复出现的"以上是文件内容"之类的模板语言删掉。

消除工具结果中的冗余标记和重复内容。不改变语义，只减少"废话"。

### 第四层：上下文折叠 (applyCollapsesIfNeeded)

**比喻**：把日记的前几个月折叠成一段摘要——"三月份主要在做重构"。

`CONTEXT_COLLAPSE` 把旧的对话段落替换为 AI 生成的摘要。信息量显著减少，但核心要点保留。

### 第五层：自动全文压缩 (autocompact)

**比喻**：行李箱真的塞不下了——把所有东西取出来，用真空压缩袋重新打包。

当 token 用量超过阈值时，启动一个**新的 AI 实例**来总结整段对话。这是最重的操作——需要一次额外的 API 调用，但能把上下文压缩到原来的 1/3 到 1/5。

**设计要点**：五层按条件触发——每次心跳都会**检查**是否需要执行每一层，但只有满足条件才真正执行（短路求值，不是瀑布式全跑一遍）。前三层（裁剪、剪裁、微压缩）的检查开销极小（几个字符串比较），即使每次都跑也几乎没有性能影响。第四层（折叠）和第五层（全文压缩）只在 token 用量超过阈值时触发。`autocompact` 的精确触发阈值来自源码计算公式（`autoCompact.ts:33-76`）：

```
effectiveContextWindow = contextWindow - min(maxOutputTokens, 20_000)
autocompactThreshold   = effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
                       (AUTOCOMPACT_BUFFER_TOKENS = 13_000)
```

对标准 200K context window：阈值 ≈ (200K − 20K 预留给摘要输出) − 13K ≈ **167K**（约 83.5%）。对启用了 context-1M beta 的模型，上限是 **1M**（`context.ts:71,86,89`），阈值按相同公式换算更大。99% 的心跳在前三层就完成了上下文管理，不需要触发昂贵的第四五层。

> 💡 **通俗理解**：上下文压缩就像**会议记录员的工作策略**——第一步：把冗长的附件折叠起来（裁剪工具结果）→ 第二步：删掉"今天没什么事"的页面（剪裁无用片段）→ 第三步：去掉重复的废话（微压缩）→ 第四步：把前几个月的内容压缩成摘要（折叠旧对话）→ 第五步：全程录音太长了，做一份精华摘要替代原始录音（全文压缩）。绝大多数时候前三步就够了，不用动用"核武器"。

> **第六道防线：reactiveCompact（响应式紧急压缩）**——如果五层压缩之后 API 仍然返回 413 错误（上下文超限），系统会立即触发一次紧急全文摘要，然后重试。它只尝试一次：如果压缩后再次 413，就认为无法恢复。详见 Q02。

---

## 3. 流式处理：边跑边接球

传统的 API 调用是"请求-响应"模式：发出请求，等待完整响应。但 Claude Code 使用**流式**（streaming）：AI 的每一个 token 生成后就立即发送给客户端。

这带来两个好处：
1. **用户体验**：用户看到 AI "实时打字"，而不是等几秒钟后突然出现一大段文字
2. **工具并行**：AI 还在说话时，已经提到的工具就可以开始执行

### StreamingToolExecutor：等不到模型说完就开始干活

大多数 Agent 框架采用"说完再做"模式：AI 生成完整响应 → 解析工具调用 → 执行工具。三步串行。Claude Code 的 StreamingToolExecutor 打破了这个顺序——它在流式传输过程中**边接收边执行**。这不是全新的发明（Cursor 的 Agent Mode 也有类似的流式处理），但 Claude Code 的实现在**并发安全分类**上做了更精细的工程决策。

StreamingToolExecutor 的流程：AI 正在说话 → 检测到一个 `tool_use` block → **立即开始执行这个工具** → AI 继续说话 → 又检测到一个 → **再立即开始** → AI 说完时，工具可能已经执行完了。

```
时间线（传统方式）：
  AI说话████████████████ → 解析 → 执行工具A███ → 执行工具B███ → 执行工具C███
  总时间：═══════════════════════════════════════════════════════════

时间线（StreamingToolExecutor）：
  AI说话████████████████
         工具A███（AI还在说话时就开始了）
              工具B███
                   工具C███
  总时间：════════════════════════（节省了大量等待时间）
```

**关键约束**：只有标记为 `isConcurrencySafe` 的工具才能在流式阶段并行执行。这个分类机制是 StreamingToolExecutor 真正有价值的工程决策：

| 并发安全 | 工具示例 | 原因 |
|---------|---------|------|
| ✅ 可并行 | Read, Glob, Grep, WebSearch | 纯读取操作，不修改状态 |
| ❌ 必须独占 | Edit, Write, Bash, NotebookEdit | 修改文件/执行命令，可能互相冲突 |
| ⚠️ 条件并行 | Agent (subagent) | 取决于子 Agent 的 isolation 级别 |

> 📚 **课程关联**：这本质上就是计算机体系结构课中**指令流水线**（instruction pipelining）的应用层实现。`isConcurrencySafe` 的角色等同于 CPU 的**冒险检测**（hazard detection）——识别哪些指令之间有数据依赖（RAW/WAW 冒险），有依赖的必须串行，无依赖的可以并行。

> **🔑 OS 类比：** 就像你在手机上**边下载边看视频**——下载器在后台搬运文件，播放器同时在播放，两件事互不等待，都在同时进行。但如果两个 App 同时往同一个文件写数据，就会出问题——所以系统需要判断"哪些操作可以并行，哪些必须排队"。

> 🌍 **竞品对比**：Cursor 的 Agent Mode 也支持流式工具执行，但其粒度更细——Cursor 直接操作 AST（抽象语法树）而非文本级别的 Edit，因此可以做到更细粒度的并行。Aider 则完全采用"说完再做"模式，不做流式并行。Windsurf (Codeium) 的 Cascade 采用多步规划+执行的模式，和 Claude Code 的反应式循环（reactive loop）形成了"规划性 vs 灵活性"的典型架构对比。

---

## 4. 工具执行：系统调用的十步权限检查

当心跳进入"舒张期"（工具执行阶段），每个工具调用都要经过权限系统的检查。这个过程在 Part 3 Q05 和 Part 4 第一章有详细分析，这里只描述它在查询循环中的位置。

```
queryLoop 发现 tool_use block
  → canUseTool() 权限检查（十步完整链路）：
    → ① 工具存在性检查 —— 这个工具名在 40 个内置工具目录 + MCP 动态工具中是否存在？
    → ② 工具可用性检查 —— 当前模式下是否启用？（如 plan 模式禁用写工具）
    → ③ 企业策略检查 (Enterprise Policy) —— MDM/远程策略是否允许？【最高优先级，不可覆盖】
    → ④ 沙箱检查 (Sandbox) —— 操作是否在沙箱允许范围内？
    → ⑤ 只读快速通道 —— isReadOnly()=true 的工具直接放行，跳过后续步骤
    → ⑥ 权限规则匹配 —— 检查用户设置的 allow/deny 规则列表
    → ⑦ 权限模式判断 —— 当前是 default / acceptEdits / bypassPermissions / plan / dontAsk 哪种外部模式？（源码 `types/permissions.ts:16-22` EXTERNAL_PERMISSION_MODES）
    → ⑧ Iron Gate 检查 —— bypass-immune 操作（如 Bash rm -rf），即使 bypass 模式也必须确认
    → ⑨ 工具实现自检 —— tool.checkPermissions() 的内容敏感检查（如 Bash 子命令）
    → ⑩ 最终决策收口 —— "passthrough" 转 "ask"，形成最终的 Allow / Ask / Deny 三元结果
    → 结果：Allow / Ask / Deny
  → Allow → tool.call() 执行
  → Ask → 弹出 UI 权限对话框，等待用户
  → Deny → 返回拒绝消息给 AI
```

> 📚 **设计模式关联**：这个十步检查链是经典的**责任链模式**（Chain of Responsibility）——每一步都可以独立决定"放行"或"拒绝"，不需要知道其他步骤的存在。同时，步骤⑤的只读快速通道是**短路求值**（short-circuit evaluation）的工程应用。详细分析见 Part 3 Q05。

**工具执行的结果**按照 Anthropic API 的消息格式，被放入一条 `UserMessage` 的 `tool_result` content block 中。一条 UserMessage 可以包含多个 `tool_result` block——这正是一次心跳中多个工具并行执行后批量返回结果的机制。从 AI 的视角看，"我调用了几个工具，然后用户告诉我了执行结果"。实际上"用户"什么都没做——这些"用户消息"是系统自动生成的。这种"欺骗性抽象"让 AI 不需要知道工具系统的存在，保持了 API 接口的简洁。

> 📚 **设计模式关联**：这是经典的**适配器模式**（Adapter Pattern）——tool_result 的真实来源是工具运行时，但通过包装成 UserMessage 的形式，适配了 Anthropic API "只有 user 和 assistant 两种角色交替对话"的接口约束。

---

## 5. AsyncGenerator 的设计选择

为什么 queryLoop 是 AsyncGenerator 而不是普通的 async 函数？

> 💡 **通俗理解（不懂代码也能秒懂）**：普通函数像**点外卖**——你下单，然后等，等做好了一次性全端上来。AsyncGenerator 像**自助火锅传送带**——厨房一盘一盘往传送带上放，你吃完一盘它再送下一盘，你吃得慢它就等一等，你可以随时喊停。queryLoop 就是这条传送带：AI 的每一次思考、每一个工具调用结果都是传送带上的一盘菜，前端 UI 在另一头一盘盘地展示给用户。

**普通 async 函数**（外卖模式：做好了才送来）：
```typescript
async function queryLoop(): Promise<Result> {
  // 运行完毕后一次性返回结果——中间发生了什么，调用者完全不知道
}
```

**AsyncGenerator**（传送带模式：边做边送）：
```typescript
async function* queryLoop(): AsyncGenerator<Event, Result> {
  yield progressEvent;  // "传送带送出一盘菜"——中间状态实时可见
  yield toolResultEvent; // "又送出一盘"——调用者可以实时更新 UI
  return finalResult;   // "最后一道主菜"——传送带停止
}
```

AsyncGenerator 在 Node.js 流式处理中是常规选择（不是 Claude Code 独创），但它确实精准匹配了 AI Agent 的需求：

1. **中间状态可观察**：每个 `yield` 把内部状态暴露给调用者，调用者可以实时更新 UI
2. **可中断**：调用者可以随时 `return()` 终止 generator，实现 Ctrl+C 中断
3. **背压控制**（通俗说：消费跟不上时自动减速）：调用者处理慢时 generator 自动暂停——不会像 EventEmitter 那样导致内存堆积
4. **组合性**：多个 generator 可以用 `yield*` 嵌套——子 Agent 的 queryLoop 可以嵌入父 Agent 的 queryLoop

> 📚 **课程关联**：AsyncGenerator 的背压机制本质上就是操作系统课中的**生产者-消费者模型**——queryLoop 是生产者，UI 是消费者，`yield` 是带容量限制的缓冲区。当消费者跟不上时，生产者自动阻塞。
>
> **🔑 OS 类比：** 就像工厂的**传送带**——queryLoop 不断把产品（事件）放上传送带，调用者在另一端不断取走。传送带让生产和消费各干各的，中间还可以插入质检、包装等环节。

> 🌍 **为什么不选其他方案？** Node.js 生态中还有 RxJS Observable 和 EventEmitter 可以实现类似的事件流。AsyncGenerator 的优势在于：它是语言原生的（不需要引入外部依赖），支持 `for await` 语法（代码最简洁），且自带背压（RxJS 需要手动处理，EventEmitter 完全没有）。代价是调试困难——堆栈跟踪在 generator 边界中断，async 错误传播路径比 Promise 链更复杂。

### 真实源码：queryLoop 的核心骨架

下面是从 `src/query.ts` 提取的真实代码（有精简，保留核心结构）：

```typescript
// src/query.ts — queryLoop 核心骨架（精简版）

async function* queryLoop(
  params: QueryParams,
  consumedCommandUuids: string[],
): AsyncGenerator<StreamEvent | Message, Terminal> {
  // ① 不可变参数——整个循环过程中不会改变
  const { systemPrompt, canUseTool, maxTurns } = params

  // ② 可变状态——每次迭代开始时解构读取，continue 时整体替换
  let state: State = {
    messages: params.messages,
    toolUseContext: params.toolUseContext,
    autoCompactTracking: undefined,
    hasAttemptedReactiveCompact: false,
    turnCount: 1,
    // ... 还有 stopHookActive, pendingToolUseSummary 等
  }

  // ③ 心跳循环——while(true) 直到 AI 不再调用工具
  while (true) {
    let { toolUseContext } = state
    const { messages, turnCount } = state

    // ④ 五层压缩：裁剪 → 剪裁 → 微压缩 → 折叠 → 自动全文压缩
    messagesForQuery = await applyToolResultBudget(messagesForQuery, ...)
    messagesForQuery = snipCompactIfNeeded(messagesForQuery)
    messagesForQuery = (await microcompact(messagesForQuery, ...)).messages
    messagesForQuery = (await applyCollapsesIfNeeded(messagesForQuery, ...)).messages  // 上下文折叠
    const { compactionResult } = await autocompact(messagesForQuery, ...)
    // autocompact 成功后：messagesForQuery = buildPostCompactMessages(compactionResult)
    // → 直接拼回当前 turn 继续，不重建 query（源码 query.ts:528-535）

    // ⑤ 调用 API（收缩期）
    yield { type: 'stream_request_start' }
    const stream = callModel(messagesForQuery, systemPrompt, ...)

    // ⑥ 流式处理——边接收边执行工具
    for await (const event of stream) {
      // text_delta → 实时显示给用户
      // tool_use  → StreamingToolExecutor 立即开始执行
    }

    // ⑦ 工具执行（舒张期）+ 权限检查
    const toolResults = await runTools(toolUseBlocks, canUseTool, ...)

    // ⑧ 后置控制（三段顺序）
    // 8a. 错误恢复：prompt-too-long → reactiveCompact; max-output-tokens → 升级64K重试(最多3次)
    // 8b. Stop Hooks：handleStopHooks() 业务规则阻断
    // 8c. TOKEN_BUDGET：checkTokenBudget() 判断是否注入 meta message 续命

    // ⑨ 判断：还有工具调用？或者 TOKEN_BUDGET 注入了续命消息？
    if (!needsFollowUp) {
      return { reason: 'stop', ... }  // 心脏停跳，本轮结束
    }
    // 否则 continue，进入下一次心跳
  }
}
```

**代码要点**：
- **不可变 vs 可变分离**（①②）：`params` 在循环中永不改变，`state` 在每轮 continue 时整体替换（而非逐字段赋值）——源码注释说"Continue sites write `state = { ... }` instead of 9 separate assignments"，这避免了忘记更新某个字段的 bug
- **`yield` 是传送带出口**（⑤）：每个 `yield` 把事件推给调用者（QueryEngine → React UI），实现了 AsyncGenerator 的"边做边送"
- **五层压缩在 API 调用之前**（④）：每次心跳开始时先"净化血液"，确保不会超出 context window

---

## 6. 调用 API：一次"系统调用"的完整旅程

`callModel()` 是 queryLoop 向外界发出的唯一"系统调用"——请求 Anthropic API。它做的事情比你想象的多：

### 6.1 请求构建

```
消息历史（messages array）
  + 系统提示词（system prompt）
  + 工具 Schema（tools array，每个工具的 JSON Schema）
  + 模型参数（model, max_tokens, temperature=1）
  + 缓存控制标记（cache_control breakpoints）
```

### 6.2 缓存边界优化

Anthropic API 有一个关键特性：**Prompt Cache**。如果两次请求的前缀完全相同，重复部分不需要重新处理。根据 Anthropic 官方 Prompt Caching 文档（https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching）：缓存读取（cache read）按基础价格的 **10%** 计费，延迟可显著降低（官方博文披露量级在 ~85% 左右，具体数字以官方公告为准）；首次写入缓存（cache write）按基础价格约 **125%** 计费（对应常见 1.25× 系数）。因此只有在前缀被多次复用时才能真正省钱——这在 Claude Code 的多轮对话场景中几乎总是成立的。

> 📚 **课程关联**：这和计算机网络课中 **HTTP 缓存**（ETag / Cache-Control）的思路完全一致——标记哪些内容没有变化，只传输增量部分。也类似操作系统中的**页面缓存**（page cache）：频繁读取的页面常驻内存，避免重复磁盘 I/O。

系统按照 Anthropic 最佳实践放置 `cache_control` 断点（Claude Code 作为 Anthropic 自家产品，自然会充分利用自家 API 特性）：
- 系统提示词的末尾（最稳定的部分，几乎每次都能命中）
- 工具 Schema 的末尾（工具列表在会话中很少变化）
- 最近消息之前（历史消息不变，只有新消息在变）

**比喻**：想象你每天给同一个人写信。信的开头（"亲爱的张三，我是李四"）每次都一样。如果邮局记住了这个开头，你只需要寄新写的部分，开头"自动附上"。Prompt Cache 就是这个"邮局的记忆"。

### 6.3 Temperature=1 的设计决策

源码中一个令人意外的细节：Claude Code 主循环的 temperature 固定为 **1**（`claude.ts:1694`：`options.temperatureOverride ?? 1`）。

> 💡 **通俗理解**：Temperature 就像**厨师的创意自由度**——temperature=0 是严格按食谱做（每次做出一样的菜），temperature=1 是允许厨师自由发挥（加点香料、换个摆盘）。你可能以为 AI 编程助手应该 temperature=0（追求确定性），但 Claude Code 选了 1。

**为什么不用 temperature=0？** 三个原因：

1. **API 限制**：Anthropic API 在启用 extended thinking 时**要求** temperature=1（源码注释："the API requires temperature: 1 when thinking is enabled, which is already the default"）。Claude Code 默认启用 thinking，所以这个值是被 API 强制的
2. **多样性需求**：编程任务不是数学求解——"帮我写一个 README" 有无数合理写法。temperature=1 让 AI 在合理范围内提供多样化的回答，避免每次生成完全相同的代码
3. **工具调用的确定性不靠 temperature**：真正需要确定性的不是生成文本，而是工具调用决策（该读哪个文件、该编辑哪一行）。这些决策的可靠性来自系统提示词的精确指令和工具 Schema 的约束，而非低 temperature

**有趣的对比**：系统中**不是所有地方**都用 temperature=1。Auto 模式的权限分类器（yoloClassifier）使用 **temperature=0**（`yoloClassifier.ts:784`），因为权限判断需要最大确定性——"这个 Bash 命令是否危险"不应该有创意发挥的空间。

| 场景 | Temperature | 原因 |
|------|-------------|------|
| 主循环（queryLoop） | 1 | API 要求 + 多样性需求 |
| 权限分类器（yoloClassifier） | 0 | 安全判断需要最大确定性 |
| Hook 评估（apiQueryHookHelper） | 0 | 结构化判断不需要随机性 |
| Skill 改进（skillImprovement） | 0 | 结构化输出需要一致性 |

> 📚 **课程关联**：Temperature 对应机器学习中 **softmax 温度缩放**的概念——T→0 时概率分布趋向 one-hot（最大概率 token 被选中），T→∞ 时趋向均匀分布（所有 token 等概率）。T=1 是不缩放的原始分布。

### 6.4 流式消费

API 返回的不是一整段完整回答，而是**像打字机一样一个字一个字发过来**。想象你在微信上看对方打字——"我" → "我来" → "我来帮" → "我来帮你"——每一小段就是一个"chunk"。queryLoop 用 `for await` 逐块接收并处理。

数据层面，每个 chunk 是一个 JSON 对象（下面的示例开发者可以深入看，非技术读者只需理解"一块块到达"即可）：

```
chunk 1: "开始一段文字"         → UI 准备显示区域
chunk 2: 文字内容 "我来"        → UI 立即显示 "我来"
chunk 3: 文字内容 "帮你"        → UI 追加显示 "帮你"
  ...
chunk N: "开始一个工具调用: Read" → StreamingToolExecutor 捕获，准备执行
chunk N+1: 工具参数 '{"file'     → 参数还没传完，继续拼接...
chunk N+2: 工具参数 '_path":"README.md"}' → 参数完整！立即开始执行 Read
  ...
chunk M: "消息结束"              → 本次 API 调用完成
```

text 块实时显示给用户。tool_use 块被 StreamingToolExecutor 捕获——当工具参数的 JSON 拼接完整后（即 `partial_json` 积累成有效 JSON），立即开始执行，不等 AI 说完。这就是"边说话边干活"的技术实现。

**partial JSON 解析机制**：AI 流式输出 tool_use 参数时，JSON 是逐字符到达的（`{"fil` → `{"file_p` → `{"file_path": "/src/main.tsx"}`）。StreamingToolExecutor 在每次收到新 chunk 时尝试将已积累的 partial_json 解析为完整 JSON。一旦解析成功（`JSON.parse` 不抛错），就认为参数完整，立即启动工具执行。这意味着：如果 AI 在一次响应中调用多个工具，第一个工具的参数完整后就开始执行，不等第二个工具的参数输出完毕。

---

## 7. 一个完整的心跳示例

让我们跟踪一个具体场景——用户说"帮我读一下 README.md 然后加一行"：

```
心跳 1：
  [压缩] — 对话刚开始，没什么可压缩的
  [调用API] — 发送用户消息 + 系统提示词
  [流式响应] — AI 说："好的，我先来读一下文件"
                AI 调用 tool_use: Read(file_path="README.md")
                → StreamingToolExecutor 立即开始读文件
  [工具执行] — Read 工具返回文件内容
  [判断] — 有工具调用，continue

心跳 2：
  [压缩] — 消息不多，跳过
  [调用API] — 发送更新后的消息历史（包含 Read 的结果）
  [流式响应] — AI 说："文件内容是...我现在来编辑它"
                AI 调用 tool_use: Edit(file_path="README.md", ...)
  [工具执行] — 权限检查 → 弹出 UI 确认 → 用户批准 → 执行 Edit
  [判断] — 有工具调用，continue

心跳 3：
  [压缩] — 仍然不多
  [调用API] — 发送更新后的消息历史（包含 Edit 的结果）
  [流式响应] — AI 说："已经完成了修改，新增了一行..."
  [工具执行] — 没有工具调用
  [判断] — 没有工具调用，return { reason: 'stop' }

→ 循环结束。三次心跳完成一个任务。
→ 后台启动：SessionMemory 提取、文件快照、Prompt Suggestion
```

---

## 8. 设计取舍与竞品对比

### 做得好的决策

1. **五层渐进式压缩**是真正有价值的工程设计——99% 的情况只需要前三层，避免了不必要的重压缩开销。**对比 Aider**：Aider 采用完全不同的策略——通过 "repo map" 一开始就只传必要内容（源码中最相关的函数签名和类名），而非事后压缩。两种策略的 trade-off 是：Claude Code 的方式保留了完整上下文（更灵活但更贵），Aider 的方式节约 token 但可能遗漏相关代码
2. **StreamingToolExecutor 的并发安全分类**——精细区分 Read（可并行）和 Edit（必须独占），而不是简单的全串行或全并行。这个分类标准虽然简单，但在实践中覆盖了绝大多数场景
3. **AsyncGenerator 选择**匹配需求——中间状态可观察、可中断、支持背压。这是 Node.js 生态的常规选择（非独创），但在 Agent 场景下确实比 Promise 或 EventEmitter 更合适
4. **权限检查嵌入循环内部**——每个工具调用独立过审，不存在"批量绕过"的可能

### 代价与局限

1. **AsyncGenerator 调试困难**——堆栈跟踪在 generator 边界中断、异步错误传播路径复杂。这是选择 generator 模式的固有代价
2. **StreamingToolExecutor 的并发安全依赖人工标记**——`isConcurrencySafe` 是工具作者手动设置的布尔值，没有静态分析或运行时检测来验证标记的正确性。如果一个工具被错误标记为安全，可能导致并发冲突
3. **五层压缩的层间耦合**——第三层（microcompact，消除冗余）在本轮心跳中修改了消息内容，但第二层（snipCompactIfNeeded）在**下一轮**心跳中可能基于已修改的内容做出不同的判断。举个具体例子：第三层删掉了一段重复的工具结果后，第二层原本认为"这段不重要可以剪掉"的标记可能变得无效，因为被剪掉的内容已经不在了
4. **没有全局超时**——`max_turns` 限制轮次但不限制时间。如果一个 Bash 工具执行了 10 分钟的编译命令，整个心跳就卡 10 分钟。**对比 Cursor**：Cursor 为每个工具设置了独立超时（per-tool timeout），更安全但也可能误杀合法的长时间操作

### 失败恢复四层模型

查询循环的生命力不仅来自"能跑"，更来自"跌倒了能爬起来"。系统的失败恢复分为**四层递进**——每一层覆盖不同粒度的失败场景：

| 层级 | 恢复粒度 | 机制 | 典型场景 |
|------|---------|------|---------|
| **Turn 级自救** | 当前回合 | reactive compact（prompt 过长时压缩重试）、max-output-tokens 升级重试（8K→64K，最多 3 次）、recovery 消息注入 | API 返回 413 / output 截断 |
| **Task 级托管** | 当前任务 | Task 生命周期管理（前后台切换、isBackgrounded、DreamTask 后台整理） | 用户切到其他任务 |
| **Session 级续接** | 当前会话 | JSONL 持久化 + `--continue`（先 live truth 后 transcript） + File History 快照 | 进程被 kill / 笔记本合上 |
| **Remote 级断连** | 跨环境 | Bridge Pointer 恢复（4h TTL）+ worktree fanout + perpetual teardown 不关 transport | 网络断线 / 远端 CCR 重启 |

> 💡 **通俗理解**：四层恢复就像赛车的安全系统——安全带（Turn 级，小颠簸自救）→ 安全气囊（Task 级，撞了还能继续比赛）→ 安全车架（Session 级，翻车后车还能修）→ 紧急救援直升机（Remote 级，车报废了人还能运走继续比赛）。每层覆盖更严重的失败，但启动成本也更高。

### 架构替代方案分析

| 决策 | Claude Code 的选择 | 替代方案 | 为什么没选 |
|------|-------------------|---------|-----------|
| 循环模式 | `while(true)` 隐式状态流 | 显式状态机（THINKING→TOOL_CALLING→WAITING） | 状态机更可审计/可持久化（断点续跑），但 Claude Code 的循环体足够简单，不需要这个复杂度 |
| 事件流 | AsyncGenerator + `for await` | RxJS Observable | RxJS 功能更强（组合操作符），但引入外部依赖且学习曲线陡峭 |
| 上下文管理 | 五层事后压缩 | Aider 式预过滤（repo map） | 预过滤省 token 但可能遗漏上下文；事后压缩保留完整信息但成本更高 |
| 工具执行 | 流式并行（StreamingToolExecutor） | 说完再做（wait-then-execute） | 流式并行节省时间但增加并发复杂度；大多数竞品选择简单的串行方案 |
| 工具并发度 | 按 `isConcurrencySafe` 二分 | 细粒度并发控制（如信号量/资源锁） | 二分法简单有效，覆盖了绝大多数场景；细粒度控制增加太多复杂度 |

---

> **[图表预留 2.4-A]**：心跳时序图 — 一次完整心跳的收缩期/舒张期，标注 API 调用、工具执行、压缩的时间占比
> **[图表预留 2.4-B]**：StreamingToolExecutor 并行时序对比 — 串行 vs 流式并行的时间节省
> **[图表预留 2.4-C]**：五层压缩瀑布图 — 从轻到重的触发条件和压缩率

---

## 非技术读者要点总结（TL;DR）

> **如果你只记住三件事**：
>
> 1. **Claude Code 是一条不停转的流水线**。你发一条消息，它就开始循环：问 AI → AI 说要做什么 → 做了 → 把结果告诉 AI → AI 再说要做什么……直到 AI 说"搞定了"，流水线才停。一个简单的"帮我读文件再改一行"，流水线要转三圈（读文件一圈、改文件一圈、汇报结果一圈）。
>
> 2. **它边听边干，不是等 AI 说完才动手**。就像你跟同事打电话——他还在说"你帮我看一下 README"，你已经打开文件了，不用等他把整句话说完。这让整个过程快了很多。但改文件、跑命令这种"有风险的活"必须排队一个一个来，不能同时改两个文件。
>
> 3. **对话太长时会自动"做笔记"**。AI 的记忆有上限（约 20 万 token，相当于一本 300 页的书）。快满时，系统会自动把前面的对话总结成精华摘要，腾出空间继续工作。就像会议记录员把三小时的录音浓缩成一页纸——你不会察觉，但信息还在。

---

## 代码落点

- `src/services/api/claude.ts` — `query()` 函数：封装 Anthropic API 调用，流式 token 处理、prompt cache 边界控制
- `src/query.ts` — `queryLoop()`：`while(true)` 心跳循环的主体，包含终止条件判断和压缩触发
- `src/tools.ts` — 工具注册表：所有内置工具的汇总，`isConcurrencySafe` 标记在此声明
- `src/utils/messages.ts` — 消息处理工具函数：消息创建、文本提取、助手消息定位
