
写跨文件重构任务时最崩溃的瞬间不是模型报错而是它安静地漏掉一步。明明对话里反复说了最后更新一下配置文件它却在改完代码后直接宣布任务完成。一开始我觉得是模型能力不行后来翻日志才意识到问题出在系统设计上——我们从来没有给模型一块白板让它把要办的事一条一条写下来。这篇文章是这个从零构建AI Code终端系统系列的第04篇专门聊待办清单模块。目标只有一个让模型在长时间、多步骤的编码任务里不丢事。项目走到这一步终端骨架、工具调用、对话循环都已经能跑通了但真正决定一个AI编码工具是玩具还是生产力工具的恰恰是这种看起来不起眼的任务管理机制。这篇文章里我会把我自己的设计思路、数据结构、主循环改动、持久化方案和实测中踩过的坑完整写出来适合正在写类似Agent终端的开发者参考也适合想搞明白为什么AI助手平时容易丢三落四的人阅读。1. 从模型发挥不稳定说起AI编码终端里的外部记忆1.1 上下文窗口是有限容器不是无限便签先说我观察到的现象。一个典型的重构任务包含四五个子步骤给某个模块加日志、抽取公共函数、修改调用方、更新测试、最后改配置文件。模型前两三步走得非常漂亮代码风格一致、边界条件也考虑到了。但到了第四步之后它开始选择性遗忘——要么跳过测试要么改了代码但没改配置甚至有时候会重复执行已经做完的步骤。这不是偶发现象而是结构性问题。大模型的工作记忆受限于上下文窗口当对话轮次变多、中间夹着大量工具返回结果时早期提到的任务约束会被后续信息冲淡。模型本质上是在凭记忆做事而不是在看一张可随时查阅的清单。人靠脑子记十件事也会漏但如果在白板上写下来复查的时候一眼就能发现哪件没做完。AI编码终端缺的就是这块白板。1.2 待办清单是给模型看的也是给用户看的所以这个模块的直接价值是给模型一份外部记忆——不依赖上下文窗口每次循环开始前都强制模型看一眼当前的任务列表。但这块白板同时也在给用户看。终端里跑长任务时用户需要知道模型现在在做什么、下一步打算做什么、哪些步骤已经完成、哪些卡住了。没有待办清单整个终端就是一个不透明的黑箱只能等模型主动说话。顺着这个思路我在系列前面的架构里给待办清单的定义是它属于系统的状态层位于会话管理层和工具执行层之间。会话管理层负责把历史对话攒起来工具执行层负责真正改文件、跑命令而待办清单负责回答三个问题——现在要干什么、哪些已经干完、哪些被堵住了。这三个问题回答清楚了模型不丢事的概率会大幅提升。2. 设计Todo项字段越少越好状态越明确越好2.1 一个Todo项到底该放什么字段我第一版设计的时候恨不得把所有信息都塞进去标签、里程碑、预估工时、指派给谁……后来很快意识到这是过度设计。注意一点这些字段最终是给两种消费方看的——一种是大模型它需要从文本里快速理解任务含义另一种是终端用户他需要扫一眼就知道状态。字段越多序列化出来的文本越长模型处理负担越大用户的视线也会被分散。最终我保留了下面这些字段对应的JSON结构是这样的{ id: T-014, summary: 重构 query_builder 模块的缓存逻辑, detail: 将 Redis 调用封装成独立的 CacheProvider替换原有内联实现保持外部接口不变, status: in_progress, priority: 1, depends_on: [T-012, T-013], file_hints: [src/query_builder.rs, src/cache_provider.rs], created_at: 2025-01-16T10:22:00Z, updated_at: 2025-01-16T10:35:00Z }字段不多但每个都有明确用途。id是稳定标识模型和用户靠它沟通任务不能改。summary是一句话概述我要求模型拆任务时每一条summary控制在10到20个字以内。模型对短文本的遵循度远高于长文本太啰嗦的summary在后续注入Prompt时既占token又容易产生歧义。detail用来存放补充说明比如修改范围、注意事项它只在任务还没开始时全量展示任务执行中展示summary即可这样省上下文空间。depends_on是依赖关系后面会专门讲它怎么避免任务顺序错乱。file_hints是模型自己填写的关联文件列表作用是让后续步骤能快速定位代码位置省去扫描整个仓库的代价。有两点我没放进去一是接单人这是单用户终端不存在多人协作二是截止时间编码任务的时间约束由用户口头控制做成字段反而会让模型在判断优先级时产生幻觉。少一个字段就少一类模型自行发挥的空间。2.2 状态机四个基础状态和一个例外状态任务状态我定义了五个但其中四个是主线状态一个是临时状态状态含义允许进入的来源todo待办还没开始新建、blocked解除后in_progress进行中模型正在处理tododone已完成且改动已落地in_progressblocked被依赖项卡住暂时无法推进in_progress、todocancelled取消不再执行todo、blocked、in_progress设计原则是状态之间不允许随便跳。比如模型不能把一个todo直接标记为done必须先进入in_progress再通过工具执行成功这个条件来关门。这看起来是绕了一圈但实际上是在强制模型经历我开始做了到我做完了的完整过程每一步都有据可查。还有一个容易忽略的点blocked和cancelled的区别。blocked意味着任务本身还是有效的只是当前被某个外部条件挡住比如依赖的另一个任务还没做完或者需要用户先装一个依赖cancelled则是任务彻底失效。如果模型把这两个搞混要么会把一个有效任务永久丢弃要么会反复尝试一个已经放弃的任务。所以在Prompt里我会明确写一句规则遇到不确定是否还需要执行的任务时把它标记为blocked并询问用户不要直接cancelled。2.3 Todo如何与文件、工具调用关联待办清单不是悬空的列表它最终要驱动代码改动。所以每个Todo项里的file_hints会被主循环拿去做了两件事第一在工具调用前引导模型先看哪个文件。我实测发现如果模型要改一个跨文件逻辑在没有提示的情况下倾向于反复阅读目录结构来定位文件浪费大量token和时间。有了file_hints它会直接针对相关文件发起读取或编辑操作往返轮次能减少30%左右。第二在任务完成后做改动校验。主循环会收集本轮所有编辑类工具的返回值把涉及的文件路径和当前Todo的file_hints做交集匹配。如果模型声称完成了某个任务但此轮工具调用里完全没有触碰file_hints里的任何一个文件我就会把该任务强行打回in_progress并附带一条提示当前任务没有产生实际文件改动请重新检查。这个机制可以说是我后续实测里最有效的一层保障。3. 主循环改造让模型照着清单干活3.1 从自由对话到清单驱动的闭环在加待办清单之前我的这个终端主循环是典型的对话驱动用户输入指令、模型判断意图、决定调用哪个工具、拿到结果继续聊。流程简单但问题是模型很容易被对话带偏——用户中途问一句顺便看看另一个模块模型可能就真的把当前主线任务丢在一边。引入待办清单后主循环改成清单驱动用户提出新需求时不是直接让模型去改代码而是先让它调用add_todo工具把需求拆解成若干任务写进清单。每一轮循环开始前模型首先读取当前的Todo列表找出状态为todo且依赖已满足的最高优先级任务。针对这个任务调用具体工具执行完一步后更新Todo状态或补充说明。当清单上所有任务都为done时模型才向用户汇报整体完成。简单说模型的行为变成了看板-干活-更新看板的循环而不是想到哪做到哪。我用的Promt片段是这样的供参考你现在有一份任务清单格式如下 [T-014] 重构 query_builder 缓存逻辑 | in_progress | 依赖 T-012 已完成 [T-015] 补充 cache_provider 单元测试 | todo | 依赖 T-014 [T-016] 更新架构文档 | todo | 无依赖 规则 1. 你每次只能推进一个 in_progress 任务其余任务保持原状态。 2. 只有任务产生真实文件改动且通过校验才能更新为 done。 3. 新需求一律先调用 add_todo 写入清单不得口头承诺。 4. 若某任务依赖未满足标记为 blocked不得跳过依赖强行执行。这个prompt看起来非常简单但它把模型的行动权牢牢锁在了清单框架里。我不需要模型聪明我只需要它稳定。3.2 清单注入Prompt的时机与压缩策略清单不能每次都全量塞进Prompt吗可以但token贵而且模型读到后面注意力会衰减。我的做法是分三种时机循环开始时全量注入一次包含所有字段此时模型做全局规划选择下一步要推进哪个任务。每次工具调用结束后只增量注入被变更的Todo项的summary和status用一行文本追加提醒模型你更新后的看板长这样。当任务数量超过15个时自动触发一次清单压缩已完成任务折叠成一行统计信息已完成T-001, T-002, T-003……只保留待办和进行中的完整信息。压缩这一步特别重要。实测里如果清单超过20项且全部完整展示模型经常会出现盯着某个已完成任务反复思考的行为。折叠之后剩余token集中在未完成任务上模型反而更清醒。3.3 三个关键控制点开始、执行、收尾控制点是为了防止模型自作主张。我卡了三个位置第一开始时校验前置依赖。模型选定某个todo任务准备执行前主循环会检查depends_on里所有依赖项是否都已经是done状态。只要有一个不是该任务会被自动置为blocked模型必须先去处理依赖项。这个检查不能靠模型自觉必须由代码硬性执行。第二执行中记录实际动作。模型每次调用编辑类工具时主循环都会把工具名称、目标文件、改动摘要记录到Todo的更新日志里。这一步的好处是当模型过一会儿想不起来自己做了什么时它可以回看T-014 刚刚编辑了 src/query_builder.rs。有了事实日志模型的短期记忆压力会小很多。第三收尾时的完成判定。前面提过模型要把任务标记为done必须具备两个条件一是存在编辑类工具调用二是改动文件命中了file_hints。两个条件都满足后主循环才允许状态流转到done并且立即追加时间戳。如果模型坚持要标记某个没有改动记录的任务为done我会让循环给出警告当前任务没有关联任何工具执行记录拒绝更新状态。3.4 让模型自己拆任务但控制拆分的边界既然要做清单就涉及到谁来做任务拆解。我的选择是拆解工作完全交给模型但主循环限制拆解的粒度和数量。模型一次性拆出5到8个任务是健康的一步一个文件、一个函数那种粒度执行时几乎不会出错。但如果模型拆出30个小任务每个任务只是给某一行加注释这种级别清单本身就会变成一个巨大的负担每次注入prompt都是灾难。所以我在add_todo工具里做了一层限制单轮最多插入10个任务并且要求每个summary不低于5个汉字、不多于20个汉字。如果模型试图插入超过10个任务工具会拒绝并提示合并相似任务或者拆分批次。拆解完任务之后模型还必须做一次依赖声明。拆解和依赖声明是绑定的——add_todo的入参里如果没有depends_on字段工具会要求模型补齐。这一步是为了让后续主循环能够正确计算任务的可执行顺序而不需要模型在每一轮都重新脑算一遍。4. 文件持久化与任务续跑4.1 存储选型为什么我选了最笨的JSON文件待办清单的数据量并不大一个中型重构项目也不过几十个任务。这种量级根本不需要上数据库我用的是单个JSON文件加原子写路径默认放在项目根目录的 .codeterm/todo_list.json 下。为什么不用SQLite因为清单数据是强结构化的但字段里的detail和file_hints又带有自然语言查询需求低、不带事务边界SQLite带来的收益几乎可以忽略。为什么不用YAML因为JSON可以被模型更稳定地解析任何语言的默认库都能直接操作序列化反序列化零成本。原子写这步要注意我直接踩过坑。最开始我用的是fs.writeFileSync覆盖写入结果有一次终端进程被用户手动杀掉JSON文件写到一半状态损坏恢复时直接抛异常。后来改成三步式写先写临时文件再fsync落盘最后rename覆盖原文件。这在Linux和macOS上都是原子操作崩溃后要么拿到旧文件要么拿到新文件不会出现半截JSON。存储目录里我两个文件todo_list.json存当前任务todo_history.jsonl存历史变更记录。history文件只追加每条记录包含时间戳、操作类型、TodoId、变更前后的状态。这个文件不参与主循环是纯日志性质后面排查问题时非常好用。4.2 终端重启后模型怎么接着干实测里一个真实场景模型执行到一半用户关掉了终端第二天早上重新打开希望能继续昨天的进度而不是从头再来。恢复流程我做成三步。第一步启动时检查todo_list.json是否存在。不存在就走全新会话流程。存在则读出来做完整性校验——校验所有Todo项的id是否唯一、每个字段是否都有值、依赖项是否存在于列表中。第二步把清单灌回上下文让模型用一句话总结当前进度确认哪些已完成哪些待推进同时在终端界面打印一份人类可读的清单摘要给用户确认。第三步把系统Prompt里的任务规则重新注入一遍防止模型丢失行为约束。这里有个容易被忽略的细节恢复时不要直接把对话历史整个加载回来否则上下文会被旧内容占满。我采用的是部分恢复策略只保留最近两轮对话完整待办清单工具调用摘要。这相当于给模型一份会议纪要而不是会议录音信息密度更高效果也更好。选型对比可以总结成一张表方案优点缺点结论单JSON文件简单、人类可读、模型易解析并发能力弱适用配合原子写SQLite查询强、事务完整依赖重、模型不友好暂不需要纯内存性能最好重启即失忆仅做缓存层版本控制仓库天然留痕、可回滚依赖git状态作为辅助备份4.3 待办清单文件该不该进git仓库这个问题的答案比预想的反直觉。我的建议是把 .codeterm/ 目录纳入版本控制而不是塞进 .gitignore。理由很简单待办清单本质上是任务的执行档案。项目做完之后翻看当时的清单你能还原出整个任务拆解思路、哪些步骤被卡过、每一步动了哪些文件——这对代码评审和回溯非常有价值。我自己的项目里git提交信息中会附带Task id比如feat: 完成T-014缓存重构这样版本历史里直接能看到某次提交对应哪个任务项。当然如果你用的是共享仓库要不希望别人每次拉代码都看到你的个人终端状态那可以只提交todo_history.jsonl的主干部分把todo_list.json留在本地。这个看团队习惯不强求。但至少我自己的工具项目里保留这个档案让我在项目中期复盘时省了很多力气。5. 实测中的丢事场景定位和修掉三类典型故障5.1 场景一模型主观完成了任务但改动没有落地第一类故障最气人。模型在回复里明明白白说已完成缓存重构它的最终消息里甚至写了一长段总结但实际上仓库里一行代码都没变。我之前一直以为是模型在撒谎后来看历史记录才发现它把之前某轮讨论中提出过重构方案当成了已经执行过重构。修复办法前面已经提过——完成判定绑定工具执行结果。光靠模型自述不算数必须在任务状态下挂一个实际编辑记录字段把每次工具调用的文件路径累加起来。模型说完成时主循环自动检查该记录是否不为空。如果为空直接拒绝接受done状态并把任务打回in_progress。这相当于给模型加了一个说做不算做动码才算做的硬规则。5.2 场景二中途插入新需求旧任务被挤出视野第二种故障发生在一个长会话的后半段。用户在对话里随口提了一句哦对了顺便看看注册接口的异常处理模型立刻去看了注册接口讨论得热火朝天。等这个话题聊完模型已经把最开始那个三小时重构任务完全忘到脑后。这个问题在加待办之前几乎无解因为对话是非结构化的用户的随口需求随时会打断主线。有了清单驱动循环之后修复方案变成任何新需求必须调用add_todo工具入列表。模型不能因为用户提了就立刻行动而是先写进清单由主循环根据依赖和优先级决定什么时候执行。这条规则的代价是牺牲了一点交互上的即时感但换来了主线任务不丢失的巨大收益。如果用户确实想让模型立刻处理新需求我提供了顶层命令打断当前任务优先处理新需求。它会把当前in_progress任务手动降级回todo或blocked再新建任务插队。这个操作是显式的用户清楚知道发生了上下文切换模型也不会因为被突然打断而丢失主线目标。5.3 场景三依赖顺序错乱先做完了后置任务第三种故障是依赖关系没用起来的时候出现的。模型把两个任务拆好一个重构缓存一个更新调用方它可能会先做后者——因为更新调用方看起来更简单、更具体导致改完后发现被它调用的缓存接口根本不存在。依赖声明机制就是专门解决这个问题的。add_todo时模型必须填写depends_on主循环启动任务前也会硬校验。如果更新调用方依赖重构缓存而后者还没done前者的状态会被强制置为blocked模型拿着一个blocked任务什么也做不了只能回头推进依赖项。我还在主循环里加了一个依赖提示逻辑当模型试图跳过依赖项时给它显示的文本是你正在处理的任务被依赖项挡住依赖任务T-012 重构缓存todo请先完成该任务后重试。模型看到这种反馈一般都会老老实实掉头。这套机制跑了几十轮之后顺序错乱基本绝迹。故障与处理对照如下故障现象根因修复方案口头完成实际没改代码模型把讨论误认为执行完成判定绑定工具执行结果新需求挤掉主线任务对话干扰导致目标遗忘新需求强制走add_todo后置任务先执行模型没理解依赖关系依赖声明启动前硬校验任务被静默取消模型自行放弃困难任务禁止直接cancelled需用户确认6. 一些可复用的工程细节6.1 给每个Todo记录变更日志排查问题时别嫌麻烦做待办清单这事最容易被低估的是日志。我会在所有状态变更的出口处埋一个统一的logTodoChange函数把操作类型、时间戳、操作前的状态、操作后的状态、触发变更的工具名称这五元组追加到todo_history.jsonl。这行日志不占用主循环对性能毫无影响但有一天模型行为异常时你能靠它还原出完整的事件链路。举个例子某次模型把任务从todo直接改成了done我第一时间觉得是模型抽风。打开日志一查发现是模型先调用了一个被我命名为edit_code的工具工具返回成功然后紧接着又调用了一次update_todo_status把状态改了。两件事放在一起看逻辑其实是自洽的。如果没有日志光靠对话记录根本猜不到真相。6.2 终端界面上用前缀和颜色区分任务状态这个终端系统是字符界面没法在渲染上花太多功夫但状态区分必须一目了然。我用的是这样的前缀方案[x] 表示done渲染为灰色。[] 表示in_progress加亮白色。[ ] 表示todo正常默认色。[!] 表示blocked黄色。[-] 表示cancelled带删除线。界面每次重绘时优先展示in_progress任务把它固定在屏幕最上方而不按创建时间排序然后按优先级展示todo任务最后用折叠列表展示done任务。用户一眼就能看到当前卡点在哪、模型正在干什么无需打开额外面板。6.3 单测策略用一段固定脚本来模拟模型的乱序操作最后一个经验是关于测试的。待办清单的状态机不复杂但状态流转分支很多手工测试容易漏。我写了一个测试脚本用固定顺序调用状态机API模拟模型可能做的各种乱序操作——比如直接标记done、跳步、重复标记、删除不存在的任务。脚本跑完会断言最终状态是否符合预期。这个测试帮我提前抓到了两个bug一个是依赖校验在部分场景下不生效另一个是cancelled之后再次进入in_progress导致状态回退。有了这个脚本后续改主循环逻辑时心里踏实很多每次改动后跑一遍就知道状态机有没有被破坏。这套待办清单模块到今天跑下来最大的体会是它不聪明但它可靠。它没法帮模型写出更好的代码但能保证模型辛辛苦苦写出来的代码确实覆盖了所有该覆盖的点。下一步我准备把清单模块和终端的会话摘要机制进一步打通——让模型在每次对话轮次结束后自动把关键决策沉淀到任务detail里把外部记忆做得更厚实。如果你也在写类似的AI编码终端建议从一个小清单开始先让模型不丢事再去想它怎么多干事。