引言:LLM推理的“性能与成本”困局

部署大语言模型(LLM)推理服务时,我们常常面临一个经典的“跷跷板”困境:用户流量忽高忽低,如果长期保持大量GPU副本在线,成本高昂;但如果副本太少,突发流量一来,服务立马超时甚至崩溃。静态配置的扩缩容策略,在LLM场景下显得格外笨拙。

为什么Kubernetes自带的基于CPU或内存的HPA(Horizontal Pod Autoscaler)不奏效?原因很简单:LLM推理是GPU密集型任务。你可能CPU利用率才30%,GPU显存已经爆满;内存还有余量,新的推理请求已经排起长队。指标选错了,扩容就永远慢半拍。

将vLLM推理引擎与Kubernetes HPA深度结合,配合自定义指标(如队列等待数、KV Cache利用率),是当前生产级LLM服务的主流解法。其底层逻辑可以概括为:单实例极致性能(内功)+ 集群级精准弹性(外功)。本文将带您从原理到实战,拆解这套方案的落地路径。

一、vLLM的“内功”:PagedAttention与Continuous Batching

在谈弹性之前,必须先理解vLLM为何能扛住高并发。答案藏在它的两项核心创新中。

1.1 PagedAttention:把显存利用率从40%拉到90%+

传统推理框架为每个请求预分配一整块连续显存来存储KV Cache,无论它最终输出多少token。这导致两个问题:一是内部碎片严重,二是显存利用率常常低于40%。

vLLM借鉴操作系统虚拟内存分页思想,将KV Cache拆成固定大小的“页”(Block),按需分配、非连续存储。每个请求维护一张Block Table(逻辑页→物理页映射表),CUDA kernel在计算attention时通过查表动态拼接数据。

# 模拟PagedAttention核心数据结构(简化版)
class PagedAttentionManager:
    def __init__(self, num_blocks=1024, block_size=16):
        # 物理显存池:预先分配的总块数 × 每块可存token数
        self.physical_pool = [None] * num_blocks  # 实际场景中是连续显存
        self.free_blocks = list(range(num_blocks))
        # 每个请求的页表:{request_id: [物理块ID列表]}
        self.block_tables = {}
    
    def allocate_pages(self, request_id, num_pages):
        """为新请求分配物理页"""
        if len(self.free_blocks) < num_pages:
            raise RuntimeError("显存不足,触发扩容信号!")
        allocated = self.free_blocks[:num_pages]
        self.free_blocks = self.free_blocks[num_pages:]
        self.block_tables[request_id] = allocated
        return allocated
    
    def get_kv_cache(self, request_id):
        """根据页表拼接KV Cache"""
        physical_ids = self.block_tables.get(request_id, [])
        # 实际vLLM中,这里会调用CUDA kernel聚合并计算attention
        return physical_ids

这套机制将显存利用率从不足40%提升到90%以上,单卡支持的并发请求数翻了数倍。显存利用率更高,意味着同样硬件下单个Pod能扛更多并发,HPA的扩容阈值可以设得更从容。

1.2 Continuous Batching:让GPU不再“等慢车”

传统静态批处理(Static Batching)有个致命缺陷:一批请求必须全部完成后才能释放资源。假如9个短请求配1个长请求,其他9个早已跑完,GPU只能干等着那个“拖油瓶”。

vLLM的Continuous Batching打破了这种僵局——它在每个Step(迭代)粒度上动态重组Batch。某个请求生成完了,立刻从队列里塞一个新请求进来继续算,GPU几乎没有空转时间。实测吞吐可达Hugging Face Transformers的10倍以上。

这两项能力的意义:单实例性能越强,HPA扩缩容的“基线”就越稳定。扩容不会过于频繁,缩容也更有底气。

二、弹性“外功”:Kubernetes HPA与自定义指标

单实例再强,面对流量洪峰终究有限。外部弹性靠Kubernetes HPA实现,但关键问题始终是指标选什么

2.1 为什么CPU/内存指标不靠谱?

