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 | 显存吃紧时把长请求暂时换出腾地方 | 有搬运开销 不能抢得太频繁 |
| 排队延迟 | 请求总耗时等于排队时间加生成时间 | 只监控生成时间会严重误判真实体验 |
避坑清单
- 不要用线程池/并发数来给推理服务限流。请求数和显存占用没有稳定关系,要拿显存当尺子。
- 服务启动时就算清显存预算:总显存减模型权重减安全余量,得到 KV Cache 的可用预算。
- 每个请求进运行集前必须做准入控制,按 prompt 加 max_tokens 估算 KV Cache,放不下就排队或返回 503。
- 用 continuous batching 替代静态批处理,逐 token 动态换入换出,别让短请求被长请求拖着等。
- 把长请求隔离到独立队列,给它单独的显存配额和很低的并发上限,别让它霸占整台 GPU。
- 显存预算留 10%-15% 安全垫,别用到 100%,激活值和显存碎片都要占地方。
- 给每个请求设总超时,超时后要真正释放它占的 KV Cache,否则显存会被慢慢漏光。
- 监控里必须有排队延迟,请求总耗时是排队加生成,只看生成时间会误判用户体验。
- 抢占不要做得太激进,换出换入 KV Cache 有开销,抢太频繁 GPU 时间全耗在搬运上。
- 单卡产能有天花板,准入控制只保证不崩,扛不住的流量要靠多副本加负载均衡分摊。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为推理服务的并发,跟普通 Web 服务一样,是个"能开多少线程"的问题。可一个大模型推理请求,占住的核心资源根本不是线程,而是一块大小随上下文长度动态变化的 GPU 显存——它的主体是 KV Cache。推理服务的并发上限,从来不是一个线程数,而是"所有飞行请求的 KV Cache 总和不能撑爆显存"这个不等式。我拿"请求数"这把和真实资源不挂钩的尺子去限流,自然要么 OOM、要么浪费,怎么调都对不准。
真正把推理服务的并发做扎实,工作量不在"写多复杂的调度算法",而在一次观念的转变:承认这是一个显存资源的调度问题,不是一个线程数的限流问题。一旦接受这一点,该做的事就都浮现出来了——把显存预算算清楚、在请求入口做准入控制、用 continuous batching 榨干利用率、把长请求隔离开。每一步都不复杂,难的是先承认:你的 GPU 不是一个能无限接活的工人,它是一块容量固定、且一旦装满就会塌的硬盘子。
我后来常拿一个停车场来想这件事。第一版的做法,等于这个停车场只在门口数"放进去几辆车":数到 50 就关闸。可车有大有小——50 辆小轿车停得下,50 辆大货车早就把场地撑爆了。停车场真正的容量,从来不是"车的数量",而是"场地的面积",而每辆车占的面积差别极大。正确的管理办法,是在入口处看一眼这辆车多大、再看场内还剩多少面积,够才放进来——这就是准入控制;是给大货车单独划一片区域、限定只能停一两辆,别让它们占满整个场地——这就是长请求隔离;是有车开走就立刻把那个车位标记为空、让等候的车补进来,而不是等一整批车都走光再放下一批——这就是 continuous batching。KV Cache 就是每辆车占的面积,显存预算就是停车场的总面积。账,从头到尾都是一笔可以算清的面积账。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你一个人、发着短 prompt 去测,KV Cache 小得可以忽略,显存大把富余,服务表现得稳如磐石,你根本意识不到那堵显存的墙就在不远处。它只在真正多人并发、且流量里混进了长上下文请求之后才集中爆发,而那时崩的不是一个请求,是整个推理进程,所有人一起掉线。所以别等线上 OOM 把服务打挂才想起显存这本账:部署推理服务的第一天,就该把"这张卡的显存预算是多少、每个请求要占多少、超了怎么办"当成和模型选型同等重要的事来设计——它不该是一个"以后压测出问题再补"的事项,而该是你写第一行推理服务代码时就立好的地基。把这笔显存账在一开始就算清楚,你才算真正跳出了那个几乎人人都会踩、却要等到线上并发上来才追悔莫及的"按线程限流"。
—— 别看了 · 2026