AI Agent 不是会重试就可靠:长任务的断点恢复与 Durable Execution 设计

上周我复盘了一个挺典型的 Agent 事故。

一个销售助手负责做三件事:读取 CRM 里的客户信息,调用报价系统生成方案,然后把报价邮件发给客户。流程看起来很简单,开发同学也写了重试:模型超时就重试,工具 500 就重试,整个任务失败就从头跑一次。

结果某天报价系统响应慢,Agent 在"邮件已经发出、状态还没写回数据库"的窗口里崩了一次。任务调度器发现失败,自动从头执行。客户最后收到了两封报价邮件,价格还不一样。

这类问题特别容易被低估。因为在 Demo 阶段,Agent 失败通常只是"回答没出来";到了生产阶段,Agent 失败可能是:

  • 邮件发出去了,但任务状态还是 running
  • 审批卡住了,worker 重启后没人知道该从哪里继续;
  • 工具调用成功了,但 checkpoint 没写进去;
  • 重试把不可撤销的外部动作执行了两遍;
  • 用户看到"任务失败",但第三方系统里已经产生了订单、工单或扣费记录。

所以我现在判断一个 Agent 系统靠不靠谱,不会先问它"模型多强""Prompt 多细",而会先问一个更土的问题:

它崩在任意一步后,能不能只重放该重放的部分,并且不重复执行外部副作用?

这篇文章就聊这个问题:AI Agent 长任务的断点恢复、checkpoint 和 Durable Execution 到底该怎么设计。里面有一个可运行的小实验,专门复现"副作用完成后、checkpoint 前崩溃"的坑。


一、为什么 Agent 长任务不能按普通 HTTP 请求处理

普通后端接口一般是短请求:进来、查库、调用几个服务、返回。哪怕中间失败,大多数时候可以让用户重试一次。

Agent 长任务不一样。它更像一个小型工作流:

text 复制代码
用户目标
  -> 任务规划
  -> 检索上下文
  -> 调用内部系统
  -> 等待人工审批
  -> 调用外部 API
  -> 汇总结果
  -> 通知用户

这条链路里有四种东西混在一起:

  1. 纯计算步骤:例如让模型生成计划、对文档做摘要。失败后可以重跑。
  2. 读操作:例如查 CRM、查知识库。通常可以重跑,但要注意读到的数据版本是否变化。
  3. 外部副作用:例如发邮件、创建订单、写工单、扣库存、转账。这些不能随便重跑。
  4. 等待事件:例如人工审批、第三方回调、定时器。它们不是"睡一会儿",而是任务生命周期的一部分。

很多 Agent Demo 只覆盖前两种,所以一个 retry(3) 看起来就够了。生产事故大多发生在第三、第四种。

如果用最朴素的代码写,通常长这样:

js 复制代码
async function runAgent(input) {
  const plan = await llmPlan(input);
  const quote = await createQuote(plan);
  await sendEmail(input.customerEmail, quote);
  await db.tasks.update(input.taskId, { status: 'done' });
  return quote;
}

问题在于:

  • llmPlan 失败,可以重试;
  • createQuote 如果只是生成草稿,可以重试;
  • sendEmail 成功后进程崩了,再重试就会重复发;
  • db.tasks.update 没执行,调度器只看到任务失败,会重新跑整条链路。

这不是"代码不够健壮",而是执行模型错了。Agent 长任务需要的不是简单重试,而是可恢复执行。


二、先把三个概念拆开:Checkpoint、Replay、Durable Execution

现在很多框架都会提 checkpoint。LangGraph 文档里也明确提到,checkpointer 会在每一步保存图状态,用来支持持久化、Human-in-the-loop 和容错执行。这是非常有用的能力。

但 checkpoint 不等于 durable execution。

我更建议把这三个概念分开看:

概念 解决什么 不解决什么
Checkpoint 保存当前状态,下次从某个点继续 不保证外部副作用不会重复
Replay 用事件历史重建内存状态 不自动知道哪些动作不可重放
Durable Execution 持久事件历史 + 调度 + 重试 + 幂等边界 仍然需要你设计业务副作用语义

Checkpoint 像游戏存档。存档能让你从上一关继续,但如果你在"领取奖励后、存档前"断电,重进游戏可能又领一次奖励。游戏系统要额外记录奖励是否发放过。

Agent 也是一样。保存 state.step = "send_email" 只是第一步,你还要记录:

  • 这封邮件是否已经发过?
  • 发送使用的幂等 key 是什么?
  • 上游返回的 message id 是什么?
  • 如果恢复时查不到状态,应该重试、人工介入,还是标记为 unknown?

