LLM 异步任务的 Webhook 工程实践:回调可靠投递、重试与幂等消费的生产设计

Batch API 跑完了,你的系统却没收到通知------Webhook 不只是发个 HTTP POST


凌晨三点,10,000 条推理任务静悄悄地消失了

你用 Batch API 跑了一批深夜任务:10,000 条文本分类请求,预计两小时完成,省了一半费用。第二天早上用户来问:结果呢?

你去查,任务确实跑完了。提供商那边状态是 ended,结果文件也生成了。但你的系统完全不知道------因为你写的轮询逻辑在半夜某个时刻抛了个未捕获异常,默默退出了。用户等了六个小时。

这不是假设场景。异步任务的完成通知在 LLM 应用里是一个经常被低估的工程问题。轮询简单,但有隐患;Webhook 更可靠,但有一套必须处理好的工程细节。


Webhook 在 LLM 应用中处于什么位置

当前主流大模型服务的异步任务场景:

大模型 Batch API:支持把最多 50,000 条请求打包,异步处理,24 小时内完成,价格通常是实时 API 的 50%。目前基本通过轮询查询状态。

消息批处理 API (部分厂商已支持):最多数千至万级请求,同样异步,最长 24 小时,同样是 50% 折扣。轮询端点返回 processing_status 字段,值为 in_progress 或 ended。

长时 Agent 任务:一个需要调用 20 次工具、跑 15 分钟的 Agent workflow,完成后需要通知业务系统或下游服务。

这些场景现在基本都是轮询。但有几个原因让你现在就要把消费端设计对:

  1. 提供商的 Webhook 支持是迟早的事。Stripe 当年也是先轮询,后来 Webhook 成了标准配置。当主流大模型厂商上线 Webhook 时,你的消费端最好已经是幂等的。
  2. 你可能自己就是 Webhook 提供方。你的 LLM 应用要通知下游(Zapier、用户自己的服务、企业内部系统),这时候你就是发送方,需要设计可靠的投递机制。
  3. 就算现在用轮询,设计思路是一样的:消息可能重复、可能丢失,消费端要幂等。

1. 签名校验:第一道门,别让任何人都能往你的端点发数据

Webhook 端点是一个公网 HTTP 接口。如果你不做签名校验,任何人都可以伪造请求触发你的业务逻辑。这不是"将来的安全问题",这是上线第一天就应该有的基础能力。

标准做法是 HMAC-SHA256 + 时间戳防重放,这也是 Stripe、GitHub、Twilio 等主流服务商的实现方式:

typescript 复制代码
import crypto from 'crypto';

interface WebhookVerifyOptions {
  rawBody: Buffer;          // 必须是原始字节,不能是 JSON.parse 后的对象
  signature: string;        // 来自 X-Webhook-Signature 头
  timestamp: string;        // 来自 X-Webhook-Timestamp 头(Unix 秒)
  secret: string;           // 你在配置 Webhook 时拿到的 secret
  maxAgeSeconds?: number;   // 默认 300 秒(5 分钟)
}

