site logo

Marico's space

AI Agent 部署后中断任务的恢复

前端技术 2026-08-07 14:51:27 6

部署是 AI Agent 运行中断最常见的原因。不是崩溃,不是超时——就是一次普通的下午发布,而一个 14 步的运行任务刚好卡在第 9 步。

Pod 收到 SIGTERM,内存里的东西全没了。如果任务重试,就得从零开始,把那 9 步重新跑一遍,副作用也重新执行一遍。

要让这个过程变得可靠,需要四件独立的东西,但团队通常只做了其中一件就觉得万事大吉了。

1. 进程外部的状态

如果你用 LangGraph.js,这就是 checkpointer,关键是选哪个:

// 随进程一起死——测试用凑合,生产环境没戏
.compile({ checkpointer: new MemorySaver() }) // 能存活下来
.compile({ checkpointer: new PostgresSaver(pool) })

如果你自己写的循环,那就是每次步执行后更新一条数据库记录:

export type Checkpoint = { runId: string; seq: number; version: number; // 见 §4 cursor: string; // 下一步是哪个 state: RunState; window: MessageParam[]; costUsd: number;
}; await store.save({ ...cp, seq: cp.seq + 1 });

不管哪种方式,关键属性是:不同的进程只需要知道 runId 就能接手运行。

2. 优雅关闭:停止接新活,把手里的干完

默认的 Node 关闭行为是直接丢弃一切。你想要的是:停止接收新任务,让当前步骤执行完并保存 checkpoint,然后退出。

let draining = false;
const inFlight = new Set<Promise<unknown>>(); process.on("SIGTERM", async () => { draining = true; await worker.pause(); // 停止拉取新任务 const deadline = Date.now() + 90_000; while (inFlight.size && Date.now() < deadline) { await Promise.race([ Promise.allSettled([...inFlight]), new Promise((r) => setTimeout(r, 500)), ]); } await pool.end(); process.exit(0);
});

然后在步骤之间检查这个标志,让长时间运行的任务在干净的边界处让出,而不是被卡在半步中间:

while (cp.cursor !== "done") { if (draining) { await store.save(cp); // 已经存过了,但确保万无一失 logger.info("yielding for shutdown", { runId: cp.runId, cursor: cp.cursor }); return { status: "paused" as const, runId: cp.runId }; } cp = await runStep(cp); await store.save(cp);
}

破坏这个机制的配置在代码里是看不出来的:编排器的终止宽限期必须超过 90 秒的期限,否则它会在中途发送 SIGKILL,那个 handler 就成了摆设。在 Kubernetes 里对应 terminationGracePeriodSeconds,检查一下,默认值是 30 秒。

3. 能恢复它的东西

暂停的运行不会自己重启。要么队列重新投递,要么一个 sweeper 去找到它。

export async function sweepStalled() { const stalled = await db.$queryRaw<{ run_id: string }[]>` SELECT run_id FROM checkpoints WHERE status = 'running' AND updated_at < now() - interval '5 minutes' LIMIT 100`; for (const { run_id } of stalled) { await queue.add("resume-agent", { runId: run_id }, { jobId: `resume-${run_id}`, // 幂等 }); }
}

jobId 作为幂等 key 很关键——两个 sweeper 实例会同时发现同一个暂停的运行,没有这个就会有两个 worker 恢复同一个 agent,这就成了另一个更麻烦的问题。

恢复的路径是同一个函数:

worker.process("resume-agent", async (job) => { const cp = await store.load(job.data.runId); if (!cp) return; await runDurable(cp);
});
A run yielding at a step boundary on SIGTERM, then resumed from its<br>
checkpoint by a different<br>
pod.

4. 陷阱:恢复的代码不是暂停的代码

这是让人意外的一点。部署是运行暂停的原因,所以恢复它的进程必然是新代码。

如果那次部署改了 state 的结构,checkpoint 现在就变成了你的代码无法理解的载荷。期望的字段缺失了,不认识的字段却出现了。

失败静悄悄的:state.approvalsundefined,某个地方的 .length 抛了异常,或者更糟的是,布尔值默认为 false,导致 agent 把已批准的步骤重新做了一遍。

给载荷加版本号,不识别的就拒绝:

export const STATE_VERSION = 4; export function load(raw: unknown): Checkpoint { const cp = CheckpointSchema.parse(raw); if (cp.version > STATE_VERSION) { throw new NewerCheckpoint(cp.version, STATE_VERSION); // 回滚了 } return migrate(cp);
} function migrate(cp: Checkpoint): Checkpoint { let c = cp; if (c.version < 3) c = { ...c, state: { ...c.state, approvals: [] }, version: 3 }; if (c.version < 4) c = { ...c, state: renameField(c.state), version: 4 }; return c;
}

前向迁移,遇到更新的 checkpoint 就明确报错,这种情况发生在回滚时——旧代码遇到了新 state。在那里大声失败比靠运气解析字段好得多。

由此得出的运维原则:把 agent 状态当数据库 schema 来对待。 增删字段是安全的;重命名和删除需要一次迁移和一个过渡窗口,期间两种结构都要能读。

恢复时绝对不能做的事

不要重放副作用。Checkpoint 是在步骤完成之后写入的,所以在"邮件已发送"和"checkpoint 已保存"之间崩溃,光标会指向一个已经执行过的步骤。

const key = `${cp.runId}:${cp.cursor}`;
await mailer.send({ ...msg, idempotencyKey: key });

用 cursor 而不是序列号作为 key——seq 每次保存都会递增,所以重放的步骤会生成不同的 key,完全破坏了幂等机制。

这就是为什么步骤边界应该和副作用边界重合:一个对外可见的副作用对应一个步骤,这样重放最多只会重复一个操作,而且这一个操作有保护。

A crash between the side effect and the checkpoint write, absorbed by a<br>
cursor-keyed idempotency<br>
key.

测试:真的把进程 kill 掉

it("completes the task across a hard restart", async () => { const runId = crypto.randomUUID(); const child = fork("./worker.js", [runId]); await waitFor(() => store.load(runId).then((c) => c?.seq >= 3)); child.kill("SIGKILL"); // 完全没有优雅关闭 await runDurable(await store.load(runId)); const final = await store.load(runId); expect(final.cursor).toBe("done"); expect(mailer.sent).toHaveLength(1); // 不是两条
});

用 SIGKILL 而不是 SIGTERM 是故意的:这测试的是没有优雅 handler 的恢复路径,而 OOM kill 或节点故障实际上就是这样。sent).toHaveLength(1) 这个断言能抓到重放 bug。

简短总结

用 Postgres 做持久化 checkpoint,而不是内存。处理 SIGTERM 时要 drain,grace period 要匹配。Sweeper 要有幂等 key。State 要加版本号,支持前向迁移,遇到回滚要大声报错。用 cursor 作为幂等 key。

漏掉任何一个,外部表现都一样:一个运行悄悄重启,花了双倍的钱,偶尔还给人发两条邮件。