☰
手搓Claude Code-第五章 todo_write:把 agent 任务清单落到本地文件
2026/10/2 11:44:17 网站建设 项目流程

1. 为什么你的 agent 跑着跑着就忘了自己要干什么

如果你正在自建 agent,尤其是做 Claude Code 这类编码助手,大概率遇到过这个场景:给模型下了一个多步任务,比如「重构 hello.py,加类型注解、补 docstring、加 main guard」。模型第一步干得挺好,第二步遇到一个 import 报错,然后它就开始疯狂查这个报错,查着查着,类型注解忘了、docstring 忘了,最后给你返回一个「已修复 import 问题」——你原本的任务它压根没做完。

这不是模型笨,是 Transformer 架构本身的注意力机制在长上下文里会涣散。注意,这跟上下文窗口被打满是两码事。上下文窗口是物理截断,超了就丢;注意力涣散是即使内容还在窗口里,模型对早期约束的权重也会衰减。表现一样:条件 A 被忽略,执行任务 C 时就没有约束。

todo_write 就是工程上缓解这个问题的一个手段:给 agent 一张待办表,让它每做几步就回头更新一次,用外部文件把「我该干什么」这件事钉住,而不是指望模型自己记住。这一章我就把 todo_write 的 JSON schema、读写本地 todo 文件的完整代码、以及怎么用一次多步任务验证增删改查是否生效,全部拆开讲一遍。适合已经在写 agent loop、想加规划能力的开发者,小白也能跟着敲。

核心检索词先摆出来:Claude Code 的 todo_write 工具,本质是一个让 agent 维护任务清单的函数调用,它把「规划」从模型脑子里搬到本地文件里,适合所有自建 agent 的场景。

2. 接入前的准备:TaoToken 与运行环境

在动手写 todo_write 之前,得先有一个能稳定调用 Claude 系列模型的通道。我这边用的是 TaoToken,它提供兼容 Anthropic 接口的调用方式,Base URL 和 Key 配好就能直接跑 Claude Code 或自建 agent。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意 API 这条不加 UTM 参数。

环境上你需要准备三样东西:Python 3.10 以上、anthropic 官方 SDK、以及一个能写文件的本地目录。我建议单独建一个工作目录,比如~/learn_claude_code/s05_todo_write,因为 todo_write 会把任务清单落到本地文件,目录乱了后面排查很痛苦。

安装依赖就一行:

pip install anthropic

然后配置环境变量。这里有个坑,很多人直接把 Key 写死在代码里,调试时改来改去容易漏。我习惯用.env加python-dotenv,但为了让你复制就能跑,下面直接读环境变量:

export ANTHROPIC_BASE_URL="https://taotoken.net/api" export ANTHROPIC_API_KEY="你的Key"

如果你用的是 Claude Code 本体,配置在~/.claude/settings.json里,长这样:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "你的Key", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" } }

注意这里三件套必须齐全:Base URL、Key、Model ID。少任何一个,请求都会以 401 或 model not found 结束。Model ID 要跟你账号里可用的模型对齐,别照抄别人的。

自建 agent 这边,初始化 client 的代码是:

import os from anthropic import Anthropic client = Anthropic( base_url=os.environ["ANTHROPIC_BASE_URL"], api_key=os.environ["ANTHROPIC_API_KEY"], ) MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") WORKDIR = os.path.abspath("./workspace") os.makedirs(WORKDIR, exist_ok=True)

WORKDIR 一定要用绝对路径。我踩过的坑是用了相对路径,agent 在子进程里执行时工作目录变了,todo 文件写到了别的地方,找半天找不到。绝对路径能省掉这类玄学问题。

到这一步,通道和环境就齐了。接下来才是本章的重点:todo_write 本身怎么设计。

3. todo_write 的 JSON Schema 与本地文件读写实现

todo_write 的本质是一个工具函数,模型通过 tool_use 调用它,传入一个 todos 数组,函数把数组标准化后写进本地文件,同时返回一个简短的结果字符串给模型。整个设计分四块:schema 定义、输入标准化、文件读写、注册到工具表。

先说 schema。这是给模型看的「说明书」,required 字段是在向模型强调哪些参数必须生成:

