Disenar Workflows de IA que realmente funcionan en produccion
Una guia practica para construir pipelines de IA robustos. Aprende arquitectura de pipelines, secuenciacion de pasos, logica de ramificacion, manejo de errores y encadenamiento de prompts de ingenieros que han desplegado estos sistemas.
Por que la mayoria de los workflows de IA fallan en produccion
Aqui tienes algo que aprendimos por las malas: hacer que una IA haga algo impresionante en una demo es facil. Hacer que haga lo mismo de manera fiable, miles de veces al dia, con datos reales y casos extremos reales? Ahi es donde la mayoria de los equipos chocan contra un muro.
Hemos construido workflows de IA para procesamiento de documentos, automatizacion de soporte al cliente, generacion de contenido y analisis de datos. En el camino, hemos cometido todos los errores imaginables. Esta guia es lo que nos hubiera gustado que alguien nos dijera antes de empezar.
La diferencia entre un prototipo y un sistema de produccion no es el modelo que usas. Es todo lo que hay alrededor del modelo.
Dejame mostrarte como disenamos realmente los workflows de IA ahora, despues de aprender estas lecciones.
La anatomia de un pipeline de IA en produccion
Piensa en un workflow de IA como una serie de etapas de procesamiento, cada una con un trabajo especifico. Aqui esta la estructura basica que usamos:
Entrada → Validacion → Pre-procesamiento → Procesamiento IA → Post-procesamiento → Validacion → Salida
↓ ↓ ↓ ↓ ↓
[Manejo] [Transformacion] [Llamada LLM] [Parsing] [Control
Error] + Enriquecimiento + Herramientas + Formato Calidad]
Cada etapa tiene entradas, salidas y modos de fallo claros. Vamos a desglosarlas.
Etapa 1: Validacion y normalizacion de entrada
Nunca confies en los datos entrantes. Nunca. Lo aprendimos cuando un cliente nos envio un "documento de texto" que en realidad era un archivo binario de 50 MB. El pipeline se atasco, la cola se congestion, y pasamos un fin de semana arreglandolo.
const validateInput = async (input) => {
const checks = {
exists: input !== null && input !== undefined,
sizeOk: Buffer.byteLength(input, 'utf8') < MAX_INPUT_SIZE,
formatOk: isValidFormat(input),
contentOk: !containsMaliciousPatterns(input)
};
const failures = Object.entries(checks)
.filter(([_, passed]) => !passed)
.map(([check]) => check);
if (failures.length > 0) {
throw new ValidationError(`Entrada fallida: ${failures.join(', ')}`);
}
return normalizeInput(input);
};
Que debes verificar:
- Limites de tamano de archivo (ponlos mas bajos de lo que crees)
- Validacion de formato (es realmente JSON, no solo nombrado
.json?) - Codificacion de caracteres (los problemas de UTF-8 te perseguiran)
- Seguridad del contenido (no envies contenido malicioso a tu LLM)
Etapa 2: Pre-procesamiento y enriquecimiento
Las entradas crudas rara vez van directamente a un LLM. Normalmente necesitas transformarlas, agregar contexto o dividirlas en chunks.
| Tarea de pre-procesamiento | Cuando usar | Ejemplo |
|---|---|---|
| Chunking | Documentos largos que exceden limites de contexto | Dividir un PDF de 100 paginas en chunks de 2000 tokens |
| Enriquecimiento | Se necesita contexto adicional | Agregar historial del cliente antes de procesar ticket de soporte |
| Extraccion | Solo partes de la entrada son relevantes | Extraer solo el campo "description" de un payload JSON |
| Transformacion | Se necesita conversion de formato | Convertir HTML a markdown para procesamiento mas limpio |
| Deduplicacion | El contenido repetido desperdicia tokens | Eliminar parrafos duplicados de contenido scrapeado |
Aqui tienes una estrategia de chunking que realmente usamos:
const chunkDocument = (text, options = {}) => {
const {
maxTokens = 2000,
overlap = 200,
preserveParagraphs = true
} = options;
const chunks = [];
let currentChunk = '';
const paragraphs = text.split(/\n\n+/);
for (const para of paragraphs) {
const combined = currentChunk + '\n\n' + para;
if (estimateTokens(combined) > maxTokens) {
if (currentChunk) {
chunks.push(currentChunk.trim());
// Mantener overlap para continuidad de contexto
currentChunk = getLastNTokens(currentChunk, overlap) + '\n\n' + para;
} else {
// Parrafo unico demasiado largo, split forzado
chunks.push(...forceSplitParagraph(para, maxTokens));
currentChunk = '';
}
} else {
currentChunk = combined;
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
};
El overlap es crucial. Sin el, la informacion que cruza los limites de los chunks se pierde. Lo aprendimos cuando nuestro summarizer de documentos seguia perdiendo puntos clave que caian entre chunks.
Disenando el nucleo del procesamiento de IA
Aqui es donde ocurren las llamadas reales al LLM. Pero una sola llamada rara vez es suficiente para tareas complejas.
El patron Prompt Chain
En lugar de un prompt gigante que intenta hacer todo, divide las tareas complejas en pasos enfocados:
[Entender] → [Planificar] → [Ejecutar] → [Verificar] → [Formatear]
Ejemplo: Procesando una queja de cliente
const processComplaint = async (complaint) => {
// Paso 1: Entender - Extraer informacion clave
const analysis = await llm.call({
system: `Extrae informacion estructurada de quejas de clientes.
Devuelve JSON con: issue_type, urgency, customer_emotion, key_details`,
user: complaint
});
// Paso 2: Planificar - Determinar estrategia de respuesta
const strategy = await llm.call({
system: `Basado en este analisis de queja, determina la estrategia de respuesta.
Considera: opciones de resolucion, necesidades de escalacion, elegibilidad de compensacion`,
user: JSON.stringify(analysis)
});
// Paso 3: Ejecutar - Generar respuesta
const response = await llm.call({
system: `Escribe una respuesta al cliente siguiendo esta estrategia.
Tono: empatico, profesional. Longitud: 2-3 parrafos.`,
user: `Estrategia: ${JSON.stringify(strategy)}\nQueja original: ${complaint}`
});
// Paso 4: Verificar - Control de calidad
const verification = await llm.call({
system: `Revisa esta respuesta al cliente. Verifica:
- Aborda todas las preocupaciones? - Tono apropiado? - Siguientes pasos accionables?
Devuelve: {approved: boolean, issues: string[]}`,
user: response
});
if (!verification.approved) {
return await regenerateWithFeedback(response, verification.issues);
}
return response;
};
Cada paso es mas simple, mas testeable y mas facil de debuguear. Cuando algo sale mal, sabes exactamente que etapa fallo.
Cuando encadenar vs. cuando paralelizar
No todo necesita ser secuencial. Asi es como decidimos:
| Patron | Usar cuando | Ejemplo |
|---|---|---|
| Cadena secuencial | Cada paso depende de la salida anterior | Entender → Planificar → Ejecutar |
| Ejecucion paralela | Los pasos son independientes | Analizar multiples documentos simultaneamente |
| Map-Reduce | Necesitas procesar items y luego agregar | Resumir cada chunk, luego combinar resumenes |
| Ramificacion condicional | Diferentes caminos para diferentes entradas | Consultas simples vs. analisis complejo |
Ejemplo de ejecucion paralela:
const analyzeDocuments = async (documents) => {
// Procesar todos los documentos en paralelo
const analyses = await Promise.all(
documents.map(doc => analyzeDocument(doc))
);
// Reduce: combinar en informe final
const combinedReport = await llm.call({
system: 'Sintetiza estos analisis individuales en un informe coherente',
user: analyses.map((a, i) => `Documento ${i + 1}:\n${a}`).join('\n\n---\n\n')
});
return combinedReport;
};
Logica de ramificacion: Enrutando al handler correcto
Las entradas del mundo real varian enormemente. Un "mensaje de cliente" puede ser una queja, una pregunta, un cumplido o spam. Cada uno necesita un tratamiento diferente.
Enrutamiento basado en clasificacion
const routeCustomerMessage = async (message) => {
const classification = await llm.call({
system: `Clasifica este mensaje de cliente en exactamente una categoria:
- complaint: Cliente expresando insatisfaccion
- question: Cliente buscando informacion
- feedback: Comentarios generales o sugerencias
- urgent: Problemas de seguridad, amenazas legales, escalacion ejecutiva
- spam: Mensajes irrelevantes o automatizados
Devuelve solo el nombre de la categoria.`,
user: message
});
const handlers = {
complaint: handleComplaint,
question: handleQuestion,
feedback: handleFeedback,
urgent: escalateToHuman,
spam: markAsSpam
};
const handler = handlers[classification.toLowerCase()] || handleUnknown;
return await handler(message);
};
Enrutamiento basado en confianza
A veces la IA no esta segura. Incorpora esa incertidumbre en tu enrutamiento:
const routeWithConfidence = async (input) => {
const result = await llm.call({
system: `Analiza y enruta esta solicitud. Devuelve JSON:
{
"category": "string",
"confidence": 0.0-1.0,
"reasoning": "por que esta categoria"
}`,
user: input
});
if (result.confidence < 0.7) {
// Baja confianza - obtener input humano o usar fallback
return await handleLowConfidence(input, result);
}
if (result.confidence < 0.9 && isHighStakes(result.category)) {
// Confianza media en decisiones importantes - verificar
return await verifyThenRoute(input, result);
}
// Alta confianza - proceder automaticamente
return await routeToHandler(result.category, input);
};
Manejo de errores: Porque las cosas van a romperse
Los workflows de IA fallan de maneras que el software tradicional no conoce. El LLM puede devolver JSON invalido, alucinar informacion, o simplemente... no seguir instrucciones. Planifica para eso.
La jerarquia de errores
class AIWorkflowError extends Error {
constructor(message, stage, recoverable = true) {
super(message);
this.stage = stage;
this.recoverable = recoverable;
}
}
class ValidationError extends AIWorkflowError {
constructor(message) {
super(message, 'validation', true);
}
}
class LLMError extends AIWorkflowError {
constructor(message, type) {
super(message, 'llm_call', type !== 'rate_limit');
this.type = type; // 'timeout', 'rate_limit', 'invalid_response', 'refused'
}
}
class OutputError extends AIWorkflowError {
constructor(message) {
super(message, 'output', true);
}
}
Estrategias de retry que funcionan
Diferentes errores necesitan diferentes enfoques de retry:
| Tipo de error | Estrategia de retry | Max retries | Backoff |
|---|---|---|---|
| Rate limit | Esperar y reintentar | 5 | Exponencial con jitter |
| Timeout | Reintentar inmediatamente | 3 | Lineal |
| Respuesta invalida | Reintentar con feedback | 2 | Ninguno |
| Modelo rechaza | Reformular y reintentar | 2 | Ninguno |
| Error de servidor | Esperar y reintentar | 3 | Exponencial |
const withRetry = async (operation, options = {}) => {
const {
maxRetries = 3,
backoffMs = 1000,
backoffMultiplier = 2,
shouldRetry = () => true
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (!shouldRetry(error) || attempt === maxRetries) {
throw error;
}
const delay = backoffMs * Math.pow(backoffMultiplier, attempt);
const jitter = Math.random() * delay * 0.1;
await sleep(delay + jitter);
}
}
throw lastError;
};
// Uso con llamadas LLM
const safeLLMCall = async (params) => {
return await withRetry(
() => llm.call(params),
{
maxRetries: 3,
shouldRetry: (error) => {
if (error.type === 'rate_limit') return true;
if (error.type === 'timeout') return true;
if (error.type === 'server_error') return true;
return false;
}
}
);
};
Auto-reparacion para respuestas invalidas
Cuando el LLM devuelve basura, a veces puedes arreglarlo:
const parseWithRecovery = async (llmResponse, expectedSchema) => {
// Primer intento: parsing directo
try {
const parsed = JSON.parse(llmResponse);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// Parsing JSON fallo, continuar a recuperacion
}
// Intento de recuperacion: pedir al LLM que arregle su salida
const fixed = await llm.call({
system: `La siguiente respuesta deberia ser JSON valido que coincida con este esquema:
${JSON.stringify(expectedSchema)}
Arregla la respuesta a JSON valido. Devuelve SOLO el JSON arreglado.`,
user: llmResponse
});
try {
const parsed = JSON.parse(fixed);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// Todavia roto
}
// Fallback final: extraer lo que se pueda
return extractPartialData(llmResponse, expectedSchema);
};
Monitoreo y observabilidad
No puedes arreglar lo que no puedes ver. Esto es lo que rastreamos:
Metricas clave
| Metrica | Por que importa | Umbral de alerta |
|---|---|---|
| Latencia (p50, p95, p99) | Experiencia de usuario, riesgo de timeout | p95 > 10s |
| Tasa de exito | Salud general | < 95% |
| Uso de tokens | Control de costos | > 150% de la baseline |
| Tasa de retry | Inestabilidad oculta | > 10% |
| Distribucion de clasificaciones | Detectar deriva | Cambio significativo de la baseline |
| Puntuaciones de calidad de salida | Detectar degradacion | Promedio < 0.8 |
Logging estructurado
Cada ejecucion de workflow deberia producir logs trazables:
const runWorkflow = async (input, context) => {
const runId = generateRunId();
const startTime = Date.now();
const log = (stage, data) => {
logger.info({
runId,
stage,
timestamp: Date.now(),
elapsed: Date.now() - startTime,
...data
});
};
try {
log('start', { inputSize: input.length });
const validated = await validate(input);
log('validated', { valid: true });
const processed = await process(validated);
log('processed', {
tokensUsed: processed.usage.total,
model: processed.model
});
const output = await format(processed);
log('complete', {
success: true,
outputSize: output.length,
totalTime: Date.now() - startTime
});
return output;
} catch (error) {
log('error', {
error: error.message,
stage: error.stage,
recoverable: error.recoverable
});
throw error;
}
};
Juntando todo: Un ejemplo completo
Construyamos un workflow de analisis de documentos que use todo lo que hemos discutido:
class DocumentAnalysisPipeline {
constructor(options = {}) {
this.maxChunkSize = options.maxChunkSize || 3000;
this.concurrency = options.concurrency || 5;
}
async run(document) {
// Etapa 1: Validar
const validated = await this.validate(document);
// Etapa 2: Pre-procesar
const chunks = await this.preprocess(validated);
// Etapa 3: Analisis paralelo con control de concurrencia
const analyses = await this.analyzeChunks(chunks);
// Etapa 4: Sintetizar
const synthesis = await this.synthesize(analyses);
// Etapa 5: Control de calidad
const final = await this.qualityCheck(synthesis, document);
return final;
}
async validate(document) {
if (!document || typeof document !== 'string') {
throw new ValidationError('El documento debe ser un string no vacio');
}
if (document.length > 1000000) {
throw new ValidationError('El documento excede el tamano maximo');
}
return document;
}
async preprocess(document) {
return chunkDocument(document, {
maxTokens: this.maxChunkSize,
overlap: 200
});
}
async analyzeChunks(chunks) {
const results = [];
// Procesar en lotes para controlar concurrencia
for (let i = 0; i < chunks.length; i += this.concurrency) {
const batch = chunks.slice(i, i + this.concurrency);
const batchResults = await Promise.all(
batch.map((chunk, idx) => this.analyzeChunk(chunk, i + idx))
);
results.push(...batchResults);
}
return results;
}
async analyzeChunk(chunk, index) {
return await withRetry(async () => {
const result = await llm.call({
system: `Analiza esta seccion del documento. Extrae:
- Temas y topicos clave
- Hechos y cifras importantes
- Citas o declaraciones notables
- Preguntas o vacios de informacion
Devuelve JSON estructurado.`,
user: `Seccion ${index + 1}:\n\n${chunk}`
});
return parseWithRecovery(result, ANALYSIS_SCHEMA);
});
}
async synthesize(analyses) {
const combined = analyses.map((a, i) =>
`Seccion ${i + 1}:\n${JSON.stringify(a, null, 2)}`
).join('\n\n---\n\n');
return await llm.call({
system: `Sintetiza estos analisis de secciones en un resumen completo del documento.
Estructura:
1. Resumen ejecutivo (2-3 oraciones)
2. Hallazgos clave (puntos)
3. Detalles importantes
4. Vacios o preguntas
5. Recomendaciones`,
user: combined
});
}
async qualityCheck(synthesis, originalDocument) {
const check = await llm.call({
system: `Revisa este analisis por calidad. Verifica:
- Precision: Refleja el documento original?
- Completitud: Se cubren los puntos principales?
- Claridad: Esta bien organizado y claro?
Devuelve: {score: 0-1, issues: string[], approved: boolean}`,
user: `Analisis:\n${synthesis}\n\nOriginal (primeros 2000 caracteres):\n${originalDocument.slice(0, 2000)}`
});
if (!check.approved) {
// Loguear para revision pero no fallar
logger.warn({ issues: check.issues, score: check.score });
}
return {
analysis: synthesis,
qualityScore: check.score,
qualityIssues: check.issues
};
}
}
Trampas comunes y como evitarlas
Despues de construir docenas de estos sistemas, estos son los errores que vemos mas frecuentemente:
1. Sin limites de timeout
Cada llamada LLM necesita un timeout. Configurallos agresivamente.
// Malo: Sin timeout
const result = await llm.call(params);
// Bueno: Timeout explicito
const result = await Promise.race([
llm.call(params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 30000)
)
]);
2. Ignorar limites de tokens
Rastrea el uso y establece presupuestos:
const tokenBudget = {
max: 10000,
used: 0,
canSpend(amount) {
return this.used + amount <= this.max;
},
spend(amount) {
this.used += amount;
if (this.used > this.max * 0.8) {
logger.warn('Presupuesto de tokens consumido al 80%');
}
}
};
3. Sin fallback para rutas criticas
Siempre ten un plan B:
const processWithFallback = async (input) => {
try {
return await primaryProcess(input);
} catch (error) {
if (error.recoverable) {
return await simplifiedProcess(input);
}
// Ruta critica - poner en cola para procesamiento manual
await queueForManualReview(input, error);
return { status: 'queued_for_review' };
}
};
Que viene despues
Los workflows de IA se estan volviendo mas sofisticados. Esto es hacia donde vemos que van las cosas:
- Mejor uso de herramientas: Los modelos mejoran en decidir cuando y como usar herramientas externas
- Contexto mas largo: Ventanas de contexto mas grandes significan menos dolores de cabeza con chunking
- Inferencia mas rapida: La latencia esta bajando, habilitando workflows en tiempo real mas complejos
- Modelos especializados: Los modelos fine-tuned para tareas especificas superan a los de proposito general
Pero los fundamentos no cambian. Valida entradas, maneja errores elegantemente, monitorea todo y siempre ten un fallback. Construye sobre esos principios, y tus workflows de IA sobreviviran el contacto con el mundo real.
Si estas construyendo workflows de IA y chocando contra muros, probablemente ya hemos visto tu problema antes. Contactanos, y encontremos la solucion juntos.
Topics covered
Ready to implement agentic AI?
Our team specializes in building production-ready AI systems. Let's discuss how we can help you leverage agentic AI for your enterprise.
Start a conversation