吃透 AI Agent 开发 · 第 25 篇 · 第五章 · 规划与任务
“先给计划,不要执行”、进度清单和持久化任务图是三件不同的事,应该由不同状态和模块负责。
你对 Agent 说:“先看看这个需求怎么改,别动代码。”它回复了一份计划,紧接着开始修改文件。问题不一定是模型没听话,也可能是系统里根本没有“只规划、不执行”这个状态,只靠一句 Prompt 约束。
当 Agent 具备写文件、发请求或部署能力时,规划应该成为明确的控制状态,而不是一种语气。
三个看起来相似的东西
计划审批、进度清单和任务图经常被混在一起,其实它们回答不同问题。
| 机制 | 回答的问题 | 生命周期 |
|---|---|---|
| 计划审批 | 现在允许开始执行吗 | 从提出方案到批准或取消 |
| 进度清单 | 当前做到哪一步了 | 一轮或一个短任务 |
| 任务图 | 哪些工作依赖哪些工作 | 可跨会话、可并行 |
装修类比很直观:设计图先由业主确认;开工后用清单看水电和木工进度;大型项目再用任务图管理“墙没砌完就不能装门”。
一个超长 Todo 列表无法同时承担审批和依赖管理。
Plan Mode 是权限状态
进入规划状态后,最稳妥的做法是从工具层收窄能力,只保留读取、搜索和写计划等工具。即使模型忽略文字规则,它也拿不到修改代码的工具。
type AgentMode = 'normal' | 'planning' | 'awaiting-approval'
function visibleTools(mode: AgentMode, all: Tool[]): Tool[] {
if (mode === 'planning') {
return all.filter((tool) => tool.readOnly || tool.name === 'write_plan')
}
if (mode === 'awaiting-approval') return []
return all
}
这里体现了一条通用原则:Prompt 负责表达行为期望,系统能力边界负责兜底。高风险约束不能只写在 Prompt 里。
自然语言批准要保守判断
计划生成后,用户可能说:
- “可以,开始吧。”
- “没问题,但先别执行。”
- “第二步换个方案。”
- “行吗?我再想想。”
关键词匹配很容易把第二句误判为批准,因为里面有“可以”或“没问题”。判断顺序应该是否定和限制优先,再判断明确批准。
type ApprovalIntent = 'approve' | 'revise' | 'reject' | 'unknown'
function classifyApproval(text: string): ApprovalIntent {
if (/先别|不要执行|暂停|再等等/.test(text)) return 'reject'
if (/改一下|换成|补充/.test(text)) return 'revise'
if (/^(可以|开始|执行|按这个来)[!!。. ]*$/.test(text)) return 'approve'
return 'unknown'
}
本地规则处理高置信度表达,剩余情况可以交给一个短超时的模型分类器。但模型失败、超时或置信度不足时,应回到 unknown,而不是默认批准。
计划文件是交接协议
一份可执行计划不只是自然语言段落。它至少应该包含:
- 目标和不做什么。
- 涉及的模块或资源。
- 有顺序的步骤。
- 每一步的验证方式。
- 风险、待确认问题和回滚思路。
这样批准后的执行轮不必依赖模型“记得刚才说过什么”。计划可以作为明确附件进入下一轮,也能被用户修改、审计和恢复。
任务图解决依赖,不负责批准
当任务可以并行或跨会话时,用节点和依赖表达更合适:
interface TaskNode {
id: string
title: string
status: 'pending' | 'running' | 'blocked' | 'done' | 'failed'
dependsOn: string[]
}
function canStart(task: TaskNode, tasks: Map<string, TaskNode>) {
return task.dependsOn.every((id) => tasks.get(id)?.status === 'done')
}
任务图需要持久化状态,Todo 则可以只是界面上的轻量反馈。把每个“正在读取文件”都建成持久任务,会制造大量管理噪声;把跨模块迁移只写成三条 Todo,又无法表达依赖和恢复。
状态转换要由系统掌握
一个简单流程可以是:
normal
-> planning
-> awaiting-approval
-> revise -> planning
-> reject -> normal
-> approve -> normal + attach plan
模型可以建议转换,但最终状态由宿主程序保存。否则模型一句“计划已批准”就可能自行授予写权限。
界面也要保留用户原始请求。用户选择进入 Plan Mode 后,不应该被迫重新输入一次需求;批准后,计划和原请求应一起进入执行上下文。
两种常见过度设计
所有复杂请求都强制规划
修一个拼写错误也要生成、审批、执行三轮,体验会很差。更实用的策略是:高风险和多步骤任务主动建议规划,简单任务直接执行,用户随时可以显式切换。
用模型判断所有意图
“取消”“不要执行”这类表达可以本地稳定判断。每次都请求模型既慢又不可靠,还会在模型不可用时让审批入口失效。
用 Done 做个小练习
为批准分类器写一组表格测试,至少覆盖:
| 输入 | 预期 |
|---|---|
| 可以,开始吧 | approve |
| 可以,但先别执行 | reject |
| 第二步换成单元测试 | revise |
| 看起来行吗 | unknown |
然后故意把肯定词判断放到否定词之前,观察哪条测试会暴露问题。
User Intent 到 Done 的小结
规划系统的核心不是让模型多想一会,而是把“设计方案”和“允许产生副作用”分开。审批控制权限,清单展示进度,任务图管理依赖,三者组合起来才是一条可恢复的执行链。
下一篇进入状态存储:一段聊天、一条长期记忆和当前模型配置,为什么必须分开保存?
审批是一条状态转换,不是一句话
用户说“可以,先别改代码”时,同时包含肯定和否定。若系统只匹配“可以”,就会把讨论误判为执行许可。自然语言审批需要否定优先、确定性 fast-path 和保守的 unknown;模型 judge 只能处理本地规则无法判断的情况,失败时不能默认放行。
stateDiagram-v2
[*] --> discussing
discussing --> pending: plan_ready
pending --> approved: explicit_yes
pending --> rejected: explicit_no
pending --> pending: ambiguous
approved --> running: start_task
running --> paused: needs_confirmation
paused --> running: approve_step
running --> done: all_dependencies_done
任务图和 Todo 列表不要互相冒充
Todo 适合展示当前进度,任务图负责依赖、状态和恢复,Plan 负责解释准备怎么做。把三者都存成一段 Markdown,界面看起来完整,程序却无法判断哪个任务已经完成、哪个任务仍被依赖阻塞。
四处源码对应四个阶段
| 阶段号 | 路径 | 要回答的问题 |
|---|---|---|
| 1. 意图识别 | src/context/plan-intent.ts |
否定、批准和 unknown 如何区分 |
| 2. 计划文件 | src/context/plans.ts |
pending plan 如何持久化和批准 |
| 3. 任务状态 | src/context/tasks.ts |
依赖和终态怎样表达 |
| 4. 任务工具 | src/tools/task-tools.ts |
模型能修改哪些字段,谁验证转换 |
失败反例:只靠 Prompt 说“不要执行”
只靠 Prompt 说“不要执行”,系统却没有真正阻止写工具。模型换版本、上下文被压缩或用户表达含糊时,这句提醒可能失效;一旦工具已经写入,再解释“模型误会了”没有任何意义。
权限必须跟随计划状态进入工具层。pending 状态下写工具直接返回结构化拒绝,approved 也只开放计划声明过的范围;任务恢复时重新读取当前授权,不能把旧会话里的批准当永久令牌。
练习:写一组难判断的审批语句
准备十条语句,包括“可以解释,但不要执行”“先按这个方向研究”“不,我的意思是继续”“就这样吧”。为每条标注批准、拒绝或 unknown,先跑本地规则,再看是否真的需要模型 judge。最后断言 unknown 不会触发任何副作用。
进入 Plan Mode 前要保留原始请求
系统识别到复杂执行型任务时,可以建议进入 Plan Mode,但不应把原始文本替换成一句“请制定计划”。原请求包含目标、文件、限制和用户语气,是计划的来源证据。用户按 Enter 接受建议后,运行时切换工具集,再把原请求原样交给规划轮;按 Esc 则在普通模式继续;Ctrl+C 取消且不执行。
classic readline 或非交互环境无法弹确认面板,可以提示“建议先规划”后继续当前请求,不能卡住等待一个不存在的 UI 事件。语义识别负责建议,终端能力决定怎样呈现,二者不要互相假设。
自动直接进入 Plan Mode 只适用于高置信且可逆的本地状态转换,不能悄悄改变用户的执行意图。用户明确说“直接修”时,不要因为任务复杂强制多走审批。
Intent Judge 的返回值要足够窄
本地规则对“开始执行”“不要执行”“修改第二步”这类表达很稳。剩下的歧义可以调用当前会话模型做短 JSON 分类,只返回 intent、confidence 和 reasonCode,不生成方案。
{"intent":"unknown","confidence":0.61,"reasonCode":"conditional-language"}
设置 3 秒左右独立超时,0 可关闭。模型失败、输出非法、置信度不足都回退 unknown。Judge 不拥有工具,也不接触完整敏感上下文,只看 pending plan 摘要与最新用户回复。
否定优先仍由本地 fast-path 控制。“可以,但先别执行”不能因为 Judge 读到肯定词而放行。模型分类是补充,不是审批权的最终来源。
计划需要版本,批准必须指向某一版
用户批准 v1 后,Agent 又把第三步从“运行单测”改成“部署到测试环境”。若 approval 只绑定 planId,新内容会借用旧许可。每次修改计划都生成 version/hash,使此前 approval 失效。
interface PendingPlan {
id: string
version: number
contentHash: string
goal: string
nonGoals: string[]
steps: PlanStep[]
risks: string[]
createdAt: string
}
interface PlanApproval {
planId: string
version: number
contentHash: string
approvedAt: string
}
执行轮收到计划附件和 approval receipt,工具层仍对具体动作做授权。批准“修改三个文件并跑测试”不等于允许删除仓库、推送或发布。
好计划的步骤有可观察验收
“修改代码”“完善测试”不是可恢复步骤。步骤应说明输入、目标位置、预期副作用和验证:读取 auth 配置;修改超时解析;新增一个无效值单测;运行指定测试文件;检查 diff 只含两处。
计划不需要预测每一行实现。过细会在探索后迅速过期,过粗又无法判断进度。合适粒度是每一步完成后能观察一个稳定状态,并能决定是否进入下一步。
发现实现与计划假设不符时,当前 step 标记 blocked,更新风险并回到 pending approval,或在批准范围内做局部调整。不能为了“按计划完成”忽略新证据。
Task Graph 要防环、孤儿和假完成
创建任务节点时检查 dependsOn 存在且不形成环。删除节点前确认没有下游依赖;将节点标 done 时验证 doneWhen 证据,不接受模型只发一个状态字符串。
interface TaskEvidence {
taskId: string
kind: 'file-hash' | 'test-exit' | 'artifact' | 'approval'
ref: string
observedAt: string
}
例如“单元测试通过”需要 exit code 和命令摘要,“文档完成”可保存文件 hash 与链接。任务图不是审计全部内容,但每个终态要能回到证据。
父任务完成条件通常是所有必须子任务 done,optional 子任务可 failed/deferred。不要用“进度 100%”覆盖一个仍 blocked 的发布检查。
TodoWrite 只服务当前注意力
Todo 的价值是让用户看到 Agent 接下来做什么,也提醒模型不要漏掉短期步骤。它可以在本轮频繁更新,无需承担跨进程一致性。
长期任务图若把每次 read_file 都变成节点,会积累数百条噪声;Todo 若只写“完成迁移”,又无法展示进度。可以从当前 running task 派生 3 到 7 条短 Todo,任务完成后归档或替换。
UI 渲染 pending/in_progress/completed,最多一个 in_progress,避免模型同时声称正在做五件串行工作。真正并发由子任务状态表达。
计划执行中的审批可以分级
低风险、计划内文件修改在一次总体批准后执行;计划外文件、Shell 危险命令、外部发布仍需 step approval。策略依据工具 effect、资源范围和计划声明判断。
审批请求包含即将发生的具体动作、与计划哪一步对应、为什么现在需要、失败如何恢复。用户拒绝某一步时,任务进入 blocked/revise,不把拒绝当工具错误后换另一条隐蔽路径。
批准有时效。会话跨天恢复、工作区 hash 大幅变化或用户切换项目后,旧 approval 失效;历史只说明当时允许过,不能作为永久令牌。
任务恢复时重新计算可运行节点
进程重启后读取任务图、证据和当前环境。running 节点不能直接保持 running,因为执行进程可能已不存在;将其转为 recovering,检查副作用与 artifact,再决定 done、pending 或 unknown。
依赖节点的结果文件若被外部修改,下游即使曾经 ready,也应重新评估。任务图保存的是逻辑关系,现实状态仍要验证。
当前模型和权限来自新进程,历史模型只展示。恢复上下文不恢复控制权,这条原则在计划系统同样成立。
用一条完整审批轨迹验收
准备请求:“先规划登录超时修复,不要改代码。”系统进入 planning,只暴露只读工具;生成 v1,用户说“第二步改成先补测试”,产生 v2;用户说“可以,但先别执行”,保持 pending;最后明确批准 v2,才开放计划内写工具。
执行中尝试改计划外 .env,策略拒绝;测试失败,任务不是 done;修复后测试 exit=0、diff 符合范围,任务完成。重启进程恢复这条轨迹,确认不会再次写入,也不会把 v1 approval 套在 v2。