二哥项目资料

给Agent CLI加上Plan-and-Execute,让Agent先规划后执行,支持DAG。

实战篇 29,914 字 #03 原文
本文目录 · 49 节

大家好,我是二哥呀。

第 1 期的 ReAct 已经能干活了,读文件、改代码、跑命令,一步接一步。

可任务一长,比如“读 pom.xml、看看源码结构、再写一份升级方案”,问题就来了。我们在 Agent 动手之前不知道它打算怎么做,互不相干的几步也没法同时跑。中途哪一步错了,只能等它自己发现。

这一期我们给 PaiCLI 加上 Plan-and-Execute。模型先出一份带依赖关系的任务清单,我们确认之后,PaiCLI 按依赖分批执行这些任务,互不依赖的任务还可以并行。

这是二期重构后的教程版本,一期未重构之前的翻到后面,会更简单,更容易懂。二期做了很多加固和升级。

01、先规划再执行

Plan-and-Execute 把一次任务交给两个角色。规划器(Planner)负责出计划,它调用模型时不给任何工具,只要求模型输出一份 JSON 格式的计划。执行器(PlanExecuteAgent)拿着这份计划,按依赖关系一批一批地跑任务。正常情况下规划器只调用一次模型;用户审阅时补充要求,或者任务失败后需要重新规划,规划器会再调用模型。

“Plan 模式”在不同产品里做法不一样。Pi 是一款开源的终端编程 Agent,它的 Plan 模式只做只读调研,通过 setActiveTools 把 edit 和 write 两个写文件工具拿掉,bash 也只放行只读命令。PaiCLI 的做法不同,只有规划器调用模型时不带工具,执行器执行每个任务时,读写文件、执行命令这些工具都照常可用。两种做法的对比可以阅读《Plan模式死了吗?》

每个任务内部,跑的还是第 1 期 ReAct。模型决定调用什么工具,工具结果交回模型,直到模型不再调用工具,这个任务就算完成。

在 Agent 开始动手之前,我们能看到完整的计划,可以确认、补充要求或者直接取消。读 pom.xml 和列出 src 目录这种没有先后关系的任务,可以同时跑。每个任务拿到的上下文也更干净,只有总目标、任务描述和直接依赖的结果。

两种模式放在一起比一下。

对比项 ReAct Plan-and-Execute
模型调用次数 由 Action 轮数决定 多一次规划,每个任务至少一次
耗时 取决于 Action 轮数 多一次规划等待,独立任务并行时可能更快
执行前能否看到步骤 不能 能,还能补充要求后修改计划
适合的任务 简单任务、边看边决定的探索 步骤多、依赖明确、想先看清流程的任务

02、计划长什么样

一个任务就是一个节点。

// src/main/java/com/paicli/plan/Task.java
public class Task {
    private final String id;
    private final String description;
    private final TaskType type;
    private volatile TaskStatus status;
    private volatile String result;
    private volatile String error;
    private final List<String> dependencies;  // 依赖的其他任务ID
    private final List<String> dependents;    // 依赖此任务的其他任务ID
}

任务类型有 5 种,FILE_READ、FILE_WRITE、COMMAND、ANALYSIS、VERIFICATION。执行任务时,任务类型会填进发给模型的提示词里,提醒模型这一步是读任务、写任务还是分析任务。类型不限制模型能用哪些工具。

dependencies 记录这个任务依赖哪些任务,用来判断它能不能开始执行;dependents 记录哪些任务依赖它,这个任务失败时,顺着 dependents 就能找出所有受影响的任务。下文把依赖某个任务的任务叫作它的“下游”。

任务状态有 5 种。

PENDING → RUNNING → COMPLETED / FAILED
PENDING → SKIPPED(依赖的任务失败,或者重新规划次数达到上限,见第 07 节)

能不能开始执行,只看一个条件,所有依赖是否都已经完成。

public boolean isExecutable(Map<String, Task> allTasks) {
    if (status != TaskStatus.PENDING) return false;
    for (String depId : dependencies) {
        Task dep = allTasks.get(depId);
        if (dep == null || dep.getStatus() != TaskStatus.COMPLETED) {
            return false;
        }
    }
    return true;
}

依赖失败或者被跳过,下游会等不到 COMPLETED,所以失败处理必须主动把下游标成 SKIPPED,不然会一直停在 PENDING。

并行执行时有两类线程。调度线程是运行执行器主循环的那个线程,任务的状态和结果都由它写,任务开始前标记为 RUNNING,收回结果后标记为完成或失败。工作线程是线程池里真正执行任务的线程,它们只读取依赖任务的结果。调度线程写完再把任务提交给线程池,工作线程执行完,调度线程通过 Future.get() 取回结果。Java 保证提交之前的写入对工作线程可见,工作线程的写入在 Future.get() 返回后对调度线程可见,所以不会读到旧值,字段上的 volatile 算多一层保险。

拓扑排序和环检测

多个任务组成一个执行计划(ExecutionPlan),任务按插入顺序存在 LinkedHashMap 里,另外维护一份拓扑排序后的顺序。拓扑排序是把有依赖关系的任务排成一个序列,保证每个任务都排在它依赖的所有任务后面。

PaiCLI 的拓扑排序用 DFS(深度优先搜索)后序遍历实现。“后序”的意思是,先递归处理完一个任务的所有依赖,再把这个任务本身加进结果,所以得到的顺序天然就是依赖在前。

// src/main/java/com/paicli/plan/ExecutionPlan.java
private boolean topologicalSort(Task task, Set<String> visited, Set<String> visiting) {
    String id = task.getId();
    if (visiting.contains(id)) {
        return false;  // 有环
    }
    if (visited.contains(id)) {
        return true;
    }
    visiting.add(id);
    for (String depId : task.getDependencies()) {
        Task dep = tasks.get(depId);
        if (dep != null && !topologicalSort(dep, visited, visiting)) {
            return false;
        }
    }
    visiting.remove(id);
    visited.add(id);
    executionOrder.add(id);
    return true;
}

