vLLM en Kubernetes: la pieza de inferencia LLM que sí escala

TL;DR

vLLM es el motor de inferencia que convierte una GPU de propósito general en un servidor LLM productivo. Su valor no está en correr un modelo —eso lo hace cualquier transformers.pipeline con tres líneas de Python— sino en exprimir la GPU hasta el último gigabyte y el último ciclo: PagedAttention para el KV cache, continuous batching para mezclar peticiones, scheduler propio para repartir tiempo de GPU entre sesiones. Kubernetes es su hábitat natural porque vLLM se comporta como un proceso UNIX moderno —tiene endpoint de health, métricas Prometheus, draining ordenado, recursos declarables— y K8s ya sabe cómo gestionarlos. Pero hay trampas: el HPA estándar no escala vLLM bien, el modelo tarda minutos en cargar, y los rolling updates ingenuos cortan sesiones a medio decodificar. Este artículo desmonta el motor y luego lo encaja, con manifests reales, en un cluster que sí pueda servirlo.

Este artículo es la continuación natural de KV cache: la memoria de trabajo que sostiene la inferencia LLM. Allí explicamos por qué cada token consume VRAM. Aquí vemos qué se hace con esa VRAM cuando la quieres ofrecer como servicio.

La analogía: kernel multiproceso para tu GPU

Imagina que tienes un único procesador y necesitas servir cien procesos concurrentes sin que ninguno bloquee a los demás. Nadie en su sano juicio escribiría un bucle while-true que despacha procesos uno a uno: instalaría un sistema operativo. El kernel se encarga del scheduling, de la paginación de memoria, del aislamiento, de las prioridades, de la limpieza al terminar. El “proceso” se convierte en una abstracción cómoda y el kernel hace el trabajo sucio.

vLLM es, para tu GPU, lo que el kernel es para tu CPU. Frente a la GPU, una conversación con un LLM es un proceso que vive durante muchos pasos de decodificación, ocupa una porción de VRAM (su KV cache) y demanda tiempo de cómputo cada vez que toca generar un token. Tienes cien de esos procesos a la vez. Necesitas:

  • Repartir tiempo de GPU entre ellos sin pausarlos enteros (sería desastroso si una conversación larga monopoliza la GPU).
  • Gestionar la memoria con paginación porque, igual que en RAM, reservar contiguo es ineficiente.
  • Encolar peticiones nuevas cuando la GPU está saturada y servirlas en orden razonable.
  • Recuperar recursos cuando una sesión termina.

PagedAttention es la memoria virtual del KV cache. Continuous batching es el scheduler con time-slicing que reparte la GPU token a token. El servidor OpenAI-compatible es la interfaz de syscalls uniforme. Llamarlo “kernel” para la GPU es marketing, pero es marketing que captura bien la idea.

Qué hace vLLM por dentro

Continuous batching: dejar de esperar al más lento

El motor de inferencia naïve hace static batching: agrupa N peticiones, las procesa hasta que todas terminan, devuelve y empieza otra ronda. El problema es obvio: si una petición pide 8 tokens y otra pide 800, las otras siete esperan a la lenta. La utilización de GPU se cae a plomo.

Continuous batching (Yu et al., 2022, popularizado por vLLM) cambia el modelo. En cada paso de decode —que produce un token para cada sesión activa— el motor compone el batch con los tokens activos de TODAS las sesiones que estén vivas en ese instante. Cuando una sesión termina su generación, libera su slot inmediatamente y otra petición de la cola lo ocupa. El batch nunca se queda esperando a la sesión más lenta porque nadie está bloqueado: todos avanzan al ritmo de un token por paso.

El paper original midió 5–23× más throughput que el static batching equivalente. El número exacto depende de la variabilidad de la longitud de las respuestas, pero el orden de magnitud se mantiene en la práctica.

Static batchingContinuous batchingsesión 1sesión 2sesión 3sesión 4slots vacíos esperan a la sesión 2slots se reasignan token a tokentiempo →

La consecuencia para el operador es contraintuitiva: una sola réplica vLLM rinde como tres réplicas naïve. No tiene sentido añadir pods sin justificarlo con métricas reales.

PagedAttention: la memoria virtual del KV cache

