Plan Mode 工具:规划模式

📑 目录

在 AI Agent 编程的实践中,一个反复出现的难题是:模型应该在什么时候停下来思考,而不是立刻动手写代码? 对于简单的 bug 修复或单行修改,直接执行当然高效;但当面对涉及多个文件、需要架构决策的复杂任务时,未经充分规划的代码往往会导致返工、破坏现有设计,甚至引入新的问题。Claude Code 通过 Plan Mode(规划模式) 这一机制,将"规划"与"执行"明确分离,让模型在获得用户认可后再开始编码。本文将深入解析实现这一机制的核心工具:EnterPlanModeToolExitPlanModeV2Tool

Plan Mode 概念

什么是规划模式

Plan Mode 是 Claude Code 中的一种特殊工作模式。当进入规划模式后,模型的行为发生显著变化:它被限制为只读探索——可以浏览代码库、搜索文件、阅读文档,但不能写入或修改任何代码文件(除了专门的计划文件)。模型的核心任务转变为:充分理解问题、探索现有代码模式、设计实现方案,并将最终方案写入计划文件中等待用户审批。

从工具层面看,Plan Mode 由两个对偶的工具共同实现:

  • EnterPlanModeTool:请求进入规划模式
  • ExitPlanModeV2Tool:请求退出规划模式,提交计划供用户审批

这两个工具在源码中分别位于 src/tools/EnterPlanModeTool/src/tools/ExitPlanModeTool/ 目录下,是 Claude Code 工具链中承担"元控制"职能的关键组件。

与正常模式的区别

在 Claude Code 的正常工作模式下,模型根据用户指令直接执行操作:读取文件、编辑代码、运行命令。工具调用与执行之间没有强制的"暂停点"。而在 Plan Mode 下,工作流被重构为三个阶段:

flowchart LR
    A[用户输入] --> B{是否进入 Plan Mode}
    B -->|否| C[正常执行模式
读/写/执行并行] B -->|是| D[规划阶段
只读探索 + 撰写计划] D --> E[用户审批计划] E -->|批准| F[执行阶段
按计划实施] E -->|拒绝| D

这种分离带来了几个关键差异:

维度正常模式Plan Mode
文件写入权限根据具体工具权限决定仅允许写入计划文件
用户交互方式逐条审批或 Auto Mode一次性审批完整计划
适用场景简单任务、明确指令复杂实现、架构决策
模型行为边探索边编码先探索设计,后编码
状态标记mode: 'auto''default'mode: 'plan'

为什么要分离规划和执行

分离规划与执行的价值,在复杂软件工程中尤为明显。Claude Code 的 EnterPlanModeTool 的 prompt 中明确总结了使用场景(src/tools/EnterPlanModeTool/prompt.ts,第 21-48 行):

  1. New Feature Implementation: Adding meaningful new functionality
  2. Multiple Valid Approaches: The task can be solved in several different ways
  3. Code Modifications: Changes that affect existing behavior or structure
  4. Architectural Decisions: The task requires choosing between patterns or technologies
  5. Multi-File Changes: The task will likely touch more than 2-3 files
  6. Unclear Requirements: You need to explore before understanding the full scope
  7. User Preferences Matter: The implementation could reasonably go multiple ways

这些场景的共同点是:存在显著的不确定性或决策空间。如果模型直接开始编码,可能会选择一种与用户期望不符的方案,导致大量返工。Plan Mode 通过强制"先设计、后实施"的流程,将决策成本前置到规划阶段,用更小的代价换取方向上的对齐。

此外,Plan Mode 还有助于避免 LLM 的一个典型陷阱——过早承诺(premature commitment)。模型在只读探索阶段不会因为"已经改了代码"而产生沉没成本效应,能够更客观地评估不同方案的优劣。

EnterPlanModeTool:进入规划模式

工具定义与结构

EnterPlanModeTool 的源码位于 src/tools/EnterPlanModeTool/EnterPlanModeTool.ts(第 1-107 行),其核心结构如下:

export const EnterPlanModeTool: Tool<InputSchema, Output> = buildTool({
  name: ENTER_PLAN_MODE_TOOL_NAME,  // 'EnterPlanMode'
  searchHint: 'switch to plan mode to design an approach before coding',
  maxResultSizeChars: 100_000,
  async description() {
    return 'Requests permission to enter plan mode for complex tasks requiring exploration and design'
  },
  async prompt() {
    return getEnterPlanModeToolPrompt()
  },
  // ...
  shouldDefer: true,
  isReadOnly() {
    return true
  },
  // ...
})

