TITAN: la infraestructura de IA de un instituto
Del metal al chat de clase: inferencia local, pool elástico, RAG y las lecciones de cada decisión
Esta página es la versión técnica de cómo está montado el sistema de IA del IES Simarro: el servidor, el modelo, el balanceador, el gateway, la plataforma y la red. Está pensada para gente que ya sabe de qué va un token, un batch o un índice de similaridad: cada bloque va al detalle —flags concretos, métricas reales extraídas de la máquina en vivo y las decisiones que costaron un incidente—. Los números de esta página se extrajeron del sistema en marcha el 5 de noviembre de 2026, no de una presentación comercial.
3 × 96 GB
RTX PRO 6000 Blackwell (GDDR7, SM120)
512k
tokens de contexto por réplica vLLM
87 %
acierto de prefix cache (métrica viva)
96
secuencias simultáneas de techo
01El servidor TITAN
Un único nodo físico en el centro hace de motor de todo: inferencia LLM, síntesis de voz, OCR, embeddings y la plataforma web. No es un rack de datacenter: es una workstation de gama profesional reconvertida en servidor.
| Recurso | Detalle | Por qué importa |
|---|---|---|
| 3 × GPU | NVIDIA RTX PRO 6000 Blackwell (Max-Q WS), 96 GB GDDR7 c/u | Arquitectura SM120: tensor cores nativos FP4/FP8 → el modelo cabecero va cuantizado mixed NVFP4+FP8 sin penalización |
| CPU | AMD Threadripper PRO 9995WX · 96 núcleos / 192 hilos, un solo dominio NUMA | El offload de pesos a RAM necesita CPU y memoria suelta; y nadie quiere paginar a swap una carga de 180 GB |
| RAM | 498 GB DDR5 | Descarga de checkpoints de +180 GB y VLLM_PLE_CPU_OFFLOAD por réplica (§03) |
| Almacenamiento | RAID software md0 · 15 TB, 16 % usado | Modelos, corpus RAG, grabaciones de clase y backups en el mismo sitio: copia 3-2-1 sin contratos |
| SO / motor | Ubuntu 26.04 LTS · Docker 29 · Compose · vLLM en imagen a medida (build 8e685d1) · CUDA 13 | Todo versionado en ficheros compose; nada de instaladores mágicos ni estado solo «en la máquina» |
| Driver | 595.99.02 | Detalle con historia: si el driver sube sin reiniciar el host, nvidia-smi miente (NVML driver/library mismatch) aunque la inferencia siga volando |
La idea de «una máquina, todo dentro» es deliberada: el centro no paga API por token, no saca datos del edificio y no depende de Internet para que un claustro entero use la IA a la vez. El coste real es la disciplina operativa que describe esta página: pools elásticos, watchdogs y runbooks, porque aquí no hay un equipo SRE de un proveedor detrás.
02El modelo: por qué este y no una API
El modelo cabecero es Qwen3.8-Flash-Next con checkpoint mixed-NVFP4-FP8 —revisión pinneada, montada solo lectura; 183,8 GB en disco, 72,07 GiB de pesos por GPU según el log de arranque del motor—. No se eligió por el ranking de moda, sino por tres propiedades verificables en su config.json:
| Propiedad | Valor real | Consecuencia de ingeniería |
|---|---|---|
| MoE muy disperso | 512 expertos, 10 activos por token (moe_intermediate_size 640) | La velocidad sigue a los parámetros activos, no a los totales: capacidad grande con coste de decode pequeño |
| Atención híbrida | 48 capas: 36 lineales + 12 completas (una cada 4) | El KV cache solo crece con las 12 capas completas → 512k de contexto caben en 14,5 GiB por réplica |
| GQA extremo | 24 cabezas de consulta, solo 2 de KV · head_dim 256 | KV por token diminuto: multiplicar réplicas no multiplica la memoria de contexto |
| Contexto | 262.144 nativo → 524.288 con YaRN (factor 2,0, mrope interleave vía --hf-overrides) | Un dossier de oposición de cientos de páginas entra entero en una sola petición, sin truncar ni ventana deslizante |
La aritmética del KV cache, con cifras de la máquina
Cada token en contexto cuesta KV en las 12 capas completas: 2 cabezas × 256 dimensiones × 2 (K y V) × 2 bytes (bf16) ≈ 24,6 KB/token. La reserva se fija a mano con --kv-cache-memory=15569256448 (14,5 GiB, sin dejar que el perfilado automático decida), y el motor lo traduce en 604.705 tokens de KV —literal del log—: cuadra con el coste por token. Con esa reserva la máxima concurrencia para una petición del contexto completo es 1,15×: una conversación puede ocupar los 512k enteros, pero entonces la tarjeta queda llena de contexto y las demás esperan. La reserva de KV es, literalmente, el presupuesto de memoria compartida entre todas las conversaciones vivas.
Cuánto cuesta «no usar la API» (y por qué sale a cuenta)
La pregunta de todo experto: ¿por qué no una API de terceros, que no obliga a mantener nada? Cuatro razones que sostienen esto en un centro público: protección de datos con menores (los datos del alumnado no salen del edificio); coste marginal cero (una tanda de ~25 exámenes generados por agentes, con ≈21k tokens de salida cada uno, sería una factura considerable en cualquier API: aquí son horas de GPU amortizada); disponibilidad soberana (la IA funciona con la fibra del instituto caída); y valor docente: el alumnado del ciclo puede auditarlo todo —del cmdline de vLLM a los percentiles de latencia— porque el sistema es suyo.
03El pool elástico: tres réplicas, un endpoint
Sobre las 3 GPU corren réplicas idénticas de vLLM con TP=1 (una tarjeta cada una: el reparto de tensores entre GPUs solo compensa cuando el modelo no cabe, y aquí cabe cuantizado; además TP añade comunicación inter-GPU y acopla los fallos). Un HAProxy 3.0 en red host las agrega tras un único endpoint OpenAI-compatible, :8000/v1, con balance leastconn —cada petición va a la réplica con menos conexiones vivas, que en streaming LLM es el proxy correcto de «menos carga»—.
La palabra elástico es la parte interesante: las réplicas no tienen el mismo estatus.
| GPU | Papel | Política |
|---|---|---|
| GPU 0 · qwen0 | El chat del instituto | Nunca sale del pool: es el único server sin disabled en el cfg. Si HAProxy arranca de cero y nadie reconcilia, el endpoint sigue sirviendo. Es la garantía de servicio mínima; los scripts de gestión se niegan a bajar de una réplica sana sin --force |
| GPU 1 · qwen1 | Capacidad prestada | Arranca fuera del pool y entra solo tras un smoke test con contenido: que genere texto, no solo que responda /health. Así una recarga del balanceador no puede mandar tráfico a una GPU que se prestó a otro modelo |
| GPU 2 · qwen2 | Prestada + auxiliares | Compartida con voz (TTS), OCR y embeddings: o Qwen o auxiliares, nunca ambos (una réplica pide ~90 GiB). Los cuatro servicios auxiliares forman un solo interruptor; mientras la GPU sirve Qwen, la voz y la ingesta RAG están caídas |
El ciclo de vida lo gobierna un runbook con tres verbos: titanctl release N --purpose X (drena sin cortar: espera hasta 30 minutos a que terminen las generaciones en vuelo, y solo entonces para y libera la GPU para otro modelo), titanctl restore N (arranca, espera, smoke test y alta en el pool) y titanctl reconcile (ajusta el pool a la realidad de las GPUs). Un timer systemd cada 30 s hace de guardián: si el endpoint muere o la realidad y la configuración divergen, lo reconcilia él.
Los flags de cada réplica, tal cual corren
vllm serve /model --served-model-name qwen3.8-flash-next --tensor-parallel-size=1 # una GPU por réplica, sin TP --max-model-len=524288 # 512k vía YaRN (hf-overrides factor 2.0) --gpu-memory-utilization=0.97 # 94,4 GiB de los 96 --kv-cache-memory=15569256448 # 14,5 GiB A MANO → 604.705 tokens de KV --max-num-seqs=32 # techo por réplica → 96 secuencias en el pool --enable-prefix-caching # 87 % de acierto medido (§04) --enable-auto-tool-choice --tool-call-parser=qwen3_coder --reasoning-parser=qwen3 # razonamiento visible en el stream env: VLLM_PLE_CPU_OFFLOAD=1 # pesos PLE (Per-Layer Embeddings) en RAM del host env: HF_HUB_OFFLINE=1 # imposible «tirar de la descarga»: aire puro
Leer /proc/1/cmdline como fuente de verdad no es un truco cosmético: cuando se cambian flags de rendimiento de noche sobre varias réplicas, lo normal es que una quede con el flag nuevo y las otras con el viejo. La auditoría de uniformidad se hace al proceso, no al compose.
04Números medidos (no estimados)
Todo esto sale de /metrics de vLLM, del CSV de stats de HAProxy y de docker logs, con el método anotado —que es la mitad del dato—. La regla de oro de la medición en casa: un pico con 1-2 peticiones corriendo no es un techo de throughput, y un bench de 5 hilos no es la carga real.
| Qué | Número | Cómo se midió |
|---|---|---|
| Prefill agregado | ≈ 17.400 tok/s | Delta de vllm:prompt_tokens_total en las 3 réplicas, ventana 45 s, con 17-21 sesiones de agentes reales activas |
| Generación agregada | 825 tok/s en carga real · 441 tok/s bench idle · 104 tok/s en serie | Ídem con generation_tokens_total; el bench usa ignore_eos y max_tokens fijo (5 flujos × 600 tok) |
| TTFT medio | ≈ 390 ms | Sum/count de time_to_first_token_seconds de una réplica (1.136 peticiones desde su arranque) |
| Latencia e2e media | 12,2 s/petición | Sum/count de e2e_request_latency_seconds: son respuestas largas de agentes, no frases de chat |
| Prefix cache | 22,6 M aciertos / 26,0 M consultas = 87 % | Contadores prefix_cache_hits/queries_total de una réplica, acumulados desde su arranque |
| Ratio prompt:generación | 26,0 M : 1,28 M ≈ 20 : 1 | El workload real es prefill-dominated: los agentes reenvían contextos enormes que el prefix-cache rescata en gran parte |
| Curva de escalado | 4 sesiones ≈ 380 tok/s · 16 ≈ 1.150 · 25 a la vez = contraproducente | Bins de «Avg generation throughput … Running: N reqs» en los logs del logger de vLLM |
Lección aprendida
--max-num-seqs a ciegas (16→32 sí se midió: +57 % de agregado; la siguiente palanca es cabecear ráfagas, no subir el techo medio)—.05Gateway: quién llama a qué
Entre los consumidores y el pool hay dos puertas con funciones distintas:
| Puerta | Qué es | Para quién |
|---|---|---|
| HAProxy :8000 | Balance puro al pool vLLM: health-check GET /health cada 3 s (fall 2 rise 2), redispatch ante error de conexión, timeouts de 1 h para streaming largo | La plataforma del centro: backend, grabadora, agentes internos. Una URL vale para cualquier número de réplicas vivas |
| LiteLLM :8300 | Gateway con API keys por cliente, presupuestos, registro de uso y max_parallel_requests por clave; expone variantes del mismo modelo (p. ej. -thinking con enable_thinking) | Herramientas externas de agentes (CLI de código, asistentes) que entran por VPN bajo TLS con <K>/llmapi/v1</K>: una clave = identidad + límite + trazabilidad |
frontend openai_front bind 0.0.0.0:8000 monitor-uri /haproxy-health default_backend qwen_pool backend qwen_pool balance leastconn option httpchk http-check send meth GET uri /health http-check expect status 200 default-server check inter 3s fall 2 rise 2 retries 2 option redispatch server qwen0 127.0.0.1:8100 # chat: siempre en el pool server qwen1 127.0.0.1:8101 disabled # prestada: entra con smoke test server qwen2 127.0.0.1:8102 disabled
Lección aprendida
max_parallel_requests: 16 → 64) y subirlo no exige reiniciar nada. Regla de dimensionado: una tanda de N agentes paralelos exige límite ≥ N en su clave, o tormenta de 429 y turnos muertos a medias sin que nada se caiga.06La plataforma del centro
La interfaz que usa el claustro y el alumnado —chat con voz, resúmenes, corrección de exámenes, corpus, grabadora de clases— es un monorepo desplegado en el mismo titan:
| Pieza | Stack | Detalle técnico relevante |
|---|---|---|
| Backend | Python · FastAPI · SQLAlchemy 2 async · Alembic (52 migraciones) | Proxy fino: no ejecuta modelos, llama al pool por HTTP (host.docker.internal:8000/v1, el truco host-gateway de Linux para cruzar la red de Docker). Streaming SSE y WebSocket con proxy_buffering off en nginx para que el primer token salga ya |
| Frontends | React 18 (chat) + React 19 (dashboard) · Vite · Tailwind | SPAs servidas por el mismo nginx: un solo origen, sin CORS. i18n en tres lenguas (es/en/va) |
| Datos | PostgreSQL 15 · Redis 7 | Roles admin / profesorado / alumnado + departamento; el alta de usuarios viene de Moodle (auth externa), no hay registro libre |
| Permisos | «Herramienta = permiso» | Cada herramienta del chat es una constante en un frozenset concedible y su tarjeta solo se pinta si está concedida. Añadir herramienta = constante + include_router con su dependency: el RBAC no se reescribe nunca |
| Workers | asyncio in-process, sin Celery ni Redis-cola | Semáforo por tipo de tarea + recover en el lifespan con backend de proceso único. Menos componentes que caen: el patrón completo levanta con un docker compose up -d —y muere igual de rápido— |
El flujo del chat, entero: navegador → nginx (TLS, CA propia) → FastAPI → HAProxy → réplica vLLM → stream SSE de vuelta. Todo local, todo en la misma máquina, todo auditable.
07RAG, OCR y voz: el resto del stack
Vector search: Qdrant + BGE-M3 híbrido
El gateway RAG (servicio propio, FastAPI en :8001) usa Qdrant con cuatro colecciones separadas por semántica de recuperación, no por capricho: materiales del centro (la que consulta el chat), normativa —allí la recuperación significa otra cosa: manda la vigencia, las fuentes se enseñan con enlace y todo se re-ingesta por curso—, exámenes y ejercicios. Los embeddings los produce BAAI/bge-m3 (1024 dimensiones, denso + sparse + multilingüe) servido por vLLM en CPU: es IO-bound y no debe comer VRAM del chat. La recuperación es híbrida (BM25 + vectorial + reranking): en un corpus normativo con términos exactos, el vectorial puro pierde frente a lo que siempre supo Lucene.
El corpus del centro: cinco decisiones que dan clase
La herramienta con la que el centro mete documentos en su base de conocimiento tiene detalles que merecen explicarse uno a uno: (1) va a la misma colección que consulta el chat, por eso un documento subido a las 10:00 cuenta a las 10:01 sin tocar el camino crítico; (2) el texto se extrae dónde toca —txt/csv/ xlsx los lee el backend in-process, y PDF/DOCX/imágenes van al gateway, que tiene el OCR por GPU DeepSeek-OCR— porque la GPU que presta el OCR se libera a veces y el corpus no puede depender de eso para un simple .txt; (3) dos modos de ingesta, inmediata o nocturna (barrido a las 02:30: un despliegue a media mañana no se pone a vectorizar cuarenta documentos en horario de clase); (4) metadatos obligatorios desde el día 1 —departamento, nivel, etiquetas—, porque nadie rellena una taxonomía después sobre cien documentos ya subidos; y (5) verificación de ingesta con búsqueda de prueba desde el panel, en vez de «ya saldrá o no saldrá» en una conversación.
Voz y vídeo: TTS y render como servicios
Qwen3-TTS (1,7 B, 9 voces, 10 idiomas) sirve la API de audio de OpenAI (POST /v1/audio/speech) en seis réplicas de ~4,8 GiB de VRAM cada una —la co-localización forzada de §03; el modelo cabe en cualquier GPU libre—. El renderizador produce las vídeo-lecciones (guion → render → locución) en otro contenedor, y la grabadora de clase sube solo audio: faster-whisper en GPU transcribe con semáforo=1 para no pisar el pool, y el resumen se genera contra el mismo pool de siempre. Cada capacidad es un servicio con su puerto, su healthcheck y su palanca de apagado —nada de librerías incrustadas que solo puedas usar «aquí»—.
08Segunda línea: los DGX Spark
Titan es un punto de fallo único, y eso en una infraestructura de centro no se lamenta: se combate con una segunda línea barata. Dos pares de NVIDIA DGX Spark (módulos GB10, 128 GB unificados CPU+GPU), cada par con su HAProxy propio (:8400 y :8401), y LiteLLM repartiendo entre pares por least-busy: la misma clave de usuario vale en cualquiera de los dos; si un par se apaga, el otro absorbe sin que nadie cambie nada.
Detalles que a un experto le van a gustar: cada par puede correr el modelo en perfil DUAL —paralelismo entre los dos nodos por fibra ConnectX-7 con NCCL— o en perfil independiente (dos máquinas pequeñas separadas, cada una con su pool). El par B todavía no puede con el DUAL: el cable funciona —los dos nodos se ven por IPv6 link-local a 0,9 ms—, pero su netplan no declara la dirección IPv4 link-local que NCCL necesita. Y un clásico del campo: los hostnames van cruzados respecto a las IPs (spark1 es la .114); el alias SSH manda, nunca el orden de las direcciones. Es un clúster de cuatro nodos sin presupuesto de clúster de cuatro nodos, con sus runbooks gemelos del de titan.
09Red y seguridad: perímetro sin nube
El sistema vive en una red privada del centro. La superficie expuesta está pensada al revés que la de un SaaS: por defecto nada, y lo que se saca se saca con identidad.
| Tramo | Montaje | Razón técnica |
|---|---|---|
| Dentro del servidor | Prácticamente todos los servicios publican solo en 127.0.0.1: vLLM 8100-8102, HAProxy :8000, LiteLLM :8300, backend :8200, render :8893… | Nada de la IA es alcanzable desde la LAN del centro ni desde fuera: la única puerta pública del host es el nginx :443 de la plataforma |
| TLS | nginx :443 (TLS 1.2/1.3) con CA propia del centro, certificado desplegado en los equipos | Sin depender de un emisor externo para servicios que nunca salen del edificio; la confianza distribuida es además control de dispositivos |
| Acceso remoto | OpenVPN cliente (2.7 con DCO) sobre UDP, con ping 10 / ping-restart 120 | El acceso de confianza entra <em>dentro</em> de la red, no al revés: titan nunca inicia conexiones salientes hacia casa |
| Agentes desde fuera | VPN + LiteLLM bajo TLS con clave por herramienta | Identidad, límite de concurrencia por clave y registro de uso: el rate limiting es un feature, no un obstáculo |
Lección aprendida
active (running), con su interfaz UP y en verde en el monitor, y no pasar un solo paquete: pasa tras un re-handshake TLS si la config no declara keepalive. Los síntomas que delatan el túnel y no la GPU: 100 % de pérdida al gateway del túnel, el rx_packets de tun0 plano con el tx creciendo, mientras el servidor responde a ping por la puerta normal. Diagnóstico: mide deltas rx/tx, no mires el estado del servicio. Fix permanente en la config: ping + ping-restart → se recupera solo en ~2 minutos.10Operación: el sistema se defiende solo (porque le obligamos)
Infraestructura local en un centro no es «montar y olvidar»: es un ciclo de vigilancia, medición y runbooks. Lo que hay en marcha hoy:
| Mecanismo | Qué hace |
|---|---|
| Watchdog horario | Revisa en cadena túnel, ruta, SSH, motor de vídeo y el nginx :443; silencio = OK, alerta si algo cae tras reintento |
| Guardián del pool (30 s) | Reconcilia el endpoint del pool: si :8000 muere, lo recupera él. Es red de seguridad, no permiso para romperlo |
| Reconciliar al arrancar | Tras un reinicio del host, el pool se ajusta solo a la realidad de las GPUs: qué réplicas están vivas manda, no la configuración |
| Smoke test con contenido | Una réplica no entra al pool por responder /health: tiene que generar texto |
| BASELINES.md | Toda medida se anota con su configuración y su contexto (tanda activa vs idle): sin contexto, un número no es un dato, es una anécdota |
| drain → recreate → restore | El ritual para cambiar flags de vLLM en producción sin cortar conversaciones en vuelo (y sin descubrir a las 3 de la mañana que solo se aplicó a una de tres réplicas) |
Lección aprendida
docker restart… y el endpoint estuvo muerto 12 minutos. Por qué: con restart: unless-stopped, una parada deliberada no se relanza — el gestor entiende que querías pararlo—. Desde entonces: recarga solo en caliente y validada (comprobar config → -sf → reconciliar), jamás docker restart del balanceador, y el guardián de 30 s como red. Lección general: en un sistema pequeño, cada componente crítico necesita un gesto protegido y su opuesto prohibido, escritos en un runbook.Y el cierre, que es también la demostración: quien ha redactado esta ficha es un modelo corriendo sobre la máquina que describe, leyendo sus propios logs y métricas —la infraestructura auditándose a sí misma—. Eso es exactamente lo que ninguna API de terceros te deja hacer, y es la mejor diapositiva de la tarde.
Ficha técnica · IES Simarro · 5 de noviembre de 2026. Los números se extrajeron en vivo del sistema en marcha (docker inspect, /proc/1/cmdline, /metrics de vLLM, stats de HAProxy y logs de arranque). Sin claves, sin IPs de acceso, sin datos de usuarios. Los picos de throughput con pocas peticiones son cargas reales, no barridos controlados de concurrencia: consúltese el método de cada cifra antes de citarla.