Ya lo dejamos apuntado en el artículo del KV cache: el motor naïve reserva un bloque contiguo por sesión, dimensionado al peor caso (max_context_len), y desperdicia el 60–80% de la VRAM porque las sesiones reales no llegan ni de lejos a su techo.

PagedAttention pide prestada la solución que los sistemas operativos llevan medio siglo usando: dividir la VRAM en bloques pequeños (16 tokens en la implementación por defecto) y mantener una tabla de páginas lógicas → físicas por sesión. Una sesión que tiene 273 tokens de contexto ocupa 18 bloques (no necesariamente contiguos), y crece de bloque en bloque conforme genera. El paper midió <4% de desperdicio —un orden de magnitud mejor que la asignación contigua— y eso se traduce en 2–4× más throughput agregado en el mismo hardware, porque caben más sesiones a la vez.

Hay un coste: cada operación de atención debe indirectarse por la tabla de páginas. Pero los kernels CUDA de vLLM están escritos para que esa indirección sea barata, y el resultado neto es masivamente positivo.

Prefill vs decode: dos fases con perfiles opuestos

Una petición LLM tiene dos fases con perfiles de GPU radicalmente distintos:

  • Prefill: procesa el prompt entero de golpe. Es compute-bound: usa los tensor cores intensamente, la GPU está al 90%+, dura entre cientos de ms y unos pocos segundos según el tamaño del prompt.
  • Decode: genera token a token. Es memory-bound: el cómputo es modesto pero hay que leer el KV cache entero por cada token, dura desde unas decenas de ms por token hasta minutos para respuestas largas.

Un servidor naïve trata cada petición como una unidad y sirve las dos fases en serie. vLLM las desacopla: mezcla peticiones en prefill con peticiones en decode en el mismo paso (técnica llamada chunked prefill cuando además trocea prefills largos). Resultado: la GPU está siempre ocupada haciendo algo —los tensor cores con prefills, el ancho de banda HBM con decodes— en lugar de oscilar entre fases.

Implicación operativa: la métrica “% utilización GPU” del nvidia-smi engaña. Una GPU al 100% haciendo prefills puede tener su HBM bandwidth ocioso. Una GPU al 40% haciendo decodes puede tener el HBM saturado. Para LLM serving, la métrica útil es el ancho de banda HBM efectivo, no el porcentaje de cómputo.

Tensor parallel: cuando el modelo no cabe en una GPU

Llama 3 70B en BF16 son ~140 GB. No hay una sola GPU en el mercado que lo aguante. La solución es tensor parallel: dividir cada capa del modelo por columnas y ejecutar las particiones en N GPUs en paralelo, sincronizando con un all-reduce tras cada capa.

Para N=5 GPUs y un modelo de 70B, cada GPU ve aproximadamente 28 GB de pesos. Suena bien hasta que recuerdas que el all-reduce de cada capa significa leer y escribir tensores grandes entre GPUs. Si las GPUs comparten NVLink/NVSwitch (300–900 GB/s), el all-reduce es barato. Si comparten solo PCIe (~32 GB/s gen4 x16), el all-reduce se come la mitad del tiempo y el throughput se hunde.

Implicación para K8s, que viene a continuación: el scheduler tiene que garantizar que las N GPUs estén físicamente cerca. Esto se traduce en NodeAffinity al producto correcto (NVIDIA-H100-80GB-HBM3), pod único con nvidia.com/gpu: N (no N pods compartiendo) y, si hace falta multi-nodo, InfiniBand con NCCL como transporte.

El servidor OpenAI-compatible

Por encima de todo lo anterior, vLLM expone un servidor HTTP con endpoints idénticos a los de OpenAI: /v1/chat/completions, /v1/completions, /v1/embeddings, /v1/models. Soporta streaming Server-Sent Events. Soporta tool calling. Soporta logprobs.

El valor de esto es enorme y se subestima: cualquier cliente que use la SDK de OpenAI funciona sin cambios. Tu aplicación apunta a https://vllm.tu-cluster.local/v1 en vez de a https://api.openai.com/v1, y todo lo demás —los SDKs de LangChain, LlamaIndex, OpenAI Python, OpenAI JS— funciona. Es la razón principal por la que vLLM ha ganado tracción sobre alternativas técnicamente comparables: es la opción aburrida que funciona.

