Agent 开发 Todo 机制设计,让 Agent 拥有规划能力
这是 code-artisan 拆解系列第七篇,也是最后一篇。
前言
前六篇拆下来,code-artisan 已经是一个结构完整的 Coding Agent:ReAct 主循环、provider 抽象、builtin 工具、middleware 系统、sandbox 接口、skills 机制。但它一直有一个形态上的局限:接到任务就一口气干到结尾,中间不回头看自己走到哪了。
短任务这样没问题。可一旦任务稍微复杂一点,比如"重构这个模块"、"加一个有 5 个 API"、"把这套测试补全",一口气干就会暴露三个问题:
- 跑偏:步骤多了之后,LLM 容易忘记最初的某个要求,或者在某个分支上越走越远
- 漏步骤:5 件事干到第 3 件,很自然地以为"差不多了"就收尾,剩下两件被丢掉
- 进度不可见:调用方(前端、CLI)只能看到一条条工具调用,没有一个"任务清单 + 完成度"的整体视图
这一篇我们落地一个 todo 系统 :让 agent 在开工前先列一份任务清单,干活过程中实时更新每一项的状态。这件事的实现会用到一个前几篇没出现过的组合:一个工具 + 一个 middleware 成对工作。为什么必须成对,是这一篇最核心的设计点。
照例每个 Part 一份独立可讲的代码。
Part 1:为什么 todo 必须是"工具 + 中间件"
先想最直接的方案:给 agent 加一个 todo_write 工具,让 LLM 自己把任务拆成清单写进去。这一步是对的,但只有工具是不够的。
工具是被动的:LLM 想调才调。实际跑下来会可能会出现1个问题:LLM 在任务开头兴致勃勃地写了一份清单,然后就一头扎进干活,再也不回来更新它。等任务跑到一半,那份清单还停留在"全部 pending"的初始状态,跟实际进度完全脱节。清单一旦不准,就比没有还糟:调用方被它误导,LLM 自己回看时也被它误导。
所以 todo 系统需要的不只是"让 LLM 能写清单"的工具,还需要一个"盯着 LLM 别忘了更新"的角色。后者正好是 middleware 的位置:它能在每次调 LLM 前介入,观察距离上次更新过了几步,必要时往 prompt 里塞一句提醒。
于是 todo 系统的形态是一个工厂函数,同时吐出 配对的工具和 middleware:
ts
type TodoStatus = "pending" | "in_progress" | "completed";
interface TodoItem {
id: string;
content: string;
status: TodoStatus;
}
function createTodoSystem(): { tool: FunctionTool; middleware: AgentMiddleware } {
// 关键:清单状态是 tool 和 middleware 共享的闭包变量
// tool 负责写,middleware 负责读 + 决定要不要提醒
let todoList: TodoItem[] = [];
const tool = defineTool({
/* ... Part 2 展开 ... */
});
const middleware: AgentMiddleware = {
/* ... Part 3 展开 ... */
};
return { tool, middleware };
}
这里的设计要点是 todoList 这份状态由工厂函数的闭包持有,工具和 middleware 各拿一半职责共享它:
tool是写入端:LLM 调todo_write时改todoListmiddleware是观察端:它读todoList判断"清单空不空"、"多久没更新了"
如果把工具和 middleware 分成两个独立模块、各自 new 一次,它们就拿不到同一份 todoList,整个机制就散了。必须是同一次 createTodoSystem() 调用产出的这一对,才共享状态。这跟前几篇的 middleware 都是"单个工厂函数返回单个 middleware"不一样,是这一篇值得单独拎出来的结构。
Part 2:todo_write 工具 - merge 语义与并发写防护
先把写入端做完。todo_write 工具接受三个参数:
ts
const TODO_WRITE_TOOL_NAME = "todo_write";
const tool = defineTool({
name: TODO_WRITE_TOOL_NAME,
description: TOOL_DESCRIPTION, // 见下文
parameters: z.object({
name: z.string().describe("这批 todo 所属计划的短标签,更新同一计划时复用同名"),
todos: z.array(
z.object({
id: z.string().describe("todo 项的唯一 id"),
content: z.string().describe("任务描述"),
status: z.enum(["pending", "in_progress", "completed"]),
}),
),
merge: z.boolean().describe("true 按 id 增量合并,false 整表替换"),
}),
invoke: async ({ name, todos, merge }) => {
invocationsThisStep++;
if (invocationsThisStep > 1) {
return CONCURRENT_WRITE_ERROR;
}
if (merge) {
// 按 id 打补丁:已存在的 id 原地更新,新 id 追加
for (const item of todos) {
const idx = todoList.findIndex((t) => t.id === item.id);
if (idx >= 0) todoList[idx] = item;
else todoList.push(item);
}
} else {
// 整表替换
todoList = [...todos];
}
stepsSinceLastWrite = 0;
return `${formatSummary(name, todoList)}\n\n${POST_WRITE_REMINDER}`;
},
});
这里有三处设计值得展开。
merge:让 LLM 只发改动项
merge=false 是整表替换,merge=true 是按 id 增量合并。看似只是个便利参数,实际是省 token 的关键。
一份 10 项的清单,干到第 6 项时,LLM 只是想把第 6 项从 in_progress 改成 completed、把第 7 项标成 in_progress。如果只有整表替换,LLM 每次都得把 10 项原样重发一遍,既费 output token,又容易在重抄的过程中手滑改错前面已经定稿的项。有了 merge=true,LLM 只发那两个变动的 id 就行,其余项纹丝不动。
经验上,开局第一次写清单用 merge=false(从零建表),之后的状态流转都用 merge=true。
并发写防护:一个 step 只认第一次
LLM 在一个 step 里是可以并行发起多个工具调用的(这一点第二篇讲 Promise.all 执行工具时就埋下了)。如果它在同一个 step 里并行调了两次 todo_write,两次调用都改同一份 todoList,最终结果取决于谁后执行,状态就乱了。
防护办法是一个计数器 invocationsThisStep:每次 invoke 进来先 ++,如果发现已经大于 1,说明这个 step 里已经有一次 todo_write 跑过了,直接返回一段错误文本,不改状态。
arduino
export const CONCURRENT_WRITE_ERROR =
"Error: todo_write 在本轮已经调用过了。多次并发调用会产生歧义状态,只有第一次生效。请把所有更新合并成下一轮的一次调用。";
注意这里没有抛异常,而是 返回一段写给 LLM 看的错误字符串。这跟第三篇定下的工具错误处理基调一致:工具层面的问题包成 tool_result 给 LLM,让它自己读懂、下一轮纠正,而不是让 run 崩掉。这个计数器什么时候清零,留到 Part 3 跟 middleware 一起讲。
返回值带上清单全貌
invoke 的返回值不是一句干巴巴的 "OK",而是 formatSummary 渲染出的整张清单:
ts
function formatSummary(name: string, todoList: TodoItem[]): string {
const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" };
const completed = todoList.filter((t) => t.status === "completed").length;
const lines = todoList
.map((t, i) => `${i + 1}. ${marker[t.status]} ${t.content}`)
.join("\n");
return `Plan "${name}" updated, ${completed}/${todoList.length} completed.\n${lines}`;
}
每次写完都把"当前完成度 + 完整清单"回灌给 LLM,等于每一次 todo_write 之后,LLM 的上下文里都有一份最新的进度快照。[ ] / [>] / [x] 三个标记也方便调用方(前端、CLI)直接把这段文本渲染成可视化的复选框列表。
Part 3:反应式提醒 - 让 LLM 不会"写完就忘"
写入端齐了,接下来是这篇最关键的部分:怎么让 LLM 持续更新清单。
前面说过 LLM 的典型毛病是"开头写一次就忘"。我们没法强制它调工具,但可以在它每次"思考前"递一张小纸条: "你的清单好像有一阵没动了,这是当前状态,看看要不要更新。" 这就是 middleware 干的事。
middleware 的三个钩子
ts
const REMINDER_CONFIG = {
STEPS_SINCE_WRITE: 5, // 距上次写超过 5 步,算"可能忘了"
STEPS_BETWEEN_REMINDERS: 5, // 两次提醒之间至少隔 5 步,别连环轰炸
} as const;
let stepsSinceLastWrite = Infinity;
let stepsSinceLastReminder = Infinity;
let invocationsThisStep = 0;
const middleware: AgentMiddleware = {
// beforeAgentStep 是第四篇结尾提到的 step 级钩子,每个 step 开头跑一次
beforeAgentStep: async () => {
invocationsThisStep = 0; // 新的一步,并发写计数器清零
},
beforeModel: async ({ modelContext }) => {
stepsSinceLastWrite++;
stepsSinceLastReminder++;
if (
todoList.length > 0 &&
stepsSinceLastWrite >= REMINDER_CONFIG.STEPS_SINCE_WRITE &&
stepsSinceLastReminder >= REMINDER_CONFIG.STEPS_BETWEEN_REMINDERS
) {
stepsSinceLastReminder = 0;
const lines = todoList
.map((t, i) => `${i + 1}. [${t.status}] ${t.content}`)
.join("\n");
return { prompt: modelContext.prompt + formatReactiveReminder(lines) };
}
},
afterToolUse: async ({ toolUse }) => {
if (toolUse.name === TODO_WRITE_TOOL_NAME) {
stepsSinceLastWrite = 0; // LLM 刚更新过,计数器归零
}
},
};
三个钩子各管一件事:
beforeAgentStep在每个 step 开头把invocationsThisStep清零。Part 2 那个并发写计数器靠它复位:一个 step 内只认第一次写,下一个 step 重新开始计。afterToolUse盯着工具调用,一旦发现 LLM 调了todo_write,就把stepsSinceLastWrite归零,表示"它刚更新过,计时重来"。beforeModel是提醒的发出点:每次调 LLM 前给两个计数器加一,满足条件就往 prompt 末尾追加一段提醒。
两个阈值,防的是两类问题
提醒逻辑挂了两个条件,分别防一类问题:
stepsSinceLastWrite >= 5 防的是"忘"。距离上次 todo_write 已经过了 5 个 step,大概率 LLM 已经埋头干活忘了清单,这时候该提醒。设太小(比如 1)会在 LLM 正常干活时频繁打断;设太大就起不到提醒作用,5 是个经验折中。
stepsSinceLastReminder >= 5 防的是"轰炸"。假设 LLM 收到提醒后仍然 没更新清单(它可能正忙于一个长子任务),那下一个 step stepsSinceLastWrite 还是超标,如果不加这个条件,就会每一步都提醒一次。第二个阈值保证两次提醒之间至少隔 5 步,给 LLM 留出"看到了但暂时顾不上"的空间。
提醒的措辞要"软"
提醒注入的文本本身也有讲究:
typescript
function formatReactiveReminder(lines: string): string {
return `\n<todo_reminder>
todo_write 工具有一阵没用了。如果你正在做的任务适合用清单跟踪,可以考虑更新一下。
仅在与当前工作相关时才使用。当前清单:
${lines}
</todo_reminder>`;
}
注意它的语气是"可以考虑"、"仅在相关时",而不是"你必须立刻更新清单"。这是故意的。如果提醒写成硬命令,LLM 会为了"服从命令、消除这条提醒"而去调 todo_write,哪怕这一步根本没有进度变化,结果是一堆无意义的空更新。软措辞把决定权留给 LLM:提醒只负责把清单现状重新摆到它眼前,要不要动由它自己判断。 这跟人类协作里"我同步个进度给你看看"和"你赶紧给我更新"的区别是一样的。
Part 4:接进 createAgent,以及这个系列的"配方"
todo 系统功能上完整了,最后是怎么接进 agent。因为工具和 middleware 共享闭包状态,接入时必须用同一次 createTodoSystem() 的返回值:
ini
const todo = createTodoSystem();
const agent = createAgent({
model,
sandbox,
tools: [readFileTool, writeFileTool, bashTool, todo.tool],
middlewares: [todo.middleware, loopDetectionMiddleware()],
});
ini
// 反例:tool 和 middleware 来自两次不同的调用,
// 各自闭包里是两份独立的 todoList,工具写的 middleware 读不到
const a = createTodoSystem();
const b = createTodoSystem();
createAgent({ tools: [a.tool], middlewares: [b.middleware] }); // 错
把 todo.tool 放进 tools、todo.middleware 放进 middlewares 就完成了。主循环依旧不知道 todo 的存在,它只是照常执行工具、照常跑 middleware 钩子。
回头看这个系列的"配方"
到这一篇,code-artisan 的拆解就结束了。如果把七篇连起来看,会发现从第四篇之后,每一篇做的其实是同一件事:
- 04 middleware:把横切关注点(压缩、死循环检测)从主循环里搬出去
- 05 sandbox:把工具背后的 IO 抽成接口,按环境注入实现
- 06 skills:把领域知识做成可外挂的文件,按需加载
- 07 todo:把"任务自我管理"做成一对工具 + middleware,挂上去
它们用的是同一个配方:主循环只保留最核心的"调模型 → 执行工具 → 再调模型",其他所有能力都做成可插拔的东西,通过 hook、通过依赖注入、通过工具注册挂上去。 主循环越瘦,每一项能力越能独立演进、独立测试、独立替换。这不是 Agent 框架独有的智慧,是软件工程里"分离关注点"的老道理,只是在 Agent 这个新场景里又一次被验证。
一个 Coding Agent 从最初的一个 while 循环(第二篇),长成现在这个有工具、有中间件、有沙箱、有技能、能自我管理的形态。我们做的事情,本质上是用一系列可插拔的机制,把一个简单的 loop 包装成了一套复杂的运行结构。这套环绕在核心循环外面、负责调度与扩展的结构该怎么设计,正是最近大家在讨论的话题 harness engineering。LLM 本身只是引擎,真正决定一个 Agent 好不好用的,是这层 harness。
写在最后
code-artisan 拆解系列到这里就完结了,七篇分别是:
- 开篇:整体架构与设计取舍
- ReAct Agent Loop:从零手写主循环 + provider 抽象
- 工具系统:defineTool + 4 个 builtin 工具
- middleware:8 个钩子与横切关注点
- sandbox:执行环境抽象,本地与 microVM 切换
- skills:渐进式披露,能力可外挂
- todo:工具与 middleware 配对,让 agent 自我管理
写这个系列的初衷,是想说明一件事:一个看起来很复杂的 Coding Agent,拆开之后每一块都是可以独立讲清楚、独立写出来的普通代码。没有魔法,只有结构。
完整代码在 GitHub:github.com/lhz960904/c...(这一篇拆的核心文件是 packages/agent/middlewares/todo/)。如果这个系列对你有帮助,欢迎给项目点个 star ⭐。