如何安全地停下一个正在工作的 Agent——中断与崩溃恢复

2026-10-15·Run 生命周期·Run 生命周期

用户点'停止'时 Agent 正在写文件------直接杀会留下损坏的文件。进程 OOM 崩了------对话历史还在吗?这篇讲两件事:如何优雅地停下一个正在工作的 Agent,以及进程崩溃后如何恢复。含企业版版本错配导致部门数据消失的真实事故,以及行业五阶段生命周期规范。

两种"停"

Agent 的"停"有两种------主动停 (用户点了"停止")和被动停(进程崩了)。两者的工程挑战完全不同。

主动停的挑战:Agent 正在做半截事(写文件写一半 / 子进程在跑 / 消息历史待更新)------怎么安全收尾而不留下损坏的状态?

被动停的挑战:进程突然消失(OOM / SIGKILL / 机器断电)------已持久化的数据还完整吗?正在跑的任务怎么标记?下次启动能不能接着来?


第一部分:主动停------优雅中断

问题的复杂性

用户点了"停止"。此刻 Agent 可能正在:

状态 直接杀的后果
执行 exec("npm install") npm 子进程变成孤儿,继续在后台跑
执行 write_file("app.ts", ...) 文件写了一半------下次 Agent 读到损坏的文件
等 LLM 返回(流式传输中) 半截的回复丢失
等用户审批(HITL 阻塞中) 审批请求悬空,前端永远显示"等待审批"

Claude Code 怎么做

Claude Code 是 CLI------用户按 Ctrl+C 发 SIGINT。进程处理信号 → 完成当前工具调用 → 退出。

这在 CLI 场景够用------用户看到"正在结束..."几秒后进程退出。但它有一个限制:如果工具调用耗时很长(比如 npm install 跑了 5 分钟),Ctrl+C 要等工具跑完才会退出。用户可以再按一次 Ctrl+C 强制杀------但那就是"不安全停止"了。

Claude Code 的 --resume 有一些已知问题值得留意。GitHub issue #47508 记录了一个问题:上下文压缩(compact)后的消息可能不被 --resume 正确加载------意味着压缩后恢复的会话可能丢失部分上下文。issue #86324 更微妙:当用户 rewind/undo 时,文件状态被回滚了但对话上下文没有同步------Agent 继续自信地引用已不存在的代码。这两个问题揭示了一个共性挑战:状态恢复需要多个维度同步(文件系统 + 对话历史 + 压缩后的摘要),任何一个维度不同步都会导致 Agent 的认知与现实脱节。

Cursor 怎么做

Cursor 的 Cancel 按钮中断 Agent 任务。由于代码修改是 Apply 机制(先 diff 预览再确认),Cancel 时只需要丢弃未 Apply 的 diff------不会留下半写的文件。这是 Apply 机制的附带好处。

安全中断的通用原则

从各产品的实践中可以提炼出三条通用原则:

原则一:协作式而非抢占式

不是在任意时刻杀掉执行线程------而是设一个"中断标志",Agent 在"安全点"检查这个标志然后停下来。安全点通常在:每轮循环开头、每个工具调用之间。

原则二:正在执行的工具给收尾时间

如果 Agent 正在执行 write_file,给它几秒时间写完(通常 < 1 秒)。超时才强制取消。文件要么完整写入,要么不存在------不能半写。

原则三:消息历史必须保持一致

Agent 的对话历史中如果有"我要调用 read_file 工具"(tool_use)但没有对应的"工具返回了 xxx"(tool_result),下次加载历史时 LLM Provider 会报格式错误。中断后必须为每个未完成的工具调用补上占位结果。

在我们的引擎中,这三条原则体现为:

  • 中断信号设标志,不杀线程
  • 给正在执行的工具 3 秒收尾窗口
  • 中断后自动为未执行的工具调用补上"操作被中断"的占位结果
  • 中断是幂等的------用户焦急地连点三次"停止",效果和一次相同
  • 中断不删除已完成的工作------Agent 在被中断前的成果全部保留

行业五阶段生命周期规范

