Agent 为什么会"跑偏"?一个 todo 工具的神奇效果

0 阅读8分钟

你有没有发现:让 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_addtodo_completetodo_remove 这些命令吧?

// ❌ 错误思路:逐条操作
todo_add("读文件A")
todo_add("读文件B")
todo_mark_complete(1)
todo_mark_in_progress(2)
todo_remove(3)

这看起来很自然。但问题是:

  1. 模型需要记住每个 item 的 id
  2. 模型需要记住之前有多少条
  3. 模型容易记错、漏掉

更好的设计:整份重写。

// ✅ 正确思路:整份重写
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

组件s02s03
工具数量45(多了 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 不需要"大脑外挂"。 它只需要一块外显的状态板。 把"正在做什么"写出来,模型就不会忘。


下一步

想继续深入?

  1. 阅读源码src/planning/todo.ts 只有 80 行
  2. 动手改造:尝试调整提醒阈值(改成 2 轮或 5 轮)
  3. 阅读 s04:看看子代理如何获得干净的上下文

项目地址:github.com/OPBR/build-…

🚀 写在最后

本文是 《从 0 到 1 构建 Claude Code》 系列专栏的第二篇。我们将持续深度拆解 Agentic Programming 的核心机制。公众号合集

如果你对 LLM 原生开发、TypeScript 架构设计 感兴趣,欢迎关注我的公众号,我们一起在 AI 时代完成技术进化。

小木樱桃公众号
长按二维码关注:小木樱桃

你在使用 Agent 时遇到过"跑偏"的问题吗?评论区聊聊!

相关阅读