大模型推理服务并发完全指南:从一次"十个用户同时提问,GPU 直接 OOM 崩溃"看懂 KV Cache 与显存调度

2023 年我给团队部署一个自己的大模型推理服务拿一个开源大模型包成一个内部 HTTP 接口别人发 prompt 过来我返回生成结果怎么扛住很多人同时用这件事我压根没多想第一版我做得很顺手用 FastAPI 包一层收到请求就直接在 GPU 上调 generate 跑完把文本返回我心里很笃定并发嘛不就是 Web 服务的老问题多开几个 worker 加个线程池不就扛住了本地一个人测一测真不错可等这服务真正开放给团队几十号人同时往里灌请求一串问题冒了出来第一种最先把我打懵多个用户同时提问服务直接 CUDA out of memory 崩溃整个进程挂掉第二种最难缠不是固定多少人就崩有时五个人就崩有时十五个人都没事毫无规律第三种最头疼我加了线程池把并发数限制在四崩是不崩了可 GPU 利用率低得可怜明明显存还剩一大半吞吐怎么都上不去第四种最莫名其妙一个用户发来超长文档让模型总结他这一个请求就把整台 GPU 占满后面所有人全部卡死排队我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为推理服务的并发跟普通 Web 服务一样是个线程数的问题可一个推理请求在 GPU 上占住的核心资源不是线程而是一块货真价实的显存这块显存的主体叫 KV Cache 它的大小随上下文长度线性增长 GPU 显存是一堵硬墙装不下不是变慢而是当场 OOM 整个服务崩溃所以推理并发真正的约束从来不是开了几个线程而是此刻所有并发请求的 KV Cache 加起来有没有撑爆显存真正把推理服务并发做扎实核心不是限制并发请求数而是认清推理并发受 GPU 显存约束每个请求的显存占用是动态可变的要用一份明确的显存预算去做准入控制用 continuous batching 榨干显存利用率并把长请求隔离开本文从头梳理为什么按线程数限流管不住推理服务一次推理请求到底占多少显存静态批处理的陷阱与 continuous batching 显存预算与准入控制长请求的隔离与抢占以及一些把推理服务做扎实要避开的工程坑

2023 年我给团队部署一个自己的大模型推理服务:拿一个开源大模型,包成一个内部 HTTP 接口,别人发 prompt 过来、我返回生成结果。怎么扛住"很多人同时用"?这件事我压根没多想。第一版我做得很顺手:用 FastAPI 包一层,收到请求就直接在 GPU 上调 model.generate(),跑完把文本返回。就完事了。我心里很笃定:"并发嘛,不就是 Web 服务的老问题?多开几个 worker、加个线程池,不就扛住了?"本地一个人测一测——真不错:发 prompt、出结果,又快又稳。可等这服务真正开放给团队、几十号人同时往里灌请求,一串问题冒了出来。第一种最先把我打懵:平时一个人用好好的,一到多个用户同时提问,服务直接 CUDA out of memory 崩溃,整个进程挂掉。第二种最难缠:它不是固定多少人就崩——有时 5 个人就崩了,有时 15 个人都没事,毫无规律,我盯着监控怎么都找不到那条线在哪。第三种最头疼:我加了个线程池、把并发数死死限制在 4,崩是不崩了,可 GPU 利用率低得可怜——明明显存还剩一大半,吞吐却怎么都上不去。第四种最莫名其妙:一个用户发来一篇超长文档让模型总结,他这一个请求,就把整台 GPU 占满了,后面所有人全部卡死排队。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"推理服务的并发,跟普通 Web 服务一样,是个线程数的问题"。这句话把"一个请求"理解成了"一个线程",把并发上限理解成了"能开多少个线程"。可它根本不是这么回事我脑子里,一个请求就是一个轻飘飘的执行单元,它占一个线程、跑完就还回去,所以并发能开多少,取决于我愿意开多少线程。可大模型推理请求,根本不是这种"轻飘飘的、占用固定"的东西。一个推理请求在 GPU 上跑的时候,它占住的核心资源不是 CPU、不是线程,而是一块货真价实的 GPU 显存——这块显存的主体,叫 KV Cache(键值缓存):模型每生成一个 token,都要把这个 token 在每一层注意力里算出的 Key 和 Value 缓存下来,供后面的 token 复用。这意味着两件要命的事。第一,一个请求占的显存不是固定的,而是随着它的上下文长度线性增长——上下文越长,KV Cache 越大,一个 8000 token 的请求,光 KV Cache 就可能是一个 200 token 请求的几十倍。第二,GPU 显存是一堵硬墙,装不下就是装不下,一旦所有在飞行中的请求的 KV Cache 总和超过了显存上限,不是变慢,是当场 OOM、整个服务崩溃。所以推理服务的并发,真正的约束从来不是"我开了几个线程",而是"此刻所有并发请求的 KV Cache 加起来,有没有撑爆显存"。我按线程数去限流,等于拿一把根本量不到点子上的尺子,去量一堵会塌的墙。真正把推理服务的并发做扎实,核心不是"限制并发请求数",而是认清推理并发受 GPU 显存约束、每个请求的显存占用是动态可变的,要用一份明确的显存预算去做准入控制(admission control),用 continuous batching 榨干显存利用率,并把长请求隔离开,别让一个巨型请求堵死所有人。这篇文章就把大模型推理服务的并发这个坑梳理一遍:为什么"按线程数限流"管不住推理服务、一次推理请求到底占多少显存、静态批处理的陷阱与 continuous batching、显存预算与准入控制、长请求的隔离与抢占,以及一些把推理服务做扎实要避开的工程坑。

