Construyendo Pipelines de IA en Producción: Lecciones de 10K+ Generaciones

El mes pasado procesamos la generación número 10,000 en nuestro pipeline. No lo celebré con champán — lo celebré investigando por qué la generación 9,847 había tardado 47 segundos en responder. Ese soy yo.

Llevo seis meses construyendo esto en producción, primero como proyecto paralelo y luego como parte central del producto de mi equipo (somos cuatro desarrolladores). Hemos pasado por tres arquitecturas distintas, quemado más dinero del que me gustaría admitir en llamadas a la API, y aprendido que la diferencia entre un pipeline de juguete y uno de producción no está en el modelo — está en todo lo demás.

Lo que voy a contar aquí no está en los tutoriales de “empieza con LangChain en 10 minutos”. Son las lecciones que solo aparecen cuando tienes volumen real, usuarios reales, y una factura de AWS que te mira fijamente a los ojos a fin de mes.

Rate Limiting y Backoff: Lo Que Aprendes a Golpes

La primera semana en producción, el pipeline empezó a lanzar RateLimitError de OpenAI de forma aleatoria. Mi solución inicial fue elegante: un time.sleep(1) entre llamadas. Funcionó en staging. En producción, con múltiples workers procesando en paralelo, fue un desastre total.

El problema no era solo el rate limit — era que no tenía ninguna estrategia de backoff exponencial, ningún sistema de cola, y mis workers competían entre sí por el mismo bucket de tokens por minuto. Lo que funciona en producción es esto:

import asyncio
import random
from openai import AsyncOpenAI, RateLimitError, APITimeoutError

client = AsyncOpenAI()

async def generate_with_retry(
    prompt: str,
    max_retries: int = 5,
    base_delay: float = 1.0,
) -> str:
    for attempt in range(max_retries):
        try:
            response = await client.chat.completions.create(
                model="gpt-4o",
                messages=[{"role": "user", "content": prompt}],
                timeout=30.0,  # no esperes para siempre
            )
            return response.choices[0].message.content

        except RateLimitError:
            if attempt == max_retries - 1:
                raise
            # jitter aleatorio para evitar el thundering herd problem
            delay = (base_delay * 2**attempt) + random.uniform(0, 1)
            await asyncio.sleep(delay)

        except APITimeoutError:
            if attempt == max_retries - 1:
                raise
            # timeout es distinto al rate limit — no esperes tanto
            await asyncio.sleep(base_delay * (attempt + 1))

Esto parece obvio en retrospectiva. Pero hay un detalle que no ves hasta que lo vives: el jitter aleatorio es crítico. Sin él, cuando varios workers fallan al mismo tiempo, todos reintentan en el mismo momento — lo cual agrava exactamente el problema que estás intentando resolver. Se llama thundering herd y es tan frustrante como suena.

Una cosa más: distingue entre RateLimitError y APITimeoutError. Son animales distintos. El rate limit requiere esperar más tiempo con backoff exponencial. Un timeout puede ser un problema temporal de red — no necesitas esperar 32 segundos antes del segundo intento. Los dos primeros meses los trataba igual. Error mío.

También configura un timeout explícito en cada llamada. El default de la librería de OpenAI es de 10 minutos. Si tu pipeline está esperando 10 minutos para declarar un fallo, tienes un problema de UX mucho antes de tener un problema técnico.

Takeaway: Implementa backoff con jitter desde el día uno. Separa tus tipos de error — no todos los fallos merecen la misma estrategia de reintento.

Observabilidad: Sin Logs Estás Literalmente Ciego

Esta fue mi mayor sorpresa. No técnica, sino operacional.

Dos semanas después de lanzar, un usuario reportó que “las respuestas se veían raras”. Sin más detalles. Tardé tres horas en rastrear el problema porque mis logs decían esencialmente: “llamada exitosa, 200 OK”. No tenía ni idea de qué prompts estaba enviando exactamente, cuántos tokens consumía por request, ni qué latencias veía por modelo. Tres horas buscando en producción a ciegas es tiempo que no vuelve.

