3. 规划,让 Agent 先想后做 - Plan

为什么需要规划?

在之前实现的AgentLoop中,LLM面对简单的任务已经能处理的很好,但是在面对复杂任务的时候,比如“为项目添加一个xxx模块”,LLM 容易迷失在项目实现细节中,甚至产生幻觉,忘记最初的任务目标。Claude Code 当时收到了很多用户的反馈,他们使用最多的就是告诉 Agent:“先不要编码”,所以最初的 Plan 功能就是简单的告诉 LLM 先不要输出编码,等待规划完成后才进行编码。

规划的实现

这里我引入了一个规划阶段,通过 createPlan 函数,将 task 分解成数个 subtasks,有几个要点:

  • 使用 LLM进行规划任务:Plan 本身也是一次LLM调用,但是只携带了只读的工具,这样 LLM 就能探索项目的目录和文件,进行任务规划。
  • 输出 & 降级:通过 Prompt 要求 LLM 结构化输出,然后对 LLM 的输出结果进行校验,如果校验失败则降级到 [task]。这种防御性编程在 Agent 开发中非常重要,经过设计可以让 Agent 自己修复问题(Self Healing)。
import { agentLoop } from "./core";
import { ChatMessage } from "./type";
import { readOnlyTools } from "./tools";
import z from "zod";
 
const schema = z.object({
  subTasks: z.array(z.string()),
});
export async function createPlan(task: string): Promise<string[]> {
  console.log("[Planning] Start Planning...");
  const msg: ChatMessage[] = getPlanMessage(task);
  const { result } = await agentLoop(`[Task]: ${task}`, msg, undefined, {
    tools: readOnlyTools,
  });
  try {
    const json = JSON.parse(result);
    const subTasks: string[] = schema.parse(json)?.subTasks;
    console.log("[Plan] Created:");
    console.log(subTasks.map((t, i) => `${i + 1}. ${t}`).join("\n"));
    return subTasks;
  } catch (e) {
    console.log(
      "[Plan] Failed to create plan. Let's just use the original task as the plan.",
    );
    return [task];
  }
}
 
function getPlanMessage(task: string): ChatMessage[] {
  return [
    {
      role: "system",
      content: `Break down the task into 3-5 simple, readable, actionable steps. Must include test. Return a JSON array of strings.
Schema:
{
  "subTasks": string[],
}
- Return ONLY valid JSON
- No Markdown
- No Explanation
- No Command
 
**Available Tools:**
${readOnlyTools.map((t) => `- ${t.type}: ${t.type}`).join("\n")}
 
Important Notes:
- ONLY use the tools listed above
- DO NOT create, write, edit, modify or delete file during planning
`,
    },
  ];
}

ReAct 和 Plan-Then-Execute 范式

Agent Loop 和 Plan 展示了 Agent 领域的两种范式,即 ReAct 和 Plan-Then-Execute。ReAct 灵活,但是容易迷失,Plan-then-Execute有全局视角,但是规划可能不准确,而且随着运行,计划可能会有变动。

Plan 模式,正如流程图所绘,先通过LLM获得subtasks,然后用subtask循环执行多次 ReAct,通过控制上下文的方式,强行让 ReAct 每次执行一个 subtask。上下文传递是多步执行的关键,通过传递上下文,不同的 ReAct 能够共享信息并执行任务。Plan 模式不在于 “先写一个大纲”(图中的 Simple Plan-Then-Execute),而在于先“建立约束和分工”,Plan 是为了让迭代不乱跑。

Plan 模式存在一些争议,很多业内人士认为,应该依赖于模型自身的规划能力,而非强制拆分任务,来充分发挥模型的能力。

Plan 模式的改进 - Plan-as-Tool

每次使用 Agent 的时候进行复杂任务的时候,都手动切换使用 plan 模式,或者有时候忘记了切换到 plan 模式,导致任务的执行效果不佳。不如把任务交给 Agent,让它自己

实现

我们可以给 Agent 提供一个 plan 工具。传入参数是用户输入的任务,这个工具能够分解一个复杂任务,创建一个多个子任务,并分步执行。

在如下 plan 工具的实现中,如果 Agent 认为任务复杂,则会使用 plan 工具,plan 工具会将任务分解并分派执行,最终将结果汇总给 Agent。我通过传入不同的工具,即具体执行任务的 LLM 不具备 Plan 工具,来防止无限调用。

import { createPlan } from "../../plan";
import { agentLoop } from "../../core";
import { subtaskPrompt } from "../../prompt";
import { ChatMessage } from "../../type";
import { ToolKit } from "..";
import { loadMcpTools } from "../../mcp";
import { EventStream, EventType } from "../../eventStream";
 
const eventStream = EventStream.getInstance();
 
interface PlanArgs {
  task: string;
}
 
export const planFunc = async (args: PlanArgs): Promise<string> => {
  const { task } = args;
  eventStream.submit({ type: EventType.PLANNING, message: `Creating plan for task`, data: { task } });
  const tasks = await createPlan(task);
  const taskResults = [];
 
  // 为 subtask 创建专用的系统消息(不包含 plan 工具提示)
  const subtaskSystemMessages: ChatMessage[] = [
    {
      role: "system",
      content: subtaskPrompt,
    },
  ];
 
  for (let i = 0; i < tasks.length; i++) {
    const t = tasks[i];
    eventStream.submit({ type: EventType.PLANNING, message: `Executing subtask ${i + 1}/${tasks.length}`, data: { subtask: t } });
    const mcpTools = loadMcpTools();
    const res = await agentLoop(
      `[Subtask ${i + 1} of ${tasks.length}]: ${t}`,
      subtaskSystemMessages,
      undefined,
      {
        tools: [...ToolKit.executeTools, ...mcpTools],
      },
    );
    taskResults.push(res);
  }
 
  const res = `[Plan Execution Results]\n${taskResults
    .map((r, i) => `Task ${i + 1}: ${r.result}`)
    .join("\n")}`;
  eventStream.submit({ type: EventType.PLANNING, message: `Plan execution completed`, data: { totalTasks: tasks.length } });
  return res;
};