visiting 是当前递归栈上的节点,递归中又碰到它,说明依赖绕回来了,比如 A 依赖 B,B 依赖 C,C 又依赖 A。visited 是已经处理完的节点,避免重复遍历。

排出来的顺序不直接决定谁先跑。调度靠每一轮重新筛可执行的任务,拓扑顺序只负责给同一轮的任务排先后,另外决定计划展示、跳过标记和失败汇总里的列出顺序。

计划本身也有状态,CREATED、RUNNING、COMPLETED、FAILED、CANCELLED,用户按 ESC 中断执行时标成 CANCELLED。

03、凭什么信任模型给的计划

计划是模型写的。规划器先把要求讲清楚,再对模型交回来的东西严格验收。

先看规划请求。

// src/main/java/com/paicli/plan/Planner.java
public ExecutionPlan createPlan(String goal) throws IOException {
    out.println("📋 正在规划任务: " + goal + "\n");
    return requestPlan(goal, "请为以下任务制定执行计划:\n" + goal);
}

private ExecutionPlan requestPlan(String goal, String planningRequest) throws IOException {
    List<LlmClient.Message> messages = Arrays.asList(
            LlmClient.Message.system(promptAssembler.assemble(PromptMode.PLANNER, PromptContext.builder()
                    .projectMemoryContext(buildProjectMemoryContext())
                    .build())),
            LlmClient.Message.user(planningRequest));
    LlmClient.ChatResponse response = llmClient.chat(messages, null, streamRenderer);
    return parsePlan(goal, response.content());
}

规划请求只有两条消息。system 消息是拼装好的规划提示词,user 消息是“请为以下任务制定执行计划”加上用户的目标。chat 的第二个参数传 null,表示规划请求不带任何工具。终端上流式显示模型的思考过程,JSON 正文不直接输出。

规划提示词

提示词放在 prompts/modes/planner.md,拼装时带上 PAI.md。PAI.md 是放在项目根目录下的项目规则文件,写着团队希望 Agent 遵守的约定,第 3 期细讲。提示词里给了 JSON 格式的示例、5 种任务类型,结尾强调“只输出 JSON,不要有其他内容”。其中和拆分粒度有关的规则如下(节选,编号和原文件一致)。

5. 简单任务允许只生成 1-3 个任务,不要为了凑步数引入无关步骤。
6. 复杂任务拆分为 5-10 个子任务。
7. 不要为了“保存中间结果”额外创建 FILE_WRITE / FILE_READ,除非用户明确要求落盘。
8. 如果一个任务一步就能完成,就保持最短计划。
9. 没有依赖关系的任务会并行执行。会写同一个文件的任务不能同时执行,必须让后一个依赖前一个。

一步就能完成的目标也交给模型规划

/plan 列出当前目录的文件 这条输入,一步就能完成,规划器向模型发一次规划请求。上面第 5 条和第 8 条规则规定了这种情况怎么处理,一步能完成就保持最短计划,所以模型交回来的计划只有一个任务。

在调用模型之前用规则判断“这个任务很简单,不用规划”,能省掉这一次规划请求。但规则只能检查输入里有没有某些词、字数有多少,判断不了一条输入实际要做哪几件事。下面两条输入都很短,也都不含“然后”、“最后”这类表示多个步骤的词,按规则都会被当成一步完成的任务。

“删除 src 下所有文件”会删掉一整个目录下的文件。按规则直接生成单任务计划,审阅时用户看到的只有这一行原文,计划里没有原文之外的任何信息,也就起不到审阅的作用。

“执行 mvn test,修复失败的测试”要先运行测试、再改代码,是两个有先后关系的步骤。按规则它只会变成一个任务,计划里看不出这层依赖。

所以 PaiCLI 定了一条原则,根据用户原话做的判断,只用来收紧 Agent 的能力,比如用户说了“不要联网”就收起联网工具;不用来替用户跳过步骤。规划要不要拆、拆成几个任务,一律交给模型判断,再由紧接着的“严格校验”这一小节讲的规则把关。

代价是每条输入多一次规划请求。规划请求不带工具,模型只输出一段计划 JSON,比执行任务时的请求轻得多。如果觉得一条简单输入也要规划太麻烦,按 Shift+Tab 切回 auto 模式即可。auto 模式是 PaiCLI 启动时的默认模式,这个模式下的普通输入直接交给 ReAct 执行,不经过规划器。

严格校验

模型交回来的计划,任何一项不合格都直接失败。下面的代码从 Planner.parsePlan 里摘出几段关键判断,省略了变量声明。

String cleaned = planJson.replaceAll("```json\\s*", "")
        .replaceAll("```\\s*", "")
        .trim();
JsonNode root;
try {
    root = mapper.readTree(cleaned);   // JSON 后面跟说明文字、键重复都算不合法
} catch (JsonProcessingException e) {
    throw new IOException("计划不是合法的 JSON:" + e.getOriginalMessage(), e);
}
// 第一遍:登记所有 id,重复即失败
if (idMapping.putIfAbsent(originalId, "task_" + taskIndex++) != null) {
    throw new IOException("计划中存在重复任务 id");
}
// 第二遍:建立依赖,引用未声明的 id 即失败
if (!depNode.isTextual() || !idMapping.containsKey(depNode.asText())) {
    throw new IOException("计划依赖必须引用已声明的任务 id");
}
// 最后:拓扑排序失败说明有环
if (!plan.computeExecutionOrder()) {
    throw new IOException("计划中存在循环依赖");
}

解析需要两次遍历。因为模型可能先写 task_2,再写 task_1,而 task_2 依赖 task_1。第一遍只登记 id,第二遍再连依赖,这样一个任务引用了排在它后面才声明的 id,也不会被误判成“引用了不存在的任务”。不管模型给的 id 叫什么,最后都按数组顺序重新编号成 task_1 到 task_N。

解析失败不自动重试,不退回 ReAct,直接返回“❌ 执行失败”和具体原因。

