# 在等待时间里藏工作

Claude Code 代码库里有一个反复出现的优化模式，我把它叫做"在等待时间里藏工作"。理解这个模式，你就理解了代码里很多看起来"多此一举"的设计。

> 💡 **通俗理解**：就像**早上起床的并行优化**——闹钟响了，你不是先穿好衣服再烧水（一件一件串行做），而是按下热水壶开关就去洗漱（并行执行），等穿好衣服出来水就开了。Claude Code 在每一个"反正要等"的时间窗口里都塞满了有用的工作。

> 🌍 **行业背景**："在等待时间里藏工作"并不是 Claude Code 发明的模式——它是计算机科学中**延迟隐藏（Latency Hiding）**这一经典技术的应用。CPU 流水线（1960 年代）、指令预取（1970 年代）、TCP 预连接（1990 年代）、浏览器 preload/prefetch（2010 年代）都是同一思想的体现。在 AI 编码助手领域，**Cursor** 用 Tab 预测 + 后台预生成来隐藏模型推理延迟；**GitHub Copilot** 在用户打字时就开始推理下一个补全；**Aider** 则采取了相反的策略——纯同步串行，不做投机执行，换取架构简洁性。Claude Code 的独特之处不在于"发现了这个模式"，而在于**将延迟隐藏系统化地应用到三个层次**（进程启动、流式工具执行、用户思考期间的投机推理），并为每个层次都设计了回滚机制。在同类开源工具的公开资料中，同时覆盖这三层的实现并不常见（严格的横向基准对比需要各家给出对等的 profiling 数据，目前缺少统一口径）。

---

## 三个层次的等待时间

### 层次 1：程序启动时的"偷跑"

> 💡 标题中的 "Pre-import I/O" 翻译过来就是"在加载代码之前先偷偷做一些读写操作"。

下面这段代码是 Claude Code 启动时最先运行的三行。你不需要理解代码语法，只需要知道：**程序一启动，还没来得及加载其他功能模块，就先偷偷发起了两个"提前读取"操作**：

```javascript
// main.tsx 的最顶部（import 之前）
profileCheckpoint('main_tsx_entry')  // 标记入口时间戳
startMdmRawRead()                    // 立即执行：启动子进程读取企业管理策略
startKeychainPrefetch()              // 立即执行：并行读取 macOS 钥匙串中的认证凭据
```

Node.js（Claude Code 运行的平台环境）加载所有模块需要约 100-200ms（因为 `import` 会像拉链条一样逐个加载所有依赖的代码文件；源码注释标注为"~135ms"，这是开发团队在特定构建环境下的内部测量值，实际耗时因硬件和模块数量而异）。这段时间一直是"浪费"的——CPU 在处理模块加载，程序逻辑还没开始。

这两个调用把这段时间利用起来：`startMdmRawRead()` 启动读取企业管理策略的子进程（macOS 上是 `plutil`，Windows 上是 `reg query`）；`startKeychainPrefetch()` 同时发起两次 macOS Keychain 读取（OAuth token 和 legacy API key），否则这两次读取会在后续 `applySafeConfigEnvironmentVariables()` 中串行执行，额外增加约 65ms 的阻塞时间（数字源自源码注释 `src/utils/secureStorage/keychainPrefetch.ts:9` "Sequential cost: ~65ms on every macOS startup"，是 Anthropic 团队 profiling 观察值）。

此外，在 `init.ts` 的初始化阶段，还有一个独立的 API 预连接优化 `preconnectAnthropicApi()`——在设置加载完成后、用户开始输入之前，提前和 Anthropic 的服务器"打个招呼"（TCP + TLS 握手——类似于打电话前先拨号等对方接通，这个建立连接的过程通常需要 100-200ms）。Claude Code 实际发行时运行在 Node.js（`package.json` 中 `engines.node >= 18`）；Node.js 的 `undici` fetch 实现自带全局 dispatcher 和 keep-alive 连接池，后续的真实 API 请求可以直接复用已建立的连接（如果宿主运行时换成 Bun，其 fetch 同样共享全局 keep-alive 连接池，此模式跨运行时均成立）。

