本地模型 API 出现 OOM,通常不是“请求太多”本身,而是多个推理请求在同一时间段同时占用显存或内存。vLLM、Ollama、llama.cpp server 这类服务在并发推理时,每个请求都会保留上下文缓存和中间激活值,峰值占用随并发数上涨。因此给 API 增加请求频率限制中间件,核心目标是限制“同时在跑的请求数”,而不是简单压低每秒请求数。
为本地模型 API 加限流,真正要控的是并发请求数,而不是单纯压低每秒请求数。建议在 API 入口加并发闸门和等待队列,超限直接返回 503。限流能降低多请求同时推理导致的 OOM 风险,但单次长上下文请求仍可能撑爆内存,需要同时限制输入长度。
判断方向:限流限制的是并发,不是 QPS
按 QPS 限流(每秒最多几个请求)只能挡均匀流量,挡不住突发:同一瞬间进来 10 个请求,QPS 限流可能把它们全部放行,模型服务还是同时跑 10 个推理,照样 OOM。中间件应该统计当前处于“处理中”的请求数,超过阈值就让后续请求排队或直接拒绝。排队适合离线批量任务,拒绝适合交互式 API。通常两者一起用:最多 N 个并发处理,最多 M 个排队,超过 N+M 返回 429 或 503。
中间件实现:并发闸门 + 等待队列
下面给 FastAPI 和 Express 两种接入骨架,按你现有入口选一种改。阈值建议先按模型服务端能同时跑几个请求来设:比如显存只允许 2 个并发推理,就把并发数设 2,排队上限设 4-8。具体数值要结合本机显存、模型大小、上下文长度实测调整,没有通用安全值。
FastAPI 版本
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
MAX_CONCURRENT = 2 # 同时进入模型推理的请求数
MAX_QUEUE = 6 # 排队上限,超过直接返回 503
LIMIT = MAX_CONCURRENT + MAX_QUEUE
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
pending = 0
@app.middleware("http")
async def concurrency_gate(request: Request, call_next):
global pending
if pending >= LIMIT:
return JSONResponse(status_code=503, content={"detail": "queue full"})
pending += 1
try:
async with semaphore:
return await call_next(request)
finally:
pending -= 1
这段中间件挂在 FastAPI 实例上,对所有路由生效。pending 记录已进入中间件的请求数,包括正在推理和排队等待的;超过 LIMIT 直接拒绝;semaphore 保证同一时刻最多 MAX_CONCURRENT 个请求真正进入模型。
Express 版本
const MAX_CONCURRENT = 2;
const MAX_QUEUE = 6;
let active = 0;
const queue = [];
app.use((req, res, next) => {
if (active >= MAX_CONCURRENT) {
if (queue.length >= MAX_QUEUE) {
res.status(503).json({ error: 'queue full' });
return;
}
queue.push({ res, next });
return;
}
run({ res, next });
});
function run(item) {
active++;
const { res, next } = item;
let released = false;
const release = () => {
if (released) return;
released = true;
active--;
if (queue.length > 0) run(queue.shift());
};
res.on('finish', release);
res.on('close', release);
next();
}
release 在响应结束或连接关闭时触发,此时才释放并发槽位并取出下一个排队请求。如果模型返回 SSE 流,确认代理层在流真正结束时才关闭响应对象,否则并发计数会提前释放。
还要确认模型服务端自己的并发参数
中间件挡在 API 前面,但模型服务本身通常也有并发开关。vLLM 的 `--max-num-seqs` 控制 batch 内最大序列数;Ollama 可通过 OLLAMA_NUM_PARALLEL 环境变量控制并行数;llama.cpp server 也有类似并行参数。如果服务端已经限制并发,中间件阈值应小于等于服务端上限,否则放行的请求在服务端排队,OOM 风险依然存在。建议先查所用服务的启动参数,确认最大并行数后再设中间件阈值。
另外,限流控制不了“单个请求的内存占用”。一个超长上下文请求本身就可能触发 OOM。中间件里最好同时校验请求体的输入长度和 max_tokens,超过上限直接返回 400,不转发到模型。这一步属于入口保护,能补上并发限流覆盖不到的漏洞。
验证方法
先验证限流逻辑生效,再观察内存表现。下面以 20 个并发请求为例:
seq 1 20 | xargs -P 20 -I {} \
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Content-Type: application/json" \
-d '{"prompt":"hello"}' \
http://127.0.0.1:8000/v1/completions
命令应同时返回 200 和 503,说明排队/拒绝逻辑在起作用。压测同时另开一个终端持续观察:nvidia-smi 看显存,free -h 看系统内存,确认峰值没有逼近上限,模型服务日志里没有 "Out of memory" 字样。阈值调大调小后重复同一组请求,比较显存峰值和返回码分布,找到当前硬件下能稳定跑住的并发数。
如果模型服务是 CPU 推理,同样看 free -h 和进程 RSS;不管哪种推理,都要给系统留出余量,不要把显存或内存用到接近满值再靠限流兜底。