Skip to content

第 6 章 TodoWrite —— 有计划的 Agent 不漂移

没有显式计划的 Agent 会在长任务中迷失方向——重复已完成的步骤、跳过关键环节、在上下文尾部被最近的工具输出牵着走。计划机制的本质不是"告诉模型怎么做",而是给模型一个可以自我锚定的结构化状态。

6.1 从自由执行到计划驱动

问题:Agent 为什么会迷失

考虑一个 10 步重构任务:添加类型标注、编写 docstring、抽取公共函数、补写测试……没有计划时,模型往往完成前 3 步后就开始即兴发挥——因为 system prompt 已经被几千行工具输出推到上下文窗口的遥远前端,注意力权重极度稀释。这不是模型"笨",而是 Transformer 的 O(n2) 注意力机制在长上下文中的必然退化。

内存 flat list:TodoManager

解决方案出人意料地简单——给 Agent 一个可读写的待办列表工具。这不是外部的项目管理系统,而是 Agent 循环中的一个内存数据结构:

python
class TodoManager:
    def __init__(self):
        self.items = []

    def update(self, items: list) -> str:
        if len(items) > 20:
            raise ValueError("Max 20 todos allowed")
        validated = []
        in_progress_count = 0
        for i, item in enumerate(items):
            text = str(item.get("text", "")).strip()
            status = str(item.get("status", "pending")).lower()
            item_id = str(item.get("id", str(i + 1)))
            if not text:
                raise ValueError(f"Item {item_id}: text required")
            if status not in ("pending", "in_progress", "completed"):
                raise ValueError(f"Item {item_id}: invalid status '{status}'")
            if status == "in_progress":
                in_progress_count += 1
            validated.append({"id": item_id, "text": text, "status": status})
        if in_progress_count > 1:
            raise ValueError("Only one task can be in_progress at a time")
        self.items = validated
        return self.render()

    def render(self) -> str:
        if not self.items:
            return "No todos."
        lines = []
        for item in self.items:
            marker = {"pending": "[ ]", "in_progress": "[>]",
                       "completed": "[x]"}[item["status"]]
            lines.append(f"{marker} #{item['id']}: {item['text']}")
        done = sum(1 for t in self.items if t["status"] == "completed")
        lines.append(f"\n({done}/{len(self.items)} completed)")
        return "\n".join(lines)

三个关键设计约束:

  1. 上限 20 项:防止模型生成过于庞大的计划。好的计划是粗粒度的——"重构 auth 模块"而非"修改 auth.py 第 37 行"。
  2. 三状态枚举pendingin_progresscompleted。状态空间越小,模型越难犯错。
  3. 同一时刻只允许一个 in_progress:这是最核心的约束。它强制 Agent 串行聚焦——做完一件事再做下一件,而不是同时推进多个任务导致哪个都做不完。

todo 工具的注册

todo 工具和 bashread_filewrite_file 等工具并列注册,没有任何特殊地位:

python
TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
    "todo":       lambda **kw: TODO.update(kw["items"]),
}

工具描述刻意简短:

python
{"name": "todo",
 "description": "Update task list. Track progress on multi-step tasks.",
 "input_schema": {
     "type": "object",
     "properties": {
         "items": {"type": "array", "items": {
             "type": "object",
             "properties": {
                 "id": {"type": "string"},
                 "text": {"type": "string"},
                 "status": {"type": "string",
                            "enum": ["pending", "in_progress", "completed"]}
             },
             "required": ["id", "text", "status"]
         }}
     },
     "required": ["items"]
 }}

这里有一个容易忽略的设计选择:todo 工具每次调用传入的是完整列表而非增量操作。模型不需要 add_todocomplete_todoremove_todo 三个工具——一个 update 搞定全部。好处是减少了工具数量(降低模型选择困难),代价是每次调用要传完整状态。对于 20 项以内的列表,这个代价可忽略。

Nag Reminder:注意力的强制回收

模型会忘记更新 todo 列表。人类在做项目管理时也一样——不是不想记录,而是沉浸在执行中就忘了。Nag reminder 是解决方案:

python
def agent_loop(messages: list):
    rounds_since_todo = 0
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return
        results = []
        used_todo = False
        for block in response.content:
            if block.type == "tool_use":
                handler = TOOL_HANDLERS.get(block.name)
                try:
                    output = handler(**block.input) if handler else \
                             f"Unknown tool: {block.name}"
                except Exception as e:
                    output = f"Error: {e}"
                results.append({"type": "tool_result",
                                "tool_use_id": block.id,
                                "content": str(output)})
                if block.name == "todo":
                    used_todo = True
        rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
        if rounds_since_todo >= 3:
            results.insert(0, {
                "type": "text",
                "text": "<reminder>Update your todos.</reminder>"
            })
        messages.append({"role": "user", "content": results})

