把 Claude Code 当作日常开发伙伴之后,我最先想明白的一件事是:它带来的绝大部分翻车事故,不是能力不够,而是没有计划。前两周我在一个内部工具库里做一次跨五个模块的重构,Claude 在第三个模块上已经把接口风格从“统一返回 Result”悄悄改成了“直接抛异常”,代码写完跑起来一看,完全偏离了最初定下的兼容约束。那一次我损失了大半天,也记住了 TodoWrite 这个工具。learn-claude-code 系列写到 S03,这一期专门拆 TodoWrite——一个看起来只是“列待办”的工具,实际是 Agent 保持方向感的底层机制:它把模糊目标变成显式清单,把长对话容易丢失的目标重新固定到上下文里。这篇文章适合三类人:刚开始用 Claude Code 做项目的新手、被长任务跑偏坑过的开发者、以及想在团队里推广 Agent 工作流的人。
1. 迷失方向不是玄学:Agent 为什么要靠一张任务清单兜底
1.1 那次跑偏事故:代码越写越顺,方向越走越远
我接到的是一个很常见的活儿:把项目里的配置加载逻辑拆成独立模块,保持对外接口不变,同时让新模块可以被单元测试直接覆盖。任务本身不复杂,但是涉及 config、loader、watch、cli、plugin 五个模块的联动。一开始 Claude 的表现很不错,它先扫描了所有相关文件,读完目录结构之后给出了实施方向。
如果没有 TodoWrite,接下来就是事故现场。它在改到第三个模块 loader 的时候,为了代码“更优雅”,擅自引入了新的错误处理约定,把原来散落在各个模块的 Result 风格统一换成了异常抛出。单独看每一步,代码都没问题,甚至很漂亮;但合在一起,整个 cli 模块原本依赖的错误捕获逻辑全乱了。
问题不在 Claude 不够聪明,而在于它把“让 loader 更优雅”这个子目标,当成了整个任务的最终目标。这种局部最优吞噬全局目标的现象,我在长任务里见到过很多次,而且越复杂的任务越明显。
1.2 上下文窗口与注意力漂移:初始目标会被淹没
这个现象背后有 LLM 注意力机制的影子。在长会话里,模型对上下文的注意力并不是均匀分配的,更靠近末尾的对话和最近的工具执行结果往往权重更高。早期会话里那句“保持对外接口不变”,在几百行工具输出之后,已经很难对每一步决策形成足够的约束。
做过搜索系统或者推荐系统的读者应该很好理解:这是一次典型的“注意力漂移”。把目标写在对话开头,就好比把一张便签贴在房间进门处,人在客厅待久了根本看不见。Agent 也一样,它不会凭空记住一个写在很久之前的约束,除非这个约束被反复强化。
这就是任务规划工具的切入点:把目标拆成清单,变成可以随时复查的显式状态。TodoWrite 每一次被调用,都相当于把这张便签重新移到 Agent 眼前,让它在做下一步决策前先对一下答案。
1.3 计划不是给 Agent 的“紧箍咒”,是给它的一副“导航路书”
有人可能会担心:给 Agent 列计划是不是限制了它的灵活性?我一开始也这么想,后来发现恰恰相反。没有清单的时候,Claude 确实“自由”,但这种自由是它被局部目标牵着走的自由;有了清单之后,它在步骤层面仍然可以自由选择实现方式,只是不再允许偏离全局方向。
这有点像给司机配导航:导航不会规定你每个路口怎么踩油门,但它会在你走错方向时提醒你。计划真正兜住的,是 Agent 的全局一致性。
所以我把 TodoWrite 在项目里的定位理解为一副路书——它把“我们最终要到哪”写进了 Agent 手里,每一步决策都能回到这个列表上对答案。有了路书,Agent 才能真正放心大胆地走,因为它知道自己不会因为走太远而迷路。
2. TodoWrite 的机制拆解:它能记住什么,又记不住什么
2.1 工具调用长什么样:title 与 todos 两个核心参数
在 Claude Code 的日常交互里,我通常不会手写 TodoWrite 的原始调用,Claude 会在合适的时机自动调用它。但要想高效使用,还是得看懂它的入参格式。从底层看,它是 Claude Code 提供的一个工具调用(function call),核心参数有两个:title 和 todos。
title 是这张清单的名字,用于在上下文里快速定位这张表;todos 是一个字符串数组,每一项是一条待办。Claude 的标记规范里支持 checkbox 语法,未完成的用- [ ],完成之后改成- [x]。我平时在日志里看到的调用大概长这样:
{ "tool": "TodoWrite", "title": "重构 config 模块并保持对外接口兼容", "todos": [ "- [ ] 梳理 config 模块现有接口与调用方", "- [ ] 设计新模块目录结构与导出入口", "- [ ] 迁移 loader 中的配置读取逻辑", "- [ ] 补齐 watch 模块对配置变更的订阅", "- [ ] 编写并跑通全量回归测试" ] }这里需要特别提醒一点:todos 里的每一项,本身应该是一句可以明确判断“完没完”的话,而不是一个模糊的动作。“迁移 loader 中的配置读取逻辑”比“优化 loader”强得多,因为前者完成与否可以立刻验证。
2.2 它不是数据库,而是上下文里的状态对象
TodoWrite 最关键的性质,是它把清单作为一份动态状态,写进了 Claude 的对话上下文。Claude 在后续执行中每完成一项,就会再次调用 TodoWrite,把对应项的- [ ]改成- [x],然后再继续下一步。
也就是说,Claude Code 的规划能力并不来源于任何外部数据库,而是通过反复把这个最新清单展示在上下文里,让后续决策随时有机会回到这份路书上来。理解了这一点,也就理解了它的边界:清单不会持久化。你退出终端,再开一个新会话,上次的清单就没了;它也不会被写进项目的某个文件,除非你自己重定向输出或者用笔记记录。
它更不是项目管理工具的替代品。别指望用 TodoWrite 管理跨多天的复杂项目,它活在会话里,服务的是单次对话中的执行一致性,不是跨会话的工程管理。
2.3 自动触发与主动干预:别把规划完全交给 Agent
Claude Code 本身有较强的任务规划自觉性,遇到多步骤任务时通常会主动调用 TodoWrite。但我在实际使用中发现,自动触发的清单质量并不稳定:有时候它把任务拆得过细,二十几个待办塞满上下文;有时候又拆得太粗,一个“处理配置兼容”直接涵盖了五个不同的约束。
因此我更倾向于在任务开头就把规划要求讲清楚。比如我会在提示词里写:“请先阅读仓库结构,然后用 TodoWrite 建立 5 到 8 项任务清单,每项必须包含可验证的完成标准。”这比事后修正清单要省得多。
主动干预的另一个手段是终端里的 /todo 命令(具体命令名以你当前 Claude Code 版本为准,可以用claude --help确认)。它可以把我当前上下文里的任务清单打印到屏幕上,让我在对话任何时刻直接检查 Claude 的理解是否还在正轨上。发现问题就当场让它改,不要等代码写完了再纠偏。
3. 实战复盘:一次完整重构任务中 TodoWrite 是怎么被用起来的
3.1 任务背景与初始清单的建立
为了把前面的原理讲透,我拿一个最近真实跑过的任务做完整复盘。项目代号就叫 internal-cli,是一个内部命令行工具,功能比较简单:读取配置文件、监听文件变更、触发插件化的任务流。这次任务的目标是把配置加载逻辑抽成独立模块,并保持对外接口完全不变。
我拿到需求之后的第一件事,不是让 Claude 直接开干,而是先让它读了项目根目录的 README 和 package.json,搞清楚模块边界,然后明确拒绝了它一开始看似周到的方案,因为那个方案引入了新的配置格式。
在我把“不要引入新配置格式”这个约束写进提示词之后,Claude 重新调用了 TodoWrite,任务清单如下:
{ "tool": "TodoWrite", "title": "抽取配置模块:internal-cli 重构计划", "todos": [ "- [ ] 盘点所有引用 config 加载逻辑的文件", "- [ ] 在 src/config 目录下建立新模块并迁移基础加载函数", "- [ ] 保持原入口文件导出名称不变,更新内部引用路径", "- [ ] 接入 watch 模块并验证热更新逻辑不受影响", "- [ ] 编写覆盖新旧两套路径的回归测试用例", "- [ ] 跑通 lint、typecheck 与全量测试" ] }注意这个清单里没有任何一项是“重构配置模块”这种大而空的说法,每一项都能对应到一份文件、一个行为或一个验证动作。
3.2 执行中清单的动态更新:何时新增、何时勾掉
整个任务全程,Claude 一共调用 TodoWrite 七次。第一次建立清单,随后每完成一块就更新一次。真正值得展开的是第三次和第五次的更新。
第三次更新发生在迁移基础加载函数时。Claude 发现 loader 里有一段旧的环境变量合并逻辑,被全局共享,如果直接迁移会影响 watch 模块。如果按原始清单硬着头皮做完,这个隐患会在最后一步测试时才暴露。但因为清单写得足够细,它在执行当前项时立刻发现了边界问题,于是没有把它当成一个“临时想法”随手处理,而是先调用了一次 TodoWrite,在清单里追加了一项:- [ ] 拆分环境变量合并逻辑并单独验证兼容行为。
这一步很关键——不是阻止 Agent 发现问题,而是让新发现的问题进入显式规划,而不是变成一次临时起意的代码改动。第五次更新在测试环节。两个模块的新旧路径测试都通过后,Claude 把对应几项全部改成了- [x],但没有一次性把所有项都勾掉,而是保留了最后一项“跑通 lint、typecheck 与全量测试”作为收尾检查点。
这种“最后一个检查点不提前勾掉”的习惯,我后来也沿用了下来。清单里的最后一项往往承担着对齐全局质量的责任,提前勾掉等于默认路径畅通,这在重构任务里是很危险的假设。
3.3 同样任务,没有清单时会发生什么
为了说明差异,我在同一个仓库里做过对照实验:不写规划要求,直接把任务描述丢给 Claude。结果是,它在一开始也生成了一个类似 TodoWrite 的清单,但因为我没有强调约束,这个清单在第二次更新时就被悄悄改掉了——原来的“保持对外接口不变”消失,取而代之的是几个“优化调用方式”。
这就是我反复提到的局部目标吞噬全局目标的过程。对照实验当然不等于完全科学的结论,但至少能说明:如果 Agent 的清单本身就是漂移的,它做出来的结果也必然漂移。TodoWrite 提供的不是一个静态的文件,而是一条时时更新的约束流。
它之所以有效,是因为它把最初的目标反复拉回到当前决策点前面,让漂移很难跨过步骤之间的缝隙。
4. 拆解任务的方法论:我是怎么把一个模糊需求变成可勾选清单的
4.1 先定检查点,再拆动作
很多人在让 Agent 列计划时,下意识列出的是一堆动作:“读取文件”“重写函数”“跑测试”。这些不是目标,只是路径。我自己摸索出的顺序是先定检查点:这个任务完成到哪一步,我可以判断“它已经做完了”。
把这些可验证的检查点找出来,再去补充每个检查点之间的动作。比如“抽取配置模块”这个任务,检查点依次是:所有引用路径已更新、旧接口行为不变、测试覆盖新旧两条路径、全量测试通过。每个检查点本身就是一个清单项。
动作细节不需要进清单,那是执行阶段分布的视角,清单只需要勾勒出这一路的里程碑。这个习惯帮我解决了一个长期困扰:任务描述一旦超过三句话,我自己都会忘记当初想让 Agent 做什么,更别提要求它保持方向了。
4.2 每一项都要自带“完成标准”
模糊动词是任务清单失效的第一原因。“优化”“调整”“完善”“重构”,这些词本身没有完成边界,Agent 做多做少都无法判断,结果就是它什么时候收手完全看心情。我在列清单时会强迫自己把每个动词改成可以验证的表述,常见做法是把动词换成“盘点、迁移、替换、验证、跑通”这类带明确动作对象的词,并在末尾补上约束对象。
给大家一个直观的对比表:
| 模糊清单项 | 可验证清单项 |
|---|---|
| 优化 loader 逻辑 | 迁移 loader 配置读取逻辑,保持现有接口签名不变 |
| 处理配置兼容 | 盘点所有旧配置字段的解析路径,并补齐新模块映射 |
| 完善测试 | 编写覆盖新旧两套配置路径的回归用例 |
| 重构 watch 模块 | 接入新模块,并验证文件变更能正常触发订阅回调 |
这张表背后真正的原则是:清单的每一项都应该能在十分钟之内被检验完成状态。如果一个清单项需要你坐下来思考良久才能判断做没做完,那它就还是太大,需要继续拆。
4.3 任务粒度与清单长度的经验值
拆到什么程度最好?我根据这段时间的实测给出一个可参考的经验区间:一个普通规模的功能任务或重构任务,清单控制在 5 到 10 项。少于 5 项,说明内部大概率藏着大石头;多于 10 项,清单本身就会变成新的认知负担,同时也会消耗更多上下文窗口。
单看一项的粒度,理想情况是让 Claude 花几分钟到半小时完成,最长不超过一两个小时。如果一项任务连续执行半小时还没有任何进展反馈,说明它应该被拆成两项甚至三项。
这不是一个硬性公式,不同任务的颗粒度差异很大,但可以作为日常使用时的一个自查标准。我自己每次拿到新任务,都会先在心里过一遍这个数:计划里有没有超过十项的大清单?有没有一项能撑半天的巨型任务?有一项,就说明规划环节还没做到位。
5. 实测中踩过的坑:TodoWrite 不是万能的
5.1 清单膨胀:已完成项也会反向污染判断
TodoWrite 每次更新都会把整张清单完整写回上下文,这意味着清单越长,每次更新消耗的 token 越多,也更挤占上下文空间。我在一次跨模块改造中发现,Claude 到后半程开始频繁调用文件搜索,而不是直接根据已有信息判断,一个重要原因就是前面累积了二十多项的历史待办,把关键信息挤得太散。
解决方式很简单:定期重写清单。每当任务完成到一个阶段,我会要求 Claude 把已完成的大块任务合并成一行历史记录,然后只保留当前阶段需要盯着的待办。保持清单在 8 项以内,往往比贪心记录所有历史更有效。
这里有个细节:不需要每次更新都全量重写,只要在感觉上下文变臃肿时做一次“瘦身”即可。过度频繁地重写清单,反而会把 Claude 的注意力从实现引到规划本身上。
5.2 把它当成需求文档:细节全部塞进去是个误区
有人喜欢在 TodoWrite 里写很长的描述,把约束、背景、验收条件全部塞进一个清单项,结果是清单读起来像一篇小作文。其实 TodoWrite 应该只负责“接下来要做哪几件事”,至于这件事的具体约束和背景,有两种更好的放置方式:
一是写在项目代码周围的注释和说明里,让 Claude 在做文件操作时自然读到;二是放在对话的上下文里,作为清单项的补充描述。清单一旦承担了所有信息携带的任务,就会变得笨重,Claude 每次思考都要从长篇细节里重新提取关键点,反而容易漏掉真正的约束。
我在早期犯过这个错:一次数据库迁移任务里,某个清单项写了近百字,包含连接池配置、超时时间、重试规则。结果 Claude 在执行时只记住了其中一条,其他全被忽略。从那以后,我的清单项永远控制在二十字以内,长约束一律放正文。
5.3 多会话场景下的清单延续问题
Claude Code 支持对话续接,但 TodoWrite 的清单并不会跨会话保留。如果你开了一个新会话,想继续昨天的重构任务,直接说“继续上次的工作”,Claude 往往只能根据 git 历史和你贴出来的信息重新理解目标。
此时最好在最开始时明确要求它:先阅读仓库状态,再重建一份与当前进度一致的 TodoWrite 清单。跨天执行长任务时我还有个习惯:结束会话前,让 Claude 把当前清单以纯文本形式输出到终端,我自己复制到项目根目录的一个 todo.md 里。
第二天新会话开始时,把这份 todo.md 的内容粘回去,让它在此基础上继续。用文件作为持久化层,恰好补上了 TodoWrite 不落盘的短板。这个方法也方便团队协作:另一个同事接手时,第一眼就知道这个任务当前卡在哪一步。
5.4 计划先行并不等于不再调整:清单要敢改
最后一个坑,是我自己最容易犯的:过度相信初始计划。TodoWrite 的价值在于让目标显式化,但它并不神圣。执行中一旦发现新的约束或更好的技术路径,应该及时要求 Claude 更新清单,而不是为了保持“计划完整”而强行走完原本的步骤。
有一次重构中,Claude 提出可以复用仓库里某个闲置工具函数,避免额外引入依赖。如果我不让它改清单,原本计划中“新增一个 helper 模块”这一项就会变成无效工作。让 Agent 修改清单,和让 Agent 死守清单,是两种不同的使用方式。
前者是把它当协作者,后者是把它当执行器。至少在 TodoWrite 这个工具的设计哲学里,前者明显更贴合它的能力边界。计划是拿来用的,不是拿来供着的;该增就增,该并就并,只要最终目标还是那个目标就行。
最后说点我现在的使用习惯。每次把新任务交给 Claude Code 前,我都会先自己花两分钟在脑子里过一遍检查点,然后把“请建立 5 到 8 项可验证的 TodoWrite 清单”直接写进任务描述。这不是不相信它的规划能力,而是让规划一开始就经过人的经验校验。这一期 S03 写到这里,下一期我打算聊聊 Claude Code 的上下文管理策略——也就是当项目大到你明显感觉它“够不着”时,应该怎么喂信息。欢迎有同样实践经验的读者来评论区交流各自的清单模板,互相抄抄作业。