为 AI 智能体设计背压,控制排队与重试
文章分析模型或工具变慢时,无限制接单如何引发队列积压、超时、重试和重复执行。提出围绕有效截止时间设计背压机制,涵盖租户公平性、队列上限、重试规则及用户可见状态。
AI 深度解读
Agent 服务出问题时,监控面板可能还很正常:接口能返回,worker 在忙,只有队列越来越长。等模型或工具变慢,积压才集中变成超时、重试和重复执行,用户看到的则是一直没有内容的聊天窗口。
作者给出的办法是建立反压(backpressure)机制:系统必须根据当前容量,明确决定哪些任务立即执行、哪些排队、哪些延期或拒绝。文章的价值在于把队列、重试、调度和前端状态连成了一份契约。不过,其中的容量数字都是示例,没有实测对照。
为什么加了队列,用户还是会一直等
队列负责存放任务,反压负责决定放不下时,上游该怎么办。只要入口继续接单,队列就可能把即时失败变成几分钟后的失败。
Agent 更容易放大这个问题。一次请求会拆出模型、检索和工具调用;一个慢依赖会长期占住并发槽位;重试又会在容量最紧张时增加工作量。批量导入甚至可能和聊天请求争抢同一份模型配额。
因此,作者建议先问:这个任务等一会儿,还有没有价值?交互请求如果已经赶不上用户需要的时间,就应尽快返回可重试的拒绝;后台任务可以返回任务引用,让用户查询状态。
截止时间必须从任务获准进入系统时开始计算,排队也要消耗这份时间。
原文举的例子很直观:一个允许执行五分钟的工作流,排队已经用了四分钟,剩下就只有一分钟。每个阶段都要接收同一个截止时间,避免过期任务继续占用新容量。
还要分清四种控制:限流判断某个客户在时间窗口内能否再发请求;预算控制 token、工具调用和费用;准入控制判断系统现在能否承诺接下任务;反压则规定不能接时,上游如何调整行为。客户没超过每分钟请求数,不代表共享 worker 还有空位。
容量限制要管到模型和工具,不能只管消息队列
任务可能堆在 HTTP 连接池、进程信号量、任务队列、模型服务、工具客户端和数据库连接池。只给消息队列设上限,积压可能转移到别处。
作者提出四层并发限制:全局限制保护共享资源,租户限制防止单个客户占满槽位,工作流限制约束扇出调用,依赖限制隔离慢模型或慢工具。例如,一个计划同时发起五十次调用,不能只算作一个任务。
下面的示例把准入结果明确分成执行、排队、延期和拒绝:
type Admission =
| { kind: "admitted"; runId: string; deadlineAt: number }
| { kind: "queued"; runId: string; positionHint?: number }
| { kind: "deferred"; retryAfterSeconds: number }
| { kind: "rejected"; retryAfterSeconds: number; reason: string };
function deadlineFromNow(ms: number) {
return Date.now() + ms;
}
type Capacity = { running: number; queued: number };
async function admitRun(input: {
tenantId: string;
priority: "interactive" | "background" | "bulk";
capacity: Capacity;
}): Promise<Admission> {
const MAX_RUNNING = 24;
const MAX_QUEUED = 200;
const tenantRunning = await countTenantRunning(input.tenantId);
if (tenantRunning >= 3) {
return { kind: "deferred", retryAfterSeconds: 30 };
}
if (input.capacity.running < MAX_RUNNING) {
return createAndLeaseRun(input, deadlineFromNow(90_000));
}
if (input.priority !== "interactive" && input.capacity.queued < MAX_QUEUED) {
return enqueueRun(input, deadlineFromNow(15 * 60_000));
}
return { kind: "rejected", retryAfterSeconds: 15, reason: "capacity_unavailable" };
}
要看清 input.priority !== "interactive" 这一行:示例里的交互任务没有容量就拒绝,后台任务才有机会排队。24 个运行槽位、200 个排队任务、租户并发 3、90 秒和 15 分钟截止时间,都不是推荐配置。
生产实现还有一个关键前提:检查容量和占用槽位必须形成原子操作,并跨副本共享状态,可以通过数据库、Redis 脚本或队列原语实现。否则多个 Node 实例都看到空位,依然可能同时放行。
队列还应限制长度,大文件和原始网页放在外部存储,任务记录保存引用即可。
公平性则需要调度规则。作者建议按优先级分组,组内按租户轮询,并示例预留 60% 给交互任务、25% 给普通后台任务、10% 给关键修复或审批恢复任务、5% 给批量任务;批量份额只在其他组为空时被回收。后台任务等待过久还应提高优先级,避免长期饥饿。
重试和流式响应,也必须服从这份契约
超时只能说明调用方没及时拿到结果,不能证明下游没有执行。尤其是写入工具,盲目重试可能产生重复操作。
作者要求每次尝试记录依赖、幂等键、截止时间、错误类型和重试次数。只有临时且可安全重试的错误才重试;遇到 429 或过载响应,遵守服务端提供的 Retry-After,使用指数退避和随机抖动。
下面的代码展示了如何让重试受截止时间和模型并发限制约束:
async function callModelWithRetry(run: Run, request: ModelRequest) {
for (let attempt = 0; attempt < 3; attempt++) {
if (Date.now() >= run.deadlineAt) throw new Error("deadline_exceeded");
try {
return await modelGate.withPermit(() => model.generate(request));
} catch (error) {
if (!isRetryable(error) || attempt === 2) throw error;
const delay = retryDelayWithJitter(error, attempt);
if (Date.now() + delay >= run.deadlineAt) throw error;
await recordRetry(run.id, attempt, error);
await sleep(delay);
}
}
throw new Error("unreachable");
}
重点看 Date.now() + delay >= run.deadlineAt:如果等待后已经没有时间,就不要继续安排重试。循环最多尝试 3 次,每次 model.generate(request) 都要取得许可。
这里原文的文字解释比代码更宽:它说信号量包住整个调用及重试决策边界,但实际代码只在 withPermit 中执行模型调用,退避和错误判断在外面。另外,示例没有展示等待许可和调用过程如何响应超时,不能据此认定它已经实现严格的端到端截止时间。
前端也要配合。连接保持打开,不代表模型正在生成;任务可能仍在队列里。作者建议发送真实状态:accepted、queued、running、waiting_for_dependency、paused_for_approval、completed、failed,不要用虚假的百分比或「快完成了」安抚用户。
无法等待的请求应在开始流式传输前拒绝。原文错误载荷包含 error: "capacity_unavailable"、retry_after_seconds: 15、request_id: "run_8b2" 和 safe_to_retry: true。客户端应遵守等待时间,并复用幂等键,避免网络抖动把一次点击变成多个任务。
降级也要保留事实边界:检索不可用,就说明只使用了现有上下文;写入没完成,就不能宣称成功。合并相似请求时,还必须确认授权上下文和输入指纹等价,不能只比较问题文本。
我的判断
我认可文章把「是否接单」提前到入口。对于接入模型和工具的 Node 服务,连接数、CPU 或请求频率都无法单独代表可用容量。增加 worker 只有在下游仍有余量时才可能有效,否则可能加重模型或数据库压力。
但这篇属于设计经验,没有给出吞吐、完成率或成本的前后对照。容量比例和优先级策略需要结合业务验证;示例函数也没有实现完整的租户排队上限、预留调度和资源租约。
相比单纯限流,这套方案能处理慢依赖造成的容量下降,代价是维护更多状态和调度规则。简单服务可以先做全局上限、租户上限和明确拒绝,再根据数据增加复杂度。
验证时,队列深度必须和最老任务年龄一起看。两者同时上升,说明任务持续积压;队列满但 worker 空闲,则可能是调度、租约或分区出了问题。作者建议人为拖慢一条模型路由,再恢复,观察混合租户流量下是否出现重试激增,以及恢复后是否发生第二轮冲击。这是演练建议,原文没有报告演练结果。
能拿来做什么
- 给 Node Agent 入口增加明确的准入结果和统一截止时间,先用观察模式记录「本该放行、排队或拒绝」的判断,再对非关键后台功能启用限制。
- 把聊天界面的空白等待改成真实任务状态,提供轮询或订阅入口;无法接受等待时,在建立流式响应前返回稳定的错误载荷。
- 检查重试是否遵守
Retry-After、截止时间和依赖并发限制;涉及写入的工具增加幂等键与结果核对机制,处理「已经成功但响应超时」。 - 按模型路由、工具、租户等级和任务类型记录排队年龄、首次有效输出耗时、完成率及每个完成工作流的成本,并用慢依赖演练验证容量设置。