文章讲解事件触发 Agent 前的签名验证、防重放与幂等去重,并说明何时应退回轮询。还提醒 Agent 的操作可能产生新事件,形成自触发循环,但未深入展开解决方案。
等 Dev 终于上传了第四季度财务报表,就在 Slack 上给我发消息。这一句话就说明了 webhook 对 AI Agent 的意义。没有 webhook,Agent 只能在有人提出请求时行动,或者按固定计划轮询。正是 webhook,让 Agent 能在其他地方发生事件的那一刻作出反应。

但也正是这种即时响应能力,让 webhook 在 Agent 系统中的风险高于普通后端。一个 webhook 把数据库里的某一行写坏了,这是一个 bug;一个 webhook 触发 Agent 发送邮件或更新客户记录,就已经变成了现实世界中的操作,而错误操作更难撤回。更麻烦的是,Agent 自己的操作也可能产生新的 webhook 事件,让系统开始对自己的行为作出反应。
本文讨论在允许 Agent 根据传入事件采取行动之前,必须做对的三件事:验证签名,确认请求确实来自服务提供方;拦截重放事件;使用幂等键对重试去重。此外,还会介绍什么时候需要回退到轮询。本文不会深入讨论自触发循环问题,这个话题会单独写一篇,不过下面也会简单提到。
当 webhook 只是更新数据库中的一行时,错误的 payload 是数据质量问题。当 webhook 能触发 AI Agent 发送邮件、向 Slack 发消息,或更新客户记录时,错误的 payload 就变成了操作问题。正因如此,在由 Agent 驱动的工作流中,签名验证是必选项,不能像某些风险较低的内部工具那样,觉得做不做都行。
标准做法是使用 HMAC SHA256。服务提供方与你的服务器共享一个密钥。服务提供方使用这个密钥,对请求体计算哈希值,通常还会结合时间戳,下一节会介绍这一点,然后通过请求头发送计算结果。你的服务器重新计算相同的哈希值,再比较两者。
这个比较必须使用恒定时间函数,例如 Node.js 中的 crypto.timingSafeEqual、Python 中的 hmac.compare_digest,或者 Ruby 中的 Rack::Utils.secure_compare。直接使用 == 比较可能泄露时间信息,让攻击者通过反复发送请求,逐字节猜出正确的签名。
大多数实现都会在这里出问题。Express 这类框架会在请求到达你的处理函数之前,就把传入的请求体解析成 JavaScript 对象。如果你随后对这个对象调用 JSON.stringify(),再重新计算哈希,那么参与计算的字节就已经与服务提供方发送的不同了,因为空白字符、键的顺序和编码都可能发生变化。结果就是,明明 payload 是真的,签名验证却失败了。
解决办法是在任何解析发生之前,先保存原始请求体:
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
app.post('/webhooks', (req, res) => {
const isValid = verifyHmacSignature(req.rawBody, req.headers['x-signature']);
if (!isValid) return res.status(401).send('Invalid signature');
res.status(200).send('OK');
});
此外,每家服务提供方的请求头名称和哈希格式都不同。当一个 Agent 接入的不再是一个应用,而是十几个应用时,这些差异就会增加不少工作量:
X-Slack-Signature 发送签名,通过 X-Slack-Request-Timestamp 发送时间戳。X-Hub-Signature-256,保留旧版 X-Hub-Signature 只是为了向后兼容。X-HubSpot-Signature-v3 和 X-HubSpot-Request-Timestamp。X-Notion-Signature。这些细节单独看都不难,但在真实的集成体系中,很快就会累积成大量工作。像 Corsair 这样在每个集成中都内置签名验证的插件目录,可以完全省掉针对每家服务提供方编写解析逻辑的工作。
有效签名只能证明某个请求曾经来自服务提供方,不能证明这是它第一次被发送。攻击者如果截获了一份真实且已签名的 payload,比如通过不安全的网络或遭到入侵的日志,就可以在之后原封不动地重新发送这个请求。你的服务器仍然会验证通过,因为签名本身依然正确。
这就是重放攻击,防御办法是把时间戳直接绑定到签名中。
大多数服务提供方会在发送签名的同时,附带一个时间戳请求头,例如 X-Slack-Request-Timestamp 或 X-HubSpot-Request-Timestamp。你的服务器需要检查这个时间戳是否落在一个较短的有效时间窗口内,通常是 5 分钟,也就是 300 秒,并拒绝所有过期请求。
只检查请求头还不够,因为能控制请求的攻击者可以直接改写时间戳,让它看起来像刚刚发送的。为防止这种情况,服务提供方会把时间戳放进参与哈希计算的字符串,而不只是放在独立的请求头中。Slack 的格式是 v0: 加上时间戳,再加上原始请求体;HubSpot 则把请求方法、URI、原始请求体和时间戳拼接起来。由于时间戳本身就在参与哈希计算的字符串中,修改它也会让签名随之失效。
对于由 Agent 触发的工作流,这一点比普通 webhook 消费端更重要。重放一个事件,只是更新数据库里的时间戳字段,风险并不高;但如果重放事件导致 Agent 再发一遍邮件,或在 Slack 上重复发消息,就会产生用户确实能感知到的现实后果。
webhook 服务提供方保证的是至少投递一次,而不是恰好投递一次。网络短暂中断、响应缓慢和超时,都可能让服务提供方重新发送服务器已经收到的事件。对普通应用来说,这意味着偶尔重复写一次数据库;对 Agent 来说,这意味着“发送这封邮件”或“发布这条更新”这样的同一条指令会到达两次,而且可能执行两次。
签名验证通过后,应在执行任何 Agent 逻辑之前,立即返回一个 2xx 状态码。服务提供方通常会设置很短的响应时限,例如 GitHub 要求在 10 秒内响应。处理函数响应太慢,会被视为投递失败,即便你的服务器还在处理,服务提供方也会触发重试。实际可用的模式是:验证请求,把事件放入队列等待后台处理,然后立即返回 202 Accepted。
5xx 响应或超时表示这次失败是暂时的,服务提供方会退避一段时间,之后再次尝试。4xx 响应则表示请求本身无效,例如签名验证失败,原样重试同一个 payload 也无济于事。混淆两者,要么会导致数据悄无声息地丢失,要么会引发重试风暴,因此应该有意识地明确区分,而不是碰巧返回某个状态码。
如果投递在尝试几次之后仍然失败,大多数系统会采用带随机抖动的指数退避,拉开重试间隔,避免所有请求同时冲击已经宕机的端点。达到尝试次数上限后,通常是 5 到 10 次,事件会被转移到死信队列,而不是无限重试。这样就可以人工检查并重新投递,而不会让事件悄悄丢失。
真正实现去重的机制是幂等键,通常直接使用服务提供方已经包含在 payload 中的事件 ID 或投递 ID。为这个 ID 设置唯一约束,并一并存储请求指纹及其处理状态。对于多租户 webhook,这一点尤其重要:唯一约束必须同时限定租户和事件 ID,避免两个客户碰巧使用相似的事件 ID 时发生冲突。
新事件到达时,尝试执行一次原子插入。如果成功,就由你取得这个事件的处理权并进行处理。如果因为键已存在而失败,说明这是重复事件,应跳过 Agent 操作,但仍然返回成功状态,因为返回错误只会让服务提供方继续重试。
这些键不需要永久保留。24 小时的保留窗口与大多数服务提供方的重试窗口一致;72 小时的窗口则能多留一点余地,防范周五上线的 bug 直到下周一才被发现的情况。
去重解决的是“这个事件是否已经见过”,并不能回答“这个事件启动的任务是否已经完成”。对于运行时间较长的 Agent 工作流,后者是另一个需要单独解决的持久可靠性问题。
webhook 无法完全替代轮询。如果把它当成完整替代方案,Agent 掌握的信息就可能悄悄出现缺口。即使采用 webhook 优先的设计,以下几种情况仍然需要轮询。
在实践中,大多数可靠的集成最终都会同时运行两套机制:用 webhook 快速响应,用后台轮询在底层兜底。在判断整套集成中哪些服务提供方需要这种回退机制、哪些已经能可靠推送事件时,值得逐一检查各个插件对 webhook 的覆盖情况,而不是假定所有集成的行为都一样。
有一种 Agent 系统特有的故障模式值得在这里提一下,尽管它并不是本文的重点。当 Agent 发送 Slack 消息或更新 CRM 记录时,这个操作本身就会让服务提供方产生一个 webhook 事件。如果这个事件未经任何过滤,又流回同一个 Agent,Agent 就会对自己造成的变化作出反应,进而启动连锁循环。
总体上的解决办法是按操作者过滤:把传入事件中的 bot ID 或用户 ID,与 Agent 自己的身份进行比较,在事件进入 Agent 的推理步骤之前,丢弃所有匹配项。服务提供方通常会直接在 payload 中包含这类操作者元数据,例如 user_id、bot_id、app_id,所以这项检查通常只需要查表,无须猜测。如果某个自定义集成需要从零加入这种过滤,基于 Corsair 的 SDK 构建插件是一种选择,可以把 webhook 路由和操作者过滤一起配置好。
这种故障模式涉及的边界情况足够多,包括递归深度、时间窗口和速率限制,值得单独写一篇,而不是在这里用几段话带过。
上面提到的所有工作,包括保存原始请求体、适配不同服务提供方的请求头格式、绑定时间戳、处理重试语义,都属于应该一次做对、之后就不用再操心的基础工作。Corsair 要替你承担的,正是这一层。
每个插件都已经接好了带类型定义、完成签名验证的 webhook 处理函数,而且所有服务提供方都通过你服务器上的同一个端点路由,无须为每个集成单独设置端点:
app.post('/webhooks', async (req, res) => {
const webhook = processWebhook(corsair, req.headers, req.body);
res.status(200).send('OK');
});
签名检查、原始请求体处理和时间戳验证都在这次调用内部完成,多租户场景下还会按租户隔离。到达集成代码的内容已经完成验证和去重:Agent 得到的是可以据此行动的事件,而不是还需要自行防御的原始请求。这样,你的代码就能专注于事件应该让 Agent 做什么,而不用先判断事件是否可信。
要为 Agent 正确处理 webhook,归根结底就是按顺序做好三项检查:确认请求确实来自服务提供方,确认此前没有见过这个请求,然后才允许 Agent 据此行动。其他一切,包括重试、轮询兜底和退避间隔,都建立在先把这个顺序做对的基础上。
如果你正在完整搭建这套机制,接下来还有两类相关故障模式值得阅读:如何阻止 Agent 因自己的操作陷入无限循环,以及 webhook 启动长时间运行的 Agent 任务之后,如何保证任务能够持久可靠地执行。
如果你不想自己构建和维护这一层,Corsair 会为它提供的每个集成处理签名验证、重试和去重,让 Agent 只接收到已经检查过、确认真实的事件。它是开源的,因此,如果缺少某个服务提供方或插件,你可以自己添加,无须等待产品路线图。自行托管免费,凭据也始终保存在你自己的数据库里。你可以在 corsair.dev 查看完整的集成支持列表,并直接试用。
这几乎总是请求体解析导致的。大多数框架会在处理函数运行之前,把传入的 payload 转换成对象。将对象重新序列化后再验证签名,得到的字节就与服务提供方最初发送的不同了。应该保存尚未解析的原始请求体,并对它计算哈希,而不是对解析后的对象计算。
不能。SHA1 已被认为在密码学上不够安全。一些服务提供方之所以保留它,例如 GitHub 旧版的 X-Hub-Signature 请求头,纯粹是为了向后兼容。只要服务提供方支持,新的集成都应该使用 SHA256 版本。
24 小时与大多数服务提供方的重试窗口一致,是一个合理的默认值。72 小时则能提供额外保护,应对周五上线的 bug 直到下周才被发现的情况。这些键的作用是防范短期内的重试,不是充当永久日志。
webhook 更适合快速响应,但通常不应该单独使用。补齐历史数据、对接完全不支持 webhook 的服务提供方,以及通过对账检查发现 webhook 悄悄丢失的事件,仍然需要轮询。
5xx 告诉服务提供方,这是暂时性故障,应该稍后重试。4xx 则表示请求本身无效,例如签名验证失败,重试相同的 payload 也不会改变结果。返回错误的状态码,要么会导致无谓重试,要么会让数据悄悄丢失。
如需进一步采取措施,你可以考虑屏蔽此人和/或举报滥用行为。