花生了什么事a头像
关注

Agent架构设计:Planning与动态任务规划

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 执行范式有两种:ReActPlan-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 是"先想后做"。

维度ReActPlan-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

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--