问题背景

这个坑普遍,是因为绝大多数后端工程师的并发直觉,都是被普通 Web 服务训练出来的——在那个世界里,一个请求约等于一个线程,占用基本恒定,你加机器、加线程就能扛更多。这套直觉用在大模型推理服务上,从根上就错了。它错得隐蔽,是因为单用户、短 prompt 的测试永远测不出来:你本地一个人发几句短话,KV Cache 小得可以忽略,显存绰绰有余,服务表现得"完全正常"。它只在多用户并发、且出现长上下文请求之后才集中爆发,而那时崩的不是某个请求,是整个推理进程

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:多人同时提问服务直接 OOM 崩溃;崩溃的人数阈值毫无规律;按线程数限流后 GPU 利用率极低;一个超长请求就能占满 GPU 卡死所有人。
  • 错误认知一:以为一个推理请求约等于一个线程,占用固定。真相是它占的是一块 KV Cache 显存,大小随上下文长度动态变化。
  • 错误认知二:以为并发上限是个线程数。真相是并发上限是"所有在飞行请求的 KV Cache 总和 ≤ 显存预算"这个不等式。
  • 错误认知三:以为限制并发数就安全了。真相是 N 个短请求和 N 个长请求占的显存差几十倍,固定的并发数要么会 OOM、要么浪费显存。
  • 真相:推理服务的并发是一个显存资源调度问题。要靠显存预算 + 准入控制 + continuous batching + 长请求隔离来管,而不是靠线程池。

一、为什么"按线程数限流"管不住推理服务

先把第一版那个推理服务摆出来。它的思路就是字面意思:收到请求,直接在 GPU 上跑生成。

# 第一版:用 FastAPI 包一层,收到请求就直接 generate(反面教材)
from fastapi import FastAPI
app = FastAPI()
model = load_model("my-llm-7b")          # 模型常驻一张 GPU

@app.post("/generate")
def generate(req: GenRequest):
    # 每个请求进来,直接在 GPU 上跑一次自回归生成
    output = model.generate(req.prompt, max_tokens=req.max_tokens)
    return {"text": output}

这段代码单人用毫无破绽。它的崩塌,要看清一个事实才显形:一个推理请求在 GPU 上,占的不是一个线程,是一块显存。当我意识到"并发"出了问题,我的第一反应,是用最熟悉的工具——线程池——把并发数摁住:

# 第二版:加一个线程池,把并发数死死限制在 4(还是不对)
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=4)   # 最多 4 个请求同时在跑

@app.post("/generate")
def generate(req: GenRequest):
    future = pool.submit(model.generate, req.prompt, max_tokens=req.max_tokens)
    return {"text": future.result()}
# 问题:4 个短请求很轻松,4 个长请求照样 OOM —— 并发数根本不等于显存占用

这一版"看起来"对了:崩溃的频率确实降下来了。但它同时暴露了两个新毛病,而这两个毛病恰恰证明"按线程数限流"这个思路从根上就没对准问题。一个毛病是还会崩:4 这个数字是我拍脑袋定的,它在大家都发短 prompt 时安全,可一旦同时来了 4 个长上下文请求,这 4 个请求的 KV Cache 加起来照样能撑爆显存。另一个毛病是太浪费:大多数时候大家发的都是短请求,4 个短请求只用掉显存的一小角,可我的并发上限就卡死在 4,剩下的显存白白闲置,吞吐被人为地压在了一个很低的水平。

