第11章 推理服务治理

导入:当推理服务从 demo 走向生产

在 demo 阶段,推理服务很简单:一个 Docker 容器跑着 vLLM,你发个请求它返回结果,一切完美。但生产环境不是这样。

生产环境的推理服务要回答这些问题:高峰期请求暴涨 10 倍怎么办?VIP 客户和免费用户的请求混在一起怎么调度?模型更新上线时如何不影响正在使用的用户?一台 GPU 同时跑多个模型时如何隔离?

这些问题没有一个能在模型层面解决——它们需要的是服务治理。本章覆盖从请求调度到自动扩缩容、从多租户隔离到模型版本管理的完整治理体系。


11.1 动态批处理与请求调度

在上一章我们介绍了 Continuous Batching。这里我们关注的是:当请求到来时,如何决定它什么时候被送入 batch?

调度的核心矛盾

推理调度本质上是在做三个权衡:

graph TB
    subgraph "调度三难"
        A[延迟] --- B[吞吐]
        B --- C[公平性]
        C --- A
    end
    
    A --> "优先处理短请求"
    B --> "等满再发"
    C --> "FIFO 先来先服务"

  • 最低延迟:请求一来就处理 → 低吞吐(batch 可能只有 1 个请求)
  • 最大吞吐:等到 batch 满再处理 → 高延迟(等批凑齐的时间)
  • 公平性:先来先服务 → 长请求拖慢整体

vLLM 的调度策略

vLLM 内置了两种调度策略:

# vLLM 调度器配置
from vllm import LLM

# 策略 1: "fcfs"(默认)— 先来先服务
llm_fcfs = LLM(model="llama-3-8b", scheduler_policy="fcfs")

# 策略 2: "priority" — 按优先级调度
llm_priority = LLM(model="llama-3-8b", scheduler_policy="priority")

自定义调度:基于 SLA 的请求优先级

# 基于请求优先级的调度器(概念实现)
import asyncio
from dataclasses import dataclass, field
from time import time
import heapq

@dataclass(order=True)
class PrioritizedRequest:
    priority: int          # 优先级(数字越小越优先)
    timestamp: float       # 到达时间
    request_id: str = field(compare=False)
    payload: dict = field(compare=False)

class PriorityScheduler:
    def __init__(self, max_batch_size: int = 32):
        self.max_batch_size = max_batch_size
        self.queue: list[PrioritizedRequest] = []
        self.lock = asyncio.Lock()
    
    async def submit(self, request_id: str, payload: dict, tier: str = "standard"):
        priority_map = {"premium": 0, "standard": 1, "batch": 2}
        req = PrioritizedRequest(
            priority=priority_map.get(tier, 1),
            timestamp=time(),
            request_id=request_id,
            payload=payload,
        )
        async with self.lock:
            heapq.heappush(self.queue, req)
    
    async def next_batch(self) -> list[PrioritizedRequest]:
        async with self.lock:
            batch = []
            while self.queue and len(batch) < self.max_batch_size:
                batch.append(heapq.heappop(self.queue))
            return batch
Tip

实用经验:在生产中,不要让请求无限排队。设置一个 max_queue_delay(如 5 秒),超时直接返回 503。这比让用户等 30 秒然后 OOM 好得多——用户端可以做重试。


11.2 自动扩缩容

推理服务的流量通常有明显的波峰波谷:白天高峰,凌晨低谷。固定副本数要么浪费资源,要么高峰期不够用。自动扩缩容是必需的。

Kubernetes HPA(Horizontal Pod Autoscaler)

K8s 原生的 HPA 可以基于 CPU/GPU 利用率自动扩缩。

# HPA 配置:基于 GPU 利用率扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-llama-70b-hpa
  namespace: inference
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-llama-70b
  minReplicas: 2
  maxReplicas: 8
  metrics:
  - type: Resource
    resource:
      name: nvidia.com/gpu
      target:
        type: Utilization
        averageUtilization: 70    # GPU 利用率超过 70% 扩容
  - type: Pods
    pods:
      metric:
        name: vllm_num_requests_waiting   # 自定义指标:等待中的请求数
      target:
        type: AverageValue
        averageValue: "5"                  # 平均等待 > 5 个请求时扩容
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300      # 缩容冷却 5 分钟
      percentage: 50                       # 每次最多缩 50%