Por qué Kubernetes es el hábitat natural

vLLM es un proceso bien comportado: arranca, expone métricas, atiende un endpoint de health, recibe SIGTERM con dignidad, declara los recursos que necesita. Kubernetes lleva diez años perfeccionando la gestión de procesos así. Lo único que K8s ha tardado en absorber bien es la GPU, y eso ya está resuelto.

GPU como recurso primitivo

El plumbing es el siguiente:

  1. El nodo tiene driver NVIDIA instalado (o lo instala el GPU Operator).
  2. Un DaemonSet, nvidia-device-plugin, registra las GPUs físicas como recursos nvidia.com/gpu ante kubelet.
  3. El scheduler de Kubernetes ve esos recursos como ve CPU y memoria, los pone en su contabilidad y los asigna a Pods que los piden.
  4. El nvidia-container-toolkit se asegura de que containerd inyecte los devices correctos en el contenedor al arrancar.

Para el pod, pedir una GPU es esto:

resources:
  requests:
    nvidia.com/gpu: 1
  limits:
    nvidia.com/gpu: 1

Sin MIG ni MPS ni time-slicing configurados, una GPU no se comparte entre pods: la pides entera o no la pides. Para vLLM —que quiere toda la GPU para sí— esto es lo deseable.

El ciclo de vida del Pod vLLM

Diferencias con un Pod de webapp típico:

  • Startup largo. Cargar 16 GB de pesos en VRAM por encima de la red tarda 30 segundos en el mejor caso y 5 minutos en el peor. Una readinessProbe con initialDelaySeconds: 30 y failureThreshold: 3 mata el pod antes de que arranque. Solución: startupProbe con threshold alto antes de que la livenessProbe empiece a evaluar.
  • Warm-up útil. El primer prefill compila kernels CUDA específicos del shape de entrada. Las primeras 2–3 peticiones son sensiblemente más lentas. Si la latencia importa desde el segundo 1, conviene disparar un POST de warm-up tras el ready.
  • Draining no instantáneo. SIGTERM no debe matar las sesiones en curso. vLLM, configurado con --disable-graceful-shutdown false (default), termina las peticiones activas antes de cerrar. Esto puede tardar 30–180 segundos. terminationGracePeriodSeconds debe acomodarlo.
  • Rollouts hostiles. Un rolling update naïve (maxUnavailable: 1) puede dejarte sin réplicas atendiendo si la nueva tarda en cargar. Pon maxSurge: 1, maxUnavailable: 0 para que el pod nuevo esté Ready antes de drenar el viejo.

Anatomía de un despliegue en serio

Antes que nada: GPU Operator

Sin GPU Operator (o instalación manual equivalente), un Pod con nvidia.com/gpu: 1 se queda Pending para siempre. Lo que el operator instala como DaemonSets en cada nodo con GPU:

  • nvidia-driver-daemonset — el driver kernel-mode (si no lo tienes instalado al nivel del host).
  • nvidia-device-plugin-daemonset — registra las GPUs como recurso de kubelet.
  • nvidia-container-toolkit-daemonset — la integración con containerd.
  • nvidia-dcgm-exporter — métricas Prometheus de la GPU (utilización, temperatura, ECC errors, memoria).
  • gpu-feature-discovery — labels del nodo: nvidia.com/gpu.product, nvidia.com/gpu.memory, etc., imprescindibles para NodeAffinity.

La instalación recomendada es el chart Helm oficial. La parte sensible es alinear el driver con la versión del kernel del host: si los nodos llevan kernel 6.x, el operator necesita un branch de driver compatible.

Deployment vLLM completo y comentado

