Hay miles de tutoriales sobre buenas prácticas en APIs REST. Este no es uno más.
Este post existe porque cuando conectas tu API a un LLM —Claude, GPT-4o, DeepSeek— los errores de diseño que antes eran "malas prácticas tolerables" se convierten en bugs costosos, respuestas inconsistentes y facturas de tokens que no esperabas.
Si estás construyendo o refactorizando una API que va a hablar con modelos de lenguaje, lo que sigue te va a ahorrar semanas de debugging.
Por qué el diseño de tu API importa más cuando hay un LLM de por medio
Una API mal diseñada que solo mueve datos entre una BD y un frontend tiene un costo: confusión del equipo, mantenimiento lento. Tolerable.
Una API mal diseñada que además llama a un LLM tiene estos costos adicionales:
- Tokens desperdiciados por payloads verbosos o estructuras redundantes que el modelo no necesita
- Respuestas no deterministicas cuando el contexto que le mandas al modelo cambia de forma inconsistente
- Rate limits que te explotan en producción porque no diseñaste bien la idempotencia
- Costos impredecibles porque no tienes trazabilidad de qué endpoint consume qué
Las buenas prácticas de siempre siguen siendo válidas. Solo que ahora tienen consecuencias económicas directas.
Principio base que no cambia: Stateless + contrato claro
El principio REST más importante —cada petición debe ser autocontenida— es especialmente crítico cuando hay un LLM en el flujo.
¿Por qué? Porque el LLM no tiene memoria entre llamadas. Si tu API guarda estado de conversación de forma implícita o inconsistente, el contexto que le mandas al modelo en la siguiente llamada va a ser incompleto o contradictorio.
# ❌ Esto parece razonable pero rompe en producción con LLMs
# El servidor guarda el historial en memoria — no escala, no es stateless
conversation_history = {} # Variable global
def chat_view(request):
user_id = request.user.id
message = request.data["message"]
history = conversation_history.get(user_id, []) # Depende de estado del servidor
# ...
# ✅ Cada request trae su propio contexto
def chat_view(request):
message = request.data["message"]
conversation_id = request.data["conversation_id"]
# El historial vive en BD, no en el servidor
history = Message.objects.filter(
conversation_id=conversation_id
).order_by("created_at").values("role", "content")
# Ahora cualquier instancia del servidor puede responder
messages = list(history) + [{"role": "user", "content": message}]
El historial de conversación va en base de datos. Siempre. Esto no es opinable.
Diseño de endpoints: el contrato que le pasas al modelo
Cuando diseñas endpoints para un sistema con IA, tienes que pensar en dos consumidores simultáneos: el frontend y el LLM. Ambos necesitan estructuras predecibles.
Nomenclatura que no te va a sorprender a las 2am
✅ Correcto
/api/conversations/
/api/conversations/{id}/messages/
/api/conversations/{id}/documents/
/api/assistants/
/api/assistants/{id}/sessions/
❌ Evitar
/api/startChat/
/api/sendMessageToAI/
/api/getConversationHistory/
Sustantivos, plural, jerarquía lógica. Esto también aplica para los nombres de campos en el JSON que eventualmente le vas a pasar al LLM como contexto.
El endpoint de chat: el más importante del sistema
# views.py - Estructura limpia para el endpoint principal
class ConversationMessageView(APIView):
"""
POST /api/conversations/{id}/messages/
Body:
{
"content": "¿Cuál es el precio del producto XYZ?",
"context": { # Contexto adicional opcional
"document_ids": [1, 2], # Docs relevantes para RAG
"metadata": {} # Datos extra para el prompt
}
}
"""
def post(self, request, conversation_id):
serializer = MessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# El service layer se encarga de todo lo relacionado al LLM
# La view no sabe qué modelo está usando ni cómo funciona
result = ConversationService().send_message(
conversation_id=conversation_id,
user=request.user,
**serializer.validated_data
)
return Response(MessageResponseSerializer(result).data, status=201)
La view no toca el LLM directamente. Nunca. Eso es trabajo del service layer.
Métodos HTTP cuando hay IA: los casos que nadie documenta
El mapeo CRUD clásico sigue siendo válido para los recursos base. Pero los sistemas con LLM introducen operaciones que no encajan en el molde tradicional:
| Método | Endpoint | Qué hace |
|---|---|---|
| POST | /api/conversations/ | Crear nueva conversación |
| GET | /api/conversations/{id}/ | Obtener conversación + historial |
| POST | /api/conversations/{id}/messages/ | Enviar mensaje (llama al LLM) |
| POST | /api/conversations/{id}/documents/ | Subir doc para RAG |
| DELETE | /api/conversations/{id}/ | Eliminar conversación |
| POST | /api/assistants/{id}/sessions/ | Iniciar sesión con un asistente |
Nota que todas las operaciones que invocan al LLM son POST. No GET. Porque no son idempotentes: el mismo mensaje enviado dos veces puede dar respuestas distintas y consume tokens en cada llamada. Tratar una llamada al LLM como si fuera un GET es uno de los errores más comunes en sistemas de IA mal diseñados.
Códigos de estado: agrega los errores del LLM al mapa
Los errores estándar siguen siendo los mismos. Lo que cambia es que ahora tienes una fuente adicional de errores: el proveedor del LLM.
200 OK → Respuesta generada exitosamente
201 Created → Mensaje guardado, respuesta en proceso (async)
202 Accepted → Tarea encolada en Celery, polling o webhook
400 Bad Request → Payload malformado
401 Unauthorized → Sin autenticación
402 Payment Required → Sin créditos suficientes (tu sistema de billing)
403 Forbidden → Sin permisos para este asistente/conversación
404 Not Found → Conversación no existe
422 Unprocessable Entity → Datos válidos pero lógica falla (ej: doc corrupto)
429 Too Many Requests → Rate limit (tuyo o del proveedor LLM)
503 Service Unavailable → El proveedor LLM está caído
El 402 y el 503 son nuevos en tu mapa. No los ignores.
# exceptions.py — Jerarquía de errores que incluye al LLM
class LLMError(Exception):
"""Base para errores del proveedor"""
class LLMRateLimitError(LLMError):
def __init__(self, retry_after: int = 60):
self.retry_after = retry_after
class LLMServiceUnavailableError(LLMError):
"""503 del proveedor — aplica retry con backoff"""
class LLMContextLengthError(LLMError):
"""El historial excedió el context window"""
class InsufficientCreditsError(Exception):
"""El usuario no tiene créditos — 402"""
# En tu view, el manejo se ve así
def post(self, request, conversation_id):
try:
result = ConversationService().send_message(...)
return Response(result, status=200)
except InsufficientCreditsError:
return Response({"error": "insufficient_credits"}, status=402)
except LLMRateLimitError as e:
return Response(
{"error": "rate_limit", "retry_after": e.retry_after},
status=429
)
except LLMServiceUnavailableError:
return Response({"error": "ai_unavailable"}, status=503)
Formato de respuestas: consistencia doble
Tu API ahora tiene dos formatos de respuesta que mantener consistentes: los datos estructurados de siempre, y el texto generado por el LLM.
Respuesta de mensaje exitoso
{
"success": true,
"data": {
"id": 147,
"role": "assistant",
"content": "El producto XYZ tiene un precio de $49.99...",
"conversation_id": 23,
"created_at": "2025-09-23T14:30:00Z",
"usage": {
"input_tokens": 312,
"output_tokens": 89,
"model": "claude-3-5-haiku-20241022"
}
}
}
El campo usage no es opcional si te importan los costos. Es lo que necesitas para saber cuánto gastó cada conversación, cada usuario, cada endpoint.
Respuesta de error del LLM
{
"success": false,
"error": {
"code": "AI_UNAVAILABLE",
"message": "El servicio de IA no está disponible temporalmente",
"retry_after": 30
}
}
Nunca expongas el error raw del proveedor al frontend. Nunca. El mensaje de Anthropic o OpenAI puede tener información interna del sistema.
Paginación en conversaciones largas: el problema del context window
Aquí es donde la paginación estándar no alcanza.
Un usuario con una conversación de 200 mensajes no puede cargar todos en el frontend. Pero tampoco puedes mandar los 200 al LLM —excedes el context window y el costo explota.
Necesitas dos estrategias de paginación simultáneas:
# Para el frontend: paginación normal de UI
GET /api/conversations/{id}/messages/?page=3&limit=20
# Para el LLM: ventana deslizante de contexto (interno, no expuesto como endpoint)
# Solo mandas los últimos N mensajes + el resumen de los anteriores
# services/conversation_service.py
def build_llm_context(self, conversation_id: int, max_tokens: int = 4000) -> list:
"""
Construye el historial optimizado para mandar al LLM.
No es lo mismo que la paginación del frontend.
"""
messages = Message.objects.filter(
conversation_id=conversation_id
).order_by("-created_at")[:50] # Últimos 50
# Contar tokens aproximados y recortar si hace falta
context = []
token_count = 0
for msg in reversed(messages):
estimated_tokens = len(msg.content.split()) * 1.3
if token_count + estimated_tokens > max_tokens:
break
context.append({"role": msg.role, "content": msg.content})
token_count += estimated_tokens
return context
Seguridad: lo que se agrega cuando hay IA
Las buenas prácticas de siempre (JWT, HTTPS, validación de inputs) siguen vigentes. El LLM agrega una capa nueva:
Prompt injection — Un usuario puede intentar manipular al LLM a través de los inputs de tu API.
# ❌ Peligroso: el input del usuario va directo al system prompt
system_prompt = f"Eres un asistente de {company_name}. Usuario dijo: {user_input}"
# ✅ El system prompt es tuyo, el user input va en su propio mensaje
messages = [
{"role": "user", "content": user_input} # Separado del system prompt
]
response = client.complete(
system="Eres un asistente de la empresa. Responde solo sobre nuestros productos.",
messages=messages
)
Rate limiting por usuario, no solo por IP — Un usuario puede usar tu app para hacer miles de llamadas al LLM y arruinar tu presupuesto.
# Valida créditos ANTES de llamar al LLM
def send_message(self, user, content, conversation_id):
if user.credits < MINIMUM_CREDITS_REQUIRED:
raise InsufficientCreditsError()
response = self.llm_client.complete(...)
# Descuenta créditos después de la llamada exitosa
user.credits -= self.calculate_cost(response.usage)
user.save()
return response
Ejemplo práctico: API de asistente para ecommerce
El mismo sistema de libros que probablemente viste en otros tutoriales, pero ahora el asistente responde sobre el catálogo usando RAG.
Requerimiento
"Quiero un chatbot en mi tienda que responda preguntas de clientes sobre productos, precios y disponibilidad, usando la información real de mi catálogo."
Endpoints diseñados
| Método | Endpoint | Descripción |
|---|---|---|
| POST | /api/assistants/ | Crear asistente con instrucciones |
| POST | /api/conversations/ | Iniciar conversación con asistente |
| POST | /api/conversations/{id}/messages/ | Enviar pregunta, recibir respuesta |
| POST | /api/conversations/{id}/documents/ | Subir catálogo de productos (RAG) |
| GET | /api/conversations/{id}/ | Obtener historial |
El flujo completo
Cliente pregunta "¿tienen el producto X en talla M?"
↓
POST /api/conversations/{id}/messages/
↓
View valida, llama a ConversationService
↓
Service verifica créditos del usuario
↓
RAGService busca productos relevantes en pgvector
↓
LLMClient construye el contexto: historial + docs relevantes
↓
Llama a Anthropic/OpenAI/DeepSeek
↓
Guarda respuesta en BD, descuenta tokens
↓
Retorna al cliente: { "content": "Sí, tenemos talla M en stock...", "usage": {...} }
Estructura de datos del mensaje
{
"id": 89,
"role": "assistant",
"content": "Sí, el producto X está disponible en talla M. El precio es $35.00 y tenemos 12 unidades en stock.",
"conversation_id": 12,
"created_at": "2025-09-23T15:00:00Z",
"usage": {
"input_tokens": 420,
"output_tokens": 42,
"model": "claude-3-5-haiku-20241022",
"estimated_cost_usd": 0.000158
},
"sources": [
{"document_id": 3, "relevance_score": 0.94}
]
}
El campo sources es crítico para RAG: le dice al frontend qué documentos usó el modelo para responder. Es transparencia y también debugging.
Documentación: lo que cambia cuando hay LLM
OpenAPI/Swagger sigue siendo el estándar. Pero ahora tienes que documentar también:
- Límites de tokens por endpoint
- Modelos disponibles y sus diferencias de costo
- Comportamiento en rate limit del proveedor externo
- Estructura del system prompt si es configurable
# Fragmento del schema OpenAPI para el endpoint de mensajes
/api/conversations/{id}/messages/:
post:
summary: Enviar mensaje al asistente
description: |
Llama al LLM configurado para la conversación.
Costo aproximado: 0.0001-0.001 USD por llamada según modelo.
Timeout: 60 segundos. En caso de timeout, el mensaje queda en estado 'processing'.
parameters:
- name: model
in: query
description: "Modelo a usar. Default: claude-3-5-haiku. Opciones: gpt-4o, deepseek-chat"
required: false
Resumen: el checklist antes de conectar tu API a un LLM
Antes de hacer tu primera llamada real a producción, verifica:
Diseño
- Historial de conversación en BD, no en memoria del servidor
- Endpoint de mensajes es POST, no GET
- Separación clara entre view, service y LLM client
Errores
- Jerarquía de excepciones LLM definida
- Manejo de 429 (rate limit) con retry_after
- Manejo de 503 (proveedor caído) con mensaje amigable
Costos y seguridad
- Campo
usageen respuestas (tokens input/output) - Sistema de créditos o rate limiting por usuario
- Input del usuario separado del system prompt (anti prompt injection)
Contexto
- Paginación de frontend ≠ ventana de contexto del LLM
- Límite de tokens configurado y monitoreado
¿Siguiente paso?
Todo lo que ves en este post —el service layer, el manejo de errores, RAG con pgvector, el sistema de créditos, el streaming— es exactamente lo que construyes en el curso Django como Backend de IA.
Sin LangChain. Sin frameworks que ocultan lo que pasa por dentro. Solo Python profesional y un proyecto que puedes mostrar en una entrevista.