该工具的关键属性包括:

  • shouldDefer: true:表示该工具需要延迟执行,等待用户明确批准后才能生效。这是 Plan Mode 的入口关卡,确保用户知情并同意进入规划模式。
  • isReadOnly(): true:标记为只读工具,因为它本身不修改代码库,仅改变会话的状态。
  • 无输入参数inputSchema 是一个空的 z.strictObject({}),模型调用时无需提供任何参数,这降低了使用门槛——当模型判断需要规划时,直接调用即可。
  • 输出确认消息outputSchema 返回 { message: string },确认已成功进入规划模式。

进入规划模式的触发条件

模型并非随意进入 Plan Mode。EnterPlanModeTool 的 prompt(src/tools/EnterPlanModeTool/prompt.ts,第 21-48 行)为模型提供了明确的判断标准:

  • 推荐使用:对于非平凡的实现任务,优先使用 EnterPlanMode
  • 避免使用:单行修复、明显的小调整、用户已给出非常具体指令的任务、纯研究/探索任务

从代码层面,EnterPlanModeTool 还有一个重要的可用性控制(src/tools/EnterPlanModeTool/EnterPlanModeTool.ts,第 47-56 行):

isEnabled() {
  // When --channels is active, ExitPlanMode is disabled (its approval
  // dialog needs the terminal). Disable entry too so plan mode isn't a
  // trap the model can enter but never leave.
  if (
    (feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
    getAllowedChannels().length > 0
  ) {
    return false
  }
  return true
}

这段逻辑体现了设计上的周全考虑:当用户通过 --channels 参数使用非终端渠道(如 Telegram、Discord)与 Claude Code 交互时,ExitPlanMode 的审批对话框无法正常显示。如果允许进入 Plan Mode 却不允许退出,模型将陷入"只能规划、无法执行"的陷阱。因此,这种情况下干脆禁用进入功能。

规划阶段的行为控制

EnterPlanModeTool 被调用并获得批准后,call 方法(src/tools/EnterPlanModeTool/EnterPlanModeTool.ts,第 66-90 行)执行以下关键操作:

async call(_input, context) {
  if (context.agentId) {
    throw new Error('EnterPlanMode tool cannot be used in agent contexts')
  }

  const appState = context.getAppState()
  handlePlanModeTransition(appState.toolPermissionContext.mode, 'plan')

  context.setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' },
    ),
  }))

  return {
    data: {
      message: 'Entered plan mode. You should now focus on exploring...',
    },
  }
}

这里有几个关键点:

  1. Agent 上下文禁止context.agentId 检查确保 Plan Mode 仅在主会话中使用,不能在子 Agent 中嵌套进入。这简化了状态管理,也避免了多层规划导致的复杂度爆炸。

  2. prepareContextForPlanMode:来自 src/utils/permissions/permissionSetup.ts(第 142-167 行),该函数保存当前模式到 prePlanMode 字段,以便退出时恢复。如果当前是 auto 模式且配置允许,还会保持 auto 分类器在规划期间的活性。

  3. handlePlanModeTransition:来自 src/bootstrap/state.ts(第 176-186 行),处理状态切换时的副作用——进入 Plan Mode 时清除待发送的退出附件,避免状态快速切换时产生混乱的通知。

规划输出的格式

EnterPlanModeTool 完成后,通过 mapToolResultToToolResultBlockParamsrc/tools/EnterPlanModeTool/EnterPlanModeTool.ts,第 91-107 行)向模型发送后续指令:

mapToolResultToToolResultBlockParam({ message }, toolUseID) {
  const instructions = isPlanModeInterviewPhaseEnabled()
    ? `${message}\n\nDO NOT write or edit any files except the plan file...`
    : `${message}\n\nIn plan mode, you should:\n1. Thoroughly explore...\n6. When ready, use ExitPlanMode...`
  // ...
}

这里引入了 isPlanModeInterviewPhaseEnabled()(来自 src/utils/planModeV2.ts,第 33-46 行),它是一个功能开关,控制是否启用"面试阶段"的 Plan Mode 工作流。对于内部用户(USER_TYPE === 'ant')始终开启,对外部用户则通过 GrowthBook 特性标志控制。

指令的核心约束是:除计划文件外,不得写入或编辑任何文件。这通过权限系统的 mode: 'plan' 标记来强制执行。

ExitPlanModeV2Tool:退出规划模式

工具定义与结构

