首页
/ learn-claude-code s03: TodoWrite 待办写入——用「单一 in_progress + nag reminder」让 Agent 在多步任务中不偏航

learn-claude-code s03: TodoWrite 待办写入——用「单一 in_progress + nag reminder」让 Agent 在多步任务中不偏航

2026-09-06 09:39:30作者:韦蓉瑛

本篇技术指南以 learn-claude-code 课程第 s03 章 TodoWrite 文档 为主体,完整拆解「规划层」这一 harness 能力的实现:一个带状态校验的 TodoManager、一个并入 dispatch map 的 todo 工具,以及一个连续 3 轮不更新计划就注入 <reminder> 的 nag reminder 机制。读完你可以从零复制这套模式,让任何 agent loop 在多步任务中保持顺序聚焦、可观测、可问责,并了解后续 s05 章节对该机制的演进与测试验证方式。

TodoManager 状态存储与 nag reminder 注入流程

问题:多步任务中模型为什么会丢失进度

s03 文档把问题描述得很直接:多步任务中,模型会丢失进度——重复做过的事、跳步、跑偏,而且对话越长越严重。原因是工具结果不断填满上下文,系统提示的影响力被逐渐稀释。文档给出的典型场景是:一个 10 步重构任务,模型做完第 1–3 步就开始即兴发挥,因为第 4–10 步已经被挤出注意力窗口了。

这正是 harness 工程要解决的问题。按照仓库 README 的核心主张——"Agency 来自模型,harness 是载具"——规划层属于典型的 harness 能力:它不替模型做决策,而是给模型一个外置的、可被系统持续问责的状态容器。s03 的一句话定位是:

"没有计划的 agent 走哪算哪" —— 先列步骤再动手。 Harness 层: 规划 —— 让模型不偏航,但不替它画航线。

方案总览:TodoManager + dispatch 分发 + nag 注入

s03 文档给出的整体结构如下:

+--------+      +-------+      +---------+
|  User  | ---> |  LLM  | ---> | Tools   |
| prompt |      |       |      | + todo  |
+--------+      +---+---+      +----+----+
                  ^                |
                  |   tool_result  |
                  +----------------+
                        |
            +-----------+-----------+
            | TodoManager state     |
            | [ ] task A            |
            | [>] task B  <- doing  |
            | [x] task C            |
            +-----------------------+
                        |
            if rounds_since_todo >= 3:
              inject <reminder> into tool_result

三个关键构件:

  1. TodoManager——进程内单例,存储带 pending / in_progress / completed 状态的任务项,同一时间只允许一个 in_progress
  2. todo 工具——和 bash/read/write/edit 一样注册进 TOOL_HANDLERS dispatch map,对 LLM 而言它只是一个"只改计划、不做执行"的工具;
  3. nag reminder——agent loop 中维护 rounds_since_todo 计数器,模型连续 3 轮以上不调用 todo 时,向工具结果中注入 <reminder>Update your todos.</reminder>

文档点出了两个设计意图:"同时只能有一个 in_progress" 强制顺序聚焦;nag reminder 制造问责压力——你不更新计划,系统就追着你问

机制一:TodoManager——结构化的规划状态

文档中的最小实现示意如下:

class TodoManager:
    def update(self, items: list) -> str:
        validated, in_progress_count = [], 0
        for item in items:
            status = item.get("status", "pending")
            if status == "in_progress":
                in_progress_count += 1
            validated.append({"id": item["id"], "text": item["text"],
                              "status": status})
        if in_progress_count > 1:
            raise ValueError("Only one task can be in_progress")
        self.items = validated
        return self.render()

仓库中 s03 章节的完整实现位于 agents/s03_todo_write.py,比文档伪码多了两层防御,值得逐条看:

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)

结合源码可以归纳出这条链路上的校验参数与行为:

校验点 规则 违反时的行为
列表长度 单次更新最多 20 个 todo ValueError("Max 20 todos allowed")
text 字段 非空(strip() 后) ValueError(f"Item {item_id}: text required")
status 取值 pending / in_progress / completed,且强制 lower() 归一 ValueError(...invalid status...)
in_progress 数量 至多 1 ValueError("Only one task can be in_progress at a time")
状态替换 校验全部通过后才整体替换 self.items 非法更新不会污染已有状态

注意最后一点:校验在前、赋值在后。整个列表先完成遍历和计数,只有全部通过才会 self.items = validated。这意味着模型的一次错误更新(比如同时写了两个 in_progress)会被拒绝且原计划原封不动——规划状态不会被"半途失败的写操作"打坏。

render() 的返回值是喂给模型的 tool_result 正文:[ ] / [>] / [x] 三个标记加 #id 前缀,尾部附 (done/total completed) 进度统计。这一设计让每次 todo 调用后的结果本身就是一份进度快照——模型在后续每轮都能从工具结果里看到当前计划的完整状态,而不必依赖被稀释的系统提示。这也是 s03 文档"agent 可以自己追踪进度,而我(用户)也看得到"这一关键洞察的落点(见 agents/s03_todo_write.py 文件头注释)。

机制二:todo 工具像其他工具一样进入 dispatch map

s03 文档强调:todo 工具不改变 agent loop 的结构,它和 bash、read_file 一样加入 dispatch map。源码印证了这一点(agents/s03_todo_write.py):

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"]),   # 唯一的"纯状态"工具
}

对应的工具定义中,todo 的 schema 约束与 TodoManager 的校验一一对应:

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

与 s02 的工具集相比(见 agents/s02_tool_use.py),s03 的工具数从 4 个(bash/read/write/edit)变为 5 个todo 是唯一不碰文件、不执行命令的工具——它只更新规划状态,实际工作仍由既有工具完成。

