给Agent CLI加上Plan-and-Execute,让Agent先规划后执行,支持DAG。
大家好,我是二哥呀。
第 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 dependencies; // 依赖的其他任务ID
private final List 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 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 visited, Set 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 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 的能力,比如用户说了“不要联网”就收起联网工具;不用来替用户...
企业级Agent工作流编排项目PaiFlow
Vibe Coding版本的PaiAgent
派聪明RAG AI知识库Java版本+Go版本
微服务 PmHub、技术派、MYDB
求职派JobClaw(OpenClaw/Hermes架构
PaiCLI(类似Claude Code的Agent
派简历(代码已完成)
等实战项目。
1. 微信扫右侧的优惠券加入知识星球
2. 解锁星球的实战项目教程和源码: 项目源码+教程获取
热门评论
65 条评论
回复