ExitPlanModeV2Tool 的源码位于 src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts(第 1-318 行),是 Plan Mode 的出口。相比入口工具,它的设计更为复杂,因为它涉及:

  • 读取计划文件
  • 处理用户审批
  • 恢复之前的工作模式
  • 支持 teammate 的 leader 审批流程
export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = buildTool({
  name: EXIT_PLAN_MODE_V2_TOOL_NAME,  // 'ExitPlanModeV2'
  searchHint: 'present plan for approval and start coding (plan mode only)',
  shouldDefer: true,
  isReadOnly() {
    return false // Now writes to disk
  },
  requiresUserInteraction() {
    if (isTeammate()) {
      return false
    }
    return true
  },
  // ...
})

关键属性变化:

  • isReadOnly(): false:因为可能需要将编辑后的计划写回磁盘(当用户通过 CCR Web UI 或 Ctrl+G 编辑计划时)。
  • requiresUserInteraction():对 teammate 返回 false(leader 通过 mailbox 异步审批),对普通用户返回 true(需要终端上的即时确认)。

退出规划模式的条件

ExitPlanModeV2Tool 通过多层验证确保退出的合法性:

1. 输入验证(validateInput(第 143-161 行):

async validateInput(_input, { getAppState, options }) {
  if (isTeammate()) {
    return { result: true }
  }
  const mode = getAppState().toolPermissionContext.mode
  if (mode !== 'plan') {
    return {
      result: false,
      message: 'You are not in plan mode...',
      errorCode: 1,
    }
  }
  return { result: true }
}

非 Plan Mode 状态下调用会被拒绝,并记录分析事件 tengu_exit_plan_mode_called_outside_plan,用于监控模型是否在不恰当的时候尝试退出。

2. 权限检查(checkPermissions(第 163-175 行):

对于非 teammate 用户,显示"Exit plan mode?"的确认对话框。对于 teammate,直接允许通过,因为 teammate 的审批流程在 call 方法中通过 mailbox 异步处理。

从规划到执行的切换

call 方法是状态切换的核心(第 176-296 行),其流程如下:

sequenceDiagram
    participant Model as 模型
    participant Tool as ExitPlanModeV2Tool
    participant State as AppState
    participant Disk as 计划文件
    participant User as 用户/Leader

    Model->>Tool: 调用 ExitPlanMode
    Tool->>Disk: 读取计划内容
    alt 是 teammate 且需要 leader 审批
        Tool->>User: 通过 mailbox 发送审批请求
        Tool->>Model: 返回 awaitingLeaderApproval
    else 普通用户
        Tool->>User: 显示计划内容,请求确认
        User-->>Tool: 批准/编辑后批准
        Tool->>State: 恢复 prePlanMode
        Tool->>State: setHasExitedPlanMode(true)
        Tool->>Model: 返回 Approved Plan
    end

状态恢复逻辑(src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts,第 237-289 行)尤其值得关注:

context.setAppState(prev => {
  if (prev.toolPermissionContext.mode !== 'plan') return prev
  setHasExitedPlanMode(true)
  setNeedsPlanModeExitAttachment(true)
  let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default'
  
  // Circuit breaker defense: if prePlanMode was auto but gate is now off
  if (feature('TRANSCRIPT_CLASSIFIER')) {
    if (restoreMode === 'auto' && !isAutoModeGateEnabled()) {
      restoreMode = 'default'
    }
    // ...
  }
  
  return {
    ...prev,
    toolPermissionContext: {
      ...baseContext,
      mode: restoreMode,
      prePlanMode: undefined,
    },
  }
})

这里体现了几个重要的设计细节:

  1. prePlanMode 恢复:退出 Plan Mode 时,系统会恢复到进入之前的模式(autodefault 等),而不是简单地回到 default。这保证了用户的工作流连续性。

  2. 熔断器防御:如果进入 Plan Mode 之前是 auto 模式,但退出时 auto 模式的"闸门"已关闭(比如因为 circuit breaker 触发或设置变更),系统会智能地降级到 default 模式,而不是强行恢复到一个不可用的状态。

  3. 危险权限的恢复/剥离:如果退出后恢复 auto 模式,会剥离危险权限;如果恢复非 auto 模式,且之前因进入 Plan Mode 而被剥离了权限,则会恢复这些权限。

规划的持久化

计划文件的持久化由 src/utils/plans.ts 管理。ExitPlanModeV2Tool 从磁盘读取计划内容:

const filePath = getPlanFilePath(context.agentId)
const plan = inputPlan ?? getPlan(context.agentId)

如果用户通过 CCR Web UI 编辑了计划(inputPlan 有值),工具会将编辑后的内容写回磁盘(src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts,第 197-201 行):

if (inputPlan !== undefined && filePath) {
  await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e))
  void persistFileSnapshotIfRemote()
}

