Anatomía de un stack de inferencia LLM on-premise: las siete capas que tienen que sostenerse las unas a las otras
TL;DR
El stack de inferencia LLM on-premise no es un servidor de modelos: es un edificio de siete capas que se sostienen las unas a las otras. La capa de inferencia (vLLM / SGLang) sirve tokens, pero sin la capa de embeddings dedicada el RAG no funciona; sin la capa de gateway el cliente acopla su SDK al motor concreto; sin la capa de observabilidad LLM-aware (Langfuse + OpenTelemetry GenAI) cualquier degradación de calidad pasa inadvertida; sin la capa de control plane GitOps (Flux + Forgejo) cualquier cambio manual deja deuda invisible; y sin la capa de dependency tracking (Hubble + intent-based policies) decomisionar un Service rompe en silencio aplicaciones que ya nadie recuerda que lo usaban. Este post nace de un incidente concreto: un pipeline que reportaba status: completed y matched_jobs: 0 durante días porque seguía invocando un Ollama que ya había sido escalado a cero, mientras un except mal escrito etiquetaba la ConnectError genérica como “ChromaDB indexing error” y el vector store —inocente— se llevaba la culpa. Cada uno de los tres síntomas era el grito de una capa que faltaba o estaba mal diseñada. El cuerpo del post recorre las siete capas con su pieza canónica OSS, las decisiones de diseño que las rompen, las matemáticas de dimensionado sobre un cluster genérico 4×H100 SXM (320 GB de VRAM, NVLink) y un diagrama final del stack conectado. La tesis: un stack que pasa el test del incidente no se mide por su throughput pico, se mide por cuánto tarda en gritar cuando algo se desvía del diseño.
Estás aquí: las siete capas vistas desde arriba
Antes de bajar al detalle, el mapa. Las siete capas no son siete servidores: son siete responsabilidades que el stack tiene que cubrir. Una capa puede colapsar en un pod (gateway) o repartirse entre varios componentes (observabilidad = traces + métricas + flow logs). Lo que no puede es faltar.
Las capas 1–4 están en el camino de la request: si caen, el cliente lo nota en segundos. Las capas 5–7 están en el camino del diseño y la operación: si caen, no hay error visible inmediato — y por eso son las que producen incidentes silenciosos, que es como acaba la mayoría de los incidentes serios. Este post argumenta que la calidad del stack se mide en las capas 5, 6 y 7, porque las 1–4 son commodities donde todo el mundo elige aproximadamente las mismas piezas.
La analogía: el edificio de oficinas con servicios compartidos
Imagina un edificio de oficinas de doce plantas con varios inquilinos. Tiene una portería (gateway: filtra quién entra), tiene ascensores (inferencia LLM: mueven la carga pesada), tiene escaleras y montacargas (embeddings: tráfico ligero pero constante, casi nadie repara en ellos hasta que se rompen), tiene fontanería y depósitos de agua (vector store: lo que de verdad guarda el estado), tiene cuadro eléctrico y sensores (observabilidad: lo que avisa cuando algo está consumiendo más de lo previsto), tiene un administrador de la finca (control plane GitOps: la única autoridad legítima para mover algo), y tiene un libro de inquilinos (dependency tracking: quién está conectado a qué servicio compartido).
Cuando entra un cliente nuevo en la planta tercera y pide instalar un servidor que necesita más amperaje del previsto, el problema no es eléctrico: es de administración. Si el cliente puede ir directo al cuadro y enchufar lo que quiera, el edificio sobrevive un tiempo y luego salta un magnetotérmico a las tres de la mañana. Si el cliente tiene que pasar por el administrador, el administrador consulta el libro de inquilinos (¿hay alguien más colgando de ese mismo circuito?), revisa la planificación eléctrica (¿estamos al límite?) y autoriza o redirige. El edificio se mantiene en pie no por su instalación eléctrica sino por la disciplina de paso por el administrador.
El stack de inferencia LLM funciona idéntico. Las capas físicas (1–4) son las que se ven y las que la gente del marketing pone en la slide. Las capas de gobierno (5–7) son las que distinguen una plataforma de un montón de pods con suerte.
Ahora vamos al incidente que motiva todo el post.
El anzuelo: el log que mentía durante seis días
La aplicación se llama internamente jobhunter. Es un pipeline cron que cada seis horas barre fuentes públicas de ofertas de empleo, filtra por geo UE, embedde los anuncios nuevos, los indexa en un vector store y los hace match contra perfiles de búsqueda. El último paso dispara notificaciones.
Durante seis días el pipeline reportó en cada run lo mismo:
status: completed
total_found: 756
new: 23
matched_jobs: 0 ← cero, run tras run
Y en los logs:
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/<id>/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/<id>/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
Es un log que invita a culpar a ChromaDB. Y de hecho el primer post-mortem que se escribió internamente apuntaba a una incompatibilidad de versiones del cliente Python contra el servidor v2. Hipótesis razonable, técnicamente plausible, completamente falsa.
La causa real: semanas atrás se había migrado el LLM general de Ollama a vLLM en la plataforma. La migración fue limpia para las dos aplicaciones que dependían directamente del modelo grande — sus manifests pasaron a apuntar al nuevo endpoint. Lo que nadie hizo fue mirar quién más estaba llamando al Service ollama.ollama.svc:11434. jobhunter lo invocaba para generar los embeddings de las ofertas. Cuando se escaló el deployment de Ollama a cero, el Service quedó vacío, y cualquier conexión saliente recibió un ConnectError("All connection attempts failed") genérico de httpcore. El try/except que envolvía toda la etapa de matching capturó la excepción y la rotuló como “ChromaDB indexing error” — porque ese era el envoltorio léxico del bloque, no porque ChromaDB tuviera nada que ver. ChromaDB respondía 200 a delete y a get en los mismos logs.
Tres factores hicieron que el incidente sobreviviera seis días:
- El
exceptnombraba el envoltorio en lugar del stage que falló. El log decía “ChromaDB indexing error” cuando el error era de embeddings contra un Service inexistente. - El pipeline retornaba
status: completedaunque hubiera errores. Ningún alerting basado enstatusse disparó. La métrica que sí habría disparado (matched_jobs sostenido en cero) no estaba instrumentada. - La imagen del pipeline rodaba como
:latestsin versionar, sin SBOM, sin reproducibilidad. Cuando empezó a fallar, era imposible saber con qué cliente de Ollama estaba compilada.
Cada uno de los tres síntomas es el grito de una capa que faltaba. El primero pide observabilidad LLM-aware que distinga stages (capa 5). El segundo pide que la lógica de pipeline y las métricas Prometheus se comporten como contratos (capa 5, dimensión SLI/SLO). El tercero pide GitOps con imágenes pinneadas y SBOM auditable (capa 6). Y el incidente entero —decomisionar un Service sin saber quién lo consume— grita dependency tracking (capa 7).
El resto del post recorre las siete capas con esa lente: qué resuelve cada una, qué pieza OSS la implementa en 2026, y qué decisión típica de diseño la rompe.
Capa 1 — Gateway: el SDK del cliente no debe acoplarse al motor
Lo que tiene que resolver. Que el cliente envíe POST /v1/chat/completions con el SDK OpenAI estándar y no se entere de qué motor (vLLM, SGLang, TensorRT-LLM), qué modelo concreto, qué adapter LoRA o incluso qué pool de GPUs está sirviendo la request. Autenticación, rate limit, routing por body.model y por tenant, header injection para tracing.
Pieza canónica OSS en 2026. Envoy AI Gateway (Envoy con extensiones GenAI: routing por modelo, token-based rate limit, fallback chains) o Cilium Gateway API con filtros propios. Para casos donde el cliente quiere multi-provider sin distinguir on-prem y SaaS, LiteLLM Proxy es el equivalente ligero.
Lo que rompe la capa. Exponer directamente el endpoint del motor de inferencia. Si los clientes llaman a http://vllm-prod.svc:8000, cualquier cambio de motor, de modelo o de pool obliga a tocar el código de todas las apps. La regla: el motor cambia, el contrato no. El SDK OpenAI es estándar; el routing detrás del gateway es donde vive la libertad de diseño.
Donde fallaba jobhunter. No había gateway. La app llamaba directamente a ollama.ollama.svc:11434. Cuando Ollama murió, no hubo capa intermedia que pudiera responder fallback, retry contra otro pool, o al menos error 503 con cuerpo descriptivo.
Capa 2 — Inferencia LLM: vLLM como elección por defecto, SGLang cuando el prefix caching manda
Lo que tiene que resolver. Servir tokens con throughput y latencia bajo control: continuous batching, PagedAttention para que el KV cache no fragmente la VRAM, FP8 para que un modelo de 32B quepa con margen, multi-LoRA para personalización por tenant sin replicar el base, structured output para function calling con garantía de schema.
Pieza canónica OSS en 2026. vLLM es la elección por defecto: cubre el estado del arte (PagedAttention, continuous batching, FlashAttention, quantization FP8, multi-LoRA, structured output, speculative decoding). SGLang entra cuando el workload tiene prefix caching alto (chat largo con system prompts grandes, agentes con instrucciones repetidas) — su RadixAttention compone mejor que el prefix caching estándar. Para inferencia muy especializada con kernels propietarios NVIDIA, TensorRT-LLM, asumiendo el lock-in de hardware.
Lo que rompe la capa. Asumir que un único modelo “lo hace todo”. Un LLM de chat de 32B no sirve /v1/embeddings — si lo intentas, vLLM responde BadRequestError: "The model does not support Embeddings API". Asumir que sí lo hacía fue una de las heridas concretas del incidente: la app esperaba un endpoint que el modelo no implementaba.
Decisión de diseño que cuesta más adelante. Servir el LLM con cuantización agresiva (INT4) sin un eval de calidad calibrado para tu corpus. INT4 con AWQ o GPTQ ahorra VRAM, pero degrada respuestas en castellano técnico o jurídico de forma medible. La regla: cualquier cambio de cuantización pasa por el mismo golden eval que un cambio de modelo.
Capa 3 — Embeddings: separados del LLM, dimensión fija, vida propia
Lo que tiene que resolver. Generar vectores densos para el corpus RAG y para las queries de retrieval. El modelo de embeddings es otra cosa que el LLM de chat: arquitectura distinta (encoder, no decoder), tamaño distinto (cientos de millones de parámetros, no decenas de miles), API distinta (un endpoint /embeddings que recibe texto y devuelve un vector de dimensión fija).
Pieza canónica OSS en 2026. Infinity o Hugging Face Text Embeddings Inference (TEI) para servir modelos de la familia bge-*, multilingual-e5-*, nomic-embed-* con throughput alto y soporte multi-modelo. OpenVINO Model Server cuando hay hardware Intel disponible. sentence-transformers como fallback embebido en la propia aplicación cuando el corpus es pequeño y el deployment es restringido.
Lo que rompe la capa. Tratarla como “el LLM también lo hace”. No lo hace si es chat-only; y aunque lo haga (algunos modelos tienen endpoint dual), mezclar serving de chat y de embeddings en el mismo proceso castiga el throughput de ambos. La separación física es el diseño.
Dato técnico que se olvida. El vector store y el modelo de embeddings forman una unidad indivisible. Cambiar el modelo de multilingual-e5-large (1024 dim) a multilingual-e5-small (384 dim) no es una sustitución: es crear una colección nueva (mi_corpus_v2) y reembebir todo el corpus. Si haces upsert sobre la colección antigua, te encuentras con un dim mismatch en runtime que tira el pod. Esto que parece obvio se viola constantemente porque el modelo de embeddings se elige una vez y se olvida.
Capa 4 — Vector store + datos relacionales + storage: lo que de verdad guarda el estado
Lo que tiene que resolver. Persistir los vectores con filtros eficientes (tenant_id, created_at, source), persistir los metadatos relacionales (usuarios, configs, prompts versionados, traces), y persistir los pesos del modelo, los adapters LoRA, los datasets y los corpus originales.
Piezas canónicas OSS en 2026.
- Qdrant para colecciones grandes (>200 k vectores), filtros payload-aware, multi-tenant via colección o via campo.
- pgvector para colecciones pequeñas con joins relacionales obligatorios (querer hacer
WHERE doc.author = ... AND vector <=> $1en la misma SQL). - PostgreSQL operado por CloudNativePG (CNPG) para los relacionales: backups Barman Cloud, replicación, conexión vía pooler.
- MinIO para objeto S3-compatible: bucket por tenant, replicación cross-site, pesos y adapters versionados por sha256.
- Redis para queues, rate-limit counters y cache de retrievals frecuentes.
Lo que rompe la capa. Asumir que el vector store es stateless ephemeral. Es justo lo contrario: es el componente donde más caro sale perder estado. Sin backups verificados del vector store, una corrupción de índice obliga a reembebir el corpus entero — y eso, en un corpus de millones de documentos, son horas o días de GPU.
Decisión de diseño que paga después. Olvidar versionar la colección por dimensión y modelo de embeddings. Convención sugerida: mi_corpus__embed-multie5l__1024d__v3. El nombre lleva metadata; cualquier cambio en cualquiera de los tres atributos fuerza colección nueva. Es feo pero protege contra el upsert accidental con dim incorrecta.
Capa 5 — Observabilidad: traces LLM-aware + métricas infra + flow logs
Lo que tiene que resolver. Que cualquier inferencia se pueda recuperar a partir de su trace_id, con todos los atributos gen_ai.* (semantic conventions) + atributos propios (tenant_id, adapter_id, priority_tier), latencia desglosada (queue → prefill → decode → red), tokens consumidos, tools invocados, modelo y adapter exactos. Y en paralelo: métricas Prometheus de vLLM (vllm:num_requests_running, vllm:gpu_cache_usage_perc, vllm:prefix_cache_hit_rate), de GPU (DCGM Exporter), de red (Hubble flow logs con drops y NetworkPolicy enforcement).
Piezas canónicas OSS en 2026.
- OpenTelemetry Collector como transporte único de traces, métricas y logs, con receivers OTLP y exporters separados por destino.
- Langfuse self-hosted para el lado LLM-aware: tracing, prompt versioning, evals con LLM-as-judge (post y post).
- VictoriaMetrics + Grafana para métricas TSDB de alto throughput y retención larga.
- Hubble (Cilium) para flow logs L3/L4/L7 y visualización de NetworkPolicy.
- DCGM Exporter para métricas GPU.
Lo que rompe la capa. Empaquetar todo el stage del pipeline en un único try/except que rotule la excepción con el nombre del envoltorio. La regla operativa: un try/except por stage, con el rótulo del stage en el mensaje y una métrica Prometheus con labels={"stage": "<name>"}. Así “ChromaDB indexing error” nunca habría sido el log para un fallo de embeddings; habría sido “Embeddings call failed: ConnectError(ollama:11434)”.
Regla complementaria. El pipeline devuelve status: completed si y solo si no hubo errores. Con errores devuelve completed_with_errors o failed, y la métrica pipeline_errors_total{stage} se incrementa. Un alert basado en increase(pipeline_errors_total[1h]) > 0 se dispara antes del segundo run fallido. Sin esta disciplina, la observabilidad existe pero no avisa.
Capa 6 — Control plane GitOps: la única autoridad legítima
Lo que tiene que resolver. Que el estado del cluster sea el estado declarado en git. Que cualquier divergencia entre git y el cluster sea visible y, en componentes críticos, auto-reconciliada o auto-alertada. Que cada imagen desplegada tenga un tag inmutable (sha digest o semver pin), un SBOM (Trivy) y trazabilidad hasta el commit que la introdujo.
Piezas canónicas OSS en 2026.
- Flux (o ArgoCD) como reconciliador.
- Forgejo (o Gitea, GitLab CE) como forge OSS auto-alojado.
- cert-manager + Trust Manager para PKI interna.
- External Secrets Operator + SOPS para secretos versionados encriptados.
- Kyverno (o OPA Gatekeeper) para policies vinculantes: deny de imágenes
:latest, deny de pods sin NetworkPolicy, deny de Services sin owner label. - Trivy para SBOM y vulnerability scanning de imágenes en el pipeline CI.
Lo que rompe la capa. Imágenes con tag mutable (:latest, :main). Cualquier kubectl edit en producción que no se refleja en git. Branches main con permisos de escritura para humanos sin revisión. La regla: si un humano puede mutar el cluster sin pasar por un commit firmado, no tienes GitOps, tienes una pizarra de Pepe.
Cómo se aplica al incidente. Si la imagen del pipeline hubiera estado pinneada a un sha digest (registry.interno.local/jobhunter@sha256:9af2...), el equipo habría podido auditar inmediatamente qué cliente de Ollama llevaba. Con :latest, ni eso.
Capa 7 — Dependency tracking: la capa que el incidente puso de manifiesto
Lo que tiene que resolver. Saber quién llama a qué Service, tanto en declarativo (qué dice el repo gitops) como en observado (qué se ha visto pasar por la red en los últimos N días). Y, en plataformas maduras, propagar esa información como policy: si nadie declara ni nadie observa tráfico al Service ollama.ollama.svc, decomisionarlo es seguro; si alguien lo declara o lo usa, decomisionarlo abre un ticket.
Piezas canónicas OSS en 2026.
- Hubble (Cilium) para los flow logs observados:
hubble observe --to-namespace ollama --since 14dda la lista de namespaces de origen que han hablado con Ollama en las últimas dos semanas. - Otterize para intent-based policy: cada Deployment declara “yo necesito hablar con
ollama-svc”, y el operator genera la NetworkPolicy correspondiente y mantiene un catálogo navegable de quién intenta hablar con qué. - kubectl-grep manual como fallback:
kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'saca la lista declarativa. - NetworkPolicy as code revisada en CI: cada PR que toca un Service requiere que la política asociada se mantenga o se actualice explícitamente.
Pre-decom checklist que el incidente sugiere codificar como hook en CI:
SVC="ollama.ollama.svc.cluster.local"
# (a) grep declarativo en el repo gitops
git -C $GITOPS_REPO grep -l "$SVC" || echo "OK declarativo"
# (b) grep observado en Hubble (últimos 14 días)
hubble observe --to-fqdn "$SVC" --since 336h --output json \
| jq -r '.source.namespace' | sort -u || echo "OK observado"
# (c) grep live en el cluster
kubectl get all -A -o yaml | grep -E "$SVC" || echo "OK live"
Si los tres devuelven vacío, el decom es seguro. Si cualquiera tiene contenido, hay deuda downstream sin cerrar. El incidente de jobhunter es exactamente lo que pasa cuando este check no existe: el equipo que decomisionó Ollama miró la lista de aplicaciones que sabía que dependían directamente; nadie miró la lista de las que dependían en silencio.
Las matemáticas que importan: dimensionar el stack sobre 4×H100 SXM (320 GB)
Cluster genérico de referencia para todo lo que sigue: 4×H100 SXM 80 GB, NVLink entre las cuatro, 640 GB de RAM de sistema, 2×NVMe NVMe-oF para storage local de pesos y caches, redundancia 25/100 GbE hacia el switch. Total VRAM agregada: 320 GB.
El presupuesto VRAM no es libre. Una primera regla de reparto razonable para un stack que sirve un LLM general grande, un modelo especializado en código mediano, embeddings y reranker, con margen para multi-LoRA y para el KV cache:
| Componente | Modelo de referencia | Quant | Peso del modelo | KV cache reservado | VRAM total |
|---|---|---|---|---|---|
| LLM general (TP=4) | 70B-instruct | FP8 W8A8 | 70 GB | 60 GB | 130 GB |
| LLM código (TP=2) | 32B-coder | FP8 W8A8 | 32 GB | 28 GB | 60 GB |
| Embeddings | multilingual-e5-large | FP16 | 1.3 GB | n/a | 8 GB (×2 réplicas) |
| Reranker | bge-reranker-v2-m3 | FP16 | 0.6 GB | n/a | 4 GB |
| Multi-LoRA pool (sobre LLM general) | hasta 16 adapters | bf16 | 16 × 0.4 GB ≈ 6 GB | reusa KV del LLM | 6 GB |
| Reservado para fragmentación + overhead | ~30 GB | ||||
| Total comprometido | ~238 GB / 320 GB | ||||
| Margen libre | ~82 GB |
El margen libre del 26% no es desperdicio: es lo que permite que el scheduler de vLLM no preempte requests bajo presión moderada, que el continuous batching pueda agrupar lotes grandes sin abortar, y que un fallover desde el otro site pueda promocionar un standby sin OOM.
Throughput esperado, con el LLM general de 70B en FP8 y tensor parallel 4, en una H100 SXM con continuous batching activo y prefix caching del 35–55% (típico en chat multi-turno con system prompts compartidos):
$$ \text{tokens/segundo agregado} \approx 1500 \text{ a } 2500 $$
para concurrencia entre 32 y 64 requests, con TTFT P95 sub-segundo en prompts cortos (<2k tokens) y TPOT P95 alrededor de 40–60 ms/token percibido por el cliente. Estos números son órdenes de magnitud razonables, no garantías: el throughput real depende del mix de prompts, de si el speculative decoding (EAGLE-3) está activo y burnt-in, y del coste de la red entre gateway y pods de inferencia.
Throughput de embeddings sobre dos réplicas de multilingual-e5-large con batch dinámico:
$$ \text{embeddings/segundo} \approx 3000 \text{ a } 6000 \quad (\text{batch óptimo} \sim 64) $$
Suficiente para reindexar un corpus de 1 millón de documentos en una hora aproximada, asumiendo chunks de 512 tokens y dos chunks por documento de media. Para corpus de decenas de millones de documentos, el reembebido se hace por delta vía CDC sobre la fuente (cubierto en RAG corpus curation), no por barrido.
Latencia de retrieval sobre Qdrant con HNSW (M=16, ef_construct=200) en una colección de 5 millones de vectores 1024-dim filtrada por tenant_id:
$$ \text{P95 latencia retrieve top-50} \approx 8 \text{ a } 25 \text{ ms} $$
Por debajo del coste del reranking cross-encoder (bge-reranker-v2-m3 sobre top-50 = ~30–60 ms más), y por debajo de cualquier llamada al LLM. El cuello de botella en un pipeline RAG bien dimensionado nunca es el vector store: es la decodificación del LLM.
Diagrama final: el stack completo conectado
Las líneas continuas son el camino de la request: cliente → gateway → motor de inferencia → vector store/embeddings → respuesta. Las líneas azules discontinuas son la telemetría: cada componente emite OTel al collector, que enruta traces a Langfuse, métricas a VictoriaMetrics y logs a Loki. Las líneas rojas discontinuas son la reconciliación: el control plane GitOps mantiene cualquier capa en su estado declarado y avisa de divergencia.
El diagrama no es decorativo: cada flecha es un contrato estable entre dos capas. Si una capa cambia (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), las flechas se mantienen. Esa estabilidad de contratos es la propiedad arquitectónica que hace que un equipo pueda migrar componentes sin romper apps downstream.
Decisiones de diseño típicas que rompen el stack
Lista corta de errores que se ven repetidamente en stacks que parecían bien diseñados sobre el papel:
1. Acoplar el SDK del cliente al motor de inferencia. Quitar el gateway porque “vLLM ya habla OpenAI-compatible” funciona el día uno y duele el día que hay que poner un fallback, un canary o un segundo modelo.
2. Compartir el endpoint LLM y embeddings. Un qwen2.5-32b-Instruct es chat-only; BadRequestError: "The model does not support Embeddings API" es el grito del diseño que confundió las dos capas.
3. Reusar la colección del vector store al cambiar el modelo de embeddings. Dimensiones distintas no admiten upsert. Versionar la colección por (modelo, dim, version) es feo pero salva el día del cambio.
4. try/except que envuelve un pipeline entero con un rótulo del envoltorio. El log miente porque el rótulo es léxico, no causal. Cada stage en su try/except con su rótulo y su métrica.
5. status: completed con errores. El pipeline tiene que distinguir completed, completed_with_errors y failed, y el alerting tiene que disparar en los dos últimos. Sin esto, la observabilidad existe en teoría y no avisa en la práctica.
6. Imágenes con tag mutable. :latest y :main no son tags, son alias. Sin sha digest, no hay reproducibilidad ni SBOM auditable.
7. Decomisionar un Service sin pre-decom check. El check de tres greps (declarativo + observado + live) tarda dos minutos y cuesta seis días de incidente cuando se salta.
8. Limits.memory por defecto en pods que cargan modelos. Un sidecar que carga sentence-transformers + torch + tokenizer necesita 2–4 GB; con limits.memory: 1Gi te encuentras con OOM en el primer pod restart, y a veces sin alert si el liveness probe responde por otra ruta.
Todas son variantes del mismo principio: el stack no falla en su capa más cara (la inferencia, donde nadie subestima el coste), falla en las capas baratas y aburridas (gateway, observabilidad, GitOps, dependency tracking) donde es tentador ahorrar.
Aplicado a hardware on-premise típico: cluster 4×H100 SXM
Sobre el cluster genérico de referencia (4×H100 SXM 80 GB, NVLink, 640 GB RAM), el reparto en pods sugerido:
nodo-gpu-01 (4×H100 SXM, NVLink intra-nodo)
├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
└── (comparte GPUs con multi-LoRA pool sobre el mismo deployment)
nodo-gpu-02 (4×H100 SXM, segundo nodo)
├── vllm-llm-codigo (TP=2) ~60 GB VRAM (2 GPUs)
├── infinity-embeddings (×2) ~16 GB VRAM (compartido en 1 GPU con MIG opcional)
├── tei-reranker ~4 GB VRAM (cohabitante)
└── reserva fallover ~120 GB VRAM libre para canary / standby
En un único nodo no cabe todo cómodamente; dos nodos con dos H100 SXM cada uno bastarían para el setup conservador, y el resto del cluster (CPU-bound: gateway, vector store, observabilidad, control plane) corre en nodos sin GPU.
La regla operativa: la inferencia se concentra, el resto del stack se distribuye. Concentrar la inferencia maximiza el aprovechamiento de NVLink (tensor parallel cross-GPU sin pasar por PCIe); distribuir el resto evita que un evento en el nodo GPU se lleve por delante el control plane.
Una configuración aún más conservadora —para PYMEs con un solo nodo 4×H100 SXM como punto de partida— sirve LLM general (TP=4) y embeddings/reranker en cohabitación con MIG (Multi-Instance GPU para particionar una H100 en slices aisladas hardware). El LLM de código se difiere a una segunda fase. Es viable y consciente del coste; lo que no es viable es prescindir de las capas 5, 6 y 7.
Lo que no hemos cubierto (próximos posts)
Este post se centra en el diseño estático del stack. Quedan piezas que merecen su propio artículo:
- El plano multi-site activo/standby: Cilium Cluster Mesh, replicación Qdrant cross-cluster, RTO/RPO realistas, cuándo activo-activo paga y cuándo no.
- El plano de fine-tuning continuo: cómo el pipeline LoRA cierra el bucle desde feedback de producción a adapter promovido, en el espíritu del post de retrain.
- El plano de safety/guardrails: dónde encaja Llama Guard, Presidio para PII y XGrammar para structured output garantizado, conectado a la capa 1 del gateway.
- El plano de coste: instrumentación
gen_ai.usage.*a nivel tenant y modelo, dashboards de tokens/euro, decisiones de elasticidad GPU vía KEDA. - El plano de cumplimiento: cómo el stack se mapea a ENS Alto, NIS2 e ISO/IEC 42001 sin convertir el deployment en un ejercicio de compliance que paraliza la entrega.
Cada uno cae en una serie distinta del blog y se cubrirá con la misma disciplina: pieza concreta, decisión justificada, error típico que se ve en la práctica.
Referencias
- Pipeline LLMOps de seis etapas — el marco general en el que este stack opera.
- El catálogo OSS para LLMOps en seis etapas — ficha por ficha de cada herramienta del stack.
- Anatomía de una petición LLM en producción — la misma arquitectura vista desde la perspectiva de una request individual.
- OSS vs hyperscalers en LLMOps — comparación con las stacks de AWS/Azure/GCP.
- Tracing LLM con OpenTelemetry GenAI — la columna vertebral de la capa 5.
- Multi-LoRA serving — la pieza que da personalización per-tenant sin replicar el base.
- RAG corpus curation y Reranker y hybrid retrieval — todo lo que entra en la capa 3-4 para que el RAG no degrade.
- Quantization para inferencia LLM — el porqué del FP8 W8A8 del dimensionado.
Documentación oficial relevante
- vLLM Production Stack — docs.vllm.ai
- SGLang RadixAttention — github.com/sgl-project/sglang
- Envoy AI Gateway — aigateway.envoyproxy.io
- Langfuse self-hosted — langfuse.com/docs/self-hosting
- OpenTelemetry Semantic Conventions for GenAI — opentelemetry.io/docs/specs/semconv/gen-ai
- Hubble flow observability — docs.cilium.io/en/stable/observability/hubble
- Otterize intent-based access — docs.otterize.com
- Flux GitOps toolkit — fluxcd.io