跳至主要内容
规划器:任务分解与可校验计划

规划器:任务分解与可校验计划

有个需求我印象很深:「帮我订一次完整的出差行程」。第一版直接丢给单个 Agent,它在预算和偏好之间反复横跳,前一步刚定的酒店被后一步的交通安排推翻,最后连出发日期都改了。模型每一步的推理单独看都讲得通,合起来是灾难。

问题不在模型能力,在这个任务本身需要先固定约束、再展开细节。那次之后我把规划从循环里拆出来,单独做一层:先产出一份可校验的计划,再让执行器按计划跑。

本系列第 2/15 篇 · 上一篇:Agent 是一个闭环控制系统 · 下一篇:工具调用协议:从 Schema 到幂等

规划到底在解决什么

第 1 篇里的单循环 ReAct,本质是贪心策略:每一步只看当前观测,选局部最优动作。这在步数少、反馈明确时很好用,但有三件事它结构性地做不好。

一是约束固化。用户说「预算 3000 以内、不坐红眼航班」,这是全局约束,不是某一步的输入。贪心循环里它只是一段早前的消息,随着观测堆进来会被挤出有效注意力范围。二是依赖顺序。要订酒店得先定日期和城市,要报销得先有订单号,这类依赖需要在动手前就明确,靠模型临场判断容易乱序。三是并行性。三个互不依赖的查询,循环里只能串行,白付两轮延迟。

规划层的价值就是把这三件事从「模型的隐性判断」变成「系统里的显式数据」。它不是让模型更聪明,是把一部分决策从概率输出搬到确定性结构上。

反过来也别过度神化。规划是一份基于当前信息的假设,环境一变就作废。我见过最难受的 bug 是执行器拿着一份过期计划严格执行到底,明明第三步已经证明文件不存在了,还在按原计划解析它。

从 CoT 到 Plan-and-Execute:三种表示

表示 产出物 执行方式 主要问题
思维链 / ReAct 自然语言推理 + 单步动作 逐步回灌 无全局约束,易原地打转
Plan-and-Solve 编号步骤列表 顺序执行 无依赖信息,无法并行,计划错了难回头
结构化 DAG 带依赖、输入输出、校验条件的步骤图 拓扑调度 + 局部重试 生成成本高,schema 要维护

我基本不用纯自然语言计划。「第一步查天气,第二步订会议室」这种文本,执行层还得再解析一次,而解析本身就可能出错,等于把歧义往后传了一层。现在一律要求规划器输出结构化 JSON,字段固定,校验不过就重试生成,绝不带着一份含糊的计划往下走。

JSON19 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"plan_version": 1,
"goal": "安排 11/20 上海出差,预算 3000 以内,不坐红眼航班",
"constraints": [
{"key": "budget_max", "value": 3000, "check": "sum(costs) <= value"},
{"key": "no_red_eye", "value": true, "check": "all(f.depart_hour >= 7)"}
],
"steps": [
{"id": "s1", "tool": "calendar.search", "args": {"city": "上海", "date": "11-20"},
"depends_on": [], "produces": ["date", "city"], "done_when": "date != null"},
{"id": "s2", "tool": "flight.search", "args": {"date": "$s1.date"},
"depends_on": ["s1"], "produces": ["flights"], "done_when": "len(flights) > 0"},
{"id": "s3", "tool": "hotel.search", "args": {"city": "$s1.city"},
"depends_on": ["s1"], "produces": ["hotels"], "done_when": "len(hotels) > 0"},
{"id": "s4", "tool": "booking.commit", "depends_on": ["s2", "s3"],
"produces": ["order_id"], "done_when": "order_id matches /^ORD-\\d+$/",
"requires_confirmation": true}
]
}

四个字段是我反复加出来的:depends_on 让调度器能并行 s2、s3;produces 声明这一步往共享状态里写什么;done_when 把「完成」变成可执行的判定;requires_confirmation 把不可逆动作在计划阶段就标出来,而不是等执行时再发现。

$s1.date 这类引用我坚持用显式占位符,不让执行器去猜「上一步结果里的日期在哪」。参数解析交给代码做,模型只负责声明依赖关系。

分解策略:怎么切才不会切坏

生成计划本身也是一次模型调用,它的输入应该是任务描述、可用工具清单及其代价、以及已知约束,而不是一整段对话历史。我把规划器的上下文压得很干净,通常只有这三项加几个 few-shot 例子。

分解方式我按任务类型分三种:

按依赖切:适合流程类任务。从上往下问「要完成 X,必须先有哪些输入」,直到叶子节点是单个工具调用。产出的天然是 DAG。

