上一篇咱搓了一个能跑命令、读写文件的最简 agent。它能干活,但干活的方式有点"愣"——用户说一句,它做一步,做完就忘了自己在干嘛。
碰到简单任务还好。一旦任务稍微复杂点,比如"帮我创建一个 Flask 项目,包含用户注册和登录功能",问题就来了:模型可能写完注册忘了登录,或者改着改着把前面的工作覆盖了。
原因很简单:它没有计划。
在使用 Claude Code 时,它在处理复杂任务时会先列一个 todo list,然后逐项推进,做完一项打个勾。这不是装样子,这是让 agent 在多步任务中保持方向感的关键机制。
这篇文章就来实现这个能力。
问题出在哪
回顾上一篇的 agent 循环:
用户输入 → 模型思考 → 调用工具 → 拿到结果 → 继续思考 → ……
循环本身没问题,但模型的"思考"完全依赖消息历史。随着对话越来越长,早期的意图会被稀释。模型在第 8 次工具调用时,可能已经记不清用户最初到底要什么了。
人类程序员怎么处理这种情况?列个清单,做完一项划掉一项。agent 也可以这么干。
设计一个 TodoPlanManager
任务管理器不需要多复杂。一个任务有三个字段就够了:
class TodoItem(TypedDict, total=False):
id: str
text: str
status: Literal["pending", "running", "completed"]
三种状态:pending 等待中,running 进行中,completed 已完成。
管理器本身就是一个列表加几个约束:
class TodoPlanManager:
def __init__(self):
self.items = []
def update(self, items: list[TodoItem]) -> str:
validated = self._validate(items)
self.items = validated
return self.render()
def render(self) -> str:
if not self.items:
return "No todos."
lines = []
for item in self.items:
marker = {"pending": "[ ]", "running": "[>]", "completed": "[√]"}[item["status"]]
lines.append(f"{marker} #{item['id']}: {item['text']}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
update 接收完整的任务列表(不是增量更新),校验后替换当前状态,然后返回渲染结果。render 把任务列表格式化成人能看懂的文本。
为什么用全量替换而不是增量操作(比如 add/remove/update 三个接口)?因为对模型来说,一次性给出完整列表比记住"第 3 项改成 completed"更不容易出错。模型擅长生成结构化数据,不擅长记住之前的状态。
两个约束
管理器内部有两个校验规则。
第一,任务总数不能超过 20 条:
MAX_ITEMS = 20
if len(items) > MAX_ITEMS:
raise ValueError(f"Maximum support for {MAX_ITEMS} pending tasks")
这不是随便定的数字。任务列表会作为工具调用的参数和返回值出现在消息历史里,太长会占用大量 token,影响模型对其他内容的注意力。
第二,同一时间只能有一个任务处于 running 状态:
def _check_in_progress(self, items: list[dict]) -> None:
running_list = [item for item in items if item["status"] == "running"]
if len(running_list) > 1:
ids = [item["id"] for item in running_list]
raise ValueError(f"Only one task can running at a time, got: {ids}")
这个约束迫使模型串行执行任务。看起来是个限制,实际上是在帮模型——并行处理多个任务对当前的 LLM 来说太容易出错了。
把 TodoPlanManager 变成工具
有了管理器,还需要把它包装成模型能调用的工具:
@tool
def update_todo(items: list[TodoItem]) -> str:
"""Update task list. Track progress on multi-step tasks."""
return todo_plan_manager.update(items)
然后在 system prompt 里告诉模型怎么用:
SYSTEM_PROMPT = f"""
You are a coding agent, {os.getcwd()} is your working directory, you can use bash tools.
Use a to-do tool to plan multi-step tasks. Mark as in progress before starting,
and mark as completed when finished.
Current runtime system is {platform.system()}
"""
关键是这句:“Use a to-do tool to plan multi-step tasks. Mark as in progress before starting, and mark as completed when finished.” 这不是建议,是指令。没有这句话,模型大概率不会主动去用 todo 工具。
工具注册的小改进
上一篇我们手动维护了一个 tools_dict,每加一个工具就要改两个地方(列表和字典)。这次换个写法:
ALL_TOOLS = [run_bash, run_read_file, run_write_file, run_edit_file, update_todo]
tools_dict = {t.name: t for t in ALL_TOOLS}
llm = llm.bind_tools(ALL_TOOLS)
一个列表搞定,字典自动生成。加新工具只需要往 ALL_TOOLS 里追加。
Agent 循环的变化
核心循环和上一篇几乎一样,只多了一个小改动——当模型调用 update_todo 时,实时把任务列表打印出来:
while response.tool_calls:
for tool_call in response.tool_calls:
result = tools_dict[tool_call["name"]].invoke(tool_call["args"])
messages.append(ToolMessage(
content=result,
tool_call_id=tool_call["id"],
))
if tool_call["name"] == "update_todo":
print(f"\n📋 Todo Plan:\n{todo_plan_manager.render()}")
response = llm.invoke(messages)
messages.append(response)
这样用户就能看到 agent 的工作进度,而不是干等着不知道它在干嘛。
实际跑起来是什么样
假设你输入:“帮我创建一个 hello.py,里面写一个 hello world 函数,然后运行它”。
agent 的行为大概是这样的:
📋 Todo Plan:
[>] #1: 创建 hello.py 并编写 hello world 函数
[ ] #2: 运行 hello.py 验证结果
📋 Todo Plan:
[√] #1: 创建 hello.py 并编写 hello world 函数
[>] #2: 运行 hello.py 验证结果
📋 Todo Plan:
[√] #1: 创建 hello.py 并编写 hello world 函数
[√] #2: 运行 hello.py 验证结果
(2/2 completed)
模型先规划出两步,然后逐步执行。每完成一步就更新状态。整个过程用户都能看到进度。
回头看看
对比上一篇的 agent,这次的改动其实很小——就加了一个 TodoPlanManager 类和一个 update_todo 工具。但效果上的差异不小:
上一篇的 agent 像一个只会听指令的执行者,你说一步它做一步。这一篇的 agent 开始有了"规划"的意识,它会先想想要做哪几件事,然后按顺序推进。
当然,这个规划能力完全依赖模型自身的推理。模型如果规划得不好,todo list 也救不了它。但至少,有了这个机制,模型在执行多步任务时不容易迷失方向。
下一篇我们继续往上加东西,比如 Claude Code 是怎么载入 skills 的。