这里要建立的第一个、也是最重要的认知是:做任何资源限流之前,你必须先问清楚一件事——"我要限的那个量,和真正会被耗尽的那个资源,是不是同一个东西"。普通 Web 服务里,这两者基本是对齐的:一个请求占一个线程、占的内存也大致均匀,所以"限制并发请求数"约等于"限制资源消耗",拿请求数当尺子,量得还算准。但大模型推理服务,把这个对齐关系彻底打破了。这里真正会被耗尽的资源,是 GPU 显存里那块用来放 KV Cache 的空间;而每个请求要占多少这种空间,差异极大——它由请求的上下文长度决定,一个几百 token 的闲聊和一个上万 token 的长文档总结,占的显存可能差几十倍。这种情况下,"请求数"这把尺子和"显存"这个资源之间,根本不存在稳定的换算关系:同样是 4 个请求,可以只占 1GB,也可以占 20GB。你用一个和真实资源不挂钩的量去限流,结果必然是两头不讨好——阈值定低了,显存大量闲置、吞吐上不去;阈值定高了,碰上几个长请求就 OOM。这就解释了第二、第三个怪现象:崩溃人数毫无规律,是因为"人数"压根不是那个决定生死的变量,长短请求的随机组合才是;限流后利用率极低,是因为你为了防住最坏情况(全是长请求),被迫把阈值定得对平均情况(全是短请求)极度保守。要走出这个困境,唯一的办法是换一把尺子——别再数请求,去算显存。

二、一次推理请求到底占多少显存:KV Cache 的账

既然要拿显存当尺子,就得先会算这笔账。一个推理请求在 GPU 上的显存占用,分两部分。一部分是模型权重,它在服务启动时一次性加载,所有请求共享、不随并发变化,是个常量。真正随请求数和上下文长度变化的,是另一部分——KV Cache

KV Cache 是什么?大模型生成是自回归的:它一个 token 一个 token 地往外蹦,每生成一个新 token,都要回头"看"前面所有 token。为了不在每一步都把前面所有 token 重算一遍,模型把每个 token 在每一层注意力里算出的 Key 和 Value 向量缓存下来——这就是 KV Cache。它的大小,有一个清清楚楚的公式:

# 估算一个请求的 KV Cache 显存占用(单位:字节)
def kv_cache_bytes(seq_len, num_layers, num_kv_heads, head_dim, dtype_bytes=2):
    # KV 是两份(Key 和 Value),每一层、每一个 token 都要存一份
    # dtype_bytes=2 表示用 fp16 / bf16 存储
    return 2 * seq_len * num_layers * num_kv_heads * head_dim * dtype_bytes

# 以一个 7B 模型为例:32 层,32 个 KV 头,每个头 128 维
per_token = kv_cache_bytes(1, 32, 32, 128) / 1024        # 每个 token 占多少 KB
print(f"每个 token 的 KV Cache 约 {per_token:.0f} KB")

# 一个 8000 token 上下文的请求,光 KV Cache 就要:
big = kv_cache_bytes(8000, 32, 32, 128) / 1024 / 1024    # 换算成 MB
print(f"8000 token 的请求,KV Cache 约 {big:.0f} MB")

这个公式里藏着推理服务并发的全部要害。盯住 seq_len 这一项:KV Cache 的大小,和上下文长度成正比。一个 200 token 的短请求和一个 8000 token 的长请求,光 KV Cache 就差 40 倍。而且 seq_len 不是个固定值——它从 prompt 的长度起步,每生成一个 token 就 +1,所以同一个请求,它占的显存是一路往上涨的,涨到它生成结束、被释放为止。这就是为什么显存占用是"动态"的。

这里要建立的认知是:很多人觉得"推理服务的并发"是个玄学问题,黑盒一样,崩不崩全靠运气——但你只要把 KV Cache 这个公式摆出来,这件事立刻就从玄学变成了一道清清楚楚的算术题。GPU 的显存总量是已知的,模型权重占多少是已知的,留给 KV Cache 的预算就是一个确定的数。而每个请求要占多少 KV Cache,由它的上下文长度决定,也是能算出来的。于是"这台 GPU 此刻还能不能再接一个请求",根本不是什么需要靠经验去蒙的事,它就是一个不等式:已用的 KV Cache + 新请求要占的 KV Cache,有没有超过预算。第一版之所以像在玩俄罗斯轮盘赌,不是因为这个问题本质上不可知,而是因为它压根没有去算这笔账——它对显存的占用一无所知,只能闭着眼睛把请求往 GPU 里塞,塞爆了才知道。这里要建立的通用认知是:面对一个看起来"不确定、靠运气"的系统行为,你的第一反应不该是接受它的不确定,而该是去找那个决定它的、可以被量化的物理量——一旦你找到了 KV Cache 这个量、并且能算出它,整个系统就从"不可控的黑盒"变成了"可以精确调度的资源池"。能不能算清楚一笔账,往往就是一个系统能不能被工程化掌控的分界线。

