s03 把计划写到模型脑外:TodoWrite 如何让 Agent 在多步任务里不跑偏

看完 s02,很多人的第一反应都会是:Agent 不是已经差不多能干活了吗?能读文件、能写文件、能改文件,还能直接跑命令。

但任务一拉长,问题马上就冒出来了:

  • 做着做着,忘了自己本来打算先做什么
  • 刚检查过的东西,过两轮又查一遍
  • 明明是个多步任务,执行起来却总是“想到哪做到哪”

agents/s03_todo_write.py 从头到尾读完后,最直观的感受是:这一版补上的不是新能力,而是执行秩序

它干的事,其实一句话就能说清:

把“接下来要做什么”从模型脑子里拽出来,落成一份可以校验、可以回看、还能持续刷新的会话计划。

源码:s03_todo_write.py

日志位置:s03_todo_write_20260411_223048.log

s03 到底比 s02 多了什么

细节先放一边,先抓主干。s03s02 的差别,核心就在这张表里:

维度 s02 s03
工具层 bashread_filewrite_fileedit_file 在原有工具上新增 todo
主循环维护的状态 messages messages + PlanState
模型如何保持节奏 主要靠上下文 todo + Python 校验 + reminder
多步任务的焦点 容易飘 明确要求只保留一个 in_progress

也就是说,s03 并没有推翻前一章的结构,而是在主循环旁边补了一块当前计划面板

用户输入

history / messages

调用模型

是否请求工具

输出最终回答

按工具名分发

read_file / write_file / edit_file / bash

todo

TodoManager

校验计划合法性

渲染为可读清单

连续多轮没刷新计划?

插入 reminder

这张图里最关键的点,不是“多了个 todo”,而是 todo 不再是内部随手记一下的变量,而是被提升成了正式工具

这样做,直接带来两个好处:

  1. 模型得显式提交计划,不能只在“脑内想过了”就算数。
  2. 计划会像其他工具结果一样回填进消息历史,下一轮推理真的看得到。

接入代码也很直接:

PLAN_REMINDER_INTERVAL = 3

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"]),
}

看到这里,s03 的骨架其实已经很清楚了:

  • 原来的工具负责做事
  • todo 负责把做事顺序写出来
  • PLAN_REMINDER_INTERVAL 负责计划太久没更新时提醒一下

另外,原来的文件工具还在正常工作,路径依旧通过 safe_path() 约束在当前工作区里。

换句话说,s03 新加的是计划层,不是把前面的工具层推倒重来。

TodoManager 才是这一版的真正中心

如果这一节只记一个类,那我会选 TodoManager

它本身不复杂,但位置卡得特别准:模型把计划交上来,Python 负责接住、校验、存下来,再渲染成可读结果回填给模型。

PlanItem

+str content

+str status

+str active_form

PlanState

+list items

+int rounds_since_update

TodoManager

+PlanState state

+update(items) : str

+note_round_without_update() : None

+reminder() : str|None

+render() : str

1. PlanItemPlanState 很小,但信息正好够用

源码里用两个数据类装计划状态:

@dataclass
class PlanItem:
    content: str
    status: str = "pending"
    active_form: str = ""

@dataclass
class PlanState:
    items: list = field(default_factory=list)
    rounds_since_update: int = field(default=0)

这套结构很克制,但够用了:

  • content 负责说清楚这一步干什么
  • status 标记还没做、正在做、已经做完
  • active_form 让进行中的任务读起来像自然语言动作,不是生硬的状态码
  • rounds_since_update 不关心任务内容,只关心计划多久没刷新

这里有个很值钱的设计点:todo 管的并不是一个完整任务系统,它管的是会话里的注意力分配

2. update() 不是打补丁,而是整份重写

这一版没有做“新增一个任务”“删除一个任务”“只改第 2 项状态”这种局部操作,而是让模型每次直接提交当前整份计划。

核心代码就是这段:

def update(self, items: list) -> str:
    if len(items) > 12:
        raise ValueError("请精简当前会话计划(最多 12 个任务)")

    normalized = []
    in_progress_count = 0
    for raw_item in items:
        content = str(raw_item.get("content", "")).strip()
        status = str(raw_item.get("status", "pending")).lower()
        active_form = str(raw_item.get("activeForm", "")).strip()

        if not content:
            raise ValueError("计划项缺少 content")
        if status not in {"pending", "in_progress", "completed"}:
            raise ValueError(f"状态无效: '{status}'")
        if status == "in_progress":
            in_progress_count += 1

        normalized.append(
            PlanItem(content=content, status=status, active_form=active_form)
        )

    if in_progress_count > 1:
        raise ValueError("最多只能有一个任务处于 in_progress 状态")

    self.state.items = normalized
    self.state.rounds_since_update = 0
    return self.render()

