Plan-and-Execute:先规划再执行
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇收尾时留了一句话:Plan-and-Execute 模式天然具备阶段信号,到那时按任务阶段调整上下文会清晰得多。这一篇就来兑现这个承诺。
前面的篇幅,咱们从 ReAct 循环起步,一路搭建了工具调用、Function Calling、终止控制、短期记忆、持久化、长期记忆、上下文工程——TinyAgent 已经是一个能想、能干、能记住你是谁、还会精打细算 Token 的智能体了。但它的决策方式始终没变:一步一步走,走一步看一步。
这种方式在简单任务上表现不错。用户说“帮我查订单 88231 的物流”,Agent 查订单拿运单号、查物流拿轨迹、给出答复——三步搞定,每一步都很清晰。但如果用户说的是这样一句话呢?
我家老人想要一台扫地机,预算 2000 以内,要好操作的。另外帮我看看订单 88231 到了没,到了的话帮我把那台坏的退掉。
一句话里塞了两个独立任务,其中一个还带条件分支(到了才退)。ReAct 会怎么处理?一步一步摸索。问题是——它不知道前面还有多少步要走,也不知道两个任务之间该怎么安排顺序。这就是 ReAct 的天花板。
这一篇,咱们给 TinyAgent 装上规划能力——先想清楚要干什么、分几步、每步用什么工具,然后再一步一步执行。这就是 Plan-and-Execute 模式。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.10.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
ReAct 的天花板:走一步看一步的代价
在引入新方案之前,先把问题说透——ReAct 到底在什么场景下会拉胯?
1. 复杂任务的真实困境
拿开头那个例子来分析。用户一句话包含了两个子任务:
- 子任务 A:推荐一台 2000 以内、适合老人用的扫地机
- 子任务 B:查订单 88231 是否签收,如果已签收则申请退款
流程图如下所示:

ReAct Agent 拿到这句话后,第 1 圈的 Thought 里确实可以想“用户有两个任务,先处理退款再推荐”——ReAct 不禁止在 Thought 里做规划。但问题是,这个规划只存在于 Thought 的一段自然语言文字里,不会被结构化保存。随着轮次增加、Observation 堆积,这段最初的规划越来越容易被大模型的注意力机制淡化。假设它先查了订单:
第 1 圈:查订单 88231 → 拿到订单详情,状态“已签收”
第 2 圈:查物流确认签收 → 拿到物流轨迹
第 3 圈:申请退款 → 拿到退款单号
第 4 圈:……然后呢?
走到第 4 圈,messages 里已经堆了三轮退款相关的 Observation。虽然原始 user message 还在上下文里,推荐扫地机的需求不会凭空消失,但退款信息占据了大量上下文空间,大模型的注意力可能偏移——它有可能在这一圈就以为任务已完成,直接输出 Final Answer,漏掉推荐。更糟的是,如果中间某一圈大脑跳到推荐任务,查了知识库,然后下一圈又跳回退款——整条轨迹就变成了一条逻辑交错的线。
这不是 TinyAgent 的 bug,而是 ReAct 范式的结构性局限——它没有为多子任务提供显式的结构化保障,导致在复杂任务上表现不够稳定。
2. 三个根因
规划不被显式结构化保存。ReAct 可以在 Thought 里做局部规划(先处理退款,再推荐),但这个规划只是一段自然语言,不会被保存为独立的数据结构。随着轮次增加、Observation 堆积,最初的规划在上下文中的权重逐渐下降。就像导航只在出发时口头说了一遍全程路线,然后每个路口都只告诉你“下个路口左转”——简单路线没问题,复杂路线你可能走偏。
缺乏显式的条件分支机制。用户说“到了的话帮我退掉”——这是一个 if-then 逻辑。ReAct 没有专门的分支结构,大脑只能在 Thought 里隐式地记住这个条件。实践中大模型通常能处理简单条件,但随着轮次增加和上下文膨胀,条件被误判或遗忘的概率上升。
多任务切换成本高。ReAct 的 messages 是线性累积的,子任务 A 的工具结果和子任务 B 的工具结果混在一起。大脑在第 6 圈要综合两个子任务的结果时,得从一堆交错的 Observation 里把相关信息捡出来——这对大模型的长上下文理解能力是个不小的挑战。
用一句话概括:ReAct 把规划和执行耦合在同一个循环里,且规划不被显式结构化保留;Plan-and-Execute 把两者拆开,计划本身就是一份可追踪、可调整的执行清单。
3. 一张表看清边界
| 维度 | ReAct | Plan-and-Execute |
|---|---|---|
| 决策方式 | 每步推理一次,决定下一个动作 | 先生成完整计划,再逐步执行 |
| 适合的任务 | 单目标、1-3 步可完成 | 多目标、多步骤、有依赖关系 |
| 全局视角 | 可在 Thought 中局部规划,但不会结构化保留 | 有,计划覆盖所有步骤并保存为独立数据结构 |
| 条件分支 | 隐式(靠 Thought 记住,轮次多时易丢失) | 显式(写在计划步骤里) |
| 失败恢复 | 靠下一 圈 Thought 自行调整 | 可以重新规划(Re-plan) |
| 额外开销 | 无 | 多一次规划的 LLM 调用 |
| 可观测性 | 只能看 Thought 日志 | 计划本身就是可读的执行清单 |
Plan-and-Execute:先想清楚再动手
1. 核心思想
Plan-and-Execute 的核心思想很直觉——把规划和执行拆成两个独立阶段。
打个比方:你去宜家买家具。ReAct 的方式是进门之后走一步看一步——先看到沙发觉得不错,逛到厨房区又想起要买碗碟,最后在出口才发现忘了量尺寸,又折回去。Plan-and-Execute 的方式是进门前先列个清单——要买什么、在几楼、按什么路线走、预算多少——然后按清单一项一项拿。
落到 Agent 上,两个阶段的职责很清晰:
- Planner(规划器):拿到用户的原始需求,结合可用工具列表,生成一份结构化的执行计划——包含哪些步骤、每步干什么、用什么工具、步骤之间的依赖关系。
- Executor(执行器):按计划逐步执行,每步调用对应的工具,拿到结果后更新步骤状态。如果某步失败或结果偏离预期,可以触发重新规划(Re-plan)。
2. 架构全景

