AI 模型部署策略:从单机推理到弹性扩缩容,GPU 资源的成本最优解
AI 模型部署策略:从单机推理到弹性扩缩容,GPU 资源的成本最优解
一、AI 部署的成本困局:GPU 很贵,闲置更贵
AI 模型部署的核心矛盾是 GPU 成本与流量波动。一张 A100-80G 的月租金约 2000~3000 美元,一个 70B 模型至少需要 2 张 A100。如果按峰值流量配置 GPU,低谷期的利用率可能只有 10%~20%;如果按平均流量配置,高峰期请求排队,延迟飙升。
更具体的场景:一个 AI 写作助手,工作日白天 QPS 约 30,夜间降至 2~3,周末约 15。如果固定部署 4 张 A100,月成本约 1 万美元,GPU 利用率平均 25%。如果能在低峰期缩容到 1 张 A100,高峰期扩容到 4 张,月成本可降至 4000 美元,降幅 60%。
但弹性扩缩容在 GPU 场景下面临独特挑战:模型加载耗时(70B 模型加载约 30~60 秒)、GPU 显存碎片化、冷启动延迟。这些问题在 CPU 部署中不存在,但在 GPU 部署中是核心工程问题。
二、部署架构演进:从单机到弹性集群
graph TB subgraph 部署架构演进 A[单机部署<br/>固定 GPU / 无扩缩容<br/>适合: 低流量 / 内部工具] --> B[多副本部署<br/>负载均衡 / 手动扩缩容<br/>适合: 稳定流量] B --> C[弹性部署<br/>自动扩缩容 / 模型预热<br/>适合: 波动流量] C --> D[Serverless 部署<br/>按请求计费 / 冷启动<br/>适合: 突发流量] end subgraph 弹性扩缩容核心组件 E[指标采集<br/>QPS / 队列深度 / GPU 利用率] --> F[扩缩决策<br/>HPA / 自定义调度器] F --> G[模型预热<br/>后台加载 / 就绪后接入流量] G --> H[流量切换<br/>优雅下线 / 连接排空] end style A fill:#e1f5fe style C fill:#e8f5e9 style D fill:#fff3e0 style F fill:#f3e5f5关键设计决策:
- 模型预热:新 Pod 启动后先加载模型,加载完成才标记为 Ready 接收流量,避免冷启动请求超时。
- 优雅下线:缩容时先从负载均衡摘除,等待现有请求完成后再终止,避免请求中断。
- 扩缩指标:不能只看 CPU 利用率(GPU 场景下 CPU 不是瓶颈),应基于推理队列深度或 QPS。
三、生产级代码:基于 Kubernetes 的弹性部署方案
3.1 推理服务实现(带健康检查与优雅关闭)
# server.py - 基于 vLLM 的推理服务 import os import signal import time import threading from http.server import HTTPServer, BaseHTTPRequestHandler import json import uvicorn from fastapi import FastAPI from pydantic import BaseModel # 全局状态:控制优雅关闭 _shutdown_requested = False _active_requests = 0 _request_lock = threading.Lock() class GenerateRequest(BaseModel): prompt: str max_tokens: int = 256 temperature: float = 0.7 class InferenceServer: """推理服务,封装模型加载、推理和健康检查""" def __init__(self, model_name: str, gpu_memory_utilization: float = 0.9): self.model_name = model_name self.gpu_util = gpu_memory_utilization self._model = None self._ready = False def load_model(self): """加载模型(耗时操作,启动时执行一次)""" from vllm import LLM self._model = LLM( model=self.model_name, gpu_memory_utilization=self.gpu_util, max_model_len=4096, ) self._ready = True def generate(self, request: GenerateRequest) -> dict: """执行推理""" from vllm import SamplingParams params = SamplingParams( max_tokens=request.max_tokens, temperature=request.temperature, ) outputs = self._model.generate([request.prompt], params) return { "text": outputs[0].outputs[0].text, "tokens": len(outputs[0].outputs[0].token_ids), } @property def is_ready(self) -> bool: return self._ready # FastAPI 应用 app = FastAPI() server = InferenceServer( model_name=os.getenv("MODEL_NAME", "meta-llama/Llama-2-7b-chat-hf"), ) @app.on_event("startup") async def startup(): """启动时加载模型""" server.load_model() @app.post("/v1/generate") async def generate(request: GenerateRequest): """推理接口""" global _active_requests with _request_lock: _active_requests += 1 try: result = server.generate(request) return {"status": "ok", "data": result} finally: with _request_lock: _active_requests -= 1 @app.get("/health") async def health(): """存活检查:进程是否存活""" return {"status": "alive"} @app.get("/ready") async def ready(): """就绪检查:模型是否加载完成""" if server.is_ready: return {"status": "ready"} return {"status": "loading"}, 503 @app.get("/metrics") async def metrics(): """自定义指标(供 Prometheus 采集)""" return { "active_requests": _active_requests, "model_ready": server.is_ready, } def handle_shutdown(signum, frame): """信号处理:优雅关闭""" global _shutdown_requested _shutdown_requested = True # 等待活跃请求完成(最多 30 秒) deadline = time.time() + 30 while _active_requests > 0 and time.time() < deadline: time.sleep(0.5) signal.signal(signal.SIGTERM, handle_shutdown)3.2 Kubernetes 部署配置
# deployment.yaml - 推理服务 Deployment apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference labels: app: llm-inference spec: replicas: 2 selector: matchLabels: app: llm-inference # 滚动更新策略:先启动新 Pod,再终止旧 Pod strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 每次多启动 1 个 Pod maxUnavailable: 0 # 不允许不可用 template: metadata: labels: app: llm-inference spec: terminationGracePeriodSeconds: 60 # 优雅关闭等待时间 containers: - name: inference image: llm-inference:latest ports: - containerPort: 8000 resources: limits: nvidia.com/gpu: 1 # 每个 Pod 1 张 GPU requests: nvidia.com/gpu: 1 env: - name: MODEL_NAME value: "meta-llama/Llama-2-7b-chat-hf" # 就绪探针:模型加载完成后才接收流量 readinessProbe: httpGet: path: /ready port: 8000 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 30 # 最多等待 150 秒 # 存活探针:进程挂掉自动重启 livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 10 # 生命周期钩子:优雅排空连接 lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"] --- # hpa.yaml - 自定义指标扩缩容 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: llm-inference-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-inference minReplicas: 1 maxReplicas: 8 metrics: # 基于 CPU 利用率(辅助指标) - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 # 基于自定义指标:推理队列深度 - type: Pods pods: metric: name: inference_queue_depth target: type: AverageValue averageValue: "5" behavior: scaleUp: stabilizationWindowSeconds: 30 policies: - type: Pods value: 2 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300 # 缩容保守:等 5 分钟 policies: - type: Pods value: 1 periodSeconds: 1203.3 成本计算模型
class GPUCostCalculator: """GPU 部署成本计算器""" def __init__( self, gpu_hourly_cost: float, # 单张 GPU 小时成本 gpus_per_replica: int, # 每副本 GPU 数 model_load_time_sec: float, # 模型加载耗时 ): self.gpu_hourly_cost = gpu_hourly_cost self.gpus_per_replica = gpus_per_replica self.model_load_time_sec = model_load_time_sec def monthly_cost( self, replicas_by_hour: list[int], # 24 小时的副本数分布 ) -> dict: """计算月度成本""" total_gpu_hours = sum(replicas_by_hour) * self.gpus_per_replica * 30 gpu_cost = total_gpu_hours * self.gpu_hourly_cost # 扩缩容的额外成本:模型加载期间的 GPU 闲置 scale_events = 0 for i in range(1, 24): if replicas_by_hour[i] > replicas_by_hour[i - 1]: scale_events += replicas_by_hour[i] - replicas_by_hour[i - 1] load_cost = ( scale_events * self.gpus_per_replica * (self.model_load_time_sec / 3600) * self.gpu_hourly_cost * 30 ) return { "gpu_cost": round(gpu_cost, 2), "load_overhead": round(load_cost, 2), "total": round(gpu_cost + load_cost, 2), "avg_utilization": self._calc_utilization(replicas_by_hour), } @staticmethod def _calc_utilization(replicas_by_hour: list[int]) -> float: """估算平均利用率""" max_replicas = max(replicas_by_hour) if max_replicas == 0: return 0.0 # 假设流量与副本数成正比 avg_replicas = sum(replicas_by_hour) / len(replicas_by_hour) return avg_replicas / max_replicas # 使用示例:A100 部署 70B 模型 calc = GPUCostCalculator( gpu_hourly_cost=3.5, # A100 约 $3.5/h gpus_per_replica=2, model_load_time_sec=45, ) # 固定 4 副本 fixed = calc.monthly_cost([4] * 24) # 弹性:白天 4 副本,夜间 1 副本 elastic = calc.monthly_cost( [1,1,1,1,1,1, 2,3,4,4,4,4, 4,4,4,4,4,4, 3,2,1,1,1,1] ) # 结果:弹性方案月成本约降 55%四、部署策略的权衡与边界
4.1 弹性扩缩容的冷启动问题
模型加载耗时 3060 秒,意味着从扩容决策到新 Pod 接收流量至少需要 1 分钟。对于突发流量,这 1 分钟的延迟可能导致请求超时。缓解方案:预热的备用 Pod(始终保持 1 个额外 Pod 在加载状态),或使用模型权重缓存(如 Redis/FUSE 缓存模型文件,将加载时间降至 510 秒)。
4.2 GPU 碎片化
Kubernetes 默认调度器不感知 GPU 拓扑。如果集群中有 4 张 GPU 分布在 2 个节点上,申请 2 GPU 的 Pod 可能被调度到不同节点,导致跨节点通信开销。解决方案:使用 GPU 拓扑调度器(如 NVIDIA 的 GPU 时间切片或 MIG),或确保多 GPU Pod 调度到同一节点。
4.3 多模型共存
不同业务线使用不同模型时,如何在同一集群中共存?方案一:每个模型独立 Deployment,按需扩缩容。方案二:多模型共享 GPU(通过 MIG 或时间切片),适合小模型。方案一隔离性好但成本高,方案二成本低但隔离性差。
4.4 适用与禁用场景
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 内部工具 / 低流量 | 单机部署 | 成本最低 |
| 稳定业务流量 | 多副本固定部署 | 简单可靠 |
| 波动流量 | HPA 弹性扩缩容 | 成本最优 |
| 突发流量 | Serverless(如 RunPod) | 按需付费 |
| 多模型共存 | 独立 Deployment + 共享集群 | 隔离 + 资源复用 |
五、总结
AI 模型部署的核心矛盾是 GPU 成本与流量波动。弹性扩缩容是降低成本的关键手段,但需要解决冷启动和 GPU 碎片化问题。Kubernetes HPA 配合自定义指标(推理队列深度)比 CPU 利用率更适合 GPU 场景。模型预热(就绪探针)和优雅关闭(preStop 钩子)确保扩缩容期间请求不中断。成本计算应包含模型加载的额外开销,而非只看 GPU 运行时间。对于低流量场景,单机部署或 Serverless 方案更经济。