按实体切:适合批处理。一百条工单,每条是独立子任务,计划里只写一次模板 + 一个数据列表。这时候检查点要细到单条记录,否则半成功状态没法恢复(第 1 篇的副作用账本在这里同样适用)。

按假设切:适合探索类任务。先列出几种可能的原因,每种给一个验证步骤,验证结果决定后续分支。这类计划本质上是决策树,调度器要支持条件边。

切分粒度有个很实用的判据:每一步应该有一个可检查的完成条件。如果某一步只能写成「分析一下情况」,说明它还能再拆,或者它根本不该交给规划器,而应该放回单步推理。另一个信号是参数——如果一步需要临时从前面好几步的自然语言输出里拼参数,说明 produces 没声明清楚。

我一般限制计划的顶层步骤在 3 到 8 个。超过 8 个,模型生成计划时自己就会开始漏依赖;真有那么复杂,应该分层,让每个子计划各自管理自己的步骤。

调度器:计划是数据,执行是代码

有了 DAG,执行层就不需要模型参与了,直到某一步失败。

PYTHON19 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def run_plan(plan, state, max_replans=2):
pending = {s["id"]: s for s in plan["steps"]}
for _ in range(max_replans + 1):
ready = [s for s in pending.values() if deps_done(s, state)]
for step in ready: # 无依赖的可并发
args = resolve(step["args"], state) # 展开 $sN.field
result = tool_invoke(step, args, idempotency_key=key(state, step))
if not check(step["done_when"], result):
state.failures.append((step["id"], classify(result)))
continue
state.update(result, produced=step["produces"])
del pending[step["id"]]
if not pending:
return state
if not state.failures:
raise Deadlock(plan) # 依赖环或缺失引用
plan = replan(plan, state) # 带失败原因重新生成
pending = {s["id"]: s for s in plan["steps"]}
return halt(state, reason="replan_exhausted")

这里最容易低估的是 resolve。占位符解析必须严格:引用不存在的字段要立刻失败,而不是传个 None 给下游让模型猜。我吃过亏,$s2.flights[0].price 在 s2 返回空数组时变成了静默的 None,一路传到下单步骤才炸。

check 只做确定性判定:字段存在、数量下界、正则、数值范围。不要用小模型去判断「这一步做得好不好」,那等于又引入一个概率环节。全局约束(预算、时间窗)我放在每一步提交后重算一次,越界就早停,而不是等计划跑完再算总账。

什么时候必须重规划

静态计划最大的风险是「执行到底」,所以重规划触发条件要显式枚举,不能靠模型自己觉得不对劲:

  • 某步连续重试仍不满足 done_when;
  • 观测与计划的前提冲突,比如计划写「读取配置文件」,实际文件不存在;
  • 用户中途修改约束;
  • 全局约束检查失败;
  • 出现计划里没有声明的工具或动作需求。

重规划不是把整份计划丢掉重来。我传回规划器的上下文只包含:原始目标、当前已完成的步骤及其产物、失败原因、剩余预算。别把完整执行日志塞回去,那会让新计划被旧路径锚住。plan_version 每次加一,所有引用旧版本产物的检查点直接判定不可恢复——版本不兼容时硬续,比重新跑一遍更贵。

有个细节:重规划次数必须设上限。我见过一个 Agent 在「查不到就换个查法」上重规划了十几次,每次都生成一份看起来很合理的新计划。上限到了就该停下来把当前状态交给人,而不是继续烧 token 表现得很努力。

优劣与边界

收益很直接:全局约束不会随上下文漂移丢失;无依赖步骤可以并发,延迟降得明显;每一步有 done_when,失败能定位到具体节点并局部重试;计划是纯数据,可以 diff、可以缓存、可以人工预审。对涉及外部副作用的场景,requires_confirmation 让危险动作在执行前就可见。

代价也不能忽略:多一次甚至多次模型调用;schema 和占位符解析要长期维护,工具一改签名,计划模板就得跟着改;面对高度不确定的探索任务,预先规划往往是在编故事,还不如让 ReAct 边走边看。另外,规划质量强依赖工具描述的质量,工具清单写得含糊,计划一定写得含糊。

我的选择规则是:步骤之间存在真实依赖、或存在全局约束、或动作不可逆 → 上规划层;路径完全靠观测决定、步数预计在五步内 → 不上。两种模式可以在同一个系统里共存,由一个很薄的路由判断,但别让模型自己决定用哪种。

规划的另一半是「动作怎么落地」。计划里写了 flight.search,那个工具的定义、参数校验、失败回灌怎么做,是下一篇的事。你现在这份计划里,有多少步的完成条件是能用一行代码判定的?如果答案是「没几步」,先补这个,比换模型有效得多。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #架构 #规划

评论