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.
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:
- El nodo tiene driver NVIDIA instalado (o lo instala el GPU Operator).
- Un DaemonSet, nvidia-device-plugin, registra las GPUs físicas como recursos
nvidia.com/gpuante kubelet. - 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.
- 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
readinessProbeconinitialDelaySeconds: 30yfailureThreshold: 3mata el pod antes de que arranque. Solución:startupProbecon threshold alto antes de que lalivenessProbeempiece 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.terminationGracePeriodSecondsdebe acomodarlo. - Rollouts hostiles. Un rolling update naïve (
maxUnavailable: 1) puede dejarte sin réplicas atendiendo si la nueva tarda en cargar. PonmaxSurge: 1, maxUnavailable: 0para 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:
/dev/shmen 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.--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.--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.- PVC
ReadOnlyManyideal. El modelo no cambia en runtime. Varios pods pueden montar el mismo PVC sin contención. - Ningún
livenessProbeque tarde menos que elterminationGracePeriodSeconds. 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étrica | Qué dice | Cuándo escalar |
|---|---|---|
vllm:num_requests_waiting | Peticiones encoladas sin entrar al batch. | Si pasa de 5–10 sostenidos. |
vllm:num_requests_running | Peticiones 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_seconds | Latencia del prefill (histograma). | Si p95 supera tu SLA. |
vllm:e2e_request_latency_seconds | Latencia 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:
- TTFT p50/p95 (time to first token) — lo que percibe el usuario al pulsar enviar.
- TPOT p50/p95 (time per output token) — la “velocidad” del streaming.
- Throughput agregado (tokens generados/segundo del cluster) — para capacity planning.
- 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: 5en 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
| Aspecto | A (1×4090) | B (5×H100 SXM) |
|---|---|---|
| Topología Pod | 1 pod, 1 GPU | 1 pod con 5 GPUs (o LWS de 5) |
| Modelo máximo BF16 | 8 B | 70 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 sesiones | 30–60 s | 60–180 s |
| Autoscaling útil | Réplicas en nodos pares | Nodos completos vía Cluster Autoscaler |
| Multi-tenancy razonable | Limitada: 4–8 sesiones | Holgada: 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:
| Criterio | vLLM | TensorRT-LLM | SGLang |
|---|---|---|---|
| Hardware soportado | NVIDIA, AMD ROCm, Intel Gaudi | NVIDIA exclusivamente | NVIDIA, AMD ROCm |
| Latencia pura (TTFT) | Buena | Mejor: kernels compilados al hardware exacto | Buena |
| Throughput agregado | Excelente | Excelente | Excelente (RadixAttention) |
| Despliegue | Trivial: imagen Docker + args | Complejo: build engine por modelo + por GPU | Moderado |
| API OpenAI-compatible | Nativa, completa | Sí, a través de Triton Inference Server | Sí |
| Soporte de modelos nuevos | Días tras release | Semanas (recompilar engine) | Días |
| Quantization | AWQ, GPTQ, FP8 cache | INT4/INT8/FP8 muy maduros | AWQ, FP8 |
| Multi-modal | Sí (Llava, Pixtral, Qwen-VL) | Sí | Excelente, prioritario |
| Function calling / tool use | Bueno | Limitado | Primera clase |
| Comunidad / cadencia release | Muy activa, semanal | Activa, NVIDIA-driven | Muy activa, académica |
| Licencia | Apache 2.0 | Apache 2.0 | Apache 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%.
Tensor parallel en GPUs sin NVLink
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
- Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023) — paper original de vLLM.
- Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI 2022) — paper que popularizó continuous batching.
- Documentación oficial de vLLM — operacional y bien mantenida.
- NVIDIA GPU Operator — instalación y troubleshooting de la capa GPU en Kubernetes.
- LeaderWorkerSet — primitivo para workloads coordinados como tensor parallel multi-pod.
- KEDA — autoscaling event-driven, idóneo para escalar por métricas de cola.
- TensorRT-LLM y SGLang — los dos comparables más serios.
- LMSYS Chatbot Arena — benchmarks periódicos comparando los tres motores.
- Artículo previo en este blog: KV cache: la memoria de trabajo que sostiene la inferencia LLM.