别让 Agent 被 Webhook 叫醒就开工:实现一个幂等启动层

别让 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,显式使用最新数据;
  • 继续运行:只有输入版本兼容时才恢复旧租约。

把"重试"和"重新执行"分开,是避免旧任务覆盖新状态的关键。

启动闸门应返回状态,不只抛异常

至少保留 duplicateexpiredblocked 三类非成功状态。重复事件通常不需要报警;过期日报可以直接跳过;缺少凭据或涉及不可逆外部动作才需要人工介入。结构化状态能让队列策略和通知策略分别处理,而不是所有问题都进入同一条错误通道。

小结

一个可靠的 Agent 启动层只做四件事:归一化触发、冻结上下文、计算业务幂等键、通过开工闸门。执行器因此得到的是一份可追踪、可重放、只会被领取一次的 Job,而不是一段来源不明的事件载荷。Tipkay 的部分岗位也可通过定时任务继续推进;接入企业流程时,同样要为每次运行保留去重、快照和关键操作确认。

相关推荐
万少2 小时前
等不到 Apple 的折叠 iPhone,我用 DeepV4.1Flash + workBuddy 一句话自己造了一台
前端·javascript·后端
一夜枫林2 小时前
vue实现scroll-view上下滑动右侧左右滑动
前端·javascript·vue.js
志尊宝3 小时前
Vue3 零基础每日笔记(011):生命周期钩子——在正确的时间做正确的事
javascript·vue.js·笔记
YWL3 小时前
OpenLayers地图分享:URL参数同步+分享链接生成,让用户一键分享地图视角
前端·javascript·vue·openlayers
梦醒沉醉3 小时前
5、表达式与运算符
javascript
Q一件事5 小时前
ArcGIS中TypeError: can‘t multiply sequence by non-int of type ‘str‘错误
前端·javascript·arcgis
WeiXin_DZbishe5 小时前
基于springboot大学生提问箱系统-计算机毕设【课程设计】72593
javascript·vue.js·spring boot·vscode·python·node.js·php
何何____6 小时前
js常见继承方法详解
前端·javascript
蜡台7 小时前
Vue 3 原生 ESM 开发实战:不用打包工具,从零搭建组件化应用
前端·javascript·vue.js·html