【Java/Go后端手撸原生Agent(第九篇):Plan-and-Execute规划模式——从“走一步看一步“到“先谋后动“】

系列目录:Phase 1-2 基础框架Phase 3 状态机+文件读取Phase 4-6 原生FC+Judge校验Phase 5 多工具并行+BashToolPhase 6 流式输出Phase 7 Token预算与滑动窗口Phase 8 摘要压缩Phase 9 规划模式(本文)

上一篇链接:【Java/Go后端手撸原生Agent(第八篇):摘要压缩------让Agent"记要点"而不是"记流水账"】

前言

前八篇做完之后,我们的Agent已经具备了:原生Function Calling、流式输出、多工具并行、Bash命令执行、LLM-as-Judge完整性校验、滑动窗口裁剪+摘要压缩的上下文管理。它能正确调用工具、能流式展示思考过程、长对话不会爆token、回答不全会被Judge打回。

但有一个本质问题一直没解决:Agent是"走一步看一步"的

想象这个场景:你让Agent"分析base_tool.py文件,统计总行数,读取execute和to_openai_tool_schema方法解释它们,然后计算这两个方法行数之和乘以4再除以2"。没有规划的Agent(纯ReAct模式)第一轮THINKING可能直接调用bash wc -l看行数,第二轮发现还要读方法内容,第三轮读了execute但忘了to_openai_tool_schema,第四轮被Judge打回才补全,第五轮才想起来要用calculator算数值------它每一步只看当下,没有全局路线图,容易在中间步骤迷路、遗漏子任务、重复调用工具。

人类做复杂任务不会这样。我们接到一个多步骤任务,会先在脑子里过一遍:"要做A、B、C三件事,先做A拿数据,再做B处理,最后做C汇总"。这就是Plan-and-Execute模式 的核心思想:先花一次LLM调用想清楚全局步骤,再按计划执行。

本文在已有Agent上加入Plan-and-Execute能力。改造只涉及main.py一个文件,核心改动:新增PLANNING状态、计划数据模型、Planner prompt、计划注入system prompt、Judge扩展计划完成度检查。

一、ReAct vs Plan-and-Execute:本质差异

1.1 ReAct的问题

纯ReAct(我们前八篇的模式)的循环是:

复制代码
while 没完成:
    思考:当前状态下我该做什么?→ 调工具/回答
    观察:工具返回什么?

每轮THINKING只决定"下一步做什么",LLM脑子里没有全局计划。这对简单任务("1+1等于几")足够,但对多步骤复杂任务存在三个问题:

  1. 中途迷路:做了3步之后忘了第4步要干什么,或者重复做已经做过的事;
  2. 策略短视:为了完成当前子目标选了一个次优路径,到后面才发现走错了需要回溯;
  3. 无法分配注意力:LLM不知道什么时候该用什么工具,因为它不知道后面还要做什么。

1.2 Plan-and-Execute的核心思想

Plan-and-Execute把"想"和"做"分开:

复制代码
规划阶段:想清楚要做哪几步、每步的目的是什么
执行阶段:按步骤逐步执行,可以灵活调整策略
校验阶段:检查是否完成了所有计划步骤的目的

类比后端开发:ReAct是"边写代码边想需求",Plan-and-Execute是"先写设计文档再按模块实现"。设计文档不是死板的(实现中可以调整),但它提供了全局路线图,避免写着写着跑偏。

1.3 一个关键设计决策:计划是"建议"还是"强制工作流"?

这是Plan-and-Execute实现中最重要的设计选择:

选项A:计划作为强制工作流(Workflow模式)

  • 程序维护步骤状态机(pending→in_progress→done)
  • Executor每次只看当前步骤,做完必须调用step_done(step_id, result)元工具显式标记完成
  • 每步完成后Planner重新评估剩余计划,支持重规划
  • 优点:控制力强,不会遗漏步骤;缺点:实现复杂、token开销大、需要元工具和重规划逻辑

选项B:计划作为软引导(Guidance模式)------本文选择

  • 计划作为文本注入system prompt,LLM看到全局路线图自主执行
  • 不维护步骤状态机,不引入元工具
  • LLM可以灵活调整执行顺序和策略(比如发现文件不存在时先ls再读)
  • Judge在最终校验时检查"计划各步骤的目的是否达成"
  • 优点:实现简洁、零侵入执行层、灵活;缺点:LLM可能不严格按计划执行(但Judge兜底)