function verifyWebhookSignature(opts: WebhookVerifyOptions): boolean {
  const { rawBody, signature, timestamp, secret, maxAgeSeconds = 300 } = opts;

  // 防重放攻击:时间戳不能超过 5 分钟
  const ts = parseInt(timestamp, 10);
  const now = Math.floor(Date.now() / 1000);
  if (isNaN(ts) || Math.abs(now - ts) > maxAgeSeconds) {
    throw new Error(`Webhook timestamp rejected: age=${Math.abs(now - ts)}s, max=${maxAgeSeconds}s`);
  }

  // 重建签名内容:timestamp + "." + 原始 body
  const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 规范化:去掉可能的前缀 "sha256="
  const actualSig = signature.replace(/^sha256=/, '');
  const actualBuf = Buffer.from(actualSig, 'hex');
  const expectedBuf = Buffer.from(expectedSig, 'hex');

  // timingSafeEqual 防时序攻击(不能用 === 字符串比较)
  if (actualBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(actualBuf, expectedBuf);
}

为什么必须用 timingSafeEqual? 字符串比较在发现第一个不匹配字符时就返回,攻击者可以通过测量响应时间推断出签名的前缀,逐字节暴力破解。timingSafeEqual 保证无论哪里不匹配,用时都相同。

Express 中的完整 middleware:

typescript 复制代码
// 关键:必须在 JSON parse 之前拿到 rawBody
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks/llm-batch', (req, res) => {
  const sig = req.headers['x-webhook-signature'] as string;
  const ts  = req.headers['x-webhook-timestamp'] as string;

  if (!sig || !ts) {
    return res.status(400).json({ error: 'Missing signature headers' });
  }

  try {
    const valid = verifyWebhookSignature({
      rawBody: req.body as Buffer,
      signature: sig,
      timestamp: ts,
      secret: process.env.WEBHOOK_SECRET!,
    });
    if (!valid) return res.status(401).json({ error: 'Invalid signature' });
  } catch (e) {
    return res.status(401).json({ error: (e as Error).message });
  }

  // 验证通过,继续处理
  const event = JSON.parse((req.body as Buffer).toString());
  handleEvent(event, res);
});

一个常见的坑:如果你在签名校验前用了 express.json() 中间件,req.body 已经是 JavaScript 对象,你把它 JSON.stringify 再签名,会因为字段顺序、空格等问题导致签名永远不匹配。必须在 raw body 上签名。


2. 快速确认模式:别让 handler 变成超时炸弹

Webhook 提供方通常有严格的超时要求------GitHub 是 10 秒,Stripe 是 30 秒,超时就认为失败,触发重试。

但一个典型的业务处理流程可能包括:数据库查询、文件下载、发送邮件通知、更新多张表......很容易超过 10 秒。

错误模式:同步处理业务逻辑

typescript 复制代码
// ❌ 这会触发重试风暴
app.post('/webhooks/batch-done', async (req, res) => {
  const event = req.body;
  
  // 这些加起来可能 30+ 秒
  await downloadResultFile(event.result_url);      // ~5s
  await parseAndStoreResults(event.batch_id);      // ~15s
  await notifyAffectedUsers(event.batch_id);       // ~8s
  await updateBillingRecord(event);                // ~2s
  
  res.status(200).json({ ok: true }); // 可能已超时,提供商认为失败
});

正确模式:校验 → 去重 → 入队 → 立即 200

typescript 复制代码
import Redis from 'ioredis';
import Queue from 'bull'; // 或 BullMQ、pg-boss 等

const redis = new Redis(process.env.REDIS_URL);
const batchQueue = new Queue('batch-results', process.env.REDIS_URL);

app.post('/webhooks/batch-done', express.raw({ type: 'application/json' }), async (req, res) => {
  // Step 1: 签名校验(快,< 1ms)
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Bad signature' });
  }

  const event = JSON.parse((req.body as Buffer).toString());
  const eventId = event.id; // 提供商给的唯一事件 ID

  // Step 2: 幂等去重(快,Redis SET NX,< 5ms)
  const acquired = await redis.set(
    `wh:seen:${eventId}`,
    '1',
    'EX', 86400,  // 24 小时 TTL,足以覆盖所有重试窗口
    'NX'          // Only set if Not eXists
  );
  if (!acquired) {
    // 重复事件,已处理过,直接 200(让提供商停止重试)
    return res.status(200).json({ status: 'already_queued' });
  }

  // Step 3: 入队(快,< 10ms)
  await batchQueue.add('process', event, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
  });

  // Step 4: 立即返回 200(总耗时通常 < 20ms)
  return res.status(200).json({ status: 'queued' });
});

// 队列消费者(在 worker 进程里,不受 HTTP 超时限制)
batchQueue.process('process', async (job) => {
  const event = job.data;
  await downloadResultFile(event.result_url);
  await parseAndStoreResults(event.batch_id);
  await notifyAffectedUsers(event.batch_id);
  await updateBillingRecord(event);
});

Redis SET NX 的幂等去重有一个微妙问题 :如果你在 SET NX 之后、入队之前崩溃了,这个事件会被标记为"已处理"但实际上没有入队,形成静默丢失。

