En resumen: Sin observabilidad, cada fallo se vuelve “el modelo estuvo raro” — un diagnóstico que no se puede testear ni arreglar. Necesitas: traces de tool calls, stop reasons, tracking de costos y capacidad de replay. No es infraestructura opcional.
Aprenderás: Requisitos mínimos de monitoreo • Un schema unificado de eventos • Taxonomía de stop reasons • Replay básico • Un incidente concreto que vas a reconocer
Sin monitoreo: los usuarios reportan primero • debugging por vibes • sin replay
Con monitoreo mínimo: detectar drift temprano • debug con traces + stop reasons • replay de los últimos runs
Impacto: respuesta a incidentes más rápida + menos fallos repetidos (porque puedes arreglar el root cause)
El problema (en producción)
Un run de agente sale mal.
Un usuario reporta: “envió el email equivocado”.
Abres logs y tienes:
- El texto final (quizá)
- Un stack trace (quizá)
- Vibes (seguro)
Si no puedes responder estas cinco preguntas, el sistema no es operable:
- ¿Qué tools se llamaron (y en qué orden)?
- ¿Con qué argumentos (o al menos args hashes)?
- ¿Qué volvió (o al menos snapshot hashes)?
- ¿Qué versión de model/prompt/tools corría?
- ¿Por qué se detuvo?
Eso no es “faltan dashboards”. Eso es “este sistema no es operable”.
El momento 03:00
Así se siente “sin monitoreo”:
03:12 — Support: "Agent emailed the wrong customer. Please stop it."
Grepeas logs y encuentras… nada que puedas joinear.
2026-02-07T03:11:58Z INFO sent email to customer@example.com
2026-02-07T03:11:59Z INFO sent email to customer@example.com
2026-02-07T03:12:01Z WARN http.get 429
2026-02-07T03:12:03Z INFO Agent completed task
Sin run_id. Sin step trace. Sin stop reason. Sin hash de args. Sin versión de model/tool.
Y haces el peor debugging: grep por una dirección de email y rezar que sea única.
Por qué esto se rompe en producción
1) Los agentes son sistemas distribuidos con pasos extra
Cuando un agente llama tools, construiste:
- múltiples dependencias (HTTP, DB, APIs)
- múltiples failure modes (timeouts, 502s, rate limits)
- múltiples retries (y retry storms)
Si no loggeas cada paso, debuggeas por storytelling.
2) “Success rate” esconde los fallos interesantes
El drift aparece como:
- más tool calls por run (se loopea, no falla)
- más tokens por request (el modelo “explica” errores)
- más latencia (retries, tools lentos)
- stop reasons distintos (budgets, denials, timeouts)
3) No puedes arreglar lo que no puedes replay
Si no puedes replay (o al menos reconstruir) un run desde logs, no puedes confiar en un “fix”. Solo vas a adivinar.
4) El monitoreo es parte de la gobernanza
Budgets, allowlists y kill switches son inútiles si no puedes ver cuándo se activaron.
Invariantes duras (no negociables)
- Cada run tiene
run_id. - Cada step tiene
step_id. - Cada tool call loggea: nombre del tool, args hash, duración, status, clase de error.
- Cada run termina con un stop event:
stop_reason. - Si no puedes replay (aunque sea parcialmente), no puedes confiar en un fix.
Ejemplo de implementación (código real)
El fallo típico aquí es tener dos formatos de log distintos:
- los eventos de tools son estructurados
- los stop events son “especiales”
Eso mata la posibilidad de join.
Este sample usa un schema unificado para tool calls y stop events.
from __future__ import annotations
from dataclasses import dataclass, asdict
import hashlib
import json
import time
from typing import Any, Literal
EventKind = Literal["tool_result", "stop"]
def sha(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:24]
@dataclass(frozen=True)
class Event:
run_id: str
kind: EventKind
ts_ms: int
# optional fields
step_id: int | None = None
tool: str | None = None
args_sha: str | None = None
duration_ms: int | None = None
status: Literal["ok", "error"] | None = None
error: str | None = None
stop_reason: str | None = None
usage: dict[str, Any] | None = None
def log_event(ev: Event) -> None:
print(json.dumps(asdict(ev), ensure_ascii=False))
def call_tool(run_id: str, step_id: int, tool: str, args: dict[str, Any]) -> Any:
started = time.time()
try:
out = tool_impl(tool, args=args) # (pseudo)
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="ok",
error=None,
)
)
return out
except Exception as e:
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="error",
error=type(e).__name__,
)
)
raise
def stop(run_id: str, *, reason: str, usage: dict[str, Any]) -> dict[str, Any]:
log_event(
Event(
run_id=run_id,
kind="stop",
ts_ms=int(time.time() * 1000),
stop_reason=reason,
usage=usage,
)
)
return {"status": "stopped", "stop_reason": reason, "usage": usage}import crypto from "node:crypto";
export function sha(obj) {
const raw = JSON.stringify(obj, Object.keys(obj || {}).sort());
return crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 24);
}
export function logEvent(ev) {
console.log(JSON.stringify(ev));
}
export async function callTool(runId, stepId, tool, args) {
const started = Date.now();
try {
const out = await toolImpl(tool, { args }); // (pseudo)
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "ok",
error: null,
});
return out;
} catch (e) {
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "error",
error: e?.name || "Error",
});
throw e;
}
}
export function stop(runId, { reason, usage }) {
logEvent({
run_id: runId,
kind: "stop",
ts_ms: Date.now(),
stop_reason: reason,
usage,
});
return { status: "stopped", stop_reason: reason, usage };
}Caso de fallo (concreto)
🚨 Incidente: “todo está lento” (y no sabíamos por qué)
Date: 2024-10-08
Duration: 3 días sin detectar, ~2 horas de debug cuando agregamos visibilidad
System: agente de soporte al cliente
Qué pasó en realidad
El tool http.get empezó a devolver 429/503 intermitentes.
Nuestra capa de tools reintentó hasta 8× por call (antes 2×) sin jitter. El agente interpretó esos fallos como “prueba otra query” y terminó haciendo más tool calls por run.
En 3 días (números ilustrativos, pero este patrón es común):
- avg tool calls/run: 4.3 → 11.7
- latencia p95: 2.1s → 8.4s
- spend/run: ~2×
Nada “crasheó”. El success rate siguió ~91%, así que el drift se veía como “los usuarios están impacientes” hasta que Support escaló.
Root cause (la versión aburrida)
- retries + sin jitter → thundering herd
- sin stop reasons en logs → “success” ocultó el drift
- sin tool-call trace → no pudimos probar dónde se iba el tiempo/spend
Fix
- Event logs estructurados (run_id, step_id, tool, args hash, duración, status)
- Stop reasons expuestos al caller/UI
- Dashboards + alerts sobre señales de drift (tool calls/run, latencia P95, stop reasons)
Dashboards + alerts (ejemplos para robar)
No necesitas observabilidad perfecta. Necesitas observabilidad útil.
PromQL (Grafana)
# Tool calls per run (p95)
histogram_quantile(0.95, sum(rate(agent_tool_calls_bucket[5m])) by (le))
# Stop reasons over time
sum(rate(agent_stop_total[10m])) by (stop_reason)
# Latency p95
histogram_quantile(0.95, sum(rate(agent_run_latency_ms_bucket[5m])) by (le))
SQL (estilo Postgres/BigQuery)
-- Alert: tool_calls/run spike vs baseline
SELECT
date_trunc('hour', created_at) AS hour,
avg(tool_calls) AS avg_tool_calls
FROM agent_runs
WHERE created_at > now() - interval '7 days'
GROUP BY 1
HAVING avg(tool_calls) > 2 * (
SELECT avg(tool_calls)
FROM agent_runs
WHERE created_at BETWEEN now() - interval '14 days' AND now() - interval '7 days'
);
Alert rules (plain English)
- Si
tool_calls_per_run_p95es 2× baseline por 10 minutos → investiga (y considera kill writes). - Si aparece
stop_reason=loop_detectedpor encima de baseline → investiga (tool spam / prompt malo / outage). - Si spikea
stop_reason=tool_timeout→ tienes problemas upstream, no “model weirdness”.
Trade-offs
- Loggear cuesta dinero (storage, indexación). Sigue siendo más barato que incidentes a ciegas.
- Evita loggear PII/secrets crudos. Hashea args y redacta agresivamente.
- Replay requiere política de retención + controles de acceso.
Cuándo NO usarlo
- No construyas una plataforma pesada de tracing antes de tener logs estructurados. Empieza pequeño.
- No loggees args crudos si contienen PII/secrets. Nunca.
- No shippees agentes sin stop reasons. Estás creando retry loops.
Checklist (copiar/pegar)
- [ ]
run_id/step_idpara cada run - [ ] Schema unificado de eventos (tool results + stop events)
- [ ] Tool-call logs: tool, args_hash, duración, status, error class
- [ ] Stop reason devuelto al usuario + loggeado
- [ ] Métricas por run: tokens/tool calls/spend
- [ ] Dashboards: latencia P95, tool_calls/run, distribución de stop_reason
- [ ] Datos para replay: snapshot hashes (con retención + access control)
Config segura por defecto
logging:
events:
enabled: true
schema: "unified"
store_args: false
store_args_hash: true
include: ["run_id", "step_id", "tool", "duration_ms", "status", "error", "stop_reason"]
metrics:
track: ["tokens_per_request", "tool_calls_per_run", "latency_p95", "spend_per_run", "stop_reason"]
retention:
tool_snapshot_days: 14
logs_days: 30
FAQ
Páginas relacionadas
Takeaway de producción
Qué se rompe sin esto
- ❌ No puedes explicar incidentes
- ❌ El drift se ve como “model weirdness”
- ❌ Los overruns de costo aparecen tarde
Qué funciona con esto
- ✅ Puedes join, replay y debuggear runs
- ✅ El drift se vuelve un gráfico, no un debate
- ✅ Kill switches se activan por señales reales
Mínimo para shippear
- Logs estructurados unificados
- Stop reasons
- Métricas + dashboards básicos
- Alerts sobre drift