news 2026/10/19 2:11:32

Agent 的中断与恢复:跑到一半能停下来吗

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent 的中断与恢复:跑到一半能停下来吗
个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <

文章目录

  • Agent 的中断与恢复:跑到一半能停下来吗
    • 一、为什么中断比想象的难
      • 1.1 一次中断到底要停掉多少东西
      • 1.2 中断后的三种可能状态
      • 1.3 恢复 ≠ 重跑
    • 二、中断的几种类型
      • 2.1 用户主动中断(优雅中断)
      • 2.2 超时中断(系统判定)
      • 2.3 异常中断(被动)
      • 2.4 部署中断(进程被杀)
    • 三、恢复的技术基础:持久化
      • 3.1 状态外置是前提
      • 3.2 WAL:副作用前的意图日志
      • 3.3 幂等设计
    • 四、中断的实现:分层取消
      • 4.1 一个可取消的任务树
      • 4.2 优雅停止 vs 强制停止
    • 五、恢复的两种形态
      • 5.1 热恢复:暂停后接着跑
      • 5.2 冷恢复:换个进程接着跑
    • 六、什么时候值得做
      • 6.1 值得做的信号
      • 6.2 不值得的信号
      • 6.3 最小可用版
    • 七、常见误区
    • 小结

Agent 的中断与恢复:跑到一半能停下来吗

一个 Agent 接到「跑全量数据迁移」的任务,跑了 20 分钟,你发现参数配错了。你点了停止——然后呢?

如果它不能保留进度,你就得从头再来。如果它保留了进度但参数是错的,恢复出来的结果就是错的。如果它「停了」但底层子进程还在跑,你得到的是一个僵尸任务。

长任务 Agent 的中断与恢复,比听起来复杂得多。这一篇讲清楚:中断有哪几种、恢复需要什么、以及怎么设计才能既安全又实用。


一、为什么中断比想象的难

1.1 一次中断到底要停掉多少东西

一个「跑全量迁移」的任务,同时可能跑着:

  • 一个 Plan-and-Execute 的主循环
  • 3 个子任务线程(并发跑)
  • 每个子任务里的 LLM 调用(流式响应中)
  • 2 个 HTTP 请求(正在等远端返回)
  • 1 个本地执行器进程(正在跑生成的数据处理代码)
  • 一个状态写库的后台任务

点了「停止」,上面每一个都要停。只停主循环,下面的还在跑——这就是僵尸任务。

1.2 中断后的三种可能状态

中断 ┌────┴────┐ ▼ ▼ 放弃 暂停 │ │ ▼ ▼ 丢弃所有 保留现场 进度 │ ├─► 改参数后继续(热恢复) └─► 稍后继续(冷恢复,需持久化)

暂停才是难点,因为它要求「现场」是可保存的。现场包括:任务状态、已完成的结果、计划、中间产物、以及下一步该干什么。

1.3 恢复 ≠ 重跑

很多人把恢复做成「重新执行整个任务,跳过已完成的步骤」。这有两个问题:

  1. 副作用会重复。已完成的步骤里如果有「发邮件」「写数据库」,重跑就再发一次、再写一次。
  2. 慢。如果 80% 的步骤已完成,重跑意味着 80% 的时间白花。

正确的恢复是「从断点继续」,而断点的定义取决于副作用是否幂等。


二、中断的几种类型

按来源分,处理策略完全不同。

2.1 用户主动中断(优雅中断)

用户点「停止」或「暂停」。这是最理想的情况——你有机会做清理。

classInterruptibleExecutor:def__init__(self):self._cancel=threading.Event()defrequest_stop(self,graceful:bool=True):self._cancel.set()self._graceful=gracefuldefrun_step(self,step):forsubinself._running_subs:sub.check_cancel(self._cancel)ifself._cancel.is_set():raiseGracefulStop("用户请求停止")returnself._execute(step)

关键:每一层都要能感知到取消信号。主循环取消、子线程取消、正在等的 HTTP 也要能取消。用threading.Event或asyncio.CancelledError。

2.2 超时中断(系统判定)

预算超了、步骤超时、总时长超。这类中断必须在进入前就设好上限:

@dataclassclassLimits:max_steps:int=50max_tokens:int=400_000max_seconds:int=1800max_subtasks:int=10

