调用限制与任务调度
调用限制与任务调度 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
读完本课你能:为「同时发起多个 AI 任务」设计并发上限与排队;面对上游 429 设计带抖动的指数退避、尊重 Retry-After、设定重试上限;把取消传播到底,并让每个失败都有明确的错误类别,而不是无限转圈。
问题场景
你在练习页连点三次「AI 讲解」,又在笔记页让 AI 总结一篇资料——四个请求同时出门。上游限流了,返回 429。你的代码一看失败就重试,四个请求原地满血复活,再撞一次墙。更糟的是用户早就关掉了面板,重试还在后台飞。限制本身不可怕,可怕的是没有调度策略的代码把一次限流放大成一场踩踏。
核心解释
限制分两层,要分开设计。
本机这一层:并发上限。 同时发 N 个请求,每个都变慢,还更容易触发上游限流。给同类任务设一个并发上限(比如 2),超出的排队。本站代码执行器就是这么干的——lib/runner/local.ts 里已有任务超过 2 个就直接返回「已有代码任务在运行,请稍后再试」,而不是让它们挤在一起。
上游这一层:被拒绝之后怎么办。 本站适配层把上游状态码分类(lib/ai/adapter.ts 的 classifyHttpError):429 是 rate_limit(等会儿再试有戏),402 是 quota(余额不足,重试没用),401/403 是 not_configured(检查密钥)。重试决策跟着类别走:
// 重试决策:类别决定要不要试、等多久
function shouldRetry(err: AiError, attempt: number): number | null {
if (err.category !== "rate_limit") return null; // 只有限流值得重试
if (attempt >= 3) return null; // 最多 3 次,绝不无限
const retryAfter = Number(err.headers?.["retry-after"]);
if (Number.isFinite(retryAfter) && retryAfter > 0)
return retryAfter * 1000; // 服务端说等几秒就等几秒
return Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 500; // 指数退避 + 抖动
}
三个设计点:Retry-After 优先(服务端比你懂它多忙);指数退避 + 抖动(不抖动的话,同一时刻被拒的所有请求会同步重试,再撞一次);上限封顶(3 次之后把 rate_limit 如实抛给用户,附人话提示)。
两条纪律:
- 重试的前提是安全。聊天接口重发会产生重复消息——本站
POST /api/ai/chat用retry: true标记重试,服务端就不重复写用户消息(幂等,A27 详述)。 - 取消要传播。用户关了面板,
AbortSignal要一路传到fetch(本站把req.signal接到上游请求的AbortController),cancelled是正常结束分支,不是错误。
示例拆解
实验区是一个完整的限流调度模拟器,骨架如下:
const MAX_CONCURRENT = 2; // 本机并发上限
const MAX_RETRIES = 3; // 重试上限
const inflight = new Map(); // key → 进行中的 Promise
function runTask(task) {
if (inflight.has(task.key)) return inflight.get(task.key); // 去重:同任务只跑一份
const p = (async () => {
for (let attempt = 0; ; attempt++) {
if (task.cancelled) throw cancelledError(); // 取消在每个重试点检查
await acquireSlot(); // 并发闸门:满则排队
try {
return await callUpstream(task); // 可能抛 429
} catch (err) {
const wait = shouldRetry(err, attempt);
if (wait === null) throw err; // 明确失败,不吞
await sleep(wait);
} finally {
releaseSlot();
}
}
})();
inflight.set(task.key, p.finally(() => inflight.delete(task.key)));
return p;
}
关键点:
inflight这张 Map 是任务去重:同一个 key(比如「给同一篇资料做总结」)在跑期间再点,拿到的是同一个 Promise,不会变成两个任务。acquireSlot是并发闸门:闸门外排队,闸门内最多 2 个。- 每次失败要么重试(有明确等待时长)、要么抛出明确类别——没有第三种「默默吞掉继续转圈」。
动手任务
- 跑实验区,观察 4 个任务被并发闸门拆成两批、429 触发退避、最终全部完成或明确失败的全过程。
- 把
MAX_RETRIES改成 0,确认被限流的任务立刻拿到明确的错误类别,而不是界面卡死。 - (本机)在聊天链路外给自己加一道约束:同时只允许一个「AI 总结」任务在跑,第二个点击直接提示「已在进行中」。
常见错误
- 无上限重试:
while (true) { try ... }遇到持续限流就是死循环,还持续消耗上游配额。 - 固定间隔重试:所有被拒请求同一时刻醒来再撞一次;退避加随机抖动才能打散。
- 把 402 当 429 重试:余额不足重试一万次也不会好,应该直接提示充值或换模型。
- 取消后仍写库:AbortSignal 已触发还在持久化结果,用户点了「停止」却冒出新消息,取消链路断了。
检查与复盘
- 能说出两层限制(本机并发、上游限流)分别用什么手段处理
- 能解释 Retry-After、指数退避、抖动三者谁优先以及为什么
- 确认重试的接口是幂等的,取消能从 UI 一路传到 fetch
参考资料
- MDN:Retry-After —— 服务端建议等待时长的标准响应头
- OpenAI 文档:Rate limits —— 上游限流的官方口径与错误码
- MDN:AbortController —— 取消传播的标准机制
读完这一课了?
标记完成后,工作台会安排接下来的学习。