这张图有三个关键点:
Planner 和 Executor 是分离的。Planner 负责想——生成和调整计划;Executor 负责执行——调用工具拿结果。两者都用 LLM,但 Prompt 不同、职责不同。架构上这种分离支持给 Planner 用推理能力强的大模型(比如 DeepSeek-R1),给 Executor 用速度快、成本低的小模型——各取所长。本篇的实现为了简单,Planner 和 Executor 共用同一个 LlmClient;如果要分模型,给 Planner 传入独立的 LlmClient 实例即可。
Re-plan 是可选的。不是每步执行完都要重新规划。只有当步骤失败、或者工具返回了计划之外的新信息时,才触发重新规划。大多数情况下,计划生成一次就够了。
最后一步是综合。所有工具调用完成后,Agent 不是简单地把结果拼起来,而是再调一次 LLM,把所有步骤的结果综合成一段连贯的、面向用户的回复。
3. ReAct vs Plan-and-Execute 的执行轨迹对比
用同一个复杂任务来对比两种方式的执行轨迹:
用户输入:“我家老人想要一台扫地机,预算 2000 以内。另外帮我看看订单 88231 到了没,到了就帮我退掉。”
ReAct 的轨迹(一步一步摸索):
第 1 圈 Thought: 用户有两个需求,先处理订单问题。查订单 88231。
→ Action: queryOrder(88231)
第 2 圈 Thought: 订单已签收,需要查物流确认。
→ Action: queryLogistics(SF1234567890)
第 3 圈 Thought: 确认已签收,用户说到了就退,我来申请退款。
→ Action: applyRefund({"orderId":"88231","reason":"用户要求退货"})
第 4 圈 Thought: 退款搞定了,还有推荐扫地机的需求。搜一下知识库。
→ Action: searchKnowledge("扫地机 2000以内 老人")
第 5 圈 Thought: 拿到推荐信息了,综合回复。
→ Final Answer: ......
5 圈,逻辑能跑通,但大脑每一圈都在临时决定下一步该干什么。如果第 3 圈大脑的注意力被退款流程占据,误以为任务已经完成,就会直接输出 Final Answer——只回复了退款结果,漏掉了推荐。这不是必然发生,但上下文越长,风险越高。
Plan-and-Execute 的轨迹(先规划再执行):
【规划阶段】
Plan:
Step 1: 搜索知识库,查询 2000 以内适合老人的扫地机型号
Step 2: 查询订单 88231 的详情,确认商品和状态
Step 3: 如果订单已签收,为订单 88231 申请退款
Step 4: 综合推荐结果和退款进展,给出最终回复
【执行阶段】
Step 1 → searchKnowledge("扫地机 2000以内 适合老人") → 拿到推荐信息 ✓
Step 2 → queryOrder("88231") → 状态:已签收 ✓
Step 3 → applyRefund({"orderId":"88231","reason":"..."}) → 退款已提交 ✓
Step 4 → LLM 综合所有结果 → 最终回复 ✓
4 步,每步目标明确。即使中间某步失败(比如退款接口超时),Agent 也清楚知道是哪一步出了问题、前面哪些步骤已经完成了、后面还有什么要做——因为计划本身就是一份完整的清单。
你可能注意到了:Plan-and-Execute 的轨迹里没有查物流这一步。Planner 在规划时看到了
queryOrder的工具描述——返回订单状态、商品、物流单号等信息——据此判断订单详情里应该包含签收状态,不需要额外安排queryLogistics。这就是全局视角的好处——Planner 能在规划阶段就根据工具描述优化步骤编排,而 ReAct 只能执行到一半才发现多走了弯路。
数据结构:计划长什么样
在写 Planner 和 Executor 之前,先把计划这个概念落成代码。一个计划由若干步骤组成,每个步骤有明确的描述、预期使用的工具、执行状态和结果。
1. PlanStep:一个步骤
public class PlanStep {
private final int stepId; // 步骤编号,从 1 递增
private final String description; // 这一步要做什么(自然语言)
private final String toolHint; // 建议使用的工具名,null 表示综合步骤
private PlanStepStatus status; // PENDING → EXECUTING → COMPLETED / FAILED
private String result; // 执行结果(成功为文本,失败为错误原因)
public PlanStep(int stepId, String description, String toolHint) {
this.stepId = stepId;
this.description = description;
this.toolHint = toolHint;
this.status = PlanStepStatus.PENDING;
}
public void markCompleted(String result) {
this.status = PlanStepStatus.COMPLETED;
this.result = result;
}
public void markFailed(String reason) {
this.status = PlanStepStatus.FAILED;
this.result = reason;
}
// getters 省略
}
public enum PlanStepStatus {
PENDING, EXECUTING, COMPLETED, FAILED
}
几个设计决策说明一下:
toolHint 是提示而非强制。Planner 在规划时会猜测每步可能用到的工具,但 Executor 执行时不一定非得用这个工具。toolHint 有两个作用:一是注入 Executor 的提示词(建议使用的工具:queryOrder),给 LLM 一个方向、降低决策负担;二是区分工具步骤和综合步骤——toolHint 为 null 表示这步不调工具,走纯文本综合分支。但 toolHint 不是铁令,Executor 有权根据实际情况选择其他工具。
状态流转是单向的。PENDING → EXECUTING → COMPLETED / FAILED,不允许倒退。如果一步失败了需要重试,走的是重新规划的路径——Planner 会生成一个新的计划(可能包含“重试第 N 步”的新步骤),而不是把失败的步骤状态改回 PENDING。
2. Plan:完整计划
public class Plan {
private final String goal; // 用户原始需求,执行步骤和重规划时均从此读取
private final List<PlanStep> steps; // 按执行顺序排列的步骤列表
public Plan(String goal, List<PlanStep> steps) {
this.goal = goal;
this.steps = new ArrayList<>(steps);
}
public String getProgressSummary() {
......
}
// getGoal()、getSteps()、nextPendingStep()、isAllDone()、hasFailedStep()、isAllDone() ......
}
getProgressSummary() 的输出长这样:
✓ Step 1: 搜索知识库,查询 2000 以内适合老人的扫地机 → {"matched":"比特 S10 Lite 扫地机","price":1599,...}
✓ Step 2: 查询订单 88231 的详情 → {"orderId":"88231","status":"已签收",...}
▶ Step 3: 为订单 88231 申请退款
○ Step 4: 综合推荐结果和退款进展,生成最终回复
这个摘要有两个用途:一是打印到控制台方便调试,二是喂给 Planner 做重新规划时的上下文——Planner 看到哪些步骤完成了、拿到了什么结果,才能做出合理的调整。
Planner:让大模型做规划
Planner 是 Plan-and-Execute 的核心——它用一次 LLM 调用,把用户的自然语言需求拆解成结构化的步骤列表。
1. 规划提示词设计
规划提示词要解决三个问题:让大模型知道有哪些工具可用、按什么格式输出计划、遵循什么规划原则。
public class Planner {
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
public Planner(LlmClient llmClient, ToolRegistry toolRegistry) {
this.llmClient = llmClient;
this.toolRegistry = toolRegistry;
}
private String buildPlannerPrompt() {
StringBuilder toolList = new StringBuilder();
for (Tool tool : toolRegistry.getTools()) {
toolList.append("- ").append(tool.name())
.append(":").append(tool.description()).append("\n");
}
return """
你是一个任务规划助手。根据用户的问题,将任务拆解为一系列可执行的步骤。
可用工具:
%s
输出格式要求:
用 JSON 数组表示计划,每个元素包含:
- stepId:步骤编号(从 1 开始)
- description:这一步要做什么(简洁明确)
- toolHint:预计使用的工具名(如果是纯推理或综合步骤,填 null)
规划原则:
1. 每一步只做一件事,对应一次工具调用或一次推理判断
2. 步骤之间按执行顺序排列——如果 B 依赖 A 的结果,A 必须排在 B 前面
3. 最后一步必须是“综合以上结果,给出最终回复”,toolHint 填 null
4. 步骤数量控制在 3-7 步,太少说明拆分不够,太多说明拆分过细
5. 如果用户的需求包含条件判断(如“如果……就……”),把条件检查和条件执行拆成两步
6. 只输出 JSON 数组,不要输出任何解释
示例:
用户问题:帮我查一下订单 88231 到了没,到了就退掉。
输出:
[
{"stepId":1,"description":"查询订单 88231 的详情,确认订单状态","toolHint":"queryOrder"},
{"stepId":2,"description":"如果订单已签收,为订单 88231 申请退款,退款原因为用户主动申请","toolHint":"applyRefund"},
{"stepId":3,"description":"综合以上结果,告知用户订单状态和退款进展","toolHint":null}
]
""".formatted(toolList);
}
// createPlan()、replan()、parsePlan()、extractJsonArray() ......
}
提示词里有几个关键设计值得展开讲。
工具列表提供给 Planner 看,但 Planner 不调工具。Planner 的职责是规划,不是执行。它需要知道有哪些工具可用,才能合理地分配步骤和 toolHint,但它本身不触发任何工具调用。
步骤数量约束在 3-7 步。太少(1-2 步)说明 Planner 没有真正做拆解,可能把多个操作挤在一步里;太多(8 步以上)说明拆得过细,增加了执行轮次和 LLM 调用成本,收益递减。3-7 步覆盖了比特严选绝大多数客服场景。
条件判断拆成两步。“如果到了就退”这种需求,Planner 应该把查询状态和申请退款拆成两步,而不是写成一步“查询订单并在签收后退款”。拆成两步的好处是:Executor 执行完第 1 步后,可以根据结果决定第 2 步是否要执行——条件逻辑变成了可观测的步骤状态。
最后一步固定为综合步骤。toolHint 为 null 表示这步不调工具,而是用 LLM 把前面所有步骤的结果综合成面向用户的回复。这一步非常重要——没有它,Agent 只能拼凑工具返回的 JSON,用户体验很差。
2. 生成计划
public Plan createPlan(String userMessage) {
ObjectMapper objectMapper = llmClient.getObjectMapper();
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildPlannerPrompt());
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", userMessage);
ChatResponse response = llmClient.chatWithTools(
messages, objectMapper.createArrayNode());
String content = response.content();
return parsePlan(userMessage, content);
}
private Plan parsePlan(String goal, String content) {
ObjectMapper objectMapper = llmClient.getObjectMapper();
try {
// 提取 JSON 数组(大模型可能在 JSON 前后附带文字)
String json = extractJsonArray(content);
JsonNode arrayNode = objectMapper.readTree(json);
List<PlanStep> steps = new ArrayList<>();
for (JsonNode node : arrayNode) {
int stepId = node.get("stepId").asInt();
String description = node.get("description").asText();
String toolHint = node.has("toolHint") && !node.get("toolHint").isNull()
? node.get("toolHint").asText() : null;
steps.add(new PlanStep(stepId, description, toolHint));
}
return new Plan(goal, steps);
} catch (Exception e) {
// 解析失败:降级为单步计划,让 Executor 自行处理
System.out.println("[Planner] 计划解 析失败,降级为单步执行:" + e.getMessage());
List<PlanStep> fallback = List.of(
new PlanStep(1, goal, null),
new PlanStep(2, "综合以上结果,给出最终回复", null)
);
return new Plan(goal, fallback);
}
}
private String extractJsonArray(String text) {
int start = text.indexOf('[');
int end = text.lastIndexOf(']');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
throw new IllegalArgumentException("未找到 JSON 数组");
}
extractJsonArray() 做了一件事——从大模型返回的文本中提取 JSON 数组。虽然提示词要求“只输出 JSON 数组”,但大模型偶尔会在 JSON 前后加一句解释。直接用 indexOf('[') 和 lastIndexOf(']') 定位,比依赖大模型严格遵守“只输出 JSON”更可靠。
解析失败的降级策略是关键——如果 Planner 返回的内容完全解析不出来(比如模型幻觉了一段不相关的文本),不能让整个 Agent 崩掉。降级为单步计划本质上就退化成了 ReAct 模式——虽然没有规划的好处,但至少能工作。
3. 重新规划
当某个步骤执行失败、或者工具返回了意料之外的结果时,Planner 需要根据已完成的步骤和当前状况重新生成 计划:
public Plan replan(Plan currentPlan) {
String goal = currentPlan.getGoal();
ObjectMapper objectMapper = llmClient.getObjectMapper();
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildPlannerPrompt());
String replanContext = """
用户原始问题:%s
之前的计划执行进 度:
%s
请根据已完成步骤的结果和失败情况,重新规划剩余步骤。
已完成的步骤不需要重复,只规划接下来要做的事。
步骤编号从 %d 开始继续编号。
""".formatted(
goal,
currentPlan.getProgressSummary(),
currentPlan.getNextStepId());
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", replanContext);
ChatResponse response = llmClient.chatWithTools(
messages, objectMapper.createArrayNode());
Plan newPlan = parsePlan(goal, response.content());
List<PlanStep> merged = new ArrayList<>();
for (PlanStep step : currentPlan.getSteps()) {
if (step.getStatus() == PlanStepStatus.COMPLETED) {
merged.add(step);
}
}
merged.addAll(newPlan.getSteps());
return new Plan(goal, merged);
}
重新规划的核心在于 getProgressSummary() 提供的上下文。Planner 看到的不只是“第 3 步失败了”,而是“第 1 步查订单成功,结果是已签收;第 2 步查物流成功;第 3 步退款失败,原因是退款接口超时”——有了这些信息,Planner 可以做出合理的调整,比如加一步“重试退款申请”或者“改为引导用户手动退款”。
合并策略也很重要:已完成的步骤保留(连同它们的结果),只替换待执行和失败的部分。这样 Executor 不会重复执行已经成功的步骤,节省时间和 Token。
Executor:按计划执行每一步
Executor 负责执行计划中的每个步骤。它接收步骤描述和之前步骤的上下文,调用 LLM 决定使用什么工具、传什么参数,然后执行工具并返回结果。
1. 执行单步的 Prompt 设计
跟 ReAct 的系统提示词不同,Executor 的提示词更聚焦——它不需要考虑接下来该做什么(那是 Planner 的事),只需要把这一步做好:
private String executeStep(PlanStep step, Plan plan, ArrayNode tools) {
ObjectMapper objectMapper = llmClient.getObjectMapper();
ArrayNode messages = objectMapper.createArrayNode();
String completedContext = buildCompletedContext(plan);
boolean isSynthesisStep = step.getToolHint() == null;
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
if (isSynthesisStep) {
systemMsg.put("content", """
你是比特严选的智能客服助手。
请根据之前各步骤的执行结果,综合生成一段面向用户的完整回复。
不要调用任何工具,直接输出最终回复文本。
之前步骤的执行结果:
%s
""".formatted(completedContext));
} else {
String toolHintLine = step.getToolHint() != null
? "建议使用的工具:" + step.getToolHint()
+ "(仅供参考,你可以根据实际情况选择其他工具)\n"
: "";
systemMsg.put("content", """
你是比特严选的智能客服助手。你正在按计划执行一个任务。
当前步骤的目标:%s
%s
之前步骤的执行结果:
%s
重要约束:
- 只完成当前步骤描述的目标,不要做其他步骤的工作
- 每次最多调用一个工具
- 拿到工具结果后,如果已经满足当前步骤的目标,直接输出结果文本,不要继续调用工具
""".formatted(step.getDescription(), toolHintLine, completedContext));
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", "用户原始问题:" + plan.getGoal());
// 综合步骤传空 tools,强制 LLM 只输出文本
ArrayNode stepTools = isSynthesisStep
? objectMapper.createArrayNode() : tools;
for (int i = 0; i < MAX_STEP_ROUNDS; i++) {
ChatResponse response = llmClient.chatWithTools(messages, stepTools);
if (!response.hasToolCalls()) {
return response.content() != null ? response.content() : "";
}
// ...... 工具调用处理(构建 assistant 消息、执行工具、追加 tool 消息)
}
throw new RuntimeException("当前步骤未能在限定轮次内完成");
}
private String buildCompletedContext(Plan plan) {
// ...... 遍历已完成步骤,拼接 “Step N(描述):结果” 格式
}
2. 关键点设计
综合步骤传空 tools 数组。这是最关键的一个设计——当 toolHint == null 时,说明这步是纯文本综合,不需要调任何工具。如果你仍然把 tools 传给 LLM,它看到工具就有冲动去调——比如在综合回复时又去搜一遍知识库。传空 tools 从 API 层面就堵死了这条路,LLM 只能输出文本。同时系统提示词也换成了综合专用版本,不再提调用工具。
工具步骤的三条约束。实际跑下来会发现,没有约束的 Executor 很容易越界——Step 1 是搜知识库,但 LLM 顺手把 queryOrder 也调了。这不是 LLM 的错,它只是想 帮忙多做一些。但越界会导致两个问题:一是后续步骤做了重复工作(Step 2 又查一遍订单),二是当前步骤的 3 圈轮次被无关调用消耗掉。三条约束——只做当前步骤、每次最多调一个工具、拿到结果就收手——把 Executor 的行为框在计划范围内。
每步最多 3 圈。大多数计划步骤只需要 1 次工具调用。给 3 圈的余量是为了处理工具返回错误后 LLM 自行重试的情况(比如第 1 圈传错了参数格式,LLM 看到错误后第 2 圈修正)。3 圈足够了——如果 3 圈还搞不定,executeStep() 会抛出异常,上层的 catch 块会标记步骤为 FAILED 并触发重新规划。
buildCompletedContext() 传递上下文。第 3 步执行时,Executor 能看到第 1 步和第 2 步的结果。比如第 2 步查了订单,拿到了状态“已签收”,第 3 步的退款就可以直接引用这个结果,不需要再查一遍。这是 Plan-and-Execute 比 ReAct 高效的一个重要原因——步骤之间的信息传递是显式的、结构化的。
PlanAndExecuteAgent:完整的主循环
把 Planner 和 Executor 串起来,就是 PlanAndExecuteAgent 的主循环。
public class PlanAndExecuteAgent {
private static final int MAX_STEP_ROUNDS = 3;
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
private final Planner planner;
private final int maxReplanCount;
public PlanAndExecuteAgent(LlmClient llmClient, ToolRegistry toolRegistry,
int maxReplanCount) {
this.llmClient = llmClient;
this.toolRegistry = toolRegistry;
this.planner = new Planner(llmClient, toolRegistry);
this.maxReplanCount = maxReplanCount;
}
public String run(String userMessage) {
ObjectMapper objectMapper = llmClient.getObjectMapper();
// 阶段一:生成计划
System.out.println("[Plan] 正在规划...");
Plan plan = planner.createPlan(userMessage);
System.out.println("[Plan] 计划生成完成:");
System.out.println(plan.getProgressSummary());
ArrayNode tools = toolRegistry.buildToolsJsonArray(objectMapper);
int replanCount = 0;
// 阶段二:逐步执行
while (!plan.isAllDone()) {
PlanStep step = plan.nextPendingStep();
if (step == null) break;
step.setStatus(PlanStepStatus.EXECUTING);
System.out.println("\n[Execute] ▶ Step " + step.getStepId()
+ ": " + step.getDescription());
try {
String result = executeStep(step, plan, tools);
step.markCompleted(result);
System.out.println("[Execute] ✓ Step " + step.getStepId()
+ " 完成");
} catch (Exception e) {
step.markFailed(e.getMessage());
System.out.println("[Execute] ✗ Step " + step.getStepId()
+ " 失败:" + e.getMessage());
if (replanCount < maxReplanCount) {
replanCount++;
System.out.println("[Replan] 第 " + replanCount
+ " 次重新规划...");
plan = planner.replan(plan);
System.out.println("[Replan] 新计划:");
System.out.println(plan.getProgressSummary());
} else {
System.out.println("[Replan] 已达最大重规划次数,跳过失败步骤");
}
}
}
// 阶段三:返回最终结果
System.out.println("\n[Plan] 执行完成:");
System.out.println(plan.getProgressSummary());
return extractFinalResult(plan);
}
// executeStep()、buildCompletedContext() 见上一节
// extractFinalResult() ......
}
主循环的三个阶段对应代码里的三个注释块,逻辑很直白。重点说几个设计决策:
MAX_STEP_ROUNDS = 3 限制每步的内部轮次。大多数计划步骤只需要 1 次工具调用,给 3 圈余量是为了处理工具返回错误后 LLM 自行重试的情况。3 圈还搞不定,executeStep() 抛出异常,上层 catch 块标记 FAILED 并触发重新规划——确保超轮次不会被当成完成。
maxReplanCount 限制重规划次数。重新规划需要一次 LLM 调用,成本不低。而且重规划存在递归风险——新计划的某步又失败,又触发重规划,无限循环。默认设为 2 次:第一次重规划处理偶发失败(如网络超时),第二次处理持续性问题(如工具真的不可用)。两次还搞不定,说明问题不在计划层面,继续重规划也没意义。
异常触发重规划,而不是每步都重规划。有些 Plan-and-Execute 实现在每步执行后都调一次 Planner 检查计划是否需要调整(Every-step Re-plan)。这种做法更健壮,但代价是 LLM 调用次数翻倍。对于比特严选的场景——5 个工具、3-5 步的计划——只在失败时重规划已经足够。如果你的场景步骤多、不确定性高(比如多轮对话中用户随时变需求),可以考虑每步重规划。
extractFinalResult() 的回退逻辑。正常情况下,最后一步(toolHint 为 null 的综合步骤)的结果就是最终回复。但如果综合步骤失败了(比如 LLM 超时),回退到拼接所有已完成步骤的结果——虽然格式不够友好,但总比返回空好。
动态重规划:计划赶不上变化
重规划不只是失败了就重来。在实际场景中,有三种情况需要重新规划:
1. 步骤执行失败
最直接的情况。比如退款接口返回“该订单不在退款有效期内”,Planner 看到这个结果后,可能会把计划调整为引导用户联系人工客服。