claude code book · 第四部分:工程实践篇 · 第 14 部分

第 14 章 Plan 模式与结构化工作流

理解 Plan 模式的「先想后做」哲学,掌握计划文件的三层恢复策略

第14章:Plan 模式与结构化工作流

“Plans are nothing; planning is everything.” — Dwight D. Eisenhower

学习目标: 理解 Claude Code 的规划模式(Plan Mode)和工作流编排系统,掌握 EnterPlanMode/ExitPlanMode 的模式切换机制,了解计划文件的存储与恢复策略,以及调度系统(Cron、RemoteTrigger)和后台任务管理的实现细节。通过完整的实战案例,你将学会如何利用 Plan 模式来提升 Agent 任务的成功率和可控性。


14.1 Plan 模式的架构

14.1.1 设计哲学:先规划后执行

Plan 模式是 Claude Code 中最独特的设计之一。它将 Agent 的行为分为两个阶段:只读探索阶段(规划)和可写执行阶段(实施)。这种分离的核心理念是:在动手之前先对齐意图,避免方向性错误导致的返工。

这种设计理念可以用建筑行业来类比:没有建筑师会拿到需求就直接开始砌墙。他们会先画图纸(规划),与甲方确认设计意图,评估结构可行性,然后才动工(执行)。在规划阶段,建筑师可以无成本地修改方案 — 改一张图纸比拆一面墙便宜得多。同样,在 Plan 模式中,Agent 的探索和思考不产生任何副作用(不修改文件、不执行命令),纠正”方向性错误”的代价几乎为零。

Plan 模式解决的核心问题是 “过早行动”(Premature Action)。没有 Plan 模式的 Agent 面临一个困境:面对复杂任务时,要么在第一轮就盲目动手(高风险),要么在每一轮都反复阅读代码但不做决定(低效)。Plan 模式为 Agent 提供了一个”合法的思考空间” — 在这个空间里,Agent 可以自由探索而不被期望产出实际结果,直到它确信自己理解了问题的全貌。

EnterPlanModeTool 是进入规划模式的入口。它的 prompt 系统针对不同用户类型(外部用户 vs 内部 ant 用户)提供了不同的指导策略。

对外部用户,系统倾向于鼓励使用 Plan 模式,提示模型优先使用 Plan 模式来处理实现任务。对内部 ant 用户,系统更加节制,建议在有疑问时直接开始工作并通过提问澄清,而非进入完整的规划阶段。

这种差异化策略反映了不同的使用场景:外部用户更看重安全性和对齐,而内部用户更看重效率和流畅度。

如果没有 Plan 模式会怎样? 我们可以想象几个典型场景:

场景无 Plan 模式有 Plan 模式
误解需求直接实现了错误功能,需要回滚在只读阶段发现误解,零成本修正
忽略已有模式写出与项目风格不一致的代码先探索项目发现既有模式,再实施
方案选择失误实现了性能差的方案,需要重写对比多个方案的 trade-off 后再动手
遗漏边界情况代码写完才发现遗漏,返工在规划中枚举边界情况并纳入方案

14.1.2 模式切换机制

当用户批准进入 Plan 模式后,EnterPlanModeTool 的 call 方法执行模式切换,核心逻辑包括:检查是否在 Agent 上下文中(如果是则抛出错误),调用权限过渡处理函数切换到 plan 模式,以及通过状态更新函数将权限上下文切换为 plan 模式配置。

flowchart TD
    START["用户请求进入 Plan 模式"] --> Q1{"是否在 Agent 上下文中?"}

    Q1 -->|"是"| ERR["抛出错误<br/>子 Agent 禁止进入 Plan 模式"]
    Q1 -->|"否"| SAVE["保存当前模式到 prePlanMode"]
    SAVE --> PREPARE["prepareContextForPlanMode<br/>运行 classifier activation"]
    PREPARE --> SWITCH["切换权限上下文为 plan 模式"]
    SWITCH --> GUIDE["返回 Plan 模式行为指令"]

    subgraph "Plan 模式行为指令 (6 步)"
        S1["1. 探索代码库理解模式"]
        S2["2. 识别相似特性和架构"]
        S3["3. 考虑多种方案及权衡"]
        S4["4. 需要时使用 AskUserQuestion"]
        S5["5. 设计具体实施策略"]
        S6["6. 使用 ExitPlanMode 呈现计划"]
        S1 --> S2 --> S3 --> S4 --> S5 --> S6
    end

    GUIDE --> S1

    classDef decisionNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef errorNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px
    classDef processNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef stepNode fill:#d4edda,stroke:#27ae60,stroke-width:1px

    class Q1 decisionNode
    class ERR errorNode
    class SAVE,PREPARE,SWITCH,GUIDE processNode
    class S1,S2,S3,S4,S5,S6 stepNode

关键点:

  • Agent 上下文中禁止进入 Plan 模式:子 Agent 不应该进入规划模式,这是架构层面的约束。想象一下,如果一个子 Agent 进入 Plan 模式,它会等待用户审批计划,而用户可能根本不知道子 Agent 的存在。这会导致整个父 Agent 的执行被阻塞在一个用户无法理解的审批请求上。
  • prepareContextForPlanMode:运行 classifier activation 的副作用,确保在 plan 模式下权限配置正确。
  • prePlanMode 保存:原始模式被保存到 prePlanMode 字段,供 ExitPlanMode 恢复使用。

进入 Plan 模式后,Agent 收到的 tool_result 包含明确的行为指令:

In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval

Remember: DO NOT write or edit any files yet.
This is a read-only exploration and planning phase.

