“完成”是什么意思——Plan 步骤状态机的七个 Bug

2026-10-05·编排与协作·Plan 系统

一个 Plan 步骤从 pending 到 done 看起来很简单。但在真实的 Agent 系统中,'完成'的语义出奇地复杂------模型说完成了就算完成吗?引擎要验证吗?验证失败怎么办?'完成'之后还能改吗?这篇文章讲七个真实 Bug,每一个都指向状态机设计的通用教训。

一个看似简单的状态迁移

Plan 步骤的生命周期,第一眼看上去不过是:

bash 复制代码
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:初始化,模型还在生成 Plan
  • active: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 的工具调用 驱动。这带来两个特殊挑战:

  1. 幂等性要求更高------模型可能在不同轮次中重复同一个调用(因为上下文窗口中有重复信息),同状态迁移必须是安全的
  2. 错误消息直接影响后续行为------模型会"学习"错误消息中的模式,不合适的报错措辞会引导模型走向错误的策略

教训二:终态必须是闭合的、持久化的、全局一致的

  • 闭合:终态集合用枚举表达,非法组合在类型系统或迁移函数中被阻止
  • 持久化:每种终态都必须写入存储,不能只走事件通道
  • 全局一致:终态内部不能有矛盾("完成 + 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 系统中,错误消息的措辞直接影响模型的后续行为。


引用来源

相关推荐
隐向量18 分钟前
给Agent装上连接外部世界的双手 - 工具调用
agent
米小虾22 分钟前
给正在干活的 Agent 插一句话:steering 的原理与实现
人工智能·agent
jerrywus27 分钟前
让 AI 帮你部署 Vercel,报错了也不用手动搬日志
前端·agent·mcp
柯南466833 分钟前
【AI工程师精讲】32:Text2SQL:让 AI 查数据库,难点八成不在生成 SQL 上
人工智能·ai编程
Trae_陈冀35 分钟前
写详细设计总堆代码没逻辑?我做了个自动生成工具,3套模板+规范内置,输出直接过评审
架构·agent
QuZhengRong1 小时前
【Luck‑Report】 AI 智能报表助手 + RAG 知识库 + 报表引擎 V2.0.9 更新
github·agent·报表·开源项目·数据可视化
艺杯羹2 小时前
MCP本地服务集成避坑:从Stdio管道僵死到双向JSON-RPC稳健通信
人工智能·python·ai·系统架构·大语言模型·ai编程·mcp
武子康2 小时前
h3.c 的两个权重槽,怎样把内存压力换成 SSD 等待
c语言·人工智能·agent
大模型真好玩2 小时前
豆包真人分身操作技巧:这次让我来做主演
人工智能·agent·deepseek