Java 转 Agent 开发之路(四):Planning——从“走到哪算哪”到可观测的任务执行
上一篇的结尾停在一个不太舒服的事实上:ToolCallingAdvisor 的循环是模型驱动的。模型请求工具,框架执行,结果回传,模型再判断——循环直到模型不再请求工具为止。这个机制在“查订单→查物流→回复”这类线性任务上工作得很好,但它没有回答一个更根本的问题:当任务需要先调研再执行、执行后反思、失败后调整策略时,谁来决定“下一步该做什么”?
如果答案仍然是“模型自己决定”,那么 Planning 就只是一个 Prompt 技巧,而不是一个可观测、可审计、可中断的系统能力。这一篇要做的,就是把 Planning 从模型的隐式行为里拎出来,变成 Agent 架构中一个有状态、有边界、有兜底的显式模块。
一、默认循环为什么会在复杂任务上“迷路”
ToolCallingAdvisor 的终止条件是“模型不再请求工具”。这意味着循环的长度完全由模型的判断决定。在简单任务中这没问题,但在三类复杂场景下,它会暴露出结构性的缺陷。
第一类:多步依赖任务。 用户说“分析这个项目的性能瓶颈并给出优化方案”。模型需要先读代码结构,再分析数据库访问,再检查缓存策略,最后汇总。这些步骤之间存在严格的依赖关系——不读完代码就无法判断数据库访问是否合理。默认循环中,模型可能在第一轮就同时请求“分析数据库”和“分析缓存”两个工具,拿到两份互不相关的报告后直接拼凑结论。循环“跑完了”,但任务没有被“规划过”。
第二类:需要反思的任务。 模型生成了一份架构方案,其中遗漏了限流策略。默认循环不会让模型“回头检查自己的输出”——它只会在工具调用层面循环,不会在输出质量层面循环。Reflection 需要的是一个独立的“生成→评价→修改”回路,而这个回路不在 ToolCallingAdvisor 的职责范围内。
第三类:长任务的中断与恢复。 一个需要十几次工具调用的调研任务,在执行到第七步时进程崩溃了。默认循环的全部中间状态都在一次 ChatClient 调用的内存里,崩溃即丢失。没有持久化的 Task State,就没有“从中断处继续”的可能。
这三个缺陷指向同一个结论:Tool Calling 的默认循环解决的是“模型能否请求执行”,Planning 要解决的是“系统如何管理任务的执行过程”。 前者是能力,后者是治理。
二、先分清两种控制力量:Workflow 与 Agent
在讨论 Planning 的具体模式之前,需要先建立一个分类框架。Spring AI 官方将 Agentic System 分为两种控制方式:Workflow 使用预定义代码路径组织模型与工具,Agent 允许模型动态决定执行过程。
这个区分在实践中至关重要,因为它决定了 Planning 应该放在哪一层。
一个退款流程可以固定为:识别退款意图 → 查询订单 → 检查退款资格 → 满足条件后申请退款。其中“用户到底想退款、咨询规则还是查询进度”适合交给 LLM 判断,但 if (refundAmount > 5000) requireManualApproval() 这种业务规则应该继续由 Java 控制。Planning 的价值不在于把一切都交给模型动态决定,而在于在“必须固定的业务步骤”和“无法提前写死的决策”之间画出清晰的边界。
把这条边界画清楚之后,三种经典的 Planning 模式就有了各自的适用位置。
三、三种规划模式,落在 Spring AI 的哪个位置
ReAct:模型内隐的“边做边想”,由 ToolCallingAdvisor 承载
ReAct 的循环是“思考→行动→观察”,本质上就是 ToolCallingAdvisor 已经在做的事情。它的优点是轻量、灵活,适合步骤之间依赖关系不强、模型有能力在少量轮次内收敛的任务。但它的 Planning 是内隐的——计划不在任何地方被显式表达,你只能从工具调用的序列中事后推断模型“想干什么”。
对于简单任务,内隐 Planning 够用。对于需要审计“模型为什么走了这条路”的场景,内隐 Planning 是不够的。你需要让计划变得可见。
Plan-and-Execute:先出计划,再逐步执行
Plan-and-Execute 的核心思想是把规划和执行分成两个阶段:先由规划器生成一份结构化的执行计划(一组带依赖关系的步骤),再由执行器逐步完成,最后汇总。Spring AI Alibaba 的 SimplifiedPlanningCoordinator 展示了这个三阶段结构:generatePlanning → executeSteps → summarize。
在 Spring AI 原生侧,Plan-and-Execute 不需要一个全新的框架——它可以用普通 Java 控制流 + 结构化输出来实现。规划阶段让模型输出一个 Plan 对象(步骤列表、每步的预期产出、依赖关系),Java 侧遍历步骤执行,每步的结果存入 Task State。规划是显式的:你可以把 Plan 存进数据库,可以在 UI 上渲染进度,可以在某一步失败后从该步骤重试而不是从头开始。
代价是灵活性下降:计划一旦生成,执行路径就相对固定。如果执行中发现计划本身有误(比如遗漏了一个关键步骤),需要回到规划阶段重新生成,而不是在执行中途随意转向。
Reflection / Evaluator-Optimizer:在任务内部做质量回路
Reflection 的本质是一个“生成→评价→修改”的反馈回路:生成器产出结果,评估器按预设维度打分并给出具体批评,未达标则把批评反馈给生成器重新生成,直到达标或达到最大轮次。
Spring AI 2.0 中已经有一个内置的 Reflection 实现:StructuredOutputValidationAdvisor。它根据 JSON Schema 验证结构化输出,验证失败时把错误信息附加到 Prompt 中重试,maxRepeatAttempts 默认是 3。这是一个确定性的评估器——Schema 就是评价标准,不依赖模型的自我判断。
这引出了 Reflection 最重要的工程边界:模型自我评价并不可靠。 评估器和生成器共享同一个能力边界,如果模型对某个领域存在盲区,生成器会犯错,评估器同样会忽略。Reflection 擅长发现“该写没写”的问题(维度覆盖、结构完整性),不擅长发现“写了但写错”的问题。因此,Reflection 应该用于高价值输出 + 确定性校验可以兜底的场景,而不是每个回答都默认多跑一轮。
四、用递归 Advisor 把 Planning 变成可观测的循环
上一节说了“应该显式化”,这一节说“怎么在 Spring AI 的机制里显式化”。
Spring AI 2.0 的递归 Advisor 是承载 Planning 循环的天然容器。递归 Advisor 可以多次遍历下游 Advisor 链,每次迭代都会重新调用 LLM,而下游的 Advisor 仍然能观察和拦截每一次迭代。这意味着你可以把“计划生成、步骤执行、反思修正”的逻辑放进一个自定义递归 Advisor,让每一次 Planning 轮次都经过日志、审计和指标采集。
一个自定义 Planning Advisor 的骨架大致是这样的:
public class PlanningAdvisor implements CallAdvisor {
private final int maxPlanningRounds;
private final UsageAccumulator usage = new UsageAccumulator();
@Override
public ChatClientResponse adviseCall(ChatClientRequest request,
CallAdvisorChain chain) {
ChatClientResponse response = chain.nextCall(request);
int round = 0;
while (round < maxPlanningRounds && needsReplanning(response)) {
// 把计划状态和修正信号注入下一轮请求
request = augmentWithPlanState(request, response);
response = chain.copy(this).nextCall(request);
usage.addRoundResponse(response.chatResponse());
round++;
}
return usage.applyAccumulatedUsage(response);
}
}
这里有几个关键点,每一个都对应着上一篇没有覆盖的工程约束。
终止条件不是可选的。 每一个递归 Advisor 都必须定义终止条件,否则循环可能无限运行,每一轮都在消耗 token 和费用。maxPlanningRounds 是硬上限,needsReplanning(response) 是软条件。一个实用的模式是:软条件判断“是否还需要再规划”,硬上限兜底“无论如何不能再跑了”。
chain.copy(this).nextCall() 是递归的核心。 它创建一条只包含当前 Advisor 之后部分的新链,确保上游 Advisor(比如 Memory Advisor)不会被重复执行,而下下游 Advisor(比如日志、指标)能观察每一次迭代。
Token 用量必须累积。 递归 Advisor 在一次 adviseCall 中会执行多次模型调用。如果只返回最后一次的用量,成本核算就是错的。Spring AI 提供了 UsageAccumulator:每轮调用 addRoundResponse 混入用量,最后 applyAccumulatedUsage 写入最终响应。
Advisor 的顺序决定了你看到什么。 一个自定义 Planning Advisor 如果放在 ToolCallingAdvisor 之前(order 值更小),它会包裹整个工具调用循环,看到的是循环开始前的状态和循环结束后的最终结果。如果放在之后(order 值更大,比如 HIGHEST_PRECEDENCE + 400),它会位于工具循环内部,能观察到每一次工具往返。选择哪个位置,取决于 Planning 是“任务级”的还是“步骤级”的——任务级 Planning 通常应该包裹工具循环,步骤级反思才需要进入循环内部。
五、Task State:Planning 的“计划”存在哪里
递归 Advisor 管的是循环的控制流,但计划的内容和进度需要一个独立的承载物。这就是 Task State 与 ChatMemory 的分野。
ChatMemory 保存的是对话上下文:用户说过什么,模型回复过什么。Task State 保存的是任务执行状态:目标是什么、当前在第几步、某一步的产出是什么、是否通过了资格检查、下一步该做什么。
用普通 Java 对象就可以表达:
public record TaskState(
String taskId,
String goal,
List<PlanStep> steps,
int currentStepIndex,
Map<String, Object> stepResults,
TaskStatus status // PLANNING, EXECUTING, HITL, DONE, FAILED
) {}
这个对象不需要塞进 Prompt。模型需要的是当前步骤的上下文,而不是整个状态机的内部字段。Task State 可以存入 Redis 或数据库,它的作用是让 Planning 具备可中断、可恢复、可审计的物理基础。
一个典型的长任务状态机可以设计为 PLANNING → EXECUTING → HITL → DONE / FAILED。HITL 状态对应 human-in-the-loop:当某一步涉及高风险操作,或者 Reflection 达到最大轮次仍未达标,任务暂停,等待人工审批后从暂停点继续。JamJet 的 Spring AI Starter 把这种能力称为 durable execution——每一步都记录为不可变事件,进程崩溃后可以重放并从中断处恢复。即使你不引入外部运行时,在 Java 侧用一个 TaskStateRepository 把状态写入数据库,也能获得最基本的恢复能力。
六、把工程约束画在代码里:预算、终止条件与人工边界
Planning 最容易失控的地方,不是“模型不会规划”,而是规划循环没有成本意识。
预算控制:Token 消耗是乘法效应
Agent 的 Token 消耗不是线性的。每一轮循环都要把完整的历史消息传给 LLM,3 轮循环的消耗不是 3 倍,而是 1+2+3=6 倍。Planning 循环叠加 Tool Calling 循环时,这个乘法效应会迅速放大。
工程上的做法是多维度预算,而不是只靠一个 maxIterations:
- 最大规划轮次:硬上限,达到即终止。
- 最大工具调用总次数:跨所有规划轮次累计。
- Token 总预算:用
UsageAccumulator的累积用量做判断,超过阈值即降级。 - 总耗时:墙钟时间兜底。
- 连续无效动作:如果模型连续 N 轮产生相同或高度相似的行动计划,强制终止。
任务应该在以下条件之一满足时结束:模型返回最终答案、达到最大步数、超过总耗时、超过 Token 或费用预算、工具连续失败。把“结束”设计成多个独立触发器的合取,而不是依赖模型“自觉停止”。
Reflection 的预算边界
Reflection 每一轮都是一次完整的模型调用。如果一个任务执行 5 个步骤、每个步骤做 2 轮 Reflection,再加上 Planning 本身的 2 轮,模型调用次数轻松超过 10 次。Reflection 应该被配置为“高价值步骤才启用”,而不是全局默认开启。评估器必须给出具体批评和量化评分,只让模型“再看一遍”通常效果有限。
人工边界不是失败,是设计
当 Planning 循环耗尽预算仍未收敛时,正确的行为不是“再试一次”,而是转入人工处理。Reflection 的循环控制在轮次耗尽后应该转入人工评审或丢弃,而不是无限重试。同样,涉及资金、删除、大额修改的高风险步骤,应该在 Task State 中进入 HITL 状态,等待审批后再继续。模型的动作边界被限制在“申请”和“建议”,执行权保留在审批通过之后的后台流程中。
七、收束:Planning 让 Agent 从“能办事”变成“能把事办完”
这一篇从上一篇的终点出发,回答了三个问题。
第一,Planning 不是 Prompt 技巧,是系统能力。 ReAct 的内隐规划适合简单任务,Plan-and-Execute 的显式计划适合多步依赖任务,Reflection 的质量回路适合高价值输出。三者不是替代关系,而是可以组合的分层策略。
第二,递归 Advisor 让 Planning 循环变得可观测。 maxRepeatAttempts 是硬终止条件,chain.copy(this) 让下游 Advisor 看到每一次迭代,UsageAccumulator 让成本核算正确,Advisor 的顺序决定了 Planning 包裹工具循环还是进入工具循环内部。
第三,Task State 和预算控制是 Planning 落地的物理基础。 计划存在哪里、当前走到第几步、花了多少 Token、什么时候该转人工——这些不是模型该操心的事情,是 Java 工程师该画在代码里的约束。
但到这里,Agent 还缺最后一块拼图。Planning 让任务可以被拆解和执行,Tool Calling 让模型可以办事,但它们都在消耗上下文。一次多轮 Planning + 工具调用的会话,消息列表会迅速膨胀;一个需要跨会话记住用户偏好的助手,不能只靠 ChatMemory 的滑动窗口。记忆的分层、检索与注入策略,是让 Agent 在长任务和多会话中保持连贯性的下一个关键问题。
- 点赞
- 收藏
- 关注作者
评论(0)