计划文件存储在用户配置目录下的 plans 文件夹中,文件名为随机生成的 word slug(如 fuzzy-river.md),并通过 getPlanSlugCache() 在会话级别缓存,确保同一会话中始终使用同一个计划文件。

规划模式的状态管理

planModeV2.ts:Plan Mode 的配置中心

src/utils/planModeV2.ts 是 Plan Mode 相关配置和实验功能的集中地,包含以下关键函数:

1. Agent 数量控制(第 6-19 行):

export function getPlanModeV2AgentCount(): number {
  if (process.env.CLAUDE_CODE_PLAN_V2_AGENT_COUNT) {
    // 环境变量覆盖,范围 1-10
  }
  // Max 订阅 + 默认 tier = 3
  // Enterprise/Team = 3
  // 其他 = 1
}

该函数根据用户的订阅类型和速率限制 tier 决定 Plan Mode V2 可用的 agent 数量。Max 订阅用户和企业/团队用户可以获得 3 个 agent,而普通用户只有 1 个。

2. 探索 Agent 数量(第 21-31 行):

export function getPlanModeV2ExploreAgentCount(): number {
  // 默认 3 个探索 agent,可通过环境变量覆盖
}

探索 agent 用于在规划阶段并行搜索和分析代码库,3 个的数量提供了足够的并行度来加速探索过程。

3. Interview Phase 开关(第 33-46 行):

export function isPlanModeInterviewPhaseEnabled(): boolean {
  if (process.env.USER_TYPE === 'ant') return true
  // 外部用户通过 tengu_plan_mode_interview_phase feature gate 控制
}

Interview Phase 是 Plan Mode 的一个增强工作流,启用后模型会经历更结构化的规划阶段(如需求澄清、方案探索等),prompt 中的"What Happens"部分也会被替换为更详细的阶段化指令。

4. Pewter Ledger 实验(第 68-85 行):

export type PewterLedgerVariant = 'trim' | 'cut' | 'cap' | null

export function getPewterLedgerVariant(): PewterLedgerVariant {
  // 控制 Phase 4 "Final Plan" 的 bullets 大小
  // Arms: null (control), 'trim', 'cut', 'cap'
}

这是一个 A/B 实验,旨在通过 progressively stricter 的 guidance 控制计划文件的大小。数据显示计划文件越大,用户拒绝率越高(<2K 时 20%20K+ 时 50%),因此团队在实验不同的文件大小限制策略。

规划状态的存储

Plan Mode 的核心状态存储在 AppStatetoolPermissionContext 中,关键字段包括:

  • mode:当前权限模式,Plan Mode 期间为 'plan'
  • prePlanMode:进入 Plan Mode 之前的模式,用于退出时恢复
  • strippedDangerousRules:如果因进入 Plan Mode 而剥离了危险权限,这里保存被剥离的规则

此外,src/bootstrap/state.ts(第 156-186 行)维护了几个全局的 plan mode 相关状态:

// 会话级别的 plan mode 退出标记
hasExitedPlanMode: boolean
needsPlanModeExitAttachment: boolean

export function handlePlanModeTransition(fromMode: string, toMode: string): void {
  if (toMode === 'plan' && fromMode !== 'plan') {
    STATE.needsPlanModeExitAttachment = false
  }
  if (fromMode === 'plan' && toMode !== 'plan') {
    STATE.needsPlanModeExitAttachment = true
  }
}

needsPlanModeExitAttachment 用于控制是否在后续对话中附加 plan mode 退出的上下文提示,帮助模型理解当前已回到正常执行模式。

与 AppState 的集成

Plan Mode 与 AppState 的集成体现在两个层面:

1. 进入时的上下文准备

prepareContextForPlanModesrc/utils/permissions/permissionSetup.ts,第 142-167 行)在进入 Plan Mode 时执行:

export function prepareContextForPlanMode(context: ToolPermissionContext): ToolPermissionContext {
  const currentMode = context.mode
  if (currentMode === 'plan') return context
  
  if (feature('TRANSCRIPT_CLASSIFIER')) {
    const planAutoMode = shouldPlanUseAutoMode()
    if (currentMode === 'auto') {
      if (planAutoMode) {
        return { ...context, prePlanMode: 'auto' }
      }
      // 关闭 auto,恢复权限
      return { ...restoreDangerousPermissions(context), prePlanMode: 'auto' }
    }
    // ...
  }
  return { ...context, prePlanMode: currentMode }
}