04、任务按什么顺序跑,哪些能并行

计划通过审阅后,执行器按轮次调度。每一轮取出所有依赖已完成的任务,这一轮全部跑完,再算下一轮。

// src/main/java/com/paicli/agent/PlanExecuteAgent.java,executePlan 节选
while (!stopAfterBatch) {
    if (CancellationContext.isCancelled()) {
        plan.markCancelled();
        return CANCELLED_PLAN_MESSAGE;
    }
    List<Task> executableTasks = getExecutableTasksInOrder(plan);
    if (executableTasks.isEmpty()) {
        break;
    }
    List<TaskExecutionResult> batchResults = executeTaskBatch(
            plan, executableTasks, streamState, taskTrustedUrls, observation);
    // 取消会打断本轮正在执行的任务,这些中断不是任务本身失败,不能再触发重新规划
    if (CancellationContext.isCancelled()) {
        plan.markCancelled();
        return CANCELLED_PLAN_MESSAGE;
    }
    for (TaskExecutionResult batchResult : batchResults) {
        // 成功就标记完成;失败就判断是重新规划,还是跳过它的下游(第 07 节)
    }
}

一轮只有一个任务时,直接在当前线程执行。多个任务就开一个线程池并行。

out.println("⚡ 本轮并行执行 " + executableTasks.size() + " 个任务: " + parallelTaskIds);
ExecutorService executor = Executors.newFixedThreadPool(Math.min(executableTasks.size(), 4), r -> {
    Thread t = new Thread(r, "paicli-plan-executor");
    t.setDaemon(true);
    return t;
});

为什么不就绪一个派发一个

按轮次调度有个代价。某个任务的依赖早就完成了,也得等这一轮最慢的那个任务结束才能开始。改成事件驱动,哪个任务的依赖一到齐就立刻派发,总耗时能更短。

PaiCLI 选按轮次,是因为每一轮的边界都很清楚。哪些任务在同一轮开始,失败发生时哪些结果已经提交,都能按轮次复现出来。PaiCLI 的评测集里有一个评测重放器(e1_replay.py),它根据运行时记录下来的事件,把调度过程重新推演一遍,逐条检查执行器有没有按规则执行,比如“触发重新规划之后不能再有新任务开始”。评测集的做法见《我给 PaiCLI 出了 28 道题》。换成就绪即派发,一个任务失败时,同一批派发出去的其他任务可能还在跑,重新规划和跳过的规则都要重新定义。

对 PaiCLI 这种计划通常只有几个到十几个任务的场景,多等一轮的代价可以接受。

并行输出和写冲突

每个并行任务的输出先写进自己的缓冲区,整轮结束后按任务顺序一次性打印。代价是并行执行期间看不到这些任务的流式输出,只能看到“▶️ 并行任务 [task_x]”这样的提示。审批弹窗和自动审查的提示是例外。自动审查指 auto 模式下,执行命令前先让模型判断这条命令有没有风险,第 06 节会讲。这两类提示直接打到终端,不进缓冲区,因为要等用户确认或查看的内容不能拖到整轮结束才出现。

模型一次可能返回好几个工具调用,读文件、搜索这类只读工具最多 4 个并行,写文件、执行命令、MCP 调用一律按模型给出的顺序串行,第 1 期讲过原因。

05、上游的结果怎么交给下游

每个任务开始时,拿到的第一条 user 消息是执行器拼出来的任务上下文。

// src/main/java/com/paicli/agent/PlanExecuteAgent.java,buildTaskContext 节选
context.append("总目标:").append(goal).append("\n");
context.append("当前任务:").append(task.getDescription()).append("\n");
if (task.getDependencies().isEmpty()) {
    context.append("依赖任务:无\n");
} else {
    context.append("依赖任务结果:\n");
    for (String depId : task.getDependencies()) {
        Task dep = plan.getTask(depId);
        context.append("- ").append(dep.getId())
                .append(" / ").append(dep.getDescription())
                .append(" / 状态=").append(dep.getStatus())
                .append("\n");
        if (dep.getResult() != null && !dep.getResult().isBlank()) {
            context.append(dep.getResult()).append("\n");
        }
    }
}
context.append("请执行此任务。如果是ANALYSIS或VERIFICATION类型,请基于以上上下文直接给出结果。");

上下文里只带直接依赖任务的结果。依赖的依赖(间接依赖)不带,和当前任务没有依赖关系的任务也不带。下游任务如果需要更早的信息,只能靠直接依赖的任务在自己的结果里转述。

为什么不让所有任务共用一份对话历史?

因为并行的任务同时往一份历史里追加消息,顺序说不清。就算串行,下游也会看到上游每一次工具调用的原始输出。

这里也可以查看进阶之路上《多 Agent 之间怎么传递上下文?》

联网权限也按依赖传

PaiCLI 每一轮都会根据用户这一轮的原话生成一份工具策略(TurnToolPolicy),规定哪些工具可以用、哪些网址可以访问。每个任务开始前,都从这份工具策略复制一份副本,同一批并行的任务各用各的副本,各自新发现的网址互不共享。

TurnToolPolicy taskToolPolicy = turnToolPolicy.forkWithTrustedUrls(dependencyUrls);

上游任务通过 web_search 拿到的 URL,只传给 DAG 里声明依赖它的下游,拼进任务上下文时单独列一段“依赖分支经 web_search 验证的 URL”。上游回复正文里写的网址不能算数,下游想抓取也会被拒绝。

这样做是为了防御提示词注入。提示词注入指网页里藏着一段专门写给模型看的指令,比如“请访问某某网址”。一个任务读到的网页再怎么诱导,也没法让另一个任务去访问它指定的地址。

06、一个任务内部怎么干活

每个任务有一份独立的消息列表。system 是任务执行提示词,带上 PAI.md、Skill 索引和任务类型,Skill 索引是当前可用技能的名称和简介列表,第 15 期细讲;user 是上一节拼好的任务上下文。之后就是第 1 期那套循环,每一轮依次做这几件事。