三、静态批处理的陷阱与 continuous batching

账会算了,下一个问题是:怎么让一张 GPU 在显存够用的前提下,尽可能多地同时处理请求?直觉的做法叫静态批处理:攒够一批请求,拼成一个大矩阵,一起送进 GPU 算。但这个直觉做法有个很坑的陷阱:

静态批处理:把一批请求凑成一个矩阵一起算,必须 padding 到最长的那个

  请求 A: [我 想 知 道]                          4 个 token
  请求 B: [请 帮 我 把 这 篇 很 长 的 文 章 总 结]   13 个 token
  请求 C: [谢 谢]                                2 个 token

  凑成一个 batch,全部 padding 对齐到最长的 13:
  A: [我 想 知 道 _ _ _ _ _ _ _ _ _]   补了 9 个废 token
  C: [谢 谢 _ _ _ _ _ _ _ _ _ _ _]    补了 11 个废 token

  后果一:大量算力和显存花在了 padding 出来的废 token 上
  后果二:整批要等最慢的 B 生成完才能一起返回,A 和 C 早就算完了却干等

静态批处理的两个后果都很致命:废 token 浪费显存和算力,短请求被长请求拖着一起等。现代推理引擎(像 vLLM)用的是另一套办法——continuous batching(连续批处理):不再"攒一整批、一起进、一起出",而是把批次变成一个动态的、每一步都在变化的集合。它的核心是一个不停转的调度循环:

# continuous batching 的核心:一个不停转的调度循环
# 不再等"一整批"凑齐,而是每一步都动态地换出已完成的、换入新来的
class Scheduler:
    def __init__(self, gpu):
        self.running = []          # 当前正在 GPU 上生成的请求集合
        self.waiting = []          # 排队等待准入的请求
        self.gpu = gpu

    def step(self):
        # 1) 把已生成完(遇到结束符)的请求移出运行集,立刻释放它的 KV Cache
        finished = [r for r in self.running if r.is_done()]
        for r in finished:
            self.running.remove(r)
            self.gpu.free(r.kv_cache_bytes())
            r.respond()

        # 2) 在刚腾出的显存里,尽量多地把等待队列里的新请求换进来
        while self.waiting and self.gpu.can_fit(self.waiting[0]):
            r = self.waiting.pop(0)
            self.gpu.reserve(r.kv_cache_bytes())
            self.running.append(r)

        # 3) 让运行集里所有请求,各自向前生成一个 token
        self.gpu.forward_one_step(self.running)

看清这个循环,continuous batching 的好处就立住了:一个请求一生成完就立刻被换出、显存立刻被释放,不用等同批的其他请求;腾出来的显存立刻能接纳新请求,GPU 几乎一刻不闲。短请求不再被长请求拖累,显存利用率被榨到很高。这个调度循环每一步都在做准入决策,它的逻辑是这样的:

[mermaid]
flowchart TD
A[调度循环的每一步] --> B[移出已生成完的请求 释放 KV Cache]
B --> C{等待队列里还有请求吗}
C -->|有| D{当前空闲显存放得下队首请求吗}
C -->|没有| F[让运行集里的请求各生成一个 token]
D -->|放得下| E[换入该请求 占用其 KV Cache]
D -->|放不下| F
E --> C
F --> A

这里要建立的认知是:静态批处理到 continuous batching 的演进,表面是一个推理引擎的技术细节,内核却是一个能迁移到无数场景的调度思想——批的粒度,决定了资源的利用率。静态批处理的问题,根子在于它的"批"是一个粗粒度的、刚性的单位:一批请求被绑死在一起,同生同死,必须一起进、一起出。一旦你把若干个体强行捆成一个不可分割的刚性单位,这个单位就必然要迁就其中"最差"的那一个——最长的那个请求决定了 padding 的长度,最慢的那个请求决定了整批的返回时间,于是其他所有请求的资源和时间都被白白拖累。continuous batching 做的事,本质是把这个刚性的"批"打碎成了可以独立换入换出的细粒度单位:每个请求的生命周期重新变得独立,谁完成谁就走、谁能放下谁就进,资源在 token 这个最细的粒度上被重新分配。这个"把粗粒度的刚性批,拆成细粒度的动态调度"的思路,你会在很多地方反复见到它——它就是为什么操作系统用时间片轮转而不是让一个进程一口气跑完,为什么流式处理常常优于攒一大批的批处理。记住这条:每当你发现系统里有一个"必须一起进、一起出"的刚性批次,就该警觉——那里大概率藏着一块被最差成员拖累掉的、可以靠细粒度调度夺回来的资源。

