2024 年我所在的团队接到一个任务把一个 7B 的开源大模型私有化部署起来给公司内部团队做代码助手 SQL 翻译 文档问答 老板说一开始预计 100 个内部用户就够了 估个两台 4090 的机器跑跑。我先用最熟悉的 HuggingFace transformers 写了一个 FastAPI 服务在单卡上把模型跑起来 测了一下单条请求 200 毫秒 token 输出速度 30 token/s 老板看完很满意 觉得可以上线了。然后一上线一连串问题让我重新认识了 LLM 推理这件事远比我想象的复杂。第一种最先把我打懵 单卡 4090 24GB 显存 跑 7B FP16 模型加载完就用掉了 14GB 用户并发一来 显存暴涨 4 个用户同时请求显存就 OOM 了 服务直接挂。第二种最难缠 并发 8 个请求时 平均延迟从 200ms 涨到 4 秒 GPU 利用率却只有 30% 我盯着 nvidia-smi 看了半天才意识到 是因为 batching 没做好 8 个请求被串行处理了。第三种最离谱 我加了 dynamic batching 后吞吐确实涨了 可某些请求要生成 2000 token 某些只要 50 token 它们被打成一个 batch 那批 50 token 的用户等了 30 秒才拿到结果 因为 batch 要等最慢的那个完成。第四种最莫名其妙 换成 vLLM 后吞吐瞬间涨了 5 倍 我却不知道它做了什么 后来才发现是 PagedAttention 把 KV Cache 分页存储 显存利用率从 50% 提到了 95%。第五种最致命 用户问了一个相同的长 prompt 同样的工具调用 模型每次都重新算 我才意识到 prompt caching 在私有化部署里也必须做 否则成本和延迟都白瞎。我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 LLM 推理就是 加载模型 转发请求 返回输出 就完事了 可这个认知是错的真正能在生产用的 LLM 推理服务是一个显存管理加批处理调度加 KV Cache 加量化加多模型路由的精密系统 任何一环做不好都会让 GPU 利用率惨不忍睹或者延迟飙升本文从头梳理 LLM 推理的核心瓶颈在哪 vLLM 和 TGI 各自的优势是什么 continuous batching 是怎么解决传统 batch 的尾部延迟问题 PagedAttention 为什么是显存利用率的革命 量化方案怎么选 以及一些把推理服务做扎实要避开的工程坑
问题背景:为什么 LLM 推理服务比想象中难
很多人对 LLM 推理的认识是 模型 + GPU + API 三件套 实际工程里这种认识能撑过 demo 但撑不过生产。问题的根源在于:
- LLM 推理是 memory bound 不是 compute bound:7B 模型 FP16 大约 14GB 显存 KV Cache 还能再占数 GB 显存带宽往往是瓶颈而不是算力。
- 请求长度高度不均衡:prompt 从几十 token 到几万 token output 从几个 token 到几千 token 简单粗暴的 batch 会导致严重的资源浪费和尾部延迟。
- generation 是迭代的:每生成一个 token 就要重跑一次模型 不像传统 ML 推理一次返回 这让 batching 策略必须重新设计。
- KV Cache 是显存大户:一个 4k 上下文的请求 KV Cache 可能占 1-2GB 多并发时 KV Cache 的总量比模型本身还大。
- 不同请求的 SLO 不同:有的请求要求秒级响应 有的可以等几十秒 一刀切的调度策略无法满足。
- 量化和精度的权衡很微妙:INT8 INT4 GPTQ AWQ 每种方案在显存压缩比 推理速度 精度损失上都有不同表现 选错就翻车。
一 LLM 推理瓶颈分析:为什么 GPU 利用率上不去
在动手优化之前必须先理解 LLM 推理的本质瓶颈。Transformer 推理分两个阶段 prefill 处理 prompt 计算所有 token 的 KV decode 一个 token 一个 token 生成输出每次只算一个新 token 与所有历史的 K V 做注意力。prefill 阶段是 compute bound 因为有大量的并行矩阵乘法。decode 阶段是 memory bound 因为每生成一个 token 都要把整个 KV Cache 从显存里读出来。这就是为什么单请求时 GPU 利用率只有 5-10% 因为 decode 阶段 GPU 算力完全在等显存带宽。
import torch
import time
from dataclasses import dataclass
@dataclass
class InferenceProfile:
prefill_time_ms: float
decode_time_per_token_ms: float
peak_memory_mb: float
kv_cache_mb: float
def profile_inference(model, tokenizer, prompt: str, max_new_tokens: int = 100) -> InferenceProfile:
inputs = tokenizer(prompt, return_tensors='pt').to(model.device)
prompt_len = inputs['input_ids'].shape[1]
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
t0 = time.time()
with torch.no_grad():
out = model(**inputs, use_cache=True)
torch.cuda.synchronize()
prefill_ms = (time.time() - t0) * 1000
past = out.past_key_values
next_token = out.logits[:, -1].argmax(dim=-1, keepdim=True)
t0 = time.time()
for _ in range(max_new_tokens):
with torch.no_grad():
out = model(input_ids=next_token, past_key_values=past, use_cache=True)
past = out.past_key_values
next_token = out.logits[:, -1].argmax(dim=-1, keepdim=True)
torch.cuda.synchronize()
decode_ms = (time.time() - t0) * 1000
peak_mb = torch.cuda.max_memory_allocated() / 1024 / 1024
kv_mb = sum(k.element_size() * k.numel() + v.element_size() * v.numel()
for k, v in past) / 1024 / 1024
return InferenceProfile(
prefill_time_ms=prefill_ms,
decode_time_per_token_ms=decode_ms / max_new_tokens,
peak_memory_mb=peak_mb,
kv_cache_mb=kv_mb,
)
跑一下这个 profile 你就能直观看到 一个 1024 token prompt 200 token output 的请求 prefill 大约 300ms decode 每 token 30-40ms KV Cache 占用大约 500MB。这个 profile 是所有优化的起点 没量化分析就盲目调参 多半是在错误的方向上努力。
二 Static Batching vs Dynamic Batching vs Continuous Batching
批处理是提升 GPU 利用率的核心手段。但 LLM 推理的批处理和传统 ML 不一样 因为请求长度差异巨大 生成步数也差异巨大。最早大家用的是 static batching 等到攒满一个 batch 再处理 缺点是延迟高 而且 batch 里最慢的请求会卡住其他请求。后来出现了 dynamic batching 按时间窗口或者 batch 大小触发 灵活了一些。但真正的革命是 continuous batching 也叫 in-flight batching 这是 vLLM TGI 这些现代推理框架的核心。
from dataclasses import dataclass, field
from typing import List, Optional
import time
@dataclass
class Request:
req_id: str
prompt_tokens: list
max_new_tokens: int
generated_tokens: list = field(default_factory=list)
finished: bool = False
arrival_time: float = field(default_factory=time.time)
class ContinuousBatchScheduler:
def __init__(self, max_batch_size: int = 32, max_total_tokens: int = 8192):
self.max_batch_size = max_batch_size
self.max_total_tokens = max_total_tokens
self.waiting_queue: List[Request] = []
self.running_batch: List[Request] = []
def add_request(self, req: Request):
self.waiting_queue.append(req)
def step(self, model):
finished = [r for r in self.running_batch if r.finished]
self.running_batch = [r for r in self.running_batch if not r.finished]
while (len(self.running_batch) < self.max_batch_size
and self.waiting_queue
and self._total_tokens() + len(self.waiting_queue[0].prompt_tokens) <= self.max_total_tokens):
self.running_batch.append(self.waiting_queue.pop(0))
if not self.running_batch:
return finished
next_tokens = model.batched_decode(self.running_batch)
for req, tok in zip(self.running_batch, next_tokens):
req.generated_tokens.append(tok)
if tok == model.eos_token_id or len(req.generated_tokens) >= req.max_new_tokens:
req.finished = True
return finished
def _total_tokens(self) -> int:
return sum(len(r.prompt_tokens) + len(r.generated_tokens) for r in self.running_batch)
这段代码的核心是 step 函数 每次迭代都会:把已完成的请求踢出 batch 把队列里等待的请求加入 batch 然后让模型对当前 batch 的所有活跃请求做一次 forward 生成下一个 token。关键是 完成的请求立刻退出 不需要等其他请求一起完成 新请求随时可以加入 batch 这就把 GPU 利用率从 30% 提到了 85% 以上。这就是 continuous batching 比传统 dynamic batching 强的根本原因。
三 PagedAttention:KV Cache 的虚拟内存革命
在 continuous batching 之外 vLLM 的另一个杀手锏是 PagedAttention。传统的 KV Cache 是为每个请求按最大长度预分配一块连续显存 比如最大 4096 token 即使请求只用了 100 token 也占着 4096 的空间 浪费严重。PagedAttention 借鉴操作系统的虚拟内存思路 把 KV Cache 按页 page 比如每页 16 token 分配 请求按需取页 用多少占多少 显存碎片消失了 利用率从 50% 提到了 95% 同时还能支持 KV Cache 的跨请求共享 比如多个并发请求共享同一段 system prompt 的 KV Cache。
class KVPage:
def __init__(self, page_id: int, size: int = 16):
self.page_id = page_id
self.size = size
self.used = 0
self.ref_count = 0
class PagedKVCacheAllocator:
def __init__(self, total_pages: int, page_size: int = 16):
self.page_size = page_size
self.pages = [KVPage(i, page_size) for i in range(total_pages)]
self.free_list = list(range(total_pages))
self.block_table = {}
def allocate(self, req_id: str, num_tokens: int) -> list:
pages_needed = (num_tokens + self.page_size - 1) // self.page_size
if len(self.free_list) < pages_needed:
raise MemoryError(f'KV cache exhausted: need {pages_needed} free {len(self.free_list)}')
page_ids = [self.free_list.pop(0) for _ in range(pages_needed)]
for pid in page_ids:
self.pages[pid].ref_count = 1
self.pages[pid].used = self.page_size
self.block_table[req_id] = page_ids
return page_ids
def append_token(self, req_id: str):
pages = self.block_table[req_id]
last = self.pages[pages[-1]]
if last.used < last.size:
last.used += 1
return pages[-1]
if not self.free_list:
raise MemoryError('KV cache exhausted on append')
new_pid = self.free_list.pop(0)
self.pages[new_pid].ref_count = 1
self.pages[new_pid].used = 1
pages.append(new_pid)
return new_pid
def free(self, req_id: str):
for pid in self.block_table.pop(req_id, []):
self.pages[pid].ref_count -= 1
if self.pages[pid].ref_count <= 0:
self.pages[pid].used = 0
self.free_list.append(pid)
def share_pages(self, src_req: str, dst_req: str, num_pages: int):
src_pages = self.block_table[src_req][:num_pages]
for pid in src_pages:
self.pages[pid].ref_count += 1
self.block_table[dst_req] = list(src_pages)
真正的杀手锏在 share_pages 共享 prefix 的 KV Cache 可以让多个请求共用同一段显存。如果你有 100 个并发请求 它们的 system prompt 都一样 占用 500 token 那只需要分配一份 500 token 的 KV Cache 就够 100 个请求用 节省的显存能用来跑更多并发 这就是 vLLM 在多用户场景吞吐能达到原始 transformers 的 5-20 倍的根本原因。
[mermaid]flowchart TD
A[新请求到达] --> B{KV Cache
空间是否足够}
B -->|不够| C[尝试 preempt
低优先级请求]
C --> D{是否能腾出空间}
D -->|不能| E[放回等待队列]
D -->|能| F[分配 KV pages]
B -->|够| F
F --> G{prompt 前缀
是否能与已有共享}
G -->|能| H[ref count 加 1
复用 pages]
G -->|不能| I[copy 新页]
H --> J[加入运行 batch]
I --> J
J --> K[迭代生成 token]
四 量化:用精度换显存与吞吐
量化是缓解显存压力和提升吞吐的另一把利器。把模型权重从 FP16 压到 INT8 显存减半 推理速度也能提 1.5 倍 INT4 更激进 显存四分之一 但精度损失也更明显。常见的量化方案有 GPTQ AWQ SmoothQuant FP8 它们的核心差异在于 是否需要校准数据 是 weight-only 还是 weight+activation 都量化 精度损失程度等等。
from dataclasses import dataclass
@dataclass
class QuantConfig:
method: str # gptq, awq, smoothquant, fp8
bits: int # 4 or 8
group_size: int = 128
desc_act: bool = False
sym: bool = True
def estimate_memory(model_params_b: float, quant: QuantConfig) -> dict:
fp16_bytes = model_params_b * 1e9 * 2
if quant.method == 'fp16':
weights = fp16_bytes
elif quant.method == 'fp8':
weights = fp16_bytes / 2
elif quant.method in ('gptq', 'awq') and quant.bits == 4:
weights = fp16_bytes / 4 + (fp16_bytes / quant.group_size) * 2
elif quant.bits == 8:
weights = fp16_bytes / 2
else:
weights = fp16_bytes
return {
'weights_gb': weights / 1e9,
'kv_per_token_kb': model_params_b * 0.5,
'activation_buffer_gb': 1.0,
}
有了基础估算函数 我们再写一个简单的决策器 把场景参数翻译成具体的量化配置 让生产里能按业务规则自动选型:
def choose_quant(latency_critical: bool, accuracy_critical: bool, vram_gb: float, model_size_b: float):
if accuracy_critical:
return QuantConfig(method='fp16', bits=16) if vram_gb >= model_size_b * 2.5 else QuantConfig(method='fp8', bits=8)
if vram_gb < model_size_b * 0.7:
return QuantConfig(method='awq', bits=4)
if latency_critical:
return QuantConfig(method='fp8', bits=8)
return QuantConfig(method='gptq', bits=4)
选量化方案要看几个维度。第一是模型类型 LLM 对 INT4 的精度容忍度比图像模型高 一般 4-bit 量化只损失 1-2 个百分点的下游任务准确率 完全可接受。第二是硬件 H100 有原生 FP8 单元 那肯定用 FP8 A100 没有 FP8 那就 INT8 或 INT4。第三是吞吐 vs 延迟 INT4 节省的显存可以让 batch 更大 吞吐更高 但单次推理的 dequantize 操作有开销 单请求延迟略升。我们的经验是 7B-13B 模型用 AWQ INT4 70B 模型用 GPTQ INT4 因为它在大模型上的精度保留更好。
五 模型路由与多实例策略
很多团队上线 LLM 服务时会想 一个模型就够了 后来才发现 不同请求适合不同模型 简单任务用 7B 复杂任务用 70B 代码生成用 CodeLlama 通用对话用 Llama 一刀切的模型部署既贵又不准。所以生产里都会做模型路由 按请求特征分发到不同的模型实例。
from enum import Enum
class TaskType(Enum):
CHAT = 'chat'
CODE = 'code'
SQL = 'sql'
SUMMARY = 'summary'
REASONING = 'reasoning'
class ModelRouter:
def __init__(self):
self.routes = {
TaskType.CHAT: 'llama-7b-instruct',
TaskType.CODE: 'codellama-13b',
TaskType.SQL: 'sqlcoder-7b',
TaskType.SUMMARY: 'llama-7b-instruct',
TaskType.REASONING: 'llama-70b-instruct',
}
self.load_thresholds = {
'llama-7b-instruct': 80,
'codellama-13b': 80,
'sqlcoder-7b': 80,
'llama-70b-instruct': 60,
}
def classify(self, prompt: str) -> TaskType:
text = prompt.lower()
if any(k in text for k in ('select ', 'insert ', 'update ', 'from ', 'where ')):
return TaskType.SQL
if any(k in text for k in ('def ', 'function ', 'class ', '代码', 'code')):
return TaskType.CODE
if any(k in text for k in ('总结', '摘要', 'summarize', 'summary')):
return TaskType.SUMMARY
if any(k in text for k in ('推理', '为什么', 'why', '分析')):
return TaskType.REASONING
return TaskType.CHAT
def route(self, prompt: str, current_load: dict) -> str:
task = self.classify(prompt)
preferred = self.routes[task]
if current_load.get(preferred, 0) < self.load_thresholds[preferred]:
return preferred
fallbacks = {
TaskType.REASONING: 'llama-7b-instruct',
TaskType.SQL: 'llama-7b-instruct',
TaskType.CODE: 'llama-7b-instruct',
}
return fallbacks.get(task, 'llama-7b-instruct')
模型路由的设计要点是 主路径加 fallback 主模型过载时降级到通用模型 而不是让请求等待。我们的经验是 70B 模型的并发上限很低 单 A100 80GB 撑不住 20 个并发 必须设个 60% 的水位线 超过就降级到 7B 否则 70B 那条线一阻塞 整个系统延迟雪崩。
六 推理服务的工程坑:那些文档里学不到的
讲完原理来说几个真实踩过的坑。第一个坑是 GPU 显存不光是模型本身 还有 PyTorch 的显存碎片 CUDA 上下文 cuBLAS 工作区 实际可用比理论值少 2-3GB 一定要留余量 否则上线后第一次 OOM 就把你打懵。第二个坑是 vLLM 的 max_num_seqs 参数 它决定并发上限 设太大显存会爆 设太小吞吐上不去 必须结合实际 KV Cache 占用计算 公式大约是 显存 - 模型权重 - 5GB 余量 除以 单请求 KV 平均大小。第三个坑是 多卡部署的 NCCL 配置 张量并行的卡间通信非常敏感 NCCL_P2P_DISABLE NCCL_IB_DISABLE 这些参数没配对 推理速度能差 3-5 倍。
import os
import psutil
import torch
class ServingHealthCheck:
def __init__(self, port: int, gpu_id: int = 0):
self.port = port
self.gpu_id = gpu_id
def check(self) -> dict:
gpu_free, gpu_total = torch.cuda.mem_get_info(self.gpu_id)
gpu_util = (gpu_total - gpu_free) / gpu_total
cpu_pct = psutil.cpu_percent()
return {
'gpu_mem_used_pct': gpu_util * 100,
'gpu_mem_free_gb': gpu_free / 1024 / 1024 / 1024,
'cpu_pct': cpu_pct,
'healthy': gpu_util < 0.95 and cpu_pct < 95,
}
与健康检查配套的是优雅退出 在做发布或者节点漂移的时候 必须先把正在跑的请求等完 而不是直接 kill 进程 否则用户的请求会被半路截断:
class GracefulShutdown:
def __init__(self, scheduler: ContinuousBatchScheduler, drain_timeout_sec: int = 60):
self.scheduler = scheduler
self.drain_timeout_sec = drain_timeout_sec
self.shutting_down = False
def initiate(self):
self.shutting_down = True
def can_accept_new(self) -> bool:
return not self.shutting_down
def drain_and_exit(self, model):
start = time.time()
while self.scheduler.running_batch or self.scheduler.waiting_queue:
if time.time() - start > self.drain_timeout_sec:
break
self.scheduler.step(model)
第四个坑是 长时间运行的显存泄漏 即使有 GC 即使用 torch.cuda.empty_cache 长时间运行的服务显存还是会缓慢增长 必须每天定时 restart 或者监控显存超阈值自动重启。第五个坑是 没做 graceful shutdown 服务重启时正在生成的请求被强杀 用户体验直接断 必须做请求 drain 让进行中的请求完成或者超时 再下线实例 这一项几乎所有团队最初都漏做。
关键概念速查
| 概念 | 含义 | 工程价值 |
|---|---|---|
| Prefill / Decode | 推理的两个阶段 | 性能特征完全不同 |
| KV Cache | 注意力 K V 缓存 | 显存大户必须管理 |
| Static Batching | 等齐再处理 | 简单但延迟高 |
| Dynamic Batching | 窗口触发 | 灵活但有尾部延迟 |
| Continuous Batching | 迭代级调度 | vLLM TGI 的核心 |
| PagedAttention | KV Cache 分页 | 显存利用率从 50% 到 95% |
| Prefix Sharing | 共享 prompt KV | 多并发同 system 显著省显存 |
| GPTQ / AWQ | 4-bit 权重量化 | 显存四分之一精度小幅损失 |
| FP8 | H100 原生 8-bit | 新硬件首选 |
| Tensor Parallel | 多卡分摊一个层 | 大模型必备但通信敏感 |
避坑清单
- 显存预算必须留 2-3GB 余量 别按理论值压满 上线后碎片化会让你 OOM。
- 单卡部署优先用 vLLM 或 TGI 不要用裸 transformers 否则吞吐差一个数量级。
- continuous batching 的 max_num_seqs 要按 KV Cache 实际容量算 不是越大越好。
- system prompt 共享 必须用支持 prefix caching 的框架 vLLM 0.3+ TGI 1.4+ 才有。
- 多卡部署一定要测 NCCL 配置 不配 P2P 速度能差 3-5 倍。
- 量化方案要做精度回归测试 别盲目用 INT4 部分任务掉点会很难看。
- 不同模型不同租户不同接口 一定要做路由 别一刀切跑大模型贵且慢。
- 长跑服务要做显存监控 显存缓涨是真实存在的 设阈值自动重启。
- 必须做 graceful shutdown 别让在生成的请求被强杀 用户体验断崖。
- 压测必须用真实分布 不要全用 1k token 测了上线发现 4k token 的请求把系统打挂。
总结
LLM 推理服务这事 很多人的直觉是 加载模型 起个 API 就行 这其实是把 我会调 transformers 和 我能在生产跑稳一个推理服务 混为一谈。模型加载只是冰山一角 真正的复杂度全在水面以下 批处理调度 显存管理 量化策略 模型路由 长跑稳定性 每一项都是一座工程大山 都需要你认真翻越。
从原型到生产 你需要做的事远不止 写一个 generate 接口。你要懂 prefill 和 decode 的性能特征 要选合适的推理框架 要算 KV Cache 的显存预算 要做量化精度回归 要设计多模型路由 要应对显存碎片 要做 graceful shutdown 要做监控告警。每一项单独看都不复杂 但它们叠加在一起 才是一个能稳定服务百用户千用户万用户的 LLM 推理系统。少任何一项 都会在某个真实流量里 把你刚才省下的优化时间 连本带利地还回去 而且通常以 GPU OOM 或者 P99 延迟 30 秒 的形式还。
我经常用一个比喻来理解 LLM 推理服务 它有点像高级餐厅的厨房。模型是大厨 显存是厨房面积 KV Cache 是料理台 batch 是出菜节奏 你不能因为请到了米其林大厨 就以为厨房自动会高效运转 你要安排料理台怎么用 怎么排上菜的顺序 怎么处理同时来的复杂订单 怎么不让某一桌的慢菜把所有人都饿着。厨房管理水平决定了大厨能不能发挥水平 而不是大厨本身。
这套架构最难的地方在于 它的复杂度在 demo 阶段几乎完全暴露不了。你单机跑一个请求觉得 4090 真快 大模型真好 但真正多用户上线你会发现 99% 的复杂度都在 那 1% 的并发 长 prompt 高负载场景里。建议任何想做 LLM 推理服务的团队 上线前一定要做一遍真实流量回放 用真实 prompt 分布 真实并发 真实长度去压一压 千万别只测一两个 case 就上线 那种系统一定会在第一周给你看 服务崩溃 GPU OOM 的灾难现场。
—— 别看了 · 2026