工作流程:

mermaid
graph TD
    A[用户提交任务] --> B[LLM 生成工具调用]
    B --> C{调用了 todo 工具?}
    C -->|是| D[rounds_since_todo = 0]
    C -->|否| E[rounds_since_todo += 1]
    D --> F[执行工具, 返回结果]
    E --> G{rounds_since_todo >= 3?}
    G -->|否| F
    G -->|是| H["注入 &lt;reminder&gt;Update your todos.&lt;/reminder&gt;"]
    H --> F
    F --> I[LLM 处理结果]
    I --> J{stop_reason == tool_use?}
    J -->|是| B
    J -->|否| K[返回最终回答]

几个实现细节值得注意:

  • 阈值是 3 轮而非每轮提醒:过于频繁的 nag 会被模型当成噪声忽略,或干扰正常的工具调用序列。3 轮是一个经验值——足够让模型完成一个小步骤,但不至于偏离太远。
  • insert(0, ...):reminder 被插入到 tool results 列表的开头,而非末尾。这确保模型在处理工具返回之前先看到提醒。
  • <reminder> 标签包裹:使用 XML 标签而非纯文本,使模型能区分系统提醒和工具输出。这是一种轻量级的结构化注入——不需要改变消息格式,只需在 tool_result 列表中插入一个 text block。

运行时的行为模式

一个典型的多步任务执行过程如下:

text
用户: 重构 hello.py——添加类型标注、docstring 和 main guard

[Round 1] LLM 调用 todo:
  [ ] #1: 读取 hello.py 当前内容
  [>] #2: 添加类型标注
  [ ] #3: 添加 docstring
  [ ] #4: 添加 if __name__ == "__main__" guard
  (0/4 completed)