Agent 进程的生命周期不只是"跑起来"和"停下来"。Geodocs 在 2026 年发布的 AI Agent 启停规范(geodocs.dev/ai-agents/agent-startup-shutdown-spec)定义了五阶段模型,与 Kubernetes Pod 的生命周期高度对齐:

bash 复制代码
init → warmup → ready → drain → terminate

init(初始化) :加载配置、建立数据库连接、校验密钥。这个阶段不应该接受请求。 warmup(预热) :恢复历史状态、预热缓存、加载模型连接。可以在后台进行。 ready(就绪) :可以接受请求了。健康检查开始返回 200。 drain(排空) :收到停止信号后,停止接受新请求,等现有任务完成。这是"优雅停止"的核心阶段。 terminate(终止):所有任务完成(或超时后强制终止),执行最终 checkpoint,释放资源。

SIGTERM 触发 drain → terminate 的转换。关键约束:drain 的超时(grace period)必须小于进程管理器的 SIGKILL 超时 ------比如 Kubernetes 的 terminationGracePeriodSeconds 默认 30 秒,你的 drain 必须在 30 秒内完成,否则 SIGKILL 到来时正在做的 checkpoint 被打断,结果比不做 checkpoint 更糟。

一个容易忽略的点 :init 阶段的耗时不能随数据量增长而增长。如果有 100 个租户的引擎在 init 时逐个加载每个租户的状态,租户数 × 加载时间 = 启动时间。当这个时间超过进程管理器的启动超时(systemd 的 TimeoutStartSec / Kubernetes 的 readiness probe),进程管理器会判定"启动失败"然后杀掉进程------而杀掉后重启又要走一遍同样的路。一次偶然的慢启动变成一轮比一轮深的重启循环。

解法:init 阶段只做"能在有界常数时间内完成的事"(绑端口、注册服务),租户级加载在 ready 之后后台进行。


第二部分:被动停------崩溃恢复

崩溃恢复不是理论问题。Amazon Kiro(2025 年 12 月)在一个生产环境中决定"删除并重建整个环境"来修一个小 bug------因为它继承了工程师的高权限。如果这个操作中途崩溃(电源中断/OOM),环境会处于什么状态?半删除------既不是旧的也不是新的。 这比完全删除更难恢复(AI Failure Index)。

进程崩溃时的状态分布

进程突然退出时,Agent 的状态分布在四个地方:

位置 例子 崩溃后
内存 当前循环的消息列表、LLM 流式响应缓冲 丢失
磁盘(已提交) 已写入数据库的历史消息、工具调用追踪 保留
磁盘(WAL 未提交) SQLite WAL 日志中的待提交写入 自动恢复(SQLite WAL 回放)
子进程 exec 启动的子进程 可能变孤儿

好消息:已提交到数据库的数据不丢(SQLite 的 ACID 保证 + WAL 自动回放)。用户之前的对话历史、长期记忆、知识库------全在。

坏消息:当前正在执行的那一轮(内存中的消息、半返回的 LLM 响应、正在跑的工具调用的结果)------丢失了。

LangGraph 与 Temporal 的恢复粒度对比

不同产品的崩溃恢复,本质区别在于恢复粒度------崩溃后能恢复到多精确的位置。

LangGraph Platform 的恢复粒度是 node boundary(图中一个节点执行完毕的边界,LangGraph 称之为"super-step boundary")。如果一个节点内部崩溃------比如一次 LLM 调用执行了 30 秒后 OOM------整个节点需要重新执行。但 LangGraph 的 Pending Writes 机制保证:并行节点中已完成的结果不丢失,只重跑失败的节点。

Temporal 的恢复粒度是 single activity------每一步的输入和输出都记录在事件历史中,崩溃后 replay 恢复到精确的那一步。这意味着即使一次 LLM 调用跑了 30 秒后崩溃,如果这次调用是一个 activity,它只需要重试这一次调用------不需要重跑整个节点。代价是事件历史的存储开销和 replay 的时间成本。

Microsoft Foundry 在 2026 年推出了 Agent 崩溃恢复模型(learn.microsoft.com/azure/foundry/agents),采用三个标志位:store=true(持久化 Run 状态)+ background=true(后台运行)+ resilient_background=True(崩溃可恢复)。Agent 在检测到需要恢复时调用 exit_for_recovery(),Foundry 重启 Agent 并注入上次的 checkpoint------关键设计:恢复不消耗 retry 预算,因为恢复不是"第二次尝试",是"同一次尝试的继续"。

