Módulo 6: Modal — Deployment serverless de LLMs
Autoscaling y cold starts
Tienes un endpoint funcionando. Funciona bien cuando lo llamas tú. La pregunta seria es: ¿qué pasa cuando lo llaman 100 usuarios al mismo tiempo? ¿Y cuando no lo llama nadie por 4 horas?
Esa es la conversación de esta cápsula. Vas a entender exactamente cómo Modal escala (cuándo levanta containers, cuánto los mantiene vivos, qué pasa con la latencia) y cómo configurar las tres palancas que tienes para balancear latencia vs costo.
Al terminar vas a poder:
- Predecir cuántos containers va a usar tu app bajo distintos patrones de tráfico
- Configurar
max_containers,min_containers,scaledown_window,concurrency - Diseñar estrategias de cold start mitigation: warm pool, snapshot del modelo, healthcheck periódico
- Medir P50/P95/P99 de latencia y diagnosticar si son cold starts o GPU saturada
Por qué importa
Esta es la cápsula donde tu deployment se vuelve profesional. Hasta ahora, todo lo que hiciste funciona — pero solo para un usuario, sin medir nada. En producción real:
- Un cold start de 30s te cuesta usuarios.
- Mantener 10 containers warm "por si acaso" te quema créditos.
- Sin límites, un cliente con un bug que llama 10,000 veces te puede vaciar la cuenta.
Las configuraciones de esta cápsula son las que separan "demo funcional" de "endpoint que tu equipo deploya con confianza".
Cómo escala Modal (modelo mental)
Cada función o clase tiene un pool de containers que Modal administra automáticamente:
Sin tráfico:
[ ] ← 0 containers, no cuesta nada
Llega 1 request:
[ ★ ] ← Modal levanta 1 container (cold start)
← El request espera ~5-30s y se responde
Llegan 5 requests en paralelo:
[ ★ ★ ★ ] ← Modal levanta hasta 3-5 containers según concurrency
← Cada uno maneja 1-N requests
Sin tráfico por X minutos:
[ ] ← Modal apaga containers idle, vuelve a 0
Los parámetros que controlan ese baile:
| Parámetro | Qué controla | Default |
|---|---|---|
max_containers | Tope máximo de containers paralelos | Sin tope (cuidado!) |
min_containers | Containers warm mantenidos siempre | 0 |
scaledown_window | Segundos de idle antes de apagar | ~60s |
@modal.concurrent(max_inputs=N) | Cuántos requests procesa un container en paralelo | 1 |
Nota sobre nombres antiguos: versiones previas de Modal usaban
concurrency_limit,keep_warm,container_idle_timeout,allow_concurrent_inputs. Si ves esos en blogs/tutoriales viejos, los nombres actuales (a inicios de 2026) son los de la tabla. Las semánticas son las mismas.
Las tres palancas, en detalle
Palanca 1 — max_containers (techo)
Sin esto, una tormenta de tráfico puede levantar 100 GPUs A10G. Tu cuenta se vacía en minutos.
@app.cls(
gpu="A10G",
max_containers=10,
...
)
class MistralService: ...
Con max_containers=10, si llegan 1000 requests Modal encola los excedentes. Tu latencia P99 sube, pero tu costo se acota.
Regla práctica: siempre pon max_containers en cualquier endpoint con GPU. No es opcional en producción.
Palanca 2 — min_containers (warm pool)
Sin esto, si nadie llama tu endpoint por 5 min, el siguiente llamado paga cold start completo.
@app.cls(
gpu="A10G",
min_containers=1, # 1 container siempre warm
max_containers=10,
...
)
class MistralService: ...
Con min_containers=1, siempre hay 1 container vivo. El primer request encuentra GPU lista y modelo cargado → respuesta en 1-3s.
Trade-off: estás pagando ese container 24/7 aunque nadie lo use. Para una A10G a ~$1/hr, eso es ~$720/mes. Sólo lo pones si tu SLA lo justifica.
Palanca 3 — scaledown_window (cuánto tarda en bajar)
Por default Modal apaga containers idle en ~60s. Si esperas tráfico burst con valles cortos:
@app.cls(
gpu="A10G",
scaledown_window=600, # 10 min de gracia antes de apagar
...
)
class MistralService: ...
Útil cuando el tráfico llega en ráfagas: container queda warm entre ráfagas, evitas pagar cold start repetido.
Palanca 4 — @modal.concurrent(max_inputs=N)
Por default, cada container maneja 1 request a la vez. Para una GPU con un LLM, esto es óptimo (vLLM internamente batchea, no quieres reentrancy).
Para una función sin GPU que es I/O-bound (consulta APIs externas), procesar varios requests en el mismo container es mucho más eficiente:
@app.function(
image=imagen,
max_containers=20,
)
@modal.concurrent(max_inputs=100)
def llamar_openai(prompt: str):
...
Acá 1 container maneja hasta 100 requests concurrentes (asyncio bajo el capó). En vez de 100 containers, usas 1.
Regla práctica:
- GPU LLM:
max_inputs=1(default). Deja a vLLM hacer el batching. - CPU I/O-bound:
max_inputs=50-100. - CPU CPU-bound (procesamiento pesado):
max_inputs=1.
Aplicado a tu Mistral API
Volvamos al MistralService de la cápsula 05 y configurémoslo para producción. Edita api.py:
@app.cls(
image=imagen,
gpu="A10G",
volumes={CACHE_DIR: pesos_volume},
timeout=600,
# Autoscaling — los parámetros nuevos
min_containers=1, # 1 GPU siempre warm
max_containers=5, # tope para no quemar la cuenta
scaledown_window=300, # 5 min de gracia antes de apagar
)
class MistralService:
@modal.enter()
def cargar(self):
from vllm import LLM
self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)
@modal.method()
def generar(self, prompt: str, max_tokens: int, temperature: float):
# ... igual que antes
...
Con esta config:
| Escenario | Comportamiento |
|---|---|
| 1 usuario llamando ocasionalmente | 1 container warm permanente → P50 = ~2s, sin cold starts |
| 10 usuarios simultáneos | Modal sube hasta 5 containers, 5 esperan en queue brevemente |
| 100 usuarios simultáneos | 5 containers procesan, 95 quedan en queue → P99 sube mucho. Considera subir max_containers o reducir max_tokens. |
| 0 tráfico por 1 hora | 1 container sigue warm (por min_containers=1). Costo: ~$24 ese día. |
Diagnóstico: cold start o GPU saturada
Cuando tu P99 está alto, hay dos causas distintas con remedios distintos:
| Síntoma | Diagnóstico | Remedio |
|---|---|---|
| Latencia ocasional de 20-30s, el resto rápido | Cold start | Sube min_containers o scaledown_window |
| Latencia constantemente alta bajo tráfico | GPU saturada | Sube max_containers, o usa GPU más rápida |
| Latencia normal pero requests caen en queue | Tope max_containers alcanzado | Sube max_containers, o agrega rate limit aguas arriba |
Cómo medir en Modal:
modal app logs mistral-api --tail 200
Busca líneas tipo:
[container-abc123] enter (cold start) — took 28.3s
[container-abc123] handled request in 2.1s
[container-def456] enter (cold start) — took 26.8s
Si ves muchos enter (cold start) durante una ráfaga de tráfico → estás sufriendo cold starts. Si ves pocos pero requests con queueing alto → te falta max_containers.
Patrón avanzado: warm pool por horario
Si tu producto tiene horario laboral muy marcado y no quieres pagar warm pool de madrugada, no puedes hacerlo desde el decorador (Modal no tiene scheduling de min_containers built-in). Tienes dos opciones:
Opción A — Healthcheck periódico desde un cron externo. Un job en GitHub Actions o un cron externo llama tu endpoint cada 4 minutos durante horario laboral. Eso mantiene el container "tocado" → no entra a scaledown.
Opción B — Modal scheduled function que invoque la clase.
Modal tiene @app.function(schedule=modal.Period(minutes=4)). Una función ligera que toca MistralService mantiene el warm.
@app.function(schedule=modal.Period(minutes=4))
def keep_warm():
servicio = MistralService()
servicio.generar.remote("ping", max_tokens=1, temperature=0.0)
Eso te da warm pool casi gratis (1 inferencia mínima cada 4 minutos vs container 24/7).
Estrategias para reducir el cold start mismo
Cuando un cold start es inevitable (primera vez del día, escalado por burst), su duración importa. Tres optimizaciones:
1. Pre-bake el modelo en la imagen. Por default, vLLM descarga pesos al volume y los carga al arrancar el container. Puedes pre-bakear los pesos dentro de la imagen (más pesada, pero arranque más rápido):
def descargar_pesos():
from huggingface_hub import snapshot_download
snapshot_download(MODELO, cache_dir="/cache/huggingface")
imagen = (
modal.Image.debian_slim()
.pip_install("vllm==0.6.3", "huggingface_hub[hf_transfer]==0.26.2")
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
.run_function(descargar_pesos) # ← ejecuta esto al construir la imagen
)
Trade-off: imagen pesa 14GB más; rebuild más lento; pero containers arrancan sin tocar disco externo.
2. Memory snapshot (si tu modelo lo soporta).
Modal puede tomar un snapshot de la memoria del container después de @modal.enter() y restaurar containers nuevos desde ese snapshot. Carga de modelo Mistral 7B pasa de ~25s a <5s.
@app.cls(
...,
enable_memory_snapshot=True,
)
class MistralService:
@modal.enter(snap=True)
def cargar(self):
from vllm import LLM
self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)
No todos los modelos toleran snapshot (algunos drivers de GPU se "rompen" al deserializar). Prueba con tu caso.
3. Modelos más chicos o cuantizados. Mistral 7B AWQ (cuantizado a 4 bits) pesa ~4GB en vez de 14GB. Cold start mucho más rápido. Trade-off: pierdes algo de calidad. Para muchos casos vale la pena.
Trampas comunes
Trampa 1 — "Puse min_containers=5 para evitar cold starts y mi factura explotó."
5 containers A10G permanentes ≈ $3,600/mes. Si tu tráfico no justifica esa capacidad permanente, no la pagues. Considera min_containers=1 + escalar por demanda.
Trampa 2 — "Sin max_containers y un cliente buggy me drenó la cuenta."
Pasa. Siempre pon max_containers. Considera además rate limit por API key (no built-in en Modal; lo implementas en el handler FastAPI o frente con un API gateway).
Trampa 3 — "Mi P99 sube de 2s a 20s cuando hay >5 usuarios."
Probable: alcanzaste max_containers, los requests están en queue. Sube el tope o agrega rate limit explícito.
Trampa 4 — "Memory snapshot me da errores raros."
Algunos drivers GPU no toleran snapshot. Si ves errores tipo "CUDA out of memory" o "context invalid" al restaurar, desactiva enable_memory_snapshot.
Trampa 5 — "min_containers no parece funcionar — sigo viendo cold starts."
Verifica que deployaste después de cambiar el parámetro. modal run no aplica min_containers. Necesitas modal deploy api.py y esperar la confirmación.
Trampa 6 — "Mi healthcheck de Modal devuelve 200 pero /chat falla con cold start largo."
El healthcheck /health no toca la clase GPU. Para que el warm pool aplique a MistralService necesitas que llamen al método de la clase, no a un endpoint no relacionado. Usa el patrón de keep_warm con @app.function(schedule=...) arriba.
Ejercicio
Tu producto va a tener un patrón de tráfico simulado así:
- De 9am a 6pm hora laboral: 100 req/min con bursts hasta 300 req/min
- De 6pm a 9am: <5 req/min, picos ocasionales
Configura MistralService para:
- Tener latencia P50 baja durante horario laboral (idealmente <3s)
- No pagar GPU encendida toda la noche
- Acotar el costo máximo (no escalar a 50 GPUs por un burst)
Justifica cada parámetro elegido (no solo pon números: di por qué).
Ver solución
@app.cls(
image=imagen,
gpu="A10G",
volumes={CACHE_DIR: pesos_volume},
timeout=600,
min_containers=0, # 0 fuera de horario, no pagamos GPU de noche
max_containers=8, # tope: cubre bursts moderados sin riesgo de gasto descontrolado
scaledown_window=900, # 15 min de gracia — bursts cercanos no pagan cold start
)
class MistralService: ...
# Warm pool por horario, usando schedule
import datetime
@app.function(
schedule=modal.Period(minutes=4),
# Modal no permite filtrar por hora aún; el filtro va en código.
)
def keep_warm():
from datetime import datetime
hora = datetime.utcnow().hour # ajusta a tu timezone
# Solo mantener warm de 14:00 a 22:00 UTC (≈ 9am-5pm hora de Ciudad de México)
if 14 <= hora < 22:
servicio = MistralService()
servicio.generar.remote("ping", max_tokens=1, temperature=0.0)
Razonamiento:
min_containers=0: no quiero pagar GPU permanente cuando hay poco tráfico (de noche).scaledown_window=900: durante el día, bursts con valles de minutos no deben re-pagar cold start.max_containers=8: 8 × A10G × 1hr ≈ $8/hr es mi tope de costo simultáneo aceptable. Si necesito más, prefiero rate-limit a los usuarios que escalar sin control.keep_warmfiltrado por hora UTC mantiene 1 container vivo durante horario laboral sin pagar el resto del día.
Trade-off aceptado: las primeras llamadas del día (9am) pagan cold start. Quien necesite latencia consistente 24/7 sube min_containers a 1.
Resumen
Aprendiste:
- ✅ Cómo escala Modal: pool de containers que crece/decrece con tráfico
- ✅ Cuatro palancas:
max_containers,min_containers,scaledown_window,@modal.concurrent - ✅ Diagnosticar cold start vs GPU saturada por logs
- ✅ Patrones de warm pool: permanente, por horario, snapshot
- ✅ Optimizaciones de cold start: pre-bake imagen, memory snapshot, modelos cuantizados
Checkpoint: si puedes mirar tu deployment y argumentar por qué elegiste cada parámetro (no solo decir "lo dejé en default"), estás listo.
Siguiente cápsula
07 — Cost optimization cubre el otro lado de la moneda: ¿cuánto cuesta exactamente tu deployment? Vas a aprender a calcular cost-per-request, elegir GPU según throughput, y aplicar batching para reducir factura sin sacrificar latencia.
Es la cápsula que necesitas antes de mostrar números a un cofounder o PM que pregunte "¿cuánto nos cuesta servir 1M de requests al mes?"
Recursos
- Modal — Scaling — todos los parámetros de escala oficiales.
- Modal — Cold start optimization — técnicas oficiales para reducir cold start.
- Modal — Memory snapshots — restauración rápida de containers.
- Modal — Schedules — cron jobs y warm pool por horario.
- vLLM — Continuous batching explained — por qué
max_inputs=1está bien con LLMs.