更健壮的做法是把"已见"标记和入队操作放在同一个数据库事务里:

typescript 复制代码
await db.transaction(async (trx) => {
  await trx.raw(`
    INSERT INTO webhook_idempotency (event_id, received_at)
    VALUES (?, NOW())
    ON CONFLICT (event_id) DO NOTHING
    RETURNING event_id
  `, [eventId]);
  
  const inserted = await trx('webhook_idempotency')
    .where({ event_id: eventId })
    .whereRaw('received_at > NOW() - INTERVAL \'1 second\'')
    .first();
  
  if (!inserted) throw new Error('DUPLICATE');
  
  await trx('pending_webhooks').insert({ event_id: eventId, payload: event });
});

3. 幂等消费:At-least-once 是现实,Exactly-once 是你的责任

Webhook 提供方只能保证"至少发一次"------网络抖动、你的服务重启、提供方自己的重试策略,都可能导致同一个事件被投递多次。

幂等性的两个层次:

层次一:网络层幂等(防止同一个 HTTP 请求被重复处理)------这是上一节的 Redis/DB 去重。

层次二:业务层幂等(防止同一个业务操作被执行多次)------这需要在每个业务操作上设计。

typescript 复制代码
// 发送用户通知:幂等版本
async function notifyUser(batchId: string, userId: string): Promise<void> {
  const idemKey = `notify:batch:${batchId}:user:${userId}`;
  
  const alreadySent = await db.notifications.findOne({
    where: { idempotency_key: idemKey }
  });
  if (alreadySent) {
    console.log(`[notify] Skipping duplicate notification for ${idemKey}`);
    return;
  }
  
  await db.notifications.create({
    data: {
      idempotency_key: idemKey,
      user_id: userId,
      batch_id: batchId,
      sent_at: new Date(),
    }
  });
  
  await emailService.send({ to: userId, template: 'batch-done', data: { batchId } });
}

关于账单扣费的幂等性:这是最容易出事的地方。一次 Batch 任务的费用,绝对不能因为 Webhook 重复触发被扣两次:

typescript 复制代码
async function recordBatchCost(event: BatchCompletedEvent): Promise<void> {
  await db.billingLedger.upsert({
    where: { external_batch_id: event.batch_id },
    create: {
      external_batch_id: event.batch_id,
      tokens_used: event.usage.total_tokens,
      cost_usd: event.usage.cost,
      recorded_at: new Date(),
    },
    update: {}, // 存在则什么都不做(防止重复扣费)
  });
}

4. 发送端的可靠投递:如果你是 Webhook 提供方

当你的 LLM 应用需要通知下游(比如你给企业客户提供了一个任务完成通知 API),你就是发送方。这时候你需要设计一个可靠的投递队列。

指数退避 + Jitter:

为什么不能固定间隔重试?如果你有 1,000 个 Webhook 目标,同时发生了一次网络问题,1 分钟后 1,000 个请求会在同一时刻全部重试,可能又把对方的服务打垮------这叫Thundering Herd。Jitter(随机扰动)可以把重试请求分散开:

typescript 复制代码
function calcNextRetryDelay(attempt: number): number {
  // 指数退避:30s, 1min, 2min, 4min, 8min, 16min, 32min, 60min
  const base = Math.min(30_000 * Math.pow(2, attempt), 3_600_000);
  // ±20% Jitter,把重试请求分散到时间窗口内
  const jitter = (Math.random() - 0.5) * 2 * base * 0.2;
  return Math.round(base + jitter);
}

投递状态机 :pending → delivered / failed / dead

typescript 复制代码
class WebhookDispatcher {
  private readonly MAX_ATTEMPTS = 8;
  private readonly REQUEST_TIMEOUT_MS = 10_000;

  async dispatch(delivery: WebhookDelivery): Promise<void> {
    try {
      const response = await fetch(delivery.endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Delivery-Id': delivery.id,
          'X-Webhook-Attempt': String(delivery.attempt),
          'X-Webhook-Timestamp': String(Math.floor(Date.now() / 1000)),
          'X-Webhook-Signature': this.sign(delivery.payload),
        },
        body: JSON.stringify(delivery.payload),
        signal: AbortSignal.timeout(this.REQUEST_TIMEOUT_MS),
      });

