Day55|从0学习 Claude Code(五):我给 Agent 发了张工单,它干活终于不跑偏了
苦猿的大模型日记 · Day55 · 从0学习Claude Code五TodoWrite-帮普通人把AI学进简历系列前言第五篇给 Agent 发一张工单上一篇咱们给循环装上了挂钩日志、权限、自动收尾、会话统计全挂在外面循环本体一行没动。加功能从开胸手术变成了往墙上挂东西。到这这个 Agent 看起来已经挺像样了会干活五个工具、有分寸门禁、可扩展挂钩。但有个问题从第一篇憋到现在我一直没敢碰这货干活没有计划。给它一个多步任务它一上来就闷头写。写两步跑个测试蹦出个报错它去修报错修着修着——它忘了自己最开始是要干嘛的了。你盯着终端能明显感觉到那个瞬间刚才还目标明确的一个人忽然开始即兴发挥。工具没问题权限没问题挂钩也没问题。问题出在一个更隐蔽的地方执行到一半的时候目标不在它的视野里了。这一篇就干一件事给 Agent 发一张工单——一个叫todo_write的工具。动手之前先列清单做一项勾一项清单本身回流进上下文让我在哪、我该干嘛变成它随时看得见的东西。读完你会拿到三样东西TodoManager的完整实现一块带状态机的白板外加三条专治清单走样的校验规则一个反直觉的设计洞察这个工具不给 Agent 增加任何执行能力——它连一个字节都不读写它增加的是规划状态本篇的高潮reminder 机制——模型列完清单就扔在一边不更新harness 会主动戳它一下以及一个连戳带勾的十步任务实录门槛不变会 Python 基础语法、有上一篇跑通的那份带挂钩的代码。直接开始。PART 01案发现场——做到第四步它忘了自己是来干嘛的先复原现场。我给上一版的 Agent 出过一道题这道题出自源仓库的教程堪称注意力测试的经典款把所有 Python 文件改成 snake_case 命名然后跑测试修好失败的。任务本身不难拆开就三步重命名、跑测试、修失败。任何一个实习生拿到这张工单都会先在纸上写下这三行。但模型的实际表现是这样的。开局其实很正常甚至可以说漂亮 glob(**/*.py) bash(git mv HelloWorld.py hello_world.py) bash(git mv UserData.py user_data.py) bash(python -m pytest)有条有理找文件、改命名、跑测试。然后测试蹦出 2 个失败画风开始变了 bash(python -m pytest tests/test_user_data.py) read_file(tests/test_user_data.py) edit_file(src/user_data.py, ...) edit_file(src/user_data.py, ...) # 修第 1 个失败顺手重构了一段 bash(python -m pytest) # 重构引入了新问题 edit_file(src/helpers.py, ...) # 去处理新问题看到问题了吗从第 5 行开始它在响应报错而不是在执行任务。每一条新命令的动机都是上一条命令的输出像多米诺骨牌一样一张推一张——而把所有文件改成 snake_case这个最初的目标不在任何一张牌上。最后它跟我说任务完成的时候项目里还躺着 4 个没改名的文件。它不装傻也不偷懒它就是纯粹地、诚恳地——忘了。因为它此刻的注意力里确实没有什么没完成的事。后面的文件没有重命名。它不装傻也不偷懒它就是纯粹地、诚恳地——忘了。最后它跟我说任务完成的时候语气特别坦然因为它此刻的注意力里确实没有什么没完成的事。注意这个跑偏过程里没有任何一步是错的。跑测试是对的修失败也是对的——单看每一轮它的决策都合理。灾难是连续性造成的一步合理的响应接着一步合理的响应走着走着就走到了任务外面。为什么会这样得从这个脑子的物理结构说起。模型的注意力天然偏向最近的内容。对话每多一轮工具结果就往上下文里灌一大块新东西——文件内容、命令输出、报错堆栈每一样都又长又吵。而任务目标呢是开头那一小段话离最新的 token 越来越远影响力被一步步稀释。一个十步的任务做完 1-3 步之后4-10 步就已经被挤出注意力中心了。它不是态度差是记忆的物理结构就这样每次只盯着最近的东西看。这事人也一样。让你边接电话边找文件边回消息十分钟后你也会忘了最初要干嘛——所以人才发明了便利贴、待办清单、工单系统。你的记忆没变只是目标被写到了一个你随时看得见的地方。给 Agent 也发一张就是这一篇全部的活。PART 02TodoManager——一块会自己画勾的白板先看数据结构。整个方案的核心就一个类持有内存里的一张清单class TodoManager: def __init__(self): self.items: list[dict] [] def update(self, todos: list | str) - str: validated [] # ...校验下面细讲... self.items validated return self.render() def render(self) - str: if not self.items: return No todos. lines [] for todo in self.items: marker { pending: [ ], in_progress: [], completed: [x], }[todo[status]] lines.append(f{marker} {todo[content]}) done sum(t[status] completed for t in self.items) lines.append(f\n({done}/{len(self.items)} completed)) return \n.join(lines) TODO TodoManager()每项就两个键content干什么和status什么状态。状态是个三档状态机[ ]pending——还没动工[]in_progress——正在干[x]completed——干完了渲染出来长这样一张人话写的进度表[ ] Rename hello.py to snake_case [] Run tests [x] Read existing files (1/3 completed)末尾那个(1/3 completed)是点睛之笔——进度条。模型每次更新清单都会看见自己走到哪了。但真正值钱的是update()里的三条校验。每一条都在治一种真实的清单病第一条最多 20 项。if len(todos) 20: raise ValueError(Max 20 todos allowed)清单太长等于没有清单。20 项排开注意力又被稀释回 PART 01 的老问题。撞到上限说明这个任务本身太大了该拆任务而不是硬塞——这个边界我们在下一篇会真正用上先埋个伏笔。第二条content 必须非空。content str(todo.get(content, )).strip() if not content: raise ValueError(ftodos[{index}] requires content)空项是自欺欺人的清单。待办干点啥这种条目列了等于没列还占用了注意力。要么写清楚要么别写。第三条也是我觉得最妙的一条同一时间只允许一个 in_progress。if in_progress 1: raise ValueError(Only one todo can be in_progress at a time)这条在治什么治全都开着头全都没收尾。模型特别容易犯这个病干着 A 想起 B把 B 也标成进行中干着 B 又觉得 C 急C 也开着——最后一张清单上五六个[]跟没标一样。强制单线程推进每一时刻正在干什么有且只有一个答案。还有一个细节值得单独说update()的入参类型是list | str。模型调工具时传参数可能规规矩矩传 JSON 数组也可能手滑传个 JSON 字符串甚至传个 Python 列表的字符串表示。代码的处理是先json.loads失败再ast.literal_evalif isinstance(todos, str): try: todos json.loads(todos) except json.JSONDecodeError: try: todos ast.literal_eval(todos) except (SyntaxError, ValueError) as e: raise ValueError(todos must be a list or JSON array string) from e注意用的是ast.literal_eval坚决不用eval——后者会执行任意代码模型传进来一句__import__(os).system(rm -rf /)你的机器就没了。上一篇装好的门禁管的是工具调用管不到你在 handler 里手贱写的 eval。安全这根弦每一行都得绷着。最后最关键的一步藏在返回值里update()返回的是render()之后的清单文本。这个文本会作为 tool_result 回流进上下文——模型每勾一项就重新看见一遍整张清单。这就是看得见的全部秘密没有魔法。PART 03todo_write 上岗——一个什么都不干的工具工具有了挂上工具箱。第六个工具定义照旧走 schema{name: todo_write, description: Create and manage a task list for your current coding session., input_schema: {type: object, properties: {todos: { type: array, maxItems: 20, items: { type: object, properties: { content: {type: string, minLength: 1}, status: {type: string, enum: [pending, in_progress, completed]}}, required: [content, status]}}}, required: [todos]}} TOOL_HANDLERS[todo_write] run_todo_writehandler 本体短得有点寒酸def run_todo_write(todos: list | str) - str: try: output TODO.update(todos) except ValueError as e: return fError: {e} print(f\n\033[33m## Current Tasks\033[0m\n{output}) return output四行更新清单往终端打一份黄色的Current Tasks面板给人看的返回清单文本给模型看的。现在停下来看一件事这是本篇最反直觉的地方。盘点一下这六个工具的产出bash 执行命令read_file 读文件write_file 写文件edit_file 改文件glob 找文件——每一个都在改变外部世界。而 todo_write 呢它不读写任何文件不执行任何命令不产出任何外部结果。调用它一千次你的硬盘上一个字节都不会变。那它干了什么它唯一的作用是改变模型自己能看到的世界。清单每次更新render() 的结果回流进上下文下一轮思考时模型眼前的世界里就有一张最新的进度表。bash 是手read_file 是眼todo_write 是——一块贴在显示器边上的便利贴。分发路径上零新意这正是好事还是TOOL_HANDLERS[block.name]查表那老一套上一篇的挂钩照常在它身上跑——权限 hook 检查它日志 hook 记录它一个不少。新器官长在旧骨架上心脏又是一行没动这已经是第三次验证那条架构原则了。还差最后一块引导。工具挂上了得告诉模型有这玩意记得用。SYSTEM 提示加了一句SYSTEM ( fYou are a coding agent at {WORKDIR}. Before starting any multi-step task, use todo_write to plan your steps. Update status as you go. )多步任务先列清单随做随更新。工具给能力提示给习惯两手都要有。至此全套齐活。模型接到任务后的标准动作变成先todo_write列出所有步骤全 pending→ 动手前把某项标成 in_progress → 做完标 completed → 看下一个 pending → 继续。每勾一项整张清单重刷一遍。听起来很美。跑起来就知道哪里会掉链子了。PART 04跑起来——清单会变成摆设所以要有监工拿源仓库自带的练习题开跑重构hello.py加类型注解、docstring、main guard。模型的第一个动作terminal 里先是那个黄色面板亮起来## Current Tasks [ ] Add type hints to hello.py [ ] Add docstrings [ ] Add main guard (0/3 completed)它先列了清单再动的手。然后读文件、标 in_progress、改代码、标 completed——三项全勾完收工。小任务上整个流程丝滑得像个真正的工程师。但我很快就撞见了预想中的问题。换个十步的大点的任务模型列完清单、干完前两步之后——它就把清单忘了。后面的八步一路闷头冲清单停在(2/10 completed)纹丝不动直到最后跟我说做完了。注意这比没有清单更具有欺骗性它看起来有计划实际上那份计划早就死在第三步了。计划和执行脱节等于给自己发了张免检通行证——我有清单这个事实本身反而成了它不再看清单的理由。治这个病靠模型自觉是没戏的得靠 harness 出手。这就是本篇的高潮reminder 机制。rounds_since_todo 0 # ...在 agent_loop 的每轮工具执行之后 used_todo any(b.name todo_write for b in tool_calls) rounds_since_todo 0 if used_todo else rounds_since_todo 1 if rounds_since_todo 3: results.append({type: text, text: reminderUpdate your todos./reminder}) rounds_since_todo 0逻辑一句话讲完一个计数器盯着每一轮工具调用——这轮碰过 todo_write计数归零没碰计数 1连续三轮没更新清单就在工具结果末尾追加一条提醒reminderUpdate your todos./reminder然后计数清零重新开始盯。再跑一遍那个十步任务效果立竿见影。终端实录节选[] Step 3: refactor parser module # 计划停在第 3 步 edit_file(src/parser.py, ...) bash(python -m pytest src/tests/) read_file(src/parser.py) # 第 3 轮没碰 todo_write ## Current Tasks # 模型被戳醒主动刷清单 [x] Step 1: scan codebase [x] Step 2: write test harness [] Step 3: refactor parser module [ ] Step 4: update call sites ... (2/10 completed)中间那个空行就是 reminder 落下的位置模型闷头连干三轮第四轮收到的工具结果末尾多了一行提醒——它愣了一下下一轮老老实实调 todo_write把清单刷到最新计数器归零继续干活。之后每隔三轮没更新就会被这么戳一下。而且你会观察到一个微妙的行为变化被戳过几次之后模型开始抢在提醒前头主动更新清单——干完一步就顺手勾一项。提醒从被动挨戳变成了主动汇报。这没有任何额外代码纯粹是那句 reminder 在上下文里积累出来的习惯。品味一下这个设计的站位。它没改模型没加提示词甚至没动任何工具——它只是往输入里塞了一句话。上一篇讲 Stop hook 时说过一句话拒绝也是一种输入。今天这条是它的姊妹篇提醒也是一种输入。你不需要重新训练模型、不需要改系统提示在合适的时机往它的世界里放一句合适的话行为就拐弯了。驯 Agent 的把手从来都在输入侧。两种失败模式现在都有解了没有清单它会忘了目标——todo_write 治有了清单不更新它会骗自己——reminder 治。PART 05你天天见的那个勾选清单底层就是这套把我们的复刻和真实 Claude Code 对一下表你会发现这次的映射近得有点过分。TodoWrite 是真实存在的内置工具。你在 Claude Code 里干复杂任务时看到的那个任务清单——就是它。状态也一模一样pending、in_progress、completed 三档一次更新整张清单。清单回流上下文机制相同。每次打勾都是一次真实的工具调用工具结果里带着渲染后的最新清单进上下文模型下一轮看得见。你在界面上看到的打勾动画不是 UI 装饰——每一次打勾都是模型在主动更新自己看得见的世界。真实系统也有监工。Claude Code 同样会在模型忘了更新清单时通过系统提醒催它——你偶尔会在输出里看到类似You have unfinished todos的提示那就是它的 reminder 在戳。边界也一致清单太长会被建议拆分任务同一时间只该有一个进行中——我们那三条校验的产品化版本。还有一个真实系统里的细节值得你亲手验证Claude Code 并不是每次都掏清单。问它一句简单问题、改一行代码它不会先列个三项清单再动手——简单任务直接干复杂任务才开工单。判断这个任务配不配一张清单本身就是模型根据任务规模做的决策。我们的复刻版靠 SYSTEM 提示里那句multi-step task引导这个分寸真实产品把这个分寸也打磨得更细。顺便说个使用体感清单还是你判断这活还能不能交给它的仪表盘。(3/10 completed)停在原地五分钟没动你就该过去看看它在干嘛了——是卡住了、跑偏了还是正陷在某一步的细节里出不来。进度条不动就是注意力又跑偏的信号。我们的复刻真实 Claude CodeTodoManager 内存清单内置 TodoWrite 工具 状态管理render() 结果回流上下文清单注入上下文模型随时可见rounds_since_todo 计数器系统 reminder 催更最多 20 项 / 单 in_progress超长建议拆任务 / 单进行中SYSTEM 里一句引导系统提示 工具描述共同引导最后收一笔大账。五个模块连起来看心脏agent 循环、双手工具分发、神经权限系统、挂钩hooks 扩展、今天的工单TodoWrite。你会发现前四个模块解决的其实都是同一类问题——怎么干循环让它干起来工具让它干得动权限让它干得安全hooks 让别人能管它怎么干。而今天这个模块第一次碰到了另一类问题还记得要干什么吗执行能力齐活的一个 Agent栽在忘了初衷上——这个教训不只适用于 Agent。结尾便利贴不是贴给眼睛的是贴给注意力的回到开头那个跑偏的现场。模型的注意力像一支手电筒只照亮最近的东西——工具结果、报错堆栈、上一轮的输出全都挤在光斑里而任务目标缩在黑暗角落喊破喉咙也没人听见。清单解决的问题从头到尾就不是记忆——它的硬伤一个都没治。它治的是锚点每勾一项光斑就扫回目标一次。手电筒还是那支手电筒只是墙上多了一张它绕不开的纸。所以这张工单值钱的不是那二十来行代码是一个认知执行能力决定它能不能做到规划状态决定它还记不记得要做什么。互动时间你见过 Agent 跑偏最离谱的一次是什么忘了跑测试忘了格式化还是改着改着把不相关的东西也顺手重构了评论区聊聊——顺便说说你觉得 reminder 几轮触发一次才合适三轮太勤还是太懒下一篇预告「从0学习 Claude Code」第六篇——Subagent子代理。清单解决了记得住但一个任务大到几十步全塞在一个对话里照样会淹没。下一篇把大任务拆开每个子任务派一个独立上下文的子 Agent 去干——干完只把结论带回来主 Agent 的上下文从此可以是干净的。— END —苦猿 · 帮普通人把 AI 学进简历
上一篇/下一篇内容由系统自动关联
返回资讯列表 →