2026-10-05·编排与协作·Plan 系统
一个 Plan 步骤从 pending 到 done 看起来很简单。但在真实的 Agent 系统中,'完成'的语义出奇地复杂——模型说完成了就算完成吗?引擎要验证吗?验证失败怎么办?'完成'之后还能改吗?这篇文章讲七个真实 Bug,每一个都指向状态机设计的通用教训。
一个看似简单的状态迁移
Plan 步骤的生命周期,第一眼看上去不过是:
pending → in_progress → done
→ skipped
→ failed
五个状态、三条终态路径。写成代码不到 20 行。
然而,在一个真实的 Agent 引擎中——模型自主决定何时标记完成、引擎负责持久化和事件发射、前端负责渲染进度——这个看似简单的状态机引发了七个 Bug。每一个都不是代码写错了("编译通过了"),而是语义选择错了。
这篇文章按时间顺序讲这七个 Bug——不是为了批评过去的设计决策(每一个在当时的上下文中都看起来合理),是为了提炼状态机设计的通用教训。
七个 Bug 的时间线和影响一览:
| # | Bug | 症状 | 影响范围 | 发现方式 |
|---|---|---|---|---|
| 1 | 引擎偷偷改 Plan(auto-advance) | 恢复后状态不一致 | Run 中断恢复场景 | 用户报告恢复后步骤"倒退" |
| 2 | 过期缓存引发非法迁移 | "illegal transition: pending → done" | 委派场景 | 共享 SessionID 的子 Agent 触发 |
| 3 | 同状态重试报错 | CycleDetector 误终止整个 Run | 所有标 done 场景 | 模型冗余确认时触发 |
| 4 | complete + pending = 死锁 | pending 步骤被静默遗弃 | 模型提前声明完成 | 前端显示"完成"但进度只到 60% |
| 5 | 中断不持久化 | 刷新后 Plan "复活" | 所有 Plan 中断场景 | 用户关闭重开页面 |
| 6 | 双 Bool 非法组合 | 引擎说 Completed,前端说 Interrupted | nudge 耗尽后 plan(complete) | 事件和状态脱节 |
| 7 | 异常终止不终结 Plan | Plan 永久显示"进行中" | 11 种非正常终止路径 | 前端"死任务"永不消失 |
如果你在设计 Agent 的任务编排、工作流引擎、或者任何涉及"多步骤、多参与者、异步执行"的系统——这些教训可能帮你绕过同一类坑。
Bug #1:引擎偷偷改 Plan
症状
模型完成步骤 A 后,有时会发现步骤 B 的状态不是预期的 pending,而是 in_progress——但模型从未显式地把 B 标记为 in_progress。
根因
引擎有一个"auto-advance"逻辑——步骤 A 完成时自动激活下一步 B。这个逻辑修改的是内存中的 Plan 状态,不落盘。
后果是:模型看到的 Plan 状态和持久化的不一致。如果 Run 在此时中断并恢复,恢复后的 Plan 回到了持久化的版本(B 还是 pending),而模型的对话上下文里记着 B 是 in_progress。
教训
Plan 状态的唯一写入者应该是模型(通过 plan 工具),引擎不应该偷偷修改。
这和"Plan 从 DAG 退回线性清单"一章的核心结论一致——引擎不该替模型做认知判断。"下一步是什么"是一个认知决策,即使答案看起来很显然("线性清单的下一个 pending 步骤"),也应该由模型做。
auto-advance 最终被完全删除。引擎的职责简化为:接收模型的 plan 工具调用 → 校验状态迁移合法性 → 持久化 → 发射事件。不做任何隐式推进。
Bug #2:过期缓存引发"非法迁移"
症状
在委派(delegation)场景中——父 Agent 委派一个子 Agent 执行子任务,两者共享同一个 SessionID——子 Agent 尝试将步骤标记为 done 时,引擎报错 "illegal transition: pending → done"。
根因
Plan 状态通过 per-run Metadata 缓存读取。委派场景中,父 Agent 已经将步骤推进到 in_progress,但子 Agent 的缓存里还是旧的 pending 状态。子 Agent 尝试从 pending 直接跳到 done——这在状态迁移表中是非法的(必须经过 in_progress)。
教训
Plan 工具必须从权威存储(TaskStore)直读,不能走 per-run 缓存。
修复后,plan 工具的每次操作都走 load → mutate → save 路径,中间通过锁串行化。返回深拷贝(不是 store 内部指针),防止一个调用方的修改穿透到 store 绕过锁。
通用规则:多参与者共享的状态必须有单一权威源,且每次操作都从权威源读最新值。 per-run 缓存适合只读数据,不适合可变共享状态。
Bug #3:同状态重试报错
症状
模型调用 plan(update, step-3, done),步骤 3 已经是 done。引擎返回错误:"terminal state"。
这看起来合理——done 是终态,不该重复迁移。但后果不合理:CycleDetector 检测到相同的工具调用产出相同的错误,将其计入 identical call streak。几次之后触发 CycleDetector 终止。
模型因为"多标记了一次完成"被终止了整个 Run。
根因
同状态迁移(done → done)不是错误——是幂等成功。在网络请求重试、多轮对话中的冗余确认等场景中,幂等性是基本要求。
教训
修复很简单:done → done 返回 "already done (no-op)",不报错。
但引申出的设计原则是:在 Agent 系统中,错误消息的措辞直接影响模型的后续行为。 "terminal state" 这种报错会让模型产生困惑——它不知道这是"你做了不该做的事"还是"你已经做了这件事"。而 "already done (no-op)" 清楚地告诉模型:你要做的事已经生效了,继续就好。
错误文本附带迁移引导(比如 "Set status=in_progress first")可以进一步帮助模型从非法迁移中恢复,而不是盲目重试。
Bug #4:complete + pending 步骤 = 死锁
症状
模型调用 plan(action=complete) 声明 Plan 完成。但 Plan 中还有 pending 步骤。
引擎把 Plan.Completed 设为 true。
此时 Plan 进入一个奇怪的状态:Completed = true,但 3/5 步骤还是 pending。 前端渲染这种状态为"已完成"(因为 Completed=true),但进度条只到 60%(2/5 步骤 done)。
更糟糕的是,引擎有一个 nudge 机制——当 Run 即将结束但 Plan 还有 pending 步骤时,注入一条提示"你还有未完成的步骤"。这个 nudge 有一个门控:!Plan.Completed。
Completed=true 短路了 nudge。 Pending 步骤被静默遗弃——没有 nudge 提醒模型、没有前端警告、没有错误日志。
根因
complete 的语义不够清晰。它到底是"所有步骤都完成了"(final),还是"我觉得可以了"(declaration)?
模型的意图是后者——"虽然还有几步没做,但我认为任务已经完成了"。但引擎和前端都把它当成前者处理。
教训
最终的修复引入了一个新行为:plan(complete) 收口所有剩余 open 步骤。当模型声明 Plan 完成时,所有仍处于 pending 或 in_progress 的步骤被一并标记为 done(treated as "模型认为不需要做了")。
这确保了 Plan 的终态是一致的——不会出现 "Completed + pending" 的矛盾状态。
通用规则:状态机的终态必须是全局一致的。 如果一个"完成"状态允许子组件仍处于"未完成",那它不是真正的终态——它是一个矛盾体,会在每个消费它的地方(前端、nudge、统计、导出)引发不同的 Bug。
Bug #5:中断不持久化,Plan "复活"
症状
Run 被中断(用户取消或 CycleDetector 终止),前端通过 SSE 收到 EventPlanInterrupted 事件,正确显示"Plan 已中断"。
用户关闭页面重新打开,Plan 又变成了"进行中"。
根因
Interrupted 状态只走了 SSE 事件通道,没有写入 Plan 结构体。Plan 结构体中根本没有 Interrupted 字段——这个状态只存在于事件流中。
getSessionPlan 端点返回的 Plan 数据不包含中断标记。页面刷新后重新拉取 → Plan "复活"为上一次持久化的状态(进行中)。
教训
每一种终态都必须被持久化。 SSE 事件是实时通道,不是持久化层。如果一个状态变更只走事件不落存储,它就是"短暂的真相"——断连或刷新后消失。
这个 Bug 其实暴露了一个更深层的设计问题——当时 Plan 的终态只有 Completed 一种。Interrupted 作为终态不存在于数据模型中。修复它不只是"加个字段"——需要重新思考 Plan 的终态集合。
Bug #6:双 Bool 的非法组合
症状
修了 Bug #5 之后,Plan 有两个 Boolean 字段:Completed 和 Interrupted。
四种组合中有一种是非法的:Completed=true && Interrupted=true——一个 Plan 不可能同时"完成了"和"被中断了"。
但没有什么能阻止这种组合出现。
实际发生的场景:模型先 plan(complete) → Completed=true,然后 nudge 耗尽 → 引擎尝试设 Interrupted=true。此时迁移表拒绝了 completed → interrupted 的迁移,但 EventPlanInterrupted 已经无条件发射了——因为发射和迁移的代码不在同一个路径中。
结果:引擎状态是 Completed,前端事件说 Interrupted。 前端显示"中断",而引擎仍然认为 Plan 是成功完成的。
根因
双 Boolean 是错误的数据模型。Completed + Interrupted 有四种组合,其中一种是非法的。更糟糕的是,终结逻辑散在三处代码中(nudge 耗尽、Run 结束、plan(complete)),每处各自判断互相矛盾。
教训
终态必须是枚举,不是 Boolean 组合。
最终的重构把双 Bool 替换为一个 PlanStatus 枚举——四个值:
planning:初始化,模型还在生成 Planactive:Plan 生成完成,正在执行completed:模型声明完成(plan(complete)或所有步骤 done)terminated:异常终止(Run 中断、nudge 耗尽、验证失败)
加上两个迁移函数:ApplyCompletion()(active → completed)和 ApplyTermination(reason)(active → terminated)。迁移表校验合法性——terminated → completed 是合法的(恢复后重新完成),completed → terminated 是非法的。
事件发射和状态迁移在同一个原子操作中——迁移成功才发射事件,失败只 Warn 不发射。这消除了"状态说 A、事件说 B"的脱节。
通用规则:如果一个数据模型用多个 Boolean 表达互斥状态,迟早会出现非法组合。 枚举 + 迁移函数是更安全的设计——非法迁移在编译期(类型系统)或运行时(迁移表校验)被拒绝,而不是留给每个消费者自己判断。
Bug #7:Run 异常终止不终结 Plan
症状
Run 因为各种原因异常终止——预算用尽、CycleDetector 检测到循环、上下文错误、引擎内部 panic 恢复——但前端 Plan 显示 "5/6 进行中"。永远。
根因
异常终止路径(共 11 种非 end_turn 的终止原因)只发射 EventRunEnd,不处理 Plan 状态。Plan 在 Run 异常终止后保持最后一次持久化的状态——通常是 "active" + 若干 pending 步骤。
Plan 变成了"死任务"——永远不会完成,永远不会被标记中断。 前端没有渲染"死任务"的逻辑,所以它就静静地显示"进行中"。
教训
每一种 Run 终止路径都必须检查 Plan 并收口。 如果 Run 结束了但 Plan 还有 pending 步骤,必须发射 Plan 终结事件(terminated 或 completed)。
修复在 Run 结束的 defer 中统一补发:非 end_turn 终止 + 有 pending 步骤 → ApplyTermination(run_interrupted) + EventPlanInterrupted。只有 end_turn + nudge 耗尽路径除外(那里已经有自己的终结逻辑,避免重复发射)。
通用规则:父生命周期的终结必须递归终结所有子生命周期。 Run 是 Plan 的父生命周期——Run 结束时,Plan 必须被终结。这和进程退出时必须清理子进程、HTTP 请求结束时必须关闭响应体是同一个原则。
七个 Bug 的统一视角
回顾这七个 Bug,可以提炼出几条跨越它们的统一教训:
教训一:Agent 系统中的状态机比传统软件复杂得多
传统状态机的状态迁移由确定性事件驱动——用户点击按钮、定时器到期、网络消息到达。事件的含义是明确的。
Agent 系统的状态迁移由 LLM 的工具调用 驱动。这带来两个特殊挑战:
- 幂等性要求更高——模型可能在不同轮次中重复同一个调用(因为上下文窗口中有重复信息),同状态迁移必须是安全的
- 错误消息直接影响后续行为——模型会"学习"错误消息中的模式,不合适的报错措辞会引导模型走向错误的策略
教训二:终态必须是闭合的、持久化的、全局一致的
- 闭合:终态集合用枚举表达,非法组合在类型系统或迁移函数中被阻止
- 持久化:每种终态都必须写入存储,不能只走事件通道
- 全局一致:终态内部不能有矛盾("完成 + pending 步骤")
教训三:事件发射必须和状态迁移在同一个原子操作中
Bug #6 的直接原因是事件先发射、状态迁移后执行。当迁移被拒绝时,事件已经不可撤回地告诉了前端一个虚假的状态。
先迁移,成功后才发射。
教训四:父子生命周期必须递归终结
Run 结束 → Plan 必须终结。Plan 终结 → 所有 pending 步骤必须收口。不能留下"孤儿"状态——那些看起来还在"进行中"但永远不会有进展的步骤。
这和分布式系统中的租约(lease)超时机制是同一个原理:如果一个活动没有明确的 owner 在推进它,它必须在有限时间内被清理。
行业中类似的问题
Plan 状态机的复杂性不是我们独有的。
2026 年的一篇 ICSE 论文("Are Solved Issues in SWE-bench Really Solved Correctly?")发现,SWE-bench 上 7.8% 的"通过"补丁实际上没通过开发者测试套件,29.6% 的补丁与参考答案有行为差异。"通过"的语义比想象中复杂得多——通过局部测试 ≠ 通过完整测试 ≠ 行为正确。
Temporal(workflow 引擎)的一篇博文记录了 Grid Dynamics 从 LangGraph 迁移到 Temporal 的过程。核心问题之一是 LangGraph 依赖 Redis 做状态管理——"We had to carefully manage the lifecycle and expiration of state, ensuring common requests weren't accidentally wiped out by newer cache updates." 状态过期、竞态条件、重启后丢失——和我们 Plan 缓存过期引发的 Bug #2 是同一类问题。
Docker 在 2026 年 6 月的一篇博文中复盘了 Kiro 删除 AWS 生产环境的事故(2025 年 12 月)。Amazon 事后推行了"controlled friction"——每次变更需要两人签批。这和我们 auto-verify 回滚的教训指向同一个方向:自动执行的决策(删除环境/回滚步骤状态)必须有人或模型在回路中确认。
一个更直接的案例是"Silent Starvation"论文中描述的规划-执行脱钩:规划层做了 452 次选择,执行层执行了 0 次,7 天没有错误信号。Plan 看起来在推进,实际一步没走——和我们的 0/14 问题在形式上同构。
总结
Plan 步骤状态机不是一个 20 行代码就能搞定的东西。在一个 LLM 驱动、多参与者(模型 + 引擎 + 前端)、支持中断和恢复的系统中,它需要:
- 枚举终态而不是 Boolean 组合
- 单一权威存储而不是 per-run 缓存
- 原子的状态迁移 + 事件发射
- 递归的生命周期终结
- 对 LLM 友好的错误消息措辞
- 幂等的同状态迁移
- 持久化每一种终态
七个 Bug,同一个教训:状态机的复杂性不在状态数量——在状态迁移的语义和参与者之间的一致性。
你现在能做什么
1. 终态用枚举,不用 Boolean 组合。 如果你的系统有"完成""中断""失败"等终态——定义一个枚举类型(completed | terminated | failed),不要用 isCompleted: bool + isInterrupted: bool。四种组合中必有一种非法,迟早有人触发它。
2. 同状态重试返回"已完成"而非报错。 Agent 重复标记 done 应该是幂等成功("already done, no-op"),不是错误。否则 CycleDetector 会把冗余的确认动作当成死循环。
3. 事件发射和状态迁移写在同一个函数里。 不要先发事件再改状态——如果状态迁移被拒绝,事件已经不可撤回地告诉前端一个虚假的状态。先迁移成功,再发射事件。
4. 父生命周期结束时遍历所有子生命周期。 Run 结束 → 检查 Plan 是否有 pending 步骤 → 有则收口。不要留下"看起来还在进行中但永远不会推进"的孤儿状态——前端会把它渲染成永久的"进行中"。
5. 错误文案要带"下一步建议"。 "terminal state" 让模型困惑——它不知道该怎么办。"already done (no-op)" 或 "Set status=in_progress first" 告诉模型接下来可以做什么。在 Agent 系统中,错误消息的措辞直接影响模型的后续行为。
引用来源
- ICSE 2026, "Are 'Solved Issues' in SWE-bench Really Solved Correctly?" — dl.acm.org/doi/abs/10.…
- Temporal Blog, "From prototype to production-ready agentic AI: Grid Dynamics" — From prototype to production-ready agentic AI solution: A use case from Grid Dynamics | Temporal
- Docker Blog, "AI Coding Agent Horror Stories: The Agent That Deleted Production" — AI Coding Agent Horror Stories: The Agent That Deleted Production | Docker
- "Silent Starvation: Dependency Deadlock and Planning Loop Failure Modes" — zeltrex.com/papers/silent-starvation-2026.pdf
- arXiv:2606.14589, "When Errors Become Narratives: A Longitudinal Taxonomy of Silent Failures in a Production LLM Agent Runtime"
- Orchestrik, "AI Agent State Management: Why Stateless Agents Fail" — AI Agent State Management: Why Stateless Agents Fail — Orchestrik
- eClips Tech, "State Management in Agentic Workflows" — State Management in Agentic Workflows: The Problem Nobody Designs For | eClips Tech