[Round 2] LLM 调用 read_file("hello.py")
[Round 3] LLM 调用 edit_file(添加类型标注)
[Round 4] LLM 调用 todo(更新 #1 completed, #2 completed, #3 in_progress)
[Round 5] LLM 调用 edit_file(添加 docstring)
...
[Round 8] LLM 调用 todo(全部 completed)

如果 Round 4 没有发生(模型忘记更新 todo),Round 5 和 6 的 tool results 前会自动注入 <reminder>,将模型拉回来。

6.2 注意力控制技巧

TodoManager 解决了"有没有计划"的问题,但更深层的挑战是:计划如何在几十轮对话后仍然影响模型的行为?这需要理解 LLM 在 Agent 场景中的注意力分布特征。

在上下文末尾维护 todo.md —— 利用 recitation 锚定注意力

Manus 团队总结的核心经验:让 Agent 维护一个结构化的待办事项文件(如 todo.md),在任务推进过程中不断更新,并在当前上下文的末尾处复述核心目标和已完成的进度。

这背后的原理是 LLM 注意力的"近因偏差"(recency bias)。在长上下文中,模型对开头(system prompt)和末尾(最近几轮)的注意力最强,中间部分最弱——这就是"Lost in the Middle"现象。TodoManager 的 render() 方法每次被调用时返回完整的列表状态,这个状态作为 tool_result 出现在上下文的最新位置,等于把全局计划强行推入模型的"近期注意力视野"。

更进一步,生产级 Agent(如 Manus)会让模型将 todo 写入实际文件系统中的 todo.md,而非仅存在内存中。这有两个好处:

  1. 持久化:即使上下文被截断或摘要压缩,Agent 可以通过 read_file("todo.md") 恢复全局计划。
  2. 可观测性:用户可以实时查看 todo.md 了解 Agent 的进度,而无需等待 Agent 输出解释。

recitation(复述)的关键不是让模型"记住"计划——模型不会遗忘上下文中存在的信息,它遗忘的是注意力权重。复述把远处的信息搬到近处,让注意力权重重新分配到计划相关的 token 上。

保留错误信息 —— 模型需要证据来更新置信度

一个反直觉但至关重要的原则:不要从上下文中移除失败的尝试记录。

当 Agent 执行命令报错、API 返回异常、文件读取失败时,开发者的本能反应是"把错误清掉,给模型一个干净的重试环境"。这恰恰是错的。

LLM 需要证据来更新其内部的置信度分布。当模型在上下文中看到:

python
> bash: pip install numpy==1.99
Error: No matching distribution found for numpy==1.99

> bash: pip install numpy==1.26.4
Successfully installed numpy-1.26.4

第一条错误记录不是噪声——它是模型推理的关键输入。没有这条记录,模型可能在后续步骤中再次尝试安装不存在的版本。有了它,模型的注意力会锚定在"1.99 版本不存在"这个事实上,自然避免重复错误。

这里的设计原则是:

  • 失败的 tool_result 原封不动保留,包括完整的 stack trace 和错误消息
  • 不要用摘要替代原始错误信息——"之前安装 numpy 失败了"远不如完整的报错有用
  • 不要在 harness 层面重试并隐藏失败——让模型看到失败,自己决定如何恢复

这种"从错误中恢复"的能力——看到错误 -> 更新策略 -> 尝试新路径——才是真正智能的 Agent 行为。隐藏错误等于剥夺了模型自我纠错的证据基础。

避免 few-shot 陷阱 —— 注入结构化噪声防止机械模仿

LLM 是极强的模式模仿者(pattern imitator)。这在单轮任务中是优势,在长序列的 Agent 执行中却是陷阱。

考虑一个文档翻译 Agent,连续翻译了 20 页格式完全相同的内容。上下文中积累了 20 组极其相似的"读取原文 -> 翻译 -> 写入译文"循环。到第 21 页时,即使遇到需要特殊处理的表格或代码块,模型也会因为"之前都是这么做的"而盲目套用相同的处理流程——这就是 few-shot 的模式化陷阱。

上下文中大量相似的 action-observation 对等价于一个隐式的 few-shot prompt,模型的行为被这些"示例"锁定,失去了对当前状态的独立判断。

对策是在序列化过程中注入可控的结构化噪声

  • 变换措辞:对相似的 tool result 使用不同的描述模板。不是每次都返回 "Wrote 1234 bytes to output.txt",而是交替使用 "File written successfully (1234 bytes)""output.txt updated, 1234 bytes"
  • 打乱无关紧要的顺序:如果 tool result 包含多个字段,偶尔变换字段的排列顺序。
  • 插入元信息:在某些 round 的 tool result 中附加时间戳、步骤编号等轻量元数据,打断纯模式复制。

这种噪声的关键是结构化——它不能影响信息的正确性和完整性,只是让形式上有足够的变化,强迫模型重新聚焦于当前步骤的实际语义而非历史模式。

6.3 规划工具的局限与演进

Flat list 的局限

本章介绍的 TodoManager 是规划工具的最简形态——一个没有依赖关系的 flat list。它在以下场景中工作良好:

  • 步骤数 <= 10 的线性任务
  • 步骤间无复杂依赖的并行任务
  • 单 Agent 独立执行的任务

但它无法表达:

  • 依赖关系:任务 C 必须在 A 和 B 都完成后才能开始
  • 子任务展开:任务 A 在执行过程中分裂成 A.1、A.2、A.3
  • 跨 Agent 协同:主 Agent 委派子任务给子 Agent,需要共享任务状态

演进路径

规划工具的演进路线:

text
Flat List        →  DAG          →  持久化 DAG     →  多 Agent 看板
(本章)              (Ch.10)          (文件系统)         (共享状态)
─────────────────────────────────────────────────────────────────────
内存数组            带依赖的图       todo.md 文件       分布式任务队列
无依赖关系          拓扑排序执行     上下文压缩可恢复   多进程读写
单 Agent            单 Agent         单 Agent           多 Agent

Flat List -> DAG:引入 depends_on 字段,每个任务可以声明前置依赖。调度器用拓扑排序决定下一个可执行的任务。这是单 Agent 场景下的自然升级——模型仍然只有一个 todo 工具,但状态结构从数组变成了有向无环图。

DAG -> 持久化:将 DAG 状态写入文件系统(如 todo.mdtodo.json),而非仅存在进程内存中。好处是:当上下文被摘要压缩时,Agent 可以通过 read_file 恢复完整的任务图。这与 Manus 团队"文件系统作为终极外部记忆"的理念一致——上下文压缩是可逆的,因为原始状态持久化在文件系统中。

持久化 -> 多 Agent 看板:当主 Agent 将子任务委派给子 Agent 时,需要一个共享的任务状态存储。子 Agent 完成任务后更新看板,主 Agent 轮询看板获取进度。这本质上是一个分布式协调问题,文件系统充当了简易的消息总线。

每一步演进都增加了复杂度,但核心不变:给模型一个可以自我锚定的结构化状态。Flat list 是这个思想最小化的体现——它证明了"Agent 需要计划"这个命题,而具体用什么数据结构只是工程细节。