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.Volume para 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):

GPUVRAMBueno paraCosto aprox
T416 GBModelos pequeños (3B-7B cuantizados)$
A10G24 GBMistral 7B, Llama 3 8B sin cuantizar$$
L424 GBSimilar a A10G, mejor inferencia$$
A100 (40GB / 80GB)40 / 80 GBLlama 3 70B con cuantización, throughput alto$$$
H10080 GBModelos 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:

  1. Pesos en disco persistente (modal.Volume) → se descargan una sola vez, viven entre containers.
  2. Pesos en VRAM → se cargan cada vez que el container arranca (no hay forma de evitar esto, pero es rápido).
  3. 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?

ElementoFunció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=1Activa 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=600Tolera 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_tokens por 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:

  1. Convertirlo a la versión con @app.cls y @modal.enter()
  2. Aceptar una lista de prompts en una sola llamada y devolver la lista de respuestas
  3. 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=1 para 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

  1. Modal — GPU acceleration — todas las GPUs disponibles y cómo elegirlas.
  2. Modal — Volumes — almacenamiento persistente.
  3. Modal — Class lifecycle@modal.enter, @modal.exit.
  4. vLLM — Quickstart — el inference server que usamos.
  5. Hugging Face — Mistral 7B Instruct v0.3 — la model card.
  6. hf_transfer GitHub — descarga acelerada.