Módulo 6: Modal — Deployment serverless de LLMs

Primera función serverless con dependencias

En la cápsula anterior corriste un "hola mundo" sin dependencias. Funcionó, pero no usaste nada característico de Modal todavía: cualquier script Python local hace lo mismo.

Ahora vamos a construir una función serverless real: con dependencias externas, manejo de secretos, y una idea clara de qué pasa "abajo" cuando llamas .remote(). Es la base mental que necesitas antes de meter GPUs y LLMs en las cápsulas siguientes.

Al terminar vas a poder:

  • Definir una imagen de container con dependencias específicas (pip_install, archivos locales, comandos shell)
  • Inyectar secretos (API keys) sin escribirlos en código
  • Observar y entender qué es exactamente el cold start, y qué lo dispara
  • Diferenciar entre .local(), .remote() y .spawn() y cuándo usar cada uno

Por qué importa esta cápsula

Modal no es "Python en la nube" en abstracto. Modal es un container que ejecuta tu función. Si tu función necesita requests, tu container necesita tener requests instalado. Si tu función llama a OpenAI, tu container necesita conocer la API key.

Esto es exactamente lo que Docker hace, pero declarado en Python en vez de un Dockerfile. Una vez que tienes este modelo mental claro, todo lo demás del módulo se vuelve fácil.


El modelo mental: imagen + función

Cada función de Modal vive dentro de una imagen. Una imagen es la receta del container: qué sistema operativo base, qué dependencias, qué archivos. Modal construye la imagen una vez y la cachea — corridas siguientes reutilizan la misma imagen y arrancan rápido.

┌─────────────────────────────────────┐
│ Imagen de container                 │
│ ┌─────────────────────────────────┐ │
│ │ Linux base (debian slim)        │ │
│ │ + Python 3.11                   │ │
│ │ + dependencias pip              │ │
│ │ + archivos copiados             │ │
│ │ + variables de entorno          │ │
│ └─────────────────────────────────┘ │
│                                     │
│ Cuando llamas .remote():            │
│ → Modal levanta un container        │
│ → Ejecuta tu función dentro         │
│ → Devuelve resultado                │
│ → Container queda warm unos minutos │
└─────────────────────────────────────┘

Ejemplo trabajado: clima desde un endpoint público

Vamos a construir una función que consulta una API pública del clima (no requiere API key) y devuelve la temperatura actual de una ciudad. Es deliberadamente simple — el punto es ver el flujo completo.

Crea clima.py:

# clima.py
import modal

# Imagen con la dependencia 'requests' instalada
imagen = modal.Image.debian_slim(python_version="3.11").pip_install("requests")

app = modal.App("clima-demo", image=imagen)


@app.function()
def temperatura_actual(ciudad: str) -> dict:
    import requests

    # Open-Meteo es público y no requiere API key
    geo = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": ciudad, "count": 1},
        timeout=10,
    ).json()

    if not geo.get("results"):
        return {"error": f"No encontré la ciudad '{ciudad}'"}

    lat = geo["results"][0]["latitude"]
    lon = geo["results"][0]["longitude"]

    clima = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={"latitude": lat, "longitude": lon, "current_weather": True},
        timeout=10,
    ).json()

    return {
        "ciudad": ciudad,
        "temperatura_c": clima["current_weather"]["temperature"],
        "viento_kmh": clima["current_weather"]["windspeed"],
    }


@app.local_entrypoint()
def main():
    for ciudad in ["Ciudad de México", "Buenos Aires", "Madrid"]:
        print(temperatura_actual.remote(ciudad))

Ejecutar:

modal run clima.py

Output esperado (primera vez):

✓ Initialized.
✓ Building image (this will take ~30s the first time)
  - Installing requests
✓ Created function temperatura_actual.
{'ciudad': 'Ciudad de México', 'temperatura_c': 18.4, 'viento_kmh': 5.1}
{'ciudad': 'Buenos Aires', 'temperatura_c': 24.6, 'viento_kmh': 12.3}
{'ciudad': 'Madrid', 'temperatura_c': 11.2, 'viento_kmh': 8.7}
✓ App finished.