我们的引擎 的恢复粒度介于 LangGraph 和 Temporal 之间:每轮(turn)即时持久化。每次工具调用的结果、每条消息、每次 token 计量------在产出的那一刻就写入数据库。不等一个 node 完成,不等整个任务结束。崩溃后丢失的只有"当前正在执行的那一轮"------可能是一次 LLM 调用的流式响应或一次工具执行的结果。

启动时恢复

进程重启后,引擎需要做两件事:

一、识别"孤儿任务"------扫描数据库中标记为"运行中"的任务,但当前进程里没有它们 → 它们的宿主进程已经崩了。标记为"进程崩溃终止",用户可以看到"Agent 在这里因进程崩溃而终止"。

二、确保启动成功 ------不管之前发生了什么(数据库部分损坏、WAL 残留、半写的文件),重启后引擎必须能正常工作。这是一条刚性原则------如果上一次崩溃导致下一次起不来,一次临时故障就升级成了永久不可用。

做法是降级而非拒绝:发现某层数据损坏 → 标记该层为"降级"(其他层正常工作),不是整体拒绝启动。比如追踪数据损坏 → 追踪不可用但对话和记忆正常 → Agent 可以继续工作,只是看不到历史追踪。

"测试 SIGKILL,不只是 SIGTERM"

一篇被广泛引用的工程博客(dev.to/gabrielanhaia)提出了一个尖锐的观点:大多数团队只测试优雅关闭(SIGTERM),从不测试强制杀死(SIGKILL)。

测试方法是:fork 一个 Agent 进程 → 让它开始执行任务 → 发 SIGKILL(不是 SIGTERM)→ 重启 → 验证:①数据没有损坏 ②没有重复执行 ③ 用户能看到发生了什么。

"没有重复执行"这一条最容易忽略。如果 Agent 在崩溃前已经执行了 exec("npm install") 并拿到了结果,但结果还在内存中没来得及持久化------重启后 Agent 不知道已经执行过了,可能再执行一次。对于幂等操作(读文件)无害,对于非幂等操作(发消息、写数据库、调用外部 API)可能产生副作用。

Temporal 的事件溯源天然解决了这个问题------每个 activity 的结果都持久化了,replay 时直接回放结果而不重新执行。LangGraph 的 Checkpoint 在 node boundary 持久化,node 内部的中间结果仍可能重执行。你的引擎选择在哪个粒度持久化,决定了 SIGKILL 后会不会重复执行。


一个真实的事故:版本错配导致数据消失

这个事故不是运行时崩溃------但它暴露了恢复逻辑的一个深层缺陷:"读不懂"和"不存在"被混为一谈。

场景 :2026 年 9 月,企业客户现场部署。运维在更新 Java 应用时没有同步更新 Agent 引擎。新版 Java 写入了新格式的配置数据(渠道凭证从明文字符串升级为加密对象)------旧版引擎读不懂。

发生了什么(精确时间线 2026-09-03 22:48):

  1. 旧版引擎读取新格式的配置 → JSON 解码失败 → 认为这个租户"不存在"
  2. 应用层查询这个租户 → 引擎说"不存在" → 应用层触发自动创建流程
  3. 引擎把已有的 租户目录当作"孤儿"归档(改名为 .deleted-1788446935575)→ 然后发现 ID 已注册 → 报 500
  4. 结果:法务部和审计部两个部门的对话历史、记忆、知识库从活动目录消失了

数据没有真丢 ------归档目录还在。运维手工 mv 目录回来 → 恢复。但中间有 5 分钟那两个部门的所有 Agent 功能不可用。

这和 Kubernetes 的 rolling update 是同一类问题。Kubernetes 在滚动更新时保证新旧 Pod 共存------新 Pod 必须能读懂旧 Pod 写的数据,旧 Pod 也不能因为新数据格式就把旧数据删了。这个事故就是缺少了这个保证。