**层次 1 的代价**：这一层几乎零风险。最坏的情况是预读取的数据没有被使用（比如用户立即退出），浪费的只是几次系统调用。预连接在代理/mTLS/Unix socket 环境下会自动跳过，避免预热错误的连接池。复杂度的增加主要体现在代码组织上——必须确保这些 side-effect 在 `import` 之前执行，需要 eslint 规则豁免和严格的文件加载顺序。

### 层次 2：模型输出期间的工具执行

```
传统路径：
  模型输出完成 → 解析 tool_use → 执行工具 → 等待结果

StreamingToolExecutor：
  模型每输出一个完整的 tool_use block → 立即开始执行工具
  模型继续输出... 工具在后台运行...
  模型停止输出 → 工具结果已就绪
```

当模型还在输出"我将要读取这三个文件，以便..."时，读取文件的 I/O 操作已经开始了。对于文件读取（几毫秒）和 Bash 命令（可能几秒），这是明显的延迟优化。

`StreamingToolExecutor` 的实现有几个关键细节：它为每个工具调用维护一个状态流转（排队等候 → 正在执行 → 执行完成 → 结果已返回），判断"完整"的依据是 Anthropic API 的 streaming 事件——当一个 `tool_use` 类型的 content block 的 JSON 输入被完整接收后（`content_block_stop` 事件），立即加入执行队列。并发安全的工具（如 Read、Glob、Grep）可以并行执行，非并发安全的工具（如 Bash）则独占执行。结果通过一个有序缓冲区按 **tool_use 在模型输出中出现的顺序** yield——即使工具 B 比工具 A 先执行完，B 的结果也会等到 A 先 yield 之后才返回给模型，保持与模型原始输出顺序一致。

**层次 2 的代价**：风险等级中等。工具执行失败时，`StreamingToolExecutor` 会通过 `siblingAbortController` 终止同批次中其他正在执行的工具子进程，避免无效工作继续运行。但如果一个 Bash 命令在执行过程中产生了副作用（如写入文件），而后续的模型输出改变了意图，这些副作用无法自动回滚——系统依赖模型在下一轮中识别并修复这种不一致。

### 层次 3：用户思考期间的投机执行

```
用户正在读取 AI 的回答、思考下一步...

与此同时，系统：
  → 生成提示建议（promptSuggestion）：预测用户最可能输入的内容
  → 以预测内容为输入，调用 runForkedAgent 提前执行整个 AI 推理循环
  → 如果用户接受建议（按 Tab）：注入预计算的结果，延迟 ≈ 0
  → 如果用户输入了别的：丢弃预测结果和 overlay 文件，正常处理
```

用户的"思考时间"（可能几秒到几十秒）被利用来做 AI 推理，最好的情况下，AI 的回答在你按下 Enter 的瞬间就已经准备好了。

**预测策略**：投机执行的预测并非猜测用户会说什么自然语言，而是由 `promptSuggestion` 模块生成一个结构化的"下一步建议"。系统基于当前对话上下文（最近一轮模型回复的内容），通过一次轻量级 API 调用生成用户最可能的下一条指令（如"继续实现剩余的测试"或"修复上面提到的类型错误"）。这个建议会显示在用户输入框中，用户可以按 Tab 接受或忽略。

**隔离机制——Overlay 文件系统**：投机执行最关键的工程难题是：如何让预执行的 Agent 读写文件，又不污染用户的实际工作区？`speculation.ts` 的解决方案是一个 copy-on-write overlay：

- 被纳入 overlay 重定向的写操作由 `speculation.ts` 中的 `WRITE_TOOLS` 常量定义，当前只包含 `Edit`、`Write`、`NotebookEdit` 三个工具，写入会被重定向到临时目录 `~/.claude/tmp/speculation/<pid>/<id>/`（MultiEdit 等其他写工具不在此列——SoT: `src/services/PromptSuggestion/speculation.ts:61` `WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])`）
- 读操作优先从 overlay 读取已修改的文件，未修改的文件直接读取原始路径
- 非只读 Bash 命令会触发"边界停止"（boundary）——投机执行到此为止，不再继续
- 如果用户接受了预测结果，overlay 中的文件被 `copyOverlayToMain` 复制到工作区；如果拒绝，整个 overlay 目录被 `safeRemoveOverlay` 删除