这里处理了 auto 模式与 Plan Mode 的交互:如果用户启用了"在 Plan Mode 期间保持 Auto"的设置,auto 分类器会在规划阶段继续运行,减少不必要的权限询问。

2. 退出时的状态恢复

如前文所述,ExitPlanModeV2Toolcall 方法负责将 toolPermissionContext.mode'plan' 恢复为 prePlanMode,并清理临时状态。这种"栈式"的状态管理确保了无论进入 Plan Mode 之前是什么模式,退出后都能正确回到原点。

与 commands/plan.ts 的关系

命令和工具的协作

除了模型自动调用的 EnterPlanModeToolExitPlanModeV2Tool,Claude Code 还为用户提供了一个手动控制 Plan Mode 的命令 /plan,其实现位于 src/commands/plan/plan.tsx(第 1-92 行)。

plan.tsx 作为一个 Local JSX Command,提供了三种行为:

  1. 启用 Plan Mode:如果当前不在 plan 模式,执行与 EnterPlanModeTool 类似的状态切换:
if (currentMode !== 'plan') {
  handlePlanModeTransition(currentMode, 'plan')
  setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' }
    )
  }))
  // ...
}

注意这里使用了完全相同的状态转换函数 handlePlanModeTransitionprepareContextForPlanMode,确保手动切换和工具触发的切换在行为上完全一致。

  1. 查看当前计划:如果已在 Plan Mode 中且不带参数,显示当前计划文件的内容和路径。

  2. 在外部编辑器中打开:支持 /plan open 子命令,调用 editFileInEditor 在外部编辑器(如 VS Code、Vim)中打开计划文件,方便用户直接编辑。

用户如何手动切换

用户可以通过以下方式与 Plan Mode 交互:

操作方式效果
进入 Plan Mode输入 /plan/plan [描述]手动启用规划模式
查看计划在 Plan Mode 中输入 /plan显示当前计划内容
编辑计划输入 /plan open在外部编辑器中打开计划文件
提交计划模型调用 ExitPlanModeV2Tool请求用户审批
退出(无计划)模型调用 ExitPlanModeV2Tool直接回到正常模式

命令与工具的协作关系可以用下图表示:

flowchart TD
    subgraph 用户层
        U1["用户输入 /plan"]
        U2["用户输入任务描述"]
    end
    subgraph 命令层
        C1["commands/plan.tsx"]
    end
    subgraph 工具层
        T1["EnterPlanModeTool"]
        T2["ExitPlanModeV2Tool"]
    end
    subgraph 状态层
        S1["AppState.toolPermissionContext"]
        S2["plans.ts 文件系统"]
    end

    U1 --> C1
    C1 -->|调用相同状态函数| S1
    U2 -->|模型判断需要规划| T1
    T1 --> S1
    T1 -->|只读探索| S2
    T2 -->|读取计划| S2
    T2 -->|恢复模式| S1
    T2 -->|返回结果| U2

总结

Claude Code 的 Plan Mode 通过 EnterPlanModeToolExitPlanModeV2Tool 两个工具,构建了一个清晰的分阶段工作流:探索 → 设计 → 审批 → 执行。这种分离不是简单的流程控制,而是一套涉及权限系统、状态管理、持久化存储和用户交互的完整机制。

从源码分析中,我们可以看到几个值得注意的设计选择:

  1. 状态栈管理:通过 prePlanMode 保存进入前的模式,确保退出时能精确恢复,而不是粗暴地回到 default

  2. 熔断器与降级:在状态恢复时检查 auto mode gate 的状态,避免将用户推入一个已被禁用的模式。

  3. Agent 隔离:Plan Mode 禁止在子 Agent 中使用,保持了主会话对规划流程的单一控制权。

  4. Teammate 协作ExitPlanModeV2Tool 为 teammate 设计了异步 leader 审批流程,通过 mailbox 机制实现团队内的计划评审。

  5. 实验驱动优化:通过 planModeV2.ts 中的 feature gate 和 A/B 实验(如 Pewter Ledger),团队持续优化计划文件的大小和结构,降低用户拒绝率。

对于 AI Agent 系统的开发者而言,Claude Code 的 Plan Mode 提供了一个优秀的参考范式:当模型面临高不确定性任务时,与其让它在探索和编码之间模糊切换,不如显式地定义"规划阶段",用状态机和权限系统来强制执行阶段边界。这种设计不仅提升了输出质量,也增强了用户对 AI 行为的可预测性和信任感。