你有没有发现:让 AI Agent 做简单任务,它表现很好。但一旦任务变复杂,它就开始"漂"。
明明已经检查过文件 A,过了一会儿又重新检查一遍。明明列好了五个步骤,执行到第二步就忘了后面还有三步。
为什么?
今天,我用一个 todo 工具教你解决这个问题。读完这篇,你会明白:Agent 不需要"大脑外挂",它只需要一块外显的状态板。
一个真实的痛点
上一版s02(为什么核心循环只有 20 行?)的 Agent 已经有 4 个工具了:bash、read_file、write_file、edit_file。看起来很强大。
但当我们给它一个多步任务:
s02 >> 分析 src/core 目录下的所有文件,找出可能的性能问题,写一份报告
你会看到这样的现象:
❌ 模型开始分析 agent-loop.ts
❌ 分析完了,开始分析 tools.ts
❌ 分析完了...突然又回去分析 agent-loop.ts
❌ 最后写的报告漏了好几个文件
模型"忘记"了它在做什么。
根本原因:隐性状态 vs 显性状态
用一个类比来理解:
隐性状态:你脑子里记住要买的东西。走着走着,忘了一半。
显性状态:你手里拿着一张清单。走一步,勾一项。不会忘。
模型脑内:想做的事(隐性、易遗忘)
↓
系统状态:todo 列表(显性、可追踪)
s03 的核心,就是把"正在做什么"从模型脑内移到系统可观察的状态里。
这不是替模型思考。是把模型在想的事写出来。
s03 的设计思路
数据结构
只需要两个简单的类型:
interface TodoItem {
content: string // 这一步要做什么
status: 'pending' | 'in_progress' | 'completed'
activeForm?: string // 进行时的描述(可选)
}
interface PlanningState {
items: TodoItem[]
roundsSinceUpdate: number // 多轮没更新了
}
activeForm 是一个很有用的字段。当某个步骤 in_progress 时,模型可以用它描述"正在做什么动作":
[>] 分析 tools.ts 的性能问题 (正在读取文件内容)
这比干巴巴的 [>] 分析 tools.ts 更有帮助。
一个关键约束
同一时间,最多一个 in_progress
这不是硬性规则,而是教学约束:强制模型聚焦当前一步。
为什么?因为如果允许多个 in_progress,模型容易"贪多嚼不烂",同时推进好几件事,最后哪件都没做好。
整份重写:为什么不是逐条操作?
很多人直觉上会想:应该有 todo_add、todo_complete、todo_remove 这些命令吧?
// ❌ 错误思路:逐条操作
todo_add("读文件A")
todo_add("读文件B")
todo_mark_complete(1)
todo_mark_in_progress(2)
todo_remove(3)
这看起来很自然。但问题是:
- 模型需要记住每个 item 的 id
- 模型需要记住之前有多少条
- 模型容易记错、漏掉
更好的设计:整份重写。
// ✅ 正确思路:整份重写
todo({
items: [
{content: "读文件A", status: "completed"},
{content: "读文件B", status: "in_progress"},
{content: "写报告", status: "pending"}
]
})
模型只需要把"当前想要的状态"一次性发出来。不需要记 id,不需要记历史。
简单,就不会错。
TodoManager 类的核心方法
class TodoManager {
private state: PlanningState
// 1. 更新计划(模型整份重写)
update(items: TodoItem[]): string {
// 验证:最多 12 条
// 验证:最多一个 in_progress
// 更新状态
// 重置 roundsSinceUpdate = 0
// 返回渲染文本
}
// 2. 记录一轮没更新
noteRoundWithoutUpdate(): void {
this.state.roundsSinceUpdate++
}
// 3. 是否需要提醒
reminder(): string | null {
if (rounds >= 3 && items.length > 0) {
return "<reminder>Refresh your current plan before continuing.</reminder>"
}
return null
}
// 4. 渲染为可读文本
render(): string {
// [ ] pending item
// [>] in_progress item (activeForm)
// [x] completed item
// (2/5 completed)
}
}
四个方法,职责清晰:
| 方法 | 什么时候调用 | 做什么 |
|---|---|---|
update() | 模型调用 todo 工具 | 验证、更新、重置计数 |
noteRoundWithoutUpdate() | agent-loop 每轮结束 | 计数 +1 |
reminder() | agent-loop 检查是否提醒 | 返回提醒文本或 null |
render() | update 内部调用 | 生成可读的计划展示 |
提醒机制:三轮不更新就"轻推"
模型可能专注于执行,忘了更新计划。这时候需要提醒。
时序图:
轮次 模型行为 roundsSinceUpdate 提醒?
─────────────────────────────────────────────────────
1 调用 todo 创建计划 0 否
2 执行工具,没更新计划 1 否
3 执行工具,没更新计划 2 否
4 执行工具,没更新计划 3 是!
5 模型看到提醒,调用 todo 0 否
为什么是 3 轮?不是 1 轮或 10 轮?
- 1 轴太敏感:每轮都提醒,模型会很烦
- 10 轮太宽松:已经漂得很远了
- 3 轮是一个平衡点:给模型一些执行空间,但不会让它完全走偏
agent-loop 的改动
s03 的改动非常小。agent-loop 只加了这几行:
// 每轮结束后:
if (todoManager) {
if (usedTodo) {
// 使用了 todo,重置计数
// (TodoManager.update 已经重置了 roundsSinceUpdate)
} else {
todoManager.noteRoundWithoutUpdate()
const reminder = todoManager.reminder()
if (reminder) {
results.unshift({ type: 'text', text: reminder })
}
}
}
注意:if (usedTodo) 的分支是空的。这不是 bug。
原因是重置逻辑已经在 update() 里做了:
this.state.roundsSinceUpdate = 0 // 在 update() 里重置
空分支的存在是为了可读性:告诉读者"这个情况我们考虑过了,只是处理逻辑在别的地方"。
todo 工具的定义
{
name: 'todo',
description: 'Rewrite the current session plan for multi-step work.',
input_schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
content: { type: 'string' },
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
activeForm: { type: 'string' },
},
required: ['content', 'status'],
},
},
},
required: ['items'],
},
}
关键是 description:它告诉模型"这是用来重写计划的",而不是"添加一条任务"。
模型看到这个描述,就会自然地一次性发送完整列表。
调用链:update() 是怎么被触发的?
很多人困惑:update() 在哪里被调用?
不是直接调用。是通过 dispatch map 间接调用。
模型调用 todo 工具
↓
agent-loop 从 handlers 查找 'todo'
↓
找到 createTodoHandler 返回的函数
↓
这个函数内部调用 manager.update()
代码追踪:
第一步:todo.ts 创建 handler:
export function createTodoHandler(manager: TodoManager) {
return (input: Record<string, unknown>): string => {
const items = input.items as unknown[]
return manager.update(items) // ← 这里调用 update()
}
}
第二步:s03-todo-write.ts 注册 handler:
const S03_HANDLERS = {
...BASE_HANDLERS,
todo: createTodoHandler(todoManager), // ← 'todo' 对应这个 handler
}
第三步:agent-loop.ts 执行 handler:
const handler = handlers[toolBlock.name] // 查表
const output = await handler(toolBlock.input) // ← 执行
这就是 dispatch map 模式:工具名 → handler → 内部调用具体方法。
动手试试
运行 s03:
pnpm run s03
你会看到:
╔════════════════════════════════════╗
║ s03 - TodoWrite ║
║ "No plan, agent drifts" ║
╚════════════════════════════════════╝
Tools: bash, read_file, write_file, edit_file, todo
s03 >> 分析 src/core 目录下的所有文件,找出可能的性能问题
> todo
[ ] 分析 agent-loop.ts
[>] 分析 tools.ts (正在读取文件)
[ ] 分析 types.ts
[ ] 写一份报告
> read_file
...读取 tools.ts 的内容...
> todo
[x] 分析 agent-loop.ts
[x] 分析 tools.ts
[>] 分析 types.ts (正在读取文件)
[ ] 写一份报告
模型每做完一步,就更新一次计划。不会漏,不会重复。
对比 s02 和 s03
| 组件 | s02 | s03 |
|---|---|---|
| 工具数量 | 4 | 5(多了 todo) |
| 计划状态 | 无(隐性) | TodoManager(显性) |
| 多步任务 | 容易漂 | 保持聚焦 |
| 提醒机制 | 无 | 3轮不更新就提醒 |
s03 不改变循环。只加了 todo 工具和 TodoManager。
FAQ
Q:为什么不把 todo 做成持久化任务系统?
A:s03 是会话内轻量计划。s12 才是持久化任务系统。
混淆这两者会让初学者迷失方向。s03 的目的是"帮助模型聚焦下一步",不是"跨会话管理任务"。
Q:最多一个 in_progress 是硬性规则吗?
A:不是。是教学约束。真实产品(Claude Code)允许多个 in_progress。
但我们用这个约束来强化"聚焦一步"的理念。学会了之后,可以放宽。
Q:提醒为什么插入到 results 开头?
A:因为提醒要先于其他工具结果被模型看到。
用 results.unshift() 把提醒放在数组开头,模型在下一轮最先看到提醒。
Q:activeForm 是必须的吗?
A:不是。它是可选的。但加上它能让模型更清楚地描述"当前动作",用户体验更好。
小结
今天我们实现了:
- ✅ 理解了 Agent "跑偏"的根本原因
- ✅ 设计了 TodoManager 的数据结构
- ✅ 理解了"整份重写"比"逐条操作"更简单
- ✅ 理解了三轮提醒机制的触发逻辑
- ✅ 跑起来了一个有规划能力的 Agent
关键洞察:
Agent 不需要"大脑外挂"。 它只需要一块外显的状态板。 把"正在做什么"写出来,模型就不会忘。
下一步
想继续深入?
- 阅读源码:
src/planning/todo.ts只有 80 行 - 动手改造:尝试调整提醒阈值(改成 2 轮或 5 轮)
- 阅读 s04:看看子代理如何获得干净的上下文
🚀 写在最后
本文是 《从 0 到 1 构建 Claude Code》 系列专栏的第二篇。我们将持续深度拆解 Agentic Programming 的核心机制。公众号合集
如果你对 LLM 原生开发、TypeScript 架构设计 感兴趣,欢迎关注我的公众号,我们一起在 AI 时代完成技术进化。
![]()
长按二维码关注:小木樱桃
你在使用 Agent 时遇到过"跑偏"的问题吗?评论区聊聊!
相关阅读:
- 30分钟手写一个 AI Agent:揭秘 Claude Code 的核心循环 - 核心循环
- Agent 为什么会"跑偏"?一个 todo 工具的神奇效果 - 工具扩展
- Claude Code 官方文档