有个需求我印象很深:「帮我订一次完整的出差行程」。第一版直接丢给单个 Agent,它在预算和偏好之间反复横跳,前一步刚定的酒店被后一步的交通安排推翻,最后连出发日期都改了。模型每一步的推理单独看都讲得通,合起来是灾难。
问题不在模型能力,在这个任务本身需要先固定约束、再展开细节。那次之后我把规划从循环里拆出来,单独做一层:先产出一份可校验的计划,再让执行器按计划跑。
本系列第 2/15 篇 · 上一篇:Agent 是一个闭环控制系统 · 下一篇:工具调用协议:从 Schema 到幂等
规划到底在解决什么
第 1 篇里的单循环 ReAct,本质是贪心策略:每一步只看当前观测,选局部最优动作。这在步数少、反馈明确时很好用,但有三件事它结构性地做不好。
一是约束固化。用户说「预算 3000 以内、不坐红眼航班」,这是全局约束,不是某一步的输入。贪心循环里它只是一段早前的消息,随着观测堆进来会被挤出有效注意力范围。二是依赖顺序。要订酒店得先定日期和城市,要报销得先有订单号,这类依赖需要在动手前就明确,靠模型临场判断容易乱序。三是并行性。三个互不依赖的查询,循环里只能串行,白付两轮延迟。
规划层的价值就是把这三件事从「模型的隐性判断」变成「系统里的显式数据」。它不是让模型更聪明,是把一部分决策从概率输出搬到确定性结构上。
反过来也别过度神化。规划是一份基于当前信息的假设,环境一变就作废。我见过最难受的 bug 是执行器拿着一份过期计划严格执行到底,明明第三步已经证明文件不存在了,还在按原计划解析它。
从 CoT 到 Plan-and-Execute:三种表示
| 表示 | 产出物 | 执行方式 | 主要问题 |
|---|---|---|---|
| 思维链 / ReAct | 自然语言推理 + 单步动作 | 逐步回灌 | 无全局约束,易原地打转 |
| Plan-and-Solve | 编号步骤列表 | 顺序执行 | 无依赖信息,无法并行,计划错了难回头 |
| 结构化 DAG | 带依赖、输入输出、校验条件的步骤图 | 拓扑调度 + 局部重试 | 生成成本高,schema 要维护 |
我基本不用纯自然语言计划。「第一步查天气,第二步订会议室」这种文本,执行层还得再解析一次,而解析本身就可能出错,等于把歧义往后传了一层。现在一律要求规划器输出结构化 JSON,字段固定,校验不过就重试生成,绝不带着一份含糊的计划往下走。
1 | { |
四个字段是我反复加出来的:depends_on 让调度器能并行 s2、s3;produces 声明这一步往共享状态里写什么;done_when 把「完成」变成可执行的判定;requires_confirmation 把不可逆动作在计划阶段就标出来,而不是等执行时再发现。
$s1.date 这类引用我坚持用显式占位符,不让执行器去猜「上一步结果里的日期在哪」。参数解析交给代码做,模型只负责声明依赖关系。
分解策略:怎么切才不会切坏
生成计划本身也是一次模型调用,它的输入应该是任务描述、可用工具清单及其代价、以及已知约束,而不是一整段对话历史。我把规划器的上下文压得很干净,通常只有这三项加几个 few-shot 例子。
分解方式我按任务类型分三种:
按依赖切:适合流程类任务。从上往下问「要完成 X,必须先有哪些输入」,直到叶子节点是单个工具调用。产出的天然是 DAG。
按实体切:适合批处理。一百条工单,每条是独立子任务,计划里只写一次模板 + 一个数据列表。这时候检查点要细到单条记录,否则半成功状态没法恢复(第 1 篇的副作用账本在这里同样适用)。
按假设切:适合探索类任务。先列出几种可能的原因,每种给一个验证步骤,验证结果决定后续分支。这类计划本质上是决策树,调度器要支持条件边。
切分粒度有个很实用的判据:每一步应该有一个可检查的完成条件。如果某一步只能写成「分析一下情况」,说明它还能再拆,或者它根本不该交给规划器,而应该放回单步推理。另一个信号是参数——如果一步需要临时从前面好几步的自然语言输出里拼参数,说明 produces 没声明清楚。
我一般限制计划的顶层步骤在 3 到 8 个。超过 8 个,模型生成计划时自己就会开始漏依赖;真有那么复杂,应该分层,让每个子计划各自管理自己的步骤。
调度器:计划是数据,执行是代码
有了 DAG,执行层就不需要模型参与了,直到某一步失败。
1 | def run_plan(plan, state, max_replans=2): |
这里最容易低估的是 resolve。占位符解析必须严格:引用不存在的字段要立刻失败,而不是传个 None 给下游让模型猜。我吃过亏,$s2.flights[0].price 在 s2 返回空数组时变成了静默的 None,一路传到下单步骤才炸。
check 只做确定性判定:字段存在、数量下界、正则、数值范围。不要用小模型去判断「这一步做得好不好」,那等于又引入一个概率环节。全局约束(预算、时间窗)我放在每一步提交后重算一次,越界就早停,而不是等计划跑完再算总账。
什么时候必须重规划
静态计划最大的风险是「执行到底」,所以重规划触发条件要显式枚举,不能靠模型自己觉得不对劲:
- 某步连续重试仍不满足
done_when; - 观测与计划的前提冲突,比如计划写「读取配置文件」,实际文件不存在;
- 用户中途修改约束;
- 全局约束检查失败;
- 出现计划里没有声明的工具或动作需求。
重规划不是把整份计划丢掉重来。我传回规划器的上下文只包含:原始目标、当前已完成的步骤及其产物、失败原因、剩余预算。别把完整执行日志塞回去,那会让新计划被旧路径锚住。plan_version 每次加一,所有引用旧版本产物的检查点直接判定不可恢复——版本不兼容时硬续,比重新跑一遍更贵。
有个细节:重规划次数必须设上限。我见过一个 Agent 在「查不到就换个查法」上重规划了十几次,每次都生成一份看起来很合理的新计划。上限到了就该停下来把当前状态交给人,而不是继续烧 token 表现得很努力。
优劣与边界
收益很直接:全局约束不会随上下文漂移丢失;无依赖步骤可以并发,延迟降得明显;每一步有 done_when,失败能定位到具体节点并局部重试;计划是纯数据,可以 diff、可以缓存、可以人工预审。对涉及外部副作用的场景,requires_confirmation 让危险动作在执行前就可见。
代价也不能忽略:多一次甚至多次模型调用;schema 和占位符解析要长期维护,工具一改签名,计划模板就得跟着改;面对高度不确定的探索任务,预先规划往往是在编故事,还不如让 ReAct 边走边看。另外,规划质量强依赖工具描述的质量,工具清单写得含糊,计划一定写得含糊。
我的选择规则是:步骤之间存在真实依赖、或存在全局约束、或动作不可逆 → 上规划层;路径完全靠观测决定、步数预计在五步内 → 不上。两种模式可以在同一个系统里共存,由一个很薄的路由判断,但别让模型自己决定用哪种。
规划的另一半是「动作怎么落地」。计划里写了 flight.search,那个工具的定义、参数校验、失败回灌怎么做,是下一篇的事。你现在这份计划里,有多少步的完成条件是能用一行代码判定的?如果答案是「没几步」,先补这个,比换模型有效得多。
评论