这六条指令的设计暗含了一个认知模型:理解 -> 发现 -> 比较 -> 澄清 -> 设计 -> 呈现。这不是随意的列举,而是一个从发散(广泛探索)到收敛(具体方案)的思维过程。Step 1-2 是发散阶段,Agent 尽可能广泛地收集信息;Step 3-4 是收敛的过渡阶段,Agent 开始聚焦但仍然开放;Step 5-6 是完全收敛,Agent 产出一个确定的实施方案。

14.1.3 退出 Plan 模式

ExitPlanModeV2Tool 负责从 Plan 模式退出并恢复原始权限模式。它的设计比 EnterPlanMode 复杂得多,因为需要处理多种场景。

模式恢复是核心逻辑。系统从 prePlanMode 中读取保存的原始模式并恢复。但存在一个关键的”断路器”防御:如果 prePlanModeauto,但 auto mode 的 gate 当前关闭(如因 circuit breaker 触发),则回退到 default 模式。

flowchart TD
    START["ExitPlanMode 触发"] --> READ["读取 prePlanMode 保存值"]
    READ --> Q1{"prePlanMode 值?"}

    Q1 -->|"default"| RESTORE["恢复为 default 模式"]
    Q1 -->|"auto"| Q2{"auto mode gate 是否开启?"}

    Q2 -->|"开启"| RESTORE_AUTO["恢复为 auto 模式"]
    Q2 -->|"关闭 (断路器触发)"| FALLBACK["回退为 default 模式<br/>防止绕过安全限制"]

    RESTORE --> CHECK{"是否为 teammate 路径?"}
    RESTORE_AUTO --> CHECK
    FALLBACK --> CHECK

    CHECK -->|"否"| LOCAL["弹出本地审批 UI"]
    CHECK -->|"是"| MAILBOX["通过 mailbox 发送给 team lead 审批"]

    classDef decisionNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef processNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef warnNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px
    classDef endNode fill:#d4edda,stroke:#27ae60,stroke-width:1px

    class Q1,Q2,CHECK decisionNode
    class READ,RESTORE,RESTORE_AUTO processNode
    class FALLBACK warnNode
    class LOCAL,MAILBOX endNode

这个断路器防御揭示了一个微妙的时间窗口问题:进入 Plan 模式时 auto mode 是开启的,但在 Plan 模式持续期间(可能几分钟甚至几十分钟),auto mode 可能因为外部原因(如系统负载、安全策略变更)被关闭。如果 ExitPlanMode 不检查这个条件,Agent 退出 Plan 模式后立即以 auto mode 权限执行操作,可能绕过了新激活的安全限制。

Teammate 路径:当 Agent 作为 teammate 运行且 Plan 模式是强制要求时,ExitPlanMode 不会弹出本地审批 UI,而是通过 mailbox 机制将计划发送给 team lead 审批。审批请求包含发送者、时间戳、计划文件路径、计划内容和请求 ID 等信息。

交叉引用: Teammate 的 mailbox 机制在第 10 章”协调器模式 — 多智能体编排”中有详细分析。Plan 模式的 teammate 路径是分布式 Agent 协作的一个典型案例 — 审批决策的权力被委托给 team lead,而非本地用户。

14.1.4 Plan 模式完整案例:从需求到计划到实施

让我们通过一个完整的案例来展示 Plan 模式的实际运作过程。

用户需求: “为项目的 REST API 添加分页支持”

Step 1: 进入 Plan 模式

Agent 判断这是一个涉及多个文件修改的非平凡任务,主动触发 EnterPlanMode。系统保存当前模式为 default,切换到 plan 模式权限配置。

Step 2: 只读探索(Plan 模式中)

Agent 执行以下只读操作:

工具调用序列(全部为只读):
1. Glob("src/routes/*.ts")           -- 发现所有路由文件
2. Glob("src/models/*.ts")           -- 发现所有模型文件
3. Grep("limit|offset|page|cursor")  -- 搜索已有的分页实现
4. Read("src/routes/users.ts")       -- 阅读典型路由实现
5. Read("src/middleware/validate.ts") -- 了解验证中间件模式
6. Grep("interface.*Response")       -- 搜索响应类型定义

Agent 发现:

  • 项目使用 Express + TypeScript
  • 已有 12 个路由文件,都没有分页支持
  • 有一个通用的 ApiResponse 类型
  • 验证中间件使用 Zod schema
  • 数据库层使用 Prisma ORM

Step 3: 方案设计与权衡分析

Agent 在内部考虑了两种方案:

维度Offset 分页Cursor 分页
实现复杂度
大数据集性能差(OFFSET 扫描)优(索引查找)
数据一致性中(插入/删除导致偏移)优(基于游标)
客户端兼容性好(页码直觉)需要适配
与 Prisma 集成简单(skip/take)需要 cursor 编码

Step 4: 呈现计划

Agent 通过 ExitPlanMode 呈现以下计划:

### 分页功能实施计划

#### 方案选择:混合模式
- 默认使用 offset 分页(简单、兼容性好)
- 为高频接口提供 cursor 分页选项

#### 实施步骤
1. 创建 src/types/pagination.ts -- 分页类型定义
2. 创建 src/middleware/pagination.ts -- 分页参数解析中间件
3. 修改 src/routes/users.ts -- 作为第一个实现样例
4. 添加 Zod schema 验证 -- limit (1-100), offset (>=0)
5. 更新 ApiResponse 类型 -- 添加分页元数据

#### 预计影响
- 新增文件:2 个
- 修改文件:3 个
- 风险:低(纯增量,不修改已有功能)

Step 5: 审批与执行

用户审批计划后,ExitPlanMode 恢复原始模式,Agent 进入执行阶段,按照计划逐步实施。

注意: 在实际使用中,Plan 模式的深度应该与任务复杂度匹配。对于简单的单文件修改,直接执行可能比进入 Plan 模式更高效。Claude Code 的 prompt 设计已经内置了这种判断 — 对于外部用户,系统只在”实现任务”时建议 Plan 模式,对于简单的查询和修改则直接处理。


