云原生 AI 推理服务的性能优化:从 GPU 利用率到请求调度的全链路调优实录
GPU 利用率 95% 但 P99 延迟 8 秒——利用率高不等于吞吐高。推理服务的优化不是压榨 GPU,而是让每一帧 GPU 计算都服务于一个真实的用户请求。
一、场景痛点:GPU 利用率的误导
我们的推理服务上线半年,监控面板上 GPU 利用率常年 85-95%。产品经理看了很开心:"资源利用率很高嘛。"
但 SLO 报告说:P99 延迟 8 秒,TTFT(首 token 延迟)波动 500ms-3s,吞吐量只有 15 req/s。GPU 95% 利用率的背后是:
GPU 利用率是 GPU 的视角,不是用户的视角。 用户关心的是"我的回答什么时候出来",而不是"GPU 有多忙"。推理服务的优化目标应该是吞吐量和延迟的帕累托最优,而不是 GPU 利用率的最大化。
二、底层机制:推理服务的全链路瓶颈分析
2.1 从请求到响应的完整链路
2.2 动态 Batching 的核心矛盾
动态 batching 有一个根本矛盾:等待时间越长,批次越大,吞吐越高;但等待时间越长,每个请求的延迟越高。
| 0ms (无等待) | 1-2 | 8 | 200ms | 500ms |
| 10ms | 3-5 | 18 | 250ms | 800ms |
| 50ms | 8-15 | 30 | 400ms | 3000ms |
| 100ms | 15-25 | 45 | 800ms | 8000ms |
| 200ms | 25-40 | 55 | 1500ms | 15000ms |
Pareto 最优点在 10-50ms 之间。但不同场景的最优等待时间不同:
- 在线对话(对延迟敏感):10ms 等待,牺牲吞吐换延迟
- 批量处理(对吞吐敏感):100ms 等待,牺牲延迟换吞吐
- 混合场景:需要请求分级,不同优先级用不同等待时间
2.3 KV Cache 碎片化
vLLM 的 PagedAttention 用分页机制管理 KV Cache,类似操作系统的虚拟内存。但不同请求的 token 长度差异巨大(50-8000 tokens),导致:
- 短请求占 2 页(1 页 = 16 tokens),利用率 100%
- 长请求占 500 页,但最后一页可能只用了 3/16 的空间
- 平均页面利用率 60% → 40% 的显存浪费
这就是为什么 GPU 利用率 95% 但 KV Cache 只用了 60%——GPU 在忙着给 padding 算无用功。
三、生产级代码实现
3.1 请求分级调度器
"""
推理请求分级调度器
核心设计:
1. 三个优先级队列: Online/Batch/Offline
2. 每个队列独立的 batching 等待时间
3. 高优先级请求可以抢占低优先级的 GPU 时间
4. 自适应等待时间: 根据当前负载动态调整
基于 vLLM 的自定义 Scheduler 实现
"""
import time
import asyncio
from dataclasses import dataclass, field
from typing import Optional
from collections import defaultdict
from enum import Enum
class RequestPriority(Enum):
"""请求优先级"""
ONLINE = 1 # 在线对话: 延迟敏感, 等待时间 10ms
BATCH = 2 # 批量处理: 吞吐敏感, 等待时间 50ms
OFFLINE = 3 # 离线评测: 不影响在线, 等待时间 200ms
@dataclass
class InferenceRequest:
"""推理请求"""
request_id: str
prompt: str
max_tokens: int
priority: RequestPriority
session_id: str = ""
user_id: str = ""
created_at: float = field(default_factory=time.time)
# 超时控制
timeout_ms: int = 30000 # 总超时 30s
ttft_timeout_ms: int = 5000 # TTFT 超时 5s
@dataclass
class BatchConfig:
"""批次配置"""
max_batch_size: int # 最大批次大小
max_waiting_time_ms: float # 最大等待时间
max_tokens_per_batch: int # 批次总 token 上限
# 三个优先级的独立配置
PRIORITY_CONFIGS = {
RequestPriority.ONLINE: BatchConfig(
max_batch_size=8, # 小批次, 快出结果
max_waiting_time_ms=10, # 等待 10ms
max_tokens_per_batch=4096, # 总 token 限制
),
RequestPriority.BATCH: BatchConfig(
max_batch_size=32, # 大批次, 高吞吐
max_waiting_time_ms=50, # 等待 50ms
max_tokens_per_batch=16384,
),
RequestPriority.OFFLINE: BatchConfig(
max_batch_size=64, # 最大批次
max_waiting_time_ms=200, # 等待 200ms
max_tokens_per_batch=32768,
),
}
class PriorityScheduler:
"""
分级调度器
工作流程:
1. 请求到达后按优先级分配到对应队列
2. 每个队列独立计时,达到等待时间或批次上限就触发
3. GPU 执行时优先处理 ONLINE 批次
4. 自适应: 当 ONLINE 队列积压时, 自动降低等待时间
"""
def __init__(self):
# 三个独立队列
self.queues: dict[RequestPriority, list[InferenceRequest]] = {
RequestPriority.ONLINE: [],
RequestPriority.BATCH: [],
RequestPriority.OFFLINE: [],
}
# 当前 GPU 状态
self.gpu_busy = False
self.current_batch_priority: Optional[RequestPriority] = None
# 自适应参数
self._online_queue_pressure = 0.0 # ONLINE 队列压力指标
# 统计
self._stats = defaultdict(lambda: {
"total": 0, "batched": 0, "avg_batch_size": 0,
"avg_wait_time_ms": 0,
})
def enqueue(self, request: InferenceRequest) -> None:
"""入队"""
priority = request.priority
self.queues[priority].append(request)
self._stats[priority]["total"] += 1
# 更新 ONLINE 队列压力
online_len = len(self.queues[RequestPriority.ONLINE])
self._online_queue_pressure = min(online_len / 10, 1.0) # >10 → 压力 1.0
def get_next_batch(self) -> Optional[list[InferenceRequest]]:
"""
获取下一个执行批次
优先级逻辑:
1. ONLINE 队列有满足条件的批次 → 立即执行
2. BATCH 队列有满足条件的批次 → 执行
3. OFFLINE 队列 → 只在 GPU 空闲时执行
"""
# 按优先级顺序检查
for priority in [RequestPriority.ONLINE, RequestPriority.BATCH, RequestPriority.OFFLINE]:
batch = self._try_form_batch(priority)
if batch:
return batch
return None
def _try_form_batch(self, priority: RequestPriority) -> Optional[list[InferenceRequest]]:
"""
尝试从指定优先级队列中组建批次
批次组建条件:
1. 等待时间达到阈值
2. 或队列长度达到最大批次大小
3. 或总 token 数达到上限
自适应: ONLINE 队列压力大时, 降低等待时间
"""
queue = self.queues[priority]
if not queue:
return None
config = PRIORITY_CONFIGS[priority]
# 自适应等待时间调整
if priority == RequestPriority.ONLINE:
# ONLINE 队列压力大: 等待时间减半, 尽快处理
adjusted_wait = config.max_waiting_time_ms * (1 – self._online_queue_pressure * 0.5)
else:
adjusted_wait = config.max_waiting_time_ms
# 检查等待时间
oldest_request = queue[0]
wait_time = (time.time() – oldest_request.created_at) * 1000
# 批次组建条件: 等够时间 OR 队列够长
if wait_time < adjusted_wait and len(queue) < config.max_batch_size:
return None
# 组建批次: 按总 token 数限制
batch = []
total_tokens = 0
for req in queue:
estimated_tokens = len(req.prompt.split()) * 2 + req.max_tokens # 粗略估计
if total_tokens + estimated_tokens > config.max_tokens_per_batch:
break
if len(batch) >= config.max_batch_size:
break
batch.append(req)
total_tokens += estimated_tokens
if not batch:
return None
# 从队列中移除
for req in batch:
self.queues[priority].remove(req)
# 统计
self._stats[priority]["batched"] += len(batch)
avg_wait = sum((time.time() – req.created_at) * 1000 for req in batch) / len(batch)
self._stats[priority]["avg_wait_time_ms"] = avg_wait
self.current_batch_priority = priority
print(
f"[Scheduler] Batch formed: priority={priority.name}, "
f"size={len(batch)}, tokens={total_tokens}, "
f"avg_wait={avg_wait:.1f}ms"
)
return batch
def get_stats(self) -> dict:
"""获取调度统计"""
return {
"queue_lengths": {
p.name: len(self.queues[p]) for p in RequestPriority
},
"stats": dict(self._stats),
"online_pressure": self._online_queue_pressure,
}
3.2 KV Cache 碎片优化:长度分组策略
"""
KV Cache 碎片优化器
核心思路:
1. 将请求按预估 token 长度分组
2. 同组请求放在同一批次, 减少页面碎片
3. 短请求和长请求分开调度
基于 vLLM PagedAttention 的分组策略
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class LengthGroup:
"""长度分组"""
name: str
min_tokens: int
max_tokens: int
optimal_batch_size: int # 该组的最优批次大小
page_utilization_est: float # 预估页面利用率
# 长度分组定义
LENGTH_GROUPS = [
LengthGroup("short", 0, 256, 16, 0.95), # 短请求: 页面利用率高
LengthGroup("medium", 256, 1024, 8, 0.80), # 中等: 利用率较好
LengthGroup("long", 1024, 4096, 4, 0.65), # 长: 利用率中等
LengthGroup("ultra", 4096, 8192, 2, 0.50), # 超长: 利用率低, 小批次
]
def estimate_token_length(prompt: str, max_tokens: int) -> int:
"""
预估请求的总 token 长度
用于分组决策。不需要精确, 只需要大致分类
"""
# 粗略估计: 中文 1.5 token/字, 英文 0.75 token/word
prompt_estimate = len(prompt) * 0.75 # 简化估计
return int(prompt_estimate + max_tokens)
def classify_request(token_length: int) -> LengthGroup:
"""将请求分入长度组"""
for group in LENGTH_GROUPS:
if group.min_tokens <= token_length <= group.max_tokens:
return group
# 超出最大组的, 放入 ultra
return LENGTH_GROUPS[-1]
class LengthBasedBatcher:
"""
长度分组 Batcher
核心优化:
1. 短请求单独组批 → 高页面利用率 → 显存节省
2. 长请求小批次 → 避免 KV Cache 碎片浪费大量显存
3. 混合长度请求 → 按最长请求的 padding 补齐
→ 只在短请求积压时才混合, 否则分开
"""
def __init__(self):
# 每个长度组的独立队列
self.group_queues: dict[str, list] = {
g.name: [] for g in LENGTH_GROUPS
}
def enqueue(self, request) -> str:
"""入队并分组"""
token_length = estimate_token_length(
request.prompt, request.max_tokens
)
group = classify_request(token_length)
self.group_queues[group.name].append(request)
return group.name
def get_next_batch(self) -> Optional[list]:
"""
获取下一个批次
策略:
1. 短请求队列满 → 立即组短批次(高利用率)
2. 中等请求队列满 → 组中等批次
3. 长请求单独处理(小批次,避免显存浪费)
4. 如果短请求积压但不够组批 → 混合短+中等
"""
# 优先处理短请求(利用率最高)
for group in LENGTH_GROUPS:
queue = self.group_queues[group.name]
if len(queue) >= group.optimal_batch_size:
batch = queue[:group.optimal_batch_size]
self.group_queues[group.name] = queue[group.optimal_batch_size:]
print(
f"[LengthBatcher] Batch: group={group.name}, "
f"size={len(batch)}, est_utilization={group.page_utilization_est}"
)
return batch
# 短请求积压但不够组批 → 和中等混合
short_queue = self.group_queues["short"]
medium_queue = self.group_queues["medium"]
if short_queue and medium_queue:
mixed_size = min(len(short_queue) + len(medium_queue), 12)
batch = short_queue[:mixed_size // 2] + medium_queue[:mixed_size // 2]
# 从队列移除
for req in batch:
group_name = classify_request(
estimate_token_length(req.prompt, req.max_tokens)
).name
self.group_queues[group_name].remove(req)
print(f"[LengthBatcher] Mixed batch: size={len(batch)}")
return batch
# 长请求单条处理
for group_name in ["long", "ultra"]:
queue = self.group_queues[group_name]
if queue:
batch = [queue[0]]
self.group_queues[group_name] = queue[1:]
print(f"[LengthBatcher] Single long request: group={group_name}")
return batch
return None
def get_page_utilization_report(self) -> dict:
"""页面利用率预估报告"""
total_requests = sum(len(q) for q in self.group_queues.values())
if total_requests == 0:
return {"overall_utilization": 1.0, "groups": {}}
overall = 0.0
groups = {}
for group in LENGTH_GROUPS:
count = len(self.group_queues[group.name])
if count > 0:
weight = count / total_requests
group_util = group.page_utilization_est
overall += weight * group_util
groups[group.name] = {
"count": count,
"estimated_utilization": group_util,
}
return {"overall_utilization": overall, "groups": groups}
3.3 Kubernetes 部署配置:推理服务的资源治理
# ============================================================
# 推理服务 Deployment: 资源治理 + 优先级调度
# ============================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: inference-service-online
namespace: ai-inference
labels:
app: inference
priority: online
spec:
replicas: 3
selector:
matchLabels:
app: inference
priority: online
template:
metadata:
labels:
app: inference
priority: online
annotations:
# Prometheus 指标采集
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
# 优先级类: Online 推理服务高优先级
priorityClassName: system-high-priority
# Pod 反亲和: 分散到不同节点
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
– weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
– key: priority
operator: In
values: ["online"]
topologyKey: "kubernetes.io/hostname"
# GPU 节点亲和: 只调度到有 GPU 的节点
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
– matchExpressions:
– key: nvidia.com/gpu.product
operator: In
values: ["A100-SXM4-80GB", "H100-SXM5-80GB"]
containers:
– name: vllm-server
image: registry.example.com/vllm:v0.6.0-custom
ports:
– containerPort: 8000
name: inference
– containerPort: 8080
name: metrics
# GPU 资源请求
resources:
requests:
nvidia.com/gpu: 1 # 请求 1 个 GPU
memory: "32Gi"
cpu: "4"
limits:
nvidia.com/gpu: 1 # 限制 1 个 GPU(不超售)
memory: "64Gi" # 显存由 GPU 管理, CPU 内存给 tokenizer
cpu: "8"
# vLLM 启动参数
command: ["python", "-m", "vllm.entrypoints.openai.api_server"]
args:
– "–model=/models/llama-3-70b"
– "–tensor-parallel-size=1"
– "–gpu-memory-utilization=0.85" # GPU 显存利用率(模型权重 + KV Cache)
– "–max-model-len=8192" # 最大序列长度
– "–max-num-seqs=32" # 最大并发序列数
– "–max-num-batched-tokens=16384" # 最大批次 token 数
– "–scheduler-policy=priority" # 使用优先级调度
– "–enable-prefix-caching" # 开启前缀缓存(复用公共 system prompt KV)
– "–disable-log-requests"
env:
– name: VLLM_SCHEDULER_WAIT_MS_ONLINE
value: "10" # Online 队列等待 10ms
– name: VLLM_SCHEDULER_WAIT_MS_BATCH
value: "50" # Batch 队列等待 50ms
– name: VLLM_SCHEDULER_WAIT_MS_OFFLINE
value: "200" # Offline 阗列等待 200ms
# 健康检查
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60 # 模型加载需要时间
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 90
periodSeconds: 10
# Volume: 模型权重
volumeMounts:
– name: model-volume
mountPath: /models
readOnly: true
volumes:
– name: model-volume
persistentVolumeClaim:
claimName: model-weights-pvc
—
# ============================================================
# HPA: 在线推理服务自动伸缩
# ============================================================
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: inference-service-online-hpa
namespace: ai-inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: inference-service-online
minReplicas: 2
maxReplicas: 10
metrics:
# 自定义指标: 请求队列长度
– type: Pods
pods:
metric:
name: vllm_request_queue_length
target:
type: AverageValue
averageValue: "5" # 平均队列长度 > 5 → 扩容
# P99 延迟指标
– type: Pods
pods:
metric:
name: vllm_request_latency_p99_ms
target:
type: AverageValue
averageValue: "3000" # P99 > 3s → 扩容
behavior:
scaleUp:
stabilizationWindowSeconds: 30 # 30 秒稳定期后扩容
policies:
– type: Pods
value: 2 # 每次最多扩 2 个 Pod
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # 5 分钟稳定期后缩容(避免震荡)
policies:
– type: Pods
value: 1 # 每次最多缩 1 个 Pod
periodSeconds: 120
3.4 自定义 Prometheus 指标导出
"""
vLLM 自定义 Prometheus 指标导出器
补充 vLLM 默认指标中缺失的业务维度:
1. 请求队列长度(按优先级)
2. KV Cache 页面利用率
3. 批次饱和度
4. TTFT 分布
"""
from prometheus_client import Counter, Histogram, Gauge, Info, generate_latest
from fastapi import FastAPI, Response
# ========== 请求指标 ==========
REQUEST_TOTAL = Counter(
'vllm_request_total',
'Total inference requests',
['priority', 'model']
)
REQUEST_QUEUE_LENGTH = Gauge(
'vllm_request_queue_length',
'Current request queue length',
['priority']
)
REQUEST_LATENCY = Histogram(
'vllm_request_latency_ms',
'Request total latency',
['priority', 'model'],
buckets=[50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000]
)
TTFT_LATENCY = Histogram(
'vllm_ttft_latency_ms',
'Time to first token latency',
['priority', 'model'],
buckets=[10, 50, 100, 200, 500, 1000, 2000, 5000]
)
# ========== GPU / KV Cache 指标 ==========
KV_CACHE_PAGE_UTILIZATION = Gauge(
'vllm_kv_cache_page_utilization',
'KV Cache page utilization ratio',
['length_group']
)
GPU_MEMORY_USED_RATIO = Gauge(
'vllm_gpu_memory_used_ratio',
'GPU memory used ratio (model + KV Cache)'
)
# ========== 批次指标 ==========
BATCH_SIZE = Histogram(
'vllm_batch_size',
'Batch size per iteration',
['priority', 'length_group'],
buckets=[1, 2, 4, 8, 16, 32, 64]
)
BATCH_TOKEN_COUNT = Histogram(
'vllm_batch_token_count',
'Total tokens per batch',
['priority'],
buckets=[128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768]
)
BATCH_PADDING_RATIO = Gauge(
'vllm_batch_padding_ratio',
'Batch padding token ratio (wasted computation)',
['priority']
)
# ========== 模型信息 ==========
MODEL_INFO = Info(
'vllm_model',
'Model information'
)
# ========== FastAPI 指标端点 ==========
metrics_app = FastAPI()
@metrics_app.get("/metrics")
async def metrics_endpoint():
"""Prometheus 指标端点"""
return Response(
content=generate_latest(),
media_type="text/plain"
)
# ========== 更新指标的 Hook ==========
def update_scheduler_metrics(scheduler_stats: dict):
"""从调度器统计更新指标"""
for priority_name, length in scheduler_stats.get("queue_lengths", {}).items():
REQUEST_QUEUE_LENGTH.labels(priority=priority_name).set(length)
def update_batch_metrics(batch_size: int, token_count: int,
padding_ratio: float, priority: str,
length_group: str):
"""从批次执行更新指标"""
BATCH_SIZE.labels(priority=priority, length_group=length_group).observe(batch_size)
BATCH_TOKEN_COUNT.labels(priority=priority).observe(token_count)
BATCH_PADDING_RATIO.labels(priority=priority).set(padding_ratio)
def update_kv_cache_metrics(utilization_report: dict):
"""从 KV Cache 统计更新指标"""
for group_name, data in utilization_report.get("groups", {}).items():
KV_CACHE_PAGE_UTILIZATION.labels(length_group=group_name).set(
data.get("estimated_utilization", 0)
)
四、边界分析:推理服务优化的四个权衡
4.1 吞吐 vs 延迟的帕累托曲线
| 增大等待时间 | +50% | -200%(恶化) | 批量处理 |
| 分级调度 | +20% | +40%(改善) | 混合场景 |
| 长度分组 batching | +30% | +10% | 长短请求混合 |
| 前缀缓存 | +15% | +30% | 多轮对话 |
| KV Cache 碎片优化 | +10% | +5% | 显存紧张 |
| 增加 GPU 实例 | +100% | +80% | 所有场景(但成本翻倍) |
关键发现:分级调度是 ROI 最高的优化——零成本、吞吐和延迟同时改善。
4.2 前缀缓存的适用场景
vLLM 的 –enable-prefix-caching 对多轮对话效果显著(公共 system prompt 的 KV Cache 可以跨请求复用)。但对单轮问答场景无效(没有公共前缀)。
实测数据(多轮对话场景):
- 5 轮对话,每轮 prompt 3000 tokens(其中 system prompt 2500 tokens)
- 无前缀缓存:每轮 Prefill 3000 tokens → TTFT 2.5s
- 有前缀缓存:第 1 轮 Prefill 3000 tokens,第 2-5 轮 Prefill 500 tokens → TTFT 0.4s
- KV Cache 显存节省:2500 tokens × 4 轮 = 约 500MB
4.3 HPA 伸缩的冷启动问题
GPU 推理 Pod 的冷启动时间约 2-5 分钟(模型加载 + GPU 初始化)。HPA 触发扩容到新 Pod 就绪期间,请求堆积无法消化。
解决方案:
- 预热池(Warm Pool):维护 1-2 个预加载模型的 standby Pod,HPA 扩容时直接激活
- 预测性扩容:根据历史流量模式提前扩容(如早 9 点上班高峰前 10 分钟扩容)
- 最大并发限流:在扩容未完成时,通过 API Gateway 限流保护在线请求延迟
4.4 量化推理的精度与速度权衡
| FP16 (baseline) | 0% | 0% | 0% | 精度要求极高 |
| INT8 (GPTQ) | 50% | 1.5x | <1% (MMLU) | 生产推理 |
| INT4 (AWQ) | 75% | 2x | 2-3% (MMLU) | 吞吐优先 |
| FP8 (H100) | 50% | 1.8x | <0.5% | H100 原生支持 |
INT8 量化是性价比最优的:显存减半 → KV Cache 空间翻倍 → 并发翻倍 → 吞吞吐翻倍,精度损失可忽略。但量化需要在模型部署前完成,不能在线切换。
五、总结
云原生 AI 推理服务的优化,不是盯着 GPU 利用率面板调参数,而是从用户视角出发,做全链路瓶颈分析。核心收获:
推理服务的优化没有银弹,每个场景的最优配置不同。建立你的评测 pipeline:改一个参数 → 跑 benchmark → 看吞吐和延迟的变化 → 决定是否采纳。数据说话,面板不看。



