大模型推理服务部署:从 GPU 资源调度到弹性伸缩的工程实践

一、GPU 资源瓶颈与推理延迟:大模型部署的双重约束

将大语言模型从开发环境推向生产环境,面临两个硬性约束:GPU 资源稀缺和推理延迟不可控。

GPU 资源稀缺体现在两个方面。一是成本:一块 A100 80G GPU 的月租约 1.5 万元人民币,一个 70B 参数的模型需要 4-8 块 A100 才能以合理延迟运行,月度硬件成本就超过 10 万元。二是供应:在算力紧张时期,GPU 实例的等待时间可能长达数周,无法按需弹性扩容。

推理延迟不可控则源于大模型的计算特性。自回归生成需要逐 Token 解码,每个 Token 的生成都需要一次完整的前向传播。70B 模型在 A100 上单 Token 生成延迟约 30-50ms,生成 500 Token 的回答需要 15-25 秒。更关键的是,推理延迟与输入长度线性相关——长上下文的 Prefill 阶段计算量巨大,可能导致首个 Token 的响应时间(TTFT)超过 10 秒。

这两个约束的交叉点就是部署架构的核心挑战:如何在有限的 GPU 资源下,最大化推理吞吐量,同时满足延迟 SLA。

二、大模型推理服务的部署架构

大模型推理服务的部署架构需要解决三个核心问题:模型加载与内存管理、请求调度与批处理、弹性伸缩与故障恢复。

graph TD
    A[客户端请求] --> B[推理网关<br/>请求路由与限流]
    B --> C[请求队列<br/>动态批处理]
    C --> D[推理引擎<br/>vLLM/TensorRT-LLM]

    D --> E[模型实例 A<br/>GPU 0-1]
    D --> F[模型实例 B<br/>GPU 2-3]
    D --> G[模型实例 C<br/>CPU 卸载]

    E --> H[PagedAttention<br/>KV Cache 管理]
    F --> H
    G --> I[模型卸载<br/>CPU/GPU 内存交换]

    B --> J[弹性伸缩控制器]
    J -->|指标驱动| K[HPA: 请求队列深度]
    J -->|定时策略| L[Cron: 业务高峰预扩容]

    H --> M[流式响应<br/>SSE/WebSocket]
    I --> M

    style B fill:#f9f,stroke:#333
    style C fill:#bbf,stroke:#333
    style H fill:#bfb,stroke:#333

上图的关键设计在于"动态批处理"和"PagedAttention"。动态批处理将多个并发请求合并为一次前向传播,大幅提升 GPU 利用率。PagedAttention 是 vLLM 的核心创新,它借鉴操作系统的虚拟内存分页机制,将 KV Cache 分页管理,解决了传统推理引擎中 KV Cache 预分配导致的内存碎片问题,将 GPU 内存利用率从 20%-40% 提升到 90% 以上。

三、推理服务部署的生产级实现

3.1 推理网关与请求调度

/**
 * 推理网关:请求路由、限流与优先级调度。
 * 为什么推理服务需要专门的网关?
 * 因为大模型推理与普通 HTTP 请求有本质区别:
 * 1. 推理延迟高(秒级),需要流式响应
 * 2. GPU 资源有限,需要精确的并发控制
 * 3. 不同请求的优先级不同(付费用户优先)
 * 普通 API 网关无法满足这些需求。
 */
@Service
public class InferenceGateway {

    private final List<InferenceBackend> backends;
    private final RequestPriorityQueue requestQueue;
    private final TokenBucketRateLimiter rateLimiter;

    /**
     * 提交推理请求。
     * 为什么使用优先级队列而非 FIFO?
     * 因为不同用户的请求价值不同:
     * - 付费用户的请求应该优先处理
     * - 实时对话请求应该优先于批量处理请求
     * FIFO 队列无法区分优先级,
     * 可能导致付费用户等待免费用户的长请求完成。
     */
    public Flux<TokenChunk> submit(InferenceRequest request) {
        // 第一步:限流校验
        if (!rateLimiter.tryAcquire(request.getEstimatedTokens())) {
            return Flux.error(new RateLimitExceededException(
                "推理服务当前负载过高,请稍后重试"
            ));
        }

        // 第二步:选择推理后端
        InferenceBackend backend = selectBackend(request);

        // 第三步:提交请求并返回流式响应
        // 为什么返回 Flux 而非 Mono?
        // 因为大模型是逐 Token 生成的,
        // 流式返回可以让用户实时看到生成进度,
        // 而非等待全部生成完毕后一次性返回。
        return backend.generate(request)
            .onErrorResume(e -> {
                // 推理失败:尝试降级到备用后端
                if (backend != getFallbackBackend()) {
                    return getFallbackBackend().generate(request);
                }
                return Flux.error(e);
            });
    }