14.2 计划验证机制

14.2.1 验证智能体

registerPlanVerificationHookExitPlanModeV2Tool 中被引用,其注释揭示了一个重要设计决策:验证钩子必须在 context clear 之后注册,因为 context clear 会清除所有 hooks。

验证钩子必须在 context clear 之后注册,因为 context clear 会清除所有 hooks。这意味着验证智能体运行在”清除上下文并开始实施”之后的阶段,作为一个独立的后台 Agent 来检验实施结果是否符合计划。

验证智能体的设计理念。 为什么需要一个独立的验证智能体,而不是让执行智能体自己检查?这涉及”自我审查”的认知偏差 — 执行者倾向于用自己的理解来评判自己的工作,而非严格按照计划原文对照。独立的验证智能体持有原始计划文件的快照,能够以”旁观者”的视角审查实施结果。

这就像软件工程中的 code review:让作者自己 review 自己的代码,效果远不如让另一个工程师来做。验证智能体扮演的就是这个”另一个工程师”的角色。

验证流程的时序:

sequenceDiagram
    participant Agent as 执行智能体
    participant Exit as ExitPlanMode
    participant Hook as 验证钩子
    participant Verify as 验证智能体
    participant Plan as 计划文件

    Agent->>Exit: 1. 退出 Plan 模式
    Exit->>Plan: 2. 保存计划文件
    Exit->>Exit: 3. 清除上下文
    Exit->>Hook: 4. 注册验证钩子
    Note over Hook: context clear 之后注册<br/>否则会被清除
    Agent->>Agent: 5. 执行阶段 -- 按计划逐步实施
    Agent->>Hook: 6. 执行完成,钩子触发
    Hook->>Verify: 7. 启动验证智能体
    Verify->>Plan: 8. 读取计划文件
    Verify->>Verify: 9. 逐项对照实施结果
    Verify-->>Agent: 10. 输出验证报告<br/>合规项 / 偏差项 / 遗漏项

14.2.2 计划文件的持久化与恢复

计划文件管理是 Plan 模式的基础设施,由专门的工具函数模块提供。这部分设计的核心挑战是:计划不能丢失。 用户花了时间审批计划,Agent 花了 token 生成计划,如果因为会话崩溃或意外中断导致计划丢失,所有投入都白费了。

文件路径生成采用 slug-based 命名策略。主会话使用简单的 {slug}.md 格式,子 Agent 使用 {slug}-agent-{agentId}.md 格式,避免文件冲突。

Slug 生成是惰性的:首次访问时生成,后续从缓存读取。如果生成的 slug 对应的文件已存在,最多重试 10 次。

恢复机制是多层次的。copyPlanForResume 在会话恢复时从三个来源尝试恢复计划:

  1. 直接读取计划文件:最简单的路径,如果文件存在直接返回。
  2. 文件快照恢复findFileSnapshotEntry):从 transcript 中的 file_snapshot 系统消息恢复,这在远程会话(CCR)中尤其重要,因为本地文件不会在会话之间持久化。
  3. 消息历史恢复recoverPlanFromMessages):从三种消息格式中提取计划内容:
    • ExitPlanMode 的 tool_use input(normalizeToolInput 注入的计划内容)
    • User message 的 planContent 字段(clear context 流程中设置)
    • Attachment message 的 plan_file_reference(auto-compact 时保留计划)

这三层恢复策略可以用”鸡蛋不能放在一个篮子里”来理解。每一层覆盖不同的故障场景:

flowchart TD
    START["计划文件恢复启动"] --> L1{"本地文件是否存在?"}

    L1 -->|是| R1["直接读取计划文件<br/>覆盖:本地文件正常,会话重启"]
    L1 -->|否| L2{"文件快照是否存在?"}

    L2 -->|是| R2["文件快照恢复 (findFileSnapshotEntry)<br/>覆盖:远程会话,本地文件未持久化"]
    L2 -->|否| L3{"消息历史是否包含计划?"}

    L3 -->|tool_use input| R3A["恢复来源一:ExitPlanMode tool_use input<br/>覆盖:API 协议保证,最可靠"]
    L3 -->|planContent 字段| R3B["恢复来源二:User message planContent<br/>覆盖:clear context 后保留"]
    L3 -->|plan_file_reference| R3C["恢复来源三:Attachment message<br/>覆盖:auto-compact 后保留"]

    L3 -->|全部丢失| FAIL["无法恢复(需要重新规划)"]

    R1 --> OK["恢复成功"]
    R2 --> OK
    R3A --> OK
    R3B --> OK
    R3C --> OK

    classDef layerNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef resultNode fill:#d4edda,stroke:#27ae60,stroke-width:1px
    classDef failNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px
    classDef decisionNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px

    class L1,L2,L3 decisionNode
    class R1,R2,R3A,R3B,R3C layerNode
    class OK resultNode
    class FAIL failNode

Fork 恢复使用完全不同的策略。copyPlanForFork 为 fork 的会话生成一个新的 slug,并将原始计划文件复制到新路径。这种设计防止了原始会话和 fork 会话互相覆盖对方的计划文件。

为什么 fork 不能直接共享原始计划文件?因为 fork 之后,两个会话可能走上完全不同的执行路径。如果 fork 对计划进行了修改(如根据实施反馈调整方案),直接修改原始文件会影响父会话。通过复制文件到新路径,fork 拥有了独立的计划副本,互不干扰。

交叉引用: Fork Agent 的创建和状态隔离机制在第 9 章”子智能体与 Fork 模式”中有详细分析。计划文件的 fork 策略是 Agent 状态隔离的一个具体体现。