这套处理方式我挺认同,原因主要有三个:

  1. 对模型更省事,不用先判断“这是新增还是修改”,直接提交当前最合理的完整状态就行。
  2. 对 Python 更好校验,整份计划统一检查,比追踪一堆增删改稳得多。
  3. 对看日志的人也更友好,每次 todo 返回的都是全貌,不是零碎 diff。

另外还有两个细节别漏掉:

  • 计划项最多 12 个,避免模型列一长串后自己都不想维护。
  • activeForm 是给模型用的字段名,进到 Python 后会转成 active_form,这是一个很轻的归一化动作。

3. render() 让计划不只是“存起来”,而是真的能看

todo 工具返回的不是一句“更新成功”,而是一份可读的计划清单:

def render(self) -> str:
    if not self.state.items:
        return "暂无当前会话 plan。"

    lines = []
    for item in self.state.items:
        marker_map = {
            "pending": "[ ]",
            "in_progress": "[>]",
            "completed": "[X]",
        }
        line = f"{marker_map[item.status]} {item.content}"
        if item.status == "in_progress" and item.active_form:
            line += f" ({item.active_form})"
        lines.append(line)

    completed = sum(1 for item in self.state.items if item.status == "completed")
    lines.append(f"\\n(已完成 {completed}/{len(self.state.items)})")
    return "\\n".join(lines)

这一步特别关键。因为模型拿到的不再是抽象状态对象,而是一份随时能回看的工作面板

日志里第一次调用 todo,返回内容就是这样:

[2026-04-11 22:31:29] ToolUseBlock(... input={'items': [{'activeForm': '创建 Python 脚本中', 'content': '创建 Python 脚本 hello_world.py', 'status': 'in_progress'}, {'activeForm': '添加函数和注释', 'content': '添加打印函数和注释', 'status': 'pending'}]}, name='todo', type='tool_use')
[2026-04-11 22:31:29] 'content': '[>] 创建 Python 脚本 hello_world.py (创建 Python 脚本中)
[ ] 添加打印函数和注释

(0/2 completed)'

这段日志直接说明了两件事:

  1. 计划不是藏在内部变量里,而是被渲染成模型和人都能看懂的文本。
  2. activeForm 很实用,能把“当前正在做什么”写得更顺。

主循环为什么会更稳

如果说 TodoManager 负责计划本身,那 agent_loop() 负责把这份计划真正纳入运行节奏。

TodoManager TOOL_HANDLERS 模型 history 用户 TodoManager TOOL_HANDLERS 模型 history 用户 alt [达到阈值] alt [调用 todo] [调用其他工具] alt [stop_reason == tool_use] [不再请求工具] 追加 user message messages + TOOLS assistant content 请求工具 update(items) 渲染后的计划文本 tool_result rounds_since_update 归零 tool_result rounds_since_update + 1 插入 reminder 进入下一轮 输出最终答案

真正让“计划刷新”变成主循环规则的,是下面这段代码:

results = []
used_todo = False

for block in response.content:
    if block.type != "tool_use":
        continue
    handler = TOOL_HANDLERS.get(block.name)
    tool_output = handler(**block.input) if handler else f"未知工具: {block.name}"
    results.append({"type": "tool_result", "tool_use_id": block.id, "content": tool_output})
    if block.name == "todo":
        used_todo = True

if used_todo:
    TODO.state.rounds_since_update = 0
else:
    TODO.note_round_without_update()
    reminder = TODO.reminder()
    if reminder:
        results.insert(0, {"type": "text", "text": reminder})

messages.append({"role": "user", "content": results})

这里真正值得看的不是语法,而是背后的判断逻辑:

  • 这一轮只要调用过 todo,就说明计划刚刷新过
  • 这一轮如果没调用 todo,就记一次“未刷新”
  • 连续达到阈值后,不是直接报错,而是插入一条轻提醒

还有个动作很容易被忽略,但其实挺关键:源码会先把模型这一轮的 assistant 原始回复追加到 messages,然后才决定要不要执行工具。这样无论本轮是不是工具调用,历史上下文都不会断。

这也是 s03 比较成熟的地方。它没有假设模型会一直自觉,而是默认模型可能会跑偏,所以在主循环里补了一个很轻、但很好用的扶正机制

对应的提醒函数也很克制:

def reminder(self) -> str | None:
    if not self.state.items:
        return None
    if self.state.rounds_since_update < PLAN_REMINDER_INTERVAL:
        return None
    return "<reminder>继续操作前请刷新你当前的计划。</reminder>"