触发时不能直接抛异常完事——要留下可恢复的现场,否则超时中断等于浪费全部投入。

2.3 异常中断(被动)

LLM 调用失败、工具报错、进程崩溃。这类最容易出现「半完成」状态:步骤 1 成功了,步骤 2 失败在中间。

处理原则:任何副作用操作前先记录意图(见第三节的 WAL),失败后靠日志判断「刚才那步到底做没做」。

2.4 部署中断(进程被杀)

Pod 重启、OOM、发版。这种最残酷——内存里的状态全丢。不持久化就什么都没了。


三、恢复的技术基础:持久化

3.1 状态外置是前提

内存里的 Agent 状态不能作为唯一真相来源。必须外置:

classTaskState(BaseModel):task_id:strstatus:Literal["running","paused","completed","failed","aborted"]plan:list[Step]step_results:dict[int,StepResult]# 已完成的步骤产出context_snapshot:str# 压缩后的关键上下文created_at:datetime updated_at:datetime checkpoint_version:int

存到 Postgres / Redis / 到一个文件都行,关键是每一步完成后立刻写,不要攒着。

3.2 WAL:副作用前的意图日志

这是恢复正确性的核心。任何有副作用的动作,执行前先写一条「我要做 X」:

defexecute_side_effect(step:Step,wal:WriteAheadLog):# 1. 先记意图entry=wal.append({"step_id":step.id,"action":step.action,"params":step.params,"status":"pending","ts":now(),})try:# 2. 执行result=perform(step.action,step.params)# 3. 标记完成wal.update(entry.id,status="done",result=result)returnresultexceptExceptionase:wal.update(entry.id,status="failed",error=str(e))raise

恢复时:

defrecover(task_id:str):state=load_state(task_id)pending=wal.pending_entries(task_id)# 意图写了但没写 done 的forentryinpending:# 这个动作可能已经执行了但没来得及标 doneifis_idempotent(entry.action):replayed=perform(entry.action,entry.params)# 幂等,重放安全wal.update(entry.id,status="done",result=replayed)else:# 非幂等:不确定是否执行过,必须人工确认flag_for_human_review(entry)abort_recovery(task_id,reason="非幂等操作状态未知")returnstate

这条规则值得单独记住:非幂等的副作用操作,状态不明时不要盲猜,挂起等人工确认。误判为「没执行」会漏掉操作,误判为「已执行」会漏掉补偿,两个都可能出生产事故。

3.3 幂等设计

让恢复变简单的根本办法是让所有操作幂等:

# ❌ 非幂等defsend_email(to,body):smtp.send(...)# ✅ 幂等(带唯一键 + 去重)defsend_email(to,body,idempotency_key):ifdedupe_store.seen(idempotency_key):return{"status":"already_sent"}dedupe_store.mark(idempotency_key)smtp.send(...)
# ❌ 非幂等definsert_log(record):db.execute(insert_record,record)# ✅ 幂等(自然键 upsert)definsert_log(record):db.execute(""" INSERT INTO logs (biz_key, payload) VALUES (:biz_key, :payload) ON CONFLICT (biz_key) DO NOTHING """,record)

给每个副作用操作设计一个幂等键,恢复时重放就是安全的。这比任何精巧的恢复逻辑都值钱。


四、中断的实现:分层取消

4.1 一个可取消的任务树

importasyncioclassCancellationToken:def__init__(self):self._event=asyncio.Event()defcancel(self):self._event.set()defis_cancelled(self)->bool:returnself._event.is_set()asyncdefthrow_if_cancelled(self):ifself._event.is_set():raiseasyncio.CancelledError()classAgentTask:def__init__(self,task_id,token:CancellationToken):self.task_id=task_id self.token=token self.subtasks=[]asyncdefrun(self):self.token.throw_if_cancelled()forstepinself.plan:ifstep.depends_on:awaitself.gather(step)# 并发子任务awaitself.token.throw_if_cancelled()result=awaitself.execute_step(step)awaitself.checkpoint(result)asyncdefexecute_step(self,step):# LLM 调用也是可取消的returnawaitasyncio.wait_for(self.llm_call(step),timeout=step.timeout,)

三层取消都要覆盖:

  1. 步骤之间:检查 token,取消就不再进下一步
  2. 步骤之内:asyncio.wait_for超时;流式响应要能中断
  3. 子任务之间:把同一个 token 传给所有asyncio.create_task