14.2.3 计划文件恢复的深度分析

让我们更深入地分析 recoverPlanFromMessages 的三种恢复来源,理解每种来源的设计意图和适用场景。

来源一:ExitPlanMode 的 tool_use input。 当 Agent 退出 Plan 模式时,ExitPlanMode 工具的输入参数中包含了计划内容。这个内容被 normalizeToolInput 注入到工具调用的 input 中。恢复时,系统从消息历史中找到 ExitPlanMode 的 tool_use 消息,提取其中的计划内容。这是最可靠的恢复来源,因为 tool_use 消息是 API 协议的一部分,不会被普通的上下文管理操作修改。

来源二:User message 的 planContent 字段。 在 clear context 流程中(即清除上下文重新开始实施时),系统会将计划内容设置到一个特殊的 planContent 字段。这个字段的目的是在上下文清除后保留计划的”种子”,使得新开始的执行阶段仍然知道要做什么。

来源三:Attachment message 的 plan_file_reference。 auto-compact 是一种自动压缩上下文的机制(详见第 7 章)。在压缩过程中,系统识别到计划文件的重要性,会在 attachment 消息中保留对计划文件的引用。这确保了即使经过自动压缩,计划文件仍然可以通过引用被定位和读取。

三种来源的优先级设计体现了”可靠性优先”的原则:tool_use input 最可靠(API 协议保证),planContent 次之(内部机制保证),plan_file_reference 最弱(依赖 auto-compact 的正确识别)。恢复逻辑按此优先级依次尝试,第一个成功的来源即为最终结果。


14.3 工作流系统

14.3.1 WorkflowTool 与 Skill 系统

Claude Code 的工作流能力主要由 Skill 系统提供。Skill 本质上是预定义的 prompt 模板,可以通过 slash command 触发。Skill 执行的核心准备函数处理三件事:

  1. Prompt 替换:将 $ARGUMENTS 占位符替换为用户提供的参数。
  2. 权限扩展:通过 createGetAppStateWithAllowedTools 为 fork 的执行上下文添加允许的工具列表。
  3. Agent 选择:优先使用 command 指定的 agent type,否则回退到 general-purpose agent。

Skill 系统的设计权衡。 为什么选择 prompt 模板而非代码级别的插件?这是”灵活性 vs 可靠性”的经典权衡。代码级插件可以提供更强的功能和更好的类型安全,但它们的开发和维护成本更高,需要开发者深入理解系统的内部 API。Prompt 模板则允许任何人用自然语言定义工作流,大幅降低了扩展门槛。

+-------------------+---------------------------+---------------------------+
| 扩展方式          | 灵活性                    | 门槛                      |
+-------------------+---------------------------+---------------------------+
| Prompt 模板       | 中等(受限于 prompt 表达力)| 低(自然语言)             |
| (Skill 系统)      |                           |                           |
+-------------------+---------------------------+---------------------------+
| 钩子命令          | 高(任意 Shell 命令)      | 中(需要编程能力)         |
| (Hook 系统)       |                           |                           |
+-------------------+---------------------------+---------------------------+
| MCP 服务器        | 极高(独立进程,任意语言) | 高(需要实现协议)         |
| (外部工具)        |                           |                           |
+-------------------+---------------------------+---------------------------+
flowchart LR
    subgraph 扩展性阶梯
        direction TB
        SKILL["Skill 系统<br/>Prompt 模板<br/>门槛: 低 | 灵活性: 中"]
        HOOK["Hook 系统<br/>Shell 命令<br/>门槛: 中 | 灵活性: 高"]
        MCP["MCP 服务器<br/>独立进程<br/>门槛: 高 | 灵活性: 极高"]
    end

    SKILL -->|"复杂度增加"| HOOK
    HOOK -->|"复杂度增加"| MCP

    classDef skillNode fill:#d4edda,stroke:#27ae60,stroke-width:1px
    classDef hookNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef mcpNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px

    class SKILL skillNode
    class HOOK hookNode
    class MCP mcpNode

Claude Code 的扩展性设计遵循”阶梯式扩展”原则:从最简单的 prompt 模板开始,随着需求复杂度的增加,逐步使用更强大的扩展机制。大多数用户只需要 Skill 系统,高级用户使用钩子命令,企业级用户通过 MCP 集成外部工具。

交叉引用: Skill 系统的完整架构在第 11 章”技能系统与插件架构”中有详细分析。Hook 系统在第 8 章”钩子系统 — Agent 的生命周期扩展点”中讨论。MCP 集成在第 12 章”MCP 集成与外部协议”中介绍。

14.3.2 Fork Agent 的状态隔离

createSubagentContext 创建了一个完全隔离的 ToolUseContext。默认情况下,所有可变状态都被隔离:

flowchart TD
    PARENT["父 Agent 上下文"] --> FORK["createSubagentContext"]

    FORK --> R["readFileState<br/>从父上下文克隆"]
    FORK --> A["abortController<br/>创建子控制器,链接父控制器"]
    FORK --> S["setAppState<br/>默认 no-op (除非 shareSetAppState)"]
    FORK --> U["UI callbacks<br/>全部为 undefined"]

    subgraph 隔离边界
        R
        A
        S
        U
    end

    U -.->|"安全设计"| NO_UI["子 Agent 不能控制父 UI<br/>不能弹通知、不能修改 UI"]
    A -.->|"中止传播"| PROP["父中止 → 子中止<br/>子中止 ≠ 父中止"]

    R --> CHILD["子 Agent 沙箱"]
    A --> CHILD
    S --> CHILD
    U --> CHILD

    CHILD -->|"唯一通信通道"| RESULT["tool_result 消息<br/>返回给父 Agent"]

    classDef parentNode fill:#e8f4f8,stroke:#2980b9,stroke-width:2px
    classDef forkNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef childNode fill:#d4edda,stroke:#27ae60,stroke-width:2px
    classDef resultNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px

    class PARENT parentNode
    class FORK,R,A,S,U forkNode
    class CHILD childNode
    class RESULT resultNode
  • readFileState:从父上下文克隆。
  • abortController:创建子控制器,链接到父控制器(父中止传播到子)。
  • setAppState:默认为 no-op,除非显式选择共享(shareSetAppState)。
  • UI callbacks(addNotification、setToolJSX 等):全部为 undefined,子 Agent 不能控制父 UI。

