Módulo 6: Modal — Deployment serverless de LLMs
API REST con Modal
En la cápsula anterior llamabas a Mistral con modal run desde tu terminal. Útil para experimentar, inservible para producción: tu chatbot real, tu app web, tu cliente móvil — ninguno habla "Python con SDK de Modal". Hablan HTTP.
En esta cápsula vas a convertir tu función Mistral en un endpoint HTTPS público que cualquiera puede consumir con curl, fetch, o cualquier librería HTTP. Vamos a usar FastAPI montado dentro de Modal con el decorador @asgi_app, agregar autenticación con un header Authorization, y diseñar el contrato JSON.
Al terminar vas a poder:
- Exponer una función de Modal como endpoint HTTPS sin servidores intermedios
- Diseñar un contrato request/response JSON con Pydantic
- Proteger el endpoint con autenticación por token simple
- Llamar tu endpoint desde
curly desde Python como cualquier API externa
Por qué importa
Un LLM detrás de modal run es un experimento. Un LLM detrás de https://tu-app.modal.run/chat es infraestructura. Esta cápsula es la que te lleva de uno al otro.
Modal hace esto sorprendentemente simple porque ya construyó el container, el autoscaler, el certificado TLS, y la URL pública. Tú solo declaras el handler.
Modelo mental: una función + un router HTTP
Modal te da dos primitivas para HTTP:
| Decorador | Cuándo usar |
|---|---|
@modal.fastapi_endpoint() | Endpoint único, un GET/POST simple, sin múltiples rutas |
@modal.asgi_app() | App FastAPI/Starlette completa con varios endpoints, middleware, etc |
Para algo serio usa @modal.asgi_app() con una FastAPI() adentro — es lo que veremos. La capa FastAPI te da:
- Routing (
/chat,/health,/models) - Validación con Pydantic
- Documentación automática (
/docs) - Middleware (auth, CORS, logging)
Modal te da:
- El container que sirve esa app
- URL pública con TLS
- Autoscaling
Ejemplo trabajado: endpoint Mistral con auth
Parte del código de la cápsula 04 (la versión con @app.cls) y agrégale el endpoint HTTP. Crea api.py:
# api.py
import os
import modal
from typing import Annotated
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel, Field
app = modal.App("mistral-api")
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",
"fastapi[standard]",
)
.env({"HF_HOME": CACHE_DIR, "HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
MODELO = "mistralai/Mistral-7B-Instruct-v0.3"
# Contrato de la API
class ChatRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000)
max_tokens: int = Field(256, ge=1, le=2048)
temperature: float = Field(0.7, ge=0.0, le=2.0)
class ChatResponse(BaseModel):
respuesta: str
modelo: str
tokens_generados: int
# La clase que corre con GPU
@app.cls(
image=imagen,
gpu="A10G",
volumes={CACHE_DIR: pesos_volume},
timeout=600,
container_idle_timeout=300,
)
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) -> dict:
from vllm import SamplingParams
sampling = SamplingParams(temperature=temperature, max_tokens=max_tokens)
prompt_fmt = f"[INST] {prompt} [/INST]"
output = self.llm.generate(prompt_fmt, sampling)[0]
return {
"texto": output.outputs[0].text.strip(),
"tokens": len(output.outputs[0].token_ids),
}
# Helper de autenticación
def verificar_token(authorization: str | None) -> None:
token_esperado = os.environ.get("API_TOKEN")
if not token_esperado:
raise HTTPException(500, "API_TOKEN no configurado en el servidor")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Falta header 'Authorization: Bearer <token>'")
if authorization.removeprefix("Bearer ") != token_esperado:
raise HTTPException(403, "Token inválido")
# El ASGI app (FastAPI) que Modal va a servir
@app.function(
image=imagen,
secrets=[modal.Secret.from_name("mistral-api-token")],
)
@modal.asgi_app()
def web():
web_app = FastAPI(title="Mistral API")
@web_app.get("/health")
def health():
return {"status": "ok", "modelo": MODELO}
@web_app.post("/chat", response_model=ChatResponse)
def chat(
req: ChatRequest,
authorization: Annotated[str | None, Header()] = None,
):
verificar_token(authorization)
servicio = MistralService()
resultado = servicio.generar.remote(
req.prompt, req.max_tokens, req.temperature
)
return ChatResponse(
respuesta=resultado["texto"],
modelo=MODELO,
tokens_generados=resultado["tokens"],
)
return web_app
Setup del secret de autenticación
Antes de deployar, crea el secret con tu token:
modal secret create mistral-api-token API_TOKEN=algun-token-largo-y-aleatorio
Genera un token aleatorio decente con:
python -c "import secrets; print(secrets.token_urlsafe(32))"
Guárdalo en tu password manager local; lo vas a necesitar para llamar el endpoint.
Deployar
Hasta ahora usabas modal run (efímero, se apaga cuando termina el script). Para un endpoint público necesitas modal deploy:
modal deploy api.py
Output:
✓ Created objects.
├── 🔨 Created mount /Users/.../api.py
├── 🔨 Created function MistralService.*.
└── 🔨 Created web function web => https://tu-usuario--mistral-api-web.modal.run
✓ App deployed in 8.2s! 🎉
La URL https://tu-usuario--mistral-api-web.modal.run es pública, con HTTPS, autoscaling, y siempre disponible (aunque puede tener cold start si nadie la usó en 5 minutos).
Probar el endpoint
Healthcheck (sin auth):
curl https://tu-usuario--mistral-api-web.modal.run/health
# {"status":"ok","modelo":"mistralai/Mistral-7B-Instruct-v0.3"}
Chat (con auth):
curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
-H "Authorization: Bearer tu-token-de-arriba" \
-H "Content-Type: application/json" \
-d '{"prompt": "Explica REST en 2 frases.", "max_tokens": 200}'
Respuesta esperada:
{
"respuesta": "REST es un estilo arquitectónico para diseñar APIs donde los recursos se identifican por URLs y se manipulan con métodos HTTP estándar (GET, POST, PUT, DELETE). Es stateless: cada request lleva toda la información necesaria, sin depender de sesiones del lado del servidor.",
"modelo": "mistralai/Mistral-7B-Instruct-v0.3",
"tokens_generados": 78
}
Sin token:
curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
-H "Content-Type: application/json" \
-d '{"prompt": "hola"}'
# {"detail":"Falta header 'Authorization: Bearer <token>'"}
Con token inválido:
curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
-H "Authorization: Bearer xxx" \
-H "Content-Type: application/json" \
-d '{"prompt": "hola"}'
# {"detail":"Token inválido"}
Desde Python (cliente):
import os
import httpx
BASE_URL = "https://tu-usuario--mistral-api-web.modal.run"
TOKEN = os.environ["MISTRAL_API_TOKEN"] # ponlo en tu .env local
response = httpx.post(
f"{BASE_URL}/chat",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"prompt": "¿Qué es Modal?", "max_tokens": 200},
timeout=60,
)
print(response.json()["respuesta"])
Documentación automática
FastAPI dentro de Modal genera docs automáticamente:
- Swagger UI:
https://tu-usuario--mistral-api-web.modal.run/docs - ReDoc:
https://tu-usuario--mistral-api-web.modal.run/redoc - OpenAPI JSON:
https://tu-usuario--mistral-api-web.modal.run/openapi.json
Abre /docs en el navegador. Vas a ver tus dos endpoints (/health, /chat), los schemas de request/response, y puedes probarlos desde ahí.
Trampa: la autenticación funciona también en /docs. Necesitas pegar tu token en el botón "Authorize" arriba a la derecha (Swagger lo entiende vía esquema de seguridad).
Gestionar el deployment
modal app list # ver tus apps deployadas
modal app logs mistral-api # logs en vivo
modal app stop mistral-api # apagar el deployment
modal deploy api.py # re-deployar (sobreescribe la versión actual)
Re-deploy es atómico: Modal no apaga la versión vieja hasta que la nueva está lista. No hay downtime visible.
Trampas comunes
Trampa 1 — "Me da 500 al llamar /chat y el log dice API_TOKEN no configurado."
Olvidaste asociar el secret a la función web. Verifica que secrets=[modal.Secret.from_name("mistral-api-token")] esté en el decorador correcto (en el de web, no en el de MistralService — la auth se valida en web).
Trampa 2 — "El endpoint tarda 60s la primera vez."
Es el cold start del container GPU (cargar Mistral). El healthcheck no lo dispara — solo /chat que invoca MistralService. Estrategias para mitigar en la cápsula 06.
Trampa 3 — "Mi auth solo compara strings — ¿es seguro?" Para uso interno o un cliente confiable, sí. Para producción con varios clientes:
- Usa comparación constante para evitar timing attacks:
hmac.compare_digest(token, esperado) - Usa tokens por cliente rotables (no un único token compartido)
- Considera OAuth2/JWT si tienes varios usuarios — pero eso es otra cápsula
Trampa 4 — "Quiero /chat accesible desde mi frontend en otro dominio (CORS error)."
Agrega middleware de CORS:
from fastapi.middleware.cors import CORSMiddleware
web_app.add_middleware(
CORSMiddleware,
allow_origins=["https://tu-frontend.com"],
allow_methods=["POST", "GET"],
allow_headers=["Authorization", "Content-Type"],
)
Trampa 5 — "Cuando llamo el endpoint, Pydantic me rechaza prompts de >4000 caracteres."
Lo limitaste tú en el contrato con max_length=4000. Es buena práctica para evitar abuse y costos descontrolados. Sube el límite o quítalo si tu caso real lo necesita.
Trampa 6 — "Quiero streaming de tokens (Server-Sent Events)."
Sí se puede con Modal + FastAPI usando StreamingResponse y un generador. Es un patrón propio que merece su propia cápsula — por ahora, el endpoint devuelve la respuesta completa al final.
Ejercicio
Agrega un endpoint POST /chat-batch que reciba una lista de prompts y devuelva sus respuestas, reutilizando MistralService.generar_batch (de la solución de la cápsula 04). Incluye validación de que la lista tenga entre 1 y 10 prompts.
Ver solución
# Schemas adicionales
class BatchRequest(BaseModel):
prompts: list[str] = Field(..., min_length=1, max_length=10)
max_tokens: int = Field(256, ge=1, le=2048)
temperature: float = Field(0.7, ge=0.0, le=2.0)
class BatchResponse(BaseModel):
respuestas: list[str]
modelo: str
# Agrega un método al servicio
@app.cls(...)
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, temperature: float) -> list[str]:
from vllm import SamplingParams
sampling = SamplingParams(temperature=temperature, 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]
# En la función web
@web_app.post("/chat-batch", response_model=BatchResponse)
def chat_batch(
req: BatchRequest,
authorization: Annotated[str | None, Header()] = None,
):
verificar_token(authorization)
servicio = MistralService()
respuestas = servicio.generar_batch.remote(
req.prompts, req.max_tokens, req.temperature
)
return BatchResponse(respuestas=respuestas, modelo=MODELO)
Prueba con curl:
curl -X POST https://.../chat-batch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"prompts": ["¿Qué es FastAPI?", "¿Qué es Modal?"], "max_tokens": 100}'
vLLM va a procesar los prompts batch en GPU — más eficiente que llamadas separadas.
Resumen
Aprendiste:
- ✅ Convertir una función Modal en endpoint HTTPS público con
@modal.asgi_app() - ✅ Diseñar contratos de request/response con Pydantic (validación gratis)
- ✅ Implementar auth con token Bearer usando un
modal.Secret - ✅
modal deployvsmodal run— uno persiste, otro es efímero - ✅ Documentación automática en
/docs(Swagger UI)
Checkpoint: si puedes hacer curl desde otra terminal y recibir la respuesta de Mistral, tienes un servicio HTTP funcionando.
Siguiente cápsula
En 06 — Autoscaling y cold starts vamos a profundizar en lo que pasa cuando varios clientes llaman tu endpoint a la vez. Verás:
- Cómo Modal levanta múltiples containers en paralelo
- Cómo configurar
concurrency_limit,container_idle_timeout,keep_warm - Estrategias para mantener primer-byte-latency bajo en producción
Es la cápsula que separa "deployment de demo" de "deployment listo para usuarios reales".
Recursos
- Modal — Web endpoints — todas las formas de exponer HTTP.
- Modal — ASGI apps — el patrón con FastAPI.
- FastAPI — Security — auth más allá del Bearer simple.
- Pydantic v2 — Field validators — validaciones complejas.
- Modal — Logs and monitoring —
modal app logs.