为什么选B? 第一性原理:抽象驱动力是真实痛点。我们的Agent处理的是2-6步的短任务(文件分析、代码理解、数值计算),这类任务在执行中很少需要根本性的计划调整------"文件不存在"这种错误LLM看到bash返回的错误自己就能换ls命令解决,不需要Planner介入。显式步骤状态机+重规划的复杂度在当前场景下是过度设计。等实际遇到"Executor频繁走丢、Judge反复打回仍无法完成"的场景,再升级到A模式。

二、数据模型:PlanStep和TaskPlan

用Pydantic定义计划结构:

python 复制代码
class PlanStep(BaseModel):
    step_id: int = Field(description="步骤编号,从1开始递增")
    description: str = Field(description="这一步具体要做什么")
    purpose: str = Field(description="为什么要做这步,它对最终答案有什么贡献")

class TaskPlan(BaseModel):
    goal: str = Field(description="对用户任务目标的清晰重述")
    steps: list[PlanStep] = Field(description="执行步骤列表,简单任务可以只有1步(直接回答)")
    notes: str = Field(default="", description="执行时的注意事项或约束,没有则为空字符串")

为什么每个步骤要有purpose字段?

这是关键设计。如果只有description("用bash wc -l统计行数"),Judge只能检查"有没有调用bash wc -l"------这是机械检查。加上purpose("获取文件总行数信息,用于在回答中告知用户文件规模"),Judge可以检查"回答中是否包含了行数信息",而不管LLM用什么方式拿到的(wc -l、cat|wc、read_file自己数都行)。purpose校验的是语义结果,description描述的是建议操作------这允许LLM灵活调整实现方式。

三、Planner:生成任务计划

3.1 PLANNER_SYSTEM_PROMPT

Planner是一个独立的LLM调用(json_mode),prompt告诉它有什么工具、怎么规划:

python 复制代码
PLANNER_SYSTEM_PROMPT = """你是任务规划器。根据用户的问题,制定一个执行计划,指导AI助手逐步完成任务。

你拥有以下工具能力:
- calculator:计算数学表达式
- read_file:读取工作区内的文件内容(支持offset/limit分段读取)
- bash:执行shell命令(ls/grep/cat/wc/find等),用于文件系统操作、代码搜索、运行命令等

规划规则:
1. 先分析用户问题需要哪些信息/操作,拆解为有序步骤;
2. 每个步骤要有明确目的(purpose),说明这步为什么做、产出什么;
3. 简单问题(如纯计算、简单问答)只需要1步"直接回答";
4. 复杂问题按逻辑顺序拆解:先获取信息(读文件/查目录)→ 再处理(计算/对比)→ 最后总结;
5. 如果需要读文件,先用bash(ls/find)确认文件存在再read_file,避免读不存在的路径;
6. 步骤数控制在2-6步之间,不要拆得过细。

输出严格JSON格式:
{
  "goal": "任务目标重述",
  "steps": [
    {"step_id": 1, "description": "具体操作", "purpose": "为什么做这步"},
    ...
  ],
  "notes": "注意事项(可为空字符串)"
}"""

注意第5条规则:Planner知道要先确认文件存在再读------这是把"最佳实践"编码到规划阶段,而不是靠Executor在执行中踩坑才学会。

3.2 plan_task函数

python 复制代码
def plan_task(user_query: str) -> TaskPlan:
    messages = [
        {"role": "system", "content": PLANNER_SYSTEM_PROMPT},
        {"role": "user", "content": f"请为以下任务制定执行计划:\n{user_query}"},
    ]
    try:
        resp = chat_completion(messages, json_mode=True)
        if resp.content:
            data = json.loads(resp.content)
            return TaskPlan(**data)
    except Exception:
        pass
    # fail-open降级:规划失败时返回通用单步计划
    return TaskPlan(
        goal=user_query,
        steps=[PlanStep(step_id=1, description="分析用户问题,调用必要工具获取信息后回答", purpose="完成用户任务")],
        notes="",
    )

fail-open设计:Planner调用失败(网络错误、JSON解析失败)时,返回一个通用的单步计划,Agent退化为纯ReAct模式,不会因为规划失败而无法工作。

3.3 _format_plan:计划格式化

python 复制代码
def _format_plan(plan: TaskPlan) -> str:
    lines = [f"【任务目标】{plan.goal}", "【执行计划】"]
    for s in plan.steps:
        lines.append(f"  步骤{s.step_id}:{s.description}(目的:{s.purpose})")
    if plan.notes:
        lines.append(f"【注意事项】{plan.notes}")
    lines.append("你可以根据工具返回的实际情况灵活调整执行顺序和策略,但必须确保最终回答覆盖所有目标。")
    return "\n".join(lines)

