:用TodoWrite实现行动前先计划)
1. 为什么你的 Agent 总是走哪算哪如果你已经跟着前两篇把 Agent 的工具调用循环跑通了大概率会遇到一个很典型的现象给它一个帮我重构 hello.py 并补上类型注解和 main guard的任务它上来就开始改代码改到一半忘了自己刚才动过哪个函数然后开始重复劳动或者干脆漏掉你明确提到的某一步。任务越长这个问题越明显。这不是模型能力不够而是规划没有被外化。模型在思维链里默默规划一旦工具结果不断填满上下文早期那点规划就被挤出了注意力窗口。一个 10 步的重构任务模型可能做完 1-3 步就开始即兴发挥因为 4-10 步早就被淹没了。Claude Code 里那个 TodoWrite 工具解决的正是这件事它不让模型在脑子里规划而是强制通过一个工具把计划写出来每个计划项带可追踪状态pending / in_progress / completed。这样做有三个直接好处——你可以在执行前就看到 Agent 打算干什么开发者可以通过检查计划状态来调试 Agent 行为Agent 自己在后续轮次里能引用这份计划哪怕早期上下文已经滚出窗口。这篇就带你从零把这个最小闭环搭起来一个带状态的 TodoManager、一个 todo 工具、一个三轮不更新就催你的 nag reminder以及一套验证计划是否被正确消费的测试动作。全程本地可跑代码可以直接复制。2. 前置准备模型接入与依赖在动手写 TodoManager 之前先把模型调用这条链路准备好。我这边用的是 TaoToken 的兼容接口它同时提供 Anthropic 风格和 OpenAI 风格的端点对自研 Agent 来说切换成本很低。你需要先拿到一个 API Key。打开 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 创建密钥然后在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 里确认一下额度。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有各语言 SDK 的示例。环境变量这样配后面代码直接读export TAOTOKEN_API_KEYsk-你的密钥 export TAOTOKEN_BASE_URLhttps://taotoken.net/apiPython 侧装官方 SDK 即可pip install anthropic注意base_url 一定要带上/api前缀很多人第一次接入报 404 就是漏了这一段。Anthropic 风格端点是https://taotoken.net/apiSDK 会自动拼/v1/messages。如果你只是想先验证模型能不能正常对话可以直接用模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 发一条消息试试确认 Key 有效再往下写代码能省掉不少排查时间。3. 核心实现TodoManager 与 todo 工具3.1 带状态的 TodoManagerTodoManager 的职责很单一存一组带状态的计划项并在每次更新时做校验。这里我加了三条约束都是踩过坑之后加的——最多 20 条、同一时间只能有一个 in_progress、状态只能是三个合法值之一。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(fItem {item_id}: text required) if status not in (pending, in_progress, completed): raise ValueError(fItem {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: lines [] for it in self.items: mark {pending: [ ], in_progress: [], completed: [x]}[it[status]] lines.append(f{mark} #{it[id]}: {it[text]}) done sum(1 for it in self.items if it[status] completed) lines.append(f\n({done}/{len(self.items)} completed)) return \n.join(lines)为什么限制 20 条不加限制时模型倾向于把任务拆成越来越细的步骤产出 50 条计划每一步都微不足道。冗长的计划很脆弱如果第 15 步失败剩下 35 步可能全部作废。20 条以内的短计划保持在正确的抽象层级更容易在现实偏离计划时做调整。实测下来大多数实际编程任务用 5-15 个有意义的步骤就能表达清楚。为什么只允许一个 in_progress这是防止一种隐蔽的失败模式模型试图通过交替处理多个项目来多任务结果往往丢失状态并产出半成品。顺序执行的专注度远高于并行切换。LLM 处理上下文切换效果不佳——它们会失去对正在处理哪个任务的追踪并在任务之间混淆细节。单一焦点约束是一个安全措施。3.2 把 todo 注册进工具分发表todo 工具和其他工具一样加入 dispatch map处理函数直接调用 TodoManagerTODO TodoManager() TOOL_HANDLERS { # ...base tools... todo: lambda **kw: TODO.update(kw[items]), }工具定义TOOLS 列表里的一项大致长这样重点是 description 要明确告诉模型多步任务先规划{ name: todo, description: ( Create and update a visible task plan. Use this BEFORE starting any multi-step task. Mark exactly one item in_progress before working on it, and completed when done. Prefer tools over prose. ), input_schema: { type: object, properties: { items: { type: array, items: { type: object, properties: { id: {type: string}, text: {type: string}, status: {enum: [pending, in_progress, completed]}, }, required: [text, status], }, } }, required: [items], }, }3.3 Agent 循环里的 nag reminder光有工具还不够模型有时候会忘记更新计划。所以循环里加一个计数器连续 3 轮以上没调用 todo就往工具结果里注入一条提醒。def agent_loop(messages: list): rounds_since_todo 0 while True: response client.messages.create( modelMODEL, systemSYSTEM, messagesmessages, toolsTOOLS, max_tokens8000, ) messages.append({role: assistant, content: [b.to_dict() for b in 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 fUnknown tool: {block.name} except Exception as e: output fError: {e} 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.insert(0, {type: text, text: reminderUpdate your todos./reminder}) messages.append({role: user, content: results})这段逻辑的关键在于提醒是注入到 tool_result 里的而不是单独发一条 user 消息。这样模型在下一轮看到的仍然是工具执行结果 一条提醒语义上更自然也不会打乱消息结构。nag reminder 制造的是问责压力——你不更新计划系统就追着你问。4. 可复制配置系统提示词骨架工具和循环都就位后系统提示词决定了模型会不会真的用 todo。下面这个骨架可以直接抄把路径换成你自己的SYSTEM You are a coding agent at /your/project/path. Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done. Prefer tools over prose. 三句话各有用处。第一句给工作目录避免模型瞎猜路径第二句是核心明确要求多步任务用 todo 规划第三句Prefer tools over prose压制模型用大段文字描述代替实际工具调用的倾向。如果你希望计划更早出现可以把第二句加强成Before writing any code for a multi-step task, first call the todo tool to create a visible plan. Do not start editing files until the plan exists.实测下来加了这句之后模型几乎总是先出计划再动手而不是边写边想。5. 验证请求计划是否被正确消费搭好了不代表跑对了。下面这套测试动作专门验证计划有没有被真正消费而不只是被创建出来。5.1 发一个多步任务用一个明确需要规划的任务比如构建一个支持加减乘除的命令行计算器from agents.s03_todo_write import TOOLS request { max_tokens: 8000, messages: [ {role: user, content: Build a CLI calculator that supports add, subtract, multiply, divide} ], model: claude-sonnet-4-6, system: SYSTEM, tools: TOOLS, }5.2 检查第一轮响应正常情况下模型的第一轮响应应该只包含一个 todo 工具调用而不是直接开始写文件。你会看到类似这样的计划[x] #1: Plan project structure [ ] #2: Create calculator logic (calculator.js) [ ] #3: Create CLI entry point (index.js) [ ] #4: Create package.json [ ] #5: Test the CLI calculator (1/5 completed)注意第一项已经是 completed——模型把规划项目结构这一步在创建计划时就标记完成了这是合理的。如果第一轮响应里没有任何 todo 调用直接开始 write_file说明系统提示词没生效回去检查 SYSTEM 是否真的传进去了。5.3 检查后续轮次的状态流转第二轮响应应该把 #2 标记为 in_progress然后才开始调用 write_file[x] #1: Plan project structure [] #2: Create calculator logic (calculator.js) [ ] #3: Create CLI entry point (index.js) [ ] #4: Create package.json [ ] #5: Test the CLI calculator (1/5 completed)再往后每完成一个文件对应的项应该从 in_progress 变成 completed同时下一项变成 in_progress。如果你看到某一项长期停在 in_progress 而模型已经在做别的项说明单一 in_progress约束没生效检查 TodoManager 的校验逻辑。5.4 验证 nag reminder想确认提醒机制工作可以临时把阈值改成 1然后发一个不需要规划的单步任务比如打印当前目录。观察 tool_result 里是否出现了reminderUpdate your todos./reminder。确认后改回 3。5.5 跑一遍完整任务最后让它把整个计算器任务跑完检查最终计划是否全部 completed[x] #1: Plan project structure [x] #2: Create calculator logic (calculator.js) [x] #3: Create CLI entry point (index.js) [x] #4: Create package.json [x] #5: Test the CLI calculator (5/5 completed)如果最后有项卡在 pending 或 in_progress说明模型没有正确收尾这通常是因为任务本身没做完就停了或者提醒阈值太高导致它忘了更新。6. 本篇常见错误排查报错一Only one task can be in_progress at a time这是 TodoManager 主动抛的校验错误说明模型一次提交了多个 in_progress。处理方式有两种一是把错误信息原样返回给模型我们的循环已经这么做了except Exception as e: output fError: {e}模型下一轮通常会自己修正二是在系统提示词里再强调一次exactly one in_progress。不要直接放宽校验那等于放弃了顺序聚焦这个核心约束。报错二Max 20 todos allowed模型把任务拆得太细。同样把错误返回给模型它一般会合并步骤。如果频繁触发说明任务本身确实复杂考虑在系统提示词里加一句keep the plan under 15 items。报错三模型从不调用 todo先确认 TOOLS 列表里真的包含了 todo 的定义且 name 和 TOOL_HANDLERS 的 key 完全一致大小写敏感。再确认 SYSTEM 里明确提到了 todo 工具。最后检查 nag reminder 的计数器逻辑——如果used_todo判断写错了比如判断的是block.name todos提醒永远不会触发。报错四计划创建了但后续不更新这是最常见的问题。根因通常是模型在后续轮次里看不到当前计划状态。解决办法是确保每次 todo 更新后render()的输出作为 tool_result 返回给模型这样模型下一轮就能看到最新状态。如果你只返回了 ok 之类的简短确认模型就失去了追踪依据。报错五接入时报 401 或 404401 一般是 Key 无效或没带上检查TAOTOKEN_API_KEY环境变量是否被正确读取。404 多半是 base_url 写错了确认是https://taotoken.net/api而不是别的路径。这两个错误在接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里都有对应说明。7. 下一步把规划能力接进你的工作流到这里先计划再行动的最小闭环就跑通了TodoManager 管状态todo 工具做外化nag reminder 兜底测试动作验证消费。这套东西不依赖任何特定框架你可以直接搬进自己的 Agent 项目。如果你打算长期做编码类 Agent建议把模型调用统一走 Coding Plan这样在长任务、多轮工具调用场景下额度和稳定性都更可控具体可以看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。想先验证不同模型在规划任务上的表现差异用模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 快速对比几条 prompt 就够了。密钥和额度管理都在 API Keys 页 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 和控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。下一篇会在这个基础上加计划与执行的偏差检测——当实际执行偏离计划时让 Agent 主动重规划而不是硬着头皮走完一份已经过时的清单。