四、显存预算与准入控制:在请求进来前就拦住它

continuous batching 的调度循环里,反复出现一个判断:gpu.can_fit(...)。这个"放不放得下"的判断,就是准入控制(admission control)的核心。要让它能判断,第一步是给 GPU 立一份明确的显存预算——服务启动时就把账算死:这张卡总共多少显存,模型权重吃掉多少,留多少安全余量,剩下的才是 KV Cache 能用的预算。

# 显存预算:推理服务启动时,先把这张 GPU 能装多少 KV Cache 算清楚
TOTAL_GPU_MEM = 24 * 1024 * 1024 * 1024        # 整张卡 24 GB 显存
MODEL_WEIGHTS = 14 * 1024 * 1024 * 1024        # 模型权重常驻,约 14 GB
RESERVED      = 2 * 1024 * 1024 * 1024         # 给激活值、碎片等留 2 GB 余量

# 真正能用来放 KV Cache 的,只剩下这部分
KV_BUDGET = TOTAL_GPU_MEM - MODEL_WEIGHTS - RESERVED   # 约 8 GB

class GpuBudget:
    def __init__(self, budget):
        self.budget = budget
        self.used = 0

    def can_fit(self, need_bytes):
        return self.used + need_bytes <= self.budget   # 装得下才放行

    def reserve(self, need_bytes):
        self.used += need_bytes                        # 准入:占用预算

    def free(self, bytes_):
        self.used -= bytes_                            # 完成:归还预算

有了预算,准入控制就是在请求进入运行集之前,先估算它要占多少 KV Cache,再问预算够不够。一个请求最终会增长到 prompt 长度 + max_tokens 这么长,按这个最长情况估算最保险:

# 准入控制:请求进来的第一件事,是估算它的显存需求并问预算够不够
def estimate_request_kv(prompt_tokens, max_tokens):
    # 一个请求最终会增长到 prompt + 最多生成 max_tokens 这么长
    final_len = prompt_tokens + max_tokens
    return kv_cache_bytes(final_len, 32, 32, 128)

@app.post("/generate")
def generate(req: GenRequest):
    need = estimate_request_kv(len(req.prompt_tokens), req.max_tokens)
    if not scheduler.gpu.can_fit(need):
        # 关键:装不下就当场排队或拒绝,绝不硬塞进去赌它不 OOM
        if scheduler.queue_full():
            raise HTTPException(503, "服务繁忙,请稍后重试")
        scheduler.enqueue(req, need)
    else:
        scheduler.admit(req, need)
    return scheduler.wait_result(req)

这一步的意义,要和第一版对照才看得清。第一版是把请求无脑塞进 GPU,塞爆了才发现——OOM 是个事后才知道的、且会炸掉整个进程的灾难。准入控制把这件事彻底反过来:在请求还没碰到 GPU、还没占用任何显存的时候,就先算一笔账,放不下就当场拦在门外——要么排队等,要么直接返回一个干净的 503。OOM 这个"进程级灾难",被降级成了"某个请求多等一会儿或被礼貌拒绝"这种"请求级的、可控的"结果。

这里要建立的认知是:准入控制背后,是一个所有高负载系统都该信奉的原则——一个系统对自己的容量,必须有"自知之明",并且要在入口处就守住这个边界。第一版最危险的地方,不在于它会崩,而在于它对"自己到底能扛多少"这件事完全没有概念:它从不评估一个请求要消耗多少资源,也从不知道自己还剩多少资源,它唯一的工作方式就是来者不拒、照单全收,然后在某个无法预测的时刻轰然倒下。这种系统的失败是"脆性"的——平时看着好好的,一旦越过那条它自己都不知道在哪的红线,就是整体性的、灾难性的崩溃,而且会把已经在处理的所有请求一起拖下水。准入控制做的,是给系统装上"自知之明":它清楚自己的总预算,清楚每个新请求的成本,于是它能在每个请求进门的那一刻做一个诚实的判断——接得住就接,接不住就明确地说"我现在不行"。一个会在入口处说"不"的系统,远比一个来者不拒、最后整体崩溃的系统健壮。这就是"优雅降级"的真正含义:负载超过容量时,系统的表现应该是平滑地变慢、或拒绝一部分请求,而绝不该是断崖式地全盘崩溃。要做到优雅降级,前提就是系统得先有自知之明、并且舍得在入口处就守住边界——宁可干脆利落地拒绝一个请求,也不要不负责任地接下所有请求最后谁都得不到服务。

