learn-claude-code s05 深解:TodoWrite 计划工具,让 Agent「先计划再执行」而不跑偏
本文基于 learn-claude-code 课程的第 5 章(s05 TodoWrite),讲清算法 Agent 在长任务中为什么会「丢目标」、TodoWrite 如何用一份受控的 TODO 状态与一个计数器注入来恢复计划能力,以及 todo_write 工具的完整实现:验证规则、字符串输入解析、工具注册、提醒注入机制,并给出可直接运行与测试的代码证据。读完你可以理解「Harness 层之计划层」的设计意图,并能在自己的 Agent 外壳(harness)中复刻同一套模式。
问题:没有计划的 Agent 为什么会在长任务中跑偏
章节以一句原则开头:"计划なき agent は風の向くままに"——没有计划的 Agent 随风而行。它给出的典型故障场景值得逐字理解:
给 Agent 一个复合任务:「把所有 Python 文件重命名为 snake_case,运行测试,并修复失败项」。Agent 开始干活:重命名了 3 个文件,运行测试,发现 2 个失败,开始修复。修复过程中,它忘记了原始目标是「重命名为 snake_case」——测试失败吸走了它全部的注意力。
文档进一步指出了恶化的根源:对话越长越糟。工具结果不断填充上下文,系统提示的影响力被稀释。一个 10 步的重构任务,做完第 1–3 步后 Agent 就开始即兴发挥,因为第 4–10 步已经被挤出注意力范围。这本质上是一个注意力与上下文管理问题:目标陈述在消息历史里越沉越深,而模型对「当前该做什么」的追踪没有外部支撑。
对应的 Harness 层定位是:计划(Planning)——让 Agent 在行动前先思考。这也是整个课程「Agent 产品 = 模型 + Harness」框架中的一层:Harness 负责给模型提供工具、知识与状态管理,而计划能力正是通过 harness 代码(而非模型训练)补上的。
方案总览:todo_write 工具 + 提醒计数器
s05 在 s04(Hooks)的基础上做了两处增量改动,其余——工具分发、权限检查、Hook 系统——全部保留:
- 新增
todo_write工具,只更新计划状态,不做实际工作;实际工作仍由 s02–s04 已有的 bash / read_file / write_file / edit_file / glob 完成; - 新增一个提醒计数器:连续 3 个工具调用轮次(round)没有调用
todo_write,就在第 3 轮的工具结果中追加一条 reminder。
关键设计点:新工具走的是与既有工具完全相同的分发路径 TOOL_HANDLERS[block.name],没有为计划能力开任何特殊通道。这是 s04「挂在循环上,而不是写进循环里」哲学的延续——计划系统对 agent loop 的侵入被压缩到「一个 handler + 一个计数器」。
TodoManager:验证规则与状态渲染
TodoManager 实现是本章的核心。它持有内存中的任务列表,负责验证更新、渲染结果并返回给模型。完整源码结构如下(与 s05_todo_write/README.ja.md 中展示的一致):
class TodoManager:
def __init__(self):
self.items: list[dict] = []
def update(self, todos: list | str) -> str:
# 先解析并验证,验证通过后才整体替换当前列表
validated = []
...
self.items = validated
return self.render()
def render(self) -> str:
# [ ] pending, [>] in progress, [x] completed
...
TODO = TodoManager()
def run_todo_write(todos: list | str) -> str:
try:
output = TODO.update(todos)
except ValueError as e:
return f"Error: {e}"
print(f"\n\033[33m## Current Tasks\033[0m\n{output}")
return output
更新验证:一次更新必须满足的约束
update() 的验证逻辑(见 code.py#L114-L149)把规则收敛为四条硬约束,违反任何一条都抛出 ValueError,且当前状态不被替换(fail-safe):
| 规则 | 源码约束 | 违反时行为 |
|---|---|---|
| 列表长度 | len(todos) > 20 即拒绝,schema 里也声明了 maxItems: 20 |
Error: Max 20 todos allowed |
每项必须是有 content 的对象 |
content 为空字符串(strip 后)即拒绝 |
Error: todos[i] requires content |
| status 只能是三个枚举值 | pending / in_progress / completed,且先 lower() 归一化 |
Error: todos[i] has invalid status '...' |
同时只有一个 in_progress |
in_progress_count > 1 即拒绝 |
Error: Only one todo can be in_progress at a time |
其中「同时只允许一个 in_progress」是最值得品味的一条:它把「聚焦」这一软约束变成了结构约束。模型如果试图并行推进两件事,状态层会直接拒绝,迫使它要么串行化,要么改写任务表述。另外,update 采用「全部验证通过才整体替换 self.items」的策略——这一点被测试 tests/test_todo_write_string_input.py 的 test_rejects_invalid_updates_without_replacing_state 明确验证:先用合法列表写入「keep this」,再分别尝试空 content、双 in_progress、21 项列表,每次非法更新返回 Error: 后 TODO.items 仍保持原样。
渲染格式:给模型一个稳定的进度视图
render() 把状态渲染为带标记的列表加一行进度汇总(见 code.py#L151-L166):
[ ]— pending[>]— in_progress[x]— completed- 末尾附
(done/total completed)形式的汇总,例如(1/2 completed)
这个渲染结果作为 tool_result 返回给模型,因此模型每更新一次 TODO,下一轮就能看到自己完整的计划快照——这是对「步骤 4–10 被挤出注意力」的直接对抗:计划不再依赖模型内部记忆,而是每次都以最新形式重新进入上下文。run_todo_write 同时把同一份状态打印到终端(带黄色 ## Current Tasks 标题),方便人类在 CLI 里观察 Agent 的计划演化。
字符串输入:不用 eval 的安全解析
实际部署中,模型有时会把本应是 JSON 数组的参数序列化成字符串发出。update() 对此有专门的解析路径(见 code.py#L115-L122):
if isinstance(todos, str):
try:
todos = json.loads(todos)
except json.JSONDecodeError:
try:
todos = ast.literal_eval(todos)
except (SyntaxError, ValueError) as e:
raise ValueError("todos must be a list or JSON array string") from e
先用 json.loads,失败再退回 ast.literal_eval(可处理单引号的 Python 列表表示),全程不使用 eval。这条设计被三个回归测试锁死(tests/test_todo_write_string_input.py 中 test_issue_340_* 系列):接受 JSON 数组字符串、接受 Python 列表 repr 字符串,以及关键的 test_issue_340_does_not_eval_string_inputs——输入 __import__('pathlib').Path(...).write_text('bad') 这类恶意字符串,断言结果以 Error: 开头且副作用文件没有被创建。测试还同时覆盖了集成版 s15_integrated_harness/code.py,说明这条安全约束在课程后续章节中被持续继承。
工具注册:加入 6 个工具的 dispatch 地图
todo_write 的工具定义与其余 5 个工具并列在 TOOLS 列表中(见 code.py#L180-L199),并在 TOOL_HANDLERS 字典里挂上 handler:
TOOLS = [
{"name": "bash", ...},
{"name": "read_file", ...},
{"name": "write_file", ...},
{"name": "edit_file", ...},
{"name": "glob", ...},
# s05: 新增
{"name": "todo_write", "description": "Create and manage a task list for your current coding session.",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array", "maxItems": 20,
"items": {
"type": "object",
"properties": {
"content": {"type": "string", "minLength": 1},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
},
"required": ["content", "status"],
},
},
},
"required": ["todos"],
},
},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
}
值得注意的细节:schema 本身已经把 20 项上限(maxItems: 20)、非空 content(minLength: 1)和状态枚举写进了声明,让模型在生成参数时就知道边界;而 TodoManager.update() 里的验证是第二道防线,保证即使 schema 校验被绕过(比如走字符串路径),状态层依然自洽。schema 声明 + 运行时验证的双层防御,是这个小工具里很实用的工程习惯。
提醒机制:三轮不更新就注入一次 nudging
光有工具不够——模型完全可能一直不用它。harness 侧的补偿手段是计数器 + 注入,位于 agent_loop 内:
rounds_since_todo = 0
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
...
results = []
used_todo = False
for block in response.content:
if block.type != "tool_use":
continue
# ... PreToolUse 拦截、handler 调用、PostToolUse ...
if block.name == "todo_write":
used_todo = True
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(output)})
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.append({"type": "text",
"text": "<reminder>Update your todos.</reminder>"})
rounds_since_todo = 0
messages.append({"role": "user", "content": results})
机制的精确语义(也是测试 test_appends_one_reminder_to_the_third_tool_result_batch 锁定的行为):
- 计数器以「轮」为单位:一轮指一次 LLM 响应包含的全部工具调用,只要其中出现了
todo_write,used_todo为 True,计数器清零; - 连续 3 轮未调用
todo_write,在第 3 轮的结果列表里追加一条{"type": "text", "text": "<reminder>Update your todos.</reminder>"}; - 注入后立即重置计数器,避免每个后续结果都带提醒;
- 提醒以 text 块形式混在 tool_result 批次里,随下一次
messages.create回到模型上下文——这是一种「系统提示影响力被稀释」的对症处理:把提醒放在最新、注意力权重最高的位置。
配合的另一端是 SYSTEM 提示词(见 code.py#L48-L53),从 s04 的通用提示改成了明确的行为指引:
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Before starting any multi-step task, use todo_write to plan your steps. "
"Update status as you go."
)
一端是「先计划再动手」的前置引导,一端是「三轮不更新就催」的后置补偿,前后夹击。
典型工作流:从计划到执行的状态机
文档描述的 Agent 接到任务后的典型循环是:
- 先调用
todo_write列出全部步骤(全部pending); - 挑一步开始,把它改为
in_progress(由于单 in_progress 约束,这同时隐式声明了当前焦点); - 做完后改为
completed; - 看下一个
pending,继续。
这个循环之所以有效,在于每一步 todo_write 的渲染结果都会重新把完整计划塞回上下文,「当前在哪个位置」这个问题因此总有一个确定答案。
s04 到 s05 的变化对照
| 组件 | 变更前 (s04) | 变更后 (s05) |
|---|---|---|
| 工具数 | 5(bash, read, write, edit, glob) | 6(+todo_write) |
| 计划能力 | 无 | 带状态的 TODO 列表 + 提醒计数器 |
| SYSTEM 提示 | 通用提示 | 增加「先计划再执行」指引 |
| 循环 | 工具分发与 Hooks | 同一分发路径,另加 rounds_since_todo 与 reminder 注入 |
除表格外,s05 还完整保留了 s04 的 Hook 注册(UserPromptSubmit / PreToolUse / PostToolUse / Stop 四类事件,权限拒绝列表、破坏性命令确认、大输出告警等回调都在 code.py#L202-L273 中),说明新增能力没有破坏既有架构。
动手试试:运行与观察
环境准备与运行
依赖来自 requirements.txt(anthropic>=0.25.0、python-dotenv>=1.0.0),代码通过 load_dotenv 读取环境变量,要求设置 MODEL_ID(见 code.py#L40-L46,并支持 ANTHROPIC_BASE_URL 指向兼容端点):
cd learn-claude-code
pip install -r requirements.txt
export MODEL_ID=<你的模型 ID> # 如需要可另设 ANTHROPIC_BASE_URL
python s05_todo_write/code.py
启动后是交互式 REPL(提示符 s05 >>),输入问题回车发送,q 退出。
推荐提示与观察要点
章节给出三个面向 s05_todo_write/example/ 目录的练习提示:
Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard(预期:先列出 3 个步骤再执行)Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.pyReview Python files under s05_todo_write/example and fix any style issues
观察清单(判断计划机制是否生效的直接指标):
- 第一次工具调用是不是
todo_write? - 列出了几步 TODO?
- 执行过程中状态是否真的从
pending→in_progress→completed迁移? - 如果某段长工具序列后出现了
<reminder>Update your todos.</reminder>,说明计数器按预期触发了。
用仓库自带测试验证机制
不接入真实模型也能验证本章机制——tests/test_todo_write_string_input.py 用假的 anthropic 客户端驱动 agent_loop,覆盖渲染格式、非法更新不污染状态、reminder 精确注入到第 3 个结果批次、字符串输入安全性等行为(本仓库环境运行 python -m pytest tests/test_todo_write_string_input.py -q 为 6 项全部通过):
python -m pytest tests/test_todo_write_string_input.py -q
关键洞察与下一步
章节的结论值得原样保留:todo_write 没有给 Agent 增加任何执行能力,它增加的是计划能力。执行能力来自模型训练,harness 能做的是提供状态、约束和提醒——这正是 learn-claude-code「Agency 来自模型,Harness 是载具」这一总纲在计划层的具体落点。
而计划能力有边界:当任务大到 TODO 列表本身装不下时(例如「重构整个认证模块」,它本身是数十个子任务的集合体,塞进同一份对话上下文会溢出),单靠 todo_write 就不够了。下一章 s06 Subagent 的解法是把大任务拆成子任务、交给各自拥有独立干净上下文的 Agent,避免相互污染——对应 s06_subagent/ 章节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00