为什么子 Agent 不能控制父 UI? 这是一个关键的安全设计。如果子 Agent 可以弹出通知或修改 UI,用户可能会被来自多个子 Agent 的通知淹没,无法区分主 Agent 和子 Agent 的输出。更严重的是,恶意的工具输出(通过 prompt injection)可能利用子 Agent 的 UI 能力进行钓鱼攻击 — 例如弹出一个看似合法的确认对话框。

Fork Agent 的状态隔离策略可以用”沙箱”来类比:子 Agent 在沙箱中运行,可以看到外部世界(通过克隆的文件状态),但不能影响外部世界(不能修改父 Agent 状态,不能控制 UI)。唯一与外部世界的通信通道是工具结果 — 子 Agent 的执行结果通过结构化的 tool_result 消息返回给父 Agent。

最佳实践: 在设计子 Agent 时,遵循”最小权限原则”。只传递子 Agent 完成任务所需的最小上下文,不要将整个父 Agent 的状态无差别地传递。这不仅能提高安全性,还能减少 token 消耗(子 Agent 的输入 token 也计入成本)。


14.4 调度系统

14.4.1 ScheduleCronTool:本地定时任务

Claude Code 的调度系统支持两类定时任务:

  • One-shotrecurring: false):触发一次后自动删除。
  • Recurringrecurring: true):按计划重复触发,从当前时间重新调度。

任务存储在 <project>/.claude/scheduled_tasks.json 中,文件格式为:

{
  "tasks": [
    {
      "id": "a1b2c3d4",
      "cron": "0 * * * *",
      "prompt": "check the deploy status",
      "createdAt": 1710000000000,
      "recurring": true
    }
  ]
}

每个 CronTask 包含一个 durable 运行时标志。durable: false 的任务仅存在于进程内存中,会话结束即消失。写入磁盘的任务会剥离这个标志。

durable 标志的设计意图。 为什么不将所有任务都持久化到磁盘?考虑这些场景:

  • 用户创建了一个一次性任务”5 分钟后检查部署状态”,这是一个短期任务,会话结束后如果还没有触发,就不再有意义了。
  • 用户在调试时创建了临时轮询任务”每分钟检查日志”,这个任务只服务于当前调试会话。
  • 将这些临时任务持久化到磁盘不仅浪费存储,还可能导致后续会话意外触发已经过时的任务。

因此,durable 标志提供了一种”轻量级任务”机制:需要跨会话持久化的任务设为 durable: true,仅服务于当前会话的任务设为 durable: false

14.4.2 CronScheduler:调度器核心

createCronScheduler 实现了完整的调度器,包含以下关键特性:

文件锁:通过 tryAcquireSchedulerLock 确保同一个项目目录下的多个 Claude 会话不会重复触发同一个 on-disk 任务。非 owner 会话每 5 秒探测一次锁,如果 owner 崩溃则接管。

这种文件锁机制解决了一个实际的分布式问题:开发者可能同时打开多个终端窗口,每个窗口运行一个 Claude Code 实例。如果没有文件锁,同一个定时任务会被每个实例各触发一次,导致重复操作(如重复发送通知、重复执行部署检查)。

文件锁的实现基于文件系统的原子操作(如 O_EXCL 创建),这在所有主流操作系统上都是可靠的。锁的超时和接管机制确保了 owner 崩溃时不会出现死锁 — 其他实例会在合理时间内检测到并接管。

Jitter 机制:为避免大量会话在同一时刻触发推理请求(惊群效应),recurring 任务添加了确定性的正向延迟。延迟与两次触发之间的间隔成正比,默认不超过 15 分钟。对于每小时执行的任务,实际触发时间会在 :00:06 之间随机分散。

One-shot 任务使用反向 jitter(提前触发),通过 oneShotMinuteMod 控制:默认值为 30,意味着只有 :00:30 的整点触发会添加 jitter。

**惊群效应(Thundering Herd)**是分布式系统中的经典问题。如果所有 Claude Code 实例都在整点触发定时任务,API 服务器会突然收到大量并发请求,可能导致延迟飙升甚至服务降级。Jitter 机制通过将触发时间分散在一个时间窗口内,将峰值负载平滑化为均匀负载。

flowchart LR
    subgraph 无Jitter["无 Jitter -- 惊群效应"]
        direction TB
        N1A["实例1: ----X-----------X-----------X----"]
        N1B["实例2: ----X-----------X-----------X----"]
        N1C["实例3: ----X-----------X-----------X----"]
        N1D["API:   ^^^峰值       ^^^峰值      ^^^峰值"]
    end

    subgraph 有Jitter["有 Jitter -- 负载平滑"]
        direction TB
        N2A["实例1: -----X----------X-----------X----"]
        N2B["实例2: -------X----------X---------X----"]
        N2C["实例3: ----X------------X-----------X---"]
        N2D["API:   ^平滑          ^平滑        ^平滑"]
    end

    无Jitter -.->|"添加 Jitter"| 有Jitter

    classDef peakNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px
    classDef smoothNode fill:#d4edda,stroke:#27ae60,stroke-width:1px

    class N1D peakNode
    class N2D smoothNode

