别让 Agent 被 Webhook 叫醒就开工:实现一个幂等启动层
很多 Agent 系统把调度器的回调直接接到执行器:Cron 到点、Webhook 进来、邮件匹配,就立即创建会话并调用工具。Demo 很顺,生产环境却很快出现重复发信、重复建单和"重试覆盖新数据"。
问题不在 Agent 会不会做,而在系统没有区分三件事:信号到达、运行尝试、业务任务成立。
官方对 Grok Automations 的描述很值得借鉴:自动化可以由日程或邮件触发,每次运行是新的请求,沿用相同指令并读取当前数据。工程上,这意味着"职责定义"与"运行实例"必须分层。
先定义三个对象
ts
type TriggerEnvelope = {
source: "cron" | "webhook" | "manual" | "agent";
eventType: string;
subjectId: string;
occurredAt: string;
traceId: string;
payloadRef: string;
};
type ContextSnapshot = {
instructionVersion: string;
dataAsOf: string;
permissionVersion: string;
inputRefs: string[];
timezone: string;
};
type Job = {
id: string;
responsibilityId: string;
idempotencyKey: string;
trigger: TriggerEnvelope;
snapshot: ContextSnapshot;
state: "ready" | "running" | "done" | "duplicate" | "expired" | "blocked";
};
TriggerEnvelope 只描述发生了什么;ContextSnapshot 冻结"本次运行看到的世界";Job 才是被允许执行的工作。
幂等键必须面向业务
不要直接使用队列消息 ID。Webhook 提供方重试时可能换 ID,多个入口也可能描述同一业务变化。
ts
function jobKey(r: string, t: TriggerEnvelope, s: ContextSnapshot) {
const bucket = t.eventType === "daily_brief"
? s.dataAsOf.slice(0, 10)
: t.subjectId;
return sha256([
r,
t.eventType,
bucket,
s.instructionVersion,
].join(":"));
}
对于 PR 检查,subjectId 最好包含最新 commit SHA;对于日报,日期和时区必须进入计算;对于订单处理,订单号通常已经足够。幂等范围由业务决定,不是由中间件决定。
在事务里完成"判重 + 领取"
ts
async function startJob(input: StartInput) {
const trigger = normalize(input);
const snapshot = await freezeContext(trigger);
const key = jobKey(input.responsibilityId, trigger, snapshot);
return db.transaction(async tx => {
const old = await tx.job.findByKey(key);
if (old?.state === "done" || old?.state === "running") {
return { state: "duplicate", jobId: old.id };
}
const gate = await evaluateStartGate(trigger, snapshot);
if (!gate.ok) return tx.job.insert({ key, state: gate.state, reason: gate.reason });
return tx.job.insertWithLease({ key, state: "running", leaseSeconds: 300 });
});
}
唯一索引仍然必不可少。应用层先查只能优化体验,数据库唯一约束才是并发下最后的防线。
上下文快照不要只存 URL
Agent 运行时间越长,数据越可能改变。建议为关键输入保存内容哈希、对象版本或查询截止时间。这样重放时可以选择:
- 严格重放:使用原版本输入复现决策;
- 重新执行:创建新 Job,显式使用最新数据;
- 继续运行:只有输入版本兼容时才恢复旧租约。
把"重试"和"重新执行"分开,是避免旧任务覆盖新状态的关键。
启动闸门应返回状态,不只抛异常
至少保留 duplicate、expired、blocked 三类非成功状态。重复事件通常不需要报警;过期日报可以直接跳过;缺少凭据或涉及不可逆外部动作才需要人工介入。结构化状态能让队列策略和通知策略分别处理,而不是所有问题都进入同一条错误通道。
小结
一个可靠的 Agent 启动层只做四件事:归一化触发、冻结上下文、计算业务幂等键、通过开工闸门。执行器因此得到的是一份可追踪、可重放、只会被领取一次的 Job,而不是一段来源不明的事件载荷。Tipkay 的部分岗位也可通过定时任务继续推进;接入企业流程时,同样要为每次运行保留去重、快照和关键操作确认。