配套地,系统提示词也被改写以引导这个行为(agents/s03_todo_write.py):

SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
Prefer tools over prose."""

"Mark in_progress before starting, completed when done" 规定了标准操作节奏:先建计划(全 pending)→ 选一步置为 in_progress → 完成后置为 completed → 取下一个 pending。文档没有给 agent 增加任何执行能力,增加的是规划能力。

机制三:nag reminder——不更新计划的问责压力

文档给出的最小示意是"连续 3 轮不调用 todo 就注入提醒"。s03 的完整实现在 agent_loop 中:

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}"
                print(f"> {block.name}:")
                print(str(output)[:200])
                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.append({"type": "text", "text": "<reminder>Update your todos.</reminder>"})
        messages.append({"role": "user", "content": results})

从源码结构看,有三处细节值得注意:

  1. 计数器按"轮"而非按"次"计算。每轮 LLM 响应中若任一 tool_use 块名为 todoused_todo 置真,计数器清零;否则 +1。连续 3 轮纯执行(read/write/edit/bash)后触发提醒。
  2. 注入位置在当前轮的工具结果批里。实现是在本轮 results 末尾追加一个 {"type": "text"} 块,与 tool_result 一并作为 user 消息回传;而文档中的简化伪码是把 reminder 插到上一条 user 消息头部(last["content"].insert(0, ...))。两者语义等价(都是在模型下一次可见的上下文里塞入提醒),实现版避免了改动历史消息,更贴近"随结果回传"的 agent 惯例。
  3. 提醒不阻塞、不替换执行<reminder>Update your todos.</reminder> 只是一句轻量文本压力,工具照常执行、任务照常推进——这正是"让模型不偏航,但不替它画航线"的工程表达。

相对 s02 的变更

完整继承 s03 文档的对照表:

组件 之前 (s02) 之后 (s03)
Tools 4 5(+todo)
规划 带状态的 TodoManager
Nag 注入 3 轮后注入 <reminder>
Agent loop 简单分发 + rounds_since_todo 计数器

试一试:运行 s03 Agent 并观察规划行为

环境准备(requirements.txt 只依赖 3 个包):

pip install -r requirements.txt   # anthropic>=0.25.0, python-dotenv>=1.0.0, pyyaml>=6.0
# 在 .env 或环境中配置模型,s03 会 load_dotenv 并读取:
#   MODEL_ID=...                 # 必填,代码中 os.environ["MODEL_ID"]
#   ANTHROPIC_BASE_URL=...        # 可选,自定义端点

运行方式(agents/s03_todo_write.py 是交互式 REPL,提示符为 s03 >> ,输入 q / exit / 空行退出):

cd learn-claude-code
python agents/s03_todo_write.py

s03 文档推荐的三个测试 prompt(英文 prompt 对 LLM 效果更好,也可以用中文):

  1. Refactor the file hello.py: add type hints, docstrings, and a main guard
  2. Create a Python package with __init__.py, utils.py, and tests/test_utils.py
  3. Review all Python files and fix any style issues

观察点与文档中的"试一试"意图一致:首工具调用是不是 todo?列出了几个步骤?执行过程中状态是否从 pending 走到 in_progress 再到 completed?终端里 > todo: 之后打印的 200 字符预览就是 TodoManager 的 render() 快照,可以直观看到 (1/3 completed) 这类进度。

测试验证与后续演进

仓库的测试用例 tests/test_todo_write_string_input.py 对这套机制的"后续演进版"(s05 章节,s05_todo_write/code.py)做了自动化验证,其中三个用例恰好覆盖了本文的核心不变量,可作为验收参考:

  • 无效更新不替换状态test_rejects_invalid_updates_without_replacing_state):空 content、双 in_progress、超过 20 项的更新都必须返回 Error:,且 module.TODO.items 保持上一次合法更新的内容——即上文"校验在前、赋值在后"的行为;
  • 第 3 轮结果批恰好追加一条 remindertest_appends_one_reminder_to_the_third_tool_result_batch):模拟 3 轮只调 glob 不调 todo 的响应流,断言前两轮 user 消息中无 text 块、第 3 轮结果批中出现且仅出现 {"type": "text", "text": "<reminder>Update your todos.</reminder>"}
  • 字符串入参不经过 eval:s05 版本把 run_todo_write(todos: list | str) 的入参放宽为可接受 JSON 数组字符串或 Python list 字面量字符串(先 json.loads,失败再 ast.literal_eval,全程无 eval),测试用 __import__('pathlib')... 的注入串验证了安全性(s05_todo_write/code.py)。

从 s03 到 s05 的演进脉络可以概括为:todoid/text 字段,注入位置在历史消息头部)→ todo_writecontent/status 字段、maxItems: 20 写进 schema、reminder 随当轮结果批回传且触发后计数器复位、字符串入参安全解析)。如果你想继续往下读,仓库中 s04_hookss05_todo_write 两章展示了 todo 机制如何与 hook 系统共存,以及"任务大到单一 TODO 列表兜不住"之后由 subagent 接力的动机。

小结

s03 用约 40 行增量代码给 agent loop 加上了规划层:TodoManager 以"20 项上限 + 三态校验 + 单 in_progress + 全量原子替换"约束了模型写入的规划状态;todo 工具零改动地融入既有 dispatch map;rounds_since_todo 计数器与 <reminder> 注入构成了不侵入执行路径的问责机制。三者叠加回答的是同一个问题——在注意力被工具结果不断稀释的长对话里,如何低成本地把"计划"从模型的隐式状态变成 harness 里显式可查、可持续追问的对象。这正是 learn-claude-code "先列步骤再动手"这一规划层设计的完整落地。

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