第 4 章:任务规划——把「准备怎么做」变成可验证状态
← 上一章:记忆与 Skill 进化 · 下一章:Subagent
开场:一个「我会分四步做」的客服,真的分四步做了吗?
前几章你让 D 类客服能动手、能记住、会调用 Skill。现在给他派一个多步骤任务:「整理三份资料,先比较差异,再写 Markdown,最后生成 HTML 并确认能打开。」
他会很自然地点头说:「好,我分四步:检索、比较、写文档、生成网页。」——这很正常,因为模型特别擅长把任务列成步骤。
可这里有个陷阱:他把步骤说出来,不等于系统知道自己正在第几步,更不等于这四步真的完成了。 下面这几个问题,光靠他嘴里那份「编号列表」一个都答不了:
- 他现在做到第几步了?
- 第 1 步失败后,第 2 步还能偷偷开始吗?
- 「写好的网页文件」究竟在哪?
- 他说「做完了」的时候,谁去检查第 4 步?
这一章把自然语言的计划升级成 Harness 里的状态:每一步有稳定的 ID、状态、依赖和完成证据;模型负责建议下一步,代码负责批准状态迁移;模型想结束时,Completion Guard 再核对真实证据。本章结束时,你能回答:「我打算怎么做」和「系统知道我现在做到哪一步」之间,到底差了什么?
用户给了四步任务,模型自然列出四步。为什么这还不够?
- A:模型说得不够详细,应该列成八步
- B:列出步骤不代表系统知道自己正在第几步,更不代表步骤真的完成
- C:用户应该自己去做
- D:四步太多了,应该合并
编号列表不是状态机
「请先规划再执行」是一句很好的系统提示,但它缺一样东西:可检查的强约束。 列表里的字只存在于模型的回复里,程序拿不到「第 2 步开始了吗」「第 1 步失败还允不允许第 2 步」这些事实。
把计划外置,是从「说过」跨到「保存过」的第一步:计划不再是模型嘴里的一段话,而是 Harness 里一份被保存、可查询、可更新的状态。教学仓库这一步把计划写成了 TODOS。
但「外置」只是起点。一份能支撑生产的计划,每一条至少要回答四个问题:
- 状态:
pending/in_progress/completed/failed/cancelled,当前是哪一种; - 依赖:哪些步骤必须先完成(
depends_on),而不是靠数组顺序猜; - 证据:标成
completed时,凭什么——测试、文件校验、检索结果还是人工批准; - 尝试:
attempt是首次执行还是重试,重新规划时保留旧版本(plan_version)。
这就是第一课:编号列表只回答「我打算做几件事」,状态机回答「现在做到哪、能不能做、凭什么说做完」。
把计划变成 Harness 里的状态
一个能用的规划系统,至少由四块组成,每一块都有「不能交给谁」的边界:
| 组件 | 职责 | 不能交给谁 |
|---|---|---|
| Planner | 提出步骤、顺序和重新规划建议 | 不负责证明任务已完成 |
| Plan Store | 保存版本、步骤、状态、依赖、证据引用 | 不从自然语言猜状态 |
| Transition Guard | 校验合法迁移、依赖、并发约束 | 不能只靠 Prompt |
| Completion Guard | 结束前核对残单与环境证据 | 不能相信模型一句「完成」 |
最小步骤模型长这样——它比「字符串数组」多出四个字段:
from dataclasses import dataclass, field
@dataclass
class PlanStep:
id: str
content: str
status: str = "pending"
depends_on: tuple[str, ...] = ()
evidence: list[str] = field(default_factory=list)
attempt: int = 0状态迁移必须由代码把关,而不是靠模型自觉。教学仓库里「同一时间只能有一个 in_progress」这类约束,就是让代码拒绝非法回退和跳转:
VALID = {
"pending": {"in_progress", "cancelled"},
"in_progress": {"completed", "failed"},
"failed": {"in_progress", "cancelled"},
"completed": set(),
"cancelled": set(),
}
def transition(step, target):
if target not in VALID[step["status"]]:
raise ValueError(f"illegal transition: {step['status']} -> {target}")
step["status"] = target为什么 depends_on 比「数组里排在前面」更可靠?因为数组顺序是巧合——一旦重新规划、并行执行或有人调整顺序,先后关系就错了。显式声明依赖,才是代码能核对的约束。
两个步骤的先后关系,为什么用 depends_on 表达比「数组里排在前面」更可靠?
- A:数组顺序读起来更直观
- B:数组顺序是模型生成的,天然不可信
- C:依赖被显式声明,不依赖数组位置的巧合;重新规划或并发时不易错位
- D:depends_on 能让代码更短
要能停下、能换路、能证明
规划解决的不是「模型不会想步骤」,而是复杂任务的三个工程问题。
进度可见。 用户、运行时和下一次模型调用,都能读到当前步骤。用事件而不是形容词:plan.step_started / plan.step_completed / plan.step_failed 这种过去式事件表示「事实已经发生」,可以重放、可以审计;而 planning 只是个转圈动画,不能当证据。
执行可约束。 前置步骤没完成,后续步骤不能偷偷开始。这一步由 Transition Guard 和 depends_on 共同保证。
结束可核验。 模型说「完成」的时候,Completion Guard 检查两件事:还有没有未完成项(残单),以及标成 completed 的步骤有没有证据(unproven)。只满足其中一件都不算数:
def completion_guard(steps):
unfinished = [
item for item in steps
if item["status"] not in {"completed", "cancelled"}
]
unproven = [
item for item in steps
if item["status"] == "completed" and not item.get("evidence")
]
return {"ok": not unfinished and not unproven,
"unfinished": unfinished,
"unproven": unproven}还有一个容易被忽略的点:失败时不要覆盖旧计划。 首选路径 API 调用失败,应该保留失败轨迹,再生成 Plan v2,并发出 plan.replanned(from=1, to=2, reason=...)。这样调试时才能回答「为什么换了路」,而不是只看到最终清单。
完整流程长这样——模型负责「建议下一步」,Harness 负责「这一步能不能执行、完成证据够不够」:
一个步骤被标成 completed,但没有任何 evidence。Completion Guard 应该怎么做?
- A:放行,因为模型明确说完成了
- B:返回 unproven,把它视作未完成,继续循环
- C:自动给这个步骤编一条 evidence
- D:把整个计划作废重来
Plan 与 Goal 的边界
最后分清两个词。它们常被混用,但寿命和结束条件完全不同:
| 概念 | 回答的问题 | 典型寿命 | 结束条件 |
|---|---|---|---|
| 待办状态 | 当前有哪些执行项、各自状态如何 | 一个 Run 或一次 Plan | 条目全部终态 |
| Plan | 为达成目标选择的步骤和依赖 | 可被重新规划替换 | 通过或被新版本取代 |
| Task | 可被执行与验收的一件工作 | 一次委派或工作单元 | 输出契约满足 |
| Goal | 最终成功是什么 | 可跨 Turn、Run、Plan | 成功标准有证据 |
边界只有一句话:Goal 定义成功,Plan 描述尝试。 Plan 可以因新观察而重排;Goal 不应因为某次 Plan 失败就悄悄改变。首选方案挂了、换条路继续走,改变发生在 Plan 层,Goal 一个字都没动。
这条边界也提醒你:本章的 Completion Guard 只能证明「当前 Plan 没有残单」。它证明不了「长期目标真正达成」——那是第 9 章 GoalTracker 的活。Mini Emperor 里的 GoalTracker 已经在做一件事:连续几轮没有进展就暂停,防止 Agent 在一个无望的方向上无限打转。
首选方案 API 调用失败,Agent 改用本地资料继续推进。这次改变发生在哪一层?
- A:Goal 层——目标变了
- B:Plan 层——换了一条路径;Goal 不应因为 Plan 失败而悄悄改变
- C:用户记忆层——用户偏好更新了
- D:Skill 发布层——发布了新 Skill
动手:让两个失败发生,再用一个测试拦住
理论和手感之间隔着一道墙,亲手撞一次才会留下印象。下面两个实验都是故意做错的。
失败一:验证「模型自述」当作验证「环境产物」。
把下面这段直接复制到课程仓库根目录运行:
uv run --python 3.12 python - <<'PY'
plan = ["检索资料", "写 Markdown", "生成 HTML"]
answer = "都完成了"
print("模型回答:", answer)
print("计划文本:", plan)
print("HTML 是否存在:无法从这两项状态判断")
assert answer == "都完成了"
PY程序会成功退出——但这个「测试通过」本身就是失败证据:它只验证了模型嘴里说的「都完成了」,没有验证任何环境产物。网页文件存不存在、对不对,从这两项状态里根本看不出来。
失败二:把步骤标绿,但没有任何证据。
steps = [
{"id": "write-md", "status": "completed", "depends_on": [], "evidence": []},
{"id": "render-html", "status": "completed", "depends_on": ["write-md"], "evidence": []},
]
def completion_guard(steps):
unfinished = [s for s in steps if s["status"] not in {"completed", "cancelled"}]
unproven = [s for s in steps if s["status"] == "completed" and not s.get("evidence")]
return {"ok": not unfinished and not unproven,
"unfinished": unfinished,
"unproven": unproven}
print(completion_guard(steps)) # ok 是 False,unproven 有两项界面上一片绿,但只要没有 evidence,Completion Guard 就把它当未完成。「状态绿了」不等于「产物出现了」。
跑完后用现成测试验证「无进展就暂停」这个停止机制真的有效——它是 Goal 层防止空转的闸:
cd "$(git rev-parse --show-toplevel)"
uv run --python 3.12 --extra dev pytest \
mini-emperor/backend/tests/test_evolution.py::test_goal_pauses_after_three_no_progress_cycles \
-v如果它不过,按这个顺序排查:
- 计数没走 →
record(progress=False)有没有被调用; - 该停没停 → 暂停条件和
max_no_progress阈值; - 暂停后还在跑 → 调用方有没有检查
status == PAUSED。
门禁·代码:跑通上面这条测试,把输出保存为证据。
本章小结
这一章把一句话「我会分四步做」变成了一个状态机。四件事连起来看:
编号列表不是状态机。 列表只回答「打算做几件事」;状态机回答「做到哪、能不能做、凭什么说完」。从「说过」到「保存过」,是计划外置的第一步。
状态由代码把关,不由模型自觉。 Planner 提建议、Transition Guard 批迁移、Completion Guard 核证据——每一块的信任边界都要写清楚。模型适合提出哪类更新,代码必须把守哪类更新。
完成要能证明,失败要留痕迹。 completed 必须绑定 evidence;重新规划要保留旧版本和 plan.replanned 原因,而不是覆盖。
Goal 定义成功,Plan 描述尝试。 换路径发生在 Plan 层;Goal 不应因为某次 Plan 失败就悄悄改变。
一句话记住这一章:
列出四步不等于系统知道自己正在第几步,更不等于四步真的完成。把「打算怎么做」变成可验证状态——有 ID、有状态、有依赖、有证据、有闸门。
如果上面这些话你自己也能说得出来(不看资料),就说明这一章的知识已经连成一片。
复述与证据
合上资料,画出这一章的完整流程:Planner 提计划 → Transition Guard 校验 → 执行 → 收证据 → Completion Guard 核验 → 结束。标出每一步里「谁建议、谁批准、谁证明」。
再追问三个问题:
depends_on与「数组顺序」有什么不同?- 为什么重新规划要保留旧版本,而不是直接覆盖?
- 待办项全绿,为什么仍不能证明 Goal 完成?
门禁·证据:提交一条运行轨迹,标出 plan_version、当前步骤、最近 evidence 和不能结束的原因。
24 小时后:不看资料,写一个 30 行以内的 transition() 和 completion_guard(),覆盖非法跳转与无证据完成。
有兴趣可以继续看
以下资料按优先级排。至少完成一项 P0,并保存完成证据。
视频P015 分钟 · 视频 04 [00:01:42–00:04:37],再看 [00:07:19–00:08:10]
- 预计
- 15 分钟
- 位置
视频 04 [00:01:42–00:04:37],再看 [00:07:19–00:08:10]- 阅读任务
- 画出状态模型、工具更新与 Completion Guard;标出哪些完成状态来自模型、哪些来自环境。
- 完成证据
- 画一张时间线,标出每次状态更新与文件验收。
教学仓库P035 分钟 · Step 08,重点 update_todos()、Tool Schema、主循环结束分支
- 预计
- 35 分钟
- 位置
Step 08,重点 update_todos()、Tool Schema、主循环结束分支- 阅读任务
- 运行一次多步骤任务,打印 stop_reason 与当前清单文本。
- 完成证据
- 保存至少三次状态快照和一次残单回推。
书与实验P045 分钟 · ai-agent-book 第 2 章 Agent 状态栏 + chapter10/staged-system-prompt
- 预计
- 45 分钟
- 位置
ai-agent-book 第 2 章 Agent 状态栏 + chapter10/staged-system-prompt- 阅读任务
- 运行 demo.py --list-stages,观察阶段状态机、工具门禁和回退。
- 完成证据
- 写出三条允许转换和一条回退,说明 review 怎么退回 implementation。
论文P150 分钟 · ReAct: Synergizing Reasoning and Acting in Language Models
- 预计
- 50 分钟
- 位置
ReAct: Synergizing Reasoning and Acting in Language Models- 阅读任务
- 区分模型生成的 reasoning 与环境的 observation;哪一层适合保存为计划证据。
- 完成证据
- 给本章最小实现画一条 ReAct 轨迹,标出一次重新规划的触发 observation。
本文提到的代码与出处(有兴趣逐条核对时用):
claude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step08_plan_todolist.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:book/chapter2.mdai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter10/staged-system-prompt/README.mdmini-emperor/backend/src/mini_emperor/goals.pymini-emperor/backend/tests/test_evolution.py