错过任务检测:启动时检查是否有任务的下次触发时间已经过去,如果有则通知用户。

自动过期:Recurring 任务默认 7 天后自动过期(recurringMaxAgeMs),防止无限递归导致内存泄漏。permanent 标志的任务(如 assistant mode 的内置任务)豁免于此限制。

7 天的过期策略是一个务实的选择。如果一个 recurring 任务连续运行了 7 天而没有被任何人关注或修改,大概率它已经不再被需要了。没有过期机制的任务会像”僵尸进程”一样持续消耗资源:每次触发都会产生 API 调用、消耗 token、占用调度器内存。

14.4.3 RemoteTriggerTool:远程触发

RemoteTriggerTool 提供了远程 Agent 的触发管理能力,支持 list、get、create、update、run 五种操作。

它通过 Anthropic API 的 triggers 端点工作,使用 OAuth bearer token 认证,并携带组织标识和 beta 标志等头部信息。

工具的启用受两个条件门控:feature flag tengu_surreal_dali 和 policy limit allow_remote_sessions。只有在两个条件都满足时,RemoteTriggerTool 才会出现在工具列表中。

远程触发的应用场景:

  1. CI/CD 集成:当 CI 流水线失败时,自动触发一个 Agent 分析失败原因并生成修复建议。
  2. 监控告警响应:当监控系统检测到异常指标时,触发 Agent 进行根因分析。
  3. 代码仓库事件:当 PR 被创建或更新时,触发 Agent 进行自动代码审查。
  4. 定时报告:每天定时触发 Agent 生成项目状态报告。
flowchart LR
    EXT["外部系统<br/>CI/监控"] -->|"webhook/trigger"| API["RemoteTrigger<br/>API 端点"]
    API -->|"API 调用"| AGENT["Claude Agent"]

    API -->|"list/get/create<br/>/update/run"| API

    subgraph 触发器管理
        API
    end

    subgraph 任务执行
        AGENT -->|"run 操作"| AGENT
    end

    classDef extNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef apiNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef agentNode fill:#d4edda,stroke:#27ae60,stroke-width:1px

    class EXT extNode
    class API apiNode
    class AGENT agentNode

最佳实践: 远程触发结合 Plan 模式可以构建强大的自动化工作流。例如,CI 失败时触发一个 Agent,Agent 自动进入 Plan 模式分析失败原因,生成修复计划,然后自动执行修复。这种”触发 -> 规划 -> 执行”的模式是 Agent 驱动自动化的核心范式。

14.4.4 定时任务的会话级生命周期

Session-scoped 任务(durable: false)的生命周期与会话绑定。它们存储在 bootstrap state 中,不写入磁盘。

listAllCronTasks 合并文件任务和 session 任务,返回统一的任务列表。

在 scheduler 的 check() 方法中,session 任务和文件任务走不同的处理路径。Session 任务直接在内存中操作(同步删除),文件任务通过 removeCronTasks 异步写入磁盘。

实战案例:自动部署监控工作流

以下是一个利用调度系统构建的自动部署监控工作流:

1. 用户:"帮我部署到 staging 环境并监控部署状态"

2. Agent 创建部署任务:
   - one-shot cron task: 30分钟后检查部署是否完成
   - prompt: "检查 staging 环境部署状态,如果失败则分析原因"

3. Agent 创建监控任务:
   - recurring cron task: 每5分钟检查一次健康状态
   - prompt: "检查 staging 环境的健康检查端点,如果返回异常则通知"
   - durable: false (仅当前会话有效)

4. 部署成功后:
   - Agent 删除监控任务
   - 部署检查任务自动触发后删除(one-shot)

5. 如果会话意外中断:
   - monitoring task 随会话消失(durable: false)
   - 部署检查任务可能在下次会话触发(如果持久化的话)

14.4.5 工作流设计模式与反模式

基于 Claude Code 的 Plan 模式和调度系统,我们可以总结出以下工作流设计模式和反模式:

模式一:Plan-Execute-Verify 循环

flowchart LR
    PLAN["Plan<br/>只读探索"] --> EXEC["Execute<br/>按计划实施"]
    EXEC --> VERIFY["Verify<br/>独立验证"]
    VERIFY -->|"通过"| DONE["Done"]
    VERIFY -->|"不通过"| PLAN

    classDef phaseNode fill:#e8f4f8,stroke:#2980b9,stroke-width:2px
    classDef doneNode fill:#d4edda,stroke:#27ae60,stroke-width:2px

    class PLAN,EXEC,VERIFY phaseNode
    class DONE doneNode

这是最经典的 Agent 工作流模式。Plan 阶段确保方向正确,Execute 阶段高效实施,Verify 阶段保障质量。三者的分离使得每个阶段都可以独立优化和调试。

模式二:Event-Triggered Workflow

flowchart LR
    EVENT["外部事件"] --> TRIGGER["触发 Agent"]
    TRIGGER --> PLAN["Plan 模式"]
    PLAN --> APPROVE["用户审批"]
    APPROVE --> EXEC["执行"]
    EXEC --> REPORT["报告结果"]

    classDef eventNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef phaseNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef endNode fill:#d4edda,stroke:#27ae60,stroke-width:1px

    class EVENT eventNode
    class TRIGGER,PLAN,APPROVE,EXEC phaseNode
    class REPORT endNode

适用于运维自动化、CI/CD 集成等场景。事件触发而非用户主动发起,Plan 模式确保自动化操作的安全性和可控性。

模式三:Polling Loop with Escalation

flowchart TD
    START["定时任务触发"] --> POLL["轮询检查"]
    POLL --> CHECK{"是否正常?"}
    CHECK -->|"正常"| POLL
    CHECK -->|"异常"| ESCALATE["升级处理<br/>进入 Plan 模式"]

    classDef pollNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef decisionNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef escalateNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px

    class START,POLL pollNode
    class CHECK decisionNode
    class ESCALATE escalateNode