**执行约束**：投机执行并非不受限制地运行。它设置了 `MAX_SPECULATION_TURNS = 20` 和 `MAX_SPECULATION_MESSAGES = 100` 的上限。工具使用受到严格的权限检查——只有已获得自动批准的文件编辑操作（`acceptEdits` 或 `bypassPermissions` 模式）才能在投机执行中进行；需要用户确认的操作会使投机在该点停止，等待用户决定。

**流水线优化**：投机执行还有一个更激进的优化——当一次投机执行完成后，系统会立即通过 `generatePipelinedSuggestion` 生成*下一次*的提示建议，形成连续的投机链。如果用户接受了当前预测，新的预测已经在等待中，实现了类似 CPU 流水线的连续预执行。

> 📚 **课程关联**：层次 3 的投机执行与**计算机体系结构**课程中的 CPU 分支预测（Branch Prediction）几乎同构——预测下一条指令、提前执行、错误时回滚（pipeline flush）。区别在于：CPU 的回滚代价是几十个时钟周期，Claude Code 的回滚代价是浪费的 API token 费用和 overlay 文件的清理。此外，overlay 文件系统的 copy-on-write 策略与**操作系统**课程中进程 fork 后的 COW 页面管理是同一思想——只在实际写入时才复制，最大限度降低隔离的开销。
>
> 关于投机执行子系统的完整技术分析（包括状态机、缓存共享策略、遥测埋点的详细解读），参见 **Part 3：投机执行子系统完全解析**。

**层次 3 的代价**：这是三个层次中风险最高的。每次投机执行都消耗真实的 API token——如果预测被拒绝，这些 token 就是纯粹的浪费。目前投机执行仅对 Anthropic 内部用户（`USER_TYPE === 'ant'`）启用，这本身就说明团队对其成本效益比仍持审慎态度。此外，overlay 文件系统虽然提供了写隔离，但对于通过 Bash 命令产生的外部副作用（如网络请求、进程启动）没有回滚能力——这也是为什么非只读 Bash 命令会触发投机停止的原因。

---

## 这个模式的抽象

**公式：** 找到"必须等待 X 完成才能继续"的地方 → 在等待 X 的期间，做所有不依赖 X 结果的工作

三个条件：
1. 有一段"等待时间"（模块加载、模型输出、用户交互）
2. 等待时间内可以做的工作是确定的（知道该做什么）
3. 做错了可以回滚或隔离（层次 1 的 prefetch 结果可忽略；层次 2 的工具结果按顺序 yield、失败时终止同批次工具；层次 3 的文件修改通过 overlay 隔离、非只读操作触发边界停止）

> ⚠️ **关于性能数据的说明**：截至本书写作时（2026 年 4 月），Anthropic 尚未公开发布 Claude Code 延迟隐藏机制的性能基准数据（如投机执行命中率、端到端延迟改善比例、token 浪费率等）。本章的分析基于源码中的设计意图和实现机制，而非实测性能数据。源码中的遥测事件（如 `tengu_speculation` 记录的 `time_saved_ms`、`tools_executed` 等字段）表明团队在内部进行了系统性的性能追踪，但这些数据未向外部公开。读者如果在自己的系统中复制类似模式，需要自行建立基准测试来验证收益。

---

## 类似设计在其他领域的体现

需要说明的是，以下这些例子不是"Claude Code 借鉴了它们"——恰恰相反，**Claude Code 是在重新发现和应用这些已有数十年历史的经典模式**。理解这些历史有助于你在自己的系统中识别和应用同样的思想。

**CPU 流水线与投机执行**：
- CPU 遇到分支指令，不等分支条件确定，直接预测并执行
- 如果预测正确，无延迟；如果错误，丢弃并重执行

**数据库预取（Prefetch）**：
- 查询结果还没用到，就提前把下一页数据加载到缓冲区
- 用户翻页时数据已在内存，感觉不到延迟

**Web 应用资源预取（prefetch/preconnect）**：
- 用户鼠标悬停在链接上时，浏览器通过 `<link rel="prefetch">` 或 `<link rel="preconnect">` 提前拉取目标页面资源或完成对目标域名的 DNS/TCP/TLS 握手（严格来说这属于"预取"和"预建连接"，与同步阻塞的 `<link rel="preload">` 不是同一机制）
- 用户点击时，关键资源已在缓存或连接已就绪