Lo siguiente despliega Llama 3 8B con KV cache cuantizado FP8, hasta 32K de contexto, en una RTX 4090. Es el manifest de referencia; los comentarios explican las decisiones no obvias.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama3-8b
  namespace: inference
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0        # nunca quedarse sin réplicas durante el rollout
  selector:
    matchLabels:
      app: vllm-llama3-8b
  template:
    metadata:
      labels:
        app: vllm-llama3-8b
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8000"
        prometheus.io/path: "/metrics"
    spec:
      # Solo nodos con la GPU que esperamos
      nodeSelector:
        nvidia.com/gpu.product: NVIDIA-GeForce-RTX-4090
      tolerations:
      - key: nvidia.com/gpu
        operator: Exists
      # Predescargar pesos si no están en el PVC compartido
      initContainers:
      - name: model-download
        image: ghcr.io/huggingface/huggingface-cli:latest
        command: ["sh", "-c"]
        args:
        - |
          if [ ! -f /models/llama-3-8b/config.json ]; then
            huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
              --local-dir /models/llama-3-8b --local-dir-use-symlinks False
          fi          
        env:
        - name: HF_TOKEN
          valueFrom:
            secretKeyRef:
              name: huggingface
              key: token
        volumeMounts:
        - name: models
          mountPath: /models
      containers:
      - name: vllm
        image: vllm/vllm-openai:v0.6.3
        args:
        - --model=/models/llama-3-8b
        - --served-model-name=llama-3-8b
        - --tensor-parallel-size=1
        - --max-model-len=32768
        - --kv-cache-dtype=fp8
        - --enable-chunked-prefill
        - --enable-prefix-caching
        - --gpu-memory-utilization=0.92
        - --port=8000
        ports:
        - name: http
          containerPort: 8000
        - name: metrics
          containerPort: 8000     # mismo puerto que http; /metrics
        resources:
          requests:
            cpu: "4"
            memory: 8Gi
            nvidia.com/gpu: 1
          limits:
            cpu: "8"
            memory: 16Gi
            nvidia.com/gpu: 1
        startupProbe:
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 10
          failureThreshold: 60     # 10 min de gracia para cargar el modelo
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 20
          failureThreshold: 3
        volumeMounts:
        - name: models
          mountPath: /models
          readOnly: true           # ningún proceso debe escribir aquí en runtime
        - name: shm
          mountPath: /dev/shm      # vLLM usa shared memory para IPC entre workers
      volumes:
      - name: models
        persistentVolumeClaim:
          claimName: model-cache
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: 4Gi
      terminationGracePeriodSeconds: 120   # acomoda drenaje de sesiones activas
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-llama3-8b
  namespace: inference
spec:
  selector:
    app: vllm-llama3-8b
  ports:
  - name: http
    port: 80
    targetPort: 8000

Cinco cosas que no se ven en primera lectura:

  1. /dev/shm en memoria, 4 GB. vLLM lanza procesos worker (uno por GPU en tensor parallel, además del driver) que se comunican por shared memory. El default de Docker (64 MB) revienta en cuanto el modelo es mediano. Sin esto, el pod arranca pero falla en cuanto sirve la primera petición compleja.
  2. --enable-prefix-caching. Si los prompts de tu carga comparten estructura (system prompt común, few-shot examples), vLLM reutiliza el KV cache de la parte común. Ganancia gratis del 30–60% en TTFT.
  3. --gpu-memory-utilization=0.92. vLLM reserva el % indicado de la VRAM para sí. El 8% restante deja margen para activations, kernels CUDA, y el overhead que no se cuenta. Bajarlo da seguridad; subirlo más de 0.95 invita al OOM.
  4. PVC ReadOnlyMany ideal. El modelo no cambia en runtime. Varios pods pueden montar el mismo PVC sin contención.
  5. Ningún livenessProbe que tarde menos que el terminationGracePeriodSeconds. Si un drain tarda 90s y la liveness mata a los 60s, los rollouts pierden sesiones.

Tensor parallel multi-pod: LeaderWorkerSet

Cuando el modelo necesita más GPUs de las que tiene un solo nodo, el patrón es un grupo de pods coordinados, uno por GPU, que se comportan como una única réplica. Esto se modeló durante años con StatefulSet más init scripts; desde Kubernetes 1.32, el primitivo idiomático es LeaderWorkerSet (LWS):

apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: vllm-llama3-70b
  namespace: inference
spec:
  replicas: 1
  leaderWorkerTemplate:
    size: 5                       # 1 leader + 4 workers = 5 pods, 5 GPUs
    restartPolicy: RecreateGroupOnPodRestart
    leaderTemplate:
      spec:
        nodeSelector:
          nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3
        containers:
        - name: vllm-leader
          image: vllm/vllm-openai:v0.6.3
          args:
          - --model=/models/llama-3-70b
          - --tensor-parallel-size=5
          - --distributed-executor-backend=ray
          # ...
    workerTemplate:
      spec:
        nodeSelector:
          nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3
        containers:
        - name: vllm-worker
          image: vllm/vllm-openai:v0.6.3
          # los workers se unen al cluster Ray del leader

