加载中...
  • 规划与任务系统:从自然语言意图到可执行任务图 loading

    吃透 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。

    本文目录
    本文目录