Agent 任务规划:把 TODO 从模型脑内挪到外部状态

摘要:现在 Agent 已经能持续对话、调用工具,也有了记忆系统。但复杂任务一多,模型仍然容易跳步、重复或提前宣布完成。解决办法不是只在 prompt 里说“请先规划”,而是给它一个真正可写、可校验的外部 TODO 状态。本文拆解 update_todos 如何让任务进度变得可观察。

标签:Agent、任务规划、TODO、Tool Use、LLM 工程

在这里插入图片描述

会调用工具,不等于会稳定完成复杂任务

一个能调用工具的 Agent 已经很有用了。它可以读文件、跑命令、查看输出,再继续判断。但当任务变复杂时,你会看到新的问题。

比如用户说:

创建一个 demo 项目:新建目录,写 README,写 hello.py,运行验证,最后告诉我结果。

模型可能创建了文件,却忘记运行验证;也可能运行了测试,但忘了总结修改;还有时候它会在仍有步骤没做完时,直接给出“已完成”的回答。

这不是因为模型完全不会规划,而是因为计划只在模型脑内。外部程序看不到当前做到哪一步,也无法在最终回答前检查还有没有残留任务。

所以任务规划的关键,不是让模型多说几句计划,而是把计划变成运行时可维护的状态。

在这里插入图片描述
在这里插入图片描述

update_todos 的核心思想

教学示例从 build-agent-example/code/step07_plan_todolist.py 开始,主项目里对应的是 agent/tools/todo.py

update_todos 做的事情很简单:让模型把完整任务列表写入一个外部 store。每个任务项有三个字段:

{
  "id": 1,
  "content": "创建 demo 目录",
  "status": "pending"
}

状态只有三种:

pending → in_progress → completed

工具会限制同一时间最多只有一个 in_progress。如果模型一次传入两个进行中的任务,工具直接返回错误,让模型重新规划。

在这里插入图片描述

这一步的价值在于:计划不再只是自然语言,而是可写、可渲染、可校验的结构化状态。

为什么每次都传完整列表

update_todos 要求每次传入完整 todos 数组,而不是只传增量变化。这个设计看起来不够精细,但对模型很友好。

增量 patch 会引入很多边界:新增、删除、替换、移动、只改状态、合并冲突。模型很容易漏字段或改错 id。完整数组则简单得多:当前计划是什么,就把当前完整计划写回来。

TodoStore.update() 会清洗输入:

for i, t in enumerate(items, start=1):
    content = (t.get("content") or "").strip()
    if not content:
        continue
    status = t.get("status", "pending")
    if status not in _VALID_STATUS:
        status = "pending"
    cleaned.append({
        "id": t.get("id", i),
        "content": content,
        "status": status,
    })

然后检查进行中数量:

in_progress_count = sum(1 for t in cleaned if t["status"] == "in_progress")
if in_progress_count > 1:
    return "Error: 同一时间只能有一个 in_progress 任务,请重新规划。"

最后工具会返回当前列表。这个返回值很关键,因为模型下一轮能直接看到最新状态,不需要凭记忆猜自己做到哪里。

工具和 prompt 要一起工作

只提供 update_todos 工具还不够。模型不会天然知道什么时候应该用它,所以 system prompt 里要写清楚行为规则:

多步骤任务先拆计划
开始某一步前标记 in_progress
完成后标记 completed
同一时间只允许一项 in_progress
简单问答不必生成 todolist

这里体现了 Agent 工程里很重要的一条原则:

工具提供能力
prompt 规定策略
代码负责校验

只有工具,没有策略,模型可能不用。只有策略,没有工具,状态仍然不可见。只有工具和策略,没有代码校验,模型仍然可能乱填状态。

最终回答前必须检查残单

很多简单 Agent 示例在模型不再返回 tool_use 时就结束:

if message.stop_reason != "tool_use":
    print(reply)
    break

但有了 todolist 后,不能只相信模型自称完成。代码应该检查是否还有未完成项。

教学示例里有这样的逻辑:

unfinished = [t for t in TODOS if t["status"] != "completed"]
if unfinished:
    history.append({
        "role": "user",
        "content": "差事尚未办妥,请按计划继续执行:\n" + render_todos(TODOS)
    })
    continue

如果还有 pendingin_progress,系统会把残单推回给模型,让它继续执行。只有全部 completed,才清空当前 TODO,避免上一轮任务污染下一轮。

在这里插入图片描述

这就是把“模型自觉完成”变成“运行时共同维护进度”。

普通计划文本为什么不够

你可能会问,让模型先输出一段 Markdown 计划不也可以吗?

1. 创建目录
2. 写 README
3. 写 hello.py
4. 运行验证

这种计划当然有帮助,但它仍然只是文本。模型后续可能遵守,也可能忘记。代码无法直接判断第 4 步是否真的做了,也无法自动阻止模型提前收口。

update_todos 的不同点在于,它把计划写进了运行时。程序能渲染它,能校验它,能在未完成时把它推回上下文。这就是结构化状态的价值。

凡是需要稳定维护的东西,都不要只放在自然语言里。自然语言适合理解和解释,结构化状态适合跟踪和约束。

什么样的 TODO 才好用

TODO 写得太虚,也会让规划失效。比如“处理项目问题”“完善代码”“检查一下”都不是好任务项,因为它们没有明确动作,也没有完成标准。

一个好 TODO 至少满足三点。

第一,一条只做一件事。不要把“读取文件、修改代码、运行测试”塞成一条。

第二,内容要能被工具验证。比如“运行 python hello.py 验证输出”比“确认能跑”更具体。

第三,最后要有收口项。很多任务不是做完修改就结束,还需要报告改了什么、验证了什么、有没有遗留风险。

这样写出来的计划,模型更容易按顺序推进,人也更容易从终端观察进度。

什么时候不需要规划

任务规划不是万能药。简单问答不该强制生成 todolist。用户问“什么是 tool use”,直接回答就好;如果每个问题都先拆计划,体验会变得笨重。

更适合使用 update_todos 的任务通常有这些特征:

  • 多步骤;
  • 涉及文件写入;
  • 需要运行命令验证;
  • 需要多次工具调用;
  • 用户明确要求“先计划再执行”。

一个好的 Agent 应该能区分“直接回答”和“进入执行流程”。这决定了它用起来是灵活的,而不是一开口就摆出重型流程。

计划状态为什么不进 history

在主项目里,TodoStore 是一个单独的运行时状态。它不直接进入 history,也不会被记忆压缩逻辑吞掉。这个设计很重要。

history 记录的是对话和工具交互,适合给模型理解上下文;todo store 记录的是当前差事的执行状态,适合由代码检查和更新。如果把 todo 只写进 history,模型当然能看到,但程序很难可靠判断它是否完成;如果把 todo 放进独立 store,代码就能直接检查状态。

可以把它理解成两类上下文:

对话上下文:让模型知道前面说过什么
任务状态:让运行时知道当前做到哪里

这两者应该配合,而不是混在一起。模型需要看到 todo 的渲染结果,才能继续推理;代码需要保存 todo 的结构化版本,才能校验完成情况。

失败案例:模型提前收口

没有完成校验时,模型很容易提前收口。它可能已经完成了大部分工作,于是生成一句“任务已完成”,但还有一步验证没做。

这类错误很隐蔽,因为回答看起来很完整。用户不一定马上发现测试没跑,文件没检查,或者最后一步总结没有覆盖风险。

有了 update_todos 后,最终回答前可以强制检查:

还有 pending → 不准结束
还有 in_progress → 不准结束
全部 completed → 可以收口

这不是不信任模型,而是把模型不擅长稳定维护的状态交给代码。模型擅长理解任务和生成下一步动作;代码擅长检查结构化状态。这种分工,比单纯要求模型“请务必不要漏步骤”可靠得多。

自己动手验证

你可以用一个小任务测试规划工具是否生效:

创建 demo 目录;
写 README.md;
写 hello.py;
运行 hello.py;
最后总结结果。