LWS garantiza el orden de arranque (workers primero, leader después) y el ciclo de vida atómico (si un worker cae, se reinicia el grupo entero, no un solo pod). Sin esto, la coordinación es manualmente frágil.

Una alternativa más sencilla, si todas las GPUs del tensor parallel caben en un solo nodo (caso de los HGX H100 con 8 GPUs y NVSwitch interno): un único Pod con nvidia.com/gpu: 5, --tensor-parallel-size=5, y vLLM se encarga de todo internamente. Sin Ray, sin LWS, mucho más simple. Es el camino recomendado cuando se puede.

Autoscaling: HPA estándar no sirve

El HPA por CPU% es inútil para vLLM. La GPU hace el trabajo; la CPU del pod está al 5–10% incluso al máximo de carga. Tampoco sirve el porcentaje de utilización de la GPU del dcgm-exporter: un pod al 100% de GPU% con gpu_cache_usage_perc=15% está atendiendo una sesión larga sin saturar, mientras que un pod al 60% de GPU% con gpu_cache_usage_perc=95% está al borde de la expulsión de sesiones.

Las métricas correctas las exporta el propio vLLM en /metrics (formato Prometheus):

MétricaQué diceCuándo escalar
vllm:num_requests_waitingPeticiones encoladas sin entrar al batch.Si pasa de 5–10 sostenidos.
vllm:num_requests_runningPeticiones activas en el batch.Para capacity planning, no para escalar.
vllm:gpu_cache_usage_perc% del KV cache ocupado.Si >80% sostenido, hay riesgo de preemption.
vllm:time_to_first_token_secondsLatencia del prefill (histograma).Si p95 supera tu SLA.
vllm:e2e_request_latency_secondsLatencia total por petición.Métrica de salida.

Para que el HPA las consuma, dos caminos: Prometheus Adapter (expone métricas custom al API de K8s) o KEDA (escala por queries Prometheus directamente, mucho más cómodo). Con KEDA:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-scaler
  namespace: inference
spec:
  scaleTargetRef:
    name: vllm-llama3-8b
  minReplicaCount: 1
  maxReplicaCount: 8
  pollingInterval: 10
  cooldownPeriod: 120              # 2 min antes de scale-down (sesiones largas)
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus.monitoring:9090
      threshold: '5'
      query: |
        sum(vllm:num_requests_waiting{app="vllm-llama3-8b"})        

El cooldownPeriod largo es importante: si bajas réplicas mientras hay sesiones decodificando, las matas. Mejor 2 minutos de holgura.

Observabilidad: las cuatro métricas que importan

De todo lo que /metrics exporta, un dashboard mínimo necesita estas cuatro:

  1. TTFT p50/p95 (time to first token) — lo que percibe el usuario al pulsar enviar.
  2. TPOT p50/p95 (time per output token) — la “velocidad” del streaming.
  3. Throughput agregado (tokens generados/segundo del cluster) — para capacity planning.
  4. Queue depth (vllm:num_requests_waiting) — el indicador adelantado: si crece, todo se va a degradar.

A esto se le suma utilización HBM y memoria libre por GPU (de dcgm-exporter) para detectar saturación de bandwidth y problemas de fragmentación. Un dashboard Grafana decente con esas 6 gráficas adelanta el 90% de los incidentes.

Dos escenarios concretos

Reutilizamos los mismos hardwares del artículo anterior para tener continuidad. Mismas matemáticas de cache, ahora con el motor montado.

Escenario A — 1×RTX 4090 (workstation o nodo K8s pequeño)

  • Topología: 1 Pod, --tensor-parallel-size=1, 1 GPU, 1 nodo.
  • Modelo: hasta 8B BF16 (Llama 3 8B, Qwen3 8B, Mistral 7B) o hasta 14B en FP8/AWQ.
  • PVC: SSD local del nodo. La 4090 lee 1 TB/s de HBM; un SSD NVMe a 5 GB/s tarda 5 segundos en alimentar 25 GB de pesos a VRAM, despreciable frente a la inicialización.
  • HPA: irrelevante dentro de la 4090 (siempre 1 réplica de vLLM por GPU), pero útil entre nodos: 3 réplicas en 3 nodos con 4090 cada uno, el Service de K8s reparte round-robin.
  • Concurrencia útil: 4–8 sesiones simultáneas con 8K de contexto, 1–2 con 32K.
  • Caso de uso natural: PoC, equipos pequeños, ambientes departamentales, edge.