    /**
     * 选择推理后端:基于模型类型和当前负载。
     * 为什么需要多后端?
     * 因为不同模型的 GPU 需求不同:
     * - 小模型(7B)可以单卡运行
     * - 大模型(70B)需要多卡张量并行
     * - 不同模型可能需要不同的推理引擎
     * 单一后端无法高效满足所有需求。
     */
    private InferenceBackend selectBackend(InferenceRequest request) {
        return backends.stream()
            .filter(b -> b.supportsModel(request.getModel()))
            .filter(b -> b.getCurrentLoad() < b.getMaxLoad())
            .min(Comparator.comparingDouble(InferenceBackend::getCurrentLoad))
            .orElseThrow(() -> new ServiceUnavailableException(
                "所有推理后端已满载,请稍后重试"
            ));
    }
}

3.2 Kubernetes 弹性伸缩配置

# 推理服务的 Kubernetes 部署配置
# 为什么用 StatefulSet 而非 Deployment?
# 因为推理服务需要稳定的网络标识和有序的部署/终止:
# 1. 模型加载需要 2-5 分钟,有序终止确保正在处理的请求完成
# 2. GPU 绑定需要稳定的设备分配
# 3. 分布式推理(张量并行)需要实例间稳定的网络通信
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: llm-inference
spec:
  replicas: 2
  podManagementPolicy: Parallel  # 并行创建,加速扩容
  selector:
    matchLabels:
      app: llm-inference
  template:
    metadata:
      labels:
        app: llm-inference
    spec:
      # 优先调度到 GPU 节点
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              preference:
                matchExpressions:
                  - key: nvidia.com/gpu.product
                    operator: Exists
      containers:
        - name: inference-server
          image: llm-inference:v1.0
          ports:
            - containerPort: 8000
          resources:
            requests:
              nvidia.com/gpu: 2  # 请求 2 块 GPU
            limits:
              nvidia.com/gpu: 2  # 限制 2 块 GPU
          # 健康检查:推理服务就绪需要模型加载完成
          # 为什么用启动探针而非就绪探针?
          # 因为模型加载需要 2-5 分钟,
          # 就绪探针的默认失败阈值太小,
          # 会导致 Pod 在模型加载期间被重启
          startupProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 10
            failureThreshold: 30  # 最多等待 5 分钟
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 5
          lifecycle:
            # 优雅终止:等待当前请求处理完成
            # 为什么需要优雅终止?
            # 因为推理请求可能需要 10-30 秒完成,
            # 强制终止会导致用户收到不完整的回答
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 30"]
          env:
            - name: MODEL_NAME
              value: "Qwen2-72B-Instruct"
            - name: TENSOR_PARALLEL_SIZE
              value: "2"
            - name: GPU_MEMORY_UTILIZATION
              value: "0.90"  # GPU 内存利用率上限
            - name: MAX_NUM_SEQS
              value: "32"    # 最大并发序列数
            - name: MAX_MODEL_LEN
              value: "8192"  # 最大上下文长度
---
# HPA:基于自定义指标的弹性伸缩
# 为什么用自定义指标而非 CPU 利用率?
# 因为 GPU 推理的瓶颈不在 CPU,
# CPU 利用率无法反映 GPU 的真实负载。
# 自定义指标(请求队列深度)更准确地反映负载状态。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-inference-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: llm-inference
  minReplicas: 2
  maxReplicas: 8
  metrics:
    # 基于请求队列深度伸缩
    - type: Pods
      pods:
        metric:
          name: vllm_request_queue_depth
        target:
          type: AverageValue
          averageValue: "5"  # 平均队列深度超过 5 则扩容
    # 基于 GPU 利用率伸缩
    - type: Pods
      pods:
        metric:
          name: gpu_utilization_percent
        target:
          type: AverageValue
          averageValue: "80"  # GPU 利用率超过 80% 则扩容
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60  # 扩容稳定窗口 1 分钟
      policies:
        - type: Pods
          value: 2       # 每次最多扩 2 个 Pod
          periodSeconds: 120
    scaleDown:
      stabilizationWindowSeconds: 600  # 缩容稳定窗口 10 分钟
      # 为什么缩容窗口比扩容长?
      # 因为模型加载需要 2-5 分钟,
      # 过早缩容可能导致刚加载的模型被卸载,
      # 下次扩容又需要重新加载,浪费时间和资源
      policies:
        - type: Pods
          value: 1       # 每次最多缩 1 个 Pod
          periodSeconds: 300

3.3 模型预热与冷启动优化

/**
 * 模型预热服务:减少冷启动延迟。
 * 为什么需要预热?
 # 新启动的推理实例需要 2-5 分钟加载模型,
 # 在模型加载完成前,实例无法处理请求。
 # 如果等到流量到达时才启动新实例,
 # 用户会经历长时间的等待。
 # 预热策略是在流量高峰前提前启动实例,
 # 确保流量到达时实例已就绪。
 */
@Service
public class ModelWarmupService {

    private final InferenceBackend inferenceBackend;
    private final KubernetesClient k8sClient;