观察终端里是否先出现完整 todo 列表。然后看每一步是否从 pending 变成 in_progress,再变成 completed。如果模型在未完成时给最终回答,运行时是否把残单推回去继续执行。

这个验证比看代码更有说服力。因为任务规划的价值不在于工具 schema 漂亮,而在于真实执行时能不能减少跳步、重复和提前结束。

prompt 里的规划规矩怎么写

update_todos 是工具,但模型什么时候调用它,仍然依赖 system prompt 里的行为规矩。规矩要写得具体,不能只写“复杂任务请先规划”。

更有效的写法是给出触发条件:

当任务需要修改文件时,先列 todo
当任务需要三步以上工具操作时,先列 todo
每次开始一个步骤前,将它标记为 in_progress
每次完成一个步骤后,将它标记为 completed
最终回答前检查是否还有 pending 或 in_progress

这些规则都能被运行时和日志验证。如果模型没有创建计划,或者完成后忘记更新状态,你能从工具调用里看出来。好的 prompt 不只是“鼓励模型认真”,而是让行为有可检查的落点。

计划粒度怎么控制

TODO 太粗会失去约束,太细又会拖慢执行。一个简单判断方法是:每条 todo 最好对应一次可以观察的阶段变化。

比如“修复 bug”太粗,因为里面包含复现、定位、修改、验证;“打开文件第 12 行”又太细,因为它只是一个工具动作,不一定代表任务阶段。更好的拆法是:

复现当前失败
定位相关实现
修改问题代码
运行验证命令
总结修改与风险

这个粒度既不会把每个工具调用都变成 todo,也能让用户看懂 Agent 现在推进到哪里。

计划不是承诺,而是工作台

很多人看到 todolist,会把它理解成模型对用户的承诺:列出来就必须一字不差执行完。但在 Agent 里,计划更像工作台。

执行过程中发现新信息,计划可以调整;某一步被证明没有必要,可以删掉或改写;遇到阻塞,也可以新增“调查替代方案”。关键是调整要显式发生,而不是模型脑内悄悄换做法。

这就是结构化计划的真正价值:不是让任务一开始就被预测完,而是让变化也能被看见。

计划和记忆的边界

前面已经有了记忆系统,为什么还要单独做 todo store?因为它们解决的问题不同。

记忆回答的是“这个 Agent 长期应该知道什么”,计划回答的是“当前这件事推进到哪里”。记忆可以跨会话存在,计划通常应该随着任务结束而清空。把临时计划写进长期记忆,会让未来上下文被旧任务污染;把长期背景塞进 todo,又会让执行列表变得臃肿。

一个简单原则是:任务结束后仍然有价值的信息,才进入记忆;只服务于当前推进过程的状态,放在计划里。这样 Agent 既不会忘掉稳定背景,也不会把每个临时步骤都背到未来。

小结

任务规划的核心,不是让模型输出一段漂亮计划,而是把任务状态外部化。

update_todos 把计划从模型脑内挪到运行时:状态可见、进度可查、错误可纠正、最终回答前可检查残单。这样 Agent 不再只靠模型自觉,而是由模型和代码共同维护任务推进。

有了计划之后,主线知道要做什么、做到哪里。但执行复杂任务时,工具输出仍然会把主 history 搞得很脏。下一步,我们需要把一些细节工作交给独立上下文处理。


视频与源码

如果你想看完整演示,可以在主页的《从零手搓 Agent》合集里按顺序观看视频版:

  • 抖音:https://www.douyin.com/user/MS4wLjABAAAAk5lgbm96yoPPEoGXoY3MIp4S8voya0dzcnG0Lom5-SI?from_tab_name=main
  • B站:https://space.bilibili.com/1452412374

文章里的示例代码和完整项目也放在这里:

  • 📦 教学仓库:https://github.com/TheSyart/claude-agent-examples
  • ⚔️ 实战项目:https://github.com/TheSyart/emperor-agent

我会持续更新 Agent 教学与实战内容。觉得有用的话,欢迎给项目点个 Star ⭐,也谢谢你一路看到这里。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