Módulo 6: Modal — Deployment serverless de LLMs
Deploy de un modelo LLM en Modal
Hasta acá tu función serverless llamaba a OpenAI vía API. Eso es útil, pero no es lo que distingue a Modal: para eso tienes el Módulo 2 (OpenAI directo). Lo que hace especial a Modal es que tú puedes correr el modelo, no solo consumir una API ajena.
En esta cápsula vas a deployar Mistral 7B Instruct corriendo en una GPU A10G dentro de un container de Modal. Vas a ver el flujo completo: imagen con CUDA, descarga de pesos, primera inferencia, y caché persistente para que las siguientes corridas no descarguen el modelo desde cero.
Al terminar vas a poder:
- Pedir una GPU específica en el decorador y entender por qué eliges esa
- Usar
modal.Volumepara persistir los pesos del modelo entre invocaciones - Cargar Mistral 7B con vLLM y generar texto
- Medir y entender por qué la primera llamada tarda más que las siguientes
Por qué importa
El cold start de un container con Python básico era ~3-8s. Con un LLM, la primera llamada puede tardar 60-120s si descargas 14GB de pesos. Si no resuelves esto, tu endpoint es inviable para usuarios reales.
La solución no es magia: es entender cuándo se descargan los pesos y dónde se guardan. Una vez que esto está claro, optimizar es un par de líneas de código.
Elegir GPU
Modal te deja pedir GPU con un string en el decorador:
@app.function(gpu="A10G")
def inferencia(prompt: str): ...
Las opciones (a inicios de 2026):
| GPU | VRAM | Bueno para | Costo aprox |
|---|---|---|---|
| T4 | 16 GB | Modelos pequeños (3B-7B cuantizados) | $ |
| A10G | 24 GB | Mistral 7B, Llama 3 8B sin cuantizar | $$ |
| L4 | 24 GB | Similar a A10G, mejor inferencia | $$ |
| A100 (40GB / 80GB) | 40 / 80 GB | Llama 3 70B con cuantización, throughput alto | $$$ |
| H100 | 80 GB | Modelos muy grandes o throughput máximo | $$$$ |
Regla práctica:
- Si tu modelo entra en VRAM con margen → la GPU más barata que cumpla.
- Pasar a GPU más cara solo si la barata no entra, o si necesitas más throughput.
Mistral 7B fp16 ocupa ~14GB. Entra en A10G (24GB) con margen para activaciones y KV cache. No entra cómodo en T4 (16GB) salvo que cuantices. Por eso usamos A10G.
Consulta precios actuales en modal.com/pricing — cambian.
El problema de los pesos
Si haces esto (mala idea):
@app.function(gpu="A10G")
def generar(prompt: str):
from vllm import LLM
llm = LLM("mistralai/Mistral-7B-Instruct-v0.3") # ❌ descarga cada vez
return llm.generate(prompt)[0].outputs[0].text
Cada llamada descarga 14GB de Hugging Face. Eso es:
- Lento (1-3 minutos)
- Caro (pagas GPU mientras descarga)
- Frágil (Hugging Face podría rate-limitear)
Lo correcto es separar dónde viven los pesos de cuándo se cargan en GPU:
- Pesos en disco persistente (
modal.Volume) → se descargan una sola vez, viven entre containers. - Pesos en VRAM → se cargan cada vez que el container arranca (no hay forma de evitar esto, pero es rápido).
- Modelo listo para inferir → se reusa mientras el container está warm.
Ejemplo trabajado: Mistral 7B con caché persistente
Crea mistral.py:
# mistral.py
import modal
app = modal.App("mistral-modal")
# Volume para cachear los pesos descargados (persiste entre runs)
pesos_volume = modal.Volume.from_name(
"mistral-pesos", create_if_missing=True
)
CACHE_DIR = "/cache/huggingface"
# Imagen con vLLM y dependencias CUDA
imagen = (
modal.Image.debian_slim(python_version="3.11")
.pip_install(
"vllm==0.6.3",
"huggingface_hub[hf_transfer]==0.26.2",
)
.env({"HF_HOME": CACHE_DIR, "HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
MODELO = "mistralai/Mistral-7B-Instruct-v0.3"
@app.function(
image=imagen,
gpu="A10G",
volumes={CACHE_DIR: pesos_volume},
timeout=600, # 10 min de tolerancia para primera descarga
)
def generar(prompt: str, max_tokens: int = 256) -> str:
from vllm import LLM, SamplingParams
llm = LLM(model=MODELO, download_dir=CACHE_DIR)
sampling = SamplingParams(temperature=0.7, max_tokens=max_tokens)
# Mistral instruct format: [INST] ... [/INST]
prompt_formateado = f"[INST] {prompt} [/INST]"
output = llm.generate(prompt_formateado, sampling)
return output[0].outputs[0].text.strip()
@app.local_entrypoint()
def main():
import time
pregunta = "Explica qué es FastAPI en 3 frases."
print(f"→ {pregunta}\n")
inicio = time.time()
respuesta = generar.remote(pregunta)
duracion = time.time() - inicio
print(respuesta)
print(f"\n⏱ {duracion:.1f}s total (incluye cold start si aplica)")
Primera corrida:
modal run mistral.py
Qué esperar:
✓ Building image... (60-90s — instala vLLM con CUDA, pesado)
✓ Starting container (A10G GPU)
✓ Downloading Mistral 7B from Hugging Face (~14GB)... (2-4 min con hf_transfer)
✓ Loading model into GPU (~30s)
✓ Generating...
FastAPI es un framework web moderno para Python que se enfoca en
construir APIs rápidas y robustas. Aprovecha tipos de Python para
validación automática de inputs y genera documentación interactiva
sin esfuerzo extra. Su rendimiento es comparable a Node.js y Go
gracias a estar construido sobre Starlette y Pydantic.
⏱ 240.3s total
Segunda corrida (inmediatamente después):
✓ Container reused (warm)
✓ Generating...
[respuesta]
⏱ 3.8s total
Tercera corrida (10 minutos después, cuando el container ya se apagó):
✓ Starting container (A10G GPU)
✓ Loading model from volume cache (~25s — pesos ya descargados)
✓ Generating...
[respuesta]
⏱ 31.5s total
Tres regímenes distintos:
- Cold start frío frío (primera vez): ~4 min — construye imagen + descarga pesos
- Warm (mismo container vivo): ~3-5s — el modelo ya está en VRAM
- Cold start tibio (container nuevo, pesos cacheados): ~30s — carga pesos del volume a VRAM
¿Qué hizo cada pieza?
| Elemento | Función |
|---|---|
modal.Volume.from_name(..., create_if_missing=True) | Disco persistente compartido entre containers. Persiste entre corridas. |
imagen.env({"HF_HOME": CACHE_DIR}) | Le dice a Hugging Face que descargue a /cache/huggingface (dentro del volume) en vez de ~/.cache. |
HF_HUB_ENABLE_HF_TRANSFER=1 | Activa hf_transfer, un downloader 3-5× más rápido que el default. |
volumes={CACHE_DIR: pesos_volume} | Monta el volume en el container, así Hugging Face encuentra/escribe los pesos. |
gpu="A10G" | Pide GPU A10G específicamente. |
timeout=600 | Tolera hasta 10 min de ejecución (default es 5 min y la primera descarga lo excede). |
Concepto clave: el Volume no es la VRAM. Es disco que persiste. Modal carga los pesos del volume al disco del container primero, después vLLM los manda a VRAM. La parte cara (descargar de internet) sucede una sola vez en la vida del volume.
Optimizar el cold start tibio aún más
El cold start de ~30s carga pesos del volume a VRAM. Hay dos optimizaciones siguientes:
1. @modal.enter() — cargar el modelo una vez por container, no por llamada.
Si tu función es invocada 100 veces dentro del mismo container, no quieres cargar Mistral 100 veces. Modal te deja inicializar recursos al arrancar el container con un lifecycle hook:
@app.cls(
image=imagen,
gpu="A10G",
volumes={CACHE_DIR: pesos_volume},
timeout=600,
)
class MistralService:
@modal.enter()
def cargar_modelo(self):
from vllm import LLM
self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)
@modal.method()
def generar(self, prompt: str, max_tokens: int = 256) -> str:
from vllm import SamplingParams
sampling = SamplingParams(temperature=0.7, max_tokens=max_tokens)
output = self.llm.generate(f"[INST] {prompt} [/INST]", sampling)
return output[0].outputs[0].text.strip()
@app.local_entrypoint()
def main():
service = MistralService()
print(service.generar.remote("¿Qué es Modal?"))
@modal.enter() corre una sola vez al arrancar el container. Las siguientes invocaciones de generar reusan self.llm que ya está en VRAM. Esto es lo que realmente quieres en producción.
2. container_idle_timeout — mantener el container warm más tiempo.
Por default Modal apaga containers idle al cabo de unos minutos. Si esperas tráfico burst y quieres evitar cold starts, súbelo:
@app.cls(
...,
container_idle_timeout=600, # 10 min de idle antes de apagar
)
class MistralService: ...
Trade-off: containers warm cobran (poco) por estar prendidos. Es un balance entre latencia y costo. Lo profundizamos en la cápsula 06.
Trampas comunes
Trampa 1 — "Out of memory" al cargar el modelo.
Mistral 7B fp16 cabe en A10G (24GB), pero apenas. Si tu prompt es muy largo o max_tokens muy alto, el KV cache puede empujarte fuera. Soluciones:
- Reduce
max_tokenspor request. - Pasa a L4/A100 (más VRAM).
- Usa quantización (AWQ/GPTQ) para reducir el modelo a ~4GB.
Trampa 2 — "El primer download tardó 8 minutos."
Sin hf_transfer, la descarga es 3-5× más lenta. Verifica que tienes HF_HUB_ENABLE_HF_TRANSFER=1 en imagen.env(...) y huggingface_hub[hf_transfer] instalado.
Trampa 3 — "El volume llenó mi cuota gratuita." Los pesos pesan. Si experimentas con muchos modelos, el volume puede crecer rápido. Modal cobra por GB-mes de storage. Limpia volumes que no uses:
modal volume list
modal volume delete mistral-pesos # cuidado: borra todo
Trampa 4 — "Estoy obligado a usar Mistral?"
No. Cambia MODELO por cualquier modelo de Hugging Face compatible con vLLM: meta-llama/Llama-3.1-8B-Instruct, Qwen/Qwen2.5-7B-Instruct, etc. Algunos modelos (Llama, Gemma) requieren aceptar la licencia en HF y pasar HF_TOKEN como secret.
Trampa 5 — "vLLM tarda mucho importando." vLLM trae CUDA libs grandes. La primera vez que construyes la imagen toma 60-90s. Después se cachea. Si reconstruyes la imagen seguido (cambias dependencias), considera fijar versiones para que el caché ayude.
Ejercicio
Modifica el código para:
- Convertirlo a la versión con
@app.clsy@modal.enter() - Aceptar una lista de prompts en una sola llamada y devolver la lista de respuestas
- Medir el tiempo por prompt después del primero (debería bajar drásticamente porque el modelo ya está cargado)
Ver solución
# mistral_clase.py
import modal
import time
app = modal.App("mistral-clase")
pesos_volume = modal.Volume.from_name("mistral-pesos", create_if_missing=True)
CACHE_DIR = "/cache/huggingface"
imagen = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("vllm==0.6.3", "huggingface_hub[hf_transfer]==0.26.2")
.env({"HF_HOME": CACHE_DIR, "HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
MODELO = "mistralai/Mistral-7B-Instruct-v0.3"
@app.cls(image=imagen, gpu="A10G", volumes={CACHE_DIR: pesos_volume}, timeout=600)
class MistralService:
@modal.enter()
def cargar(self):
from vllm import LLM
self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)
@modal.method()
def generar_batch(self, prompts: list[str], max_tokens: int = 256) -> list[str]:
from vllm import SamplingParams
sampling = SamplingParams(temperature=0.7, max_tokens=max_tokens)
prompts_fmt = [f"[INST] {p} [/INST]" for p in prompts]
outputs = self.llm.generate(prompts_fmt, sampling)
return [o.outputs[0].text.strip() for o in outputs]
@app.local_entrypoint()
def main():
prompts = [
"Explica FastAPI en una frase",
"Explica Django en una frase",
"Explica Flask en una frase",
"Explica Starlette en una frase",
]
service = MistralService()
inicio = time.time()
respuestas = service.generar_batch.remote(prompts)
duracion = time.time() - inicio
for p, r in zip(prompts, respuestas):
print(f"\n→ {p}\n {r}")
print(f"\n⏱ {duracion:.1f}s para {len(prompts)} prompts ({duracion/len(prompts):.1f}s/prompt)")
vLLM además batchea automáticamente los prompts cuando los recibe juntos. Vas a ver que 4 prompts tardan menos de 4× lo que tarda uno — esa es la ganancia del batching en GPU.
Resumen
Aprendiste:
- ✅ Pedir GPU con
gpu="A10G"(o T4/L4/A100/H100 según necesidad) - ✅ Cachear pesos del modelo en
modal.Volume(evita descarga repetida) - ✅ Configurar
HF_HUB_ENABLE_HF_TRANSFER=1para descargas rápidas - ✅ Tres regímenes de latencia: cold-cold (~minutos), cold-tibio (~segundos), warm (~milisegundos)
- ✅ Patrón
@app.cls+@modal.enter()para cargar el modelo una vez por container
Checkpoint: si tu segunda inferencia tarda <5s y tu dashboard muestra el volume mistral-pesos con ~14GB, estás listo.
Siguiente cápsula
En 05 — API REST con Modal vas a exponer tu Mistral como endpoint HTTP público, con autenticación básica y request/response JSON. Es lo que cualquier cliente externo necesita para consumir tu modelo. Ahí dejamos de invocar con modal run y empezamos a hacer curl https://tu-app.modal.run/chat.
Recursos
- Modal — GPU acceleration — todas las GPUs disponibles y cómo elegirlas.
- Modal — Volumes — almacenamiento persistente.
- Modal — Class lifecycle —
@modal.enter,@modal.exit. - vLLM — Quickstart — el inference server que usamos.
- Hugging Face — Mistral 7B Instruct v0.3 — la model card.
- hf_transfer GitHub — descarga acelerada.