ClaudeCode 的执行机制:ReAct 与 Plan-and-Execute 在 TypeScript 源码中的实现
这篇文章继续沿着 claude-code 的 TypeScript 源码往下拆。
如果上一篇文章回答的是 ClaudeCode “怎么记住上下文”,那这篇文章回答的是另一个更核心的问题:
- ClaudeCode 到底是不是按 ReAct 在跑;
- 它说的 Plan-and-Execute,在源码里到底是怎么实现的;
- 什么时候会走默认的直接执行,什么时候会切到 plan mode;
- 它判断“该不该先规划”的依据到底是什么。
先说结论:
ClaudeCode 的默认执行内核是一个典型的 ReAct 式主循环:模型输出、发现
tool_use、执行工具、把tool_result追加回上下文、继续下一轮。而所谓 Plan-and-Execute,并不是另一套完全独立的 agent runtime。它本质上是在同一个 ReAct 主循环上,再叠加一层 plan mode 权限模式 + plan file + 用户审批流 + 额外提示词/attachment。
换句话说:
- ReAct 是 ClaudeCode 的“基础发动机”;
- Plan-and-Execute 是这个发动机上的“受控规划挡位”。
1. 先看大图:ClaudeCode 其实是“两层机制”
很多人第一次看 ClaudeCode,会以为:
- 简单任务走普通 agent;
- 复杂任务走另一个 planner executor 系统。
但源码里不是这么分的。
更准确地说,ClaudeCode 是:
- 永远有一个统一的
query()主循环,负责采样模型、收工具调用、执行工具、继续下一轮; - 是否处于 plan mode,只会改变这轮循环的约束、提示和权限,不会换掉底层循环本身。
所以它的真实结构更像这样:
用户请求
-> query() 主循环
-> 模型输出 assistant
-> 如果出现 tool_use,则执行工具
-> 把 tool_result 追加回上下文
-> 继续下一轮
如果进入 plan mode:
-> 还是同一个 query() 主循环
-> 只是变成“只读探索 + 写 plan file + AskUserQuestion/ExitPlanMode”
-> 计划获批后,退出 plan mode
-> 再回到同一个 query() 主循环继续真正实施
这也是为什么,从工程实现上说,Plan-and-Execute 更像:
“ReAct loop + planning constraints + approval boundary”
而不是一个和普通执行完全分开的执行器。
2. ReAct:ClaudeCode 的默认执行内核
2.1 query() 就是主循环
ClaudeCode 最核心的执行循环在 src/query.ts。
它先把当前运行态状态放进一个 state,然后直接进入一个无限循环:
let state: State = {
messages: params.messages,
toolUseContext: params.toolUseContext,
...
}
while (true) {
let { toolUseContext } = state
const { messages, turnCount, ... } = state
...
}
这段代码在 src/query.ts。
这个结构本身就很像经典的 agent 主循环:
- 读取当前消息上下文;
- 调模型;
- 看模型有没有要用工具;
- 有工具就执行;
- 再带着新结果继续下一轮。
如果从模式上看,这就是一个非常标准的 ReAct runtime。
2.2 ClaudeCode 里的 ReAct,不是概念,而是“assistant -> tool_use -> tool_result -> next turn”
ReAct 的关键不是“模型会思考”,而是:
- 模型先给出当前轮响应;
- 如果响应里有工具调用,就进入行动;
- 把行动结果再喂回模型继续推理。
ClaudeCode 在流式消费模型输出时,会显式收集这轮 assistant 里出现的 tool_use block:
if (message.type === 'assistant') {
assistantMessages.push(message)
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}
}
这里有两个很关键的点:
- 只要 assistant 消息里出现了
tool_use,就会把needsFollowUp = true; - ClaudeCode 不依赖 API 返回的
stop_reason === 'tool_use',因为源码里明确写了这个值“不可靠”。
源码原注释非常直白:
// 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.
这意味着 ClaudeCode 在工程上真正判断“这一轮是不是要继续 ReAct”的依据,不是 stop_reason,而是:
流里有没有真实到达
tool_useblock。
这点很重要,因为它说明 ClaudeCode 的 ReAct continuation 是按消息内容判定的,不是按 API 元数据盲信的。
2.3 工具执行之后,结果会被重新塞回下一轮上下文
ClaudeCode 在拿到 toolUseBlocks 之后,会执行工具:
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults()
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
for await (const update of toolUpdates) {
if (update.message) {
yield update.message
toolResults.push(
...normalizeMessagesForAPI(
[update.message],
toolUseContext.options.tools,
).filter(_ => _.type === 'user'),
)
}
}
这段做了两件事:
- 执行工具,产出工具结果;
- 把工具结果规范化成可以重新送回模型的消息,塞进
toolResults。
然后在需要继续下一轮时,它会构造新的状态:
state = {
...state,
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: updatedToolUseContext,
turnCount: turnCount + 1,
pendingToolUseSummary: nextPendingToolUseSummary,
}
continue
这就是最典型的 ReAct 闭环:
messagesForQuery是上一轮已有上下文;assistantMessages是这轮模型刚刚输出的推理/工具调用;toolResults是执行后的观察结果;- 三者拼起来进入下一轮。
所以如果要用一句最工程化的话定义 ClaudeCode 的 ReAct:
ClaudeCode 把每一轮 assistant 产出的
tool_use视作 continuation signal,执行后把tool_result追加回消息流,再进入下一轮query()。
2.4 什么时候会停下,不再继续 ReAct
如果这一轮没有出现工具调用,needsFollowUp 就会保持为 false:
if (!needsFollowUp) {
const lastMessage = assistantMessages.at(-1)
...
}
也就是说,ClaudeCode 默认会在下面这些情况下结束当前 ReAct 回合:
- 模型已经给出最终文本回答,没有再发
tool_use; - 达到了
maxTurns; - 被用户中断;
- 遇到模型错误、上下文溢出等异常恢复失败;
- 某些 hook 明确阻止 continuation。
所以 ClaudeCode 的 ReAct 不是“无限自动做事”,而是:
只要模型还在发工具调用,它就继续;一旦本轮没有工具调用,就把这轮当成完成。
2.5 ClaudeCode 什么时候会走 ReAct
答案其实很简单:
几乎所有正常的 ClaudeCode 任务,默认都在走 ReAct。
因为不管你是在:
- 读代码;
- 改代码;
- 搜文件;
- 跑命令;
- 用 agent tool;
最后都还是走到同一个 query() 主循环里,由 assistant 消息决定要不要发工具调用。
所以 ReAct 不是 ClaudeCode 的“某种高级模式”,而是它的默认操作系统。
3. Plan-and-Execute:不是第二套引擎,而是 ReAct 上叠加的 planning 模式
3.1 Plan mode 的本质:切换 toolPermissionContext.mode
ClaudeCode 用 ToolPermissionContext 表示当前工具权限状态:
export type ToolPermissionContext = {
mode: PermissionMode
...
prePlanMode?: PermissionMode
}
这里的 prePlanMode 注释写得很清楚:
/** Stores the permission mode before model-initiated plan mode entry,
* so it can be restored on exit */
也就是说,plan mode 不是一个抽象概念,而是一个真实的运行态权限模式:
- 当前模式是
default / auto / bypassPermissions / plan之一; - 一旦进入
plan,ClaudeCode 会记住进入前的模式; - 退出 plan mode 时再恢复回去。
这就是 Plan-and-Execute 的第一层实现基础。
3.2 手动进入 plan mode:/plan
用户可以直接通过 /plan 命令手动进入 plan mode。
src/commands/plan/plan.tsx 里的逻辑很直接:
if (currentMode !== 'plan') {
handlePlanModeTransition(currentMode, 'plan')
setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prepareContextForPlanMode(prev.toolPermissionContext),
{
type: 'setMode',
mode: 'plan',
destination: 'session',
},
),
}))
...
}
这说明只要用户显式发 /plan:
- 就会把当前 session 切到
plan; - 同时运行
prepareContextForPlanMode(),保存原模式并做必要的权限调整。
所以第一种进入 Plan-and-Execute 的方式非常明确:
用户显式要求规划。
3.3 模型主动进入 plan mode:EnterPlanModeTool
ClaudeCode 还允许模型自己调用 EnterPlanModeTool。
src/tools/EnterPlanModeTool/EnterPlanModeTool.ts 的说明是:
return 'Requests permission to enter plan mode for complex tasks requiring exploration and design'
真正切模式的逻辑也是:
context.setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prepareContextForPlanMode(prev.toolPermissionContext),
{ type: 'setMode', mode: 'plan', destination: 'session' },
),
}))
这个工具的返回结果还会进一步告诉模型:
Entered plan mode. You should now focus on exploring the codebase and
designing an implementation approach.
并且明确限制:
- 只读探索;
- 设计方案;
- 需要时用
AskUserQuestion; - 准备好后用
ExitPlanMode; - 不要先写代码或改文件。
这说明第二种进入 Plan-and-Execute 的方式是:
模型认为当前任务足够复杂,主动请求进入 plan mode。
4. 模型什么时候会主动进入 plan mode
这部分是最关键的,因为用户真正关心的是:
ClaudeCode 凭什么判断“这次要先规划,不要直接干”?
答案其实直接写在 EnterPlanModeTool 的 prompt 里。
4.1 External 版本的判断标准:偏积极进入 plan mode
在 src/tools/EnterPlanModeTool/prompt.ts 里,external 版本写得很细。
它明确说:下面这些情况,任意一个成立 都应该优先考虑进入 plan mode:
- 新功能实现;
- 存在多种合理实现路径;
- 会修改现有行为或结构;
- 涉及架构决策;
- 很可能要跨 2-3 个以上文件;
- 需求不清,需要先探索;
- 用户偏好会影响实现选择。
原文核心意思是:
Use this tool proactively when you're about to start a non-trivial
implementation task.
Prefer using EnterPlanMode for implementation tasks unless they're simple.
Use it when ANY of these conditions apply:
...
这相当于一种“宁可先对齐方案,也不要返工”的策略。
4.2 Ant 版本的判断标准:更保守,更偏“先做事”
同一个文件里,ant 版本明显更保守。
它强调的是:
- 只有当实现路径存在真正的重大歧义时才进入 plan mode;
- 如果可以合理推断出实现路径,就直接开始;
- 能问具体问题就别开完整 planning phase。
最关键的几句是:
Use this tool when a task has genuine ambiguity about the right approach...
Skip plan mode when you can reasonably infer the right approach:
- The task is straightforward even if it touches multiple files
- The user's request is specific enough that the implementation path is clear
...
When in doubt, prefer starting work and using AskUserQuestion
for specific questions over entering a full planning phase.
所以如果用一句话概括两种风格:
- external:复杂实现任务,倾向先 plan;
- ant:只有存在明显歧义和高返工风险时,才 plan;否则先做。
4.3 明确不该进入 plan mode 的情况
无论 external 还是 ant 版本,其实都明确列了“不该进入 plan mode”的任务类型:
- 小修小补;
- 明确、直接、实现路径显然的任务;
- 纯研究/纯阅读代码任务;
- 只是想知道某个实现位置,而不是要改代码。
比如 prompt 里直接举例:
Fix the typo in the READMEAdd a console.log to debug this functionWhat files handle routing?
这些都不应该触发 planning phase。
所以 ClaudeCode 的判断不是“复杂就一定 plan”,而是:
当任务的歧义、影响面、返工风险、用户偏好敏感度足够高时,才切 plan mode。
5. 进入 plan mode 后,模型实际会被怎样约束
5.1 plan mode 不只是一个 flag,还会注入额外 instructions
进入 plan mode 后,ClaudeCode 不是只把 mode 改成 plan 就完了。
它还会把 plan mode 指令注入到消息流里。
src/services/compact/compact.ts 里有一个很关键的函数:
export async function createPlanModeAttachmentIfNeeded(
context: ToolUseContext,
): Promise {
const appState = context.getAppState()
if (appState.toolPermissionContext.mode !== 'plan') {
return null
}
return createAttachmentMessage({
type: 'plan_mode',
reminderType: 'full',
isSubAgent: !!context.agentId,
planFilePath,
planExists,
})
}
注释也写得非常明确:
This ensures the model continues to operate in plan mode after compaction
也就是说,plan mode 不是只靠“之前模型还记得”来维持,而是:
ClaudeCode 会显式创建
plan_modeattachment,把规划约束重新灌给模型。
5.2 这些约束是怎么传给模型的
src/utils/messages.ts 会把 plan_mode attachment 变成真正发给模型的消息。
在 attachment 分发里:
case 'plan_mode': {
return getPlanModeInstructions(attachment)
}
而这些 instructions 最终会被包装成:
createUserMessage({ content, isMeta: true })
并进一步包上:
...
对应工具函数是:
export function wrapInSystemReminder(content: string): string {
return `\n${content}\n `
}
这说明在模型视角里,plan mode instruction 并不是一个神秘内部状态,而是:
- 一个
user侧的 meta message; isMeta: true;- 内容被
<system-reminder>包住; - 明确告诉模型自己当前在 plan mode,哪些事能做,哪些事不能做。
5.3 plan mode 下实际要求模型怎么做
在 messages.ts 里,plan mode 的说明非常具体。
尤其是 interview 版 workflow,核心要求是:
Plan mode is active. The user indicated that they do not want you to
execute yet -- you MUST NOT make any edits (with the exception of the
plan file mentioned below), run any non-readonly tools ... or otherwise
make any changes to the system.
然后要求模型循环执行:
- Explore:只读地读代码;
- Update the plan file:把发现写进 plan file;
- Ask the user:当遇到无法仅靠代码决定的歧义时,问用户。
并且 turn 结束只能有两种方式:
- 用
AskUserQuestion继续澄清; - 用
ExitPlanMode请求用户审批。
这段源码其实已经把 Plan-and-Execute 的“Plan”阶段定义得非常清楚了:
读代码、写计划、问用户,但不真正实施。
6. Plan-and-Execute 里的 “Execute” 是什么时候开始的
6.1 必须通过 ExitPlanMode
当计划准备好之后,模型必须调用 ExitPlanModeV2Tool。
它的 prompt 直接写着:
Use this tool when you are in plan mode and have finished writing your
plan to the plan file and are ready for user approval.
并且特别强调:
- 研究任务不要调用;
- 只是理解代码不要调用;
- 只有在“已经写完计划,准备开始真正实施”时才调用。
这说明 ClaudeCode 的设计不是:
计划做完就自动开始执行
而是:
计划做完后,必须跨过一个明确的 approval boundary。
6.2 普通用户场景:需要本地确认
对普通非 teammate 场景,ExitPlanModeV2Tool 会要求用户确认:
requiresUserInteraction() {
return true
}
checkPermissions(...) {
return {
behavior: 'ask',
message: 'Exit plan mode?',
}
}
也就是说:
- 模型先在 plan mode 里把方案写好;
- 通过
ExitPlanMode请求退出; - 用户确认后,ClaudeCode 才真正退出 planning phase。
6.3 teammate 场景:可能要求 team lead 审批
如果当前 session 是 teammate,并且带了 plan_mode_required,逻辑会更严格。
在 ExitPlanModeV2Tool.ts 里:
if (isTeammate() && isPlanModeRequired()) {
...
const approvalRequest = {
type: 'plan_approval_request',
...
}
await writeToMailbox('team-lead', ...)
return {
data: {
awaitingLeaderApproval: true,
...
},
}
}
这说明对于 swarm/team 协作场景:
plan-and-execute 甚至可以不是“用户审批”,而是“leader 审批”。
而 --plan-mode-required 这个 CLI 参数,则是从运行时上强制该 teammate 必须先 plan 再 implement。
在 src/main.tsx 里也能看到这个选项:
program.addOption(
new Option('--plan-mode-required', 'Require plan mode before implementation')
)
6.4 退出 plan mode 后,不是切到另一个 executor,而是回到原来的 ReAct 主循环
退出 plan mode 时,ClaudeCode 会恢复进入前的权限模式:
let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default'
...
toolPermissionContext: {
...baseContext,
mode: restoreMode,
prePlanMode: undefined,
}
同时还会注入一个 plan_mode_exit 提示:
You have exited plan mode. You can now make edits, run tools, and take actions.
所以 Execute 阶段本质上就是:
- 通过
ExitPlanMode获得批准; - 恢复到
default或原来的auto; - 再回到同一个 ReAct 主循环里继续正常做事。
这也是本文最重要的一个结论:
ClaudeCode 的 Plan-and-Execute 不是 “planner 线程 + executor 线程” 两套引擎,而是 “先在 ReAct 里受限规划,再在同一个 ReAct 里恢复执行”。
7. ClaudeCode 到底依据什么判断“该直接做,还是先 plan”
把前面的代码合起来,判断依据其实可以总结成四层。
7.1 第一层:显式用户意图
如果用户:
- 手动敲了
/plan; - 明确要求“先给方案”“先规划一下”“别急着改代码”;
那么 ClaudeCode 会直接进入 plan mode。
这是最硬的一层信号。
7.2 第二层:任务特征是否足够复杂/模糊
如果是模型主动判断,核心依据就是 EnterPlanModeTool prompt 里列出来的那几类任务特征:
- 新功能;
- 多种合理路径;
- 会改现有结构;
- 涉及架构抉择;
- 多文件改动;
- 需求不清,需要先探索;
- 用户偏好会显著影响实现方案。
也就是说,它不是拿“任务长度”判断,而是拿:
歧义度、影响面、结构性风险、返工成本
来判断。
7.3 第三层:当前模式本身也会影响判断
如果处于 auto mode,源码里还会额外注入一句很强的指导:
Prefer action over planning — Do not enter plan mode unless the user
explicitly asks. When in doubt, start coding.
这意味着在 auto mode 里,ClaudeCode 的默认倾向是:
- 先做;
- 少打断;
- 少开 plan mode;
- 除非用户明确要求,或者确实存在重大歧义。
所以“要不要 plan”不是静态判断,还会被当前 permission mode 改写。
7.4 第四层:协作/团队约束
如果是 teammate 且 plan_mode_required=true,那这个判断已经不是模型自由选择了,而是被运行环境强制:
必须先进 plan mode,拿到 approval 才能实施。
这种场景更像企业流程管控,而不是 agent 自己的自由判断。
8. ReAct 和 Plan-and-Execute 的关系,可以怎么理解
如果把这两套机制放在一起看,可以把 ClaudeCode 理解成下面这张图:
flowchart TD
A["用户请求"] --> B["query() 主循环"]
B --> C["模型输出 assistant"]
C --> D{"是否出现 tool_use"}
D -->|否| E["结束当前回合"]
D -->|是| F["执行工具"]
F --> G["把 tool_result 追加回 messages"]
G --> B
A --> H{"是否进入 plan mode"}
H -->|否| B
H -->|是| I["只读探索 + 写 plan file + AskUserQuestion"]
I --> J["ExitPlanMode 请求审批"]
J --> K{"审批通过?"}
K -->|否| I
K -->|是| B
这张图其实表达了一个很实用的工程事实:
- ReAct 是底层循环;
- Plan-and-Execute 是这层循环上的 gating 和 workflow;
- “Plan”阶段和“Execute”阶段,本质上都还是同一个
query()在跑,只是上下文约束不同。
9. 扩展:ClaudeCode 里还有更重型的 planning 形态
除了本地 plan mode,ClaudeCode 还有一个更重型的 /ultraplan。
src/commands/ultraplan.tsx 里直接写了:
Advanced multi-agent plan mode with our most powerful model
它会把 planning 放到 Claude Code on the web 侧远程跑,甚至支持更长时间、多 agent 的 planning。
不过从本文主题看,/ultraplan 更像是:
Plan-and-Execute 的远程增强版
而不是 ClaudeCode 日常默认的主执行机制。
日常主线还是本文前面分析的那套:
- 普通请求:默认 ReAct;
- 需要方案对齐:进入本地 plan mode;
- 获批后:回到 ReAct 执行。
10. 最后总结
如果只记住三句话,我觉得最重要的是这三句:
ClaudeCode 默认就是 ReAct。
query.ts的主循环本质上就是 assistant 产出tool_use,系统执行工具,再把tool_result回灌给下一轮。Plan-and-Execute 不是第二套执行器。
它是在同一个 ReAct runtime 上,加了一层plan mode + plan file + approval flow + system reminder。什么时候进入 plan mode,取决于歧义、影响面、返工风险和显式用户意图。
任务足够明确时,ClaudeCode 倾向直接做;任务存在重大路径分歧或需要先对齐方案时,ClaudeCode 才会切到 planning phase。
如果从 agent 工程角度看,这套设计其实很聪明:
- 默认执行路径保持简单,永远只有一个主循环;
- 复杂任务时,不需要切换到另一套“planner framework”;
- 只要通过 permission mode 和 message injection,就能让同一个 runtime 同时支持“直接执行”和“先规划再执行”。
这也是 ClaudeCode 源码里很值得学的一点:
不要急着把 planning 和 execution 做成两套系统。很多时候,一个稳定的 ReAct 主循环,加上一层明确的 planning workflow,就已经足够强。