自定义指标扩缩容

HPA 基于 GPU 利用率有个问题:GPU 利用率 100% 不代表服务在饱和工作(可能在做有效计算,也可能在等待)。更准确的指标是请求等待队列深度P99 延迟

# 自定义指标导出器(Prometheus 格式)
from prometheus_client import Gauge, generate_latest
import http.server

# 定义自定义指标
QUEUE_DEPTH = Gauge('vllm_num_requests_waiting', 'Requests waiting in queue')
ACTIVE_REQUESTS = Gauge('vllm_num_requests_running', 'Requests currently generating')
GPU_MEM_USED = Gauge('gpu_memory_used_bytes', 'GPU memory used in bytes')

class MetricsHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/metrics':
            # 从 vLLM 的 /metrics 端点获取数据
            # 也可以从 GPU exporter 获取
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(generate_latest().encode())
    
    def log_message(self, format, *args):
        pass  # 静默日志

# 启动 metrics 服务器
server = http.server.HTTPServer(('0.0.0.0', 9100), MetricsHandler)
server.serve_forever()

KPA(Knative Pod Autoscaler)

如果你用 Knative,KPA 能基于并发请求数(而非 CPU 利用率)自动扩缩——这比 HPA 更适合推理场景。

# Knative Service 配置
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: vllm-llama-8b
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/class: kpa.autoscaling.knative.dev
        autoscaling.knative.dev/min-scale: "2"       # 最少 2 副本
        autoscaling.knative.dev/max-scale: "20"      # 最多 20 副本
        autoscaling.knative.dev/target: "10"         # 每副本目标并发 10
        autoscaling.knative.dev/scale-down-delay: "5m"  # 缩容延迟
    spec:
      containers:
      - image: vllm/vllm-openai:latest
        resources:
          limits:
            nvidia.com/gpu: 1
        args:
        - --model
        - meta-llama/Llama-3-8B-Instruct
        - --max-model-len
        - "4096"
Warning

扩容容易缩容难:扩容是新起 Pod,几乎不丢请求。缩容是杀 Pod,正在处理的请求可能中断。务必配置 terminationGracePeriodSeconds 足够长(如 300s),让推理实例优雅完成正在处理的请求。同时从负载均衡器中摘除即将终止的实例。


11.3 延迟敏感型与吞吐敏感型场景设计

不同的业务场景对延迟和吞吐的要求完全不同:

场景 延迟要求 吞吐要求 典型模型
实时对话 TTFT < 500ms 低-中 LLM (Chat)
代码补全 TTFT < 200ms LLM (Coder)
RAG 检索增强 TTFT < 1s LLM + Embedding
文档摘要 < 30s LLM
Embedding 生成 < 100ms/条 极高 Embedding Model
批量数据标注 无要求 极高 LLM

延迟敏感型:实时对话

graph LR
    A[用户发消息] --> B[网关]
    B --> C[推理实例\nstreaming 模式]
    C -->|SSE| D[逐 token 返回]
    D --> E[前端渲染]
    
    style A fill:#ff9
    style D fill:#9f9

关键优化: - 流式输出:首 token 时间(TTFT)比总时间更重要 - 小模型优先:用 8B 而不是 70B,除非质量差距不可接受 - Prefix Caching:system prompt 和对话历史缓存 - 低并发:每副本只接 5-10 个并发,保证延迟

吞吐敏感型:批量处理

# 高吞吐离线处理架构
import asyncio
import aiohttp

