为本地模型API增加请求频率限制避免OOM的中间件实现

文章导读
本地模型 API 出现 OOM,通常不是“请求太多”本身,而是多个推理请求在同一时间段同时占用显存或内存。vLLM、Ollama、llama.cpp server 这类服务在并发推理时,每个请求都会保留上下文缓存和中间激活值,峰值占用随并发数上涨。因此给 API 增加请求频率限制中间件,核心目标是限制“同时在跑的请求数”,而不是简单压低每秒请求数。
📋 目录
  1. 判断方向:限流限制的是并发,不是 QPS
  2. 中间件实现:并发闸门 + 等待队列
  3. 还要确认模型服务端自己的并发参数
  4. 验证方法
A A

本地模型 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 个请求真正进入模型。

为本地模型API增加请求频率限制避免OOM的中间件实现

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,不转发到模型。这一步属于入口保护,能补上并发限流覆盖不到的漏洞。

为本地模型API增加请求频率限制避免OOM的中间件实现

验证方法

先验证限流逻辑生效,再观察内存表现。下面以 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;不管哪种推理,都要给系统留出余量,不要把显存或内存用到接近满值再靠限流兜底。