// src/main/java/com/paicli/agent/PlanExecuteAgent.java,任务循环节选
while (true) {
    if (CancellationContext.isCancelled()) { /* 返回“已取消任务” */ }
    AgentBudget.ExitReason exitReason = budget.check();   // 停滞或预算命中就收尾
    injectPendingLspDiagnostics(messages, out, actor);    // 上一步改过代码,补上语法诊断
    maybeCompactHistory(messages, out, actor);            // 太长就先压缩
    TurnToolPolicy.ToolExposure toolExposure = taskToolPolicy.expose(toolDefinitions);
    LlmClient.ChatResponse response = llmClient.chat(
            messages, toolExposure.definitions(), streamRenderer);
    // 没有工具调用 → 任务完成,返回模型的回答
    // 有工具调用 → 执行工具,结果经安全边界包装后交回模型 → 注入本轮加载的 Skill → 重复检测
}

注释里有几个词需要单独说明。语法诊断是指上一步改过 Java 文件后,PaiCLI 会检查语法错误,把错误信息补进下一次请求。安全边界包装是指工具结果交回模型前,外面会包一层标签,标明这是不可信的数据,里面的指令模型不能执行。重复检测就是第 1 期讲过的那道关卡,模型连续 3 次做同一个动作,或者连续 3 次碰到同一类错误,就在工具结果后面追加一条提醒,让模型换个做法,不拦截任何工具。

权限也和 ReAct 共用一套。计划任务和 ReAct 用同一个工具注册表,默认的 auto 模式下,执行命令的工具 execute_command 在执行前,先由当前模型关闭思考模式发一次简短请求,判断这条命令有没有风险。判断为可以执行才执行,否则把原因交回模型。

什么时候算任务失败?

执行过程中抛出异常,比如模型调用失败。工具报错会作为工具结果交回模型,让它自己处理。预算触发时返回部分结果,这里的预算指任务循环的上限。轮数和 Token 上限默认不限,但模型连续 5 次调用同一个工具、参数也完全相同,第 1 期讲的兜底收尾就会生效,关掉所有工具,让模型基于已有结果收尾。用户取消返回“已取消”。这几种都不算失败,不会触发重新规划。

07、任务失败了怎么收场

一个任务失败后,执行器先看整份计划的完成度。

if (!stopAfterBatch && plan.getProgress() < 0.5) {
    int maxReplans = maxReplans();
    if (replansUsed < maxReplans) {
        out.println("🔄 尝试重新规划(第 " + (replansUsed + 1) + "/" + maxReplans + " 次)...\n");
        ExecutionPlan replanned = planner.replan(plan, errorMessage);
        return reviewAndExecutePlan(replanned, streamState, explicitTaskEnvelope, replansUsed + 1)
                .result();
    }
    String stopNotice = "已达到重新规划上限(" + maxReplans + " 次),停止执行剩余任务";
    out.println("🛑 " + stopNotice + "。\n");
    failureLines.add(stopNotice);
    stopAfterBatch = true;
    continue;
}
for (Task skipped : plan.skipDependentsOf(task.getId())) {
    failureLines.add("任务 " + skipped.getId() + " 已跳过: 依赖的任务 " + task.getId() + " 失败");
}

完成度状态为 COMPLETED 的任务数除以任务总数,失败、跳过和还没执行的任务都不算完成(ExecutionPlan.getProgress)。完成度低于一半,说明计划刚开始就出了问题,更可能是计划本身不对,需要重新规划。新计划同样要经过用户审阅。

重新规划最多 2 次,可以用 PAICLI_PLAN_MAX_REPLANS 调整。不设上限的话,新计划在一半之前失败,就会一直规划下去。到了上限,不再启动任何新任务,只保留这一轮已经返回的结果,其余任务全部标 SKIPPED。

完成度过半,说明大部分工作已经做完,没必要推倒重来。失败任务的直接和间接下游标记成 SKIPPED,不受影响的任务继续跑。

重新规划时模型知道什么

context.append("原任务: ").append(failedPlan.getGoal()).append("\n");
context.append("失败原因: ").append(failureReason).append("\n");
context.append("已完成的任务:\n");
for (Task task : failedPlan.getAllTasks()) {
    if (task.getStatus() == Task.TaskStatus.COMPLETED) {
        context.append("- ").append(task.getId())
                .append(": ").append(task.getDescription()).append("\n");
    }
}
context.append("\n请制定新的执行计划,避开之前的问题。");
return createPlan(context.toString());

模型只知道哪些任务做完了,看不到它们的结果。新计划是一份全新的计划,不继承旧计划的完成状态,所以没法保证只重做失败的那一步。审阅新计划时要留意有没有重复操作。

为什么不把结果也带上?

规划请求要尽量小而固定。一旦带上结果,读过大文件的任务会把规划请求撑得很长很长,而这份请求的格式也是评测重放器逐字核对的内容。这是 PaiCLI 目前明确保留的限制,要改得连重放器一起改。

用户取消的时候

用户按 ESC 取消时,正在跑的任务会被打断,这些任务会以异常的形式回来。如果按普通失败处理,完成度又不到一半,执行器就会返回“🔄 尝试重新规划”,在用户已经放弃的任务上再调用一次模型。

所以一轮结果回来后,执行器先检查取消状态,已取消就把计划标成 CANCELLED,直接返回“⏹️ 已取消当前计划执行。”,不进入失败处理。

汇总

有任务失败时,汇总先列已完成任务的结果,再列失败和跳过的原因。已经流式显示过的结果不再重复打印。格式如下,内容为示意。

⚠️ 计划部分完成,有任务失败。
已完成的任务结果:
[task_1] pom.xml 使用 Maven 构建,Java 17
[task_2] src/main/java 下共有 3 个包
任务 task_3 失败: 读取 README.md 超时
任务 task_5 已跳过: 依赖的任务 task_3 失败