最后一句是关键:明确告诉LLM计划是建议性的,可以灵活调整。这避免了LLM死板执行计划(比如明明文件不存在还硬调read_file),同时提醒它最终要覆盖所有目标。

四、状态机改造:新增PLANNING状态

4.1 状态流转变化

复制代码
之前:THINKING → TOOL_EXECUTING → THINKING → ... → VALIDATING → FINISHED

现在:PLANNING(第0轮:生成计划)
    → THINKING(system prompt注入计划文本)
    → TOOL_EXECUTING → THINKING → ...
    → VALIDATING(Judge检查问题覆盖+计划完成度)
    → FINISHED

PLANNING是初始状态,只在循环开始时执行一次:

python 复制代码
state = AgentTaskState.PLANNING  # 初始状态改为PLANNING
task_plan: Optional[TaskPlan] = None
plan_text: str = ""

# 在while循环中:
if state == AgentTaskState.PLANNING:
    print("=== 规划任务 ===")
    task_plan = plan_task(user_query)
    plan_text = _format_plan(task_plan)
    print(plan_text)
    print()
    state = AgentTaskState.THINKING
    continue

4.2 THINKING注入计划上下文

python 复制代码
if state == AgentTaskState.THINKING:
    # 构造system prompt:行为规则 + 任务计划
    effective_system = SYSTEM_PROMPT
    if task_plan:
        effective_system = SYSTEM_PROMPT + "\n\n" + plan_text

    # Token预算计算用effective_system(包含计划文本的token开销)
    external_reserve = (
        count_text(effective_system) + 4
        + count_tools_schema(tools_schema)
        + 2
        + OUTPUT_RESERVE
    )
    # ...后续token检查和流式调用逻辑不变...

    messages = [{"role": "system", "content": effective_system}]
    messages.extend(memory.get_messages())

改动点很小:system prompt从固定的SYSTEM_PROMPT变成SYSTEM_PROMPT + 计划文本。Token预算计算也要用新的effective_system,确保计划文本的token被算进外部固定开销里。

TOOL_EXECUTING零改动------工具执行层完全不知道计划的存在,它只负责执行LLM请求的工具调用。这是"计划作为软引导"的优势:执行层不需要任何改造。

五、Judge扩展:计划完成度校验

5.1 Judge Prompt扩展

Judge的系统提示增加了任务计划维度:

python 复制代码
JUDGE_SYSTEM_PROMPT = """你是回答质量检查员。你的任务是判断"AI助手的回答"是否完整覆盖了"用户的问题"中的所有要求,以及是否完成了任务计划中的必要步骤。

你会收到以下信息:
1. 【用户问题】:用户的原始提问
2. 【任务计划】:AI在执行前制定的步骤计划(包括目标和每个步骤的目的)
3. 【AI调用过的工具及结果摘要】:AI调用了哪些工具、参数、返回什么
4. 【AI回答】:AI最终给出的回答

判断标准(新增的标★):
- ...(原有标准:工具使用正确性、数值引用、错误处理等)...
- ★ 对照任务计划:计划中每个步骤的"目的"是否在回答中得到了体现?
  如果某步骤的目的是获取某信息但回答中没有该信息,判定为不通过;
- ★ 计划步骤是建议性的,AI可以灵活调整执行方式,
  但最终结果必须覆盖计划中各步骤的目的。

missing字段示例:"计划要求统计文件行数但回答中没有行数信息"
"""

核心变化:Judge不再只检查"用户问题的字面要求是否被覆盖",还检查"计划中各步骤的目的是否达成"。双重保险------即使用户问题表述模糊,计划里拆解的子目标也能被Judge检查到。

5.2 judge_answer函数签名扩展