Diagrid 在讨论 LangGraph、CrewAI、Google ADK 等框架时也强调过一个观点:checkpoint 可以保存状态,但它本身不等于完整的 durable execution。后者还包括 worker 崩溃检测、自动调度、事件历史、重试策略和外部副作用边界。

Temporal 这类 durable execution 系统的核心也不是"把状态写数据库"这么简单,而是把 workflow 的事件历史持久化,worker 崩溃后可以通过历史重放恢复到一致状态。放到 AI Agent 上,这个差异会非常关键。


三、一个小实验:为什么"有 checkpoint"仍然会重复执行副作用

为了把问题说清楚,我写了一个很小的 Node.js 脚本。它模拟一个三步 Agent:

  1. plan:生成任务计划;
  2. send_quote:模拟外部副作用,向 effects.log 写入一条"发送报价";
  3. summarize:生成总结。

我们故意让程序在"副作用已经执行、checkpoint 还没写入"时崩溃,然后重新启动。对比三种策略:

  • naive:失败后从头跑;
  • checkpoint:保存 step,下次从 step 继续;
  • durable:checkpoint 之外,再维护 effect ledger,用幂等 key 记录副作用是否完成。

核心代码如下,完整脚本放在本文产物目录的 scripts/agent-durable-demo.mjs

js 复制代码
import fs from 'node:fs';
import path from 'node:path';

const mode = process.argv[2] || 'naive';
const crashPoint = process.argv[3] || 'none';
const root = process.argv[4] || './demo-state';

fs.mkdirSync(root, { recursive: true });
const checkpointFile = path.join(root, `${mode}.checkpoint.json`);
const effectFile = path.join(root, `${mode}.effects.log`);
const ledgerFile = path.join(root, `${mode}.ledger.json`);

function readJson(file, fallback) {
  try {
    return JSON.parse(fs.readFileSync(file, 'utf8'));
  } catch {
    return fallback;
  }
}

function writeJson(file, data) {
  fs.writeFileSync(file, JSON.stringify(data, null, 2));
}

function appendEffect(line) {
  fs.appendFileSync(effectFile, line + '\n');
}

function maybeCrash(point) {
  if (crashPoint === point) {
    console.error(`[demo] crash at ${point}`);
    process.exit(42);
  }
}

let state = mode === 'naive'
  ? { step: 'plan' }
  : readJson(checkpointFile, { step: 'plan' });

let ledger = readJson(ledgerFile, {});

function checkpoint(next, extra = {}) {
  state = { ...state, ...extra, step: next, updatedAt: new Date().toISOString() };
  if (mode !== 'naive') writeJson(checkpointFile, state);
}

if (state.step === 'plan') {
  checkpoint('send_quote', { planId: 'quote-20260709-a', userId: 'u_42' });
}

if (state.step === 'send_quote') {
  const effectKey = `${state.planId}:send_quote`;

  if (mode === 'durable' && ledger[effectKey]) {
    console.log(`[effect] skip duplicated ${effectKey}`);
  } else {
    appendEffect(`${new Date().toISOString()} SEND_QUOTE key=${effectKey}`);
    if (mode === 'durable') {
      ledger[effectKey] = { doneAt: new Date().toISOString() };
      writeJson(ledgerFile, ledger);
    }
  }

  maybeCrash('after_effect_before_checkpoint');
  checkpoint('summarize', { quoteSent: true });
}

if (state.step === 'summarize') {
  checkpoint('done', { result: '报价已发送,并生成摘要。' });
}

运行脚本:

bash 复制代码
./scripts/run-demo.sh ./checks/demo-state

得到结果:

策略 崩溃后恢复结果 effects.log 记录数
naive 从头执行,重复发送 2
checkpoint send_quote 继续,仍然重复发送 2
durable + effect ledger 恢复时识别副作用已完成,跳过发送 1

真实输出里最关键的一段是:

text 复制代码
== checkpoint ==
[step] plan
[step] send_quote
[demo] crash at after_effect_before_checkpoint
[step] send_quote
[step] summarize

effects.log:
SEND_QUOTE key=quote-20260709-a:send_quote
SEND_QUOTE key=quote-20260709-a:send_quote

== durable ==
[step] plan
[step] send_quote
[demo] crash at after_effect_before_checkpoint
[step] send_quote
[effect] skip duplicated quote-20260709-a:send_quote
[step] summarize

