s03_把计划写到模型脑外:TodoWrite 如何让 Agent 在多步任务里不跑偏
文章目录
s03 把计划写到模型脑外:TodoWrite 如何让 Agent 在多步任务里不跑偏
看完 s02,很多人的第一反应都会是:Agent 不是已经差不多能干活了吗?能读文件、能写文件、能改文件,还能直接跑命令。
但任务一拉长,问题马上就冒出来了:
- 做着做着,忘了自己本来打算先做什么
- 刚检查过的东西,过两轮又查一遍
- 明明是个多步任务,执行起来却总是“想到哪做到哪”
把 agents/s03_todo_write.py 从头到尾读完后,最直观的感受是:这一版补上的不是新能力,而是执行秩序。
它干的事,其实一句话就能说清:
把“接下来要做什么”从模型脑子里拽出来,落成一份可以校验、可以回看、还能持续刷新的会话计划。
日志位置:s03_todo_write_20260411_223048.log
s03 到底比 s02 多了什么
细节先放一边,先抓主干。s03 和 s02 的差别,核心就在这张表里:
| 维度 | s02 | s03 |
|---|---|---|
| 工具层 | bash、read_file、write_file、edit_file |
在原有工具上新增 todo |
| 主循环维护的状态 | messages |
messages + PlanState |
| 模型如何保持节奏 | 主要靠上下文 | 靠 todo + Python 校验 + reminder |
| 多步任务的焦点 | 容易飘 | 明确要求只保留一个 in_progress |
也就是说,s03 并没有推翻前一章的结构,而是在主循环旁边补了一块当前计划面板。
这张图里最关键的点,不是“多了个 todo”,而是 todo 不再是内部随手记一下的变量,而是被提升成了正式工具。
这样做,直接带来两个好处:
- 模型得显式提交计划,不能只在“脑内想过了”就算数。
- 计划会像其他工具结果一样回填进消息历史,下一轮推理真的看得到。
接入代码也很直接:
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 负责接住、校验、存下来,再渲染成可读结果回填给模型。
1. PlanItem 和 PlanState 很小,但信息正好够用
源码里用两个数据类装计划状态:
@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()
这套处理方式我挺认同,原因主要有三个:
- 对模型更省事,不用先判断“这是新增还是修改”,直接提交当前最合理的完整状态就行。
- 对 Python 更好校验,整份计划统一检查,比追踪一堆增删改稳得多。
- 对看日志的人也更友好,每次
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)'
这段日志直接说明了两件事:
- 计划不是藏在内部变量里,而是被渲染成模型和人都能看懂的文本。
activeForm很实用,能把“当前正在做什么”写得更顺。
主循环为什么会更稳
如果说 TodoManager 负责计划本身,那 agent_loop() 负责把这份计划真正纳入运行节奏。
真正让“计划刷新”变成主循环规则的,是下面这段代码:
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>"
这个判断顺序也值得注意:
- 如果还没有计划,不提醒。
- 只有已经有计划,而且连续多轮没刷新,才提醒。
所以这不是“催模型干活”,而是在避免会话节奏慢慢散掉。
看一遍真实运行记录,理解会更牢
这一轮运行的终端截图在这里:

光看截图,基本就能把这轮动作串起来:
- 先调用
todo写计划 - 再调用
write_file真正创建文件 - 完成后再次调用
todo,把状态改成已完成 - 后面跑
bash验证脚本 - 连续几轮没有刷新计划,于是插入 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。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)