GPU 永远不够用,这大概是每个做推理服务的人都有的共识。相比无脑加卡,更实际的办法是把现有资源榨干。下面这些是我在实际项目里反复用到的几个调优手段,有代码、有数据、也有一些踩坑经验。
1、连续批处理的请求塑形
vLLM 的核心优势在于 continuous batching,把多个请求的 token 打包进同一个计算步骤。想要发挥这个特性,需要在请求端做些配合。
max_new_tokens
必须要控制,对于聊天场景 256 到 512 基本够用。那种动不动要生成几千 token 的请求会拖垮整个 batch 的效率。流式返回可以降低用户感知到的延迟,同时让 batch 持续运转。另外 prompt 长度如果能相对统一会更好,长短差异太大会影响 packing 效率。
客户端可以这样写,异步流式加并发控制:
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="NOT_NEEDED")
SEM = asyncio.Semaphore(128) # tune: concurrent in-flight requests
async def ask(msg: str):
async with SEM:
stream = await client.chat.completions.create(
model="your-vllm-model",
messages=[{"role":"system","content":"You are concise."},
{"role":"user","content":msg}],
temperature=0.7,
max_tokens=256, # keep it bounded
stream=True
)
out = []
async for chunk in stream:
token = chunk.choices[0].delta.content or ""
out.append(token)
return "".join(out)
async def main():
qs = [f"Question {i}" for i in range(500)]
answers = await asyncio.gather(*[ask(q) for q in qs])
print(answers[0])
asyncio.run(main())
这种模式能让服务端持续收到短小可控的生成请求,continuous batching 的效果就出来了。
2、KV cache 复用的前缀设计
vLLM 的 paged attention 和 KV cache reuse 机制要求共享前缀在字节级别完全一致。所以system prompt一定要固定下来,连标点和空格都别改。动态内容往后放,用户数据、工具输出这些全部挪到消息末尾。
template 里千万别在用户问题前面插时间戳或者随机 ID,cache miss 率会飙升。
SYSTEM = (
"You are a helpful assistant. Use bullet points. "
"Cite numbers when you can.\n" # stable, byte-for-byte
)
def build_messages(user_msg: str, hints: list[str]):
# Put variable hints AFTER the user message to maximize shared prefix.
return [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_msg + "\n\n" + "\n".join(hints)},
]
FAQ 类应用或者 RAG 场景下,这招的收益非常明显,QPS 能有肉眼可见的提升。
3、推测解码配小模型
GPU 预算紧张的时候,speculative decoding 值得一试。小模型先提议 token,大模型负责验证,整体步数能减少不少。
拿个 1B 到 8B 的小模型做 draft,配合主模型使用。在 temperature 0.3 到 0.9 这个区间、中等长度输出的场景效果最好。acceptance rate 健康的话,tokens/sec 会有实打实的增长。
# e.g., vLLM server args
--model main-model
--speculative-draft-model tiny-draft-model
需要盯着 acceptance rate、draft 模型的显存占用,以及那些特别有创意的生成场景(draft 在这种情况下帮助有限)。
4、量化来换 batch size
权重量化能在同样的显卡上跑更大的 batch。AWQ 和 GPTQ 这类方法在聊天质量上的损失相对可控,比简单粗暴地全面 4-bit 要好。但不是所有模型都适合激进量化,有些架构压过头会丢失语言风格或者逻辑连贯性。
量化完重新调 batch 上限,通常能塞进去更多并发序列,QPS 自然就上来了。经验上讲,显存卡住计算之前成为瓶颈的话,量化是最该是最先考虑的。
5、拓扑匹配的并行策略
多卡机器上,tensor parallel size 要和模型用的 GPU 数量对齐,进程要 pin 住。
单模型多卡就用 tensor parallelism,让每张卡分摊模型的一部分并且每步都参与计算。
一台机器跑多个模型的话,进程隔离更合适,内存带宽和上下文切换会严重拖累 QPS。CPU pinning 和 GPU affinity 别偷懒,让数据加载和网络线程尽量靠近设备。
运维上还有几个点要注意:数据中心的 GPU 关掉节能模式;显存利用率控制在 90% 到 95%,给突发流量留点余量;多个 vLLM 实例记得分配独立的端口和 GPU(比如 CUDA_VISIBLE_DEVICES=0,1
和 2,3
分开)。
6、准入控制保护批处理引擎
高 QPS 不光是吞吐,还要在高负载下保持稳定。在 vLLM 前面加一层简单的门控来过滤请求。
超过 max tokens 或者 timeout 预算的请求直接拒掉或者降级处理。每个 API key 用 leaky bucket 或者 token bucket 限流。队列最好带 backpressure,避免超时堆积导致雪崩。
from fastapi import FastAPI, HTTPException, Request
import asyncio, time
app = FastAPI()
SEM = asyncio.Semaphore(256) # max in-flight
MAX_NEW_TOKENS = 384
REQ_TIMEOUT_S = 20
@app.middleware("http")
async def guard(request: Request, call_next):
start = time.time()
params = await request.json() if request.method == "POST" else {}
if params.get("max_tokens", MAX_NEW_TOKENS) > MAX_NEW_TOKENS:
raise HTTPException(400, "max_tokens too large")
try:
async with asyncio.timeout(REQ_TIMEOUT_S):
async with SEM:
response = await call_next(request)
return response
except TimeoutError:
raise HTTPException(503, "Busy, try lower max_tokens")
finally:
# you can log (queue_depth, wait_ms, in_flight) here
pass
这个代码看着很简单但是用起来的话是真管用,可以防止少数贪婪请求把所有人的延迟都拉垮。
7、热路径预热和指标监控
还有两个看起来无聊但实际很关键的点。
第一是预热。部署的时候先用合成请求把常用的 system prompt 和 template 跑几遍,让 KV cache pages 提前填充好。5 到 10 个请求就够,能让第一分钟的性能稳下来。
import asyncio
HOT_PROMPTS = [
"Summarize this email in 3 bullets:",
"Draft a polite reply:",
"Explain this code block step-by-step:"
]
async def warm():
await asyncio.gather(*[ask(p) for p in HOT_PROMPTS for _ in range(3)])
# call warm() right after deploy; ignore outputs
第二是监控指标别弄错了,generated tokens/sec 是 QPS 的核心指标,scheduler queue length 告诉你什么时候该扩容或者甩负载。平均值有时候没什么太大的用处,所以p50/p95/p99 的 time-to-first-token 和 time-to-last-token 才反映真实体验,产品质量活在 p95。
整体流程
最后整理一个完整的流程:
请求先到 FastAPI 的门控层,限制 max_tokens 和并发数,短暂排队后批量打到 vLLM 进程(绑定了特定 GPU)。vLLM 用 paged attention 管理多个序列,持续批处理新 token。稳定前缀命中 KV cache,speculative decoding 减少步骤,量化权重控制显存。token 流式返回,队列深度超阈值就开始丢弃低优先级流量。
总结
在最后总结之前先给一个实测的数据
单张 80GB 的 GPU 跑 7B 到 8B 的聊天模型,从无限制无流式改成流式加 256 token 上限,用户感知响应速度能翻倍,可持续 QPS 提升 30% 到 60%,能提高这么多的主要原因就是因为 batch 健康了。
而且配置合理的 speculative setup 能再加 15% 到 35% 的 tokens/sec,但是这个具体要看模型。上面说的前缀复用如果做得好,FAQ 场景基本就是开挂,基本能提高1倍多。
vLLM 上跑高 QPS 不能从单点突破,而是需要多个优化叠加才能产生好的结果。从请求塑形开始,把能复用的缓存用上,再叠加 speculative decoding 和量化。后面用并行和准入控制保证规模化的稳定性,热路径预热,盯住真正能反映问题的指标。
https://avoid.overfit.cn/post/fe3bc408622e424695dbcc27f0b7f14f
作者:Syntal