修复后的四条纪律:

  • "读不懂"和"不存在"是两种错误 ------引擎区分 ErrSpecUndecodable(格式不认识)和 ErrCellNotFound(真的没有)。前者保留数据、降级运行;后者才触发清理
  • 归档不是删除 ------对"可能是孤儿"的数据做归档(改名),不做删除。改名是运维可以 mv 回来的操作;删除是不可逆的
  • 引擎启动时打印构建指纹 (version=... commit=...)------运维看一行日志就知道"跑的是哪个版本",不需要 strace
  • 引擎只升不降------确需回退版本时,先在测试环境验证旧版能读懂现有数据

崩溃恢复的行业对比

以下对比展示了不同产品在崩溃恢复三个关键维度上的做法:

维度 Claude Code Cursor LangGraph Platform Temporal Microsoft Foundry 我们的引擎
恢复粒度 会话级(全部历史) 文件级(diff/revert) 节点级(super-step) 单步级(activity) 任务级(checkpoint) 每轮级(turn)
数据保全 对话完整,当前操作丢失 代码 diff 保留 已完成节点+pending writes 保留 全部保留(event replay) Checkpoint 保留 已提交数据全保留,当前轮丢失
用户可见性 --resume 可看历史 Revert 可撤销 Run status API 可查 Temporal UI 可查全历史 管理面板可查 "进程崩溃终止"标记 + 追踪
恢复时间 秒级(加载历史) 即时(文件在磁盘) 秒级(读 checkpoint) 秒-分级(replay) 秒级 百毫秒级(WarmUp)
SIGKILL 安全 ✅(append-only 文件) ✅(磁盘文件) ✅(Postgres ACID) ✅(event store ACID) ✅(Azure 托管) ✅(SQLite ACID + WAL)

崩溃恢复的通用原则

从各产品的实践和我们的教训中,提炼出五条通用原则:

原则 含义 违反后果
启动永远成功 无论之前发生什么,重启后能正常工作 一次崩溃 → 永久不可用
降级而非拒绝 部分数据损坏 → 那部分不可用,其他正常 追踪数据坏了 → 整个 Agent 不可用(过度惩罚)
持久化在执行时而非结束时 每轮工具结果立即写盘,不等任务结束 崩溃丢失整个任务的所有工作
归档而非删除 不确定的数据做归档(可恢复),不做删除 误判导致不可逆的数据丢失
区分"读不懂"和"不存在" 格式不认识 ≠ 数据不存在 版本错配导致正常数据被清理

Claude Code 天然满足第三条(对话历史在每轮后写入本地文件)。Cursor 通过 Apply 机制部分满足第四条(修改先 diff 再确认,Cancel = 丢弃 diff 不影响磁盘)。Temporal 通过事件溯源满足前四条。我们的引擎在企业多租户场景中把五条都做到了系统级------因为企业客户不能接受"崩溃一次就不可用",更不能接受"升级一次就丢数据"。


引用来源

本文所有数据均可通过以下链接验证。

相关推荐
李溪白1 小时前
RAG 进阶:Agentic RAG 与多智能体协作(系列三·第5篇·收官):生产级 Agentic RAG 系统全景
agent
Bryceee1 小时前
DeepSeek Harness (dsh) 会话管理架构深度剖析:为什么他们砍掉了 SQLite,选择 Zstd 分帧 JSONL?
agent
136096757231 小时前
把模型调用抽成 4 个对象,换厂商只改一行配置
agent
没事写点啥2 小时前
WeKnora 上下文管理
后端·ai编程
武子康2 小时前
Coding Agent 的知识也会漂移:用固定快照检查 Vercel Plugin 升级
人工智能·后端·agent
阿里云大数据AI技术3 小时前
云栖实录|Hologres 5.0全新发布:Agent Native 的全模态实时数仓
大数据·人工智能·agent
CoovallyAIHub4 小时前
用 AI 做 PPT 被辞退,法院判公司赔:员工贴进对话框的数据,到底谁负责?
前端·agent
CoovallyAIHub4 小时前
Anthropic 昨晚发布 Claude Duet:AI 第一次主动找你干活
github·agent·设计
花椒技术4 小时前
花椒 AI 应用工程化实践:灵泡泡的工作流、版本管理与任务恢复
后端·agent·ai编程