🩺 先用 30 秒判断故障在哪一层
“AI 请求超时”经常不是一个问题,而是浏览器、Serverless Function、应用队列、AI 提供商和回调链路中的某一层先到达了截止时间。先看任务有没有落库、有没有入队、Worker 是否领取、提供商是否返回任务 ID、Webhook 是否到达,再决定修哪一层。
页面一直转圈
前端等待时间过长,或者没有把长任务切成“提交任务 + 查询状态”。先查浏览器请求与 API 状态码。
函数到点被终止
平台函数时限小于提供商处理时间。应提前中止当前调用、落库并转交后台任务,而不是把时限调到最大。
队列反复消费
常见原因是 Worker 完成了外部调用,却没来得及确认消息;需要幂等键、租约和结果唯一约束。
Cron 偶发重复或漏跑
调度只保证触发,不等于业务恰好执行一次。任务生成必须幂等,并用补偿扫描找回卡住任务。
Webhook 收到多次
重试投递和网络不确定性会产生重复事件。必须验签,并以 delivery ID 或事件 ID 去重。
429 后流量更高
所有实例同时立即重试会形成重试风暴。需要限次、指数退避、随机抖动和全局并发上限。
🧱 同步、后台任务、队列和 Cron 怎么分工
一个稳定的 AI 项目通常不让一次 HTTP 请求承担整条长链路。先按用户是否必须立即拿到结果来分流:
- 同步请求:适合可在短时间稳定完成、失败后用户可以立即重试的轻任务。请求结束前必须留出序列化、日志和网络抖动余量。
- 提供商后台任务:适合模型处理时间不可预测的长任务。以 OpenAI Responses API 为例,可以用 background mode 创建后台响应,再轮询状态或接收完成 Webhook。
- 应用队列:负责控制并发、重试、租约、优先级和业务幂等。即使提供商支持后台任务,应用仍要保存本地 job 与 provider job ID 的映射。
- Cron:只负责按时生成任务、扫描卡住任务和触发补偿,不在调度请求里直接执行长时间 AI 工作。
提交任务 / 查状态
状态 / 幂等键
并发 / 重试 / 租约
同步或后台任务
Webhook 回来后也不要直接执行复杂业务:验签、去重、记录事件、入队,然后尽快返回成功。这样即使下游数据库或通知服务暂时变慢,也不会让提供商不断重投。
🔁 先把任务状态做成可恢复状态机
只有 success: true/false 无法回答“正在处理、等待重试、卡住、人工停止还是彻底失败”。最少保存以下状态和恢复字段:
type JobStatus =
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "cancelled";
type Job = {
id: string;
status: JobStatus;
idempotencyKey: string;
attempts: number;
maxAttempts: number;
nextRunAt: string | null;
lockedUntil: string | null;
providerJobId: string | null;
lastErrorCode: string | null;
createdAt: string;
updatedAt: string;
};
pending:已接收但还没被 Worker 领取;可按nextRunAt延迟。processing:由带过期时间的租约保护,Worker 崩溃后其他实例可以接管。succeeded:结果已持久化;重复消息只返回已有结果,不再次扣费调用。failed:本轮失败但仍可能重试;必须保存脱敏后的错误分类。dead_letter:达到重试上限或确认不可恢复,等待人工判断。cancelled:用户或运维明确取消;Worker 领取后仍要再次检查。
⏱️ 函数超时要按整条链路分预算
不要只问“Vercel Function 最长能跑多久”。平台上限会随计划、运行模式和配置变化,部署前应查看当前官方限制。更重要的是把总时间拆成前端、网关、函数、队列租约、提供商请求和结果持久化预算。
用户等待预算
超过产品可接受等待时间,就返回 job ID 和状态页,不让浏览器一直占着连接。
函数执行预算
主动中止时间应早于平台硬终止,给异常处理、状态落库和响应留出余量。
外部调用预算
连接、首字节、整体响应分别设超时;长任务改用后台模式或队列,不无限等待。
租约时间也不能简单等于函数时限。租约过短会让两个 Worker 同时处理,过长会拖慢崩溃恢复。可以让 Worker 定期续租,并确保每次外部副作用都有幂等保护。
📈 重试不是再调用一次
网络中断、408、429 和部分 5xx 通常可以有限重试;400、401、403 等输入、认证或权限错误通常要先修正原因。失败请求也可能计入速率限制,因此立即循环重试只会让恢复更慢。
const RETRYABLE_STATUS = new Set([408, 409, 429, 500, 502, 503, 504]);
function shouldRetry(status?: number) {
return status === undefined || RETRYABLE_STATUS.has(status);
}
function retryDelayMs(attempt: number) {
const base = 1_000;
const cap = 60_000;
const exponential = Math.min(cap, base * 2 ** attempt);
return Math.floor(Math.random() * exponential); // full jitter
}
// 400 / 401 / 403 等配置或权限错误先修正原因,不做无边界重试。
// 每次重试都要受 maxAttempts、截止时间和幂等键约束。
生产策略还应同时限制单任务最大次数、全局并发、单用户并发和重试截止时间。若响应提供 Retry-After 或速率限制 Header,优先遵循服务端提示;否则使用带随机抖动的指数退避。重试前再次检查任务是否已成功或取消。
| 故障 | 默认动作 | 退出条件 |
|---|---|---|
| 网络中断 / 408 | 退避后重试 | 达到截止时间或最大次数 |
| 429 | 降低并发并按 Header / 退避等待 | 预算耗尽后转等待或降级 |
| 500 / 502 / 503 / 504 | 有限重试,必要时熔断 | 持续错误进入死信或降级 |
| 400 | 修正输入,不自动重试 | 新版本或人工修复后重新提交 |
| 401 / 403 | 检查 Key、权限与项目配置 | 凭据或权限确认恢复 |
🔒 幂等、锁和唯一约束缺一不可
队列和 Webhook 通常提供“至少一次”语义:同一任务可能被交付多次。业务层要接受重复输入,并保证只产生一次扣费、一次订单状态变更或一份最终结果。
- 客户端为一次业务意图生成稳定的
idempotencyKey,重复点击继续使用同一个键。 - 数据库对“用户 + 业务类型 + 幂等键”建立唯一约束,不能只依赖内存锁。
- Worker 用带过期时间的租约避免正常情况下并发处理;租约只是协调,不替代唯一约束。
- 外部调用成功后先持久化 provider job ID 或结果,再确认队列消息。
- 通知、扣费、积分等副作用分别建立自己的去重记录,不假设主任务只会执行一次。
async function processJob(job: Job) {
const lease = await jobs.tryAcquireLease(job.id, 60_000);
if (!lease) return;
try {
const current = await jobs.get(job.id);
if (!current || current.status === "succeeded") return;
const result = await callProvider({
inputRef: current.id,
idempotencyKey: current.idempotencyKey,
});
await jobs.complete(current.id, result);
} catch (error) {
const latest = await jobs.get(job.id);
const retryable = shouldRetry(getHttpStatus(error));
if (retryable && latest.attempts < latest.maxAttempts) {
await jobs.reschedule(latest.id, retryDelayMs(latest.attempts));
} else {
await jobs.moveToDeadLetter(latest.id, sanitizeError(error));
}
} finally {
await jobs.releaseLease(job.id, lease);
}
}
🗓️ Cron 只负责触发和修复扫描
以 Vercel Cron 为例,官方文档明确提醒调度失败不会自动重试,长任务可能和下一次调度重叠,事件也可能重复投递。因此 Cron Handler 应尽快完成以下三件事:
- 验证调度请求,例如使用
CRON_SECRET;不要让公开 URL 任意触发高成本任务。 - 按“任务类型 + 计划时间窗口”生成唯一幂等键,只创建一次 job。
- 把 job 入队并快速返回;真正的 AI 调用、批处理和通知交给 Worker。
另设一个低频补偿扫描:寻找租约过期的 processing、长时间未推进的 pending、以及已经完成但通知未发送的任务。Cron 时间通常按 UTC 解释,写配置时同时记录业务时区,避免夏令时或人工换算造成偏移。
🪝 Webhook 要先验签、去重、快速返回
OpenAI 官方 Webhook 文档建议用官方 SDK 验证签名,并让处理器尽快返回 2xx;非必要工作交给后台 Worker。投递可能重试并出现重复,官方文档建议使用 webhook-id 作为幂等键。不要在验签前解析并重写原始请求体。
import OpenAI from "openai";
const client = new OpenAI({ webhookSecret: process.env.OPENAI_WEBHOOK_SECRET });
export async function POST(request: Request) {
const body = await request.text();
const headers = Object.fromEntries(request.headers);
// 官方 SDK 会校验签名;验签失败直接拒绝。
const event = await client.webhooks.unwrap(body, headers);
const deliveryId = request.headers.get("webhook-id");
if (!deliveryId || await deliveries.exists(deliveryId)) {
return new Response("ok", { status: 200 });
}
await deliveries.recordAndEnqueue({ deliveryId, event });
return new Response("ok", { status: 200 });
}
示例中的存储接口只是架构占位,实际实现必须让“记录 delivery ID + 入队”成为原子操作,或用可重放的 outbox 补偿。Webhook Secret 与 API Key 分开存储和轮换,日志只记录事件类型、delivery ID、关联 job ID 和处理结果。
📮 死信队列和人工恢复怎么设计
达到最大重试次数后,消息不能悄悄消失,也不能永久循环。Cloudflare Queues 等队列支持把失败消息送入 Dead Letter Queue;若没有配置死信队列,达到重试上限的消息可能被删除。无论平台是否内建 DLQ,都应在业务数据库保留最终状态。
- 死信记录只保存恢复所需的引用、错误分类、尝试次数和时间,不复制敏感 Prompt、Key 或完整用户数据。
- 提供“重放前预览”:确认代码版本、凭据、配额、输入和幂等键仍然有效。
- 重放生成新的执行记录,但继续关联原 job;避免覆盖历史失败证据。
- 不可恢复的 400/权限错误由人工关闭;可恢复的提供商故障在确认恢复后分批重放,避免瞬间回灌。
🛟 生产故障时怎样降级而不是雪崩
降级目标不是假装服务正常,而是保护已有结果、控制成本并给用户清楚预期。建议按影响从轻到重准备以下开关:
- 排队而非失败:返回 job ID、当前状态和合理的再次查询时间,避免前端高频轮询。
- 暂停非关键任务:先停摘要刷新、批量标签和低优先级离线任务,把配额留给用户直接请求。
- 最后成功结果:对允许陈旧数据的场景展示最后一次成功结果,并明确更新时间,不能冒充实时结果。
- 受控替代路径:只有在输出质量、隐私、地区可用性和成本都经过验证时才切换模型或提供商;不可静默改变关键业务语义。
- 熔断和慢启动:持续失败时暂时停止新调用;恢复后逐步放量,而不是让所有积压任务同时冲回上游。
- 人工重试:给运维和用户提供有权限、有次数限制、可审计的重新执行入口。
🔭 最少要记录哪些观测字段
只记录“AI 调用失败”无法排障。每一次执行至少要能串起客户端请求、业务 job、队列交付和提供商请求:
身份
job_id、idempotency_key 哈希、用户或租户的内部 ID
队列
入队时间、开始时间、queue_delay、delivery / attempt 次数
执行
worker 版本、处理时长、租约更新时间、最终状态
上游
提供商、模型配置标识、provider request/job ID、HTTP 状态
容量
队列深度、消费速率、并发、429 比例、死信新增量
安全
只记错误分类和脱敏摘要,不记录 Key、Authorization、Cookie 或完整敏感正文
告警优先看趋势而非单次失败:队列等待时间持续上升、成功率下降、429 突增、processing 租约过期、Webhook 延迟和死信新增量,往往比 CPU 使用率更接近真实用户影响。
✅ 上线前做五组故障演练
下面的命令名是建议的测试入口,不是可直接复制到所有项目的现成脚本。把同类场景接入你自己的测试夹具或预发布环境:
# 1. 模拟提供商限流或暂时故障
PROVIDER_FAULT=429 npm run test:worker
# 2. 同一个幂等键并发提交两次,只允许产生一个业务结果
npm run test:idempotency
# 3. Worker 在完成前退出,锁到期后任务应能被其他实例接管
npm run test:lease-recovery
# 4. 连续失败达到上限后进入死信,不得无限重试
npm run test:dead-letter
# 5. Webhook 重放同一 delivery ID,状态更新只能执行一次
npm run test:webhook-replay
- 限流场景不会立即重试,也不会超过单用户和全局并发上限。
- 两个 Worker 同时收到同一任务时,只产生一份最终结果和一次业务副作用。
- Worker 在外部调用后、确认消息前崩溃,恢复后能识别已有 provider job 或结果。
- 连续失败进入死信并触发告警;人工重放保留历史,且不会一次性回灌全部消息。
- Webhook 重放、乱序和延迟到达都不会把已成功任务改回旧状态。
❓ 常见问题
用了 OpenAI background mode,还需要自己的队列吗?
通常仍需要。background mode 解决提供商侧长任务的异步执行;你的应用仍要管理用户请求、并发、状态映射、幂等、通知、取消和跨提供商降级。
把函数最大执行时间调高就能解决超时吗?
只能缓解一部分场景。平台硬上限、用户等待、网络中断和提供商处理时间仍然存在。长任务更适合返回 job ID,再用队列、后台任务和状态查询完成。
Cron 每分钟跑一次,能当队列 Worker 吗?
不建议。Cron 适合生成任务和补偿扫描,不能自然提供高效消费、并发控制、租约、重试和背压。低流量原型可以轮询任务表,但也要实现唯一约束与锁。
为什么消息处理成功后还会再来一次?
Worker 可能已经完成业务操作,但在确认消息前崩溃或网络中断。队列会认为没有成功消费并再次交付,所以业务必须幂等。
所有 5xx 都应该重试吗?
可以把部分 5xx 作为候选,但仍要限制次数、截止时间和并发,并结合请求是否安全重放。持续 5xx 应进入熔断、降级或死信,而不是无限循环。
轮询状态应该多频繁?
根据任务预计时长逐步放慢,并返回建议的下一次查询时间。页面失焦后降低频率,完成或失败后停止。高并发场景优先用事件通知配合低频兜底轮询。
死信消息可以自动全部重放吗?
不应默认全量重放。先确认根因已修复,再按批次、速率和影响范围逐步恢复;否则会重新触发限流、重复副作用或把旧输入带回生产。
📚 官方资料来源
- OpenAI Background mode:后台响应创建、状态轮询与适用边界。
- OpenAI Webhooks:签名验证、快速返回、重试投递和重复事件处理。
- OpenAI Rate limits:速率限制维度、响应 Header 与指数退避建议。
- Vercel Cron Jobs:重试、重叠执行、重复投递、时区和鉴权注意事项。
- Vercel Function duration:函数执行时限配置与当前计划边界。
- Cloudflare Queues Dead Letter Queues:最大重试次数与死信队列配置。
平台限制和产品能力可能更新。本文避免写死套餐时限;上线前请以对应平台的当前官方文档和账户控制台为准。