    /**
     * 定时预热:根据历史流量模式预扩容。
     * 为什么用定时预热而非纯响应式扩容?
     * 因为 HPA 的响应式扩容有延迟:
     * 1. 指标采集延迟(15-60秒)
     * 2. 扩容决策延迟(HPA 检查间隔)
     * 3. Pod 创建延迟(调度+拉取镜像)
     * 4. 模型加载延迟(2-5分钟)
     * 总延迟可能达到 5-8 分钟,
     * 对于可预测的流量高峰(如工作日早 9 点),
     * 提前预热比响应式扩容更可靠。
     */
    @Scheduled(cron = "0 50 8 * * MON-FRI")  // 工作日 8:50 预热
    public void warmupForMorningPeak() {
        int targetReplicas = calculateTargetReplicas("morning_peak");
        int currentReplicas = getCurrentReplicas();

        if (targetReplicas > currentReplicas) {
            // 扩容到目标副本数
            scaleTo(targetReplicas);

            // 等待新实例就绪后发送预热请求
            // 为什么发送预热请求?
            // 因为模型加载完成后,首次推理的延迟
            # 比后续推理高 2-3 倍(JIT 编译、缓存冷启动)。
            # 预热请求触发首次推理,消除冷启动延迟。
            waitForInstancesReady(targetReplicas);
            sendWarmupRequests();
        }
    }

    /**
     * 发送预热请求:触发模型推理的首次执行。
     * 为什么用简短请求而非真实业务请求?
     * 因为预热请求的目的是触发模型加载和缓存初始化,
     # 不需要生成有意义的输出。
     # 简短请求消耗最少的 Token,降低预热成本。
     */
    private void sendWarmupRequests() {
        InferenceRequest warmupRequest = InferenceRequest.builder()
            .model("Qwen2-72B-Instruct")
            .prompt("Hello")
            .maxTokens(1)       # 只生成 1 个 Token
            .temperature(0.0)
            .build();

        // 对每个新实例发送预热请求
        for (String instanceUrl : getNewInstanceUrls()) {
            try {
                inferenceBackend.generate(warmupRequest, instanceUrl)
                    .blockLast(Duration.ofSeconds(30));
            } catch (Exception e) {
                // 预热失败不影响实例上线
                // 首次真实请求会完成隐式预热
            }
        }
    }
}

四、推理服务部署的代价与适用边界

大模型推理服务的部署架构在带来性能提升的同时,也引入了显著的运维复杂度。

第一,GPU 资源的利用率困境。 GPU 是昂贵的专用资源,但推理服务的流量通常有明显的波峰波谷。在工作日白天,GPU 利用率可能达到 90%;在凌晨低谷期,利用率可能只有 10%。但 GPU 实例无法像 CPU 实例那样快速启停——模型加载需要数分钟,频繁缩容再扩容会浪费大量时间在模型加载上。解决方案是:低谷期不完全缩容到零,而是保留最小可用实例数,同时利用低谷期执行离线批处理任务(如文档向量化),提高 GPU 资源的综合利用率。

第二,多卡张量并行的通信开销。 70B 及以上模型需要多卡张量并行,每次前向传播都需要在 GPU 之间同步中间结果。NVLink 的带宽约 600GB/s,PCIe 4.0 的带宽约 64GB/s。如果 GPU 之间只有 PCIe 连接,通信开销可能占推理时间的 30%-50%。因此,多卡推理必须使用 NVLink 互联的 GPU 服务器,而非普通的 PCIe 服务器。

第三,弹性伸缩的滞后性。 从 HPA 触发扩容到新实例真正就绪,可能需要 5-8 分钟(包括 Pod 调度、镜像拉取、模型加载、预热)。在这段时间内,已有实例可能已经过载。解决方案是设置合理的限流阈值——当队列深度超过安全水位时,主动拒绝新请求,而非让所有请求都排队等待。

适用边界:自建推理服务适合日均调用量超过 10 万次、对数据隐私有严格要求(如金融、医疗)、或需要深度定制推理流程的场景。对于调用量较低或对隐私要求不高的场景,直接使用云厂商的托管推理服务(如 Azure OpenAI、AWS Bedrock)成本更低、运维更简单。

禁用场景:如果团队没有 GPU 运维经验,自建推理服务的故障排查成本极高。GPU 驱动兼容性问题、CUDA 版本冲突、NCCL 通信超时等问题的排查需要专门的硬件知识。在这种情况下,托管推理服务是更务实的选择。

五、总结

大模型推理服务的部署是一个系统工程,涉及 GPU 资源管理、请求调度、弹性伸缩、模型预热等多个环节。核心目标是在有限的 GPU 资源下,最大化推理吞吐量,同时满足延迟 SLA。

落地路线建议:第一步,选择合适的推理引擎(vLLM 或 TensorRT-LLM),在单机环境验证模型的推理性能和内存占用。第二步,构建推理网关,实现请求路由、限流和流式响应,这是推理服务对外提供 API 的基础。第三步,部署到 Kubernetes,配置基于自定义指标的 HPA,实现弹性伸缩。第四步,根据历史流量模式配置定时预热策略,减少流量高峰期的冷启动延迟。第五步,建设 GPU 利用率和推理延迟的监控看板,持续优化部署参数。

推理服务的部署没有"最优"配置,只有在成本、延迟和吞吐量三者之间找到适合业务需求的平衡点。关键是建立可观测性,用数据驱动优化决策,而非凭经验猜测。

Logo

免费领 200 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