状态机与 Checkpoint:中断恢复与死循环防护
一句话定位:状态机与 Checkpoint 是 Harness 部件 ③状态 的「进度」半边——记忆管记性,这套机制管「任务进行到哪了」:打断可恢复、失败可复现、卡死能自愈。前置:什么是 Harness、模型调用与容错(单次调用的重试,本篇管整任务的恢复)、记忆机制(状态外部化是本篇的地基)、权限机制(等人审批是一种状态)。框架里现成的对应物见 LangGraph 的存档机制——本篇讲手写时要造哪些零件。
先从三种「从头再来」说起
你让 Agent 跑一个四十步的数据迁移,跑到第 27 步,它要执行一个数据库变更——权限机制弹窗等你审批。这时你刚好走开了。
接下来有三种可能。你十分钟后回来,点了批准,它从第 27 步继续——前提是它记得自己进行到哪;你第二天才回来,它要么还在干等、要么早被超时杀掉——重启后从第 1 步重来,前 26 步白干;更糟的是第 27 步本身一直失败,它反复重试到天亮,烧了一晚上 token 就在同一个坑里打转。
三种结局的分水岭,就是 Harness 有没有把「任务进行到哪」当成一份数据来管理——有没有状态机,有没有存档。
怎么把「进行到哪」记下来,让打断可恢复、失败可复现、卡死能自愈?
答案是把任务建模成一台状态机:每个状态转移都是一次存档(下文的 Checkpoint)的机会,每个异常状态都连着明确的下一步(重试、等人或放弃),每个循环都带着计数器。进度是数据,不是上下文里的感觉。
图:任务状态机——主干向前,两条回环转移(失败重试、等人批准)都能回到 running;三个终态各收一条单向边
渐进拆解
类比:这套机制就是游戏存档。打 Boss 前存档(进危险状态前先落盘)、阵亡读档(中断恢复)、卡关看攻略提示(错误恢复注入)、同一关死太多次换个打法(死循环熔断)。游戏的贴心之处在于存档点安排在关键动作之前——Harness 的 Checkpoint 也一样,存的是「下一步要做什么的上下文」,不是「屏幕截图」。
把开头那个「等人审批」的场景走完整——看 waiting_approval 怎么与权限机制衔接而不卡死主循环:
图:waiting_approval 的衔接——权限机制出裁决,状态机管等待,Checkpoint 管进度不丢
大白话:状态机就管三件事。
- 进度要落盘:「我做到第 27 步了」必须写在纸面上,不是模型「记得」。
- 异常要有去处:失败不是死胡同——tool_failed 后面写着「记次数、重试或放弃」,waiting_approval 后面写着「等人」。
- 循环要有计数:「再试一次」说三次以上,就得有人(或熔断器)喊停。
技术定义:
八状态与转移
本篇综合多方材料整理出八个状态。主干:created(受理)→ planning(拆解计划)→ running(执行),外加 completed / failed / cancelled 三个终态;running 的两条回边——经 tool_failed(工具失败,步骤账本记下错误后回 running 重试,重试耗尽转 failed)与经 waiting_approval(等人裁决,批准回 running、拒绝按 tool_failed 处理带理由),转移细节见上面的状态机图。
一次性计划 vs 动态计划:planning 阶段还分一次性计划(开工前拆完所有步骤)与动态计划(边跑边调整)——动态计划里「计划本身」也是要随状态存档的一部分。
待办清单:计划拆出的每一步落进清单、写文件而不是写在对话里(状态外部化),完成一项划掉一项;Checkpoint 里的「待办快照」就是它的瞬间像,中断恢复后第一件事就是把清单读回来——进度感不靠回忆,靠这张纸。
Checkpoint 存什么
存「位置」,不存「行李」——这是手写 Harness 的默认取向(框架级实现如 LangGraph 默认存全量图状态,含消息历史,省心但恢复时全量注入也挤占新会话的预算)。具体存五样:
- 当前状态与步骤号。
- 已完成步骤的清单。
- 关键中间产物的引用——文件路径、外部系统的记录号;本体放在文件系统里,这就是状态外部化。
- 待办清单的快照。
- 计划快照——动态计划改到了哪一步、后续步骤是什么;一次性计划只需一个指针。
不存全量上下文——上下文是可以重建的:摘要加工作集的方式与压缩后的重建同理(最近文件、激活技能、工具清单),消息历史丢失的部分靠中间产物文件重放。存全量上下文的存档又大又脆,存的其实是「无法重建的部分」的引用。
其余技术定义:
- 步骤账本(又叫重试记录):running 不是原子态,内部是每一步的小账本——每步记着当前值、试了几次、上次错在哪。这本账是两件东西的原料:重试决策(还该不该再试)与死循环识别(同一错误试了几次)。
- 中断恢复、失败复现与人工接管:三者共用同一份存档——中断恢复(读档从断点继续)、失败复现(存档里有「上次错在哪、用什么输入错的」,可以原地重放复现)、人工接管(人直接从存档接手跑几步,再把状态交还——存档格式对人是可读的,又是状态外部化的好处)。
- 死循环识别与行为干预:计数器盯两类循环——同一工具同一参数反复失败,或模型反复产出相同动作没有进展。轻则注入提醒干预(走 system-reminder 通道),重则熔断转 failed 交还给人;渐进三段见下图,为什么先提醒后熔断见「边界与取舍」一节。
- 上下文感知的错误恢复提示:错误发生后注入的恢复提示不该只有错误栈,还要带上下文:错误发生在哪一步、之前试过什么、按计划下一步本来该干什么。同一个「文件没找到」,第 3 步遇到多半该重读或检查路径,第 27 步遇到则先查产物引用里记录的文件名是否被前序步骤改过——提示带上下文,自救才有方向。
动手:转移、存档与熔断
伪代码把三件事串起来(注释呼应状态机):
def step_loop(task):
while task.state not in TERMINAL: # 三个终态:completed/failed/cancelled
if task.state in ("created", "planning"):
task.plan_or_replan() # 拆解/重计划:产出步骤清单再进入执行
task.state = "running" # 计划本身也随状态存档(见 checkpoint 第五样)
if task.state == "running": # 关键转移前落盘(示意简化在循环顶,
checkpoint(task) # 实际锚定危险动作与阶段切换,见下节)
if task.state == "running":
if loop_detected(task): # 原地打转(无失败也查,见下文)
task.remind_or_fuse() # 先提醒(计数重置),到顶熔断
continue # 熔断后当轮不再执行步骤
result = run_step(task.current)
if result.ok:
task.advance() # 下一步;计划动态调整也在此
elif result.needs_approval: # 由权限裁决置入(见等人审批时序图)
task.state = "waiting_approval"
else:
task.state = "tool_failed"
task.record(result.error) # 步骤账本:错误 + 次数 +1
elif task.state == "tool_failed":
if task.retries(task.current) >= MAX_RETRY: # MAX_RETRY 高于提醒阈值
task.state = "failed" # 重试耗尽:终态,落盘后带全账本
checkpoint(task)
else:
task.state = "running" # 带着记录重试
elif task.state == "waiting_approval":
v = poll_decision(task) # 挂起轮询,不占模型调用
if v is APPROVED:
task.state = "running" # 读档续跑
else:
task.state = "tool_failed" # 拒绝理由进步骤账本
# cancelled 由人叫停的外部信号直接置入终态,不进本循环
def checkpoint(task):
save(state=task.state, step=task.current, # 位置
done=task.done_list, # 已完成清单
refs=task.artifact_refs, # 中间产物引用(本体在文件)
todos=task.todos, # 待办快照——上下文不存
plan=task.plan) # 计划快照:动态计划改到哪、后续步骤是什么
同一概念的叫法对照:
| 本篇用词 | 文献中的其他叫法 | 深挖去处 |
|---|---|---|
| 任务状态机 | 任务生命周期 · 八状态模型 | 本篇 |
| Checkpoint | 存档 · 快照 · 断点续跑 | LangGraph 存档机制 |
| 重试记录 | 步骤账本 · 尝试历史(调用层的单次重试是它的最小版本) | 本篇 |
| 行为干预 | System Reminders 循环打断 | 本篇 |
| 待办清单 | 待办管理 · 任务清单 | 记忆机制 |
死循环防护单独画出来——它是三段渐进的,不是一刀切:
图:死循环防护的渐进三段——计数、提醒、熔断,跳过提醒直接熔断会误伤「正当绕路」
边界与取舍:存档不是越密越好
简单任务不需要八状态
三状态(待跑 / 在跑 / 完成)加一个失败态就够——状态机的复杂度要配任务的时长与打断概率。一次性五分钟的脚本任务,全套状态机是过度设计;跨天、多人、带审批的长任务才值得完整八态。
Checkpoint 的密度是个折中
每步一存,慢且冗余;十步一存,出事丢十步进度。实用的锚点是关键动作前存(危险操作、外部副作用、阶段切换)——与游戏存档同一个思路,存档点安排在「死了会很亏」的位置。存档本身要轻:存引用不存本体(中间产物放文件系统),存档动作才不会变成新的性能瓶颈。
熔断会误伤正当的绕路
有些任务「看起来在原地打转」其实是在枚举尝试(逐个试十种配置里哪一种能通)——把「相同工具」判成死循环就误伤了。缓解:计数的判据用「工具 + 参数 + 错误类型」三元组而不是单看工具,且先提醒后熔断,给模型自证「这不是死循环」的机会(改路径后计数重置)。
坏味道预警
如果你的 Agent 中断重启后「记得结论、忘了过程」(说得出做到第几步、却要重读所有文件),缺的是中间产物引用——存档里只有位置没有行李清单;如果等待审批时整个程序卡住,是把「任务的等待」做成了「进程的阻塞」——挂起的该是状态机,不是主循环。
自测
合上文章先自己答,再展开对照:
Checkpoint 里存什么才能既可恢复、又不必存全量上下文?
存「位置」,不存「行李」:五样东西——当前状态与步骤号、已完成步骤的清单、关键中间产物的引用(文件路径、外部记录号,本体放在文件系统里,即状态外部化)、待办清单快照、计划快照(动态计划改到哪、后续步骤是什么)。不存全量上下文的原因是上下文可重建:重建方式与压缩后的工作集重建同理(最近文件、激活技能、工具清单),消息历史丢的部分靠中间产物文件重放回来。判据一句话:**无法重建的部分才值得进存档,能重建的只存重建线索(引用)**——存全量上下文的存档又大又脆,恢复时还要全量注入,挤占新会话的预算。注意这是手写 Harness 的默认取向:框架级实现(LangGraph)默认存全量图状态,省心但更重。
waiting_approval 状态怎么和权限机制衔接而不卡死主循环?
分工是「权限出裁决、状态机管等待」:running 中的工具调用被权限机制判为需要人裁决时,任务先做一次 Checkpoint 落盘(当前步骤、已完成清单、产物引用),再转入 waiting_approval 挂起——挂起的是任务状态机,不是进程:主循环空转轮询裁决结果、不占模型调用,也不重发请求(弹窗只弹一次),人来早来晚都不丢进度。批准则读档从断点续跑(转回 running);拒绝则把拒绝理由作为工具结果转 tool_failed,模型带着理由换路径重试。产品层在用户输入间隙的投机预执行(见权限篇)与本层不矛盾——预计算的是建议输入的操作结果,挂起的是任务进度。两个衔接点别做错:等待期间不重发请求(重复弹窗),拒绝后不让模型盲试(理由必须进步骤账本)。
参考与延伸
- Anthropic 工程博客《Building Effective Agents》(2024-12):「Agent 可以在检查点暂停等待人的反馈」(human checkpoints)——waiting_approval 设计的出处 — anthropic.com/engineering/building-effective-agents
- LangGraph 官方文档:Checkpoint 持久化与线程恢复的框架级实现(存全量图状态的对照物) — docs.langchain.com/oss/python/langgraph/persistence
- Claude Code 官方文档:权限模式(plan 档的计划期行为与 auto 档的熔断阈值) — code.claude.com/docs/en/permission-modes
本系列的后续学习路线:多 Agent 编排——本系列机制的收官之作,把前面全部零件拼成一个团队。