五、长请求会拖垮所有人:隔离与抢占

到这里,OOM 治住了、利用率也上来了。但还剩第四个怪现象没解决:一个超长请求,会把整台 GPU 占满,卡死所有人。原因很直白:一个 32000 token 的长文档总结请求,它的 KV Cache 可能就吃掉了大半个显存预算,准入控制一看预算被它占走了,后面的短请求全都 can_fit 失败,只能在队列里干等这个巨无霸慢慢生成完。一个请求,劫持了整个服务。

解法是按请求的"体量"分流隔离:把长请求和短请求放进两个独立的队列,各自有独立的显存配额和并发上限,谁也吃不掉谁的那一份。

# 长请求隔离:按"请求要占多少显存"分流,别让一个巨无霸堵死所有人
LONG_REQUEST_THRESHOLD = 4000        # 上下文总长超过 4000 token 算"长请求"

def route_request(req):
    total_len = len(req.prompt_tokens) + req.max_tokens
    if total_len > LONG_REQUEST_THRESHOLD:
        # 长请求走专用队列,这个队列的并发上限很低(比如同时最多 1-2 个)
        long_queue.put(req)
    else:
        # 短请求走快速队列,可以保持高并发
        fast_queue.put(req)

# 两个队列各自持有一份独立的显存配额:
#   - 短请求配额:保证大量日常短请求始终有得跑、延迟稳定
#   - 长请求配额:长请求再大,也只能在自己那份配额里折腾,吃不到短请求的预算

隔离之外,还有一个更进阶的手段叫抢占(preemption):当显存吃紧、又有高优先级的短请求在等,调度器可以把某个长请求暂时换出——把它已经生成的 KV Cache 暂存(或干脆丢弃后续重算),先腾出显存让短请求跑完,过会儿再把长请求换回来接着生成。这正是 continuous batching 的调度循环能做到的事:既然请求的换入换出本来就是逐 token 进行的,那"换出一个还没跑完的请求"也就成了一个自然的操作。这里的关键是别把抢占做激进了——抢出去再换回来是有开销的,抢得太频繁,GPU 的时间全花在搬运 KV Cache 上了。

这里要建立的认知是:这一节真正要讲的,是一个在任何共享资源系统里都成立的道理——当一群任务共享一份有限的资源时,如果你对它们一视同仁,那么资源就会被"体量最大的那个任务"主导,而绝大多数"体量小的任务"会成为受害者。第一版对长请求和短请求完全一视同仁,把它们扔进同一个队列、抢同一份显存,结果就是:一个长请求的体量可能是普通短请求的几十倍,它一进来就像往独木桥上开了一辆卡车,后面成百上千辆小车全得停下等它。而真实的线上流量,几乎总是这种"绝大多数是小请求、偶尔夹杂一两个巨型请求"的混合分布。应对这种分布,正确的思路不是"让小请求和大请求公平竞争"——那样表面公平,实际上是用绝大多数用户的体验,去补贴极少数巨型请求。正确的思路是按体量分级、给不同等级的任务划定彼此隔离的资源配额:让海量的小请求在它们自己的、充裕的配额里跑得又快又稳,把少数巨型请求关进一个单独的、受限的配额里,让它的"重",只由它自己承担,而不外溢成所有人的卡顿。这个"按体量隔离资源、防止重任务拖垮轻任务"的模式,就是各种系统里"快慢分离队列""大小请求分池""为重型任务单独划资源"的共同内核。一个系统的服务质量是否稳定,常常不取决于它处理普通情况有多快,而取决于它有没有把那个最极端的情况隔离开。

六、工程里那些推理服务并发的坑

并发的主体逻辑对了,落地时还有几个工程坑反复咬人。第一个,准入估算要按最坏情况来,但别估得太满。估算一个请求的 KV Cache,要按 prompt + max_tokens 的最长情况算——因为你事先不知道它会在哪个 token 停下。但也别把整个预算用到 100%,要留安全垫,因为还有激活值、显存碎片等隐性开销。下面这份配置,就是一份"宁可保守、绝不冒险"的并发参数:

# 一份保守的推理服务并发配置(宁可拒绝,也绝不 OOM)
CONFIG = {
    "kv_budget_ratio": 0.85,        # 只用 85% 的可用显存,留 15% 安全垫
    "max_running_requests": 64,     # 运行集硬上限,防止调度本身的开销失控
    "max_waiting_queue": 256,       # 等待队列上限,超了就直接返回 503
    "long_request_threshold": 4000, # 上下文超过此长度,走长请求隔离队列
    "long_request_concurrency": 2,  # 长请求队列最多同时跑 2 个
    "request_timeout_s": 120,       # 单请求总超时,防止卡死的请求长期占着显存
}

第二个,请求超时一定要做,而且超时后要真正释放显存。一个卡住的请求,如果不被超时掐断,它占的那块 KV Cache 就永远不会归还,等于显存预算被慢慢"漏"光。第三个,显存碎片:KV Cache 不断地分配和释放,会让显存变得碎片化,可能出现"总量够、却找不到一块连续空间"的情况——这也是 vLLM 的 PagedAttention 用"分页"管理 KV Cache 的原因。第四个,别忘了排队延迟也是延迟:一个请求的总耗时 = 排队时间 + 生成时间,你只监控生成时间,会对用户的真实体验产生严重误判。第五个,单卡有上限,该上多卡和多副本时就别硬扛:准入控制能让单卡不崩,但扛不住的流量,终究要靠多个推理副本 + 负载均衡来分摊。把上面这些都接进监控,你才有数据去调参:

推理服务必须盯死的几个核心指标:

  gpu_kv_cache_usage    KV Cache 显存使用率,持续逼近预算上限就要扩容
  running_requests      当前在 GPU 上并发生成的请求数(动态变化)
  waiting_queue_len     等待队列长度,持续增长说明产能已经不够
  request_queue_time    请求在队列里排了多久 —— 这是常被忽略的那半截延迟
  time_to_first_token   首 token 延迟,长请求挤占会让它明显飙高
  rejected_503_count    被准入控制拒绝的请求数,高了说明该扩容了

这里要建立的认知是:把这一节的坑串起来看,会浮现一个关于"大模型推理服务"的总体判断——它本质上是一个"资源极其昂贵、且高度受限"的服务,这个属性,决定了你对待它的工程态度,必须和对待一个普通的、廉价的、可以随便加机器的无状态 Web 服务截然不同。一台 GPU 很贵,一张卡的显存就那么多,你没法像加 Web 实例那样轻飘飘地"再加几台"。这种"贵且有限"的属性,逼出了这一节所有的工程纪律:正因为显存有限且 OOM 代价惨重,你才要保守地留安全垫、按最坏情况估算;正因为每一块被占住的显存都珍贵,你才绝不能容忍一个卡死的请求把它白白漏掉,必须用超时去回收;正因为单卡的天花板触手可及,你才要老老实实地监控利用率、并在数据告诉你该扩容时就扩容,而不是指望靠调参榨出无限的产能。说到底,做大模型推理服务的并发,考验的不是你会不会写调度代码,而是你心里有没有一杆秤,时刻称量着"资源的稀缺程度"和"请求的消耗"——把一个昂贵稀缺的资源,用准入控制、用隔离、用监控,精打细算地分配出去,让它服务尽可能多的人,同时一刻都不让它崩。这种"把稀缺资源当稀缺资源来敬畏、来精算"的态度,才是这整篇文章真正想交给你的东西。

关键概念速查

概念 说明 关键点
KV Cache 缓存每个 token 在每层注意力的 Key Value 向量 大小随上下文长度线性增长 是显存占用主体
显存是硬墙 所有飞行请求的 KV Cache 总和超过显存就 OOM 不是变慢 是整个推理进程当场崩溃
按线程数限流的错 请求数和显存占用没有稳定换算关系 阈值低了浪费 高了 OOM 两头不讨好
显存预算 总显存减模型权重减安全余量后的可用量 启动时算死 是准入控制的判断基准
静态批处理的陷阱 整批 padding 对齐 一起进一起出 废 token 浪费算力 短请求被长请求拖累
continuous batching 逐 token 动态换入换出请求的调度循环 请求完成即释放显存 利用率被榨到很高
准入控制 请求进运行集前先估算显存够不够 把进程级 OOM 降级为请求级排队或 503
长请求隔离 长短请求分两个队列 各有独立显存配额 防一个巨型请求霸占显存卡死所有人
抢占 preemption 显存吃紧时把长请求暂时换出腾地方 有搬运开销 不能抢得太频繁
排队延迟 请求总耗时等于排队时间加生成时间 只监控生成时间会严重误判真实体验