class BatchProcessor:
    """高吞吐批量处理器"""
    
    def __init__(self, endpoint: str, max_concurrent: int = 100):
        self.endpoint = endpoint
        self.semaphore = asyncio.Semaphore(max_concurrent)
    
    async def process_batch(self, items: list[dict]):
        """并发提交所有请求"""
        async with aiohttp.ClientSession() as session:
            tasks = [self._process_one(session, item) for item in items]
            return await asyncio.gather(*tasks)
    
    async def _process_one(self, session, item):
        async with self.semaphore:
            async with session.post(
                f"{self.endpoint}/v1/completions",
                json={
                    "model": "llama-3-8b",
                    "prompt": item["prompt"],
                    "max_tokens": 256,
                    "temperature": 0.0,    # 确定性输出
                },
                timeout=300,
            ) as resp:
                result = await resp.json()
                return {"id": item["id"], "output": result["choices"][0]["text"]}

# 处理 10 万条数据
processor = BatchProcessor(endpoint="http://vllm-8b:8000", max_concurrent=200)
results = await processor.process_batch(all_items)
Tip

分而治之:如果一个集群同时服务实时对话和批量处理,将它们部署到不同的命名空间/节点组。批量处理的请求可以随时被暂停和恢复,但实时对话不行。用 K8s 的 ResourceQuota 和 PodPriority 确保实时任务优先获得资源。


11.4 模型版本管理与 A/B 测试

模型不是静态的——你会不断更新 prompt、微调权重、切换模型版本。如何安全地管理这些变更?

模型注册中心

graph TB
    subgraph "模型生命周期"
        A[训练完成] --> B[模型注册中心]
        B --> C{审批}
        C -->|通过| D[Staging 环境]
        D --> E{A/B 测试}
        E -->|通过| F[Production]
        E -->|失败| G[回滚]
        F --> H[流量迁移]
    end

# 使用 MLflow 作为模型注册中心(简化示例)
import mlflow

# 注册新模型版本
mlflow.set_tracking_uri("http://mlflow:5000")

model_name = "llama-3-8b-instruct"
model_uri = "models:/llama-finetuned/v1"

# 注册新版本
model_version = mlflow.register_model(
    model_uri=model_uri,
    name=model_name,
)

# 将新版本移到 Staging
client = mlflow.tracking.MlflowClient()
client.transition_model_version_stage(
    name=model_name,
    version=model_version.version,
    stage="Staging",
)

# Production 流量迁移(Canary)
client.transition_model_version_stage(
    name=model_name,
    version=model_version.version,
    stage="Production",
    archive_existing_versions=False,  # 不立即归档旧版本
)

A/B 测试与 Canary 发布

# 使用 Istio 做 Canary 发布
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: llama-model-canary
spec:
  http:
  - match:
    - headers:
        x-model-version:
          exact: "v2"
    route:
    - destination:
        host: vllm-llama-8b-v2
        port:
          number: 8000
  - route:
    - destination:
        host: vllm-llama-8b-v1
        port:
          number: 8000
      weight: 90    # 90% 流量到 v1
    - destination:
        host: vllm-llama-8b-v2
        port:
          number: 8000
      weight: 10    # 10% 流量到 v2(Canary)
# 应用层 A/B 路由(更灵活)
import random

def route_request(request_data: dict) -> str:
    """根据用户 ID 做一致性 A/B 路由"""
    user_id = request_data.get("user_id", "")
    
    # 基于用户 ID hash 做一致性分配
    hash_val = hash(user_id) % 100
    
    if hash_val < 10:    # 10% 到新版本
        return "http://vllm-llama-8b-v2:8000"
    else:                 # 90% 到旧版本
        return "http://vllm-llama-8b-v1:8000"
Warning

A/B 测试的一致性:同一个用户应该始终看到同一个模型版本。不要用随机数路由——用用户 ID 的 hash。否则用户会感受到不同版本之间的不一致,体验极差。


11.5 GPU 共享与多租户隔离

GPU 很贵(H100 约 $30,000)。如果一个模型用一个 GPU,大部分时间 GPU 利用率不到 20%。GPU 共享是降低成本的关键。

三种共享模式

graph TB
    subgraph "时空分片 Time-Slicing"
        TS1[GPU] --> TS2[进程 A\n时间片 1]
        TS1 --> TS3[进程 B\n时间片 2]
    end
    
    subgraph "MIG Multi-Instance GPU"
        M1[GPU] --> M2[实例 1\n隔离的 SM + 内存]
        M1 --> M3[实例 2\n隔离的 SM + 内存]
        M1 --> M4[实例 3\n隔离的 SM + 内存]
    end
    
    subgraph "多模型同进程"
        MM1[推理框架] --> MM2[模型 A]
        MM1 --> MM3[模型 B]
        MM1 --> MM4[共享 KV Cache 池]
    end

