上周我复盘了一个挺典型的 Agent 事故。
一个销售助手负责做三件事:读取 CRM 里的客户信息,调用报价系统生成方案,然后把报价邮件发给客户。流程看起来很简单,开发同学也写了重试:模型超时就重试,工具 500 就重试,整个任务失败就从头跑一次。
结果某天报价系统响应慢,Agent 在"邮件已经发出、状态还没写回数据库"的窗口里崩了一次。任务调度器发现失败,自动从头执行。客户最后收到了两封报价邮件,价格还不一样。
这类问题特别容易被低估。因为在 Demo 阶段,Agent 失败通常只是"回答没出来";到了生产阶段,Agent 失败可能是:
- 邮件发出去了,但任务状态还是
running; - 审批卡住了,worker 重启后没人知道该从哪里继续;
- 工具调用成功了,但 checkpoint 没写进去;
- 重试把不可撤销的外部动作执行了两遍;
- 用户看到"任务失败",但第三方系统里已经产生了订单、工单或扣费记录。
所以我现在判断一个 Agent 系统靠不靠谱,不会先问它"模型多强""Prompt 多细",而会先问一个更土的问题:
它崩在任意一步后,能不能只重放该重放的部分,并且不重复执行外部副作用?
这篇文章就聊这个问题:AI Agent 长任务的断点恢复、checkpoint 和 Durable Execution 到底该怎么设计。里面有一个可运行的小实验,专门复现"副作用完成后、checkpoint 前崩溃"的坑。
一、为什么 Agent 长任务不能按普通 HTTP 请求处理
普通后端接口一般是短请求:进来、查库、调用几个服务、返回。哪怕中间失败,大多数时候可以让用户重试一次。
Agent 长任务不一样。它更像一个小型工作流:
text
用户目标
-> 任务规划
-> 检索上下文
-> 调用内部系统
-> 等待人工审批
-> 调用外部 API
-> 汇总结果
-> 通知用户
这条链路里有四种东西混在一起:
- 纯计算步骤:例如让模型生成计划、对文档做摘要。失败后可以重跑。
- 读操作:例如查 CRM、查知识库。通常可以重跑,但要注意读到的数据版本是否变化。
- 外部副作用:例如发邮件、创建订单、写工单、扣库存、转账。这些不能随便重跑。
- 等待事件:例如人工审批、第三方回调、定时器。它们不是"睡一会儿",而是任务生命周期的一部分。
很多 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:
plan:生成任务计划;send_quote:模拟外部副作用,向effects.log写入一条"发送报价";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 只会从头重跑,那它不是可靠,只是乐观。
而生产系统最怕的,就是乐观地重复执行一个已经成功过的副作用。