2023 年我做一个大模型推理服务,把一个开源大模型部署在 GPU 上,包一个 HTTP 接口对外提供。第一版我做得很省事:来一个请求,就调一次 model.generate,推理完返回。本地一个人测了测——真不错:发一个请求,几秒就回来,响应挺快。我心里很踏实:"模型推理嘛,包成一个 HTTP 接口,来一个请求调一次 generate,不就行了。"可等这个服务真正上线、扛起多用户的并发请求,一串问题冒了出来。第一种最先把我打懵:并发一上来,响应时间暴涨——十几个请求同时进来,每一个都得排在前面所有请求后面干等,P99 延迟飙到了几十秒。第二种最反直觉:服务这么慢,我以为 GPU 一定被压满了,可一看监控,GPU 利用率低得可怜——它大部分时间都在空等,一次只算一个请求,显存和算力大片大片地闲着。第三种:偶尔有个请求要生成特别长的内容,这一个请求就把它后面排队的所有请求,全堵死了。第四种最致命:某次流量一冲高,服务直接 OOM 崩溃——所有请求一拥而入,没有任何排队和上限,显存瞬间被撑爆。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"把模型推理包成 HTTP 接口,来一个请求调一次 generate,就和写一个普通 Web 接口一样"。这句话把"GPU 推理"和"普通 Web 请求处理"当成了一回事。可它不是。普通 Web 请求,CPU 有很多核,来一个请求开一个线程,大家真正在并行地跑。可 GPU 推理完全不同:一次 model.generate 会几乎独占整块 GPU,请求之间是串行排队的;而更关键的是——GPU 算 1 个请求,和算 16 个请求,耗时其实差不太多。这意味着"一次只算一个"不是稳妥,而是把昂贵的算力大把大把地浪费掉。真正做好 LLM 推理服务,核心不是"来一个算一个",而是理解 GPU 擅长批量、把并发请求攒成一批一起算、用请求队列削峰、用并发上限做背压。这篇文章就把 LLM 推理服务梳理一遍:为什么"来一个算一个"是错的、批处理为什么能数倍提升吞吐、请求队列怎么搭、动态批处理与超时怎么权衡、背压与并发上限怎么做,以及流式输出下的批处理、长度分桶、连续批处理这些把推理服务真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把模型推理包成"来一个请求调一次 generate"的接口之后,上线冒出一串问题:并发一高,请求排长队、P99 延迟飙到几十秒;服务很慢,GPU 利用率却很低,算力大片闲置;偶尔一个超长生成的请求,把后面所有请求堵死;流量一冲高,服务直接 OOM 崩溃。
我当时的错误认知:"把模型推理包成 HTTP 接口,来一个请求调一次 generate,就和写普通 Web 接口一样。"
真相:LLM 推理服务,和普通的 Web 服务,有一个根本的不同。普通 Web 服务的瓶颈在 CPU 和 IO,而 CPU 有很多核——来 100 个请求,开 100 个线程/协程,它们能真正地并行处理。可 LLM 推理的瓶颈在 GPU:一次 model.generate 调用,会几乎独占整块 GPU,所以多个请求"同时"调 generate,在 GPU 这一层其实是串行排队的——这就是并发一高就排长队的原因。而更关键、更反直觉的一点是:GPU 是为大规模并行计算而生的,它算 1 个请求和算 16 个请求,耗时差别很小。也就是说,你逐个请求去算,GPU 的绝大部分并行能力都在空转——这就是又慢、利用率又低的原因。所以 LLM 推理服务的正确姿势,不是"来一个算一个",而是"把同时到达的多个请求,攒成一批(batch),一次性喂给 GPU"。批处理,是 LLM 推理服务一切性能优化的起点。
要把 LLM 推理服务做对,需要几块认知:
- 为什么"来一个算一个"是错的——GPU 串行排队,且批量几乎免费;
- 批处理——把多个请求攒成一批,吞吐能涨十倍;
- 请求队列——用一个队列收拢并发、攒批、削峰;
- 动态批处理与超时——攒批不能死等,要在吞吐和延迟间权衡;
- 背压、长度分桶、连续批处理这些工程坑怎么处理。
一、为什么"来一个请求算一个"是错的
先把这件最根本的事钉死:写普通 Web 接口的那套直觉,在 GPU 推理这里会彻底失灵。普通接口,你来一个请求开一个协程,它们各跑各的,互不相干,因为 CPU 多核,真有那么多"车道"。但 GPU 不是这样——它更像一个虽然吞吐极大、却只有一条入口的超级流水线。你让请求一个一个排队进去,这条流水线每次只装一件货,它那惊人的并行产能,绝大部分都在空转。LLM 推理服务慢,往往不是 GPU 不够强,而是你喂给它的方式,根本没让它发挥出来。
下面这段代码,就是我那个"上线就排长队、还 OOM"的第一版:
# 反面教材:把模型推理包成 HTTP 接口,来一个请求就独占 GPU 算一次
from fastapi import FastAPI
app = FastAPI()
model = load_model() # 一个开源大模型,跑在 GPU 上
@app.post("/generate")
async def generate(prompt: str):
# 直接调 generate:这一次调用会几乎独占整块 GPU
output = model.generate(prompt, max_tokens=512)
return {"text": output}
# 破绽一:10 个并发请求,在 GPU 上只能一个接一个排队,后面干等。
# 破绽二:GPU 算 1 个请求和算 16 个请求耗时接近,逐个算是巨大浪费。
# 破绽三:没有任何排队上限,请求一拥而入,显存瞬间被撑爆 OOM。
这段代码在本地一个人测试时表现不错,因为一个人发请求,本来就没有并发,GPU 一次只算一个,恰好就是它能做到的最好情况。它的问题不在代码本身,而在一个被忽略的前提:它默认"多个并发请求,会像普通 Web 接口那样并行处理"。可在 GPU 这一层,它们只能串行排队。于是那串问题就有了解释:并发排长队,是因为GPU 一次只吞一个请求;GPU 利用率低,是因为它的并行能力被"一次一个"白白浪费了;一个长请求堵死全部,是因为串行队列里,前面那个不算完,后面谁也别想动;OOM,是因为没有任何东西拦住请求一拥而入。问题的根子清楚了:做好推理服务的工程量,全在"承认 GPU 要的是批量、不是逐个"之后——你不把请求攒成批,就只能眼看着昂贵的算力空转。先从批处理这件最核心的事说起。
二、批处理:为什么"攒一批一起算"能涨十倍吞吐
批处理(batching)的思路朴素到极点:不要拿到一个请求就立刻算,而是稍微等一等,把这一小段时间内到达的多个请求,凑成一批(batch),一次性喂给 GPU。它之所以有效,是因为前面反复强调的那个 GPU 特性——算一批和算一个,耗时差别很小。模型的接口,本身就支持一次传入一批 prompt:
# 批处理:把多个 prompt 攒成一个 batch,一次性喂给 GPU
def generate_batch(prompts):
"""GPU 天生擅长并行 —— 一批一起算,几乎和算一个一样快。"""
# model 接受一批 prompt,内部并行计算,返回一批结果
outputs = model.generate(prompts, max_tokens=512)
return outputs
# 实测对比(同一张 GPU、同样 16 个请求):
# 逐个算 16 个:16 次调用,耗时约等于 16 个单位时间
# 攒成 1 批算:1 次调用,耗时约 1.3 个单位时间
# —— 吞吐量差出十倍以上,这就是"来一个算一个"的真实代价
这个对比里藏着批处理的全部价值:逐个算 16 个请求,要花约 16 份时间;攒成一批算,只花约 1.3 份。同一张 GPU、同样的请求量,吞吐量差出整整十倍。这里的认知要点是:批处理不是一个"锦上添花"的优化项,它是 LLM 推理服务的地基。GPU 的算力是按"整块"卖给你的,你不把它喂饱,闲置的部分并不会退钱。批处理做的事,就是把这块昂贵算力的利用率,从"逐个算"的百分之几,拉到"批量算"的百分之七八十——同样的硬件,服务能力翻好几倍。道理清楚了,但真正的难题是:线上的请求是一个一个、随机到达的,你怎么把它们"攒"起来?
三、请求队列:把零散的并发请求收拢成批
请求是零散、随机到达的,而 GPU 要的是成批的输入——中间需要一个缓冲带,这就是请求队列。整个架构变成这样:HTTP 接口收到请求后,不直接碰 GPU,而是把请求丢进一个内存队列,然后挂起等待;另有一个常驻的后台 worker,不断从队列里捞请求、攒成批、喂给 GPU,算完再把结果分发回每一个等待的请求。第一步,是让每个请求带上一个用于回填结果的 Future,然后入队:
import asyncio
# 一个进程内的请求队列:所有并发请求先在这里排队
request_queue: asyncio.Queue = asyncio.Queue()
class InferenceRequest:
"""一个待推理的请求:带着 prompt,和一个用于回填结果的 Future。"""
def __init__(self, prompt):
self.prompt = prompt
self.future = asyncio.get_event_loop().create_future()
@app.post("/generate")
async def generate(prompt: str):
req = InferenceRequest(prompt)
await request_queue.put(req) # 请求只管入队,不直接碰 GPU
result = await req.future # 挂起,等后台 worker 回填结果
return {"text": result}
下面这张图,把一次请求从入队到拿到结果的完整流程串起来:
这里的认知要点是:请求队列是"零散的到达"和"成批的处理"之间的变速箱。它把推理服务拆成了两个彻底解耦的角色:对外的 HTTP 接口只负责"收下请求、入队、挂起、等结果",它不知道也不关心 GPU 怎么算;后台 worker 只负责"攒批、推理、分发",它不关心请求从哪来。这个解耦,是后面所有优化——攒批策略、背压、超时——能够干净落地的前提。队列搭好了,但那个后台 worker 到底该怎么"攒"批,才不会顾此失彼?
四、动态批处理:在吞吐和延迟之间找平衡
后台 worker 攒批,会立刻撞上一个两难:攒得越多,一批的吞吐越高;可攒得越多,就越要等——第一个进队列的请求,得干等到这一批攒满才能发车,它的延迟就被拖长了。如果死等"攒满一批",那么半夜只有一个请求时,它会永远等不到第二个、被无限期挂起。所以正确的攒批策略是动态批处理:设两个上限——"批量上限"和"等待时间上限",哪个先到,就立刻发车:
import time
MAX_BATCH = 16 # 一批最多攒 16 个请求
MAX_WAIT = 0.05 # 最多等 50 毫秒,到点就发车,绝不死等
async def batch_worker():
"""后台常驻:不断从队列攒一批请求,一起推理,再把结果分发回去。"""
while True:
# 至少先拿一个(队列空就在这里挂起,不空转)
batch = [await request_queue.get()]
start = time.time()
# 在"攒够一批"和"等太久了"之间,谁先到就听谁的
while len(batch) < MAX_BATCH:
remaining = MAX_WAIT - (time.time() - start)
if remaining <= 0:
break # 等够 50ms,立刻发车
try:
req = await asyncio.wait_for(request_queue.get(), remaining)
batch.append(req)
except asyncio.TimeoutError:
break # 这段时间没新请求,发车
_run_batch(batch)
批攒好了,推理完,还要把一批结果,精确地分发回各自的请求——靠的就是入队时带上的那个 Future:
def _run_batch(batch):
"""一批请求一起推理,再把每个结果精确回填到对应请求的 Future。"""
prompts = [req.prompt for req in batch]
outputs = model.generate(prompts, max_tokens=512) # 一次推理整批
for req, out in zip(batch, outputs):
if not req.future.done():
req.future.set_result(out) # 回填结果,唤醒挂起的请求
# 顺序对齐是命门:outputs[i] 必须正好是 batch[i] 的结果,不能错位
这里的认知要点是:动态批处理的精髓,是承认"吞吐"和"延迟"是一对必须妥协的矛盾,然后用两个上限把这个妥协量化下来。MAX_BATCH 是你给吞吐设的目标,MAX_WAIT 是你给延迟设的底线——高峰期请求密集,批很快攒满,走的是吞吐;低谷期请求稀疏,等到点就发车,守的是延迟。一套参数,自动适配两种负载。攒批和分发都通了,但还有一个开头最致命的问题没解决:请求一拥而入,把显存压垮怎么办?
五、背压与超时:别让请求把服务自己压垮
开头第四个问题——"流量一冲高就 OOM"——根子在于:队列是没有上限的,请求来多少就积压多少,而每个积压的请求都占着显存,迟早撑爆。解法是背压(backpressure):给队列设一个容量上限,满了就直接拒绝新请求,返回一个明确的"服务繁忙"。快速拒绝一部分,好过拖垮全部:
from fastapi import HTTPException
MAX_QUEUE = 200 # 队列最多积压 200 个请求
@app.post("/generate")
async def generate(prompt: str):
# 背压:队列满了就直接拒绝,而不是让请求无限堆积、最终拖垮显存
if request_queue.qsize() >= MAX_QUEUE:
raise HTTPException(status_code=503, detail="服务繁忙,请稍后重试")
req = InferenceRequest(prompt)
await request_queue.put(req)
result = await req.future
return {"text": result}
第二件事:给每个请求的等待设一个超时上限。一个请求,可能排队排太久,也可能自己生成内容太长、推理太慢。无论哪种,都不能让它无限期地挂着——既占资源,客户端那头也早不耐烦了。到点就如实返回超时:
REQUEST_TIMEOUT = 30.0 # 单个请求从入队到拿到结果,最多 30 秒
@app.post("/generate")
async def generate(prompt: str):
if request_queue.qsize() >= MAX_QUEUE:
raise HTTPException(status_code=503, detail="服务繁忙,请稍后重试")
req = InferenceRequest(prompt)
await request_queue.put(req)
try:
# 给等待结果设上限:排太久或推理太慢,如实返回超时
result = await asyncio.wait_for(req.future, REQUEST_TIMEOUT)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="推理超时,请重试")
return {"text": result}
这里的认知要点是:背压和超时,是推理服务的两道"安全阀"。它们的共同哲学是——一个服务必须诚实地知道自己的能力边界,并在请求量越过边界时,主动、明确地说"不",而不是默默地全盘接下、然后一起崩溃。被快速拒绝的请求,客户端还能重试或降级;被拖进一场雪崩的请求,则什么都救不回来。能保住的服务,从不假装自己能扛下一切。主链路的设计完整了,最后是几个真正上规模后才会撞见的工程坑。
六、工程坑:流式输出、长度分桶与连续批处理
五块设计之外,还有几个工程坑,不处理就会让推理服务要么不好用、要么不够快、要么你不知道它快不快。坑 1:长度悬殊的请求别硬凑一批。一批请求是一起算完才一起返回的,如果一批里混进了一个要生成几千 token 的超长请求,那么这一整批,都得陪它算到最后——批里那些本该很快返回的短请求,全被拖慢了。对策是长度分桶:把长度相近的请求放进同一批:
def split_by_length(batch):
"""把一批请求按 prompt 长度分桶 —— 长短混在一批会拖慢整批。"""
short, long = [], []
for req in batch:
if len(req.prompt) <= 256:
short.append(req)
else:
long.append(req)
# 短请求自成一批,能很快算完、很快返回,不必陪长请求干耗
return [b for b in (short, long) if b]
坑 2:推理服务一定要做指标监控。"我觉得 batching 生效了"是没有意义的。要盯住几个关键指标:平均批大小(若长期接近 1,说明攒批根本没生效)、队列深度(持续很深说明产能不够)、吞吐 QPS:
_metrics = {"total": 0, "batches": 0, "batch_sizes": []}
def record_batch(batch_size):
"""记录每一批的关键指标,用于判断 batching 到底有没有生效。"""
_metrics["total"] += batch_size
_metrics["batches"] += 1
_metrics["batch_sizes"].append(batch_size)
def get_stats():
sizes = _metrics["batch_sizes"]
avg_batch = sum(sizes) / len(sizes) if sizes else 0
# 平均批大小长期接近 1 —— batching 形同虚设,要回头查攒批逻辑
return {
"avg_batch_size": round(avg_batch, 2),
"queue_depth": request_queue.qsize(),
"total_requests": _metrics["total"],
}
坑 3:流式输出(SSE)下的批处理更复杂。前面的批处理,是一批一起算完、一起返回。但很多 LLM 应用要流式输出(一个字一个字往外蹦)。流式场景下,一批请求生成进度各不相同,不能等整批算完——这正是连续批处理(continuous batching)要解决的:它不以"一整批"为调度单位,而是以"一步生成"为单位,某个请求生成完了,立刻让队列里的新请求补进它的位置,GPU 一刻不空。这套机制自己实现非常复杂,坑 4:不要自己造推理引擎的轮子。vLLM、TGI 这类成熟的推理引擎,已经把连续批处理、KV Cache 管理、显存调度都做好了,直接用它:
# 进阶:用 vLLM 这类推理引擎,内置了更强的"连续批处理"
# 它不必等一整批同时算完,某个请求一结束就立刻让新请求补位
from openai import OpenAI
# vLLM 启动后,提供一个 OpenAI 兼容的接口
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="my-llm",
messages=[{"role": "user", "content": "你好"}],
max_tokens=512,
)
# 动态批处理、KV Cache、显存调度都由引擎在内部完成,
# 你只需把它当成一个高吞吐的 OpenAI 兼容接口来调用
print(resp.choices[0].message.content)
这里的认知要点是:本文从零搭一遍队列与批处理,是为了让你彻底看懂"批处理为什么是地基"这件事。但真到生产环境,连续批处理、KV Cache 这些深水区,该交给 vLLM、TGI 这样千锤百炼的引擎。懂原理,是为了用对、调对、出问题时能定位——而不是为了自己重造一个更差的轮子。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 批处理 batching | 多个请求攒成一批一起喂 GPU,吞吐可涨十倍 |
| GPU 串行性 | 一次 generate 几乎独占 GPU,并发请求实为排队 |
| 请求队列 | 零散到达与成批处理之间的缓冲带 |
| Future 回填 | 请求挂起等待,worker 算完按序回填结果唤醒它 |
| 动态批处理 | 批量上限与等待上限,谁先到谁发车 |
| 背压 | 队列满即拒绝新请求,避免显存被压垮 |
| 请求超时 | 排队或推理过久即返回超时,不无限挂起 |
| 长度分桶 | 长短请求分批,避免短请求陪长请求干耗 |
| 连续批处理 | 以一步生成为调度单位,请求完成即补位 |
| vLLM / TGI | 成熟推理引擎,内置连续批处理与显存调度 |
避坑清单
- GPU 推理是串行的,来一个算一个会让并发请求排长队。
- GPU 算一批和算一个耗时接近,逐个算是巨大的算力浪费。
- 核心优化是批处理:把并发请求攒成一批一次性喂给 GPU。
- 用请求队列解耦 HTTP 接口与 GPU 推理,接口只管入队等结果。
- 攒批用动态批处理,设批量上限和等待上限,谁先到谁发车。
- 绝不死等攒满一批,否则低峰期单个请求会被无限期挂起。
- 队列要设容量上限做背压,满了快速返回 503 而非硬扛。
- 每个请求设等待超时,排队或推理过久就如实返回 504。
- 长短请求分桶,别让一个超长请求拖慢整批的短请求。
- 连续批处理和 KV Cache 交给 vLLM、TGI,别自己造轮子。
总结
回头看那串"并发排长队、GPU 利用率低、长请求堵死全部、流量一冲就 OOM"的问题,以及我后来在推理服务上接连踩的坑,最该记住的不是某一个参数,而是我动手前那个想当然的判断——"把模型推理包成 HTTP 接口,来一个请求调一次 generate,就和写普通 Web 接口一样"。这句话错在它把"GPU 推理"套进了"CPU 多核并行"的旧直觉里。我以为来十个请求,就有十条车道同时跑。可我忽略了一件事:GPU 不是十条窄车道,它是一条吞吐惊人、却只有一个入口的超级流水线。你把请求一个一个塞进去,它每次只装一件货,那惊人的并行产能,绝大部分都在空转——你买的是一整块算力,用出来的却只有零头。
所以做好 LLM 推理服务,真正的工程量不在"把 generate 包进一个接口"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"GPU 要的是批量,不是逐个",并据此把整个服务重新组织一遍:请求是零散到达的,你就用一个队列把它们收拢起来;GPU 要成批的输入,你就让后台 worker 把队列里的请求攒成批;攒批不能死等,你就用批量上限和等待上限做动态批处理;请求会一拥而入,你就用背压在队列满时果断拒绝;请求会等太久,你就给它设一个超时;长短请求混批会互相拖累,你就按长度分桶;连续批处理太难,你就把它交给 vLLM。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"来一个算一个"为什么错,再讲批处理为什么是地基、请求队列怎么搭、动态批处理怎么权衡、背压和超时怎么做,最后是长度分桶、连续批处理这几个把推理服务做扎实的工程细节。
你会发现,LLM 推理服务的批处理,和现实里"一辆班车怎么载客"完全相通。从城东到城西,有一辆大巴。一个不会调度的司机会怎么做?来一个乘客,他就发一趟车,载着这一个人空荡荡地开过去(这就是来一个请求算一个)。结果呢?车上几十个空座全程空着(这就是 GPU 利用率极低),后面排队的乘客得眼睁睁等大巴跑一个来回才轮到自己(这就是并发排长队),站台上人越积越多,最后挤到栏杆都被压垮(这就是 OOM)。而一个会调度的司机怎么做?他在站台稍微等一等,等车快坐满、或者等够了发车时刻,就开一趟(这就是动态批处理:批量上限或等待上限,谁先到谁发车);站台人实在太多了,他会先拉上栏杆,让后来的人去坐下一班,而不是把车挤到爆(这就是背压)。同样一辆大巴、同样的油钱,可前者一整天运不了几个人,后者却把一城的客流都从容地送了过去——差别不在车,只在那一套"等一等、凑一车、再发车"的调度章法。
最后想说,LLM 推理服务做没做对,差距永远不会在"本地一个人发请求、几秒就回来"时暴露——本地你就一个人,本来就没有并发,GPU 一次算一个恰好是它在那个场景下能做到的最好,你会觉得"包个接口调一次 generate"已经是全部。它只在真实的、多用户并发、流量有高峰有低谷、请求长短不一的线上环境里才显形。那时候它会用最伤体验、也最伤钱包的方式给你结账:做不好,你的用户会卡在几十秒的长队里,你昂贵的 GPU 却大半时间在空转,流量一高服务直接崩给你看;而做对了,你的推理服务会稳稳地把并发请求攒成批喂给 GPU:同一张卡的吞吐翻上好几倍,延迟在高峰期也守得住,流量再猛也只是优雅地拒绝一部分、而不是全盘崩溃。所以别等"用户抱怨慢、老板追问 GPU 账单"找上门,在你写下那行 model.generate 的时候就该想清楚:我面对的不是一个个会并行处理的普通 Web 请求,而是一群必须排队、且攒成批才划算的 GPU 任务——它们入队了吗、攒批了吗、背压和超时兜住了吗,这一道道工序,我是不是都替它们安排好了?这些问题有了答案,你交付的才不只是一个"本地能跑"的推理接口,而是一套真正吃满算力、扛得住并发、经得起流量洪峰的可靠推理服务。
—— 别看了 · 2026