Harness 工程 · 状态机
学习舱 / Harness 工程 / 状态机

状态机与 Checkpoint:中断恢复与死循环防护

整理中 深度 · 应用 更新 2026-09-22

前置 Harness 概览、模型调用、记忆机制、权限机制

状态机与 Checkpoint:中断恢复与死循环防护

一句话定位:状态机与 Checkpoint 是 Harness 部件 ③状态 的「进度」半边——记忆管记性,这套机制管「任务进行到哪了」:打断可恢复、失败可复现、卡死能自愈。前置:什么是 Harness、模型调用与容错(单次调用的重试,本篇管整任务的恢复)、记忆机制(状态外部化是本篇的地基)、权限机制(等人审批是一种状态)。框架里现成的对应物见 LangGraph 的存档机制——本篇讲手写时要造哪些零件。

先从三种「从头再来」说起

你让 Agent 跑一个四十步的数据迁移,跑到第 27 步,它要执行一个数据库变更——权限机制弹窗等你审批。这时你刚好走开了。

接下来有三种可能。你十分钟后回来,点了批准,它从第 27 步继续——前提是它记得自己进行到哪;你第二天才回来,它要么还在干等、要么早被超时杀掉——重启后从第 1 步重来,前 26 步白干;更糟的是第 27 步本身一直失败,它反复重试到天亮,烧了一晚上 token 就在同一个坑里打转。

三种结局的分水岭,就是 Harness 有没有把「任务进行到哪」当成一份数据来管理——有没有状态机,有没有存档。

怎么把「进行到哪」记下来,让打断可恢复、失败可复现、卡死能自愈?

答案是把任务建模成一台状态机:每个状态转移都是一次存档(下文的 Checkpoint)的机会,每个异常状态都连着明确的下一步(重试、等人或放弃),每个循环都带着计数器。进度是数据,不是上下文里的感觉。

任务状态机:八个状态、两条回边 created 任务已受理 planning 拆解与计划 running 逐步执行中 tool_failed 步骤账本记错误 waiting_approval 挂起等人裁决 completed 验证通过收官 failed 重试耗尽放弃 cancelled 人叫停 重试 批准 拒绝 重试耗尽 tool_failed 与 waiting_approval 是两个回环状态:失败记进步骤账本再重试,等人裁决后读档续跑

图:任务状态机——主干向前,两条回环转移(失败重试、等人批准)都能回到 running;三个终态各收一条单向边

渐进拆解

类比:这套机制就是游戏存档。打 Boss 前存档(进危险状态前先落盘)、阵亡读档(中断恢复)、卡关看攻略提示(错误恢复注入)、同一关死太多次换个打法(死循环熔断)。游戏的贴心之处在于存档点安排在关键动作之前——Harness 的 Checkpoint 也一样,存的是「下一步要做什么的上下文」,不是「屏幕截图」。

把开头那个「等人审批」的场景走完整——看 waiting_approval 怎么与权限机制衔接而不卡死主循环:

等人审批:挂起、读档、续跑 running · 第 27 步 高危数据库变更 权限裁决:问人 弹窗等待输入 转 waiting_approval Checkpoint 落盘后挂起 挂起态(非下一步) 主循环空转不占调用 人批准 → 读档 从第 27 步续跑 人拒绝 拒绝理由转 tool_failed 「不卡死」的关键:挂起的是任务不是进程——轮询等待、不重发请求(弹窗只弹一次),批不批、什么时候批,都不丢进度

图: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 默认存全量图状态,含消息历史,省心但恢复时全量注入也挤占新会话的预算)。具体存五样:

  1. 当前状态与步骤号。
  2. 已完成步骤的清单。
  3. 关键中间产物的引用——文件路径、外部系统的记录号;本体放在文件系统里,这就是状态外部化。
  4. 待办清单的快照。
  5. 计划快照——动态计划改到了哪一步、后续步骤是什么;一次性计划只需一个指针。

不存全量上下文——上下文是可以重建的:摘要加工作集的方式与压缩后的重建同理(最近文件、激活技能、工具清单),消息历史丢失的部分靠中间产物文件重放。存全量上下文的存档又大又脆,存的其实是「无法重建的部分」的引用。

其余技术定义:

  • 步骤账本(又叫重试记录):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 循环打断本篇
待办清单待办管理 · 任务清单记忆机制

死循环防护单独画出来——它是三段渐进的,不是一刀切:

死循环防护:先提醒,后熔断 计数(步骤账本) 同一工具 + 同一参数 + 同类错误 = 计数 +1 提醒干预(轻) 注入 system-reminder: 「已重复三次,换条路径试试」 熔断(重) 计数到顶仍无进展 转 failed,连账本交还给人 先提醒再熔断:把「看见问题」留给机制,把「怎么改」先留给模型——它有时能找到人想不到的绕路

图:死循环防护的渐进三段——计数、提醒、熔断,跳过提醒直接熔断会误伤「正当绕路」

边界与取舍:存档不是越密越好

简单任务不需要八状态

三状态(待跑 / 在跑 / 完成)加一个失败态就够——状态机的复杂度要配任务的时长与打断概率。一次性五分钟的脚本任务,全套状态机是过度设计;跨天、多人、带审批的长任务才值得完整八态。

Checkpoint 的密度是个折中

每步一存,慢且冗余;十步一存,出事丢十步进度。实用的锚点是关键动作前存(危险操作、外部副作用、阶段切换)——与游戏存档同一个思路,存档点安排在「死了会很亏」的位置。存档本身要轻:存引用不存本体(中间产物放文件系统),存档动作才不会变成新的性能瓶颈。

熔断会误伤正当的绕路

有些任务「看起来在原地打转」其实是在枚举尝试(逐个试十种配置里哪一种能通)——把「相同工具」判成死循环就误伤了。缓解:计数的判据用「工具 + 参数 + 错误类型」三元组而不是单看工具,且先提醒后熔断,给模型自证「这不是死循环」的机会(改路径后计数重置)。

坏味道预警

如果你的 Agent 中断重启后「记得结论、忘了过程」(说得出做到第几步、却要重读所有文件),缺的是中间产物引用——存档里只有位置没有行李清单;如果等待审批时整个程序卡住,是把「任务的等待」做成了「进程的阻塞」——挂起的该是状态机,不是主循环。

自测

合上文章先自己答,再展开对照:

Checkpoint 里存什么才能既可恢复、又不必存全量上下文?

存「位置」,不存「行李」:五样东西——当前状态与步骤号、已完成步骤的清单、关键中间产物的引用(文件路径、外部记录号,本体放在文件系统里,即状态外部化)、待办清单快照、计划快照(动态计划改到哪、后续步骤是什么)。不存全量上下文的原因是上下文可重建:重建方式与压缩后的工作集重建同理(最近文件、激活技能、工具清单),消息历史丢的部分靠中间产物文件重放回来。判据一句话:**无法重建的部分才值得进存档,能重建的只存重建线索(引用)**——存全量上下文的存档又大又脆,恢复时还要全量注入,挤占新会话的预算。注意这是手写 Harness 的默认取向:框架级实现(LangGraph)默认存全量图状态,省心但更重。

waiting_approval 状态怎么和权限机制衔接而不卡死主循环?

分工是「权限出裁决、状态机管等待」:running 中的工具调用被权限机制判为需要人裁决时,任务先做一次 Checkpoint 落盘(当前步骤、已完成清单、产物引用),再转入 waiting_approval 挂起——挂起的是任务状态机,不是进程:主循环空转轮询裁决结果、不占模型调用,也不重发请求(弹窗只弹一次),人来早来晚都不丢进度。批准则读档从断点续跑(转回 running);拒绝则把拒绝理由作为工具结果转 tool_failed,模型带着理由换路径重试。产品层在用户输入间隙的投机预执行(见权限篇)与本层不矛盾——预计算的是建议输入的操作结果,挂起的是任务进度。两个衔接点别做错:等待期间不重发请求(重复弹窗),拒绝后不让模型盲试(理由必须进步骤账本)。

参考与延伸

本系列的后续学习路线:多 Agent 编排——本系列机制的收官之作,把前面全部零件拼成一个团队。

本节点大纲与自测题(7 个子点 · 2 问)

大纲

  • 任务状态机:created / planning / running / tool_failed / waiting_approval / completed / failed / cancelled
  • 步骤状态与重试记录
  • Checkpoint:中断恢复、失败复现、人工接管续跑
  • 死循环识别与 System Reminders 行为干预
  • 上下文感知的 Error Recovery 提示注入
  • Planning 与任务拆解:一次性计划 vs 动态计划
  • 待办 / 任务清单管理

自测——答不上来就是缺口

  • Checkpoint 里存什么才能既可恢复、又不必存全量上下文?
  • waiting_approval 状态怎么和权限机制衔接而不卡死主循环?