Ahora logueo esto en cada generación:

  • Versión del prompt (porque los prompts evolucionan — más sobre esto después)
  • Tokens de entrada y salida por separado
  • Latencia total vs. latencia hasta el primer token (TTFT)
  • Costo estimado de esa llamada individual
  • Un hash del contenido generado (para detectar respuestas duplicadas o loops)
  • El modelo exacto usado, incluyendo versión cuando es relevante

No uso ninguna herramienta especial para esto — tenemos Datadog por otras razones, así que metemos todo ahí en forma de métricas custom. Si empezara desde cero, usaría Langfuse. Es más agnóstico que LangSmith (que empuja bastante hacia el ecosistema LangChain), tiene buena UI para debugging de prompts, y la versión self-hosted es gratuita si te preocupa mandar tus datos a un tercero.

One thing I noticed: la latencia hasta el primer token (TTFT) es más útil que la latencia total si estás usando streaming. Tu usuario empieza a ver contenido mucho antes de que termines de generar — si el TTFT sube de 800ms a 3 segundos, tienes un problema de UX real aunque la latencia total sea “aceptable”. No lo supe hasta que un usuario se quejó de que la app “se sentía lenta” y mis gráficas de latencia promedio se veían perfectas.

Here is the thing: los LLMs fallan de formas sutiles que no son errores HTTP. Un prompt ligeramente diferente puede producir una respuesta completamente fuera de tu formato esperado — y tu pipeline sigue marcándola como “exitosa”. Sin logs del contenido real (o al menos un hash para detectar anomalías), esas fallas silenciosas acumulan y solo las descubres cuando ya hay usuarios afectados.

Takeaway: Loguea el costo por generación desde el primer día. Te ahorrará sorpresas al revisar la factura, y te dará el contexto que necesitas cuando algo falle.

El Error Que Casi Me Cuesta un Cliente: Versionado de Prompts

Aquí va el momento de vergüenza prometido.

En diciembre, actualicé el system prompt de nuestro pipeline principal. Cambio pequeño — añadí una instrucción de formato para que las respuestas destacaran métricas numéricas en el primer párrafo. Lo testué con una muestra de documentos, parecía bien, lo desplegué en viernes (sí, ya sé). Dos días después, un cliente enterprise que procesa unos 200 documentos por semana nos escribió: “el tono de las respuestas cambió completamente, esto no funciona para nuestro caso de uso”.

Tenía razón. El cambio de formato había alterado sutilmente el comportamiento del modelo de formas que mis pruebas de muestra no habían capturado. Y lo peor: no podía reproducir exactamente qué habían recibido antes porque no había versionado mis prompts. Tenía el historial de git del archivo, sí, pero no sabía con certeza qué versión del prompt había procesado cada uno de sus documentos.

La solución que usamos ahora es simple pero efectiva:

# prompts/registry.py

PROMPT_REGISTRY = {
    "summarize": {
        "default": "v2",
        "versions": {
            "v1": """Eres un asistente especializado en síntesis de documentos técnicos.
Genera un resumen en máximo 3 párrafos. Usa lenguaje directo y preciso.
No uses bullets a menos que el usuario los pida explícitamente.""",

            "v2": """Eres un asistente especializado en síntesis de documentos técnicos.
Genera un resumen en máximo 3 párrafos. Usa lenguaje directo y preciso.
No uses bullets a menos que el usuario los pida explícitamente.
Si el documento contiene métricas numéricas, destacalas en el primer párrafo.""",
        },
    },
}

def get_prompt(task: str, version: str | None = None) -> tuple[str, str]:
    """Devuelve (prompt, version_usada) para auditoría."""
    entry = PROMPT_REGISTRY[task]
    v = version or entry["default"]
    return entry["versions"][v], v

Cada generación ahora loguea la versión del prompt que usó. Si necesito reproducir un resultado específico de hace tres semanas, puedo hacerlo. Y los nuevos clientes pueden quedar anclados a una versión específica mientras que el resto avanza a la default actualizada.

No uses una base de datos para guardar tus prompts a menos que tengas una razón muy concreta — equipos no-técnicos que necesitan editarlos sin tocar código, por ejemplo. El overhead de gestión no vale la pena antes de ese punto. Un módulo Python en control de versiones es suficiente para la mayoría de los casos.

Takeaway: Los prompts son código. Versiónalos como si lo fueran, con el mismo rigor con el que versiones cualquier otra lógica de negocio.

