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