长流程怎么支持中断和恢复?断点存在哪?
简化版
长流程的中断恢复要解决三件事:断点存什么、怎么停、怎么接着跑。断点只存「恢复所需的最小集合」:进度指针(跑到第几轮、第几步)和最新的中间产物(比如上一版修订稿),而且每完成一步就写库,不能只放在内存里。停分两条路:状态改成「已中断」后取消后台任务,任务在当前等待点被打断;同时流程每一步开头主动检查状态,挡住任务不在内存里的情况。接着跑时,先把上次停在「运行中」的半截步骤标记为失败,再用库里的进度和产物构造初始状态重新调度;服务重启后在启动阶段扫描所有「运行中」的记录,同样重新调度。断点可以用框架的 checkpointer,也可以直接存在业务表里,取决于页面和观测是不是已经在读业务表。
详细版
断点:run.current_round = 1,run.revised_json = 第一版修订稿(每步完成就写)
中断:UPDATE run SET status='INTERRUPTED' WHERE id=? AND status='RUNNING'
→ 影响 0 行:状态已变化,拒绝
→ 取消后台任务 → 被打断的那一步记为失败
流程每一轮开头:重新读状态,不是 RUNNING 就结束
恢复:状态改回 RUNNING → 重新调度
→ 把停在 RUNNING 的步骤记为失败
→ 初始状态 = {round_no: current_round, working: revised_json 或原稿}
重启:启动钩子扫描 status = RUNNING 的运行 → 逐个重新调度(走同一套恢复逻辑)
| 问题 | 做法 |
|---|---|
| 断点存哪 | 业务表的进度字段 + 产物字段,或框架 checkpointer |
| 什么时候写断点 | 每完成一个有产出的步骤立刻写,不等流程结束 |
| 怎么停 | 带条件改状态 + 取消任务 + 每步开头检查状态 |
| 怎么续 | 清理半截步骤 → 从断点构造初始状态 → 步骤号接着排 |
| 重启怎么办 | 启动阶段扫描「运行中」记录重新调度 |
完整版教学
一、为什么长流程需要中断恢复
一次多轮的 AI 流程可能跑几十秒到几分钟,中间有十几次模型调用。这期间会发生三类事:用户发现方向不对,想先停下;服务要发版重启;某次模型调用卡住,需要人工介入。如果不能中断,只能干等或者强杀进程;如果不能恢复,每次都要从头再来。
一次自检最多 5 轮,每轮 2 次调用,每次约 8 秒(示意)
跑到第 4 轮被重启打断:已花 3 × 2 × 8 = 48 秒、6 次调用
不能恢复:从头再来,这 6 次调用全部白花
能恢复: 从第 4 轮接着跑,只重做被打断的那一次
所以中断恢复本质上是保护已经花出去的成本,同时让长流程在运维上可控。
二、断点存什么:恢复所需的最小集合
断点不是把整个内存状态都存下来,而是只存「重新开始时必须知道的东西」:
| 存 | 不存 |
|---|---|
| 进度指针:已完成几轮、几步 | 可以重新计算的东西:模型配置、Prompt 模板(重新从库里读) |
| 最新的中间产物:上一版修订稿 | 函数、连接这类不能序列化的对象 |
| 本轮的判定结果:问题清单、得分 | 已经记录在步骤日志里的历史细节 |
写入时机也很关键:每完成一个有产出的步骤就写。审查结束写当前轮次,重写结束写新的修订稿。如果等整个流程结束才写,中途被打断时库里什么都没有。
记忆钩子:断点 = 进度指针 + 最新产物,每步一写。能从库里重新读出来的,都不用存。
三、怎么停:取消任务和主动检查两条路
中断请求到达时,后台任务可能处在三种位置:正在等一次模型调用返回;在两次数据库写入之间;或者根本不在当前进程的内存里(比如服务刚重启过)。一条路径覆盖不了全部情况:
| 手段 | 做法 | 覆盖的情况 |
|---|---|---|
| 取消任务 | 调用任务的 cancel(),任务在当前 await 处抛出取消异常 | 正在等待模型返回的任务,能立刻停 |
| 主动检查 | 每一轮开头重新读库里的状态,不是「运行中」就结束 | 任务不在内存里、或刚好停在两次写库之间 |
两条路要一起用。被取消的那一步已经写了「运行中」的步骤记录,还没来得及写结果,要在取消异常的处理分支里把它标成失败,比如写明「运行已中断,本步未完成」,否则时间线上会一直显示有一步在跑。
四、先改状态,再取消任务
中断接口里有一个顺序问题:先改库里的状态,还是先取消任务?
正确顺序:
1. UPDATE ... SET status='INTERRUPTED' WHERE id=? AND status='RUNNING'
影响 0 行 → 任务刚好在这一刻跑完了,告诉用户「状态已经变化」
2. 取消后台任务
任务收到取消异常时,库里已经是 INTERRUPTED,它只需处理半截步骤,不再改运行状态
第 1 步带着「当前必须是运行中」的条件更新,解决的是竞态:用户点中断的同一瞬间任务正好跑完并写了「成功」,不带条件的更新会把成功覆盖成中断。影响行数为 0 就说明状态已经变了,直接拒绝这次中断。状态机和带条件更新的一般设计,见「如何用状态机设计可恢复的 Agent?」。
五、怎么续:清理、构造、续号
恢复时后台任务从头执行主流程,但不是从头跑业务,而是按三步接上:
1. 清理:把这次运行下仍是「运行中」的步骤记为失败
(进程被强杀时,被打断的步骤来不及进入异常分支)
2. 构造:初始状态 = 已完成轮次 + 最新修订稿(没有修订稿就用原稿)
3. 续号:新步骤的序号 = 已有步骤条数 + 1,接着往下排
用一个例子看轮次怎么接:第 1 轮审查和重写都完成了(库里 current_round = 1、有第一版修订稿),第 2 轮审查的模型调用进行中被中断。恢复后初始状态是「已完成 1 轮 + 第一版修订稿」,第一个节点算出本轮是第 2 轮,重新审查第一版修订稿。时间线上被打断的那次第 2 轮审查保留为失败,新的第 2 轮审查另起一条记录。
六、进程重启:启动阶段接管所有「运行中」
服务进程退出时,内存里的后台任务全没了,但库里的运行记录还停在「运行中」。没人处理的话,这些记录永远停在那里。做法是在应用启动、开始对外提供服务之前扫描一遍:
启动钩子:
ids = SELECT id FROM run WHERE status = 'RUNNING'
for id in ids: 重新调度(id) ← 和「恢复」走同一个调度函数、同一套续跑逻辑
复用同一套逻辑很重要:重启恢复和手动恢复本质相同,都是「从断点接着跑」。如果任务是用「只有待执行状态才能被抢占」这类条件保护的,重启时还要先把停在「执行中」的记录退回「待执行」,否则重新调度也会被抢占条件挡住。
七、断点放在框架里还是业务表里
LangGraph 这类框架提供 checkpointer:按线程 ID 保存每一步的状态快照,官方有内存、SQLite、Postgres 等实现,配合 interrupt() 可以在节点里暂停、之后用 Command(resume=...) 继续。什么时候用它,什么时候自己存?
| 维度 | 框架 checkpointer | 业务表字段 |
|---|---|---|
| 接入成本 | 编译图时传入即可 | 要自己决定存哪些字段、何时写 |
| 存储 | 限于它支持的存储实现 | 用项目已有的数据库 |
| 可读性 | 快照是框架的内部格式 | 进度、产物就是业务字段,页面直接读 |
| 适合 | 状态复杂、没有现成的业务表 | 运行记录、步骤表已经存在,页面和观测都在读 |
如果项目已经有运行记录表和步骤表,实时进度、时间线、观测统计都从这两张表读,那么把进度和产物也存在这里最自然;再接一个 checkpointer 等于多一份存储、多一个数据源。
八、一步之内的「续做」
还有一种更细粒度的恢复:一个步骤本身产出了一部分就失败了。比如模型要排 5 天的行程,只写进库 3 天就中断了。此时不必整步重来,可以把已经写进库的部分查出来交给模型,只让它补剩下的:
第 1 次尝试:写入第 1–3 天,失败
第 2 次尝试:Prompt 追加「已排:第 1–3 天明细……请只补第 4–5 天」
续做要有次数上限,并且要区分失败原因:普通的「没做完」可以续做;被护栏主动拦下的(调用次数超限、原地打转)不应再续,否则又会撞到同一个护栏。
九、常见误区与追问
- 误区:断点等流程跑完再写。 中途被打断时库里什么都没有,断点要每完成一个有产出的步骤就写。
- 误区:取消任务就够了。 任务不在内存里、或停在两次写库之间时取消不到,还要每步开头主动检查状态。
- 误区:中断时直接改状态就行,不用加条件。 任务可能在同一瞬间跑完,不带条件的更新会把「成功」覆盖成「中断」。
- 误区:恢复就是把整个流程重新跑一遍。 要从断点构造初始状态,已完成的轮次不再重做。
- 误区:用了框架就一定要用它的 checkpointer。 运行记录已经存在业务表、页面都在读业务表时,断点存在业务表更简单。
- 追问:恢复时为什么要先把「运行中」的步骤标成失败? 进程被强杀时被打断的步骤来不及写结果,不清理的话时间线上会一直显示它在跑。
- 追问:被打断的那次模型调用算不算成本? 算,请求可能已经发出并计费,只是结果没拿到;所以断点粒度越细,重做的浪费越少。
十、加强记忆
长流程中断恢复记「存、停、续、启」:存的是进度指针加最新产物,每完成一个有产出的步骤就写库,能重新读出来的不存;停要两条路一起走,先带「当前是运行中」的条件改状态防竞态,再取消后台任务,被打断的步骤标成失败,同时每轮开头重读状态兜住任务不在内存的情况。续是清理半截步骤、用库里的轮次和产物构造初始状态、步骤号按已有条数续排;启是服务启动时扫描所有「运行中」的记录,用同一个调度函数重新接管。断点放框架 checkpointer 还是业务表,看页面和观测是不是已经在读业务表。一步之内还能续做,但被护栏拦下的不续。
项目实战落地
项目里怎么做的
《AI Agent智能会议纪要辅助系统》的纪要自检是后台跑的 LangGraph 反思环,页面上可以中断、恢复,服务重启后自动续跑:
- 断点在运行记录上:审查结束时
do_review写current_round,重写结束时do_refine写revised_json; - 中断:接口先用
AgentRun.filter(id=…, status="RUNNING").update(status="INTERRUPTED", stage="已中断"),返回 0 就报「自检运行状态已经变化」,然后cancel_agent_run取消后台任务; - 主动检查:注入状态图的
should_stop每次重新读运行记录,审查节点每轮开头先调它,不是 RUNNING 就返回interrupted,路由直接结束,不写汇总; - 恢复:接口把状态改回 RUNNING 并清掉上次的失败原因和结束时间,再
schedule_agent_run重新调度;execute_agent_run先把停在 RUNNING 的步骤记为失败,再把current_round和revised_json(没有就用纪要原文)作为状态图的初始状态; - 服务重启:FastAPI 生命周期钩子在对外提供接口之前依次恢复转写、纪要生成、纪要自检三类任务,自检的
recover_agent_runs把所有停在 RUNNING 的运行重新调度。
《AI Agent旅游行程智能规划平台》做的是一版之内的续排:模型这次只排了一部分天数时,已写入的明细保留,下一次尝试把它们从库里反查出来拼进 Prompt,只让模型补没排的天,复用同一个轮次号,最多 5 次;三道护栏拦下的不再续排。
为什么这样取舍
- 不用 LangGraph 的 checkpointer:运行状态本来就在业务表里,SSE 运行台、步骤时间线、观测中心都直接读运行表和步骤表;官方 checkpointer 只有内存、SQLite、Postgres 几种存储,项目用的是 MySQL,接进来等于再挂一个库,页面的数据源也要换。把
current_round和revised_json作为初始状态交给状态图,效果和 checkpointer 续跑一样。 - 取消之外还要
should_stop:任务停在两次数据库写入之间,或者_running_agents里没有这个任务(比如服务重启过)时,取消不到,要靠状态图里这一处检查。
面试官还会追问
- 中断时正在进行的那次模型调用,会写进 AI 调用日志吗?
- 同一份纪要已经有一个自检在运行或等待确认,还能再发起一次吗?为什么要这样限制?
- 删除一场会议时,自检的步骤表里没有会议 ID,这些步骤记录是怎么一起删掉的?
学完《AI Agent智能会议纪要辅助系统》,上面这些追问你都会迎刃而解。