      if (response.ok) {
        await this.markDelivered(delivery.id);
        return;
      }

      if (response.status === 410) {
        // 410 Gone:端点永久下线,停止重试
        await this.markDead(delivery.id, `Endpoint returned 410 Gone`);
        return;
      }

      if (response.status >= 500 || response.status === 429) {
        await this.scheduleRetry(delivery, `HTTP ${response.status}`);
        return;
      }

      // 其他 4xx:记录但不重试
      await this.markFailed(delivery.id, `HTTP ${response.status} (no retry)`);
    } catch (err) {
      await this.scheduleRetry(delivery, String(err));
    }
  }

  private async scheduleRetry(delivery: WebhookDelivery, reason: string): Promise<void> {
    if (delivery.attempt + 1 >= this.MAX_ATTEMPTS) {
      await this.moveToDLQ(delivery, reason);
      return;
    }
    const delay = calcNextRetryDelay(delivery.attempt);
    await db.webhookDeliveries.update({
      where: { id: delivery.id },
      data: {
        attempt: delivery.attempt + 1,
        nextAttemptAt: new Date(Date.now() + delay),
        lastError: reason,
        status: 'pending',
      },
    });
  }
}

5. Dead Letter Queue:失败不是终点,是排障入口

超过最大重试次数后,不能直接删除记录。DLQ 让失败的投递有迹可查、可以人工 replay。

typescript 复制代码
class DeadLetterQueue {
  async enqueue(delivery: WebhookDelivery, finalReason: string): Promise<void> {
    const dlqEntry = await db.webhookDLQ.create({
      data: {
        original_delivery_id: delivery.id,
        endpoint: delivery.endpoint,
        payload: delivery.payload,
        total_attempts: delivery.attempt,
        final_reason: finalReason,
        entered_dlq_at: new Date(),
      },
    });

    await alerting.fire({
      severity: 'warning',
      title: `Webhook 投递进入 DLQ`,
      body: `端点: ${delivery.endpoint} | 投递 ID: ${delivery.id} | 尝试次数: ${delivery.attempt} | 原因: ${finalReason}`,
    });
  }

  async replay(dlqEntryId: string, overrideEndpoint?: string): Promise<string> {
    const entry = await db.webhookDLQ.findUniqueOrThrow({ where: { id: dlqEntryId } });
    const newDelivery = await db.webhookDeliveries.create({
      data: {
        endpoint: overrideEndpoint ?? entry.endpoint,
        payload: entry.payload,
        attempt: 0,
        status: 'pending',
        nextAttemptAt: new Date(),
      },
    });
    await db.webhookDLQ.update({
      where: { id: dlqEntryId },
      data: { replayed_at: new Date(), replay_delivery_id: newDelivery.id },
    });
    return newDelivery.id;
  }
}

DLQ 常见触发原因:

原因 典型症状 处理建议
端点 URL 配置错误 全部 delivery 到同一端点都进 DLQ 通知用户修正 URL,replay
客户服务器长期宕机 所有时间段 delivery 都失败 等服务恢复后 replay
签名验证失败(客户端 bug) HTTP 401 协助排查,replay
Payload 格式变更导致客户端 500 某时间点后开始失败 回滚格式,replay 受影响区间

6. 端点健康熔断:别对不健康的端点持续轰炸

typescript 复制代码
class EndpointCircuitBreaker {
  async shouldBlock(endpointId: string): Promise<boolean> {
    const circuitKey = `cb:${endpointId}`;
    const state = await redis.get(circuitKey);
    if (state === 'open') return true;

    const recent = await db.webhookAttemptHistory.findMany({
      where: { endpoint_id: endpointId },
      orderBy: { attempted_at: 'desc' },
      take: 10,
    });

    if (recent.length < 5) return false;

    const failRate = recent.filter(r => !r.success).length / recent.length;
    if (failRate >= 0.7) {
      // 70% 失败率打开熔断,60 秒后自动重试
      await redis.setex(circuitKey, 60, 'open');
      return true;
    }

    return false;
  }
}