El manifest de arriba está dimensionado para este escenario. Cambiando solo el modelo y los args, el mismo Deployment sirve Qwen, Mistral o el que toque.

Escenario B — 5×H100 SXM (cluster con NVLink/NVSwitch)

  • Topología: 1 Pod con nvidia.com/gpu: 5 en un nodo HGX, --tensor-parallel-size=5. Si la plataforma no permite agrupar 5 GPUs en un solo Pod, LeaderWorkerSet con 5 pods coordinados por Ray.
  • Modelo: hasta 70B BF16 (Llama 3 70B) o hasta 200B+ en FP8 con cuantización del cache.
  • PVC: NVMe directamente atado al nodo, o storage en red rápido (Ceph con red 25/100 GbE, Lustre, GPFS). Cargar 140 GB de pesos por una red lenta tarda 5 minutos por arranque.
  • HPA: irrelevante dentro del cluster de 5 GPUs (las 5 son una unidad indivisible), pero útil añadiendo más nodos HGX completos cuando la carga pasa de cierto umbral. Esto se combina con Cluster Autoscaler si la infraestructura subyacente lo permite.
  • Concurrencia útil: 32–128 sesiones simultáneas con contextos medianos, 4–16 con contextos enormes.
  • Caso de uso natural: servicio interno corporativo, exposición pública con SLA, multi-tenant.

A y B, lado a lado

AspectoA (1×4090)B (5×H100 SXM)
Topología Pod1 pod, 1 GPU1 pod con 5 GPUs (o LWS de 5)
Modelo máximo BF168 B70 B
TTFT @ 8K contexto, idle~250 ms~80 ms
TPOT, idle~30 ms/tok~15 ms/tok
Throughput @ concurrencia 16~50 tok/s/sesión~200 tok/s/sesión
Drain de sesiones30–60 s60–180 s
Autoscaling útilRéplicas en nodos paresNodos completos vía Cluster Autoscaler
Multi-tenancy razonableLimitada: 4–8 sesionesHolgada: 32–128 sesiones
Coste indicativo (hardware)~2 K €~250 K € (≈ 125×)

La asimetría sigue siendo la del artículo anterior: 125× más caro, sólo ~4× más throughput por sesión y ~10× más concurrencia. Lo que el cluster compra no es proporcional; compra acceso a modelos un orden de magnitud más grandes y latencias suficientemente bajas para uso interactivo a escala. Si tu carga es batch o agentes asincrónicos donde la latencia no es crítica, varias 4090s rinden sorprendentemente cerca.

vLLM frente a TensorRT-LLM y SGLang

Honestamente, los tres son buenos motores. La elección depende de criterios prácticos, no técnicos. Mapa de decisión, no benchmark:

CriteriovLLMTensorRT-LLMSGLang
Hardware soportadoNVIDIA, AMD ROCm, Intel GaudiNVIDIA exclusivamenteNVIDIA, AMD ROCm
Latencia pura (TTFT)BuenaMejor: kernels compilados al hardware exactoBuena
Throughput agregadoExcelenteExcelenteExcelente (RadixAttention)
DespliegueTrivial: imagen Docker + argsComplejo: build engine por modelo + por GPUModerado
API OpenAI-compatibleNativa, completaSí, a través de Triton Inference Server
Soporte de modelos nuevosDías tras releaseSemanas (recompilar engine)Días
QuantizationAWQ, GPTQ, FP8 cacheINT4/INT8/FP8 muy madurosAWQ, FP8
Multi-modalSí (Llava, Pixtral, Qwen-VL)Excelente, prioritario
Function calling / tool useBuenoLimitadoPrimera clase
Comunidad / cadencia releaseMuy activa, semanalActiva, NVIDIA-drivenMuy activa, académica
LicenciaApache 2.0Apache 2.0Apache 2.0

