El problema
En dev, tu agente “funciona”.
En prod:
- se queda en loop con una API flaky
- hace 200 tool calls porque “solo uno más”
- no puedes explicar qué pasó porque tu único log es el final answer
Eso no es un problema del LLM. Es un problema de stack.
Por qué pasa en sistemas reales
Un agente es, básicamente:
- un planner (LLM)
- una runtime (tu código)
- side effects (tools)
- state (memory/artifacts)
- restricciones (budgets/policy)
- observabilidad (logs/audit)
Si solo construyes el planner, te van a paginar.
Qué se rompe si lo ignoras
- Sin audit = sin postmortem (o el postmortem es “fue el modelo”)
- Sin budgets = coste sin límites
- Sin frontera de policy = writes accidentales con credenciales de prod
- Sin state = trabajo repetido, tool calls duplicados, prompt bloat
El stack (lo que de verdad corremos)
- Punto de entrada: UI/API, auth, request id
- Orchestrator: routing, retries, budgets, tracing
- Capa de modelo: llamadas al LLM (con spend tracking)
- Capa de tools: APIs, browser, DB (con allowlists)
- State: memory, artifacts, caches, claves de idempotencia
- Observabilidad: logs estructurados, trazas, eventos de auditoría
- Capa de control: policy engine, kill switch, stop de incidentes
Diagrama (dónde se coloca la capa de control)
Este es el modelo mental que usamos:
Si metes el “control” dentro de un prompt, no tienes una capa de control. Tienes una sugerencia.
Capa por capa: lo que aprendimos a golpes
Punto de entrada
El punto de entrada es donde decides el blast radius.
Buenos defaults:
- autenticar antes de que corra el agente
- generar un request id
- atar tenant/entorno a ese request id
- fijar el budget upfront (no dejes que el modelo “negocie” budgets)
Si dejas que el modelo elija el tenant o el entorno, tarde o temprano vas a escribir en el equivocado.
Orchestrator
Esta es la runtime que mantiene a tu agente “honesto”:
- step loop
- timeouts
- retry policy
- tool allowlists
- trace collection
- stop reasons
Si no construyes esto, cada agente se vuelve una snowflake que falla distinto. Las snowflakes son monas… hasta que tienes que operarlas.
Model layer
Tu capa de modelo va, sobre todo, de:
- provider fallbacks (if you have them)
- spend tracking
- predictable output formats (tool actions)
El modelo no es la única parte “unreliable”. Pero es la única a la que todo el mundo culpa, porque es más fácil que admitir que falta la runtime.
Tool layer
Los tools son donde viven los side effects. Aquí es donde impones:
- allowlists (what can be called)
- permissions (what can be written)
- idempotency keys (what can be repeated safely)
- timeouts (what can’t hang)
- rate limits (what can’t DDoS your dependencies)
La capa de tools no debería aceptar “do the thing” como input. Debería aceptar args estructurados con validación.
State layer
El state no es un solo bucket.
Lo separamos en:
- scratch: notas por run, de vida corta (pequeñas, estructuradas)
- artifacts: outputs que necesitas luego (drafts, extractos, planes)
- memory: lo que quieres cargar entre runs (con cuidado)
- cache: deduplicar lecturas caras (URLs, lookups de KB)
Si metes todo en “memory”, obtienes prompt bloat y peores respuestas. Si no cargas nada, obtienes trabajo repetido y tool calls duplicados.
Observability
Si no puedes responder “¿qué hizo?”, no puedes correrlo en producción.
Observabilidad mínima:
- action trace (steps, tool calls, stop reason)
- structured tool logs (args hash, duration, status)
- spend/cost estimation
- per-tenant usage metrics
Si vas en serio, añade tracing (spans del modelo, spans de tools). Pero incluso logs estructurados simples ganan a “el modelo dijo”.
Control layer
La capa de control es la parte que quieres que exista cuando estás dormido:
- budgets (hard limits)
- tool permissions (least privilege)
- approvals (for writes)
- kill switch (operator stop)
- incident stop (circuit breakers)
No es “security theater”. Es lo que convierte una demo de LLM en un sistema que puedes dejar corriendo.
Código: skeleton de orquestación (TypeScript)
No necesitas un framework enorme. Sí necesitas puntos de control explícitos.
type Budget = { maxSteps: number; maxSeconds: number; maxUsd: number };
type ToolName = "web.search" | "http.get" | "ticket.create";
type Policy = {
allowTools: ToolName[];
budget: Budget;
requireApprovalFor: ToolName[];
};
type AuditEvent =
| { type: "tool.call"; tool: ToolName; args: unknown; ms: number }
| { type: "budget.stop"; reason: string }
| { type: "kill"; reason: string };
export async function runAgent(input: string, policy: Policy) {
const started = Date.now();
const events: AuditEvent[] = [];
for (let step = 0; step < policy.budget.maxSteps; step++) {
if (Date.now() - started > policy.budget.maxSeconds * 1000) {
events.push({ type: "budget.stop", reason: "time" });
break;
}
if (await killSwitchIsOn()) {
events.push({ type: "kill", reason: "operator" });
break;
}
const action = await llmDecideNext(input); // returns {tool, args} or {finish}
if (action.type === "finish") return { output: action.text, events };
if (!policy.allowTools.includes(action.tool)) {
throw new Error(`tool not allowed: ${action.tool}`);
}
if (policy.requireApprovalFor.includes(action.tool)) {
await waitForHumanApproval(action); // (pseudo)
}
const t0 = Date.now();
const obs = await callTool(action.tool, action.args); // must enforce timeouts + idempotency
events.push({ type: "tool.call", tool: action.tool, args: action.args, ms: Date.now() - t0 });
input = updateState(input, action, obs); // keep state small, structured
}
return { output: "stopped", events };
}
Qué medimos (porque “parece que va” no es una métrica)
Si quieres operar agentes en producción, mide lo aburrido:
- completion rate (¿terminó o chocó con el budget?)
- p50/p95 runtime
- p50/p95 tool calls per run
- cost per run (tokens + tool credits)
- loop rate (runs stopped by loop guard)
- policy deny rate (how often your allowlist blocks it)
Si no mides los policy denies, vas a “arreglar” el agente ampliando permisos en vez de arreglar la tarea.
Taxonomía de stop reasons (para debugear sin vibes)
Si shipeas sin stop reasons explícitos, tus dashboards van a ser 100% vibes: “it didn’t work” → “it timed out” → “maybe the model was bad”.
Logeamos un único stop_reason por run y lo tratamos como un contrato. Es la diferencia entre:
- “agent feels flaky”
- “60% of runs stop on
tool_timeout:http.getbecause the upstream is dying”
Stop reasons comunes que vemos de verdad:
finishmax_steps,max_seconds,max_usdpolicy_deny:<tool>approval_timeouttool_timeout:<tool>tool_error_exhausted:<tool>loop_detectedoperator_kill
Ejemplo de evento (este tipo de línea aburrida te salva un día después):
{
"request_id": "req_9f2c",
"tenant": "acme-prod",
"steps": 25,
"tool_calls": 17,
"usd_estimate": 1.03,
"stop_reason": "max_usd"
}
Sí, puedes ponerte fancy con “partial success” y “degraded mode”. Empieza con un stop reason. Hazlo consistente. Tu yo de guardia te lo va a agradecer.
Realidad multi‑tenant (donde se esconden los incidentes)
Los sistemas multi‑tenant fallan de formas bastante predecibles:
- contexto de tenant equivocado
- caches cruzados entre tenants
- credenciales compartidas
- tools “globales” que acceden a todo en silencio
Controles:
- el tenant id lo fija el punto de entrada, nunca el modelo
- los caches van keyeados por tenant + entorno
- las credenciales van scopeadas por tenant + entorno
- los audit logs siempre incluyen tenant id
Si falta cualquiera de esos, en algún momento vas a filtrar datos.
Rate limits y circuit breakers (porque los agentes amplifican outages)
Si una dependencia está flaky, un agente es un amplificador de fallos: reintenta, busca alternativas, vuelve a intentar, “verifica”, y vuelve a intentar.
Así conviertes:
- “la API upstream devuelve 500 durante 2 minutos” en
- “enviamos 80k requests y nos rate‑limitearon una hora”
Hacemos tres cosas aburridas:
- Caps de concurrencia por tool (por tenant). Ejemplo: tool de browser max 2 runs concurrentes. Más es auto‑DDoS.
- Rate limiting en la frontera del tool. No dentro del modelo.
- Circuit breakers que hacen fail fast cuando el error rate se dispara.
Pseudocódigo:
const httpGet = rateLimit({ perTenantRps: 5 }, async (url: string) => {
return fetch(url, { signal: AbortSignal.timeout(8000) });
});
const breaker = new CircuitBreaker({
windowMs: 30_000,
failureRate: 0.5,
cooldownMs: 60_000,
});
const res = await breaker.exec(() => httpGet("https://api.example.com/health"));
Cuando el breaker está abierto, paramos el run con una razón clara (tool_unhealthy:http.get),
y no fingimos que el modelo pueda “razonar” a través de un outage.
No puede. Solo va a quemar budget.
El rollout que usamos (porque un agente no merece confianza el día 1)
Shipear a prod no es un interruptor binario.
Shipeamos así:
- solo usuarios internos
- tools solo lectura
- porcentaje canary pequeño
- expandir permisos gradualmente (con aprobaciones para writes)
- y solo entonces considerar comportamiento “autónomo”
Y sí: mantenemos el kill switch a mano todo el tiempo.
Dónde debería vivir la “memory” (pista: no en los prompts)
Si guardas todo en el prompt, consigues:
- context windows que se inflan
- peores respuestas (el modelo se ahoga en ruido)
- más coste
Preferimos:
- scratchpad pequeño y estructurado por run
- artifacts guardados fuera (drafts, notas, citas)
- memory a largo plazo opcional, con scoping estricto + TTL
La memory es una feature de producto. Trátala como tal. Testéala. Audítala. Scópela.
Respuesta a incidentes (lo que quieres listo antes del primer pager)
Los agentes van a fallar. La pregunta es si puedes parar el daño rápido.
Antes de shipear, asegúrate de poder:
- desactivar un tool (browser, email, payments) sin desplegar código
- desactivar un tenant sin tumbar a los demás
- encontrar un run por request id
- hacer replay de un run en un entorno seguro
- responder “¿qué tool calls ocurrieron?” en menos de un minuto
Si no puedes hacer eso, el primer incidente va a ser lento y doloroso.
Testing y replay (porque “funciona con mi prompt” no es un test)
La verdad molesta: el comportamiento del agente cambia cuando cambias cualquier cosa. Versión del modelo. Prompt. Schema del tool. Respuestas de la API upstream. Incluso timeouts.
Así que testeamos el stack, no solo el prompt:
- grabar/reproducir (record/replay) respuestas de tools en un sandbox (inputs iguales, outputs estables)
- correr una suite pequeña de tareas “golden” en cada deploy
- hacer asserts sobre traces, no solo sobre el texto final (steps, tools, stop_reason)
Esto nos pilló regresiones reales:
- un rename del schema del tool hizo que el agente loopeara por errores de validación
- un tweak de retries duplicó los tool calls (coste ~2× de un día a otro)
Si no puedes hacer replay determinista de un run, el debugging se vuelve arqueología.
Orden de construcción “boring first”
Si empiezas desde cero, construye en este orden:
- tool wrapper (allowlist + timeouts + idempotency)
- budgets (steps/time) + stop reasons
- eventos de auditoría (tool calls con hash de args)
- kill switch
- y solo entonces: planning fancy, memory, routing multi‑agent
La mayoría de equipos lo hace al revés porque las demos premian lo “smart”. Producción premia “para cuando se pone raro”.
Fallo real
Una vez shipeamos un agente “funcionando” sin eventos de auditoría estructurados. Luego hizo algo raro en producción.
Timeline del postmortem:
- “llamó muchísimo al tool”
- “creemos que reintentó”
- “no podemos saber qué argumentos usó”
Eso costó ~medio día de ingeniería, sobre todo discutiendo qué había pasado.
Arreglo:
- cada tool call emite un evento estructurado (tool, hash de args, duración, estado)
- el request id se arrastra a través de todo
- el kill switch es un clic, no un deploy
Compromisos
- Más instrumentación = más código.
- Más policy = más casos de “agent refused”.
- Aun así, es más barato que debugear a ciegas.
Cuándo NO construir un stack de agente
Si esto es un script interno one‑off que corre una vez por semana, no lo sobre‑ingenierices. Pero si toca sistemas de producción o dinero real, necesitas el stack. Punto.
Enlaces
- Seguridad: Permisos de tools
- Fallos: Infinite loop
- Patrones: Research agent