用CPU或内存利用率触发扩缩容,在AI推理场景下相当危险。CPU可能才30%,GPU已经满载;内存还有余量,显存早就溢出了。基于错误指标的扩缩容,要么扩得太慢导致服务超时,要么缩得太狠引发雪崩。

2.2 vLLM暴露的关键指标

vLLM会暴露一系列精准的业务指标,可通过Prometheus抓取,再经Prometheus Adapter转换为Kubernetes自定义指标,供HPA使用。

以下是最常用的几个指标:

指标名 含义 适用场景
vllm:num_requests_waiting 队列中等待的请求数 吞吐优先,队列堆积时扩容
vllm:num_requests_running 当前正在处理的请求数 直接负载指标
vllm:gpu_cache_usage_perc KV Cache利用率(0-1) 延迟敏感型,提前扩容

关于指标选择,有两条经验法则:

  • 吞吐优先:使用num_requests_waiting。当队列开始堆积时扩容,让单实例充分消化后再加副本。
  • 延迟敏感:使用gpu_cache_usage_perc。KV Cache接近满载意味着新请求可能排队,需提前扩容。

HPA扩缩容的计算公式为:

期望Pod数 = ceil[当前Pod数 × (当前指标值 / 目标指标值)]

例如,当前每Pod平均有10个请求在等待,目标设为5,则HPA会扩容至ceil(1 × 10/5)=2个Pod。

2.3 将vLLM指标暴露给Prometheus

在K8s集群中,先通过PodMonitor配置让Prometheus抓取vLLM的/metrics端点:

apiVersion: monitoring.googleapis.com/v1
kind: PodMonitoring
metadata:
  name: vllm-pod-monitoring
spec:
  selector:
    matchLabels:
      app: vllm-tpu
  endpoints:
  - path: /metrics
    port: 8000
    interval: 15s

然后配置Prometheus Adapter,将vLLM指标注册为Kubernetes自定义指标:

rules:
  # 将 vllm:num_requests_waiting 转为自定义指标
  - seriesQuery: '{__name__=~"^vllm:num_requests_waiting$"}'
    resources:
      overrides:
        namespace:
          resource: "namespace"
    name:
      matches: ""
      as: "vllm_num_requests_waiting"
    metricsQuery: sum by(namespace) (vllm:num_requests_waiting)

2.4 实战HPA配置:基于等待队列数扩容

以下是一个推荐的HPA配置示例,使用num_requests_waiting作为主要扩缩依据:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
  namespace: llm-services
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-deployment
  minReplicas: 2
  maxReplicas: 20
  metrics:
  # 主指标:平均每个Pod等待队列长度
  - type: Pods
    pods:
      metric:
        name: vllm_num_requests_waiting
      target:
        type: AverageValue
        averageValue: 5   # 当每个Pod平均等待>5个请求时扩容
  # 辅助指标:KV Cache利用率(作为二次保险)
  - type: Pods
    pods:
      metric:
        name: vllm_gpu_cache_usage_perc
      target:
        type: AverageValue
        averageValue: 70  # 百分比
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
      - type: Percent
        value: 100
        periodSeconds: 30   # 每30秒最多翻倍
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 10
        periodSeconds: 120  # 保守缩容,防止震荡

关键参数解读

  • 扩容稳定窗口(60秒):快速响应负载增加。
  • 缩容稳定窗口(300秒):避免处理长请求时副本被过早缩减。
  • 扩容策略:每30秒最多扩容100%,快速拉起副本应对洪峰。
  • 缩容策略:每120秒最多缩容10%,非常保守,防止反复震荡。

三、高级弹性方案:KEDA + 外部指标

标准的HPA有一个局限:它主要依赖Pod级别的指标(如Pods类型)。但有时我们需要基于聚合后的全局指标来扩容,比如整个服务所有Pod的等待请求总数

这时KEDA(Kubernetes Event-driven Autoscaling)就派上了用场。KEDA扩展了HPA的能力,支持从Prometheus等外部数据源拉取指标,并执行更灵活的扩缩规则。

3.1 KEDA配置示例

