AI 模型部署策略:从单机推理到弹性扩缩容,GPU 资源的成本最优解

AI1天前发布 beixibaobao
4 0 0

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: 120

3.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 方案更经济。

© 版权声明

相关文章