En octubre pasado, un equipo con el que trabajo lanzo un agente de onboarding de clientes. El dia de la demo fue perfecto: el agente recopilo informacion, verifico documentos y creo cuentas en menos de dos minutos. Tres semanas en produccion, estaba fallando silenciosamente en el 23% de las solicitudes. Los timeouts de herramientas se encadenaban en bucles de reintentos infinitos. El LLM ocasionalmente generaba JSON malformado que hacia fallar el parser. Una memorable noche de viernes, una caida de una API de terceros hizo que el agente se disculpara con los clientes en un bucle infinito, quemando $400 en tokens antes de que alguien se diera cuenta.
El agente no estaba roto, simplemente nunca habia estado listo para produccion.
Gartner predice que mas del 40% de los proyectos de IA agentica seran cancelados para finales de 2027, citando costos crecientes, valor de negocio poco claro y controles de riesgo inadecuados. La encuesta State of Agent Engineering 2025 de LangChain encontro que mientras el 57% de las organizaciones ahora tienen agentes en produccion, la calidad sigue siendo la principal barrera: el 32% la llamo el mayor desafio. La brecha entre "funciona en la demo" y "corre de manera confiable a escala" es donde la mayoria de los proyectos mueren.
Este articulo cierra esa brecha. Construiremos patrones de grado produccion para orquestacion, manejo de errores, degradacion elegante, observabilidad y escalamiento, todo en TypeScript, todo probado en batalla. No teoria. Codigo funcional que puedes adaptar a tu propia infraestructura de agentes.
| Lo que construiras | Por que importa |
|---|---|
| Orquestacion ReAct con ejecucion limitada | Evita que los agentes iteren infinitamente en tareas ambiguas |
| Clasificacion de errores y estrategias de reintentos | Evita que fallas transitorias se conviertan en caidas permanentes |
| Circuit breaker para llamadas a herramientas | Protege servicios downstream y tu presupuesto de tokens |
| Pipeline de degradacion elegante | Mantiene a los agentes utiles incluso cuando componentes fallan |
| Tracing basado en OpenTelemetry | Hace que el comportamiento del agente sea depurable en produccion |
| Escalamiento horizontal basado en colas | Maneja picos de trafico sin perder solicitudes |
Requisitos previos
Deberias sentirte comodo con TypeScript y patrones async/await. Familiaridad con APIs de LLM (OpenAI, Anthropic) ayuda pero no es estrictamente necesaria, ya que los patrones aplican independientemente del proveedor.
Si eres nuevo en infraestructura de herramientas para agentes, comienza con Herramientas para Agentes de IA: MCP, OpenAPI y Gestion de Herramientas. Para patrones de generacion aumentada por recuperacion referenciados en la seccion de degradacion, consulta RAG desde Cero.
npm install openai zod bullmq ioredis @opentelemetry/api @opentelemetry/sdk-nodeTodos los ejemplos de codigo son TypeScript autocontenidos. Hacen referencia a versiones simplificadas de patrones de produccion, adapta los tipos y el manejo de errores a tu propio stack.
Patrones de Orquestacion de Agentes que Sobreviven en Produccion
El patron de orquestacion que elijas determina como tu agente razona, se recupera de errores y consume recursos. ReAct y plan-and-execute son los dos patrones dominantes, y la mayoria de los sistemas en produccion usan elementos de ambos. Elegir el equivocado, o implementar cualquiera sin limites de ejecucion, es como los agentes terminan en bucles infinitos quemando tokens a las 3 AM.
ReAct: Pensar, Actuar, Observar
ReAct (Reasoning + Acting) intercala pasos de razonamiento con llamadas a herramientas. El agente piensa sobre que hacer, lo hace, observa el resultado y decide el siguiente paso. Es el patron por defecto en LangChain, LangGraph y la mayoria de los frameworks de agentes porque se mapea naturalmente a como los LLMs generan respuestas.
Aqui hay un bucle ReAct de produccion con las protecciones que un prototipo se saltaria: limites de pasos, timeouts y manejo estructurado de errores:
interface AgentState {
messages: Message[];
steps: AgentStep[];
startTime: number;
totalTokens: number;
}
interface AgentStep {
thought: string;
action?: { tool: string; input: Record<string, unknown> };
observation?: string;
error?: string;
durationMs: number;
}
interface AgentConfig {
maxSteps: number; // Hard cap on reasoning cycles
maxTokens: number; // Budget ceiling per run
timeoutMs: number; // Wall-clock deadline
tools: ToolDefinition[];
}
async function runReActAgent(
prompt: string,
config: AgentConfig
): Promise<AgentResult> {
const state: AgentState = {
messages: [{ role: 'user', content: prompt }],
steps: [],
startTime: Date.now(),
totalTokens: 0,
};
for (let step = 0; step < config.maxSteps; step++) {
// Guard: wall-clock timeout
const elapsed = Date.now() - state.startTime;
if (elapsed > config.timeoutMs) {
return finalize(state, 'timeout',
`Agent timed out after ${elapsed}ms (limit: ${config.timeoutMs}ms)`);
}
// Guard: token budget
if (state.totalTokens > config.maxTokens) {
return finalize(state, 'budget_exceeded',
`Token budget exceeded: ${state.totalTokens}/${config.maxTokens}`);
}
const stepStart = Date.now();
// Reason: ask the LLM what to do next
const response = await callLLM(state.messages, config.tools);
state.totalTokens += response.usage.totalTokens;
// Check if the agent wants to respond (no tool call)
if (!response.toolCall) {
state.steps.push({
thought: response.content,
durationMs: Date.now() - stepStart,
});
return finalize(state, 'complete', response.content);
}
// Act: execute the tool
const { tool, input } = response.toolCall;
let observation: string;
let error: string | undefined;
try {
observation = await executeTool(tool, input, config);
} catch (err) {
error = err instanceof Error ? err.message : 'Unknown tool error';
observation = `Tool "${tool}" failed: ${error}`;
}
// Record the step
state.steps.push({
thought: response.content,
action: { tool, input },
observation,
error,
durationMs: Date.now() - stepStart,
});
// Observe: feed the result back to the LLM
state.messages.push(
{ role: 'assistant', content: response.content, toolCall: response.toolCall },
{ role: 'tool', content: observation, toolCallId: response.toolCall.id }
);
}
// Exhausted all steps without completing
return finalize(state, 'max_steps_exceeded',
`Agent reached ${config.maxSteps} steps without completing`);
}Tres cosas hacen esto de grado produccion que los prototipos se saltan: el contador de pasos previene bucles infinitos, el presupuesto de tokens previene costos descontrolados, y el timeout de tiempo real previene que el agente se cuelgue cuando una llamada al LLM tarda 45 segundos en lugar de los 3 habituales. Sin los tres, eventualmente vas a encontrarte con cada modo de falla.
Plan-and-Execute: Pensar Primero, Luego Hacer
Para tareas complejas de multiples pasos (flujos de onboarding, orquestacion de pipelines de datos, analisis de multiples documentos) el enfoque paso a paso de ReAct puede divagar. Plan-and-execute genera un plan estructurado por adelantado, lo valida, y luego ejecuta cada paso metodicamente.
La fase de planificacion te da un checkpoint critico que ReAct no tiene: la capacidad de inspeccionar y aprobar el enfoque del agente antes de que empiece a llamar herramientas y gastar dinero.
interface ExecutionPlan {
goal: string;
steps: PlanStep[];
estimatedCost: number;
estimatedDurationMs: number;
}
interface PlanStep {
id: string;
description: string;
tool: string;
dependencies: string[]; // IDs of steps that must complete first
fallback?: string; // Alternative tool if primary fails
}
async function planAndExecute(
prompt: string,
config: AgentConfig
): Promise<AgentResult> {
// Phase 1: Generate plan
const plan = await generatePlan(prompt, config.tools);
// Phase 1b: Validate — reject plans that exceed budget or use unknown tools
const validation = validatePlan(plan, config);
if (!validation.valid) {
return { status: 'plan_rejected', reason: validation.errors };
}
// Phase 2: Execute with dependency ordering
const completed = new Map<string, StepResult>();
const sortedSteps = topologicalSort(plan.steps);
for (const step of sortedSteps) {
// Wait for dependencies
const depResults = step.dependencies.map(id => completed.get(id));
if (depResults.some(r => r?.status === 'failed' && !step.fallback)) {
completed.set(step.id, {
status: 'skipped',
reason: 'Dependency failed'
});
continue;
}
// Execute step with retry
const result = await executeStepWithRetry(step, depResults, config);
completed.set(step.id, result);
// If step failed and has a fallback, try the alternative
if (result.status === 'failed' && step.fallback) {
const fallbackResult = await executeStepWithRetry(
{ ...step, tool: step.fallback }, depResults, config
);
completed.set(step.id, fallbackResult);
}
}
return synthesizeResults(plan, completed);
}Observa el arreglo dependencies en cada paso, esto es lo que hace poderoso a plan-and-execute para flujos de trabajo. Los pasos sin dependencias pueden ejecutarse en paralelo (cubriremos esto en la seccion de escalamiento), mientras que los pasos dependientes esperan sus entradas. El campo fallback le da a cada paso un Plan B sin tener que re-planificar todo el flujo.
Que Patron, Cuando?
No lo pienses demasiado. Este es el framework de decision que usamos:
La investigacion de Google sobre escalamiento de sistemas de agentes, publicada a principios de 2026, respalda esto cuantitativamente. Su evaluacion de 180 configuraciones de agentes encontro que la coordinacion multi-paso mejora el rendimiento en tareas paralelizables pero lo degrada en tareas secuenciales. La conclusion practica: no uses un patron de orquestacion complejo cuando un simple bucle ReAct cumple el objetivo.
Manejo de Errores que No te Miente
La mayoria del manejo de errores en agentes cae en dos categorias: bloques try/catch genericos que tragan errores silenciosamente, o ningun manejo de errores en absoluto. Ambos son deshonestos: ocultan fallas a los operadores y usuarios. Los agentes en produccion necesitan clasificacion de errores, porque la respuesta correcta a "la API de Stripe devolvio un 429" es fundamentalmente diferente a "el ID de pedido del cliente no existe."
Clasificando Errores: Transitorios vs. Permanentes
La primera decision cuando ocurre un error es si reintentar. Reintentar un error permanente desperdicia tiempo y tokens. No reintentar un error transitorio convierte un tropiezo momentaneo en una falla visible para el usuario.
Este sistema de clasificacion maneja los errores que realmente hemos visto en sistemas de agentes en produccion, no solo los casos de libro de texto:
enum ErrorCategory {
TRANSIENT = 'transient', // Retry with backoff
PERMANENT = 'permanent', // Fail fast, don't retry
RATE_LIMIT = 'rate_limit', // Retry with longer backoff
CONTEXT = 'context', // LLM misunderstood — rephrase
BUDGET = 'budget', // Resource limit hit — escalate
}
function classifyError(error: unknown): ErrorCategory {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const status = (error as any).status || (error as any).statusCode;
// HTTP status-based classification
if (status === 429) return ErrorCategory.RATE_LIMIT;
if (status === 408 || status === 502 || status === 503 || status === 504) {
return ErrorCategory.TRANSIENT;
}
if (status === 400 || status === 404 || status === 422) {
return ErrorCategory.PERMANENT;
}
if (status === 402) return ErrorCategory.BUDGET;
// Message-based classification for LLM-specific errors
if (msg.includes('timeout') || msg.includes('econnreset')) {
return ErrorCategory.TRANSIENT;
}
if (msg.includes('context length') || msg.includes('token limit')) {
return ErrorCategory.CONTEXT;
}
if (msg.includes('invalid') || msg.includes('not found')) {
return ErrorCategory.PERMANENT;
}
}
return ErrorCategory.TRANSIENT; // Default to retryable
}Backoff Exponencial con Jitter
Una vez que has clasificado un error como reintentable, necesitas una estrategia de backoff que no martille un servicio que se esta recuperando. El blog de arquitectura de AWS popularizo el enfoque de "full jitter" hace anios, y sigue siendo el mejor predeterminado para sistemas distribuidos. El jitter previene el problema del thundering herd: sin el, todos tus agentes reintentando van a golpear el servicio exactamente en los mismos intervalos.
La implementacion a continuacion maneja tanto errores transitorios estandar como rate limits (que necesitan periodos de enfriamiento mas largos):
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
jitterFactor: number; // 0-1, how much randomness
}
const RETRY_CONFIGS: Record<ErrorCategory, RetryConfig | null> = {
[ErrorCategory.TRANSIENT]: {
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 10_000,
jitterFactor: 0.5,
},
[ErrorCategory.RATE_LIMIT]: {
maxRetries: 5,
baseDelayMs: 2_000,
maxDelayMs: 60_000,
jitterFactor: 1.0, // Full jitter to spread load
},
[ErrorCategory.PERMANENT]: null, // Never retry
[ErrorCategory.CONTEXT]: null, // Rephrase, don't retry same call
[ErrorCategory.BUDGET]: null, // Escalate, don't retry
};
function calculateDelay(attempt: number, config: RetryConfig): number {
// Exponential: 500, 1000, 2000, 4000...
const exponential = config.baseDelayMs * Math.pow(2, attempt);
const capped = Math.min(exponential, config.maxDelayMs);
// Full jitter: uniform random between 0 and capped value
const jitter = capped * config.jitterFactor * Math.random();
const base = capped * (1 - config.jitterFactor);
return base + jitter;
}
async function executeWithRetry<T>(
fn: () => Promise<T>,
label: string
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const category = classifyError(error);
const retryConfig = RETRY_CONFIGS[category];
// Non-retryable or exhausted retries
if (!retryConfig || attempt >= retryConfig.maxRetries) {
throw new AgentError(
`${label} failed after ${attempt + 1} attempts: ${lastError.message}`,
{ category, attempts: attempt + 1, originalError: lastError }
);
}
const delay = calculateDelay(attempt, retryConfig);
console.warn(
`[retry] ${label} attempt ${attempt + 1}/${retryConfig.maxRetries}` +
` (${category}), waiting ${Math.round(delay)}ms`
);
await sleep(delay);
}
}
}El Circuit Breaker: Cuando los Reintentos No Son Suficientes
Los reintentos manejan fallas individuales. Pero que pasa cuando un servicio downstream esta genuinamente caido, no un parpadeo, sino una caida sostenida? Tus agentes van a agotar sus presupuestos de reintentos en cada solicitud, agregando latencia y quemando tokens en conversaciones de manejo de errores. Un circuit breaker detecta este patron y cortocircuita las llamadas por completo.
La maquina de estados tiene tres estados: cerrado (operacion normal), abierto (el servicio esta caido, fallar inmediatamente) y medio-abierto (probando tentativamente si el servicio se ha recuperado):
enum CircuitState {
CLOSED = 'closed', // Normal: requests flow through
OPEN = 'open', // Tripped: fail immediately
HALF_OPEN = 'half_open', // Testing: allow one probe request
}
class CircuitBreaker {
private state = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime = 0;
private successCount = 0;
constructor(
private readonly name: string,
private readonly config: {
failureThreshold: number; // Failures before opening
resetTimeoutMs: number; // How long to stay open
halfOpenSuccesses: number; // Successes needed to close
}
) {}
async execute<T>(fn: () => Promise<T>, fallback?: () => Promise<T>): Promise<T> {
// Check if circuit should transition from open to half-open
if (this.state === CircuitState.OPEN) {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed >= this.config.resetTimeoutMs) {
this.state = CircuitState.HALF_OPEN;
this.successCount = 0;
} else if (fallback) {
return fallback();
} else {
throw new CircuitOpenError(
`Circuit "${this.name}" is open. Retry after ` +
`${this.config.resetTimeoutMs - elapsed}ms`
);
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
if (fallback && this.state === CircuitState.OPEN) {
return fallback();
}
throw error;
}
}
private onSuccess(): void {
if (this.state === CircuitState.HALF_OPEN) {
this.successCount++;
if (this.successCount >= this.config.halfOpenSuccesses) {
this.state = CircuitState.CLOSED;
this.failureCount = 0;
}
} else {
this.failureCount = 0;
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.config.failureThreshold) {
this.state = CircuitState.OPEN;
}
}
getState(): { state: CircuitState; failures: number } {
return { state: this.state, failures: this.failureCount };
}
}En produccion, tendrias un circuit breaker por dependencia externa: uno para OpenAI, uno para tu API de CRM, uno para la base de conocimiento. Cuando el circuito del CRM se abre, tu agente todavia puede responder preguntas usando la base de conocimiento y el LLM; simplemente no puede buscar registros de clientes hasta que el CRM se recupere.
Degradacion Elegante: Util Cuando Esta Roto
Que hace tu agente cuando GPT-4o esta caido? Cuando la base de conocimiento esta inaccesible? Cuando la integracion de CRM del cliente devuelve errores? Si la respuesta es "crashear" o "mostrar un mensaje de error generico", estas dejando confiabilidad sobre la mesa.
Degradacion elegante significa disenar tu agente para que proporcione funcionalidad reducida pero todavia util cuando componentes fallan. Es la diferencia entre "Lo siento, estoy teniendo problemas ahora mismo" y "No puedo verificar el estado de tu pedido ahora mismo, pero puedo ayudarte con preguntas generales sobre nuestra politica de devoluciones."
El Pipeline de Degradacion
Piensa en la degradacion como una serie de capas de respaldo. Cada capa es mas simple y menos capaz, pero mas confiable. El agente desciende a la capa de mayor funcionalidad disponible:
interface DegradationLayer {
name: string;
isAvailable: () => Promise<boolean>;
execute: (input: AgentInput) => Promise<AgentOutput>;
}
class DegradationPipeline {
private layers: DegradationLayer[];
constructor(layers: DegradationLayer[]) {
// Ordered from most capable to most resilient
this.layers = layers;
}
async execute(input: AgentInput): Promise<AgentOutput & { layer: string }> {
for (const layer of this.layers) {
try {
const available = await Promise.race([
layer.isAvailable(),
sleep(2000).then(() => false), // Health check timeout
]);
if (!available) continue;
const result = await layer.execute(input);
return { ...result, layer: layer.name };
} catch {
continue; // Fall through to next layer
}
}
// All layers exhausted — return the hardcoded safety net
return {
response: "I'm currently experiencing technical difficulties. " +
"Please try again in a few minutes or contact support.",
layer: 'safety_net',
confidence: 0,
};
}
}
// Example: customer support agent with four degradation layers
const supportAgent = new DegradationPipeline([
{
name: 'full_agent',
isAvailable: async () => {
const llm = await checkLLMHealth();
const kb = await checkKnowledgeBaseHealth();
const crm = await checkCRMHealth();
return llm && kb && crm;
},
execute: async (input) => {
// Full capability: LLM + knowledge base + CRM tools
return runReActAgent(input.query, fullConfig);
},
},
{
name: 'no_crm',
isAvailable: async () => {
const llm = await checkLLMHealth();
const kb = await checkKnowledgeBaseHealth();
return llm && kb;
},
execute: async (input) => {
// LLM + knowledge base only — no customer-specific data
const result = await runReActAgent(input.query, kbOnlyConfig);
result.response += "\n\nNote: I couldn't access your account details. " +
"For order-specific questions, please contact support.";
return result;
},
},
{
name: 'cached_responses',
isAvailable: async () => true, // Cache is always "available"
execute: async (input) => {
// Semantic search over cached responses for common queries
const cached = await searchResponseCache(input.query);
if (cached && cached.similarity > 0.85) {
return { response: cached.response, confidence: cached.similarity };
}
throw new Error('No suitable cached response');
},
},
{
name: 'static_fallback',
isAvailable: async () => true,
execute: async (input) => {
// Rule-based response for known intents
const intent = classifyIntentLocally(input.query);
const staticResponse = STATIC_RESPONSES[intent] || STATIC_RESPONSES.default;
return { response: staticResponse, confidence: 0.3 };
},
},
]);Cadenas de Respaldo de Modelos
Las caidas de proveedores de LLM ocurren mas seguido de lo que esperarias. Claude tiene una mala hora, GPT-4o tiene problemas de capacidad, Gemini devuelve respuestas degradadas durante un rollout. Una cadena de respaldo de modelos permite que tu agente cambie de proveedor de forma transparente.
Este patron trata a los modelos como una lista ordenada por prioridad: intenta con el mejor primero, recurre progresivamente:
interface ModelConfig {
provider: 'openai' | 'anthropic' | 'google';
model: string;
maxTokens: number;
costPer1kTokens: number;
}
const MODEL_CHAIN: ModelConfig[] = [
{ provider: 'anthropic', model: 'claude-sonnet-4-20250514', maxTokens: 8192, costPer1kTokens: 0.003 },
{ provider: 'openai', model: 'gpt-4o', maxTokens: 4096, costPer1kTokens: 0.005 },
{ provider: 'google', model: 'gemini-2.0-flash', maxTokens: 8192, costPer1kTokens: 0.001 },
];
async function callLLMWithFallback(
messages: Message[],
tools: ToolDefinition[]
): Promise<LLMResponse> {
const errors: Array<{ model: string; error: string }> = [];
for (const config of MODEL_CHAIN) {
const breaker = getCircuitBreaker(`llm:${config.provider}`);
try {
return await breaker.execute(
() => callProvider(config, messages, tools),
);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown';
errors.push({ model: `${config.provider}/${config.model}`, error: msg });
}
}
throw new AllModelsFailedError(
`All ${MODEL_CHAIN.length} models failed`,
errors
);
}Cada proveedor tiene su propio circuit breaker. Si Anthropic ha estado fallando consistentemente, el circuito se abre y las solicitudes subsiguientes saltan directamente a OpenAI, sin latencia desperdiciada en un servicio que se sabe esta caido. Cuando Anthropic se recupera, el estado medio-abierto permite que una sola solicitud de prueba pase, y si tiene exito, el trafico se reanuda.
Monitoreo y Observabilidad: Viendo lo que tu Agente Realmente Hace
Alguna vez has depurado un problema de produccion donde el agente "hizo algo raro" pero nadie podia decirte exactamente que paso? Sin observabilidad, depurar agentes es adivinanza. Con ella, puedes reconstruir cada pensamiento, llamada a herramienta y punto de decision que llevo a un mal resultado.
La encuesta de LangChain 2025 encontro que el 89% de las organizaciones con agentes en produccion han implementado alguna forma de observabilidad, y el 71.5% tiene tracing completo a nivel de pasos. Esto no es infraestructura opcional: es la diferencia entre "el agente a veces da respuestas incorrectas" y "el agente alucino en el paso 3 porque la base de conocimiento devolvio cero resultados para este tipo de consulta."
Tracing Estructurado con OpenTelemetry
Las convenciones semanticas GenAI de OpenTelemetry ahora definen atributos estandar para llamadas a LLM, uso de tokens, invocaciones de herramientas y pasos de agentes. Esto significa que puedes instrumentar una vez y exportar a LangSmith, Datadog, Langfuse o cualquier backend compatible con OTel.
La estructura de trazas refleja la ejecucion del agente: un span padre para la ejecucion completa, spans hijos para cada paso de razonamiento, y spans anidados para llamadas al LLM y ejecuciones de herramientas:
import { trace, SpanKind, SpanStatusCode, context } from '@opentelemetry/api';
const tracer = trace.getTracer('agent-service', '1.0.0');
async function tracedAgentRun(
prompt: string,
config: AgentConfig
): Promise<AgentResult> {
return tracer.startActiveSpan('agent.run', {
kind: SpanKind.SERVER,
attributes: {
'gen_ai.system': 'custom',
'gen_ai.request.model': config.model,
'agent.max_steps': config.maxSteps,
'agent.timeout_ms': config.timeoutMs,
},
}, async (runSpan) => {
try {
const result = await runReActAgent(prompt, config);
runSpan.setAttributes({
'agent.status': result.status,
'agent.steps_taken': result.steps.length,
'agent.total_tokens': result.totalTokens,
'agent.duration_ms': result.durationMs,
});
runSpan.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
runSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : 'Unknown',
});
runSpan.recordException(error as Error);
throw error;
} finally {
runSpan.end();
}
});
}
async function tracedToolCall(
toolName: string,
input: Record<string, unknown>
): Promise<string> {
return tracer.startActiveSpan(`tool.${toolName}`, {
kind: SpanKind.CLIENT,
attributes: {
'tool.name': toolName,
'tool.input_keys': Object.keys(input).join(','),
},
}, async (toolSpan) => {
const start = Date.now();
try {
const result = await executeTool(toolName, input);
toolSpan.setAttributes({
'tool.duration_ms': Date.now() - start,
'tool.output_length': result.length,
'tool.success': true,
});
return result;
} catch (error) {
toolSpan.setAttributes({
'tool.duration_ms': Date.now() - start,
'tool.success': false,
'tool.error_category': classifyError(error),
});
throw error;
} finally {
toolSpan.end();
}
});
}Diagram error: failed to render
gantt
title Agent Run Trace (Waterfall)
dateFormat X
axisFormat %L ms
section Agent Run
agent.run :0, 4200
section Step 1
llm.call (think) :0, 800
tool.search_kb :800, 1400
section Step 2
llm.call (reason) :1400, 2100
tool.check_order :2100, 2800
section Step 3
llm.call (respond) :2800, 4200Sobre que Alertar
Los dashboards estan bien. Las alertas que te despiertan en el momento correcto son esenciales. Estas son las cinco alertas que hemos encontrado realmente importan para agentes en produccion, clasificadas por la frecuencia con que se disparan:
interface AgentAlert {
name: string;
condition: string;
severity: 'warning' | 'critical';
runbook: string;
}
const PRODUCTION_ALERTS: AgentAlert[] = [
{
name: 'success_rate_drop',
condition: 'success_rate < 0.95 over 5 minutes',
severity: 'critical',
runbook: 'Check LLM provider status, review recent tool errors, ' +
'verify knowledge base connectivity',
},
{
name: 'p95_latency_spike',
condition: 'p95_duration_ms > 15000 over 5 minutes',
severity: 'warning',
runbook: 'Check LLM response times, look for tool timeout patterns, ' +
'verify no infinite loop in agent steps',
},
{
name: 'token_cost_anomaly',
condition: 'hourly_token_cost > 2x rolling_7d_average',
severity: 'warning',
runbook: 'Check for prompt injection attempts, review agent step counts, ' +
'look for context window overflow patterns',
},
{
name: 'circuit_breaker_open',
condition: 'any circuit breaker state == open',
severity: 'critical',
runbook: 'Identify which dependency tripped, check downstream service health, ' +
'verify degradation pipeline is activating',
},
{
name: 'eval_score_regression',
condition: 'rolling_eval_score < baseline - 0.5 over 1 hour',
severity: 'warning',
runbook: 'Compare recent prompts to baseline, check for model version change, ' +
'review knowledge base freshness',
},
];La alerta de anomalia de costo de tokens es la que la gente se salta y luego lamenta. Un ataque de prompt injection o un bucle de agente trabado puede quemar cientos de dolares en minutos. Si tu costo por hora se duplica inesperadamente, algo anda mal, y quieres saberlo antes de que llegue la factura.
Para monitoreo en produccion de calidad de agentes mas alla de metricas crudas, los scorecards automatizados detectan regresiones de comportamiento que las alertas de latencia no detectan. Si tu agente empieza a ser tecnicamente correcto pero tonalmente inapropiado, un scorecard lo detecta; un dashboard de latencia no lo hara.

