Agent 规划并不是让模型列 TODO:我把任务拆成计划图、约束和可回滚执行
让模型列一个计划很容易,让计划在工具失败、证据变化和用户插话之后仍然成立,很难。大三上结束后,我开始碰到的 Agent 任务不再是“查一个资料并回答”,而是“读需求、找相关代码、改实现、跑测试、解释改动”。这类任务如果只靠一串 TODO,失败时几乎一定会乱。
我最早让模型先输出步骤:
1. 阅读需求
2. 搜索相关文件
3. 修改代码
4. 运行测试
5. 总结结果这看起来像计划,实际只是目录。它没有说哪些步骤依赖哪些证据,哪些动作可以重试,哪些失败后要回滚,哪些地方必须停下来问用户。计划如果不能约束执行,就只是更礼貌的废话。
ReAct 适合短任务,长任务需要更稳定的外部结构
ReAct 的“思考、行动、观察”循环很适合短任务。模型看到当前状态,选择一个工具,拿到结果,再决定下一步。我的第一个 Agent、RAG 助手和评测样例,大多可以靠这种循环完成。
但长任务会暴露几个问题。
第一,局部最优很容易把任务带偏。模型当前看到一个错误线索,就沿着它继续搜索,忘记原始目标。
第二,步骤之间缺少显式依赖。修改代码前应该先确认相关文件;运行测试前应该知道改了哪些模块;总结前应该知道测试是否真的跑过。纯对话历史里有这些信息,但没有结构化约束。
第三,失败恢复困难。第 6 步失败时,Agent 不知道应该回到第 3 步重做,还是换一个分支,还是停止请求确认。
所以我开始把 Planner 从“让模型想下一步”改成“生成并维护一个可执行计划图”。模型仍然参与规划,但计划本身变成运行时可以检查的数据结构。
TODO 列表缺少依赖,计划图才知道先后关系
一个简单任务可以写成线性列表,但真实任务通常是图:
理解需求
├── 找入口文件
├── 找相关测试
└── 找配置开关
│
▼
确认修改范围
├── 修改实现
└── 补充测试
│
▼
运行相关测试
│
▼
生成总结图里的节点不是一句自然语言,而是带类型和约束的任务:
{
"id": "find_related_tests",
"kind": "search",
"goal": "找到覆盖登录重试逻辑的测试文件",
"depends_on": ["understand_requirement"],
"success_criteria": [
"至少找到一个测试文件,或明确说明没有现有测试"
],
"allowed_tools": ["search_files", "read_file"],
"risk": "read"
}这样运行时可以判断:find_related_tests 没完成前,不能进入“修改测试”;allowed_tools 不包含写文件,就算模型提出写操作也应该拒绝;success_criteria 没满足,就不能把节点标记为完成。
计划图不需要一开始完美。它可以随着观察结果扩展。但每次扩展都应该保留依赖和约束,而不是让模型在聊天记录里随口补一句“接下来我会……”。
计划节点要有完成条件,否则模型会过早宣布完成
模型很擅长说“已完成”。如果没有可检查的完成条件,它可能在只读了一半文件后进入下一步。
我给节点写完成条件时,尽量避免空泛描述:
差:
理解相关代码
好:
列出与登录重试相关的函数、文件路径和调用关系;
如果没有找到测试,说明搜索过哪些目录和关键词。完成条件有三类:
确定性条件:
文件存在、测试通过、JSON 符合 schema
证据条件:
回答必须引用读过的文件、文档或工具结果
人工或 Judge 条件:
需求理解是否覆盖关键点、解释是否完整能用代码检查的就用代码检查。比如“测试通过”不应该让模型判断,而是看测试命令退出码。“引用文件路径”也可以检查路径是否真的出现在 trace 里。
语义类完成条件可以交给 Judge 或人工抽查,但它们不应该覆盖权限、文件、测试这些确定性事实。Planner 的深度不在于让模型写更长的计划,而在于把计划拆成可以验证的条件。
计划修订必须解释为什么改,不是偷偷换目标
长任务中,计划一定会变。搜索不到文件,测试失败,用户补充限制,工具返回冲突信息,都可能导致原计划不再合适。
计划可以修订,但修订要留下原因:
{
"type": "plan.revised",
"reason": "related test file not found under tests/auth",
"changed": [
{
"node": "add_regression_test",
"from": "modify existing test",
"to": "create new test under tests/login-retry.test.ts"
}
],
"evidence": ["tool.search_files#12", "tool.list_dir#15"]
}这个事件有两个作用。
第一,防止目标漂移。用户要求修登录重试,Agent 不能因为找不到测试就改成“顺便重构登录模块”。计划修订应该仍然指向原目标。
第二,方便复盘。最后如果任务失败,我能看到它在哪一步换了路线,依据是什么。没有修订日志,模型可能在上下文里悄悄改目标,最后给一个听起来合理但完全不是用户想要的结果。
我还会限制模型一次修订的范围。小修订可以自动接受,比如补一个搜索节点。高风险修订要停下来,例如从“只读分析”变成“修改代码”,从“运行测试”变成“安装依赖”。Planner 不应该绕过工具沙箱。
规划器和执行器分开,失败定位会清楚很多
我试过让一个模型同时负责规划、执行和总结。它能跑,但失败后很难定位。它是计划错了,执行错了,还是总结时把证据说歪了?
后来我拆成三层:
Planner:
生成和修订计划图,定义节点目标、依赖和完成条件
Executor:
根据当前可执行节点调用工具,收集证据,不随意改目标
Verifier:
检查节点完成条件、权限边界、测试结果和最终答案这不一定要用三个模型。很多时候 Planner 和 Executor 可以是同一个模型的不同提示,Verifier 里大量是确定性代码。关键是职责分开。
职责分开后,评测也更细。规划失败可以表现为节点缺失、依赖错误、风险分类错误。执行失败可以表现为工具选择错、参数错、步骤超预算。验证失败可以表现为错误地通过一个未完成节点。
如果所有东西都藏在一个“智能体”里,最后只能说“Agent 没做好”。这句话没有工程价值。
工作流适合确定路径,Agent 适合不确定分支
写 Planner 时,我反复遇到一个问题:有些任务根本不需要 Agent 规划。
比如发布博客文章:
检查 frontmatter -> 运行 markdown 检查 -> 构建 -> 发布这条路径很固定,应该写成工作流。模型可以帮忙解释错误、补全文案,但不应该决定是否跳过构建。
而排查一个测试失败就更适合 Agent:
先看错误日志,可能读源码,可能读测试,可能查配置,
可能发现是环境问题,也可能需要最小复现。这里分支多,提前写死所有路径成本很高。Planner 的作用是动态选择下一步。
我现在的原则是:确定顺序交给工作流,不确定选择交给 Agent。两者可以组合。
工作流外壳:
准备环境 -> Agent 分析和修改 -> 固定测试 -> 人工确认 -> 合并
Agent 内部:
根据错误选择文件、解释失败、提出补丁这样能减少模型自由度。越靠近高风险动作,越应该回到固定流程。Planner 不是为了让模型控制所有步骤,而是为了处理那些确实无法提前枚举的部分。
长任务需要检查点,不然失败成本太高
一次 Agent 任务跑 20 分钟后失败,如果只能从头开始,体验很差,也浪费资源。计划图天然适合做检查点。
每个节点完成后,保存:
节点状态
完成证据
工具结果引用
产生的文件 diff
测试结果
预算消耗后面失败时,可以从最近稳定节点继续,而不是重跑全部搜索和分析。
代码任务尤其需要这样做。比如 Agent 已经完成需求理解和文件定位,修改代码时失败了。恢复时不需要重新阅读整个仓库,只要加载前面的证据和当前 diff,再让它修正补丁。
检查点还有利于人工介入。用户可以在某个节点后暂停,看计划和证据,再决定是否继续。Agent 不是一口气跑到最后才展示结果,而是把长任务拆成可以检查的阶段。
这比“自主执行到底”更现实。真正可靠的长任务系统,往往不是完全不需要人,而是在人最该介入的位置停下来。
候选计划要比较代价和风险,不能只选最像答案的那个
同一个目标经常有多条路径。用户说“帮我修这个测试失败”,Agent 可以直接读失败栈附近代码,也可以先看最近提交,也可以跑更小范围的测试复现。每条路径都有代价。
我开始让 Planner 生成两个或三个候选计划,而不是只给一个:
计划 A:
从失败栈定位文件,直接阅读相关函数和测试。
代价低,适合错误栈明确的场景。
计划 B:
先复现测试,再根据输出定位。
代价中等,适合本地环境稳定的场景。
计划 C:
先读最近修改和相关模块历史。
代价较高,适合怀疑回归来自最近改动的场景。然后由运行时或 Arbiter 根据任务风险选择。选择标准包括:
- 是否需要写操作
- 是否需要联网或安装依赖
- 能否在当前沙箱里执行
- 预估工具调用次数
- 是否依赖不稳定外部状态
- 失败后是否容易回滚
这一步听起来像多余的规划,但它能避免模型直接选最激进的路径。比如“跑全量构建、自动修复所有错误”听起来彻底,但对一个小 bug 来说成本太高。先跑相关测试再扩大范围,通常更合理。
候选计划比较也能暴露模型不知道的信息。如果三个计划都依赖“运行测试”,但当前环境没有依赖,Planner 应该先生成“确认环境”节点,而不是假设测试能跑。
计划里的风险等级要跟工具沙箱对齐
我最早给计划节点加 risk 字段,只是为了展示。后来发现它必须和工具沙箱打通。
例如节点标记为 read,执行器就只能使用只读工具。节点如果需要写文件,必须是 write,并且计划修订事件里说明为什么从只读阶段进入写阶段。节点如果需要发请求、创建 issue 或提交 PR,则属于外部副作用,要走确认流程。
这样 Planner 就不能在一个看似只读的节点里偷偷调用写工具。运行时可以检查:
节点风险: read
模型请求工具: write_file
结果: 拒绝,并要求 Planner 修订节点风险或重新规划这个设计把计划从“建议”变成了权限约束的一部分。计划节点定义当前阶段的能力边界,工具沙箱负责执行边界,事件日志记录越界尝试。
风险等级还会影响评测。一个计划如果把“修改代码”标成 read,即使最终没出事故,也应该判为失败。因为它说明 Planner 没有正确理解任务后果。
我更喜欢这种失败提前暴露。等 Agent 真的越权写文件再追责,已经晚了。计划阶段就发现风险分类错误,修复成本低很多。
计划图也要进评测,而不是只看最终任务是否成功
Planner 本身需要测试。否则模型生成的计划看起来很完整,但可能缺关键依赖。
我给规划器写了几类 eval:
依赖测试:
修改代码前必须存在定位文件节点
风险测试:
涉及写文件的节点必须标记为 write
证据测试:
总结节点依赖测试结果和关键工具观察
修订测试:
检索为空时允许新增搜索节点,但不能改变用户目标
预算测试:
简单问题不能生成十几个无关节点这些测试不需要执行完整任务。输入一个用户目标,让 Planner 输出计划图,然后检查结构。这样可以在很早阶段发现问题。
计划图 eval 和最终任务 eval 互补。一个任务成功,不代表计划好,可能只是模型临场发挥;一个计划结构正确,也不代表执行能成功。分开测试,才能知道该改 Planner、工具描述、检索,还是执行器。
Planner 的失败模式比我想象中更固定
做了一批样例后,我发现 Planner 失败并不是完全随机,常见模式很固定。
第一类是过度规划。简单问题生成很多节点,甚至把“回答一个概念”拆成搜索、验证、总结、复查。这样的计划消耗高,还会让任务走向无关工具。
第二类是跳过证据。模型直接生成“修改实现”节点,却没有“定位相关代码”和“确认失败原因”。这会让后面的 Executor 变成猜补丁。
第三类是风险低估。计划写“分析项目”,实际节点里包含写文件或运行命令。模型常把“为了完成任务需要做的事”和“当前是否有权限做”混在一起。
第四类是目标漂移。搜索不到证据后,计划改成做一个相近但不是用户要求的任务。例如用户要修现有功能,它改成“新增一个更通用的工具函数”。
第五类是缺少退出条件。计划写了“继续调查直到找到原因”,但没有预算和停止条件。真实运行时这会变成无限搜索。
这些失败模式适合做成规则检查:
节点数量是否超过任务复杂度阈值
写节点之前是否存在证据节点
风险等级是否覆盖所需工具
修订后目标是否仍匹配原始需求
每个循环型节点是否有最大尝试次数有了这些规则,Planner 的质量不再完全依赖主观阅读。它仍然需要模型生成结构,但结构能被机器检查。
计划可以引用假设,但不能把假设当事实
调试任务里经常需要假设。比如测试失败可能是时区问题,也可能是缓存问题。Planner 可以记录这些假设,但必须标清状态。
我把信息分成三类:
Fact:
工具观察到的事实,例如测试输出、文件内容、函数引用
Hypothesis:
基于事实的解释,尚未验证
Decision:
根据事实和假设选择下一步,例如读某个文件或跑某个测试如果计划节点依赖一个假设,它应该包含验证方式:
假设:
失败来自 date parser 对无时区字符串处理不一致。
验证:
阅读 parseEssayDateInput,并运行覆盖无时区输入的测试。不能直接写:
修改 date parser 修复问题。这少了验证环节。模型把可能原因当成确定根因,就会过早写补丁。
把假设显式化还有一个好处:失败时更容易换路线。如果验证后发现假设不成立,Planner 可以回到候选假设列表,而不是在错误方向上继续加补丁。
我踩过的坑:计划越详细,不一定越可靠
我曾经要求模型一次生成完整计划,越详细越好。结果计划写了十几步,看起来很像项目管理文档,执行时却很差。
原因很简单:很多细节在观察之前根本不知道。还没读仓库,就计划修改某个文件;还没跑测试,就计划修哪个断言;还没看到错误,就计划根因。详细计划反而制造了错误承诺。
后来我改成粗计划加渐进展开:
先生成高层阶段:
理解需求 -> 定位相关代码 -> 修改与验证 -> 总结
进入每个阶段前:
根据当前证据展开下一批具体节点这样计划不会过早假设未知事实。它保留方向,但允许根据工具观察调整。
这也像人类做复杂任务。刚接到 bug 时,我不会立刻写出每一行修改步骤,而是先确定排查阶段。看完日志后,计划才变具体。让模型模仿这种节奏,比让它一开始写完 20 步更可靠。
规划质量最终要落在用户可理解的检查点上
计划图是给系统用的,但用户不应该被迫阅读一堆节点 JSON。长任务里,用户需要的是几个清楚的检查点:
我已经定位到相关模块,依据是这些文件和调用关系。
我准备修改两个文件,不会触碰配置和依赖。
相关测试是这些,预计先跑小范围测试。
当前缺少证据,所以我不会自动应用补丁。这类汇报不是为了礼貌,而是为了让用户能在关键点纠正方向。很多任务失败不是模型工具能力不足,而是早期理解偏了。如果 Agent 一口气跑完,用户只能在最后发现它修错方向。
我会让 Planner 在高风险节点前生成“执行摘要”,但摘要必须来自计划图和事实,不允许模型临场编。比如“将修改两个文件”要对应实际候选 diff,“相关测试”要对应测试选择工具结果。
这让计划既能被机器执行,也能被人审查。面试时我觉得这一点很重要:Agent 不是黑盒自动化,而是把复杂任务拆成可以验证、可以暂停、可以解释的阶段。
Planner 最终要服务于可控执行,而不是展示思考过程
规划器容易被做成“让模型展示推理”的功能。输出一大段计划,看起来很聪明,但运行时不使用它、不检查它、不根据它恢复,那它对系统没有多少价值。
我现在更关心计划能否被机器使用:
- 能不能判断哪个节点现在可执行
- 能不能知道节点需要哪些工具权限
- 能不能在失败时找到回退点
- 能不能记录计划修订原因
- 能不能把节点完成条件放进评测
- 能不能让人工在关键节点确认
如果答案是不能,那这份计划只是文本装饰。
从运行时、沙箱、评测台往上走,Planner 是我遇到的第一层复杂应用逻辑。它把底层能力组织起来:状态机推进节点,工具网关限制动作,评测台检查计划和轨迹,事件日志记录修订。
这篇文章的结论也很明确:Agent 规划不是让模型列 TODO,而是把不确定任务变成一张可执行、可验证、可修订的图。模型负责提出结构和根据证据调整,运行时负责执行边界和状态一致性。只有这样,长任务失败时才有地方下手,而不是盯着最后一句“抱歉我没完成”发呆。