según la necesidad
HAProxy leastconn
OpenAI + Anthropic
fuera de la VRAM
1. El problema de diseño
Servir un MoE de checkpoint mixto NVFP4+FP8 de 171 GiB en tres RTX PRO 6000 de 96 GB tiene un primer obstáculo aritmético: el modelo no cabe entero en ninguna tarjeta, y el tensor-parallel clásico (partir cada capa entre GPUs) introduce comunicación por PCIe/NVLink en el camino crítico de cada token. Nuestra elección fue la contraria: réplicas completas con paralelismo de tensor 1, y una cola de distribución delante. Cada GPU es un motor de inferencia autónomo; la elasticidad no la da el chip, la da la topología. Todo lo que sigue —memoria, balanceo, gateway— está diseñado alrededor de esa decisión.
2. Topología del pool
- Las réplicas no se publican: solo escuchan en
127.0.0.1:810Ndel host. El único punto de entrada externo es la cola HAProxy, de modo que subir o bajar una GPU del servicio es invisible para los clientes. - Un solo nombre publicado (
qwen3.8-flash-next): el mismo endpoint y el mismomodelsirven a todas las aplicaciones del centro — agentes de código, aula, generación de contenido, vídeo. - Orquestación con perfiles Docker Compose: el fichero base define el servicio;
los overrides
docker-compose.2replicas.ymlydocker-compose.512k.ymlcambian la forma del despliegue sin duplicar configuración.
3. Servir con una, dos o tres GPUs: el pool es elástico
Con réplicas TP=1, escalar es una operación aritmética trivial — pero cada GPU tiene competidores. La GPU 2 del Titán también da servicio a TTS (6 voces), render de vídeo, OCR y embeddings. El pool se dimensiona por perfiles según la campaña:
| Perfil | Configuración | Pico medido | Concurrencia | Cuándo lo usamos |
|---|---|---|---|---|
| 3 réplicas | GPU 0+1+2 dedicadas al LLM | ~1.540–1.670 tok/s | ~48 sesiones | Máxima demanda docente/producción de contenido |
| 2 réplicas (unificado) | GPU 0+1 LLM · GPU 2 libre para voz/vídeo | ~1.030 tok/s | ~32 sesiones | Estado por defecto del centro |
| 1 réplica | GPU 0 · mantenimiento del resto | ~600 tok/s | ~16 sesiones | Updates, pruebas, fallo de una tarjeta |
Números de nuestros benches y logs (ver benchmarks oficiales). El coste de sacrificar la tercera réplica —40 %
de pico y un tercio de concurrencia— se asume deliberadamente: con dos réplicas completas la GPU 2
puede síntesis de voz y render de vídeo sin drenar el pool a mano cada vez. La tercera réplica está
definida pero a replicas: 0: documentada y viva, a un flag de distancia.
Dos consecuencias elegidas de este diseño:
- Sin punto único de fallo: una réplica que muere solo resta capacidad; HAProxy la saca del pool en ~6 s y las sesiones en vuelo de esa GPU se reencolan en las demás. No hay EngineCore compartido que tumbe el servicio entero (algo que nosotros mismos probamos, ver §5).
- Migración de modelo sin corte: al cambiar el modelo de la plataforma, las réplicas publicaron temporalmente los alias del anterior para que todos los clientes siguieran respondiendo durante el cambio de minuto, y se retiraron el mismo día con todos los clientes ya repuntados.
4. El plan de memoria: por qué TP=1 con offload gana al TP=2
- Pesos (72,1 GiB GPU): la cuantización mixta NVFP4+FP8 de la familia Qwen deja los pesos que sí participan en cada token por debajo del umbral de los 96 GB. Con el bf16 original, TP=2 sería obligatorio — y pagaría comunicación entre GPUs en cada capa.
- Tabla PLE — 51,2 B parámetros (RAM): el modelo arrastra una tabla de
embeddings n-gram (PLE) que en BF16 ocupa ~95 GB y se accede de forma dispersa: es el
componente ideal para vivir en DDR5 ECC y no en GDDR7. El offload a host
(
VLLM_PLE_CPU_OFFLOAD=1) libera la VRAM para lo que necesita los 1.792 GB/s de ancho de banda: pesos activos y KV. - KV cache (14,5 GiB GPU + paginado a RAM): la atención híbrida (36 capas lineales con estado fijo + 12 completas) recorta la KV a un cuarto; con 14,5 GiB reservados a mano caben 604.705 tokens de KV GPU, y el paginador extiende con la RAM cuando llegan ráfagas.
- Balance por réplica: 72,1 + 14,5 + 0,3 (CUDA graphs) ≈ 87 GB de los 96 de VRAM · ~100–115 GiB de los ~450 visibles de RAM. Tres réplicas llenan el nodo sin fricción entre ellas porque cada una es autónoma.
# Producción real por réplica (abreviada; idéntica en las 3) vllm serve /model \ --served-model-name qwen3.8-flash-next \ --tensor-parallel-size=1 --distributed-executor-backend=mp # mp, no ray: requisito del offload PLE \ --max-model-len=524288 \ --gpu-memory-utilization=0.97 \ --kv-cache-memory=15569256448 \ --enable-prefix-caching \ --enable-auto-tool-choice --tool-call-parser qwen3_coder \ --reasoning-parser qwen3 --max-num-seqs=16 \ --hf-overrides{"text_config":{"rope_parameters":{"rope_type":"yarn","factor":2.0,...}}} env VLLM_PLE_CPU_OFFLOAD=1 VLLM_PLE_OFFLOAD_READY_TIMEOUT=1800 # nativo 262.144 ctx → YaRN×2 para 524.288; checkpoint con revisión pinneada, montado RO
5. La cola de distribución: HAProxy y lo que aprendimos balanceando
Con réplicas independientes, la calidad del servicio depende de la cola que hay delante. Nuestra configuración y las tres lecciones medidas que la justifican:
leastconn, no roundrobin
Con streams LLM largos lo que importa es cuántas generaciones vivas tiene cada réplica, no cuántas peticiones ha recibido nunca. El round-robin cuenta la historia pasada; el leastconn cuenta la realidad presente. Y los timeouts de la cola subidos a 1 h: una generación agéntica sobre un repositorio grande puede tardar muchos minutos y la cola no debe cortar el stream a mitad.
La afinidad por origen nos quemó (y la retiramos)
Probamos
stick on src para no perder el prefijo cacheado de cada conversación agéntica.
Error medido en 40 minutos: todo el tráfico entraba desde la misma IP (el contenedor
del gateway), así que las tres sesiones se pegaron a la réplica 0 — 11 generaciones vivas y 5
encoladas en GPU 0, mientras dos GPUs enteras estaban al 0 %. Para afinidad por conversación
hace falta una cabecera estable propagada (X-Session-Id); sin ella, leastconn
reparte y eso es lo que importa. La publicamos como lección, no la borramos.
Salud agresiva, retirada limpia
Health-check
GET /health con inter 3s fall 2 rise 2: una réplica que muere sale
del pool en ~6 s (los 30 s del default dejaban una ventana de peticiones muriendo). Y el
socket de administración de HAProxy es UNIX con grupo del usuario operador: drenar, poner en
mantenimiento o devolver una réplica al servicio se hace sin sudo y sin exponer el plano de
control por TCP.
El incidente del conector PLE (por qué cada réplica puede morir sin tumbar nada)
El 01-09-2026, bajo un benchmark de concurrencia 64, la réplica 0 cayó con el EngineCore entero.
Causa raíz: la imagen de vLLM que usábamos traía el conector de offload PLE con su cola interna
de maxsize=1; bajo carga el put_nowait lanzaba queue.Full
y se llevaba por delante el motor. Fix: un conector corregido (cola dimensionada a
max_concurrent_batches + pool de eventos CUDA para las copias GPU→host) que viaja
dentro del propio checkpoint pinneado y se monta como overlay del paquete —
cero descargas adicionales, revisión auditable. Este incidente es el argumento definitivo a
favor de la topología de réplicas: murió un motor, el pool siguió respondiendo con las otras
GPUs, y la réplica volvió tras el overlay sin que ningún cliente notara más que la cola de ese
minuto.
6. El gateway: LiteLLM hablando dos dialectos
vLLM solo habla OpenAI; nuestro stack usa también Claude Code, que habla el Messages API de Anthropic
(bloques tipados, tool_use, thinking). LiteLLM publica ambos protocolos
(/v1/messages y /v1/chat/completions) sobre el mismo pool y añade lo que el
motor de inferencia no debe gestionar nunca: claves virtuales por usuario, límites de gasto y
registro de consumo. Delante, nginx publica el gateway por TLS con la CA interna del centro.
Dos perfiles de modelo, medidos y documentados en el propio fichero de configuración:
qwen3.8-flash-next(sin razonamiento): para respuestas de una tirada con formato fijo — clasificar, extraer, resumir. Medido: 182 tokens / 1,8 s frente a 2.154 tokens / 21,0 s del mismo prompt razonando.qwen3.8-flash-next-thinking(con razonamiento): para trabajo agéntico. Sobre 222 turnos reales: el 45 % gasta <500 tokens de razonamiento, mediana 598 — el modelo se autorregula y solo se explaya donde decide algo. Con el pool ocioso el 76 % del tiempo en uso agéntico interactivo, esa latencia sale de capacidad que estaba parada.
7. Operación y control
- Revisión pinneada en todo: checkpoint con hash de commit HF, imágenes Docker por
digest, versiones en un
versions.envcentral. Reconstruir el pool dentro de un año debe dar exactamente el mismo sistema. - Preflight y humo: un script de preflight valida GPUs/puerto/memoria antes de arrancar réplicas, y pruebas smoke automáticas tras cada despliegue.
- Drenado por réplica: mantener una GPU = sacarla del pool por el socket de HAProxy, esperar a las generaciones vivas, intervenir, devolverla — sin ventana de servicio cortado para el resto del centro.
- Telemetría: los
/metricsde cada engine (TTFT, ITL, KV, prefijos) son la fuente de los benchmarks publicados: el mismo dato que opera el sistema es el que se documenta hacia fuera.
¿Funciona? Los números, en los benchmarks oficiales
Todo lo descrito aquí se tradujo en 602 tok/s por GPU, 1.668 en el clúster y un TTFT mediano de 470 ms con prompts de 90K tokens — medidos en producción 24/7, con metodología y limitaciones publicadas.
VER BENCHMARKS DEL TITÁN →Sobre este documento
Documento público y vivo de la plataforma de inferencia del IES Dr. Lluís Simarro, Centro de Excelencia Nacional en IA y Big Data. Descripción verificada contra el despliegue real del nodo Titán el 13 de septiembre de 2026 (composers, configuración de gateway y cola, logs de operación). Repositorio: aulaenlanube/pp1-simarro.