Escalando Cargas de Trabajo de Agentes
Una sola ejecucion de agente puede tomar 30 segundos, hacer 5 llamadas al LLM, invocar 3 herramientas y mantener el estado de la conversacion todo el tiempo, fundamentalmente diferente de un endpoint REST que responde en 50ms y olvida todo. Cuando el trafico salta de 10 a 10,000 solicitudes por minuto, las estrategias de escalamiento que funcionan para APIs stateless te van a fallar.
Por que el Autoscaling Basado en CPU No Funciona
Aqui esta la trampa: los agentes pasan la mayor parte del tiempo esperando respuestas de la API del LLM. La utilizacion de CPU se mantiene baja incluso cuando el sistema esta completamente sobrecargado. Si escalas basandote en CPU, nunca vas a agregar capacidad hasta que sea demasiado tarde.
Escala basandote en la profundidad de la cola. Una arquitectura basada en BullMQ desacopla la aceptacion de solicitudes del procesamiento, permitiendote absorber picos de trafico sin perder solicitudes:
import { Queue, Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
maxRetriesPerRequest: null,
});
// Producer: accepts requests and enqueues them
const agentQueue = new Queue('agent-runs', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86400 }, // Keep completed jobs for 24h
removeOnFail: { age: 604800 }, // Keep failed jobs for 7 days
},
});
async function enqueueAgentRun(request: AgentRequest): Promise<string> {
const job = await agentQueue.add('run', request, {
priority: request.priority || 5, // Lower = higher priority
timeout: request.timeoutMs || 120_000,
});
return job.id!;
}
// Worker: processes agent runs from the queue
const worker = new Worker('agent-runs', async (job: Job<AgentRequest>) => {
const { prompt, config, conversationId } = job.data;
// Load conversation state from external store (not process memory)
const history = await loadConversationHistory(conversationId);
const result = await tracedAgentRun(prompt, {
...config,
messages: history,
});
// Persist updated state
await saveConversationHistory(conversationId, result.messages);
// Publish result for the waiting client
await publishResult(job.id!, result);
return result;
}, {
connection,
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '10'),
limiter: {
max: 50, // Max 50 jobs per duration window
duration: 60_000, // Per minute — prevents LLM rate limits
},
});
worker.on('failed', (job, err) => {
console.error(`Agent run ${job?.id} failed:`, err.message);
// Emit metric for alerting
metrics.increment('agent.run.failed', {
error_category: classifyError(err)
});
});Externalizando el Estado
La linea clave en ese worker es loadConversationHistory, no this.conversationHistory. Si el estado de la conversacion vive en la memoria del proceso, no puedes escalar horizontalmente. Una solicitud debe poder aterrizar en cualquier instancia de worker y continuar donde el paso anterior se quedo.
Redis es la opcion natural para estado caliente (conversaciones activas, ejecuciones en progreso). Mueve el estado frio (conversaciones completadas, contexto historico) a tu base de datos principal. Este enfoque de dos niveles mantiene Redis ligero mientras conserva el historial completo de conversaciones:
interface ConversationStore {
// Hot path: Redis (active conversations)
getActive(conversationId: string): Promise<ConversationState | null>;
setActive(conversationId: string, state: ConversationState, ttlSeconds?: number): Promise<void>;
// Cold path: database (historical)
archive(conversationId: string): Promise<void>;
getArchived(conversationId: string): Promise<ConversationState | null>;
}
class RedisConversationStore implements ConversationStore {
constructor(private redis: IORedis) {}
async getActive(id: string): Promise<ConversationState | null> {
const data = await this.redis.get(`conv:${id}`);
return data ? JSON.parse(data) : null;
}
async setActive(
id: string,
state: ConversationState,
ttlSeconds = 3600
): Promise<void> {
await this.redis.setex(
`conv:${id}`,
ttlSeconds,
JSON.stringify(state)
);
}
async archive(id: string): Promise<void> {
const state = await this.getActive(id);
if (state) {
await db.conversations.insertOne({ ...state, archivedAt: new Date() });
await this.redis.del(`conv:${id}`);
}
}
async getArchived(id: string): Promise<ConversationState | null> {
return db.conversations.findOne({ conversationId: id });
}
}Matriz de Decision de Escalamiento
No todas las cargas de trabajo de agentes necesitan la misma estrategia de escalamiento. Un agente interno de bajo volumen esta sobreservido por una arquitectura de colas. Un agente orientado al cliente manejando 10K conversaciones concurrentes necesita cada pieza de esta infraestructura.
| Patron de Trafico | Estrategia | Necesita Cola? | Almacen de Estado |
|---|---|---|---|
| < 100 req/min | Proceso unico, estado en memoria | No | Memoria del proceso |
| 100-1K req/min | Multiples workers, estado externo | Si | Redis |
| 1K-10K req/min | Workers auto-escalados, colas particionadas | Si | Redis + base de datos |
| > 10K req/min | Deployment regional, colas con prioridad | Si | Cluster de Redis + base de datos |
La transicion de "proceso unico" a "cola + workers" es la mas dolorosa. Hazla antes de que la necesites. Adaptar una arquitectura stateless a un agente stateful es significativamente mas dificil que empezar con estado externo desde el primer dia.
Uniendo Todo: La Arquitectura de Agente para Produccion
La teoria es una cosa. Conectar todo junto es otra. Asi es como los patrones de este articulo se componen en un deployment de produccion.
Cada solicitud fluye a traves del mismo pipeline: aceptar, validar, encolar, ejecutar con reintentos y circuit breakers, degradar elegantemente si es necesario, rastrear todo y devolver un resultado. Ningun componente es opcional. Saltate el circuit breaker y aprenderas por que lo necesitabas a las 2 AM:
Un Checklist para Produccion
Antes de desplegar cualquier agente a produccion, recorre este checklist. Cada item existe porque alguien (frecuentemente yo) aprendio de la manera dificil que saltarselo causa problemas reales:
Limites de Ejecucion
- Conteo maximo de pasos configurado (previene bucles de razonamiento infinitos)
- Timeout de tiempo real configurado (previene ejecuciones colgadas)
- Presupuesto de tokens aplicado por ejecucion (previene costos descontrolados)
Manejo de Errores
- Errores clasificados como transitorios/permanentes/rate-limit
- Backoff exponencial con jitter en reintentos
- Circuit breakers en cada dependencia externa
- Cola de dead letter para trabajos fallidos permanentemente
Degradacion
- Cadena de respaldo de modelos probada (primario -> secundario -> terciario)
- Capa de respuestas cacheadas para consultas comunes
- Respaldo estatico para cuando todos los proveedores de LLM estan caidos
- Mensajes orientados al usuario que explican limitaciones honestamente
Observabilidad
- Cada ejecucion de agente produce una traza con detalle a nivel de pasos
- Uso de tokens, latencia y tasas de error exportadas como metricas
- Alertas configuradas para tasa de exito, latencia y anomalias de costo
- Trazas enlazadas a IDs de conversacion para depuracion de soporte al cliente
Escalamiento
- Estado de conversacion externalizado (Redis o base de datos)
- Procesamiento basado en colas con soporte de prioridades
- Autoscaling basado en profundidad de cola, no en CPU
- Rate limits que previenen agotamiento de cuota de API del LLM
Si estas usando pruebas de escenarios para validar el comportamiento de agentes, ejecuta tu suite de escenarios contra los modos degradados tambien, no solo el camino feliz. Un agente que maneja la degradacion elegantemente durante las pruebas pero falla en produccion en un modo que no probaste no esta listo para produccion.
Que Se Rompe Despues: Patrones a Observar
La infraestructura de agentes esta evolucionando rapido, pero tres tendencias estan redefiniendo lo que significa "listo para produccion."
Salida estructurada como capa de confiabilidad. OpenAI, Anthropic y Google ahora soportan generacion de JSON con restricciones. Esto elimina toda una categoria de fallas en produccion: argumentos de llamadas a herramientas malformados, respuestas no parseables, violaciones de schema. Combinado con solidos fundamentos de prompt engineering, el modo de salida estructurada es el cambio de mayor impacto que puedes hacer si todavia estas parseando la salida de texto libre del LLM con regex.
Evaluacion nativa de agentes en CI. Los equipos estan pasando de "ejecutar evaluaciones manualmente cuando alguien se acuerda" a suites de evaluacion automatizadas que bloquean los deployments. Si tu pipeline de gestion de prompts no incluye verificaciones de calidad automatizadas, estas desplegando a ciegas. Cubrimos el framework para esto en Como Evaluar Agentes de IA, el patron esta madurando rapidamente.
La memoria persistente cambiando supuestos de escalamiento. Cuando los agentes tienen memoria a largo plazo, recordando conversaciones previas, preferencias del cliente, procedimientos aprendidos, la historia de gestion de estado se vuelve mas compleja. No puedes solo externalizar el historial de conversacion a Redis; necesitas una capa de memoria que persista entre sesiones, maneje conflictos entre actualizaciones concurrentes y degrade elegantemente cuando el almacen de memoria no este disponible.
Los equipos que sobrevivan la tasa de cancelacion del 40% de Gartner seran los que trataron la confiabilidad en produccion como una preocupacion de primera clase desde el inicio, no algo para agregar despues de que la demo impresiono a los ejecutivos. Cada patron en este articulo existe porque alguien lo lanzo sin el y pago el precio.
Construye la infraestructura aburrida. Tus agentes te lo agradeceran a las 2 AM.
Lanza agentes que no se rompen a las 2 AM
Chanl se encarga del monitoreo, pruebas de escenarios y scorecards de calidad, para que tus agentes se mantengan confiables en produccion sin la carga de infraestructura.
Empieza a construir gratisEngineering Lead
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
Aprende IA Agéntica
Una lección por semana: técnicas prácticas para construir, probar y lanzar agentes IA. Desde ingeniería de prompts hasta monitoreo en producción. Aprende haciendo.