Segunda corrida: mucho más rápido (3-5s típico), porque la imagen ya existe en el caché y el container probablemente sigue "warm".


¿Qué acaba de pasar?

Cinco cosas, en este orden:

  1. Modal serializó tu código. Tu clima.py y sus imports se empaquetaron.
  2. Modal verificó si la imagen existía. Como era la primera vez, la construyó (debian slim + pip install requests).
  3. Modal levantó un container con esa imagen.
  4. Ejecutó tu función dentro del container, una vez por cada .remote("...").
  5. Devolvió los resultados a tu proceso local y serializó el response.

Lo importante de este flujo: el import requests que ves en el código se ejecuta en el container remoto, no en tu máquina. Por eso requests puede no estar instalado localmente y aún así funcionar. Lo que importa es que esté en la imagen.


Manejo de secretos

Una API pública sirve para demo, pero en el mundo real tu función va a hablar con OpenAI, Anthropic, una DB con password — algo con credenciales.

Nunca pongas API keys en el código fuente. Modal tiene un sistema de secrets que las inyecta como variables de entorno al container.

Crear un secreto:

modal secret create openai-secret OPENAI_API_KEY=sk-tu-clave-real

Usarlo en la función:

import modal

app = modal.App("chat-con-openai")

imagen = modal.Image.debian_slim().pip_install("openai")

@app.function(
    image=imagen,
    secrets=[modal.Secret.from_name("openai-secret")],
)
def chat(prompt: str) -> str:
    import os
    from openai import OpenAI

    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content


@app.local_entrypoint()
def main():
    print(chat.remote("Resume FastAPI en una frase"))

os.environ["OPENAI_API_KEY"] lee la variable inyectada por Modal. Tu código fuente no tiene la clave. Si quitas el secret, la función falla con un error claro de "variable de entorno no encontrada".

Verificación visual: abre tu dashboard de Modal → Secrets. Deberías ver openai-secret listado. Si vas al detalle, Modal no te muestra el valor (es un secreto): puedes solamente rotarlo o eliminarlo.


Local vs remote vs spawn

Cada función decorada tiene tres formas de invocarse:

LlamadaDónde correDevuelveCuándo usarla
f.local(args)Tu máquinaEl resultadoDebug rápido sin pagar Modal
f.remote(args)Container ModalEl resultado (bloqueante)El caso normal
f.spawn(args)Container ModalUn handle (no bloqueante)Fire-and-forget o procesamiento masivo en paralelo

Ejemplo de .spawn para paralelismo:

# Lanza 100 jobs en paralelo
handles = [chat.spawn(f"Resume {tema}") for tema in temas]
# Recolecta resultados cuando estén
resultados = [h.get() for h in handles]

Si haces esto con .remote(), los 100 jobs corren secuencialmente y tardan 100x. Con .spawn(), Modal escala a múltiples containers y corren en paralelo.


Cold start en detalle

Llamas .remote() por primera vez. Modal:

  1. Busca un container warm con tu imagen. Si encuentra uno → arranca casi instantáneo.
  2. Si no, construye o trae la imagen desde caché distribuido.
  3. Arranca un container desde la imagen (~1-3s).
  4. Importa tu módulo Python dentro del container.
  5. Ejecuta la función.

El tiempo total se llama cold start. Su componente más caro suele ser el #4: importar dependencias pesadas (torch, transformers) toma varios segundos solo en importar. Para una función simple como temperatura_actual, el cold start completo es ~3-8s. Para una que importa transformers será 10-20s. Para una que carga pesos de un modelo de 14GB será 30-90s. Lo veremos en detalle en la cápsula 06.

¿Por qué importa? Si tu API HTTP recibe un request y tarda 60s en responder porque hubo cold start, perdiste al usuario. Hay estrategias para minimizarlo, también cubiertas en 06.


Trampas comunes

