这段代码看起来很合理:
await retry(() => agent.run(task), { attempts: 3 });
直到 agent.run() 里面包含“上传图片”“发邮件”“发布文章”或“付款”。
第一次执行可能已经把请求送到外部平台,只是在返回响应前断线;第二次重试又执行一遍。你得到的不是高可用,而是两篇文章、两封邮件,或者两笔费用。
Agent 的恢复问题,本质上不是异常捕获,而是:如何证明已经完成了什么,以及哪些动作可以重放。
先画出 side effect fence
我会先把 workflow 的动作分四类:
type ReplayPolicy =
| "safe" // 纯计算,可重跑
| "idempotent" // 同一 key 可重复调用
| "reconcile-first" // 先查外部状态,再决定是否补偿
| "never"; // 不允许自动重放
例如:
const actions = {
outline: "safe",
renderCover: "idempotent",
uploadAsset: "idempotent",
publish: "reconcile-first",
charge: "never"
} as const;

Fence 之前的纯计算可以大胆重跑;越过 Fence 的动作必须产生可查询的外部凭证。
一次可靠工具调用至少要留下两条事件
不要只在成功后写日志。进程可能恰好死在“外部已成功、内部未记录”之间。
await events.append({
type: "EFFECT_INTENT",
deliveryKey,
target: "csdn",
requestHash
});
const result = await publisher.publish(request);
await events.append({
type: "EFFECT_CONFIRMED",
deliveryKey,
externalId: result.articleId
});
如果只看到 EFFECT_INTENT,恢复器不能直接再发一次。它应该使用 deliveryKey、草稿 ID 或内容哈希去外部平台回读:已存在就补写 CONFIRMED;明确不存在才重试;无法确认则进入人工检查。
这就是 reconcile-first。
Checkpoint 不是保存内存,是保存一个可证明的阶段结论
每 10 秒序列化一次对象,不一定能恢复。你可能保存到了半个上传、未提交事务或过期签名 URL。
一个真正可用的 checkpoint 应该包含:
runId: content-20260908-2030
lastCommittedSeq: 37
stage: visual_verified
inputVersions:
facts: v4
csdnDraft: v2
artifacts:
cover: artifact://images/cover@sha256:7c1d
body1: artifact://images/body1@sha256:2b4a
assertions:
- images_exist
- image_count_is_3
- no_watermark_detected
next: csdn_prefill

恢复之前先验证引用的产物仍然存在、哈希仍匹配。通过后才能从 next 开始。
状态恢复应该发生在模型醒来之前
一种常见反模式是:把旧消息发给模型,问“继续刚才的工作”。这会把事务事实交给概率模型回忆。
更合理的顺序:
async function resume(runId: string) {
const log = await eventStore.read(runId);
const cp = latestVerifiedCheckpoint(log);
await artifactStore.assert(cp.artifacts);
await reconcilePendingEffects(log);
return singleWriter.runExclusive(runId, async () => {
const state = replay(log, cp.lastCommittedSeq);
return worker.start({ state, from: cp.next });
});
}
这里刻意把 worker.start() 放在最后。事件日志和外部系统先决定事实,模型再决定下一步做法。
为什么要 single-writer
定时调度器判断任务超时,启动恢复 worker;与此同时,原 worker 只是网络抖动,稍后又继续了。没有互斥,两边会从同一检查点向前跑。
可以用数据库租约解决:
update runs
set lease_owner = :worker,
lease_until = now() + interval '90 seconds'
where run_id = :run
and (lease_until is null or lease_until < now());
受影响行数为 0,就说明另一个 worker 正在执行。Google AX 的公开设计同样强调 single-writer、durable event log 和 recovery,但仓库也明确说明仍在早期开发、协议可能大幅变化;把它当作架构参考比把它当成稳定依赖更稳妥。
输入升级要分支,不能伪装成恢复
原任务基于 facts-v3 写完初稿。恢复时资料库已经到了 facts-v4。系统如果直接换新输入继续,就会出现“旧正文 + 新验收”的混合状态。
恢复有两种合法语义:
- 保持原版本,继续同一个 run;
- 升级输入,创建新 revision,并让受影响阶段重新验收。
不要把第二种藏在 retry 里。
小团队的最小实现
你不需要先做 Kubernetes 上的分布式 Runtime。四张表已经能覆盖大部分内容工作流:
runs 当前阶段、状态、lease
events 追加式事实轨迹
checkpoints 已验收阶段与 artifact manifest
effects delivery key、目标、外部 ID、确认状态
再配三个失败出口:RETRYABLE、NEEDS_RECONCILIATION、NEEDS_HUMAN。只有第一类允许自动重试。
这也是我们做 Tipkay 定时任务和多 AI 员工接力时的一个取舍。研究、写作、配图、排版和发布准备是不同岗位,但用户需要的是一条能继续的工作链,而不是五段随时失忆的聊天。已经完成的素材应该成为下游的稳定输入;不可确认的发布状态应该停下来回读,而不是多点一次。
一个人的生意,也能有一支专业团队。对这支团队来说,恢复能力不是“崩了再跑”,而是保住已经完成的劳动,并且绝不把同一个外部动作做两次。