4.2 优雅停止 vs 强制停止

defstop(self,graceful:bool):self.token.cancel()ifgraceful:# 等当前步骤收尾(最多 5 秒),保存状态,然后退出awaitasyncio.wait_for(self.finish_current_step(),timeout=5)else:# 直接取消,状态可能不完整self.token.cancel(force=True)self.save_checkpoint()

UI 上应该提供两个按钮:停止(优雅)和强制终止。默认优雅,长按或二次确认强制。

一个常见错误:点了停止,主流程停了,但后台还在跑的子 Agent 没收到取消。所以 token 必须一路传下去。


五、恢复的两种形态

5.1 热恢复:暂停后接着跑

任务暂停,状态在库里,参数可改。用户改完点「继续」:

asyncdefresume(task_id:str,new_params:dict|None=None):state=load_state(task_id)ifstate.status!="paused":raiseValueError("只能恢复暂停中的任务")# 1. 处理暂停期间产生的副作用(补偿或跳过)awaitreplay_pending_wal(task_id)# 2. 如果参数变了,重新规划剩余部分ifnew_params:state.plan=replan(state,new_params)# 3. 从断点继续token=CancellationToken()state.status="running"save_state(state)awaitAgentTask(task_id,token).run_from(state)

新参数怎么作用到已完成的步骤是个设计难点。三种情况:

  • 参数只影响未完成步骤 → 直接改剩余 plan
  • 参数影响已完成步骤 → 需要重算并标记旧结果失效(要能识别哪些下游结果依赖它)
  • 参数影响不可逆的副作用 →不能恢复,只能放弃重跑

第三种要在 UI 上明确告知用户,别让他以为「改个参数接着跑」是万能的。

5.2 冷恢复:换个进程接着跑

进程被杀后,新进程接手:

defcold_start():# 扫描所有 running 但心跳超时的任务stale=db.query(""" SELECT * FROM task_state WHERE status = 'running' AND updated_at < :threshold """,{"threshold":now()-timedelta(minutes=5)})fortaskinstale:# 先尝试恢复,恢复不了标记 failedtry:resume(task.id)exceptNonIdempotentState:mark_failed(task.id,"非幂等状态需人工介入")

心跳是冷恢复的关键:没有心跳,你无法区分「任务真的停了」和「任务还在跑只是慢」。心跳间隔 30~60 秒,超时 2~3 个周期判定为僵死。


六、什么时候值得做

和别的高级特性一样,中断恢复有明确的适用边界。

6.1 值得做的信号

  • 任务运行时间> 2 分钟(用户等不了,中途想停/改)
  • 有不可逆副作用(发消息、写库、花钱)
  • 部署会重启(Pod 滚动更新、OOM)
  • 任务是长流水线(数据迁移、批量生成)
  • 用户需要在不同设备/时间点接手

6.2 不值得的信号

  • 单轮对话、秒级返回 → 中断就是取消,恢复等于重问
  • 纯读操作(搜索、查询)→ 没有副作用,重跑零成本
  • MVP 阶段 → 先把主流程跑通,持久化后加

6.3 最小可用版

如果现在就要做,优先级排序:

  1. 状态外置 + 每步 checkp(否则一切免谈)
  2. 幂等键(让恢复安全)
  3. 优雅停止(用户主动中断)
  4. 超时限制(兜底)
  5. 热恢复(暂停改参继续)
  6. 冷恢复 + 心跳(进程级容错)

前两项不做,后面全是空中楼阁。


七、常见误区

误区一:把「停止」做成「kill 进程」就完事。后台子任务、未完成的副作用、未保存的状态,全都成了隐患。

误区二:恢复 = 重跑。副作用会重复。要么幂等重放,要么改从断点续。

误区三:副作用状态不明时自动重放。非幂等操作必须挂起等人工,盲猜会出事故。

误区四:没有心跳。分不清「停了」和「慢了」。

误区五:参数改了就直接恢复。要判断它影响的是剩余步骤还是已完成步骤。影响已完成且不可逆的,只能放弃。

误区六:暂停不可取消的子任务。token 必须传进每一层,包括并发子任务和正在进行的流式调用。