Trampa 1 — "Mi import falla con ModuleNotFoundError aunque está en requirements.txt." Tu requirements.txt no se aplica automáticamente. Modal solo instala lo que pongas en la imagen. Usa imagen.pip_install_from_requirements("requirements.txt") si quieres reusarlo:

imagen = modal.Image.debian_slim().pip_install_from_requirements("requirements.txt")

Trampa 2 — "Mi función no ve los archivos de mi proyecto." Modal sube solo el archivo que se ejecuta y los módulos importados directamente. Si necesitas otros archivos (CSV, JSON, modelos locales), usa image.add_local_file() o add_local_dir():

imagen = modal.Image.debian_slim().add_local_dir("./datos", remote_path="/datos")

Trampa 3 — "Cambié el código pero corre la versión vieja." Modal a veces cachea con agresividad. Si parece desfasado, ejecuta con --force o cambia el nombre de la App temporalmente para forzar rebuild.

Trampa 4 — "Hardcodeé la API key 'solo para probar' y la commiteé." Pasa todo el tiempo. Si lo hiciste, rota la clave inmediatamente (panel de OpenAI/etc) y borra el commit del historial con git filter-repo o BFG. Tener la clave en el historial de un repo público equivale a haberla publicado.


Ejercicio

Refactoriza la función chat de arriba para que:

  1. Reciba un segundo parámetro modelo: str = "gpt-4o-mini"
  2. Acepte una lista de prompts y los procese en paralelo usando .spawn
  3. Devuelva la lista de respuestas en el mismo orden
Ver solución
import modal

app = modal.App("chat-paralelo")
imagen = modal.Image.debian_slim().pip_install("openai")


@app.function(
    image=imagen,
    secrets=[modal.Secret.from_name("openai-secret")],
)
def chat(prompt: str, modelo: str = "gpt-4o-mini") -> str:
    import os
    from openai import OpenAI

    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    response = client.chat.completions.create(
        model=modelo,
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content


@app.local_entrypoint()
def main():
    prompts = [
        "Resume FastAPI en una frase",
        "Resume Django en una frase",
        "Resume Flask en una frase",
    ]
    # spawn en paralelo, get() recolecta en orden
    handles = [chat.spawn(p) for p in prompts]
    respuestas = [h.get() for h in handles]
    for prompt, respuesta in zip(prompts, respuestas):
        print(f"\n→ {prompt}\n  {respuesta}")

Por qué la solución usa .spawn y no .remote en un loop: .remote() en un loop espera cada uno antes de empezar el siguiente (3 prompts = 3× la latencia). .spawn() los manda todos a Modal sin esperar, y .get() los recolecta cuando ya están listos en paralelo.


Resumen

Aprendiste:

  • ✅ Las funciones de Modal viven dentro de una imagen que tú declaras
  • ✅ Dependencias Python se agregan con .pip_install(...) o .pip_install_from_requirements(...)
  • ✅ Las credenciales no van en código — usa modal.Secret
  • .local(), .remote() y .spawn() son tres formas distintas de invocar la misma función
  • ✅ El cold start es el costo de levantar un container la primera vez; depende mucho de qué importes

Checkpoint: si pudiste correr chat.remote("...") usando un secret y la respuesta de OpenAI te llegó al terminal, estás listo.


Siguiente cápsula

En 04 — Deploy de un modelo LLM vamos a saltar a lo serio: deployar Mistral 7B con vLLM en una GPU A10G. Vas a ver:

  • Cómo declarar gpu="A10G" en el decorador
  • Cómo cachear pesos del modelo en disco persistente (modal.Volume) para no descargarlos en cada cold start
  • Cómo medir cuánto tarda la primera inferencia vs la décima

Después de esa cápsula vas a tener un endpoint LLM corriendo en tu cuenta de Modal.


Recursos

  1. Modal — Defining images — todas las opciones para construir imágenes.
  2. Modal — Secrets — gestión de credenciales.
  3. Modal — Function lifecycle@enter, @exit, container reuse.
  4. Modal — Spawn for parallel jobs — paralelización.
  5. Open-Meteo API — API pública usada en el ejemplo.