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,完成后需要通知业务系统或下游服务。
这些场景现在基本都是轮询。但有几个原因让你现在就要把消费端设计对:
- 提供商的 Webhook 支持是迟早的事。Stripe 当年也是先轮询,后来 Webhook 成了标准配置。当主流大模型厂商上线 Webhook 时,你的消费端最好已经是幂等的。
- 你可能自己就是 Webhook 提供方。你的 LLM 应用要通知下游(Zapier、用户自己的服务、企业内部系统),这时候你就是发送方,需要设计可靠的投递机制。
- 就算现在用轮询,设计思路是一样的:消息可能重复、可能丢失,消费端要幂等。
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。