08、怎么进入计划模式,计划怎么审

PaiCLI 启动后默认为 auto 模式。进入 Plan-and-Execute 有三种方式。

  • /plan:只有下一条任务走计划模式,执行完回到原来的模式
  • /plan <任务>:这一条任务直接走计划模式
  • Shift+Tab 切到 plan 模式,或者输入 /mode plan:之后每条普通输入都先规划再执行,状态栏显示“PLAN shift+tab to cycle”,直到切到其他模式。

Shift+Tab 按 auto → plan → ask 循环。三种模式的区别在于哪些操作需要用户确认。auto 模式下读写文件直接执行,执行命令先经过模型审查;ask 模式下写文件、编辑文件、执行命令、创建项目等操作都要用户确认;plan 模式在哪些操作需要确认上和 auto 一样,只是多了一个“普通输入走计划”的开关。在 plan 模式下显式输入 /team,仍然走 Multi-Agent,本条输入里显式写的命令优先。

审阅

计划生成之后、执行之前,终端会停下来。默认显示折叠摘要,有目标、任务数、并行批次数、当前可执行和首批执行的任务。

📝 计划已生成。
   - 回车:按当前计划执行
   - Ctrl+O:展开完整计划
   - ESC:折叠或取消本次计划
   - I:输入补充要求,在当前计划上修改

Ctrl+O 展开带状态图标的完整任务图,ESC 在展开时折叠回摘要,在摘要里就是取消。执行期间这张图不会刷新,只输出逐行日志,比如“▶️ 执行任务”“⚡ 本轮并行执行 N 个任务”“✅ 完成”“❌ 失败”。

按 I 输入的补充要求,会连同当前计划一起交给模型,在当前计划上修改,不从头规划。

public ExecutionPlan revisePlan(ExecutionPlan current, String feedback) throws IOException {
    String revisedGoal = current.getGoal() + "\n补充要求:" + feedback;
    String request = "请为以下任务制定执行计划:\n" + revisedGoal
            + "\n\n下面是用户刚审阅过的当前计划。请在它的基础上按补充要求修改,"
            + "不受补充要求影响的任务保持原样,输出修改后的完整计划:\n"
            + toPlanJson(current);
    return requestPlan(revisedGoal, request);
}

为什么不按补充后的目标从头规划?

用户刚审过这份计划,只想改一两处,从头生成会把没问题的任务也换个说法,还得从头再审一遍。

终端读不到单个按键时会退回行模式。直接回车、y、run 都是执行,/view 展开,cancel 或 esc 取消,其他文字当作补充要求。

09、跑起来看看

编译运行。

mvn clean package
java -jar target/paicli-1.0-SNAPSHOT.jar

先来一个串行的例子,每一步都依赖上一步。

/plan 创建一个 Java 项目叫 demo,写一个 Hello 类输出 Hello World,然后编译运行

再来一个能并行的。

/plan 请把任务拆成可并行的 DAG:
1. 读取 pom.xml
2. 列出 src/main/java 下的文件
3. 列出 src/test/java 下的文件
4. 读取 README.md 的前 80 行
5. 最后汇总以上结果,告诉我这个项目的构建方式、源码结构和测试情况。
要求:前 4 个任务彼此独立并行执行,最后 1 个任务依赖前 4 个任务。

审阅时按 I,输入“汇总时用表格输出”,看模型怎么在原计划上修改。

最后按 Shift+Tab 切到 plan 模式,直接输入一条普通任务,不用再敲 /plan。

简历怎么写

PaiCLI|Java 终端 Coding Agent|核心开发 第 2 期

项目简介:在 ReAct Agent 的基础上实现 Plan-and-Execute 执行模式,由模型先生成带依赖关系的任务计划,用户审阅确认后按 DAG 分批并行执行,并支持计划修改、失败重新规划与部分结果汇总,适用于步骤多、依赖明确的本地研发任务。

技术栈:Java 17、Maven、Jackson、JLine3、ExecutorService、DAG 拓扑排序、Plan-and-Execute

核心职责:

  • 设计 Task 和 Execution Plan 模式,用双向依赖记录上下游关系,实现五种任务状态流转;基于 DFS 后序遍历完成拓扑排序与环检测。
  • 实现 Planner 规划器,规划请求不暴露工具,所有目标都交给 LLM 规划。
  • 实现 DAG 调度器,每轮筛选依赖已完成的任务,用线程池最多 4 路并行;并行任务输出先写入独立缓冲区,整轮结束后按拓扑顺序输出。
  • 设计任务间的 Context 传递机制,下游任务只接收直接依赖的完整结果,每个任务使用独立的工具策略副本,web_search 得到的可信 URL 只沿 DAG 声明的依赖向下游传递,防止跨分支的提示词注入攻击。
  • 完成度低于 50% 时基于已完成进度重新规划且默认最多 2 次,完成度过半时跳过失败任务,下游任务继续执行;用户取消时不触发重新规划,汇总保留已完成的结果和失败、跳过原因。
  • 集成 JLine3 实现计划审阅交互,支持回车执行、Ctrl+O 展开任务图、ESC 取消和补充要求后在原计划基础上修改,并接入 Shift+Tab 会话模式,plan 模式下普通输入自动走计划执行。

大家好,我是二哥呀。

PaiCLI 的第 1 期我们已经实现了,一个基础的 ReAct Agent,能一步一步执行任务,一边思考一边行动。

但这种方式有个问题:复杂任务需要很多轮对话,每一步都需要调用 LLM。

比如“创建一个 Spring Boot 项目,写个 REST API,然后打包运行”这个任务:

一共要调用 5 次 LLM,每次都要等网络往返。

第 2 期,我们来实现 Plan-and-Execute 模式:先让 LLM 制定完整计划,然后按步骤执行,中间不再反复询问 LLM。

01、Plan-and-Execute 的核心思想

Plan-and-Execute 模式来自论文《Plan-and-Solve Prompting》。

核心思想是规划和执行分离。