工程决策矩阵

场景 任务量级 推荐方案 备注
简单个人项目 < 100 任务/天 轮询 够用,别过度设计
中等规模 SaaS 100 ~ 10,000/天 Webhook + Redis 去重 + Bull 队列 主流选择
高并发多租户 > 10,000/天 Webhook + SQS/Kafka + 独立 Consumer 集群 水平扩展
强合规要求 任意量级 Webhook + HMAC + mTLS + 审计日志 金融/医疗场景
对外提供 Webhook 任意量级 发送端队列 + DLQ + 熔断 + 管理界面 参考 Stripe 设计

真实提供商的实现:可以直接借鉴

Stripe(公认的 Webhook 最佳实践来源):

  • 签名格式:Stripe-Signature: t=timestamp,v1=hmac_hex,包含多版本支持
  • 超时:30 秒
  • 重试:最多 3 天,按 1min → 5min → 30min → 2h → 6h → 12h → 24h 递增
  • 事件语义:payment_intent.succeeded、charge.failed 等语义明确的名称

GitHub Webhooks:

  • 签名:X-Hub-Signature-256: sha256=hmac_hex
  • 超时:10 秒(比 Stripe 严格)
  • 重试:3 次

两者共同点:签名头带时间戳防重放;每次请求有唯一 delivery ID;事件有唯一 event ID,消费端负责幂等;明确的 410 语义。


生产 Checklist

css 复制代码
□ 签名校验使用 HMAC-SHA256 + timestamp 防重放
□ 使用 timingSafeEqual 比较签名(不用 ===)
□ 在 raw body 上签名(不在 JSON 对象上)
□ Webhook handler 在 10 秒内返回 200(异步处理业务逻辑)
□ 基于 event_id 的幂等去重(Redis NX 或 DB UPSERT)
□ 账单/扣费操作有独立的幂等键保护
□ 发送端使用指数退避 + Jitter 重试
□ 超过最大重试次数的 delivery 进入 DLQ 并告警
□ 实现 DLQ replay 接口(管理员可手动补发)
□ 端点健康熔断,避免对宕机端点持续请求

结语

Webhook 的工程复杂度被严重低估。发一个 HTTP POST 只用三行代码,但让它在生产环境可靠运行------不丢、不重、不假------需要签名、幂等、重试、DLQ、熔断这五套机制配合。

大模型 Batch API 现在还是轮询,但这不是推迟设计消费端的理由。幂等消费的习惯一旦养成,不管提供商用什么通知方式,你的系统都能优雅应对。


文章代码示例使用 TypeScript,依赖 Node.js 标准库 crypto、ioredis、bull。

相关推荐
高频因子挖掘机1 小时前
Python 批量获取股票行情后,如何用 Pandas 统一字段、索引和数据类型?
后端·github·api
高频因子挖掘机1 小时前
多股票 K 线如何用 Pandas 对齐?从收益率计算到横截面分析
后端·github·api
架构技术专栏10 小时前
Agent 工具调用:从工具声明到执行反馈
前端·人工智能·后端
码事漫谈10 小时前
你让AI干的活,它可能只干了十分之一——而你根本看不出来
后端
Wx-bishekaifayuan11 小时前
express皖美特色农产品网售系统53118-计算机课程设计、毕业设计
人工智能·spring boot·后端·python·课程设计·express·旅游
Wx-bishekaifayuan11 小时前
springboot汉中旅游服务平台41442-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
WeiXin_DZbishe12 小时前
基于springboot城市公交实时运营与调度系统-计算机毕设 附源码【课程设计】66173
java·vue.js·spring boot·后端·python·php·课程设计
WeiXin_DZbishe12 小时前
基于Springboot+AI的kuall音乐广场的设计与实现-计算机毕设 附源码【课程设计】12715
vue.js·人工智能·spring boot·后端·python·php·课程设计
A接拉起00712 小时前
2026 年 JavaScript 开发者需要了解的知识
前端·javascript·后端