系列目录:Phase 1-2 基础框架 → Phase 3 状态机+文件读取 → Phase 4-6 原生FC+Judge校验 → Phase 5 多工具并行+BashTool → Phase 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等于几")足够,但对多步骤复杂任务存在三个问题:
- 中途迷路:做了3步之后忘了第4步要干什么,或者重复做已经做过的事;
- 策略短视:为了完成当前子目标选了一个次优路径,到后面才发现走错了需要回溯;
- 无法分配注意力: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一个文件),但架构上有本质变化:
- 新增PLANNING状态:循环开始前先调用Planner生成TaskPlan,包含goal、steps(每步有description和purpose)、notes;
- 计划作为软引导注入system prompt:THINKING状态的LLM每轮都能看到全局路线图,零侵入TOOL_EXECUTING层;
- Judge双重校验:从"只检查用户问题覆盖"升级为"检查用户问题覆盖+计划步骤目的达成";
- fail-open降级:Planner失败时退化为纯ReAct模式,不阻塞主流程;
- purpose字段是关键:让Judge校验语义结果而非机械操作,允许LLM灵活调整执行方式。
至此,我们的Agent已经从"反应式"升级为"规划式"------接到复杂任务先想清楚步骤,再按计划灵活执行,最后由Judge按计划验收。
下一篇链接:【Java/Go后端手撸原生Agent(第十篇):HTTP+SSE+Web UI------让Agent长出真正的用户界面】