El viernes se lanzó el modelo y no pudimos esperar
La noticia de Qwen3.8-27B salió un viernes y, como casi siempre nos pasa, no fuimos capaces de esperar al lunes para ponerlo en marcha. Este post es la primera crónica de ese despliegue: lo hicimos por encima, a mano, sobre la infra que ya teníamos, porque aún no hemos documentado cómo gobernamos todo con Flux CD ni cómo montamos Forgejo. Eso lo veremos en posts siguientes. Aquí, de momento, nos interesa contar la parte que más nos sorprendió: cuánto nos cuesta —o deja de costar— correr un modelo de 27B en casa, y qué lecciones de las recetas oficiales nos hicieron saltar de 9 a más del doble de tokens por segundo.
Es un post inicial, a propósito. No va a pretender ser el manual definitivo del despliegue: es la foto de lo que pasó, con las cifras que sacamos de los logs, y con las sensaciones propias que nos llevamos del camino.
El escenario: una DGX Spark dentro del clúster
Nuestro clúster K3s tiene dos nodos. El primero, gamorcloud01, es el control plane y el worker de CPU. El segundo, gx10-db4d, es el nodo con GPU donde hace años que dormitan las cargas de inferencia. Ahí es donde aterrizó Qwen3.8-27B, en el namespace dgx-ia, como un Deployment de Kubernetes corriendo el contenedor vllm/vllm-openai:v0.27.1.
El modelo que subimos es la versión cuantizada en NVFP4 — la receta que publicaron — descargada desde Hugging Face con su propio initContainer, y montada en un PVC para que no vuelva a bajar 40 GB cada vez que reinicia:
containers:
- name: vllm-nvfp4
image: vllm/vllm-openai:v0.27.1
command: [vllm, serve, /models/qwen3.8-27b-inferact-nvfp4]
resources:
limits:
nvidia.com/gpu: "1"
memory: "120Gi"
requests:
nvidia.com/gpu: "1"
memory: "64Gi"
Dos cosas que importan y que suelen doler en este tipo de despliegues caseros:
- El
startupProbeconfailureThreshold: 180. Un modelo de 27B tarda en arrancarse. Si el probe es impaciente, el clúster te mata el pod antes de que haya aprendido a respirar. Le dimos casi una hora de margen de arranque y luego le dejamos unreadinessProbenormal. - El
initContainercon un.download-complete. La primera vez descarga el modelo y toca un marcador; las siguientes ve el marcador y salta directo al servicio. Sin esto, cada reinicio era una espera nueva.
Todo esto sin Flux CD todavía: lo aplicamos a mano, para poder ver la diferencia entre config y config sin que el GitOps nos la enmascare. Eso, por ahora, es una deuda que saldaremos en un post dedicado a cómo lo gobernamos por GitOps.
La receta base: y por qué daba 9 tokens/s
Empezamos con una configuración conservadora, de “primera vez que lo pruebo y no quiero que se caiga”. Tenía sentido: --gpu-memory-utilization 0.45, sin prefix caching explícito, sin MTP. Arrancaba. Respondía. Pero despacio.
La cifra base que nos quedamos grabada fue 9 tokens/segundo. Poco, pero estable. Y ahí empezó la curiosidad, porque las recetas oficiales de Qwen3.8 con vLLM hablaban de un escenario bastante más ágil del que estábamos obteniendo.
La receta nueva: tres palancas, un cambio
Lo que hicimos fue aplicar la receta recomendada para Qwen3.8 en vLLM, palanca a palanca. Tuvimos mucho cuidado de no tocar lo que ya iba bien: --kv-cache-dtype fp8, --max-model-len 262144, --enable-chunked-prefill, y los parsers de razonamiento y tool-calling (--reasoning-parser qwen3, --tool-call-parser qwen3_coder, --enable-auto-tool-choice). Cambiamos solo tres cosas:
# 1) MTP (Multi-Token Prediction): Qwen3.8 trae MTP; vLLM 0.27.1 lo soporta.
- --speculative-config
- '{"method": "qwen3_next_mtp", "num_speculative_tokens": 2}'
# 2) Memoria: 0.45 -> 0.90. El KV cache solo usaba 9-9,8% de la GPU;
# ~55% quedaba sin asignar. (Riesgo: si hay OOM, bajar a 0.85.)
- --gpu-memory-utilization
- "0.90"
# 3) Prefix caching explícito: por defecto va ON en el V1 engine, pero
# queríamos asegurarlo. Antes teníamos hit rate 0%.
- --enable-prefix-caching
Cada una de las tres resolvía un problema distinto, y por eso las separo:
1. MTP (Multi-Token Prediction)
Qwen3.8 trae, de fábrica, una capa de predicción de varios tokens a la vez (MTP). vLLM 0.27.1 sabe aprovecharla con --speculative-config y num_speculative_tokens 2. La idea: el modelo especula un par de tokens de una, y el modelo grande confirma o descarta. Si acertamos, ganamos; si nos equivoca, no perdemos (se descarta y se re-empieza). En los logs veíamos una tasa de aceptación de borrador de entre el 60 % y el 82 %, y una longitud media de aceptación de 2,3–2,6 tokens. No es magia, pero es una forma elegante de avanzar.
2. Memoria de la GPU: 0.45 → 0.90
Esta fue la que más nos sorprendió en el diagnóstico. Con 0.45, el KV cache solo usaba 9-9,8 % de la GPU. Es decir, teníamos más de la mitad de la memoria de la tarjeta sin asignar a lo que más lo necesitaba: la caché de contexto. Subir a 0.90 no acelera por sí solo el token, pero libera la caché para poder atender más peticiones y más contexto, y prepara el terreno para el punto 3. El único riesgo, y lo dejamos escrito en el propio manifest, es un OOM: si pasara, se baja a 0.85.
3. Prefix caching explícito
Este es el que cambia el día. El V1 engine de vLLM lleva el prefix caching ON por defecto, pero nosotros teníamos un hit rate del 0 % — el caché se activaba, pero no servía para nada. Al añadirlo explícito, el hit rate subió al 84-86 % que vimos en los logs. ¿Por qué importa tanto? Porque cuando dos peticiones comparten contexto (el mismo system prompt, la misma conversación, el mismo prefijo de herramienta), en vez de recalcularlo desde cero, el motor reutiliza lo que ya había calculado. En una infra con muchos chats que comparten plantillas, eso es el 86 % del trabajo ahorrado.
Los números: de 9 a más del doble, y a veces de 4 a 6 veces
Aquí es donde conviene ser honesto, porque “rápido” puede significar cosas distintas, y en los logs se ven dos velocidades que no son la misma.
En throughput bruto (lo que miden las recetas): las cifras salen por encima del doble. La base era 9 tokens/s. Con la receta nueva, en peticiones individuales (una sola en vuelo) los logs muestran ~22–31 tokens/s de generación; y cuando el motor está con las 4 peticiones concurrentes que habilita --max-num-seqs 4, los picos suben a 40–56 tokens/s. O sea: más del doble que la receta base, tal y como decían las recetas.
En tiempo de respuesta (lo que percibimos en el uso real): el salto se amplifica. La sensación fue de un x4 o x6. Y tiene sentido, porque el tiempo de respuesta no depende solo del throughput bruto: el prefix caching al 86 % corta el tiempo hasta el primer token en las peticiones que comparten contexto, y el MTP adelanta la generación. Lo que “parece más del doble” en las estadísticas se traduce, en la experiencia, en una respuesta que llega de 4 a 6 veces más rápido.
| Escenario | 27B sin optimizar | 27B optimizado | Salto |
|---|---|---|---|
| Throughput bruto (peticiones individuales) | ~9 tokens/s | ~22–31 tokens/s | > 2× |
| Throughput en pico (4 peticiones concurrentes) | ~9 tokens/s | 40–56 tokens/s | ~4–6× |
| Hit rate de prefix caching | 0 % | 84–86 % | el gran cambio |
| Sensación de respuesta | referencia | 4–6× más rápida | lo que se percibe |
No confiamos “más del doble” con “4–6 veces”: son la misma configuración, medida de dos formas. El throughput se ve en los logs; la sensación de 4–6× es lo que se nota al usarlo, y viene de que el caché y la especulación reducen el tiempo hasta la respuesta, no de que cada token sea 6× más rápido.
La sensación real: frente al Qwen3.6 35B
Aquí es donde la cosa se pone interesante, y donde conviene ser muy honesto con el tipo de comparación — porque no es “modelo A contra modelo B a la misma velocidad”. Son dos cosas distintas, y conviene separarlas.
Lo que teníamos antes — Qwen3.6 35B, un MoE A3B. Es un modelo mixture of experts que solo activa 3.000 millones de parámetros por token. Eso lo hacía muy rápido: el coste por token es bajo porque por cada token solo trabajan unos pocos miles de millones de parámetros. Pero con un precio: una precisión que nos decepcionaba. Rápido, sí; pero en las tareas finas se notaba que perdía calidad, que no acertaba tan bien cuando importaba el detalle.
Lo nuevo — Qwen3.8-27B, sin optimizar. Aquí vino la decepción. Sin la receta, este modelo iba muy lento: 9 tokens/s. Lento hasta el punto de que, frente al 35B MoE, parecíamos haber retrocedido. Y sin embargo tenía lo que el 35B no tenía: una precisión mucho mayor.
Y entonces, con las tres optimizaciones. Lo que de verdad nos sorprendió. No solo recuperamos la velocidad —la nueva receta nos lleva a una velocidad similar a la del 35B MoE (y, en el tiempo de respuesta percibido frente al 27B sin optimizar, un 4-6× mejor)—, sino que lo hace conservando esa precisión. Es decir: conseguimos la rapidez del 35B y la calidad del 27B. Lo mejor de los dos, en el mismo despliegue.
Eso es la sensación: no es que “un modelo pequeño aplaste a uno grande”. Es que el 27B, mal montado, parecía un desastre; bien montado, es a la vez el más rápido y el más preciso. La receta no solo aceleró: nos devolvió un modelo que además se comporta mejor. En una infra casera, donde cada segundo de GPU cuenta, tener velocidad y precisión y eficiencia en el mismo despliegue es lo que de verdad cambia la experiencia.
Lo que nos llevamos (y lo que viene)
Tres lecciones del camino:
- No confíes en los defaults sin medirlos. El prefix caching iba “ON” por defecto y teníamos un hit rate del 0 %. A veces el default está desactivado de facto. Mídelo, no asumas.
- La memoria sin asignar es memoria de rendimiento. El
0.45degpu-memory-utilizationno era un error, era una oportunidad: la mitad de la GPU dormía. - Optimizar puede más que cambiar de modelo. El 27B sin receta iba más lento que el 35B MoE; con las tres optimizaciones llegó a una velocidad similar al 35B y con una precisión mucho mayor. No siempre hay que cambiar de modelo: a veces hay que montarlo bien.
Y lo que viene —porque este post es a propósito la primera parte—: cómo gobernamos estos despliegues con Flux CD (así que la próxima vez no lo hacemos a mano), y cómo montamos Forgejo para que todo esto sea un PR con revisión humana en vez de un kubectl apply de madrugada.
Mientras tanto, el modelo sigue corriendo en gx10-db4d, en dgx-ia, sin caerse desde que lo subimos. Y, si me preguntas, la respuesta es: sí, se nota.