避坑清单

  1. 不要用线程池/并发数来给推理服务限流。请求数和显存占用没有稳定关系,要拿显存当尺子。
  2. 服务启动时就算清显存预算:总显存减模型权重减安全余量,得到 KV Cache 的可用预算。
  3. 每个请求进运行集前必须做准入控制,按 prompt 加 max_tokens 估算 KV Cache,放不下就排队或返回 503。
  4. 用 continuous batching 替代静态批处理,逐 token 动态换入换出,别让短请求被长请求拖着等。
  5. 把长请求隔离到独立队列,给它单独的显存配额和很低的并发上限,别让它霸占整台 GPU。
  6. 显存预算留 10%-15% 安全垫,别用到 100%,激活值和显存碎片都要占地方。
  7. 给每个请求设总超时,超时后要真正释放它占的 KV Cache,否则显存会被慢慢漏光。
  8. 监控里必须有排队延迟,请求总耗时是排队加生成,只看生成时间会误判用户体验。
  9. 抢占不要做得太激进,换出换入 KV Cache 有开销,抢太频繁 GPU 时间全耗在搬运上。
  10. 单卡产能有天花板,准入控制只保证不崩,扛不住的流量要靠多副本加负载均衡分摊。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为推理服务的并发,跟普通 Web 服务一样,是个"能开多少线程"的问题。可一个大模型推理请求,占住的核心资源根本不是线程,而是一块大小随上下文长度动态变化的 GPU 显存——它的主体是 KV Cache。推理服务的并发上限,从来不是一个线程数,而是"所有飞行请求的 KV Cache 总和不能撑爆显存"这个不等式。我拿"请求数"这把和真实资源不挂钩的尺子去限流,自然要么 OOM、要么浪费,怎么调都对不准。

真正把推理服务的并发做扎实,工作量不在"写多复杂的调度算法",而在一次观念的转变:承认这是一个显存资源的调度问题,不是一个线程数的限流问题。一旦接受这一点,该做的事就都浮现出来了——把显存预算算清楚、在请求入口做准入控制、用 continuous batching 榨干利用率、把长请求隔离开。每一步都不复杂,难的是先承认:你的 GPU 不是一个能无限接活的工人,它是一块容量固定、且一旦装满就会塌的硬盘子。

我后来常拿一个停车场来想这件事。第一版的做法,等于这个停车场只在门口数"放进去几辆车":数到 50 就关闸。可车有大有小——50 辆小轿车停得下,50 辆大货车早就把场地撑爆了。停车场真正的容量,从来不是"车的数量",而是"场地的面积",而每辆车占的面积差别极大。正确的管理办法,是在入口处看一眼这辆车多大、再看场内还剩多少面积,够才放进来——这就是准入控制;是给大货车单独划一片区域、限定只能停一两辆,别让它们占满整个场地——这就是长请求隔离;是有车开走就立刻把那个车位标记为空、让等候的车补进来,而不是等一整批车都走光再放下一批——这就是 continuous batching。KV Cache 就是每辆车占的面积,显存预算就是停车场的总面积。账,从头到尾都是一笔可以算清的面积账。

这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你一个人、发着短 prompt 去测,KV Cache 小得可以忽略,显存大把富余,服务表现得稳如磐石,你根本意识不到那堵显存的墙就在不远处。它只在真正多人并发、且流量里混进了长上下文请求之后才集中爆发,而那时崩的不是一个请求,是整个推理进程,所有人一起掉线。所以别等线上 OOM 把服务打挂才想起显存这本账:部署推理服务的第一天,就该把"这张卡的显存预算是多少、每个请求要占多少、超了怎么办"当成和模型选型同等重要的事来设计——它不该是一个"以后压测出问题再补"的事项,而该是你写第一行推理服务代码时就立好的地基。把这笔显存账在一开始就算清楚,你才算真正跳出了那个几乎人人都会踩、却要等到线上并发上来才追悔莫及的"按线程限流"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

登录会话存储完全指南:从一次"用户老是莫名其妙就退出登录"看懂 Cookie 与 Session

2026-5-22 18:06:21

技术教程

HTTP/2 完全指南:从一次"网站首页加载慢得想关掉,优化了文件却还是慢"看懂多路复用与队头阻塞

2026-5-22 18:21:29

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索