Costos: Cómo Bajé la Factura de $800 a $190 al Mes

En octubre estábamos gastando aproximadamente $800/mes en APIs de modelos. El volumen no había cambiado dramáticamente — habíamos mejorado en eficiencia. Estas son las tres palancas que movimos:

Model routing. No todas las tareas necesitan GPT-4o. Implementé un clasificador rápido (GPT-4o-mini, ~$0.0002 por request) que decide si una tarea es “compleja” o “simple” basándose en longitud del documento y un conjunto de heurísticas. Las simples van a GPT-4o-mini; las complejas van a GPT-4o. Esto solo redujo la factura un 40%. Es la palanca de mayor impacto con menor complejidad de implementación.

Prompt caching. Si tienes un system prompt largo que se repite en muchas llamadas — y la mayoría de los pipelines tienen uno — el caching de prompts puede reducir costos significativamente en tokens de input. Anthropic lo tiene desde Claude 3.5 Sonnet; OpenAI lo implementó también. Nuestro system prompt tiene ~2,000 tokens que se enviaban en cada request. Con caching, pagamos por esos tokens una vez cada pocos minutos en lugar de en cada llamada individual. El ahorro depende del volumen, pero para nosotros fue de otro 25%.

Truncamiento inteligente de contexto. Esto suena obvio pero tardé demasiado en implementarlo: si tu pipeline acumula historial de conversación, ese historial crece con cada turno. Y pagas por todos esos tokens en cada llamada siguiente. Implementé una ventana deslizante que mantiene siempre los últimos N tokens de contexto más el system prompt. Para nuestro caso de uso, perder contexto antiguo es aceptable — el tuyo puede variar, así que mide antes de cortar.

Honestamente, no estoy 100% seguro de qué tan bien escala el model routing más allá de dos categorías. Con tres o cuatro categorías el clasificador empieza a necesitar más supervisión, los casos edge se multiplican, y el ahorro puede no compensar la complejidad añadida. Tu millaje puede variar.

Takeaway: Implementa model routing primero. Es el cambio de mayor impacto con menos trabajo. El caching de prompts viene segundo si tienes system prompts largos.

Lo Que Haría Diferente Desde el Día Uno

So, si pudiera volver atrás y darte un consejo concreto: prioriza la observabilidad antes de escalar.

No el modelo más potente. No la arquitectura más sofisticada. No las features avanzadas. Los logs. Porque una vez que tienes volumen, depurar sin datos es como arreglar una fuga de agua en la oscuridad — puedes adivinar dónde está el problema, pero vas a mojar muchas cosas por el camino.

Mi stack actual, sin patrocinios: GPT-4o + GPT-4o-mini con routing basado en complejidad, Langfuse para observabilidad de prompts, Redis para caché de respuestas deterministas (cuando temperatura es 0 y el input es idéntico, ¿para qué llamar a la API dos veces?), y Celery con Redis como broker para manejar picos de tráfico sin que el pipeline se ahogue. Nada exótico, nada que no puedas mantener con un equipo pequeño.

¿Qué modelo usaría para un proyecto nuevo? Para razonamiento complejo y generación de texto larga, Claude Sonnet 4.6 — he tenido resultados consistentemente mejores en las últimas semanas en tareas de síntesis y análisis. Para clasificación, extracción estructurada, y tareas simples: GPT-4o-mini. Pero esto cambia rápido, y lo que es cierto hoy puede no serlo en dos meses. Diseña tu pipeline para que cambiar de modelo sea fácil.

Lo que definitivamente no haría: empezar con un framework de orquestación grande antes de entender mis propios requirements. LangChain y LlamaIndex son útiles cuando ya sabes qué problema tienes. Antes de eso, añaden capas de abstracción que complican el debugging exactamente cuando más necesitas ver qué está pasando. Empieza con las APIs directamente; añade abstracción cuando el dolor sea real y concreto, no anticipado.

El pipeline todavía tiene partes que me hacen fruncir el ceño cuando las leo. Eso es normal — las muecas son parte del proceso. Lo importante es que ahora tenemos los datos para saber dónde están los problemas reales, y eso cambia completamente cómo tomamos decisiones sobre qué arreglar primero.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top