
部署是 AI Agent 运行中断最常见的原因。不是崩溃,不是超时——就是一次普通的下午发布,而一个 14 步的运行任务刚好卡在第 9 步。
Pod 收到 SIGTERM,内存里的东西全没了。如果任务重试,就得从零开始,把那 9 步重新跑一遍,副作用也重新执行一遍。
要让这个过程变得可靠,需要四件独立的东西,但团队通常只做了其中一件就觉得万事大吉了。
如果你用 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 就能接手运行。
默认的 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 秒。
暂停的运行不会自己重启。要么队列重新投递,要么一个 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);
});
这是让人意外的一点。部署是运行暂停的原因,所以恢复它的进程必然是新代码。
如果那次部署改了 state 的结构,checkpoint 现在就变成了你的代码无法理解的载荷。期望的字段缺失了,不认识的字段却出现了。
失败静悄悄的:state.approvals 是 undefined,某个地方的 .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,完全破坏了幂等机制。
这就是为什么步骤边界应该和副作用边界重合:一个对外可见的副作用对应一个步骤,这样重放最多只会重复一个操作,而且这一个操作有保护。
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。
漏掉任何一个,外部表现都一样:一个运行悄悄重启,花了双倍的钱,偶尔还给人发两条邮件。