预计时间:60 分钟 | 前置:完成第 12 章 | 本章调用真实 DeepSeek 模型
第 12 章解决了智能体怎样按需获得任务指导,但操作手册不能代替任务本身的状态管理。第 07 章的智能体已经能够连续对话,连续对话却不等于能够管理长期任务。以“把一个旧工具迁移到新框架”为例,程序需要随时回答:当前目标是什么?是否应该继续?接下来有哪些步骤?如果这些信息只写在普通对话里,模型可能忽略它们;如果只存在内存中,程序退出后又会丢失。
本章把这些信息分成四类,各自解决一个问题:
- 长期目标(Goal):记录当前唯一的目标,以及它正在进行、暂停、完成还是阻塞;
- 计划模式(Plan Mode):表示当前会话是否只调查和设计,暂不开始实施;
- 用户问答(User Questions):让智能体暂停并取得结构化的人类选择;
- 任务清单(Todo):记录眼前需要完成的几项工作。
四类信息都写入第 05 章的会话日志,需要恢复时再从事件中重新计算当前状态。这样,任务管理不会成为一份与对话历史相互矛盾的独立数据。
完成本章后,你将能够:
- 使用
active、paused、complete和blocked表示目标状态; - 用
GoalRef中的目标编号和版本号拒绝基于旧状态的修改; - 把目标变更写入会话日志,并通过重放恢复当前目标;
- 在下一步骤开始时提交计划模式的切换,并通过用户明确评审退出;
- 使用统一的用户问答服务传递问题与结构化答案;
- 使用整体替换的方式维护并校验任务清单;
- 让模型按顺序完成范围确认、计划评审、目标创建和任务清单写入。
计划模式是一种协作状态,不是文件权限。开启后,下一次系统提示词会增加“先调查和设计、暂不实施”的要求;真正能否写文件或执行命令,仍由第 10、11 章的权限与审批规则决定。
UserQuestionService 只负责把结构化问题交给当前交互界面,并等待用户回答。同一时间只能有一个界面提供这项服务,空问题会被拒绝。只有当前任务最外层的智能体可以发起提问,子智能体不能让父任务停在一个无人处理的提问上。
退出计划模式的工具 exit_plan_mode 始终存在,但只有当前确实处于计划模式时才能成功执行。它要求智能体提交一份以 Markdown 标题开头的完整计划,再向用户发起确认。只有用户明确选择 Approve,并且没有附加修改意见,计划才算通过。
批准后不会立刻关闭计划模式。控制器先保存一项待生效的选择,到下一步骤开始时再写入 plan/mode=false。这样,同一批模型工具调用中的剩余操作仍使用原来的提示词和工具清单,不会在执行到一半时改变运行条件。
智能体执行一个持续数小时的任务时,可能遇到这些情况:
- 用户说先停一下,目标该暂停,还是销毁?
- 依赖的上游服务挂了,目标是失败,还是等待?
- 程序中断后从磁盘恢复,目标是否仍然存在,又是否应该自动继续?
这些情况要求目标拥有明确状态,并规定哪些状态可以相互转换。代码使用四个状态和六种操作:
| 操作 | 效果 | 状态变化 |
|---|---|---|
create |
建立目标 | revision=1、phase=active、启用续行 |
edit |
改目标文本或轮次上限 | 保留 phase、blocker reason 与 activation |
pause / resume |
暂停 / 恢复 | 停用 / 恢复续行;resume 清除阻塞原因 |
complete |
完成 | 停用续行 |
block |
阻塞 | 记录文本说明,只用一个持久 phase |
这里有两条重要规则:
第一,同一时间最多只有一个当前目标。已经完成的目标可以被新目标替换,但不能同时维护多个含义不清的进行中目标。
第二,“是否立即继续执行”不会写入持久化状态。会话从中断中恢复后,即使目标仍是 active,续行状态也会恢复为 disarmed,直到新的直接用户请求明确要求继续。持久状态为 paused 时,模型工具不能自行恢复;恢复操作属于用户控制入口。
官方默认允许一个目标开始 256 个 Goal Round。GoalStore.create() 使用同一默认值;调用方也可以给出 JSON 安全范围内的正整数覆盖。edit 可以调整上限,但不能降到已经开始的 Round 数以下;Round 数达到上限后,resume 会被拒绝。
目标状态的载体:
@dataclass(frozen=True)
class Goal:
id: str
revision: int
phase: str
objective: str
max_rounds: int
rounds_started: int = 0
blocker_reason: str | None = None每个操作都遵循相同流程:校验当前状态,生成新快照,将版本号 revision 加一,再追加 goal/change 事件。以 resume 为例:
def resume(self, ref: GoalRef) -> GoalRef:
current = self._require(ref)
if current.phase not in {PHASE_ACTIVE, PHASE_PAUSED, PHASE_BLOCKED}:
raise ValueError(f"{current.phase} 目标不能 resume")
if current.phase == PHASE_ACTIVE and self._armed:
raise ValueError("目标已经 active 且已启用续行")
if current.rounds_started >= current.max_rounds:
raise ValueError("目标轮次已达上限,无法 resume")
self._commit(self._with_phase(current, PHASE_ACTIVE, None), "resume", armed=True)
return GoalRef(id=current.id, revision=current.revision + 1)_require 会检查调用方持有的目标编号和版本号:
def _require(self, ref: GoalRef) -> Goal:
if self._current is None:
raise ValueError("没有当前目标")
if self._current.id != ref.id:
raise ValueError(f"引用指向不同的目标(ref={ref.id} != 当前={self._current.id})")
if self._current.revision != ref.revision:
raise ValueError(
f"陈旧的引用(ref r{ref.revision} != 当前 r{self._current.revision}),"
"请重新 get() 后再操作"
)
return self._current为什么每次操作都要检查 revision?假设智能体 A 根据 r3 版本决定暂停目标,而智能体 B 已经把目标推进到 r5。如果仍允许 A 提交,旧决定就可能覆盖 B 的新进展。版本检查会拒绝这种基于旧状态的修改,并要求调用方重新读取当前目标。这是一种乐观并发控制方法。
第 17 章面向模型的 update_goal 要求显式传入 goal_id 和 revision,工具不会自行读取最新版本。模型必须先调用 get_goal,再基于刚取得的引用提交更新。工具动作使用官方名称 blocked;领域层记录的操作仍名为 block。
每个动词最后都做同一件事,_commit 追加事件:
def _commit(self, goal: Goal, operation: str) -> None:
self._session.append(
"goal/change",
{"version": 1, "operation": operation, "goal": _goal_to_dict(goal)},
)
self._current = goalgoal/change 同时记录操作名和变更后的完整快照,因此目标状态可以像第 05 章的会话一样通过事件重建。clear() 会写入一条删除标记,英文常称为 tombstone,明确记录“这个目标被删除了”,而不是让状态无缘无故消失。
@classmethod
def replay(cls, session: Session) -> "GoalStore":
store = cls(session)
for event in session.snapshot_events():
if event.type == "user/message":
source = event.data.get("source")
if isinstance(source, dict) and source.get("kind") == "goal":
# 校验 goal id、revision 与 round 连续后,
# 只推进 rounds_started。
...
continue
if event.type == "goal/change":
# 校验完整快照、revision 或 clear tombstone。
...
return storeadmit_round() 追加的是一条注明来自目标的 user/message。它表示智能体开始处理新一轮目标消息,不是修改目标本身,因此只增加 rounds_started,不会增加 revision。版本连续性只在同一个目标内检查;创建新目标时重新从 r1 开始。
教学版检查目标编号、版本号、关键状态迁移、轮次和删除标记是否连续,但没有实现官方的完整数据形状和时间戳顺序检查。GoalStore.replay() 只恢复持久状态,进程内续行权限保持 disarmed。
长期目标记录任务是否继续,任务清单 Todo 则记录眼前需要完成的步骤。todo_write 每次接收一份完整列表,并用它替换旧列表,不提供只修改其中一项的操作:
def todo_write(
session: Session,
items: list[TodoItem],
*,
allow_parallel_in_progress: bool,
) -> str:
errors = validate_todos(
items, allow_parallel_in_progress=allow_parallel_in_progress
)
if errors:
return "Error: invalid todos: " + "; ".join(errors)
snapshot = [{"content": item.content, "status": item.status} for item in items]
session.append("todo/write", {"todos": snapshot})
# ...返回统计文本三个设计意图:
- 每个
todo/write事件都是完整快照。恢复时只需采用最后一份列表,不会出现只增加或删除到一半的中间状态。 - 每项任务只有等待中
pending、进行中in_progress和已完成completed三种状态,避免引入含义相近的额外状态。 - 写入前会去除内容两端的空白,再检查空项、重复项和非法状态。
allow_parallel_in_progress=False时还会拒绝同时存在多个进行中任务。返回的错误会明确告诉模型应该修正什么。
示例从计划模式开始,并向模型同时注册四个工具:ask_user_question、exit_plan_mode、create_goal 和 todo_write。用户任务明确要求依次确认范围、提交计划、创建目标,再写入三项任务清单。模型需要根据每次工具结果决定下一步,而不是由 Python 代码直接调用四项能力。
agent.py 在每个步骤开始时调用 plan_mode.apply_boundary(),再根据当前模式重新组装系统提示词:
for step in range(1, max_steps + 1):
notice = plan_mode.apply_boundary()
system_prompt = "你是任务规划助手……"
plan_section = plan_mode.prompt_section()
if plan_section:
system_prompt += "\n\n" + plan_section
session.record_system_prompt(system_prompt, turn=1, step=step)
reply = client.chat(session.derive_messages(), tools)exit_plan_mode 获批时只把退出选择放入待生效状态。create_goal 和 todo_write 还会检查当前是否仍处于计划模式,因此模型即使把它们与退出工具放在同一批调用中,也不会提前实施。下一步骤提交 plan/mode=false 后,目标和清单工具才会成功。
命令行示例没有交互界面,因此 TeachingProvider 代替终端用户选择每个问题的第一项,并在计划评审时选择 Approve。这只固定了人类输入;问题内容、计划、目标和清单仍由真实模型生成,所有工具调用与结果都进入同一份会话日志。
uv run python chapters/13-goal-plan-todo/src/demo.py下面是一次真实运行的关键输出。模型生成的问题、计划文字和目标表述可能变化:
用户问答: 旧工具迁移到新框架时,是否只迁移公开接口? -> 是,只迁移公开接口
用户问答: Approve this plan and leave plan mode? -> Approve
=== 模型调用的任务工具 ===
ask_user_question({...})
{"answers":[{"id":"scope","selected":["是,只迁移公开接口"]}]}
exit_plan_mode({'plan': '## 迁移计划……'})
工具执行出错: exit_plan_mode requires a non-empty markdown plan starting with a # heading
exit_plan_mode({'plan': '# 迁移计划:旧工具 → 新框架(仅公开接口)……'})
Plan approved — plan mode exited; carry out the plan starting with your next step.
create_goal({'objective': '将旧工具仅迁移公开接口到新框架……'})
{"id": "goal-26", "revision": 1}
todo_write({'todos': [...]})
Updated todo list: 2 pending, 1 in progress, 0 completed.
模型最终回答:
已确认迁移范围,计划已经批准,并已创建长期目标和三项任务清单。
日志恢复出的目标: r1 [active] 将旧工具仅迁移公开接口到新框架……
日志中的任务清单写入次数: 1
这次运行还展示了错误回灌。模型第一次提交的计划以二级标题 ## 开头,不符合工具要求;错误作为工具结果进入下一次请求后,模型改用一级标题 # 并再次提交。批准后,模式在步骤边界切换,目标与清单依次写入事件日志。最后打印出的目标和 todo/write 次数来自真实日志,而不是模型的文字总结。
Goal:用四种状态和六种领域操作管理一个长期目标UserQuestionService:统一传递结构化问题与答案,并限制由当前根智能体发起PlanModeController:记录计划模式,让退出选择在下一步骤生效GoalRef:通过目标编号和版本号拒绝过期修改goal/change:保存完整目标快照,并支持从日志恢复todo_write:整体替换任务清单,校验内容和状态- 真实模型流程:范围确认、计划评审、模式切换、目标和清单共用一个事件驱动循环
- 恢复后续行状态为
disarmed;持久暂停的目标只能由用户入口恢复
| 官方实现 | 我们对应实现 | 说明 |
|---|---|---|
packages/goal/goal/README.zh.md |
GoalStore |
保留事件恢复、GoalRef 版本保护、单一目标、状态转换、进程内 activation 和恢复后停用续行;教学版省略完整投影注册表与时间戳校验 |
packages/goal/tool-goal/README.zh.md |
第 17 章的 goal 工具 | 工具要求模型回传精确 goal_id 与 revision;持久 paused 目标不能由模型恢复 |
| 同上 | admit_round |
官方只有已经接收且来源为目标的 user/message 才会增加目标轮数;普通用户对话不会增加 roundsStarted |
packages/todo/tool-todo/README.zh.md |
todo_write |
对齐整体替换、完整快照、三值状态、内容校验与可配置的并行进行中策略 |
packages/interaction/user-questions/README.zh.md |
UserQuestionService |
与官方一样只允许一个交互界面,拒绝空问题和非法调用方;教学版会同步等待用户回答 |
packages/plan/plan-mode/README.zh.md |
PlanModeController |
与官方一样由最后一条事件决定模式,在步骤边界提交选择,并要求用户明确评审计划;教学版没有 /plan 命令 |
长期目标、计划模式与任务清单分别回答三个问题:长任务是否继续、当前如何协作、眼前有哪些步骤。它们不能互相替代。
- 长期目标、计划模式、用户问答和任务清单都与任务状态有关,但时间尺度和职责不同。请为“迁移一个旧服务”设计它们的分工,并说明哪些信息不应重复保存。
- 一个长任务可能经历暂停、恢复、阻塞和完成。画出你认为合理的状态迁移,并说明哪些迁移必须拒绝、哪些需要人类确认,以及崩溃后如何从事件日志恢复。
- 计划模式只改变协作提示,不是文件权限。假设模型在计划阶段仍尝试写文件,系统还需要哪些独立控制?为什么不能依赖提示词保证安全?
- 两个调用方基于同一个目标版本同时更新时,其中一个操作会被拒绝。讨论这种乐观并发控制对自动化智能体和人类协作者的好处,并设计冲突后的重新读取或合并流程。
- 扩展本章的真实模型流程:先让用户拒绝一次计划并给出反馈,再批准修订版;随后完成一项任务并更新目标。中途保存和恢复会话,验证当前模式、目标与清单都能从日志重新得到。