effects.log:
SEND_QUOTE key=quote-20260709-a:send_quote

这个实验很小,但它暴露了一个生产里非常常见的误判:

只保存 Agent 状态,不保存外部副作用状态,恢复仍然是不安全的。


四、生产设计的核心:把"状态"和"副作用"拆成两本账

我一般会建议团队至少维护两本账。

第一本是 agent_runs,记录任务状态:

sql 复制代码
CREATE TABLE agent_runs (
  run_id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  status TEXT NOT NULL,
  current_step TEXT NOT NULL,
  state_json JSONB NOT NULL,
  attempt INT NOT NULL DEFAULT 0,
  locked_by TEXT,
  locked_until TIMESTAMP,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

第二本是 agent_effects,记录外部副作用:

sql 复制代码
CREATE TABLE agent_effects (
  effect_key TEXT PRIMARY KEY,
  run_id TEXT NOT NULL,
  step_name TEXT NOT NULL,
  effect_type TEXT NOT NULL,
  status TEXT NOT NULL,
  request_json JSONB NOT NULL,
  response_json JSONB,
  external_id TEXT,
  created_at TIMESTAMP NOT NULL,
  completed_at TIMESTAMP
);

这两个表的职责不能混。

agent_runs 回答的是:任务走到哪里了?内存状态是什么?下一个 step 是什么?

agent_effects 回答的是:某个不可重复动作有没有执行过?执行时传了什么?上游返回了什么?恢复时能不能跳过?

一个发送邮件 step 可以写成这样:

ts 复制代码
async function sendEmailOnce(run, payload) {
  const effectKey = `${run.runId}:send_email:${payload.templateId}`;

  const existing = await effects.find(effectKey);
  if (existing?.status === 'completed') {
    return existing.responseJson;
  }

  await effects.insertIfAbsent({
    effectKey,
    runId: run.runId,
    stepName: 'send_email',
    effectType: 'email',
    status: 'pending',
    requestJson: payload,
    createdAt: new Date()
  });

  const response = await emailProvider.send({
    ...payload,
    idempotencyKey: effectKey
  });

  await effects.markCompleted(effectKey, {
    responseJson: response,
    externalId: response.messageId,
    completedAt: new Date()
  });

  return response;
}

这里有三个细节很重要。

第一,insertIfAbsent 必须有唯一约束。不要只在应用层 find -> insert,并发恢复时会打穿。

第二,能传 idempotency key 给上游就一定要传。邮件、支付、工单、消息推送、订单系统,如果支持幂等 key,就把 effectKey 传过去。

第三,不支持幂等的上游更危险。至少要在本地 effect ledger 里记录 pending / completed / unknown,一旦卡在 unknown,不要自动重放,应该进入人工核查或补偿流程。


五、Agent step 应该怎么分类:不是每一步都要同样持久化

把所有东西都塞进 durable workflow 会很重,把所有东西都放内存又很脆。实际落地时,我会先按 step 类型分类。

Step 类型 例子 恢复策略
Pure compute 规划、摘要、格式化 可重放,保存输入输出即可
Deterministic read 查配置、查用户资料 可重放,但要记录版本或快照
Non-deterministic LLM 让模型生成方案 建议保存输出,恢复时不要默认重问
External effect 发邮件、建工单、扣费 必须 effect ledger + idempotency key
Wait event 审批、回调、定时器 持久化等待条件,不占 worker

这里最容易踩坑的是 LLM 调用。

很多人会把 LLM 当成纯计算:失败了再问一次不就好了?

但 LLM 不是确定性函数。哪怕 temperature 很低,上游模型版本、上下文构造、工具结果顺序、系统提示词版本都可能变化。对于一些会影响外部动作的步骤,例如"生成报价方案""决定是否退款""选择要执行的 SQL",我倾向于把 LLM 输出当成状态保存下来。

也就是说:

text 复制代码
LLM 生成计划 -> 保存 plan_json -> 后续执行 plan_json

而不是恢复时重新生成计划。

如果你确实要重问模型,至少要创建新的 attempt,并把两个版本都记录下来,不能悄悄覆盖。


六、等待人工审批时,千万别让 worker 睡在那里

Human-in-the-loop 是 Agent 生产化里最常见的长等待场景。

错误写法通常是:

ts 复制代码
await sendApprovalMessage(task);
await sleepUntilUserReplies(task.id); // 可能等几个小时

这会带来几个问题:

  • worker 被长时间占用;
  • 进程重启后等待状态丢失;
  • 审批超时策略不好实现;
  • 同一个审批被重复发送;
  • 用户回复后不知道唤醒哪个 run。

正确思路是把"等待"变成一个持久状态:

json 复制代码
{
  "run_id": "run_123",
  "current_step": "wait_approval",
  "wait": {
    "type": "approval",
    "approval_id": "apv_789",
    "timeout_at": "2026-07-09T12:00:00+08:00",
    "on_timeout": "reject"
  }
}

worker 做完 sendApprovalMessage 后就释放。审批系统收到用户点击后,发一个事件:

json 复制代码
{
  "type": "approval.completed",
  "approval_id": "apv_789",
  "decision": "approved"
}

调度器再把对应 run 从 wait_approval 推到下一个 step。

这也是为什么 durable execution 系统会强调 event history。长任务不是一个一直挂着的函数调用,而是一串可持久化、可恢复、可观察的事件。


七、LangGraph、Temporal、自研队列怎么选

这不是一个"谁最好"的问题,而是看你要解决哪一层。

1. 只做对话连续性:LangGraph checkpointer 足够

如果你的 Agent 主要是多轮对话、工具调用链不长、外部副作用少,那么 LangGraph checkpointer 很实用。

它适合解决:

  • 线程级记忆;
  • 对话状态持久化;
  • 人工介入后继续;
  • graph step 状态保存。

但你仍然要自己处理外部副作用账本。checkpointer 不会自动知道"这封邮件已经发过"。

2. 做小时级/天级长任务:考虑 durable execution

如果你的 Agent 会跨越很长时间线,比如:

  • 等审批;
  • 等第三方回调;
  • 执行多步骤业务流程;
  • worker 随时可能重启;
  • 失败后要自动恢复;
  • 需要严格的事件历史和审计;

那就应该认真考虑 Temporal 这类 durable execution,或者至少借鉴它的事件历史模型。

Temporal 的优势是:任务状态、事件历史、重试、timer、worker crash recovery 都是核心模型的一部分。你不需要自己用一堆 cron 和数据库锁拼一个半成品调度器。

3. 团队早期:自研最小模型也可以,但别漏这四件事

如果你暂时不想引入新基础设施,最小可用模型也可以很简单:

  • agent_runs:保存 run 状态;
  • agent_events:追加事件历史;
  • agent_effects:记录外部副作用;
  • scheduler:扫描可运行任务,拿锁执行一步。

关键是:每次只执行一个 step,step 完成后保存状态,worker 不长期持有任务。

伪代码:

ts 复制代码
async function tick() {
  const run = await runs.lockNextRunnable({ workerId, ttl: 30_000 });
  if (!run) return;

  try {
    const step = registry[run.currentStep];
    const result = await step.execute(run.stateJson, { run });

    await events.append(run.runId, {
      type: 'step.completed',
      step: run.currentStep,
      result
    });

    await runs.moveToNextStep(run.runId, result.nextStep, result.statePatch);
  } catch (err) {
    await events.append(run.runId, {
      type: 'step.failed',
      step: run.currentStep,
      error: serializeError(err)
    });

    await runs.scheduleRetryOrFail(run.runId, err);
  }
}

这套东西不复杂,但要守住一个原则:

不要让一个长任务依赖某个进程的内存活着。

进程可以随时死,任务不能跟着失忆。


八、恢复策略要写进产品语义,而不是只写进基础设施

Agent 恢复不是纯技术问题。很多步骤失败后,正确行为取决于业务语义。

例如同样是"发通知":

  • 给用户发"任务完成"通知,重复一次可能只是打扰;
  • 给客户发"报价邮件",重复一次会影响信任;
  • 给财务系统发"扣款通知",重复一次就是事故。

所以 step 定义里最好显式声明恢复策略:

ts 复制代码
const sendQuoteEmailStep = {
  name: 'send_quote_email',
  kind: 'external_effect',
  retry: {
    maxAttempts: 3,
    backoff: 'exponential'
  },
  idempotency: {
    key: ({ run, state }) => `${run.runId}:send_quote_email:${state.quoteId}`,
    onUnknown: 'pause_for_manual_check'
  },
  timeout: '30s'
};

这里的 onUnknown 很重要。

最危险的状态不是成功,也不是失败,而是 unknown:

  • 请求发出去了;
  • 本地超时了;
  • 不知道上游到底执行没有;
  • 上游又没有查询接口或幂等 key。

遇到 unknown,如果你自动重试,就可能重复执行;如果你直接标失败,又可能让业务方以为没执行。更稳妥的策略是暂停,暴露给人工或补偿系统处理。

这听起来麻烦,但比线上重复扣款、重复发信、重复建单便宜太多。


九、需要监控哪些指标

做了 durable execution 之后,可观测性也要跟上。否则任务卡住了你还是不知道。

我会至少看这些指标:

指标 含义 告警建议
runnable backlog 可运行但未执行的 run 数 持续上涨告警
stuck running 超过 lock TTL 的 running run 立即告警
retry rate by step 每个 step 的重试率 某 step 异常升高告警
unknown effects 不确定是否完成的副作用数 立即告警
duplicate effect blocked 被幂等账本拦住的重复副作用 上升说明恢复路径频繁触发
recovery success rate 崩溃/失败后成功恢复比例 低于阈值告警
wait timeout count 审批/回调等待超时数 按业务等级告警

尤其是 duplicate effect blocked。很多人会觉得这个指标出现说明系统有问题。我反而觉得它是好事:它说明事故已经发生过,但被防线挡住了。

真正可怕的是你没有这个指标,重复副作用已经发生了,系统还显示一片绿色。


十、一份落地清单

如果你正在把 Agent 从 Demo 推到生产,可以按这份清单自查。

1. Step 是否分类

  • 哪些 step 可以安全重放?
  • 哪些 step 是非确定性 LLM 输出?
  • 哪些 step 会产生外部副作用?
  • 哪些 step 会等待人或外部事件?

2. 状态是否持久化

  • run 状态是否存在数据库?
  • worker 重启后能不能恢复?
  • 是否每个 step 完成后更新状态?
  • LLM 关键输出是否保存,而不是恢复时重新生成?

3. 副作用是否幂等

  • 每个外部副作用有没有 effect key?
  • 本地是否有唯一约束?
  • 上游是否支持 idempotency key?
  • unknown 状态是否会暂停,而不是盲目重试?

4. 等待是否事件化

  • 人工审批是否持久化?
  • worker 是否会释放?
  • 超时策略是否明确?
  • 用户回复能否定位到 run?

5. 恢复是否可观察

  • 能否看到 run 卡在哪一步?
  • 能否看到某个 step 的重试率?
  • 能否查询副作用执行历史?
  • 能否区分 failed、retrying、waiting、unknown?

如果这五项都没有,Agent 再聪明也只能算 Demo。


结论:可靠 Agent 的核心不是"更会想",而是"摔倒后不乱动"

AI Agent 的生产化,很多时候不是模型能力问题,而是分布式系统问题。

会规划、会调用工具、会总结,只是第一步。真正难的是:

  • 它执行到一半可以停;
  • 停了以后知道自己在哪;
  • 恢复时不重复做危险动作;
  • 等人的时候不占资源;
  • 失败后能留下可审计的事件历史;
  • 该自动重试时自动重试,该人工介入时停下来。

一句话总结:

Checkpoint 让 Agent 记得自己走到哪,Durable Execution 让 Agent 在摔倒后还能按业务语义站起来。

如果你的 Agent 只会从头重跑,那它不是可靠,只是乐观。

而生产系统最怕的,就是乐观地重复执行一个已经成功过的副作用。

相关推荐
卷无止境1 小时前
SFML 深度解读:一个教科书级 C++ 多媒体库的内功心法
c++·后端
GuangdongTong1 小时前
基于RabbitMQ实现延迟任务
后端·rabbitmq
学渣超1 小时前
微服务日志智能诊断系统(九) 让 LLM 成为日志分析专家
java·后端·agent
feelmylife591 小时前
AI系统测试实战指南——从确定性边界到非确定性输出,构建AI系统的分层测试体系
后端
想要成为糕糕手1 小时前
从 0 到 1 打造 Coding Agent——mini cursor(终篇):装上命令行手脚,造一个会"干活"的 AI 程序员
langchain·agent·ai编程
AliCloudROS1 小时前
使用 Skill 安装和管理 ECS 扩展程序
后端
ZZZMMM.zip1 小时前
营养方程式-营养餐单规划的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙系统
用户298698530141 小时前
Python 实现 Excel 新旧格式互转:告别 Office 依赖的自动化方案
后端·python·excel
leeyi1 小时前
让 Agent 说中文:Prompt 模板怎么管理(第47篇-E33)
aigc·agent·ai编程