适用于持续监控场景。低成本的定时轮询只在检测到异常时才触发高成本的 Agent 推理。

反模式一:无计划的复杂任务

直接让 Agent 执行涉及多个文件修改的复杂任务,不经过 Plan 模式。风险:方向错误导致大面积返工。

反模式二:过度规划

对简单任务(如修改一个配置值)也进入 Plan 模式。浪费时间和 token,降低用户体验。

反模式三:永不清理的定时任务

创建 recurring 任务但不设置合理的过期时间。结果:僵尸任务持续消耗资源。

反模式四:同步等待轮询结果

在主对话中同步等待定时任务的结果(通过 Sleep + 轮询)。正确做法是将结果通过通知机制异步推送,主对话不被阻塞。


14.5 后台任务与主动模式

14.5.1 SleepTool

SleepTool 被列为后台任务管理的一部分。在 Claude Code 的工具注册中,它与其他调度相关工具一起注册。SleepTool 允许 Agent 在执行过程中暂停一段时间,这在长时间运行的后台任务场景中特别有用,例如轮询部署状态或等待外部系统完成操作。

SleepTool 的设计看似简单(就是等待指定时间),但它在 Agent 交互模型中扮演着重要角色。没有 SleepTool 的 Agent 在面对”等待”场景时只有两种选择:要么立即返回结果(即使外部操作尚未完成),要么在循环中频繁轮询(浪费 token)。SleepTool 提供了第三种选择:Agent 可以暂停执行,等待一段时间后再继续,期间不产生 token 消耗。

SleepTool 的使用场景:

  1. 部署轮询:Agent 触发部署后,sleep 30 秒,然后检查部署状态,如果还在进行中就继续 sleep。
  2. 缓存预热等待:修改了缓存配置后,sleep 等待缓存失效期过去,然后验证新缓存是否生效。
  3. 限速适配:调用有速率限制的外部 API 时,sleep 等待速率窗口重置。

反模式警告: 不要在 Plan 模式中使用 SleepTool。Plan 模式应该是高效的只读探索,不应该包含等待操作。如果 Plan 模式中的某个操作需要等待,这说明探索策略需要优化 — 应该一次性获取所有需要的信息,而不是轮询等待。

14.5.2 后台会话管理

runForkedAgent 是后台 Agent 执行的基础。它通过 createSubagentContext 创建完全隔离的上下文,运行独立的查询循环,并追踪完整的 usage 指标。

关键的隔离设计包括:

  1. 文件状态缓存克隆cloneFileStateCache):子 Agent 的文件读取不影响父 Agent 的缓存。这确保了父 Agent 的文件状态视图不会被子 Agent 的中间操作污染。例如,如果子 Agent 读取了一个文件然后该文件被修改,父 Agent 仍然持有修改前的缓存版本,不会因为子 Agent 的读取而刷新。

  2. 独立的 AbortController:子 Agent 可以被独立中止,而不影响父 Agent。这使得父 Agent 可以启动多个子 Agent,在需要时选择性取消某个子 Agent,而不影响其他正在进行的子任务。

  3. 权限提示抑制:后台 Agent 的 getAppState 包装了 shouldAvoidPermissionPrompts: true,避免后台操作弹出 UI 提示。这是因为后台 Agent 通常执行预先授权的任务(如 Plan 验证、会话摘要),不需要也不应该打断用户。

  4. Transcript 记录:将子 Agent 的消息记录到独立的 sidechain 中,与主会话的消息分离。这使得主会话的消息历史保持清晰,不被子 Agent 的内部操作淹没。

lastCacheSafeParams 是一个全局 slot,用于在 post-turn hooks 中保存当前轮次的 cache-safe params。这使得 post-turn fork(如 promptSuggestion、postTurnSummary)可以直接复用主循环的 prompt cache,无需每个调用者手动传递参数。

lastCacheSafeParams 的设计精妙之处在于”隐式上下文传递”。 显式的做法是在每个需要 cache-safe params 的地方都从主循环传递参数,但这会导致函数签名膨胀 — 每个新增的调用点都需要修改参数列表。通过全局 slot,调用者只需要在正确的时机读取 slot 值,而不需要知道值从哪里来。这降低了组件之间的耦合度。

当然,全局状态也有其风险:如果读取时机不对(比如在一个异步操作中延迟读取),slot 值可能已经被后续轮次覆盖。Claude Code 通过在 post-turn hook 的同步执行段中立即读取 slot 值来避免这个问题 — slot 的写入和读取发生在同一个同步执行段中,不存在竞态条件。

14.5.3 后台任务的生命周期管理

后台任务从创建到完成经历以下生命周期阶段:

flowchart TD
    subgraph 创建阶段
        C1["确定任务参数"] --> C2["创建上下文"]
        C2 --> C3["克隆状态"]
    end

    subgraph 执行阶段
        E1["独立查询循环"] --> E2["独立权限上下文"]
        E2 --> E3["独立 abort 控制"]
        E3 --> E4["实时 usage 追踪"]
        E4 --> LOOP["工具调用循环<br/>StreamingToolExecutor<br/>流式结果收集"]
        LOOP --> E1
    end

    subgraph 终止阶段
        T1["收集 usage 指标"] --> T2["记录 transcript"]
        T2 --> T3["返回结果给父"]
        T3 --> T4["清理资源"]
    end

    C3 --> E1
    E4 --> T1

    classDef createNode fill:#e8f4f8,stroke:#2980b9,stroke-width:1px
    classDef execNode fill:#fff3cd,stroke:#f39c12,stroke-width:1px
    classDef termNode fill:#d4edda,stroke:#27ae60,stroke-width:1px
    classDef loopNode fill:#f8d7da,stroke:#c0392b,stroke-width:1px

    class C1,C2,C3 createNode
    class E1,E2,E3,E4 execNode
    class LOOP loopNode
    class T1,T2,T3,T4 termNode

