Agent架构设计:Planning与动态任务规划
很多 Agent 的问题,不是不会调用工具,而是不知道什么时候该停下来规划。
简单任务中,边思考边执行没有问题。但当任务涉及多个模块、多个步骤时,Agent 很容易陷入一种状态:每一步都合理,最后结果却和最初目标不一致。区别在于:执行能力和规划能力是两个独立的问题。
目录
没有规划的Agent
在 AgentLoop 那篇文章里,我们讲过 Agent 的基本循环:收到用户输入,推理,调工具,拿到结果,再推理,直到任务完成。这个模式在简单任务上表现很好。创建一个文件、查一个信息、改一段代码,Agent 每一步都能看到当前状态,做出合理判断。
但任务一旦变复杂,这个模式就开始暴露问题。
让 Agent “把一个单体项目拆成微服务”。它没有先想清楚要拆几个服务、边界在哪、共享数据怎么处理,而是直接上来就开始动手:先创建了一个目录,然后开始搬代码。搬着搬着发现两个模块之间有循环依赖,于是停下来处理依赖关系。处理完继续搬,又发现数据库表被两个模块共用,不知道该放哪个服务里。它每一步都在做局部最优的决策,但这些局部最优拼在一起,并不等于全局最优。
如果有规划呢?提前查好景点开放时间,按地理位置排好路线,吃饭的地方也标好。同样的时间,能多去两个地方,还不累。
Agent 也是一样。没有规划的 Agent 是 reactive 的,走一步看一步;有规划的 Agent 是 proactive 的,先想清楚再动手。
规划的本质
从工程角度看,Planning 解决的是执行前的信息组织问题:提前明确目标、步骤以及每一步的预期结果。
人类做复杂任务时天然就会规划。搭一个博客系统,脑子里会先过一遍:要有哪些模块?数据库怎么设计?先做什么后做什么?这些思考发生在动手写代码之前,但 LLM 不一样,它不会天然规划。
为什么 LLM 不会天然规划
LLM 本身并不维护任务状态。它看到的是当前上下文,而不是一个结构化的任务模型。在 AgentLoop 里,每一步的决策都是根据当前 messages 预测下一个动作,执行后把结果塞回 messages,进入下一轮。
问题在于两点:
当前上下文 ≠ 完整任务状态。 messages 里堆的是历史对话和工具输出,但它不包含"这个任务的最终目标是什么"、“已经完成了哪些子目标”、"还剩什么没做"这类结构化的任务状态信息。LLM 看到的是一堆碎片,它需要从碎片中推断全局,这本身就是一个不稳定的任务。
当前最优动作 ≠ 全局最优路径。 每一步 LLM 都在做局部最优决策,但局部最优拼起来不等于全局最优。
所以复杂任务会出现这种典型的局部决策陷阱:
Step1: "创建目录 src/modules/"
↓
Step2: "写代码搬进去"
↓
Step3: 发现目录结构不合理,和另一个模块冲突
↓
返工
每一步在当时看都是合理的,但从全局看,Step1 的目录结构就不对。
Planning 是额外引入一个结构化的能力层:
任务状态模型(当前在哪、目标是什么)
+
目标分解(大目标拆成小目标)
+
执行路线(先做什么、后做什么、每步产出什么)
这三层不是 LLM 天然具备的,需要我们在架构层面主动加进去。在 Agent 开始执行之前,先插入一个"规划阶段",让 LLM 把任务拆解成步骤,生成一个计划,然后按计划逐步执行。
两种范式
目前主流的 Agent 执行范式有两种:ReAct 和 Plan-and-Execute。
ReAct 就是我们之前讲的 AgentLoop 的模式。每一步都是:思考(Reason)→ 行动(Act)→ 观察(Observe)→ 再思考。Agent 永远在根据当前状态做即时反应。
ReAct 循环:
用户输入
│
▼
思考:我该做什么?
│
▼
行动:调用工具
│
▼
观察:拿到结果
│
▼
思考:下一步该做什么?
│
▼
行动:调用工具
│
...
Plan-and-Execute 多了一个规划阶段。先让 LLM 生成一个完整的执行计划,然后逐步执行。执行过程中如果发现计划需要调整,再重新规划。
Plan-and-Execute 流程:
用户输入
│
▼
规划:生成执行计划 [步骤1, 步骤2, 步骤3, ...]
│
▼
执行步骤1 → 观察结果
│
▼
执行步骤2 → 观察结果
│
▼
需要调整吗?→ 是 → 重新规划
│
▼
执行步骤3 → ...
两者的区别在决策时机:ReAct 是"边做边想",Plan-and-Execute 是"先想后做"。
| 维度 | ReAct | Plan-and-Execute |
|---|---|---|
| 决策时机 | 每步即时决策 | 先规划再执行 |
| 全局视角 | 弱 | 强 |
| 适用场景 | 简单任务、步骤少 | 复杂任务、步骤多且有依赖 |
但实际生产环境的 Agent 很少纯粹用其中一种。更多是混合模式:

Plan 提供方向,ReAct 提供执行灵活性。 目前很多代码 Agent 都采用类似思路:先生成执行方向,再进入工具调用循环,并在必要时重新调整计划。和之前讲的 Agent 错误恢复也是同一个思路——执行过程中出问题了,暂停、调整、继续。
对于大多数简单任务,ReAct 足够了。但当任务步骤超过五六步、且步骤之间有依赖关系时,Planning 的全局视角就能避免很多"局部最优、全局混乱"的问题。Agent 不需要每步都重新想"我该做什么",它只需要看一眼计划,执行当前步骤,然后继续。
Plan不是任务列表
会有初学者理解的 Plan 是这样的:
1. 创建文件
2. 安装依赖
3. 写代码
4. 跑测试
这只是一个 Task List,不是 Plan。Task List 只告诉你"做什么",不告诉你"为什么做"、“做到什么程度”、“怎么验证做对了”。
一个完整的 Plan 应该包含五个要素:
| 要素 | 含义 | 示例 |
|---|---|---|
| Goal | 最终目标 | 搭建一个可运行的 React 项目 |
| State | 当前状态和目标状态 | 当前:空目录 → 目标:npm run build 通过 |
| Constraints | 约束条件 | React 18、TypeScript、feature 目录结构 |
| Steps | 执行步骤 | 初始化 → 配置 → 实现 → 验证 |
| Validation | 验证条件 | npm run build 无报错、ESLint 无警告 |
为什么这个区分重要?因为在 Agent 执行过程中,最容易出问题的不是"下一步调哪个工具",而是任务目标有没有漂移。
举个例子。Agent 的目标是"搭建一个 React 项目,用 feature 模块划分目录"。执行到第三步时,它发现 Vite 默认模板用的是 pages 结构,于是顺手就按 pages 结构搭了。每一步的代码都没问题,但最终结果偏离了原始需求。
这就是只维护 Task List 不维护 Plan State 的后果。Agent 知道自己在执行第几步,但它不知道"原始目标是什么"、“当前进度和目标之间的差距有多大”。没有 Goal 和 Constraints 做锚点,执行过程中很容易被中间结果带偏。
所以 Planner 输出的不应该只是步骤列表,还应该包含目标描述和约束条件,执行过程中持续对照,防止漂移。
怎么拆任务
规划的核心环节是任务拆解。把一个大任务拆成可执行的步骤列表,这是 Planner 的职责。
最直接的方式:让 LLM 根据用户输入,输出一个步骤列表。
给 LLM 的 prompt 模板:
你是一个任务规划器。根据用户的请求,生成一个执行计划。
每个步骤必须是可独立执行的原子操作。
输出 JSON 格式。
用户请求: {user_request}
可用工具: {tool_descriptions}
输出格式:
{
"steps": [
{"id": 1, "description": "步骤描述", "tool": "工具名", "params": {...}},
...
]
}
LLM 返回的计划大概长这样:
{
"steps": [
{"id": 1, "description": "初始化 TypeScript 项目", "tool": "bash", "params": {"command": "npm create vite@latest . -- --template react-ts"}},
{"id": 2, "description": "安装 ESLint 和 Prettier", "tool": "bash", "params": {"command": "npm install -D eslint prettier eslint-config-prettier"}},
{"id": 3, "description": "写入 ESLint 配置", "tool": "write_file", "params": {"path": ".eslintrc.js", "content": "..."}},
{"id": 4, "description": "写入 Prettier 配置", "tool": "write_file", "params": {"path": ".prettierrc", "content": "..."}},
{"id": 5, "description": "按 feature 创建目录结构", "tool": "bash", "params": {"command": "mkdir -p src/features/auth src/features/dashboard ..."}}
]
}
这种方式简单直接,适合步骤不多的任务。但有个问题:如果任务本身很复杂,一步"搭建后端"太粗了,需要进一步拆解。
这时候可以递归拆解。先让 LLM 把任务拆成几个大步骤,再把每个大步骤继续拆,直到每一步都是"一个工具调用能完成"的粒度。
def plan_task(task: str, tools: list, depth: int = 0, max_depth: int = 3) -> list:
"""递归拆解任务"""
if depth >= max_depth:
return [{"task": task, "subtasks": []}]
# 让 LLM 拆解任务
plan_prompt = f"""把以下任务拆成 2-5 个子任务,每个子任务用一句话描述。
任务: {task}
输出 JSON: {{"subtasks": ["子任务1", "子任务2", ...]}}"""
result = json.loads(llm(plan_prompt))
subtasks = []
for st in result["subtasks"]:
# 根据工具能力判断是否需要继续拆
if is_atomic(st, tools):
subtasks.append({"task": st, "subtasks": []})
else:
subtasks.append({
"task": st,
"subtasks": plan_task(st, tools, depth + 1, max_depth)
})
return subtasks
def is_atomic(task: str, tools: list) -> bool:
"""判断任务是否可以用单个工具调用完成"""
prompt = f"""判断以下任务是否可以用单个工具调用完成。
任务: {task}
可用工具及参数: {describe_tools(tools)}
判断标准:任务的输入能否完全映射到一个工具的参数,输出是否就是该工具的返回值。
回答 yes 或 no。"""
return "yes" in llm(prompt).lower()
递归拆解出来的是一棵任务树,而不是扁平的列表。执行时按深度优先遍历,先处理叶子节点,再向上汇总。
判断"任务是否足够细粒度"不应该完全依赖 LLM。LLM 说"一步就能完成",实际上可能需要三步。更好的做法是结合工具能力来判断——如果一个任务的输入能完全映射到某个工具的参数,输出就是该工具的返回值,那它就是原子的。比如"创建 User.java 文件并添加字段",可以拆成 write_file 一个调用,是原子的;但"搭建用户管理模块"涉及建表、写 Model、写 Service、写 Controller,不是原子的。
不过在实际工程中,递归拆解的 token 消耗比较大,大多数场景下让 LLM 直接输出扁平的步骤列表就够了。只有当任务确实需要多层分解时,才值得用递归方式。
动态调整
计划不是一成不变的。Agent 在执行过程中会遇到各种意外:某个步骤执行失败了,执行结果和预期不一样,或者执行过程中发现了新的信息需要调整后续计划。
所以 Plan-and-Execute 需要一个 Replan 机制。
但实际系统不会每一步都 Replan。一个 100 步的任务,每步都调一次 Replanner,等于多了一倍的 LLM 调用,成本扛不住。生产环境通常用触发式 Replan:
执行步骤
│
▼
是否满足触发条件?
│
┌──┴──┐
否 是
│ │
▼ ▼
继续 Replan
执行 生成新计划
常见的触发条件有三种:
| 触发条件 | 示例 |
|---|---|
| 执行失败 | write_file 权限不足,bash 命令报错 |
| 关键状态变化 | 发现数据库里已有同名表,结构不同 |
| 连续偏离计划 | 连续 N 步的实际结果和预期不符 |
def should_replan(step: dict, result: str, history: list) -> bool:
"""判断是否触发 Replan"""
# 条件1:执行失败
if "error" in result.lower() or "failed" in result.lower():
return True
# 条件2:结果和预期不符
if step.get("expected") and step["expected"] not in result:
return True
# 条件3:连续 N 步偏离
recent_failures = sum(1 for h in history[-3:] if h.get("deviated"))
if recent_failures >= 2:
return True
return False
举个实际场景。Agent 的计划是"创建数据库表 → 写 Model 类 → 写 API 接口"。执行第一步时发现数据库里已经有一个同名的表,结构还不一样。触发条件命中,Agent 暂停执行,把"表已存在"这个信息反馈给 Planner,Planner 生成新计划:先对比已有表结构和需求的差异,再决定是修改表还是修改 Model 类。
如果第一步顺利,第二步也顺利,那就正常往下走,不触发 Replan。只有出问题了才暂停调整。
这就是动态规划和静态规划的区别。静态规划是一次性的,生成完就不管了;动态规划是持续的,按需触发调整。实际项目中几乎都是动态规划,因为现实世界太复杂,不可能一次就把所有情况都考虑到。
实战:核心流程
把上面的概念串起来,Plan-and-Execute 的核心流程用伪代码表示:
# 规划阶段
plan = planner.generate(user_request)
# 执行阶段
while plan.has_next():
step = plan.next_step()
result = executor.run(step)
# 触发式 Replan:执行失败或结果不符预期时才调整
if need_replan(step, result):
plan = planner.replan(plan.state, result)
plan.mark_done(step, result)
三个组件的职责:
- Planner:接收用户请求,结合工具定义,生成结构化的执行计划(Goal + Steps + Constraints)
- Executor:执行单个步骤,调用对应的工具,返回结果
- Replanner:在触发条件命中时,根据当前状态和执行结果调整剩余计划
整个流程走一遍:
用户: "创建一个 Python 项目,包含 main.py 和 requirements.txt"
计划:
步骤1: write_file → 创建 main.py
步骤2: write_file → 创建 requirements.txt
执行: 创建 main.py → done
执行: 创建 requirements.txt → done
全部完成
如果中间出了问题,比如写文件权限不足,Replanner 会收到失败信息,调整计划。实际项目中,Executor 的工具调用应该通过沙箱执行(参考 Agent 沙箱那篇),而不是直接暴露 shell。
小结
Planning 并不是所有 Agent 都必须增加的一层。对于简单任务,ReAct 已经足够;但当任务包含多个依赖步骤时,引入规划阶段可以降低执行过程中的方向偏移和返工。实际工程中的做法通常是混合模式:Plan 给方向,ReAct 做执行,Replan 兜底。生成计划和 Replan 都有 token 成本,五步以内的任务加规划层反而更慢,规划的价值在复杂任务中才真正体现。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_68431870/article/details/163251604