这样做的好处有:

  1. 减少 LLM 调用次数:规划一次,执行多次
  2. 可预测性更强:提前知道整个执行流程
  3. 支持并行执行:识别无依赖的任务并行处理
  4. 失败可重试:某步失败可以单独重试,不用从头来

代价是灵活性降低,如果执行过程中发现计划有问题,需要重新规划。

02、任务建模

要实现 Plan-and-Execute,首先要定义什么是“任务”。

为什么需要任务建模

在 ReAct 模式中,任务隐含在对话历史中。LLM 通过阅读历史消息知道当前该做什么。但这种方式有两个问题:

第一,上下文膨胀。复杂任务需要很多轮对话,随着历史消息越来越长,Token 的消耗剧增。

第二,状态不清晰。对话历史里混杂了思考过程、工具调用、执行结果,很难一眼看出任务执行到哪一步。

任务建模把“做什么”和“怎么做”分离开来。计划阶段确定“做什么”(任务列表),执行阶段解决“怎么做”(具体执行)。

Task 类设计

public class Task {
    private final String id;              // 任务唯一标识
    private final String description;     // 任务描述
    private final TaskType type;          // 任务类型
    private TaskStatus status;            // 执行状态
    private String result;                // 执行结果
    private String error;                 // 错误信息
    private final List<String> dependencies;  // 依赖的任务ID
    private final List<String> dependents;    // 被依赖的任务ID
    private long startTime;               // 开始时间
    private long endTime;                 // 结束时间
}

任务类型我们定义了 6 种:

  • PLANNING:规划任务,用于分析和决策
  • FILE_READ:读取文件,获取信息
  • FILE_WRITE:写入文件,输出结果
  • COMMAND:执行命令,编译运行等
  • ANALYSIS:分析结果,中间决策
  • VERIFICATION:验证结果,检查正确性

任务状态有 5 种:

  • PENDING:等待执行
  • RUNNING:执行中
  • COMPLETED:已完成
  • FAILED:执行失败
  • SKIPPED:被跳过(依赖失败)

任务的生命周期

一个任务从创建到完成,完整的生命周期如下所示:

PENDING → RUNNING → COMPLETED/FAILED/SKIPPED

每个状态转换都有对应的方法:

public void markStarted() {
    this.status = TaskStatus.RUNNING;
    this.startTime = System.currentTimeMillis();
}

public void markCompleted(String result) {
    this.status = TaskStatus.COMPLETED;
    this.result = result;
    this.endTime = System.currentTimeMillis();
}

public void markFailed(String error) {
    this.status = TaskStatus.FAILED;
    this.error = error;
    this.endTime = System.currentTimeMillis();
}

记录时间戳有两个用途:一是统计执行耗时,二是分析任务瓶颈。如果某个任务总是耗时很长,可能需要优化或者拆分。

依赖关系

复杂任务有先后依赖。比如“写代码”依赖“创建项目”,“运行”依赖“编译”。

我们用 DAG(有向无环图)表示依赖关系:

每个任务可以声明自己依赖哪些任务(dependencies),系统会自动计算出执行顺序。

依赖关系的核心方法是 isExecutable:

public boolean isExecutable(Map<String, Task> allTasks) {
    if (status != TaskStatus.PENDING) return false;
    for (String depId : dependencies) {
        Task dep = allTasks.get(depId);
        if (dep == null || dep.getStatus() != TaskStatus.COMPLETED) {
            return false;
        }
    }
    return true;
}

只有当所有依赖都已完成时,任务才可以执行。这个简单的检查保证了执行顺序的正确性。

任务类型我们定义了 6 种:

  • PLANNING:规划任务
  • FILE_READ:读取文件
  • FILE_WRITE:写入文件
  • COMMAND:执行命令
  • ANALYSIS:分析结果
  • VERIFICATION:验证结果

依赖关系

复杂任务有先后依赖。比如“写代码”依赖“创建项目”,“运行”依赖“编译”。

我们用 DAG(有向无环图)来表示依赖关系:

每个任务可以声明自己依赖哪些任务(dependencies),系统会自动计算出执行顺序。

执行计划

多个任务可以组成一个执行计划:

public class ExecutionPlan {
    private final String id;
    private final String goal;           // 计划目标
    private final Map<String, Task> tasks;  // 所有任务
    private final List<String> executionOrder;  // 执行顺序
    private PlanStatus status;
    private String summary;
}

拓扑排序算法

核心方法是 computeExecutionOrder(),使用拓扑排序算法把 DAG 转换成线性执行顺序。

拓扑排序的基本思想是:

  1. 找到所有入度为 0 的节点(没有依赖的任务)
  2. 把这些节点加入结果列表
  3. 移除这些节点及其出边
  4. 重复 1-3,直到所有节点都处理完

我们用 DFS 算法来实现:

public boolean computeExecutionOrder() {
    executionOrder.clear();
    Set<String> visited = new HashSet<>();
    Set<String> visiting = new HashSet<>();

    for (Task task : tasks.values()) {
        if (!visited.contains(task.getId())) {
            if (!topologicalSort(task, visited, visiting)) {
                return false;  // 有环
            }
        }
    }

    Collections.reverse(executionOrder);
    return true;
}

private boolean topologicalSort(Task task, Set<String> visited, Set<String> visiting) {
    String id = task.getId();

    if (visiting.contains(id)) {
        return false;  // 有环,排序失败
    }
    if (visited.contains(id)) {
        return true;
    }

    visiting.add(id);

    // 递归处理所有依赖
    for (String depId : task.getDependencies()) {
        Task dep = tasks.get(depId);
        if (dep != null) {
            if (!topologicalSort(dep, visited, visiting)) {
                return false;
            }
        }
    }

    visiting.remove(id);
    visited.add(id);
    executionOrder.add(id);
    return true;
}

算法用两个集合来跟踪状态:visiting 是当前递归栈中的节点,用于检测环;visited 是已处理完的节点,用于避免重复处理。