{ "name": "todo_write", "description": "Create and manage a task list for your current coding session.", "input_schema": { "type": "object", "properties": { "todos": { "type": "array", "items": { "type": "object", "properties": { "content": {"type": "string"}, "status": { "type": "string", "enum": ["pending", "in_progress", "completed"] } }, "required": ["content", "status"] } } }, "required": ["todos"] } }

status 只有三个合法值,这个 enum 很关键。模型有时候会自己发明done、finished这种状态,enum 能把它约束回来,但约束不保证 100% 生效,所以后端还得再校验一次。

接下来是输入标准化。模型返回的 todos 可能是字符串、可能是 Python 字面量、也可能是残缺结构,得统一转成 list[dict]:

import json import ast def _normalize_todos(todos): if isinstance(todos, str): try: todos = json.loads(todos) except json.JSONDecodeError: try: todos = ast.literal_eval(todos) except (SyntaxError, ValueError): return None, "Error: todos must be a list or JSON array string" if not isinstance(todos, list): return None, "Error: todos must be a list" for i, t in enumerate(todos): if not isinstance(t, dict): return None, f"Error: todos[{i}] must be an object" if "content" not in t or "status" not in t: return None, f"Error: todos[{i}] missing 'content' or 'status'" if t["status"] not in ("pending", "in_progress", "completed"): return None, f"Error: todos[{i}] has invalid status '{t['status']}'" return todos, None

为什么先用 json 再用 ast?因为 json 对标准格式更严格,能挡住大部分脏输入;ast 容错性更强,能解析单引号、元组这类 Python 独有语法。只用 ast 的话,模型输出再离谱都能混进来,隐藏 bug 会变多。两层兜底,先严后宽,是实践中比较稳的顺序。

然后是文件读写。todo 落到本地文件,路径固定在 WORKDIR 下的.todos.json:

import os TODO_FILE = os.path.join(WORKDIR, ".todos.json") CURRENT_TODOS: list[dict] = [] def _save_todos(todos): with open(TODO_FILE, "w", encoding="utf-8") as f: json.dump(todos, f, ensure_ascii=False, indent=2) def _load_todos(): if not os.path.exists(TODO_FILE): return [] try: with open(TODO_FILE, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): return [] def run_todo_write(todos) -> str: global CURRENT_TODOS todos, error = _normalize_todos(todos) if error: return error CURRENT_TODOS = todos _save_todos(todos) lines = ["\n## Current Tasks"] for t in CURRENT_TODOS: icon = {"pending": " ", "in_progress": ">", "completed": "x"}[t["status"]] lines.append(f" [{icon}] {t['content']}") print("\n".join(lines)) return f"Updated {len(CURRENT_TODOS)} tasks"

注意_load_todos在启动时调用一次,把上次会话的清单恢复回来。这样 agent 重启后不会丢进度,长任务续跑很实用。

最后注册到工具表:

TOOLS = [{ "name": "todo_write", "description": "Create and manage a task list for your current coding session.", "input_schema": { ... } # 上面那段 schema }] TOOL_HANDLERS = { "todo_write": run_todo_write, }

到这里,todo_write 的读写闭环就完成了。模型调用它,它写文件、打印、返回结果,模型下一轮就能看到「Updated N tasks」。

4. 在 agent loop 里验证增删改查是否生效

光有工具不够,得让 agent loop 主动去用它。核心逻辑是:每三轮提醒模型更新一次待办表,一旦模型调用了 todo_write,计数器归零。

rounds_since_todo = 0 def agent_loop(messages: list): global rounds_since_todo while True: if rounds_since_todo >= 3 and messages: messages.append({ "role": "user", "content": "<reminder>Update your todos.</reminder>" }) rounds_since_todo = 0 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 rounds_since_todo += 1 results = [] for block in response.content: if block.type != "tool_use": continue handler = TOOL_HANDLERS.get(block.name) output = handler(**block.input) if handler else f"Unknown: {block.name}" if block.name == "todo_write": rounds_since_todo = 0 results.append({ "type": "tool_result", "tool_use_id": block.id, "content": output, }) messages.append({"role": "user", "content": results})

system 提示词也要改,权重高,得让模型重视待办表:

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." )

现在验证。先建一个测试文件workspace/hello.py:

def greet(name): print("Hello, " + name) greet("world")

然后给 agent 下任务:

messages = [{ "role": "user", "content": "Refactor workspace/hello.py: add type hints, docstrings, and a main guard." }] agent_loop(messages)

跑起来后观察终端。正常流程是:模型先调 todo_write 列出三条任务,状态都是 pending;然后开始改文件,改一条就把对应任务标 in_progress,改完标 completed;最后返回「All tasks are complete」。

验证增删改查是否真的生效,直接看.todos.json:

cat workspace/.todos.json

你应该能看到类似这样的内容:

[ {"content": "Add type hints to greet()", "status": "completed"}, {"content": "Add module and function docstrings", "status": "completed"}, {"content": "Add main guard", "status": "completed"} ]

如果模型中途新增了任务,比如发现要处理 import,数组里会多一条。这就是「增」。改状态是「改」,删任务一般发生在模型重新规划时,会传一个更短的数组覆盖。查就是每次_load_todos恢复。

实测下来,加了 todo_write 之后,模型跑偏的概率明显下降,尤其是三步以上的任务。代价是 token 消耗变高,因为每轮都要带上待办表,而且每三轮多一次提醒。这个取舍后面会讲。

5. 常见报错排查:401、local proxy failed、reading choices、OAuth

自建 agent 接 Claude 时,报错集中在几个地方。我按真实遇到的顺序列一下。

401 Unauthorized。最常见,八成是 Key 没配或配错。检查ANTHROPIC_API_KEY是否生效,注意别把 Base URL 和 Key 搞混。如果你用的是 Claude Code 本体,检查~/.claude/settings.json里的 env 块,三件套 Base URL、Key、Model ID 是否齐全。少 Model ID 有时不报 401,而是报 model not found,别被误导。

local proxy failed。这个报错通常出现在你本地起了代理但没起来,或者环境变量指向了一个不存在的本地端口。检查ANTHROPIC_BASE_URL是不是被别的工具改成了http://localhost:xxxx。正确值应该是https://taotoken.net/api。如果你同时装了多个 AI 工具,它们可能互相覆盖环境变量,用echo $ANTHROPIC_BASE_URL确认一下。

reading choices 相关报错。这个一般出现在你混用了 OpenAI 格式的 SDK 去调 Anthropic 接口。Anthropic 的响应结构是content数组,不是choices。检查你用的是anthropic包而不是openai包,client 初始化方式也要对应。

OAuth 相关报错。Claude Code 本体走的是 OAuth 登录,如果你手动改了配置又没清缓存,会报 token 失效。解决办法是删掉~/.claude/下的凭据缓存重新登录,或者干脆用 API Key 模式绕开 OAuth。自建 agent 一般不涉及 OAuth,如果你遇到了,说明你可能在混用两套认证。

todo_write 返回 Error: todos must be a list。这是模型输出格式不对,_normalize_todos挡住了。看模型传进来的原始内容,多半是它把 todos 包成了{"todos": [...]}而不是直接传数组。检查 schema 里required: ["todos"]有没有写对,以及 handler 调用时是不是handler(**block.input),**会把{"todos": [...]}展开成todos=[...],这个细节错了就会一直报错。

文件写不进去。检查 WORKDIR 是否存在且有写权限。用绝对路径,别用相对路径。如果.todos.json一直不生成,在_save_todos里加个 print 看有没有被调用。

排查顺序建议:先确认 401 和 Base URL,再确认 SDK 类型,最后看 todo_write 自己的逻辑。大部分问题在前两步就能定位。

6. 把 todo_write 用起来:从验证到长期编码

todo_write 跑通之后,你会发现它不只是个「待办表」,它其实是 agent 的短期记忆锚点。模型每轮都能从.todos.json里读到「我该干什么」,注意力涣散的影响被外部文件抵消了一部分。

如果你想把它用到长期编码或 Agent 场景,建议配合 Coding Plan 一起用,入口在 https://taotoken.net/api 对应的 coding-plan 页面,适合需要连续多轮、跨会话的任务。验证模型本身的能力,可以直接去模型对话页面试;接入和排障的细节,看接入文档和 API Keys 页面就够了。

最后留一个我自己的经验:todo_write 的提醒频率别设太密。三轮一次是原项目的选择,我试过每轮都提醒,token 消耗翻倍,效果提升有限。另外,todo 文件建议加个时间戳字段,方便你回溯模型是什么时候改的清单,排查「它为什么突然换任务」这类问题时特别有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询