python 复制代码
def judge_answer(user_query: str, answer: str, tool_trace: list[dict], plan: Optional[TaskPlan] = None) -> JudgeResult:
    if not answer or len(answer.strip()) < 10:
        return JudgeResult(passed=False, missing="回答过短,没有实质内容。")

    trace_text = _format_tool_trace(tool_trace)
    plan_text = _format_plan(plan) if plan else "(无计划,直接回答)"
    messages = [
        {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
        {"role": "user", "content": (
            f"【用户问题】{user_query}\n\n"
            f"【任务计划】\n{plan_text}\n\n"
            f"【AI调用过的工具及结果摘要】\n{trace_text}\n\n"
            f"【AI回答】{answer}\n\n"
            f"请判断AI回答是否完整覆盖了用户的所有问题点,且完成了计划中各步骤的目的。"
        )},
    ]
    # ...后续逻辑不变...

plan参数默认None,向后兼容。没有计划时plan_text显示"(无计划,直接回答)",Judge退化为原有的问题覆盖度检查。

六、运行效果

多步任务的执行输出:

复制代码
=== 规划任务 ===
【任务目标】分析tools/base_tool.py文件,统计总行数,解释execute和to_openai_tool_schema方法的作用,并计算指定数值
【执行计划】
  步骤1:用bash wc -l命令统计base_tool.py文件总行数(目的:获取文件行数信息)
  步骤2:用read_file读取execute方法和to_openai_tool_schema方法的代码内容(目的:理解这两个方法的实现逻辑以便解释)
  步骤3:基于读取的代码内容解释两个方法分别做了什么(目的:回答用户关于方法功能的提问)
  步骤4:确定两个方法的行数,用calculator计算(行数1+行数2)*4/2(目的:得到数值计算结果)
【注意事项】先用ls确认文件路径正确,再执行读取和统计操作
你可以根据工具返回的实际情况灵活调整执行顺序和策略,但必须确保最终回答覆盖所有目标。

=== 第1轮 THINKING ===
好的,我来按步骤完成这个任务。首先确认文件存在并统计行数。
【工具调用意图】bash

=== 第1轮 TOOL_EXECUTING ===
【工具调用】bash({"command": "wc -l tools/base_tool.py", "cwd": "/Users/bytedance/..."})
【工具返回】[退出码] 0\n[执行目录] ...\n[stdout]\n     107 tools/base_tool.py\n...

=== 第2轮 THINKING ===
文件共107行。现在读取execute和to_openai_tool_schema方法的内容。
【工具调用意图】read_file
...

=== 第5轮 THINKING(最终回答)===
根据我的分析:
1. tools/base_tool.py文件共107行。
2. execute方法是模板方法...
3. to_openai_tool_schema方法...
4. 两个方法的行数分别是25行和18行,(25+18)*4/2 = 86。

【校验】正在Judge回答完整性...
【校验通过】回答完整

对比之前没有规划的版本:

  • 之前:Agent第1轮可能直接调read_file不知道读什么范围,中间容易遗漏calculator计算
  • 现在:第一轮就看到4步计划,每步有明确目的,执行顺序清晰,Judge按步骤目的逐项检查

七、小结

本阶段改动很小(只改main.py一个文件),但架构上有本质变化:

  1. 新增PLANNING状态:循环开始前先调用Planner生成TaskPlan,包含goal、steps(每步有description和purpose)、notes;
  2. 计划作为软引导注入system prompt:THINKING状态的LLM每轮都能看到全局路线图,零侵入TOOL_EXECUTING层;
  3. Judge双重校验:从"只检查用户问题覆盖"升级为"检查用户问题覆盖+计划步骤目的达成";
  4. fail-open降级:Planner失败时退化为纯ReAct模式,不阻塞主流程;
  5. purpose字段是关键:让Judge校验语义结果而非机械操作,允许LLM灵活调整执行方式。

至此,我们的Agent已经从"反应式"升级为"规划式"------接到复杂任务先想清楚步骤,再按计划灵活执行,最后由Judge按计划验收。

下一篇链接:【Java/Go后端手撸原生Agent(第十篇):HTTP+SSE+Web UI------让Agent长出真正的用户界面】

相关推荐
ZJH__GO1 小时前
网络编程v2--多客户端互通
java·运维·服务器·开发语言·计算机网络
lxw18449125141 小时前
Python uv 完整使用教程
python·conda·pip·uv
hangyuekejiGEO1 小时前
临沂GEO技术解析与行业应用方案
人工智能·python
霸道流氓气质1 小时前
SpringBoot中基于 AES-GCM + KMS 密钥管理的数据加解密 Starter 实践
java·数据库·spring boot
Gofarlic_OMS2 小时前
NX浮动许可调度黑名单机制,对比两款谁更合理
java·大数据·运维·开源·制造
乐观的Terry2 小时前
8、发布系统-完整流水线的核心
java·spring boot·spring·spring cloud
Alexalbb2 小时前
从角色过滤到可审计授权:单体系统数据权限设计复盘与演进
java
万亿少女的梦1682 小时前
基于Spring Boot的游戏交易管理系统设计与实现
java·spring boot·mysql·系统设计·交易管理
不做Java程序猿好多年3 小时前
Java中 String、StringBuffer、StringBuilder 的区别详解
开发语言·python