NVIDIA MIG(硬件级隔离)

MIG(Multi-Instance GPU)是 H100/A100 的硬件功能,可以将一张 GPU 物理切分为多个隔离实例,每个实例有独立的 SM(流多处理器)和内存。

# 启用 MIG(需要在 GPU 节点上执行)
sudo nvidia-smi -i 0 -mig 1

# 切分为 7 个实例(每个 1g.10gb)
sudo nvidia-smi mig -cgi 19,19,19,19,19,19,19 -C

# 查看实例
sudo nvidia-smi mig -lgi

# K8s 中使用 MIG 设备
# Pod 配置申请 MIG 实例
# K8s Pod 使用 MIG 实例
apiVersion: v1
kind: Pod
metadata:
  name: vllm-small-model
spec:
  containers:
  - name: vllm
    image: vllm/vllm-openai:latest
    args:
    - --model
    - meta-llama/Llama-3-1B
    resources:
      limits:
        nvidia.com/mig-1g.10gb: 1    # 申请 1 个 MIG 实例(10GB)

时间分片(Time-Slicing)

MIG 只在 A100/H100 上支持。对于消费级 GPU(如 A10G、L4),可以用 NVIDIA 的时间分片驱动:

# K8s Device Plugin 配置时间分片
# nvidia-device-plugin 的 ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: nvidia-device-plugin-config
data:
  config.yaml: |
    version: v1
    sharing:
      timeSlicing:
        resources:
        - name: nvidia.com/gpu
          replicas: 4          # 1 张卡共享给 4 个 Pod
Warning

时间分片不是真隔离:时间分片是时分复用——多个进程轮流使用 GPU。如果一个进程突然来了一个大计算任务,其他进程的延迟会受影响。对于延迟敏感场景,不要用时间分片,用 MIG。

多租户公平性保障

当多个团队共享一个推理集群时,需要防止”吵闹的邻居”:

# K8s ResourceQuota 限制每个团队的资源
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-quota
  namespace: team-a
spec:
  hard:
    requests.nvidia.com/gpu: "4"        # 最多申请 4 张 GPU
    requests.memory: "200Gi"
    pods: "20"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-b-quota
  namespace: team-b
spec:
  hard:
    requests.nvidia.com/gpu: "2"
    requests.memory: "100Gi"
    pods: "10"
# PriorityClass 确保高优先级任务优先获得 GPU
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: inference-production
value: 1000
globalDefault: false
description: "Production inference workloads"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: inference-experiment
value: 100
globalDefault: false
description: "Experimental inference workloads, can be preempted"

小结

推理服务治理是从”能跑”到”能稳定服务”的关键一步。本章覆盖了五个治理维度:

  1. 请求调度:动态批处理在延迟和吞吐间找平衡,优先级调度保障 SLA 分层
  2. 自动扩缩容:HPA 基于 GPU 利用率,KPA 基于并发数,自定义指标更精准
  3. 场景适配:延迟敏感型用流式 + 低并发,吞吐敏感型用高并发 + 确定性输出
  4. 版本管理:模型注册中心 + A/B 测试 + Canary 发布,确保安全变更
  5. 多租户隔离:MIG 做硬件隔离,ResourceQuota + PriorityClass 做软隔离

治理的本质是在有限资源下,让不同的负载各得其所。这不是技术问题——这是一个系统工程问题。


延伸阅读

  • Kubernetes HPA 文档:https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
  • NVIDIA MIG 文档:https://docs.nvidia.com/datacenter/tesla/mig-user-guide/
  • “Serving LLMs at Scale: Lessons from Production” — Anyscale, 2024
  • Knative Autoscaling 文档:https://knative.dev/docs/serving/autoscaling/
  • “LLM Serving: A Survey of Techniques, Systems, and Challenges” — Zhang et al., 2024