误区七:只在内存保存状态。进程一死全丢。外置存储是前提。


小结

  • 中断要停掉的是一整棵树:主循环、子任务线程、进行中的 LLM 调用、HTTP 请求、本地进程。只停主循环 = 僵尸任务。
  • 恢复的技术前提是状态外置(每步立刻 checkpoint);恢复的安全前提是WAL + 幂等:副作用前先记意图,执行后标完成,恢复时幂等重放、非幂等挂起等人工。
  • 中断分四类:用户主动(优雅停)、超时(预设上限)、异常(半完成,靠 WAL 兜)、部署(进程死,靠心跳 + 冷恢复)。
  • 热恢复要处理参数变更的影响范围——只影响剩余步骤才好办,影响不可逆副作用的只能放弃。
  • 落地优先级:状态外置 → 幂等键 → 优雅停止 → 超时限制 → 热恢复 → 冷恢复。前两项不做,后面都白搭。
  • 适用边界:任务 > 2 分钟、有不可逆副作用、部署会重启、长流水线。单轮对话和纯读操作不值得做。

长任务 Agent 和演示 Demo 的区别,往往就在「能不能停下来、能不能接回去」。这两件事做扎实了,Agent 才敢被用在真正有价值的任务上。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 2:11:30

快速排序(力扣基础)(递归)

核心目标&#xff1a;在无序数组中&#xff0c;不用完全排序&#xff0c;找到第 k 大 / 第 k 小元素 推荐&#xff1a;数据结构合集 - 快速排序(算法过程, 效率分析, 稳定性分析)_哔哩哔哩_bilibili int quickselect(int[] nums, int l, int r, int k) {if (l r) return num…

作者头像 李华
网站建设 2026/10/19 2:11:28

基于TRL库的SFT微调实现

一.TRL库的介绍 TRL库提供一系列的Trainer&#xff0c;实现大语言模型的微调和对齐训练。 使用TRL进行SFT微调的时候&#xff0c;需要先配置SFTTrainer实例。SFTTrainer包含了所需要训练的model,tokenizer以及SFTConfig参数。配置完成之后&#xff0c;即可调用SFTTrainer实例…

作者头像 李华
网站建设 2026/10/19 2:10:49

Flash 缓存没刷新读旧值——写了缓存未回写,写后 invalidate/回写

Flash 缓存没刷新读旧值——写了缓存未回写&#xff0c;写后 invalidate/回写文章标签&#xff1a;#嵌入式开发 #单片机 #Flash #OTA升级 #STM32 #扇区 #擦写 #缓存 #回写一、开篇&#xff1a;那个"写进去了却读不出"的缓存 做 STM32F7/H7&#xff08;带 DCache/ICac…

作者头像 李华
网站建设 2026/10/19 2:10:10

联调时我当了太久人肉快递员,最后让两个 agent 自己聊上了

开发最耗人的地方&#xff0c;我一直觉得不是写代码。 前几天有个活&#xff1a;一部分功能跑在无影云上&#xff0c;手机插在本地这台 PC 上。做起来是这样——云上跑一遍&#xff0c;看日志&#xff0c;复制结论&#xff1b;切到本地&#xff0c;接上手机&#xff0c;再跑一遍…

作者头像 李华
网站建设 2026/10/19 2:10:07

处理超时未支付订单:两套轻量化方案,附 PHP 极简实现

电商交易类业务&#xff0c;「待支付订单超时自动取消」是个绕不开的需求。用户占着库存、优惠券、活动名额迟迟不付款&#xff0c;不及时释放就是实打实的资损。很多技术人第一反应就是上延迟队列、分布式事务、各种中间件&#xff0c;好像不用点 “高大上” 的技术就不算做架…

作者头像 李华
网站建设 2026/10/19 2:09:29

Qwen3.8-27B 对上 Nemotron 3.5 Bolt、Gemma 4:一张 24GB 显卡该装谁

封面&#xff1a;大数字海报 纸白&#xff0c;数字为 Hugging Face 显示的 Qwen3.8-27B 上月下载量&#xff0c;条形为三家官方公布的 GPQA Diamond 分数&#xff0c;由本号封面脚本生成。 Hugging Face 上赞数最多的千问&#xff0c;不是几百 B 的大模型&#xff0c;而是一个…

作者头像 李华