AI 服务并发与背压 从有界准入到排队预算
给模型调用加一个并发信号量,能限制同时执行的请求,却未必限制等待它的任务。若入口仍不断创建协程,执行区里只有两条请求,内存里仍可能积着成千上万条等待。
背压要同时约束执行中的请求、排队的请求,以及队列之外等待入队的生产者。本文接续总超时预算与安全重试,用不联网的事件模拟区分准入、拒绝、排队过期和迟到完成。容量与耗时均为教学设定,不是模型服务参数建议。
并发上限与入口容量不是同一个数
Python 的 asyncio.Semaphore用计数控制同时获得许可的任务;它不为整个请求入口规定一个有限的等待任务总数。
asyncio.Queue可以设置正数 maxsize 来限制队内项目,但 await put() 会等待空位;如果调用者无限创建等待 put() 的任务,压力仍留在队列外。完整设计还需要限制生产者数量,或在入口采用明确的有限等待、立即拒绝等策略。本文选择队满立即拒绝。
| 层级 | 本文计数 | 上限与边界 |
|---|---|---|
| 正在执行 | running | 最多 C 个;结束后才释放执行槽 |
| 已经入队 | waiting | 最多 Q 个,按到达顺序启动 |
| 尚未准入 | 入口请求 | 不另设无限等待区,满载立即拒绝 |
| 教学记录与输入脚本 | 模拟器自身数据 | 保留全部轨迹用于验证,不声称整个模拟器内存只有 C+Q 条记录 |
C+Q 限制的是这个模型内已准入且尚未终止的工作数,不包含输入脚本、日志、连接池和其他进程。多副本服务的本地信号量也不自动等于全局配额。
队列满时为什么要反馈给上游
当输入长期超过处理能力,排队会把即时拒绝变成更晚的失败。AWS Builders Library 的队列积压讨论强调,需要关注任务年龄、积压恢复与上游压力反馈,而不只看成功入队的比例。
拒绝策略取决于业务语义:实时问答可以明确报告忙碌并让上游按契约处理;必须持久化的后台作业则需要持久队列、优先级与恢复机制。不能为了让入口成功率好看,偷偷把实时请求变成无限等待的后台工作。
- 有空闲执行槽就启动
先给旧队列分配空位,再接纳同一时刻的新到达,保持 FIFO。
- 没有执行槽但队列有位就排队
截止时刻从到达开始计,等待不会获得新的总预算。
- 执行区与队列都满就拒绝
不给被拒请求另建一个隐形等待任务;拒绝不等于已完成模型计算。
flowchart TD
A[请求到达入口] --> B{执行槽有空位}
B -- 是 --> C[启动并记录开始时间]
B -- 否 --> D{有排队容量}
D -- 是 --> E[入队并保留原截止时刻]
D -- 否 --> F[立即拒绝 overload]
E --> G{先过期还是先获得执行槽}
G -- 先过期 --> H[终止 expired 不发出调用]
G -- 获得槽 --> C
五条请求的手工演算
设 C=2、Q=2,A 到 E 都在 0 毫秒到达。预算从到达时刻开始,deadline=arrival+budget。以下毫秒数均为合成数据。
| 请求 | 合成执行耗时 | 总预算 | 入口决定 |
|---|---|---|---|
| A | 100 | 300 | 立即执行 |
| B | 100 | 300 | 立即执行 |
| C | 50 | 200 | 排队 |
| D | 50 | 90 | 排队 |
| E | 20 | 200 | 执行槽、队列均满,拒绝 |
| 时刻 | 事件 | 正在执行 | 排队 |
|---|---|---|---|
| 0 | A、B 启动;C、D 入队;E 拒绝 | A、B | C、D |
| 90 | D 达到截止时刻,从队列移除 | A、B | C |
| 100 | A、B 完成;C 启动 | C | 空 |
| 150 | C 完成,距到达共 150 毫秒 | 空 | 空 |
C 的执行只花 50 毫秒,但端到端耗时是 150 毫秒。只报告运行耗时会漏掉前面的排队。
排队过期与执行超时要分开
尚未启动的请求达到截止时刻,可以直接从等待队列移除,不产生一次下游调用。已经启动的请求则不同:客户端不再等待,并不证明下游已经停止执行。
本模拟器不假设服务端取消成功。已经运行的任务即使过期,也继续占用执行槽,直到合成的实际完成事件出现;完成时间达到或超过 deadline 时,结果标为 late,不记作按时成功。这样可以避免“客户端超时就立刻释放槽,再启动另一个后台实际仍重叠的调用”的错误计数。
flowchart LR
A[总截止时刻到达] --> B{请求是否尚在队列}
B -- 是 --> C[移出队列 标记 expired]
B -- 否 已经运行 --> D[不假设下游取消成功]
D --> E[实际完成后才释放执行槽]
E --> F[迟到完成标记 late]
可运行的纯 Python 事件模拟
依赖 Python 3.10 或更新版本的标准库,不创建协程、不访问网络、不真实睡眠。执行耗时是脚本的未来完成事件,准入决定不根据它预测真实服务性能。
同一时刻的顺序为:等待请求过期、运行请求完成、启动旧队列、处理新到达。同一时刻到达按输入顺序处理。执行耗时必须为正,避免零耗时事件反复在同一批次产生。
1 | from collections import OrderedDict |
本文 queue_capacity=0 表示没有等待区,不同于 asyncio.Queue(maxsize=0) 的无限队列语义;不能把这个参数直接照搬给实际 Queue。过期事件若发现请求已经启动,则不释放执行槽,等实际完成事件处理。
对于 q 条输入记录,事件数 O(q),堆维护时间 O(q log q),轨迹、脚本、结果与事件占 O(q) 空间。OrderedDict 负责 FIFO 与队中任意请求的过期移除,操作平均 O(1)。模拟器刻意保留完整记录供对拍,不是可直接上线的请求服务器。
生产系统还要保护哪些边界
| 边界 | 必须明确 |
|---|---|
| 单机与全局 | 多进程、多副本的本地并发数会相加;供应商全局配额需要额外协调 |
| 并发与速率 | C 限制同时工作数,不等于每秒请求数或 token 额度 |
| 重试 | SDK 重试与退避是否一直占用本地执行槽,要遵循自己的资源定义 |
| 流式输出 | 首 token 到达不代表完整流结束,不能提前释放仍在工作的槽 |
| 租户公平性 | 单一 FIFO 可能让一个租户占满队列,需要另行设计配额或调度 |
| 持久后台任务 | 不能照搬本例的过期丢弃;需匹配持久化与恢复契约 |
| 性能调参 | 服务时间随批处理、模型、输入长度和设备变化,C 与吞吐不必线性对应 |
自检:队列最多 2 条,但入口无限创建等待 put() 的任务,内存压力已经有界了吗?
没有。队内数量有界,队外阻塞的任务仍可能无界。必须再限制入口生产者或等待数量,并明确定义拒绝、超时与取消策略。
| 必测情况 | 期望结果 |
|---|---|
| C=2、Q=2、同刻到达五条 | 两条执行、两条排队、一条拒绝 |
| 队列中途过期 | 无下游 start 事件,及时腾出队列位置 |
| 完成与新到达同刻 | 旧队列先启动,新请求只使用剩余容量 |
| 完成恰好达到 deadline | 标 late,不算按时成功 |
| 运行已过期但还未完成 | 继续占用执行槽,不产生假空位 |
| 请求 ID 重复或布尔值冒充整数 | 拒绝无效输入 |
项目 tests/verify-bounded-admission-article.cjs 提取并运行本文代码,以独立逐毫秒模型核验随机事件轨迹,检查 running≤C、waiting≤Q 与每个请求唯一终态。测试不会发出真实模型请求。
观测指标应分别报告准入、入口拒绝、排队过期、按时完成与迟到完成,记录排队时长和端到端时长。继续接上RAG 回归评测:质量门槛与容量门槛需要分别通过,不能用一个平均成功率掩盖不同失败。