Cuándo elegir cada uno:

  • vLLM: el “boring choice” que funciona. Camino con menos fricción para llegar a producción. Si tu equipo no tiene un especialista dedicado al inference serving, esto. Soporta hardware variado, modelos al día, API estable, comunidad enorme.

  • TensorRT-LLM: cuando la latencia por petición es la métrica única que importa y tu modelo es estable (entrenado in-house, no cambias cada quincena). El precio del rendimiento es que cada modelo + cada GPU + cada versión de TRT requiere rebuild del engine, y eso bloquea iteración rápida.

  • SGLang: para cargas dominadas por agentes (tool calling intensivo) o multi-modal complejo. Su RadixAttention —caching estructural de prompts con prefijos compartidos— brilla en patrones tipo ReAct donde el mismo system prompt se repite miles de veces.

Para la mayoría de equipos que están empezando con LLM serving on-prem, vLLM es la respuesta correcta hasta que tengas datos en producción que te empujen a otra cosa.

Trampas operativas frecuentes

Una lista de gotchas que se ven una y otra vez:

El modelo se descarga en cada rolling update

Síntoma: cada deploy tarda 5+ minutos en estar disponible. Causa: no hay PVC compartido. Cada pod nuevo descarga el modelo desde Hugging Face de cero. Remedio: PVC ReadOnlyMany sobre un storage rápido, o un mirror local del registry (un Pod con huggingface-cli que sirve un directorio por HTTP). En CI/CD, hidratar el PVC antes del rollout es 1 línea de bash.

readiness con timeout corto que mata pods cargando

Síntoma: pods nuevos entran en CrashLoopBackOff durante la primera carga del modelo. Causa: readinessProbe con timeout demasiado bajo dispara antes de que vLLM termine de cargar; livenessProbe lo remata. Remedio: startupProbe con failureThreshold: 60 o más (10 minutos de gracia) antes de que la liveness empiece a evaluar.

KV cache sin cuantizar y luego OOM

Síntoma: el pod arranca bien, atiende cinco minutos, OOMKilled cuando llega la sesión número cinco con contexto largo. Causa: KV cache en BF16 (default) consume el doble que en FP8. Remedio: --kv-cache-dtype=fp8. Pérdida de calidad despreciable en la inmensa mayoría de casos, capacidad duplicada.

Confundir réplicas con concurrencia

Síntoma: el HPA escala a 8 réplicas con poca carga real y la factura cloud sube. La latencia no mejora. Causa: alguien configuró targetAverageUtilization: 50% sobre CPU, pensando que es “carga”. Realidad: una sola réplica vLLM atiende decenas de sesiones simultáneas. Remedio: HPA sobre vllm:num_requests_waiting. Si la cola está vacía, una réplica basta aunque la GPU esté al 90%.

Síntoma: throughput 3× peor del esperado, GPUs al 30%, mucho tráfico PCIe. Causa: tensor_parallel=4 en 4 GPUs conectadas solo por PCIe; el all-reduce satura el bus en cada capa. Remedio: o las GPUs comparten NVLink/NVSwitch (modelos SXM/HGX), o pipeline parallel (peor latencia pero menos all-reduce), o reduces TP y aceptas que no cabe el modelo entero.

Sesiones cortadas en rolling update

Síntoma: usuarios ven respuestas truncadas durante el deploy. Causa: terminationGracePeriodSeconds: 30 (default) no llega para drenar generaciones largas. Remedio: terminationGracePeriodSeconds: 120–180. Combinado con maxUnavailable: 0, los rollouts son invisibles para los usuarios activos.

Lo que no hemos cubierto (próximos artículos)

  • vLLM con LoRA adapters en caliente: servir un base model + N adapters específicos por tenant sin recargar pesos.
  • Disaggregated serving: separar prefill y decode en pods especializados, cada uno optimizado para su perfil de GPU.
  • Quantization deep-dive: AWQ vs GPTQ vs FP8 dinámico vs FP4, trade-offs reales, cuándo cada uno.
  • Gateway API + AI Inference Extensions: la propuesta sigwg para que los LLMs sean ciudadanos de primera en K8s (routing por modelo, sticky session por conversación, fairness multi-tenant).
  • Multi-modal serving: el mismo runtime, otro tipo de peticiones —imágenes, audio, embeddings—.

Referencias