在上一篇文章中我们深入拆解了 harness9 框架的 Tool-Calling 工具调用系统。本章我们继续介绍下 Planning 模块的设计。关于 harness9harness9 是一款 Local-First、轻量级、功能完备、生产可用的通用 Go Agent 框架。官网https://zhangshenao.github.io/harness9/GitHubhttps://github.com/ZhangShenao/harness9⭐ Star 是对开源工作最直接的支持欢迎提 Issue 和 PR。本文你将学到你将看清 Plan Mode 为什么用工具层白名单硬过滤而不是在 prompt 里说不要创建文件——以及这个区别在 Agent 工程中意味着什么你将理解 TodoStore 为什么选择全量替换而非增量 API以及双重 copy策略背后的数据竞争考量你将看到todo_write防作弊校验如何从一个真实 bug11 个任务被一次性批量完成演化为阈值 1的设计决策你将理解停滞检测stagnation detection为什么用done计数而非pending计数来判断进度你将掌握 FilePlanWriter 的路径策略git 项目与非 git 项目的持久化位置差异及其原因TL;DRharness9 的 Planning 模块把LLM 能做什么的控制权从 prompt 下沉到工具 schema把LLM 有没有真正在干活的校验从运行时观察变成前置拒绝把执行卡住了的判断从人工干预变成停滞计数器。三件事各找最合适的层来做没有上移也没有下移。Plan Mode一扇门不是一句话大多数 Agent 框架在规划阶段的实现方式是在 prompt 里加一段话“现在你处于规划阶段不要修改文件只做分析。” 这是软约束soft constraint。LLM 可以忘记它可以被历史上下文里的工具用例诱导绕过它可以在上下文压缩后丢失它。harness9 的做法是在 Planning 阶段把拥有写权限的工具从 Tool Schema 里把写工具直接卸载。// internal/engine/agent_loop.govarplanModeWhitelistmap[string]bool{read_file:true,bash:true,use_skill:true,todo_write:true,}funcfilterReadOnlyTools(tools[]schema.ToolDefinition)[]schema.ToolDefinition{varresult[]schema.ToolDefinitionfor_,t:rangetools{ifplanModeWhitelist[t.Name]{resultappend(result,t)}}returnresult}write_file和edit_file不在白名单里。Plan Mode 下LLM 收到的工具列表里根本不存在这两个工具——它在 API 层就不存在了而不是存在但被要求不要用。这是工具层硬约束hard constraint与 prompt 层软约束的本质差异前者是物理限制后者是行为建议。filterReadOnlyTools在runLoop内部每个 Turn 开始时调用而planMode本身在runLoop入口被快照// agent_loop.go — runLoop 入口e.mu.RLock()planMode:e.planMode// 快照一次整个循环内不变e.mu.RUnlock()// 每个 Turn 开始时ifplanModeplanning.PlanModePlan{availableToolsfilterReadOnlyTools(availableTools)}快照的意义TUI goroutine 可以在任何时候调用eng.SetPlanMode()但正在运行的runLoop已经拿到了开始时的模式副本不会在循环中途被切换。这是 harness9 处理 goroutine 间状态一致性的惯用手法——不是加锁保护整个循环而是在入口快照循环内读只读变量。工具层过滤之外runLoop还对用户 prompt 注入了行为引导前缀ifplanModeplanning.PlanModePlan{userPrompt分析以下请求用 todo_write 输出一份可直接执行的实现计划然后用纯文字简述计划后停止。\n// ...不要创建文件、执行 build/install 或做任何实际修改。\n\nuserPrompt}注意措辞prompt 说的是不要这么做而不是你没有权限这么做。权限由工具层决定prompt 只引导行为。两层分工清晰互不越权。TodoStore全量替换的设计取舍TodoStore是一个线程安全的内存任务列表但它的 API 设计是反直觉的——它没有Add、Update、Delete只有Write和Read。// internal/planning/todo.gotypeTodoStorestruct{mu sync.RWMutex items[]TodoItem}func(s*TodoStore)Write(items[]TodoItem)[]TodoItem{s.mu.Lock()defers.mu.Unlock()s.itemsmake([]TodoItem,len(items))copy(s.items,items)returns.copy()}为什么全量替换而非增量 APILLM 调用todo_write时它的自然输出形式是完整的任务列表而不是把第 3 项的 status 从 pending 改为 in_progress这样的增量指令。增量 API 要求 LLM 对当前状态有精确的认知——ID 拼错了状态就发散了。全量替换则不依赖 LLM 对历史状态的记忆每次写入都是一个确定性快照。实现简单是次要好处Write方法 5 行代码没有合并逻辑没有冲突处理。Write的双重 copy 策略值得注意// 第一次 copy入参 items 与内部存储解耦s.itemsmake([]TodoItem,len(items))copy(s.items,items)// 第二次 copys.copy()返回值与内部存储解耦returns.copy()调用方传进来的items切片、TodoStore内部的s.items、返回给调用方的副本三者各自独立。如果直接s.items items调用方后续修改原切片就会悄悄影响TodoStore内部状态。这类 bug 在并发环境下往往是间歇性的极难复现。双重 copy 用 20 字节的内存代价换来了确定性的隔离。状态转换约束刻意没有放在TodoStore里// TodoStatus 状态转换约束由 todo_write 工具tools 包负责执行TodoStore 本身不做校验。TodoStore是无判断的存储层业务约束由工具层表达。这个分层是蓄意的——TodoStore可以被测试代码直接写入任意状态不需要绕过校验逻辑而工具层的校验逻辑可以独立变化不需要改动存储层。todo_write防作弊的工程故事todo_write工具的防作弊校验不是从设计文档里推导出来的它来自一个具体的 bug。在一次连续对话中LLM 将 11 个任务中的 9 个一次性批量完成从 2/11 跳到 11/11没有对应的文件创建或 bash 执行操作。这是幻觉执行——LLM 省略了实际工作直接伪造进度。修复策略是在一次todo_write调用中最多允许 1 个任务从非in_progress状态直接跳转到completed// internal/tools/todo_write.go — Execute()prev:t.store.Read()prevStatus:make(map[string]planning.TodoStatus,len(prev))for_,item:rangeprev{prevStatus[item.ID]item.Status}vardirectCompletionsintfor_,item:rangeinput.Todos{ifitem.Status!planning.TodoCompleted{continue}prior,exists:prevStatus[item.ID]if!exists||priorplanning.TodoPending{directCompletions// pending → completed计入continue}ifpriorplanning.TodoCancelled{return,fmt.Errorf(任务 %q 已取消不能直接标记为 completed...,item.ID)}// in_progress → completed合法不计入}ifdirectCompletions1{return,fmt.Errorf(不允许在一次调用中将 %d 个任务直接标记为 completed未经 in_progress...,directCompletions)}阈值为什么是 1 而不是 0阈值 0 在续跑场景中会产生误伤Agent 在一次续跑中完成了一项真实工作调用了 bash 或 write_file然后直接把对应 todo 标记为completed而没有经过in_progress中间步骤——这是正当行为Agent 省略了状态标记的中间步骤但工作是真实的。阈值 0 会导致 Agent 反复收到拒绝错误并陷入重试循环。阈值 1 保留了对原始 bug 模式大量批量完成的防护同时允许单项直接完成这一正常用法。校验失败时todo_write返回error引擎将其包装为ToolResult{IsError: true}注入上下文。LLM 看到工具调用失败的错误信息被迫重新组织参数。循环不会终止Agent 自己修正自己——这是 harness9自愈self-healing设计的标准模式。执行 Prompt 的设计意图用户批准计划后TUI 不是简单地发送开始执行而是发送一段精心设计的规范// cmd/harness9/tui_update.goconstexecPrompt按照 todo 清单逐项执行。规则\n1. 每开始一项前用 todo_write 将其状态设为 in_progress\n2. 用工具完成该项的实际工作——创建文件、写代码、运行命令等仅更新 todo_write 状态而不调用其他工具不算完成该项\n3. 确认实际产出后用 todo_write 将其状态设为 completed\n4. 不要输出进度摘要文字立即处理下一项\n全部完成后用一句话汇报整体结果。规则 2 是关键“仅更新状态而不调用其他工具不算完成该项。” 这是 prompt 层对抗幻觉执行的约束与工具层的批量完成检测形成双重防护。一层是硬拒绝一层是行为引导——两层都在防同一件事但机制不同。续跑时用更精简的execContinuePromptconstexecContinuePrompt继续处理 todo 清单中下一个 pending 或 in_progress 的任务项。先用 todo_write 标记为 in_progress然后用工具完成实际工作写文件、执行命令等确认产出后标记为 completed再处理下一项。不要只更新状态而不做实际操作不要输出进度摘要。续跑不需要重复完整规则——LLM 的上下文里有execPrompt的历史已知晓基本框架。精简版只需要提示继续下一项减少无效 token 消耗。停滞检测用 done 计数而非 pending 计数自动执行autoExecuting模式下每次EventDone触发以下决策// cmd/harness9/tui_update.go — EventDone handlerifm.autoExecutingm.todoStore!nil{items:m.todoStore.Read()varpending,doneintfor_,item:rangeitems{switchitem.Status{caseplanning.TodoPending,planning.TodoInProgress:pendingcaseplanning.TodoCompleted:done}}ifpending0{ifdonem.autoExecPrevDone{m.autoExecStuck0// 有进度重置}else{m.autoExecStuck// 无进度计数}ifm.autoExecStuck3{m.autoExecPrevDonedonereturnm.dispatch(execContinuePrompt)}m.autoExecutingfalsem.linesappend(m.lines,dimStyle.Render( ⚠ 执行停滞请手动描述下一步))}else{m.autoExecutingfalse// 全部完成}}停滞检测的判断基准是done已完成数而非pending待完成数。这个选择值得展开说。pending的变化有两种来源任务真正完成pending → in_progress → completed和任务被标记为进行中pending → in_progress。如果用pending减少来判断进度LLM 只要不断把任务改成in_progress而不真正完成就能持续通过进度检测——这是另一种形式的幻觉执行。只有completed状态才代表真实的工作产出。done计数在一轮EventDone后没有增加意味着 LLM 运行了一整轮推理但没有推进任何任务到完成状态。连续 3 次如此停滞检测介入。阈值 3 是经验值给 LLM 一些缓冲空间应对需要多轮探索才能完成的复杂任务但不允许无限空转。dispatch()本身内置了并发保护func(m tuiModel)dispatch(promptstring)(tuiModel,tea.Cmd){ifm.running{returnm,nil// 已有推理在进行静默忽略}// ...}autoExecuting 续跑时dispatch由EventDonehandler 在 Elm Update 单线程循环内调用不存在并发问题。running检查是额外安全网防止其他代码路径意外触发双路推理。FilePlanWriter不只是写文件每次todo_write工具成功写入后如果注入了FilePlanWriter任务列表会被持久化为 Markdown 文件// internal/hooks/plan_writer.gofuncNewFilePlanWriter(workDir,homeDir,sessionIDstring)(*FilePlanWriter,error){timestamp:time.Now().Unix()slug:sessionID[:8]filename:fmt.Sprintf(%d-%s.md,timestamp,slug)varbasestringifisGitRepo(workDir){basefilepath.Join(workDir,.harness9,plans)}else{basefilepath.Join(homeDir,.harness9,plans)}// ...}路径策略有一个简单但有意思的分支isGitRepo(workDir)检测工作目录是否含有.git。git 项目写入workDir/.harness9/plans/——这个路径在项目目录下可以被纳入版本控制也可以通过.gitignore排除。把规划产物放在项目旁边让任务状态与代码变更保持上下文关联。非 git 项目写入homeDir/.harness9/plans/——没有项目目录的概念集中存放在 home 目录下的个人数据区不污染当前工作目录。这个判断是在构造时做的不是在每次写入时做的。isGitRepo调用一次路径固定下来Write方法每次覆写同一个文件而不是重新计算路径。PlanWriter接口定义在planning包而非hooks包// internal/planning/plan_writer.gotypePlanWriterinterface{Write(todos[]TodoItem)error}这是 harness9 一贯的接口位置原则接口定义在使用者侧而非实现者侧。TodoWriteTool使用PlanWriter接口就定义在planning包。FilePlanWriter实现这个接口但接口不在hooks包里声明。这个选择的实际作用是切断了tools包对hooks包的依赖——如果接口在hooks包tools就必须 importhooks而hooks又会 importtools循环导入立刻出现。跨 runLoop 的状态连续性TodoStore的内容随 Session 持久化到 SQLite每次runLoop启动和结束时自动同步// agent_loop.go — runLoop// 启动从 Session 恢复ifsess!niltodoStore!nil{iftodos,err:sess.GetTodos(ctx);errnil{todoStore.Write(todos)}}// 结束defer 保证所有退出路径都执行deferfunc(){ifsess!niltodoStore!nil{iferr:sess.SaveTodos(ctx,todoStore.Read());err!nil{log.Print(...)}}}()autoExecuting模式下每次续跑都是一次独立的runLoop调用。每次runLoop启动时从 DB 恢复TodoStore结束时写回——这确保了todo_write防作弊校验的正确性pending的任务在上次运行后保存到 DB下次运行时加载回内存prevStatus快照能准确反映任务的历史状态。如果不做持久化跨runLoop的状态对照就会失效批量完成检测就成了哑炮。defer是关键细节不管runLoop因为自然终止LLM 不再调用工具、MaxTurns 超限还是 context 取消而退出SaveTodos都会执行。上下文压缩时活跃任务会随摘要一起注入// internal/memory/summarization.go — Compact()ifc.TodoInjector!nil{iftodoText:c.TodoInjector.FormatForInjection();todoText!{summaryContent\n\n## Active Tasks\ntodoText}}压缩后的摘要消息末尾会追加## Active Tasks [ ] 实现 handler/user.go [] 配置数据库连接 [ ] 添加路由注册即使对话历史被压缩得面目全非未完成的任务也不会从 LLM 的视野中消失。与其他框架的差异约束在哪一层能力Claude Code / DeepAgents 常见做法harness9规划阶段限制写文件prompt 声明规划阶段不要写文件工具 schema 层硬过滤任务状态更新增量 APIadd/update by ID全量替换快照防止幻觉执行无或依赖 prompt 约束工具层 prompt 层双重校验执行停滞处理通常依赖 maxTurns 或人工干预自动停滞检测3 次无进度规划产物持久化通常仅存在对话历史中FilePlanWriter Markdown 文件harness9 的核心取向是把能用代码保证的事情从 prompt 声明降级到代码约束。不是因为 LLM 不可信而是因为代码约束是可测试的、可推理的、不依赖模型版本的。filterReadOnlyTools有单元测试todo_write的防作弊逻辑有单元测试停滞检测逻辑有单元测试。这些约束是工程产物不是 prompt 调优的副产品——它们的行为在模型升级后仍然确定。结语Planning 模块的真正价值不是给 Agent 加了个规划阶段而是把 Agent 行为的几个关键约束点从软层prompt挪到了硬层代码。每一个挪移都需要一个理由为什么这件事不能靠 prompt 说清楚答案通常是prompt 可以被忘记、被压缩、被绕过——代码不会。思考题todo_write的防作弊阈值是 1如果改成 2 会影响什么场景改成 0 又会影响什么