每个阶段都有对应的资源管理策略。创建阶段分配资源(上下文、控制器),执行阶段使用资源(API 调用、文件访问),终止阶段释放资源(关闭控制器、写入 transcript)。这种”分配-使用-释放”的三段式生命周期管理确保了资源不泄漏。


实战练习

练习 1:体验 Plan 模式完整流程

在 Claude Code REPL 中输入一个非平凡的实现任务(如”为项目添加一个配置验证模块”),观察:

  1. EnterPlanMode 触发的条件(模型是否主动进入了 Plan 模式)
  2. Plan 模式下模型的行为(是否只使用了只读工具)
  3. 计划文件的内容和存储位置
  4. ExitPlanMode 的审批流程和模式恢复

记录模板:

| 步骤 | 观察到的行为 | 使用的工具 | 耗时 |
|------|------------|-----------|------|
| 进入 Plan 模式 | ... | EnterPlanMode | ...s |
| 探索阶段 #1 | ... | Read/Grep/Glob | ...s |
| 探索阶段 #2 | ... | Read/Grep/Glob | ...s |
| 呈现计划 | ... | ExitPlanMode | ...s |
| 用户审批 | ... | - | ...s |
| 执行阶段 | ... | Edit/Write/Bash | ...s |

延伸思考: 对比使用 Plan 模式和不使用 Plan 模式完成同一任务的效果差异。特别关注:实施结果是否符合预期?是否需要返工?总耗时和 token 消耗如何?

练习 2:创建 Cron 定时任务

使用 /schedule 技能创建一个 one-shot 和一个 recurring 定时任务。检查 .claude/scheduled_tasks.json 的内容变化。终止并重新启动 Claude Code,观察错过任务的检测通知。

具体步骤:

  1. 创建一个 one-shot 任务:“5 分钟后报告当前 git 分支状态”
  2. 创建一个 recurring 任务:“每小时检查是否有未提交的变更”
  3. 查看 .claude/scheduled_tasks.json,理解每个字段的含义
  4. 等待 one-shot 任务触发,观察触发行为
  5. 退出并重启 Claude Code,观察错过任务的检测通知
  6. 手动删除 recurring 任务,确认文件内容更新

练习 3:分析计划文件恢复

在一个远程会话(CCR)中,进入 Plan 模式并创建一个计划。终止会话后恢复(--resume),检查计划文件是否被正确恢复。结合 recoverPlanFromMessages 的三种恢复来源,分析你的场景走了哪条路径。

调试提示: 使用 CLAUDE_CODE_DEBUG=1 环境变量启动 Claude Code,在日志中搜索 copyPlanForResumerecoverPlanFromMessages 关键词,可以观察到恢复过程走了哪条路径。

练习 4:设计一个完整的事件驱动工作流

选择以下场景之一,设计一个完整的事件驱动工作流(使用 Plan 模式 + 定时任务 + 远程触发):

场景 A:自动化代码审查

  • 触发条件:PR 创建/更新
  • Plan 阶段:阅读 PR 变更,分析代码质量
  • 执行阶段:生成审查评论
  • 验证阶段:检查评论是否成功发布

场景 B:基础设施健康监控

  • 触发条件:每小时定时触发
  • Plan 阶段:查询关键指标,分析异常
  • 执行阶段:生成报告,触发告警(如有异常)
  • 验证阶段:确认告警已发送

对于你选择的场景,请回答:

  1. 任务应该是 durable 还是 session-scoped?
  2. 是否需要 jitter?为什么?
  3. Plan 模式的退出条件是什么?
  4. 如何处理任务执行失败的情况?

关键要点

  1. Plan 模式是只读探索与可写执行的分离,通过权限模式切换实现。EnterPlanMode 保存原始模式并切换到 plan,ExitPlanMode 恢复原始模式并处理断路器防御。这种分离确保了 Agent 在”思考”阶段不产生任何副作用,极大降低了方向性错误的风险。

  2. 计划文件管理采用 slug-based 命名和惰性生成,支持三层恢复策略(直接读取、文件快照、消息历史),确保计划在各种故障场景下不丢失。恢复策略按可靠性优先级排序,体现了”鸡蛋不放一个篮子”的防御性设计思想。

  3. Fork Agent 通过 CacheSafeParams 实现提示缓存共享,通过 createSubagentContext 实现状态隔离,确保子 Agent 不干扰主 Agent 的状态。子 Agent 的”沙箱”设计 — 可读外部世界但不可修改父状态 — 是 Agent 安全的关键边界。

  4. 调度系统支持文件持久化和会话级两种任务,通过文件锁防止多会话重复触发,通过 jitter 机制防止惊群效应,通过自动过期防止无限递归。这些机制共同构成了一个健壮的分布式任务调度框架。

  5. Teammate 的 Plan 审批走 mailbox 机制,而非本地 UI 弹窗,这是分布式 Agent 协作的基础设施。审批权力的委托(从本地用户到 team lead)体现了 Agent 系统中的权限代理模式。

  6. 工作流设计应遵循”Plan-Execute-Verify”模式,避免”无计划的复杂任务”和”永不清理的定时任务”等反模式。好的工作流设计在自动化效率和人为监督之间取得平衡。

  7. lastCacheSafeParams 的全局 slot 设计体现了”隐式上下文传递”模式,降低了组件间的耦合度,同时通过同步读写避免了竞态条件。这种模式在需要跨多层组件传递上下文时特别有用。