# Step 37: TodoWrite 工具

一句话导读:给模型一个 TodoWrite 工具,让它把复杂多步任务拆成一张可勾选清单、边做边更新——对模型是外部工作记忆(防止「走着走着忘了还有哪几步」),对用户是实时进度面板。技术看点是「无状态工具如何写共享状态」。


# 一、这一步做了什么(What)

新增内置工具 TodoWrite。模型传入一个完整的待办数组,系统:

  1. 轻校验(必须是数组、每项有 content 和合法 status);
  2. 把待办写进 AppState.todos(step35 的 store);
  3. 返回一句摘要给模型(「已更新待办:完成 1 / 进行中 1 / 待办 1」),并在终端渲染成面板。

数据模型三态:

type TodoStatus = "pending" | "in_progress" | "completed";
interface Todo {
  content: string;      // 任务描述(祈使句,如"修复登录 bug")
  status: TodoStatus;
  activeForm?: string;  // 进行时描述(如"正在修复登录 bug")
}
1
2
3
4
5
6

面板:☑ 完成(绿)、▶ 进行中(黄,显示 activeForm)、☐ 待办(灰),顶上一行 待办(2/5 完成)。


# 二、面试官视角:为什么要做?(Why)

面试题:模型自己在脑子里(上下文里)记着要做哪几步不就行了?为什么要额外给它一个 TodoWrite 工具把清单写出来?

因为「记在上下文里」在长任务里会悄悄退化,而 TodoWrite 把它变成显式、外部、可校验的状态:

  1. 对抗遗忘与漂移:多步任务里,早期规划的步骤会随着对话变长被后续工具输出「稀释」,模型容易漏做或重复做。把清单写成结构化数据、每轮回显,等于给模型一份始终在场的检查表,它每次决策前都能对照「我到哪一步了」。
  2. 单一进行项 = 强制串行专注:规则「同一时间只有一项 in_progress」逼模型一次只推进一件事、做完再开下一件,避免它同时铺开五件事然后全都半途而废。
  3. 用户可观测:Agent 干长活时最怕「黑箱转圈」。进度面板让用户实时看到「拆成了几步、做到哪了」,把不确定的等待变成有进度条的等待。

一句话:TodoWrite 是给模型的 working memory,也是给用户的 progress bar——同一份数据,两个受众。


# 三、原理:它是怎么工作的(How)

# 核心难题:无状态工具怎么写共享状态?

Bash、Read 这些工具都是无状态对象——execute(input) 进、结果出,不碰任何全局状态。但 TodoWrite 要写「共享待办」,存哪?如果让它直接 import 那个 AppState 单例,工具就和全局状态耦死了,没法测、没法复用。

解法是工厂函数 + 依赖注入:把「写到哪」作为一个回调注进来。