以下是一个KEDA ScaledObject配置,基于聚合的num_requests_waiting总和进行扩容:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-keda-scaler
  namespace: llm-services
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-deployment
  minReplicaCount: 2
  maxReplicaCount: 20
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-operated.monitoring.svc:9090
      metricName: vllm_waiting_total
      query: sum(vllm:num_requests_waiting{namespace="llm-services"})
      threshold: '10'   # 所有Pod等待总数超过10则扩容
      activationThreshold: '3'  # 低于3则缩容

KEDA的优势

  • 支持从0扩容(适合serverless场景)。
  • 支持更丰富的触发源(Prometheus、Kafka、RabbitMQ等)。
  • 可以通过activationThreshold设置“死区”,避免小流量时频繁震荡。

四、冷启动优化:让扩容真正“快起来”

弹性扩缩最大的挑战是冷启动时间——新Pod从启动到模型加载完成,往往需要数十秒甚至数分钟。如果扩容信号发出了,但新Pod迟迟不能服务,扩容就形同虚设。

以下四招能显著缩短冷启动时间:

4.1 预构建镜像与模型量化

将vLLM依赖、Python环境、甚至模型权重预置在镜像中,避免每次拉取。同时使用GPTQ/AWQ量化将模型体积压缩30%-50%,加载速度提升3-5倍。

4.2 共享缓存卷(Cloud Storage FUSE / NFS)

将模型权重挂载为共享卷,多个Pod复用同一份缓存,避免每个Pod重复下载上百GB的模型文件。Google Cloud的GKE提供了GCSFuse CSI驱动,可将Cloud Storage桶挂载为PV,支持并行下载,极大加速模型加载。

volumes:
- name: gcs-fuse-csi-ephemeral
  csi:
    driver: gcsfuse.csi.storage.gke.io
    volumeAttributes:
      bucketName: my-model-bucket
      mountOptions: "file-cache:enable-parallel-downloads:true,file-cache:parallel-downloads-per-file:100"

4.3 就绪探针(Readiness Probe)

配置TCP探针,仅当模型完全加载后才将Pod纳入服务流量:

readinessProbe:
  tcpSocket:
    port: 8000
  initialDelaySeconds: 15
  periodSeconds: 10

4.4 HAMi:让单卡跑多个模型,减少扩容需求

如果GPU资源紧张,还可以考虑用HAMi(GPU虚拟化调度器)在单张GPU上切分显存运行多个模型。vLLM Production Stack已原生支持HAMi参数。配置示例:

requestGPU: 1
limitGPU: 1
requestGPUMem: "14000"   # 申请14GB显存
limitGPUMem: "14000"
podAnnotations:
  hami.io/gpu-scheduler-policy: "binpack"  # 尽量堆叠到同一张卡

这种方式可以通过提高单卡利用率来降低扩容的迫切性,间接优化弹性效率。

五、行业实践:Pinterest与Uber的验证

这套技术栈并非纸上谈兵,已在多家头部公司生产环境中得到验证。

Pinterest将Kubernetes + Ray + PyTorch + vLLM组合用于批量推理,实现了4.5倍吞吐量提升、30倍成本下降,每月运行约1800个推理作业。

Uber将其LLM训练与推理工作负载迁移至Kubernetes,基于此架构实现了2-3倍吞吐量提升,覆盖从7B到70B模型的微调与推理,批量推理规模达每月数千个作业。

总结

vLLM + PyTorch在K8s集群中实现高效弹性扩缩容,核心有三层:

  1. 底层(内功):依赖PagedAttention和Continuous Batching提升单实例性能上限,让每个Pod能扛更多并发。
  2. 中层(决策):通过HPA + 自定义指标(num_requests_waitinggpu_cache_usage_perc)或KEDA外部指标,实现精准的负载感知扩缩容,避免基于CPU/内存的“瞎扩”。
  3. 上层(提速):通过预构建镜像、模型量化、共享缓存卷、就绪探针等手段优化冷启动时间,让扩容真正“快起来”。

最终达到的效果是:单机扛得住、集群扩得快、成本压得低——这正是生产级LLM服务架构的理想状态。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