云原生 AI 推理服务的性能优化:从 GPU 利用率到请求调度的全链路调优实录
2026/7/26 18:19:25 网站建设 项目流程

云原生 AI 推理服务的性能优化:从 GPU 利用率到请求调度的全链路调优实录

GPU 利用率 95% 但 P99 延迟 8 秒——利用率高不等于吞吐高。推理服务的优化不是压榨 GPU,而是让每一帧 GPU 计算都服务于一个真实的用户请求。

一、场景痛点:GPU 利用率的误导

我们的推理服务上线半年,监控面板上 GPU 利用率常年 85-95%。产品经理看了很开心:"资源利用率很高嘛。"

但 SLO 报告说:P99 延迟 8 秒,TTFT(首 token 延迟)波动 500ms-3s,吞吐量只有 15 req/s。GPU 95% 利用率的背后是:

  1. 排队堆积:大量请求在队列中等待,GPU 在处理旧请求,新请求干等
  2. KV Cache 碎片:不同长度的请求混在一起,PagedAttention 的内存页利用率只有 60%
  3. 批次不饱和:动态 batching 的等待时间设了 50ms,但很多批次只有 1-2 个请求,GPU 算力浪费在 padding 上

GPU 利用率是 GPU 的视角,不是用户的视角。用户关心的是"我的回答什么时候出来",而不是"GPU 有多忙"。推理服务的优化目标应该是吞吐量和延迟的帕累托最优,而不是 GPU 利用率的最大化。

二、底层机制:推理服务的全链路瓶颈分析

2.1 从请求到响应的完整链路

2.2 动态 Batching 的核心矛盾

动态 batching 有一个根本矛盾:等待时间越长,批次越大,吞吐越高;但等待时间越长,每个请求的延迟越高。

等待时间平均批次大小吞吐量 (req/s)P50 延迟P99 延迟
0ms (无等待)1-28200ms500ms
10ms3-518250ms800ms
50ms8-1530400ms3000ms
100ms15-2545800ms8000ms
200ms25-40551500ms15000ms

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 延迟的帕累托曲线

优化手段吞吐提升P99 延迟改善适用场景
增大等待时间+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%2x2-3% (MMLU)吞吐优先
FP8 (H100)50%1.8x<0.5%H100 原生支持

INT8 量化是性价比最优的:显存减半 → KV Cache 空间翻倍 → 并发翻倍 → 吞吞吐翻倍,精度损失可忽略。但量化需要在模型部署前完成,不能在线切换。

五、总结

云原生 AI 推理服务的优化,不是盯着 GPU 利用率面板调参数,而是从用户视角出发,做全链路瓶颈分析。核心收获:

  1. GPU 利用率是 GPU 的视角:95% 利用率不代表服务好。排队堆积、KV Cache 碎片、批次不饱和——这些都让 GPU 在忙但用户在等。优化目标是吞吐 × 延迟的帕累托最优。
  2. 分级调度是 ROI 最高的优化:零成本改动,Online/Batch/Offline 三队列独立等待时间,吞吐和延迟同时改善。优先做这个。
  3. 长度分组 batching 解决 KV Cache 碎片:短请求高利用率组批,长请求小批次处理。预估页面利用率从 60% 提升到 85%,等于凭空多出 25% 的显存给更多并发。
  4. 前缀缓存是多轮对话的杀手级优化:公共 system prompt 的 KV Cache 跨请求复用,TTFT 从 2.5s 降到 0.4s。但要判断你的场景是否有公共前缀。

推理服务的优化没有银弹,每个场景的最优配置不同。建立你的评测 pipeline:改一个参数 → 跑 benchmark → 看吞吐和延迟的变化 → 决定是否采纳。数据说话,面板不看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询