export function createTodoWriteTool(onUpdate: (todos: Todo[]) => void): ToolDef {
  return {
    name: "TodoWrite",
    // ...inputSchema...
    async execute(input): Promise<ToolResult> {
      const todos = (input?.todos as Todo[]) ?? [];
      if (!Array.isArray(todos))
        return { content: [{ type: "text", text: "todos 必须是数组" }], isError: true };
      for (const t of todos) {            // 轻校验:必须有 content + 合法 status
        if (!t.content || !["pending","in_progress","completed"].includes(t.status))
          return { content: [{ type: "text", text: "每项待办需要 content 和合法 status" }], isError: true };
      }
      onUpdate(todos);                     // 写到哪,工具不关心
      return { content: [{ type: "text", text: summarize(todos) }] };
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

index.ts 注入时才把它接到 store:

registry.register(createTodoWriteTool((todos) => { state.todos = todos; }));
1

工具本体依旧无状态——它只校验入参、调 onUpdate,待办实际存在 AppState.todos 里。存储位置是外部注入的依赖,工具对此一无所知。

# 数据流

AI 调 TodoWrite({ todos:[...] })
  → execute 校验 → onUpdate(todos) → state.todos 被整体替换
  → 返回摘要文本给模型("已更新待办:完成1/进行中1/待办1")
  → tool_result 事件到达 index.render()
  → 检测到 name==="TodoWrite" → 打印待办面板
1
2
3
4
5

# 提示词即约束:行为不靠代码强制,靠 description 约定

工具没有用代码强制任何流程,而是把规则写进 description 让模型自觉遵守:

管理任务待办清单。每次调用传入完整的待办数组(会整体替换旧清单)。规则:同一时间只把一项标为 in_progress;完成一项就立刻标 completed 再开始下一项。简单的单步任务不必使用。

「整体替换」这个语义是关键设计:模型每次发全量清单,服务端无脑覆盖 state.todos,省掉了增/删/改的同步协议——没有「按 id 更新第 3 项状态」这种容易错位的增量操作,也就不会有状态不一致。


# 四、深入追问(面试常见 follow-up)

Q:为什么用「工厂函数」而不是让工具直接 import AppState 单例? A:直接 import 会让工具和某个具体全局状态耦死——换个存储(比如存数据库、存另一个会话)就得改工具源码,测试时也没法注入假 store。工厂函数把「存哪」抽象成 onUpdate 回调注进来,工具保持纯粹:它只知道「有人要收这份 todos」,不知道收去了哪。这和 step35 命令的依赖注入是同一套思路。

Q:为什么选「全量替换」而不是「增量更新」?增量不是更省 token 吗? A:增量协议要设计一套「add/update/delete + 定位(id 或 index)」的语义,模型一旦发错定位或漏发一步,服务端状态就和模型认知错位,且很难自愈。全量替换用一点 token 冗余换来绝对的一致性:模型每次发它心里完整的清单,服务端状态永远等于模型认知,不存在同步问题。对 LLM 这种「偶尔会算错」的客户端,简单健壮 > 精打细算。

Q:content 和 activeForm 为什么要分两个字段? A:content 是祈使句(「修复登录 bug」),适合在 pending/completed 态展示;activeForm 是进行时(「正在修复登录 bug」),在 in_progress 态展示更自然。渲染时按状态选字段:进行中用 activeForm,其余用 content。这是很小的体验打磨,但让面板读起来像人话。

Q:既然规则「同时只一项 in_progress」没有代码强制,模型不遵守怎么办? A:确实没有硬约束——这是用提示词约束行为的典型取舍。代码只做「结构合法性」的轻校验(是数组、有 content、status 合法),流程性规则(单一进行项、完成即勾)交给 description 引导。好处是灵活、实现简单;代价是依赖模型配合。对「工作记忆」这种辅助性功能,模型偶尔违反规则的后果很轻(面板难看一点),不值得为它写复杂的状态机强制。


# 五、设计权衡

  • 代码校验 vs 提示词约束:结构性错误(不是数组)会让下游崩,必须代码硬校验;流程性规则(单一进行项)违反了顶多面板难看,交给提示词更划算。按「违反后果的严重度」决定用硬校验还是软约束。
  • 工具返回摘要而非完整清单:execute 回给模型的是一句统计摘要,不是把整个 todos 数组回显——模型自己刚发的清单,不必再喂回去占 token,回一句「收到,现状 X」即可。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
createTodoWriteTool(onUpdate) 工厂注入 真实的 TodoWrite 工具(同样能访问会话状态)
Todo 三态 + activeForm 真实版同样的 status / activeForm 字段
全量替换语义 真实版同样「每次传完整列表替换」
待办存 AppState.todos 真实版存在 AppState store
终端面板渲染 真实版有专门的 todo UI 组件

# 七、一句话总结

TodoWrite = 给模型的外部工作记忆 + 给用户的进度面板:用「工厂函数注入 onUpdate」解决无状态工具写共享状态、用「全量替换」免掉增量同步协议、用「description 约定」而非代码强制流程规则——一个小工具,把依赖注入、协议简化、软硬约束取舍三个模式讲透了。

# 下一节预告

有了命令框架、hooks、待办,下一步是 Phase 4 的智力重头戏——step38 子代理 AgentTool:fork 一个子 QueryEngine 去跑子任务,把中间过程隔离在沙箱里。