如果检测到环(visiting.contains(id)),说明任务依赖关系有问题,比如 A 依赖 B,B 依赖 C,C 又依赖 A。这种情况下计划无法执行,需要报错提醒。

计划状态管理

执行计划本身也有状态:

  • CREATED:刚创建,还没开始执行
  • RUNNING:正在执行中
  • COMPLETED:所有任务都完成
  • FAILED:有任务失败
  • CANCELLED:被取消

状态转换由执行结果决定:

public void markStarted() {
    this.status = PlanStatus.RUNNING;
    this.startTime = System.currentTimeMillis();
}

public void markCompleted() {
    this.status = PlanStatus.COMPLETED;
    this.endTime = System.currentTimeMillis();
}

public boolean hasFailed() {
    return tasks.values().stream()
        .anyMatch(t -> t.getStatus() == TaskStatus.FAILED);
}

计划级别的状态让用户能快速了解整体执行情况,不需要逐个检查任务。

03、规划器实现

规划器负责把用户输入的复杂任务分解成可执行的计划。

public class Planner {
    private final GLMClient llmClient;

    public ExecutionPlan createPlan(String goal) throws IOException {
        // 1. 构建规划提示
        List<Message> messages = Arrays.asList(
            Message.system(PLANNING_PROMPT),
            Message.user("请为以下任务制定执行计划:\n" + goal)
        );

        // 2. 调用 LLM 生成计划
        ChatResponse response = llmClient.chat(messages, null);

        // 3. 解析 JSON 计划
        return parsePlan(goal, response.content());
    }
}

规划提示词工程

关键是给 LLM 一个清晰的提示,让它输出标准格式的计划。

提示词设计有几个原则:

第一,明确输出格式。告诉 LLM 必须输出 JSON,并且给出完整示例。

请按以下JSON格式输出执行计划:
{
    "summary": "任务摘要",
    "tasks": [
        {
            "id": "task_1",
            "description": "任务描述",
            "type": "FILE_READ",
            "dependencies": []
        }
    ]
}

第二,定义任务类型。列出所有可用的任务类型和用途,让 LLM 知道什么场景用什么类型。

可用任务类型:
- FILE_READ: 读取文件内容,用于获取信息
- FILE_WRITE: 写入文件内容,用于输出结果
- COMMAND: 执行Shell命令,用于编译运行等
- ANALYSIS: 分析结果,用于中间决策
- VERIFICATION: 验证结果,用于检查正确性

第三,给出约束规则。明确任务的粒度、依赖关系的表达方式等。

规则:
1. 每个任务必须有唯一的id(如 task_1, task_2)
2. dependencies列出依赖的任务id
3. 任务应该按执行顺序排列
4. 任务描述要具体明确
5. 复杂任务拆分为5-10个子任务

解析 LLM 输出

LLM 输出 JSON 后,我们需要解析并构建 Task 对象。这里有个细节:LLM 生成的任务 ID 可能重复或格式不统一,我们需要重新映射。

private ExecutionPlan parsePlan(String goal, String planJson) throws IOException {
    // 清理可能的 markdown 代码块
    String cleaned = planJson.replaceAll("```json\\s*", "")
            .replaceAll("```\\s*", "")
            .trim();

    JsonNode root = mapper.readTree(cleaned);
    String summary = root.path("summary").asText();
    JsonNode tasksNode = root.path("tasks");

    ExecutionPlan plan = new ExecutionPlan(generatePlanId(), goal);
    plan.setSummary(summary);

    // 第一遍:创建任务,不处理依赖
    Map<String, String> idMapping = new HashMap<>();
    int taskIndex = 1;

    for (JsonNode taskNode : tasksNode) {
        String originalId = taskNode.path("id").asText();
        String newId = "task_" + taskIndex++;
        idMapping.put(originalId, newId);

        // 创建任务...
        Task task = new Task(newId, description, type);
        plan.addTask(task);
    }

    // 第二遍:处理依赖关系
    // ...
}

用两遍扫描的原因是:LLM 可能先定义 task_2,再定义 task_1,但 task_2 依赖 task_1。第一遍先创建所有任务,第二遍再建立依赖关系,避免前向引用问题。

重新规划

如果执行过程中某个步骤失败了,可以基于已完成的进度重新规划:

public ExecutionPlan replan(ExecutionPlan failedPlan, String failureReason) {
    // 构建上下文:已完成任务 + 失败原因
    String context = buildContext(failedPlan, failureReason);

    // 重新生成计划
    return createPlan(context);
}

这样即使中途出错,也不用从头开始,已完成的任务可以保留。

04、PlanExecuteAgent

现在把规划器和执行器整合起来,实现 PlanExecuteAgent:

public class PlanExecuteAgent {
    private final GLMClient llmClient;
    private final ToolRegistry toolRegistry;
    private final Planner planner;

    public String run(String userInput) {
        // 1. 创建执行计划
        ExecutionPlan plan = planner.createPlan(userInput);

        // 2. 显示计划
        System.out.println(plan.visualize());

        // 3. 执行计划
        for (String taskId : plan.getExecutionOrder()) {
            Task task = plan.getTask(taskId);
            executeTask(task);
        }

        // 4. 返回结果
        return buildResult(plan);
    }
}

智能模式切换

简单任务不需要规划。我们加一个启发式判断:

private boolean shouldPlan(String input) {
    // 包含多个动作关键词或长度超过50字符,需要规划
    String[] keywords = {"创建", "写", "读", "执行", "然后", "接着"};
    int actionCount = 0;
    for (String keyword : keywords) {
        if (input.contains(keyword)) actionCount++;
    }
    return actionCount >= 3 || input.length() > 50;
}

简单任务用 ReAct,复杂任务用 Plan-and-Execute,自动选择最优模式。

05、计划可视化

执行计划可以可视化展示,让用户清楚知道 Agent 要做什么:

执行过程中实时更新状态图标:⏳ → ▶️ → ✅/❌

06、运行测试

