
云原生 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 在处理旧请求新请求干等KV Cache 碎片不同长度的请求混在一起PagedAttention 的内存页利用率只有 60%批次不饱和动态 batching 的等待时间设了 50ms但很多批次只有 1-2 个请求GPU 算力浪费在 padding 上GPU 利用率是 GPU 的视角不是用户的视角。用户关心的是我的回答什么时候出来而不是GPU 有多忙。推理服务的优化目标应该是吞吐量和延迟的帕累托最优而不是 GPU 利用率的最大化。二、底层机制推理服务的全链路瓶颈分析2.1 从请求到响应的完整链路2.2 动态 Batching 的核心矛盾动态 batching 有一个根本矛盾等待时间越长批次越大吞吐越高但等待时间越长每个请求的延迟越高。等待时间平均批次大小吞吐量 (req/s)P50 延迟P99 延迟0ms (无等待)1-28200ms500ms10ms3-518250ms800ms50ms8-1530400ms3000ms100ms15-2545800ms8000ms200ms25-40551500ms15000msPareto 最优点在 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_factorytime.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_size8, # 小批次, 快出结果 max_waiting_time_ms10, # 等待 10ms max_tokens_per_batch4096, # 总 token 限制 ), RequestPriority.BATCH: BatchConfig( max_batch_size32, # 大批次, 高吞吐 max_waiting_time_ms50, # 等待 50ms max_tokens_per_batch16384, ), RequestPriority.OFFLINE: BatchConfig( max_batch_size64, # 最大批次 max_waiting_time_ms200, # 等待 200ms max_tokens_per_batch32768, ), } 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}, fsize{len(batch)}, tokens{total_tokens}, favg_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}, fsize{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-size1 - --gpu-memory-utilization0.85 # GPU 显存利用率模型权重 KV Cache - --max-model-len8192 # 最大序列长度 - --max-num-seqs32 # 最大并发序列数 - --max-num-batched-tokens16384 # 最大批次 token 数 - --scheduler-policypriority # 使用优先级调度 - --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: 1203.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( contentgenerate_latest(), media_typetext/plain ) # 更新指标的 Hook def update_scheduler_metrics(scheduler_stats: dict): 从调度器统计更新指标 for priority_name, length in scheduler_stats.get(queue_lengths, {}).items(): REQUEST_QUEUE_LENGTH.labels(prioritypriority_name).set(length) def update_batch_metrics(batch_size: int, token_count: int, padding_ratio: float, priority: str, length_group: str): 从批次执行更新指标 BATCH_SIZE.labels(prioritypriority, length_grouplength_group).observe(batch_size) BATCH_TOKEN_COUNT.labels(prioritypriority).observe(token_count) BATCH_PADDING_RATIO.labels(prioritypriority).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_groupgroup_name).set( data.get(estimated_utilization, 0) )四、边界分析推理服务优化的四个权衡4.1 吞吐 vs 延迟的帕累托曲线优化手段吞吐提升P99 延迟改善适用场景增大等待时间50%-200%恶化批量处理分级调度20%40%改善混合场景长度分组 batching30%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.4sKV Cache 显存节省2500 tokens × 4 轮 约 500MB4.3 HPA 伸缩的冷启动问题GPU 推理 Pod 的冷启动时间约 2-5 分钟模型加载 GPU 初始化。HPA 触发扩容到新 Pod 就绪期间请求堆积无法消化。解决方案预热池Warm Pool维护 1-2 个预加载模型的 standby PodHPA 扩容时直接激活预测性扩容根据历史流量模式提前扩容如早 9 点上班高峰前 10 分钟扩容最大并发限流在扩容未完成时通过 API Gateway 限流保护在线请求延迟4.4 量化推理的精度与速度权衡量化方式显存节省推理加速精度损失适用场景FP16 (baseline)0%0%0%精度要求极高INT8 (GPTQ)50%1.5x1% (MMLU)生产推理INT4 (AWQ)75%2x2-3% (MMLU)吞吐优先FP8 (H100)50%1.8x0.5%H100 原生支持INT8 量化是性价比最优的显存减半 → KV Cache 空间翻倍 → 并发翻倍 → 吞吞吐翻倍精度损失可忽略。但量化需要在模型部署前完成不能在线切换。五、总结云原生 AI 推理服务的优化不是盯着 GPU 利用率面板调参数而是从用户视角出发做全链路瓶颈分析。核心收获GPU 利用率是 GPU 的视角95% 利用率不代表服务好。排队堆积、KV Cache 碎片、批次不饱和——这些都让 GPU 在忙但用户在等。优化目标是吞吐 × 延迟的帕累托最优。分级调度是 ROI 最高的优化零成本改动Online/Batch/Offline 三队列独立等待时间吞吐和延迟同时改善。优先做这个。长度分组 batching 解决 KV Cache 碎片短请求高利用率组批长请求小批次处理。预估页面利用率从 60% 提升到 85%等于凭空多出 25% 的显存给更多并发。前缀缓存是多轮对话的杀手级优化公共 system prompt 的 KV Cache 跨请求复用TTFT 从 2.5s 降到 0.4s。但要判断你的场景是否有公共前缀。推理服务的优化没有银弹每个场景的最优配置不同。建立你的评测 pipeline改一个参数 → 跑 benchmark → 看吞吐和延迟的变化 → 决定是否采纳。数据说话面板不看。