这个判断顺序也值得注意:

  1. 如果还没有计划,不提醒。
  2. 只有已经有计划,而且连续多轮没刷新,才提醒。

所以这不是“催模型干活”,而是在避免会话节奏慢慢散掉。

看一遍真实运行记录,理解会更牢

这一轮运行的终端截图在这里:

s03_测试输出

光看截图,基本就能把这轮动作串起来:

  1. 先调用 todo 写计划
  2. 再调用 write_file 真正创建文件
  3. 完成后再次调用 todo,把状态改成已完成
  4. 后面跑 bash 验证脚本
  5. 连续几轮没有刷新计划,于是插入 reminder

也就是说,todo 不是最后收尾时顺手补一下,它是真的参与了整个过程里的调度。

日志里有个很真实的细节:计划已经完成,reminder 还是会出现

日志后半段有这样一段内容:

[2026-04-11 22:31:29] ToolUseBlock(id='call_function_e6dvnv8w4357_1', caller=None, input={'command': 'python hello_world.py'}, name='bash', type='tool_use')
[2026-04-11 22:31:29]   'content': [{'type': 'text', 'text': '<reminder>继续操作前请刷新你当前的 plan。.</reminder>'},
[2026-04-11 22:31:29]               {'type': 'tool_result',
[2026-04-11 22:31:29]                'content': 'Hello World!'}]}

这段日志挺有意思。

前面 todo 明明已经把两项任务都标成完成了,但后面模型又连续做了几轮非 todo 操作,所以 reminder 还是被插进来了。

这说明 reminder 盯的并不是“任务是不是快结束了”,它盯的是:

你既然已经有计划了,后续动作还要不要继续对齐这份计划。

这个设计我挺喜欢,因为它看的是节奏一致性,不是表面上的完成感。

这次运行还顺手暴露了一个 Windows 编码细节

日志中间还有一段很值得留意:

[2026-04-11 22:31:29] ToolUseBlock(... input={'command': 'python hello_world.py'}, name='bash', type='tool_use')
[2026-04-11 22:31:29] "SyntaxError: (unicode error) 'utf-8' codec can't decode byte 0xbc in position 18: invalid start byte"

为什么会这样?看 run_write() 就明白了:

def run_write(path: str, content: str) -> str:
    fp = safe_path(path)
    fp.parent.mkdir(parents=True, exist_ok=True)
    fp.write_text(content)
    return f"写入 {len(content)} 字节到 {path}"

这里没有显式指定编码。

在 Windows 环境里,如果模型生成的是带中文注释、同时又声明 # -*- coding: utf-8 -*- 的脚本,就可能出现“文件实际写入编码”和“解释器按 utf-8 读取”不一致的问题。

这个细节很有价值,因为它刚好说明了两件事:

  • TodoWrite 解决的是“计划有没有外显、节奏稳不稳”
  • 文件工具本身的工程细节,依然会在真实运行里暴露出来

所以 s03 的重点,不是让所有问题一下子消失,而是先让多步任务推进得更有条理。至于编码、权限、异常恢复,这些问题还是后面要继续补的。

这一章最值得带走的 4 个点

1. todo 的价值,不是记录任务,而是固定当前焦点

同一时间只允许一个 in_progress,本质上是在逼模型回答一个问题:

你现在到底在做哪一步?

这个限制看着简单,但对多步任务特别有效。

2. 计划一定要能被程序校验,不能只是自然语言描述

如果只是让模型“顺便说一下计划”,那计划很快就会退化成上下文里的一段普通文字。

s03 把它做成了结构化输入,Python 还能统一校验状态和数量,这一下稳定性就上来了。

3. 整份重写,往往比局部修改更适合会话计划

会话计划不是数据库事务,也不是项目管理系统。

对于这种短周期状态,“每次都提交当前全貌”通常比维护复杂增量操作更容易稳住。

4. reminder 不是装饰,它已经是主循环的一部分

很多人看到这种提醒,第一反应会觉得只是“体验优化”。

但从代码位置就能看出来,它已经进入了循环控制逻辑。换句话说,从 s03 开始,Agent 不只是执行动作,也开始维护动作过程中的结构化状态了。

收个尾

s01 让我们看见 Agent 为什么要循环,s02 让我们看见工具能力是怎么长出来的,到了 s03,真正补上的其实是过程管理这一层。

它没有一下子把系统做成复杂任务平台,而是很克制地先做了一件特别关键的事:

把当前会话里的计划,写到模型脑外。

这一点一旦成立,Agent 在多步任务里的表现就会稳很多。因为它不再只靠“记得住”,而是开始依赖一份能回看、能校验、还能被提醒的外显状态。

致谢

这一组学习内容的主线整理和启发,受益于 shareAI-lab/learn-claude-code

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