**浏览器解析 HTML**：
- 浏览器发现 `<script src>` 时，不等当前 HTML 解析完，立即发起脚本请求

---

## 在你设计 AI 应用时

当你在等待大语言模型回答时（通常几秒到几十秒），你的应用可以同时做什么？

一些思路：
- 预加载用户可能需要的数据（根据模型输出的前几个 token 判断方向）
- 更新 UI 的某些部分（虽然主要回答还没完成）
- 运行不依赖回答的后台任务（日志、分析、缓存预热）
- 开始下一个预期的 API 请求（如果工作流程高度可预测）

每一个"等待时间"都是隐藏工作的机会，但需要注意几个重要的约束条件：
- **预测准确率是关键**：如果预测命中率低于一定阈值，隐藏工作的净收益为负（浪费的资源 > 节省的时间）
- **并行度不是免费的**：并行执行增加系统复杂度和调试难度，在资源受限的环境中（如移动端）可能比串行更慢
- **必须有回滚或隔离机制**：没有回滚能力的投机执行可能导致不可恢复的副作用

---

## 代码落点

- `src/main.tsx`，第 1-20 行：Pre-import 阶段——在 `import` 语句之前调用 `startMdmRawRead()`（启动 MDM 策略子进程）和 `startKeychainPrefetch()`（并行读取 Keychain 凭证）
- `src/utils/settings/mdm/rawRead.ts`：MDM 策略预读取实现——启动 `plutil`（macOS）或 `reg query`（Windows）子进程
- `src/utils/secureStorage/keychainPrefetch.ts`：Keychain 预取实现——并行发起 OAuth token 和 legacy API key 的 Keychain 读取
- `src/utils/apiPreconnect.ts`：API 预连接实现——在 `init.ts` 初始化阶段发起 HTTP HEAD 请求完成 TCP+TLS 握手（100-200ms）
- `src/services/tools/StreamingToolExecutor.ts`：流式工具执行器——管理工具的并发执行、有序缓冲和错误传播
- `src/services/PromptSuggestion/speculation.ts`：投机执行引擎——overlay 文件系统、`runForkedAgent` 调用、边界检测和结果注入
- `src/utils/forkedAgent.ts`：分叉 Agent 基础设施——创建隔离的子 Agent 上下文，共享父级 prompt cache

## 代价与权衡

"在等待时间里藏工作"并非免费午餐，三个层次各自的风险等级和代价结构完全不同：

| 层次 | 风险等级 | 失败时的代价 | 复杂度代价 |
|------|---------|-------------|-----------|
| 层次 1：Pre-import I/O | **低** | 预读取的数据未使用，浪费几次系统调用 | 代码必须在 `import` 之前执行，需要 eslint 豁免和严格的加载顺序 |
| 层次 2：流式工具执行 | **中** | 工具执行失败需终止同批次工具；有副作用的命令（如文件写入）无法自动回滚 | 需要维护有序缓冲区、并发控制、`siblingAbortController` 等并发基础设施 |
| 层次 3：投机执行 | **高** | 预测被拒绝时消耗的 API token 是纯粹浪费；非只读 Bash 的外部副作用无法回滚 | overlay 文件系统、权限检查、状态隔离、流水线建议——系统复杂度最高 |

**共同的局限**：延迟隐藏依赖对"接下来会发生什么"的正确预测。层次 1 预测的是"用户一定会需要 API key 和企业策略"——这几乎总是对的；层次 2 预测的是"模型输出的工具调用应该被执行"——这在大多数情况下是对的；层次 3 预测的是"用户会接受系统生成的下一步建议"——这取决于建议质量和用户习惯，命中率远低于前两层。预测越不确定，隐藏工作的"净收益"就越不稳定。

**关于复杂度成本**：并行度增加意味着调试难度增加。当系统行为出现异常时，需要考虑的状态空间远大于纯串行架构。这也解释了为什么 Aider 选择了纯同步串行——架构简洁性本身就有工程价值，尤其是对于更偏向批处理式使用场景的工具。
