首页
/ learn-claude-code s05 深解:TodoWrite 计划工具,让 Agent「先计划再执行」而不跑偏

learn-claude-code s05 深解:TodoWrite 计划工具,让 Agent「先计划再执行」而不跑偏

2026-09-04 20:35:46作者:余洋婵Anita

本文基于 learn-claude-code 课程的第 5 章(s05 TodoWrite),讲清算法 Agent 在长任务中为什么会「丢目标」、TodoWrite 如何用一份受控的 TODO 状态与一个计数器注入来恢复计划能力,以及 todo_write 工具的完整实现:验证规则、字符串输入解析、工具注册、提醒注入机制,并给出可直接运行与测试的代码证据。读完你可以理解「Harness 层之计划层」的设计意图,并能在自己的 Agent 外壳(harness)中复刻同一套模式。

Todo 机制总览:todo_write 更新 TodoManager 状态,三轮未更新时向工具结果注入 reminder

问题:没有计划的 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 系统——全部保留:

  1. 新增 todo_write 工具,只更新计划状态,不做实际工作;实际工作仍由 s02–s04 已有的 bash / read_file / write_file / edit_file / glob 完成;
  2. 新增一个提醒计数器:连续 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.pytest_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.pytest_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_writeused_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 接到任务后的典型循环是:

  1. 先调用 todo_write 列出全部步骤(全部 pending);
  2. 挑一步开始,把它改为 in_progress(由于单 in_progress 约束,这同时隐式声明了当前焦点);
  3. 做完后改为 completed
  4. 看下一个 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.txtanthropic>=0.25.0python-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/ 目录的练习提示:

  1. Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard(预期:先列出 3 个步骤再执行)
  2. Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py
  3. Review Python files under s05_todo_write/example and fix any style issues

观察清单(判断计划机制是否生效的直接指标):

  • 第一次工具调用是不是 todo_write
  • 列出了几步 TODO?
  • 执行过程中状态是否真的从 pendingin_progresscompleted 迁移?
  • 如果某段长工具序列后出现了 <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/ 章节。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384