编译运行:

mvn clean package
java -jar target/paicli-1.0-SNAPSHOT.jar

输入 /plan 进入计划模式,然后输入提示词:创建一个 Java 项目叫 demo,写一个 Hello 类输出 Hello World,然后编译运行

规划了 5 个任务,task5 依赖 task4,task4 依赖 task3,task3 依赖 task2,task2 依赖 task1。

然后开始执行计划。

这里我们可以加一个交互,看看用户是否有要补充的,是否要修改计划,然后再开始执行计划。

直接让 Codex 帮我们来补全这一步。

有了。

整个流程清晰可见,每一步都知道在做什么。

07、和 ReAct 的对比

两种模式各有优劣,适用场景不同。

特性 ReAct Plan-and-Execute
LLM 调用次数 多(每步都调) 少(只调规划)
执行速度 慢(网络往返多) 快(本地执行多)
Token 消耗 高 低
灵活性 高(随时调整) 低(按 plan 执行)
可预测性 低 高
错误恢复 容易(随时改) 需要重规划
适用场景 简单/探索性任务 复杂/确定性任务

什么时候用 ReAct

  • 任务简单,1-3 步就能完成
  • 需要探索性操作,不确定具体步骤
  • 用户想看到思考过程
  • 需要频繁的人机交互

比如“查看当前目录有什么文件”,直接 ReAct 一步完成。

什么时候用 Plan-and-Execute

  • 任务复杂,需要多步操作
  • 步骤之间有明确依赖关系
  • 追求执行效率
  • 需要可预测的执行流程

比如“搭建一个完整的 Web 项目,包括前后端、数据库、部署”,用 Plan-and-Execute 更合适。

混合使用

实际产品中,两种模式可以混合使用:

1. 用 Plan-and-Execute 制定整体计划
2. 每个任务内部用 ReAct 执行
3. 如果某步失败,用 ReAct 分析原因并决定是重试还是重规划

这种混合模式兼顾了效率和灵活性,Claude Code 和 Codex 内部都是类似的架构。

08、进阶:并行执行

当前实现是顺序执行,但 DAG 中无依赖的任务可以并行。

// 获取所有可执行的任务(依赖都已完成)
List<Task> executableTasks = plan.getExecutableTasks();

// 并行执行
List<CompletableFuture<Void>> futures = executableTasks.stream()
    .map(task -> CompletableFuture.runAsync(() -> executeTask(task)))
    .toList();

// 等待全部完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

比如“创建项目”和“写 README”可以同时进行,进一步提升效率。

并行执行可能遇到的问题

并行执行可能会遇到这样的问题:

  • 资源冲突:两个任务同时写同一个文件,会导致数据丢失。
  • 输出混乱:两个任务的日志同时输出,用户看不清哪个是哪个。
  • 错误处理复杂:一个任务失败,其他正在执行的任务怎么办?

我们已经实现了哈。

提示词:

/plan 请把任务拆成可并行的 DAG:
1. 读取 pom.xml
2. 列出 src/main/java 下的文件
3. 列出 src/test/java 下的文件
4. 读取 README.md 的前 80 行
5. 最后汇总以上结果,告诉我这个项目的构建方式、源码结构和测试情况。
要求:前 4 个任务彼此独立并行执行,最后 1 个任务依赖前 4 个任务;在执行日志里明确提示哪些任务是并行执行的。

开始并行执行任务。

当所有并行任务结束后,开始汇总。

更智能的规划

当前的规划器只调用一次 LLM,生成完整计划。但复杂任务往往需要分层规划:

第一层规划:确定主要阶段
- 阶段1:环境搭建
- 阶段2:核心功能开发
- 阶段3:测试验证

第二层规划:细化每个阶段
- 阶段1包含:安装依赖、配置环境、初始化项目
- 阶段2包含:写模块A、写模块B、集成测试

分层规划的好处是:高层计划稳定,低层计划可以灵活调整。如果某个阶段的详细计划有问题,只需要重规划这个阶段,不影响整体。

规划的自我修正

LLM 制定的计划不一定完美,可能遗漏步骤、顺序错误、依赖不合理。我们需要规划的自我修正机制。

一种方法是规划验证:在执行前,用规则检查计划是否合理。

public List<String> validatePlan(ExecutionPlan plan) {
    List<String> errors = new ArrayList<>();

    // 检查是否有重复ID
    // 检查依赖是否存在
    // 检查是否有循环依赖
    // 检查任务类型是否合法

    return errors;
}

另一种方法是规划反馈:执行几步后,评估计划质量,必要时重新规划。

if (successRate < 0.5) {
    // 成功率太低,重新规划
    plan = planner.replan(plan, "前序任务成功率低");
}

PaiCLI 如何写到简历上?

PaiCLI 项目(第 2 期)| 2026.04 - 2026.06 | Agent 开发

项目描述:为 Agent CLI 加入 Plan-and-Execute 能力,实现任务分解、DAG 依赖管理和拓扑排序执行。

技术栈:Java 17、Maven、GLM-5.1 API、DAG 拓扑排序、JSON 解析

核心职责:

  • 设计 Task 任务模型,实现 6 种任务类型和 5 种状态流转,支持任务依赖双向追踪和 DAG 有向无环图表示
  • 实现基于 DFS 的拓扑排序算法,将任务 DAG 转换为线性执行顺序,能自动检测循环依赖并报错,确保任务按顺序执行;并使用线程池并发执行无依赖的多项任务,相比串行执行效率大幅提升
  • 开发 Planner 规划器,使用 LLM 将复杂任务分解为 5-10 个可执行子任务,通过 JSON 格式输出计划,实现 ID 映射和前向引用处理
  • 实现 PlanExecuteAgent,支持根据任务复杂度自动切换 ReAct 和 Plan-and-Execute 两种模式
  • 集成 JLine3 实现交互式命令行界面,支持命令历史(上下箭头)、Tab 自动补全、行编辑和语法高亮,用户体验接近原生 Shell