Agregué una Caja Negra de 71 Líneas a Mi Agente Python y Consulté la Falla de $200 con DuckDB
El incidente comenzó con una aburrida tarea de automatización de soporte.
Tomar una solicitud de usuario, buscar en un índice privado de documentos, resumir la respuesta y entregar el resultado a un revisor. Nada heroico. El tipo de agente Python que construyes cuando la demo terminó y el flujo de trabajo real comienza.
Entonces una ejecución se quedó atrapada en un bucle de reintentos.
No quemó $200 antes de que lo detectara. La ejecución de prueba real fue más barata. El problema era la proyección: el mismo bucle defectuoso, la misma búsqueda de documentos, las mismas llamadas al modelo, ejecutándose dentro del lote nocturno. La estimación se acercó a $200 por una falla completamente evitable.
La respuesta que produjo parecía lo suficientemente pulida como para pasar una revisión somnolienta. La traza detrás de ella no era pulida en absoluto. El agente había llamado a la herramienta correcta con el input equivocado, reintentado contra contexto obsoleto, resumido resultados viejos y seguido pagando por cada turno.
Fue entonces cuando dejé de tratar al agente como una funcionalidad de chat.
Empecé a tratarlo como un sistema que necesita una caja negra.
No un dashboard. No un stack completo de observabilidad. No otro servicio hosteado.
Simplemente un archivo local que pueda responder:
- ¿Qué intentó hacer el agente?
- ¿Qué herramienta llamó?
- ¿Qué input recibió la herramienta?
- ¿Falló la herramienta?
- ¿Cuánto tardó?
- ¿La ejecución cruzó un límite de costo o de turnos?
- ¿Puedo consultar la ejecución después de que todo terminó?
Vamos a construir esa caja negra en Python puro, y luego usaremos DuckDB para inspeccionarla como una pequeña base de datos de crash.
Antes y Después
Antes de la solución, depurar se veía así:
La respuesta final está mal.
El modelo probablemente alucinó.
Quizás la herramienta de búsqueda devolvió datos incorrectos.
Quizás el bucle de reintentos reutilizó un mensaje viejo.
Quizás el pico de costo vino de la llamada al modelo.
Eso no es depurar. Eso es adivinar con resaltado de sintaxis.
Después de la solución, depurar se veía así:
El turno 1 llamó a search_docs con la query incorrecta.
La herramienta expiró después de 147.82 ms.
El reintento usó contexto obsoleto.
El guardia detuvo la ejecución en $0.0124.
DuckDB muestra un tool_error y un guard_stop.
Mismo bug. Día muy diferente.
La Forma del Problema
Un script Python normal generalmente falla en un solo lugar.
Un agente falla a lo largo de una cadena.
Solicitud del Usuario -> Decisión del Modelo -> Llamada a Herramienta -> Resultado de Herramienta -> Siguiente Turno -> Respuesta Final
Si solo registras la respuesta final, tienes una entrada de diario.
Si registras la cadena, tienes evidencia.
El formato útil más simple es JSONL. Un evento por línea.
{"type":"tool_start","tool":"search_docs","input":{"query":"límites de tasa"}}
{"type":"tool_end","tool":"search_docs","duration_ms":83.4,"ok":true}
{"type":"turn_end","turn":2,"total_cost_usd":0.0041}
JSONL es aburrido exactamente de la manera correcta. Agrega líneas limpiamente, sobrevive crashes mejor que un gran documento JSON y puede consultarse con herramientas normales.
Un Pequeño Recorder Que Hace Trabajo Real
Aquí está el recorder.
Hace cuatro cosas:
- asigna a cada ejecución un id único
- escribe eventos JSONL en modo append-only
- mide la duración de las herramientas
- sanitiza secretos obvios antes de escribir cualquier cosa a disco
from __future__ import annotations
import json
import re
import time
import traceback
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4
SECRET_KEYS = re.compile(
r"(api[_-]?key|token|password|secret|authorization|cookie)",
re.IGNORECASE,
)
@dataclass
class Event:
run_id: str
event_id: str
type: str
timestamp: float
data: dict[str, Any] = field(default_factory=dict)
def sanitize(value: Any) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if SECRET_KEYS.search(str(key)):
cleaned[key] = "[redacted]"
else:
cleaned[key] = sanitize(item)
return cleaned
if isinstance(value, list):
return [sanitize(item) for item in value]
return value
class AgentBlackBox:
def __init__(self, path: str | Path, run_id: str | None = None) -> None:
self.path = Path(path)
self.run_id = run_id or uuid4().hex
self.path.parent.mkdir(parents=True, exist_ok=True)
def record(self, event_type: str, **data: Any) -> None:
event = Event(
run_id=self.run_id,
event_id=uuid4().hex,
type=event_type,
timestamp=time.time(),
data=sanitize(data),
)
with self.path.open("a", encoding="utf-8") as file:
file.write(json.dumps(asdict(event), default=str) + "\n")
@contextmanager
def tool(self, name: str, **tool_input: Any) -> Iterator[None]:
started = time.perf_counter()
self.record("tool_start", tool=name, input=tool_input)
try:
yield
except Exception as exc:
self.record(
"tool_error",
tool=name,
error_type=type(exc).__name__,
error=str(exc),
traceback=traceback.format_exc(limit=6),
duration_ms=round((time.perf_counter() - started) * 1000, 2),
)
raise
else:
self.record(
"tool_end",
tool=name,
ok=True,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
)
La función sanitize() no es perfecta. Es un cinturón de seguridad, no una bóveda.
Aun así, previene la versión más embarazosa de este patrón: construir una traza de depuración útil que silenciosamente almacena API keys.
Envuelve Una Herramienta Primero
Comienza con una herramienta. No instrumentes todo el primer día.
import random
import time
def search_docs(query: str, api_key: str) -> list[str]:
time.sleep(random.uniform(0.05, 0.2))
if "timeout" in query:
raise TimeoutError("Document search timed out")
return [
"JSONL funciona bien para trazas append-only.",
"Los context managers son útiles alrededor de llamadas a herramientas.",
"DuckDB puede consultar archivos JSON sin un servidor.",
]
Ahora registra la llamada:
box = AgentBlackBox("traces/run.jsonl")
query = "python agent trace format"
with box.tool("search_docs", query=query, api_key="***"):
docs = search_docs(query=query, api_key="***")
box.record("tool_result", tool="search_docs", result_count=len(docs))
Abre traces/run.jsonl y la key está redactada.
{"tool":"search_docs","input":{"query":"python agent trace format","api_key":"[redacted]"}}
Ese pequeño detalle importa. Depurar no debería crear un segundo incidente.
Agrega un Guardia de Ejecución Barato
La mayoría de las historias de agentes descontrolados comienzan con un bucle que parecía inofensivo.
Así que la caja negra no solo debería registrar lo que sucedió. Debería registrar cuándo se negó a continuar.
class RunStopped(RuntimeError):
pass
def stop_if_needed(
box: AgentBlackBox,
*,
turn: int,
max_turns: int,
spent_usd: float,
max_usd: float,
) -> None:
box.record(
"guard_check",
turn=turn,
max_turns=max_turns,
spent_usd=round(spent_usd, 6),
max_usd=round(max_usd, 6),
)
if turn > max_turns:
box.record("guard_stop", reason="max_turns", turn=turn)
raise RunStopped(f"Stopped at turn {turn}. Max turns is {max_turns}.")
if spent_usd > max_usd:
box.record("guard_stop", reason="budget", spent_usd=spent_usd)
raise RunStopped(f"Stopped at ${spent_usd:.4f}. Budget is ${max_usd:.4f}.")
Esto no es facturación exacta. Usa la respuesta de tu proveedor para conteos reales de tokens cuando los tengas.
El objetivo aquí es un cable trampa local. Quieres que la ejecución deje una razón clara cuando se detiene.
Un Pequeño Bucle de Agente
Este bucle simulado mantiene las partes móviles pequeñas.
Reemplaza la sección del modelo simulado con tu llamada real al modelo.
def estimate_cost(input_tokens: int, output_tokens: int) -> float:
return input_tokens * 0.0000005 + output_tokens * 0.0000015
def run_agent(question: str) -> str:
box = AgentBlackBox("traces/run.jsonl")
messages = [{"role": "user", "content": question}]
spent_usd = 0.0
max_turns = 3
max_usd = 0.01
box.record("run_start", question=question, max_turns=max_turns, max_usd=max_usd)
for turn in range(1, max_turns + 1):
stop_if_needed(
box,
turn=turn,
max_turns=max_turns,
spent_usd=spent_usd,
max_usd=max_usd,
)
box.record("turn_start", turn=turn, message_count=len(messages))
# Simula que el modelo eligió este input de herramienta.
query = question if turn == 1 else "python jsonl duckdb traces"
with box.tool("search_docs", query=query, api_key="***"):
docs = search_docs(query=query, api_key="***")
messages.append({"role": "tool", "content": "\n".join(docs)})
turn_cost = estimate_cost(
input_tokens=sum(len(message["content"].split()) for message in messages),
output_tokens=120,
)
spent_usd += turn_cost
box.record(
"turn_end",
turn=turn,
message_count=len(messages),
turn_cost_usd=round(turn_cost, 6),
total_cost_usd=round(spent_usd, 6),
)
answer = "Record every tool call as JSONL, then query failures after the run."
box.record("run_end", answer=answer, total_cost_usd=round(spent_usd, 6))
return answer
Ejecútalo una vez con una pregunta normal.
print(run_agent("How should I debug Python agent tools?"))
Luego ejecútalo con una mala.
print(run_agent("timeout during document search"))
La segunda ejecución debería fallar, pero ahora falla con un rastro.
Para forzar una parada por presupuesto durante las pruebas, establece temporalmente max_usd = 0.0001. La siguiente verificación del guardia escribirá un evento guard_stop en lugar de dejar que el bucle continúe silenciosamente.
Consulta el Crash con DuckDB
Esta es la parte que hace que JSONL se sienta menos como logging y más como una herramienta de debugging.
Instala DuckDB:
pip install duckdb
Luego consulta la traza:
import duckdb
def query_trace(path: str = "traces/run.jsonl") -> None:
con = duckdb.connect()
con.sql(
f"""
create or replace view events as
select *
from read_json_auto('{path}');
"""
)
print("Conteo de eventos")
con.sql(
"""
select type, count(*) as events
from events
group by type
order by events desc;
"""
).show()
print("Errores de herramientas")
con.sql(
"""
select
data.tool as tool,
data.error_type as error_type,
data.error as error,
data.duration_ms as duration_ms
from events
where type = 'tool_error';
"""
).show()
print("Herramientas lentas")
con.sql(
"""
select
data.tool as tool,
data.duration_ms as duration_ms
from events
where type = 'tool_end'
order by data.duration_ms desc
limit 5;
"""
).show()
Ahora ejecuta:
query_trace()
El resultado debería verse así:
Conteo de eventos
+-------------+--------+
| type | events |
+-------------+--------+
| guard_check | 4 |
| turn_start | 3 |
| tool_start | 3 |
| tool_end | 2 |
| tool_error | 1 |
| guard_stop | 1 |
+-------------+--------+
Y la fila del crash ahora es un resultado de query, no un misterio:
Errores de herramientas
+-------------+--------------+---------------------------+-------------+
| tool | error_type | error | duration_ms |
+-------------+--------------+---------------------------+-------------+
| search_docs | TimeoutError | Document search timed out | 147.82 |
+-------------+--------------+---------------------------+-------------+
Puedes responder preguntas que los logs de print normales hacen molestas:
- ¿Qué herramientas fallaron con más frecuencia?
- ¿Qué herramienta fue la más lenta?
- ¿Qué turno cruzó el presupuesto?
- ¿El mismo input falló repetidamente?
- ¿El guardia detuvo la ejecución, o la herramienta crasheó primero?
Esa es la mejora.
No “tengo logs”.
“Puedo interrogar la ejecución.”
Qué Registraría en un Proyecto Real
Para una demo, la traza anterior es suficiente.
Para un proyecto real, agregaría estos campos:
modelproviderprompt_hashtool_schema_versioninput_tokensoutput_tokensfinish_reasonretry_countuser_id_hashenvironment
No registraría esto por defecto:
- tokens de acceso sin procesar
- documentos privados
- prompts completos de clientes
- respuestas completas de herramientas con datos sensibles
- cookies o headers de solicitud
La regla de seguridad aburrida es simple:
Registra suficiente para depurar el comportamiento. No registres suficiente para dañar a alguien.
El Patrón en Una Sola Frase
Cada ejecución de agente debería producir un flujo de eventos local, append-only, que sea seguro de conservar, fácil de consultar y útil después de que el proceso falle.
Esa frase es menos emocionante que un nuevo truco de prompts.
Pero es más probable que te salve el fin de semana.
Archivo Completo
Aquí está el ejemplo completo en un solo lugar.
from __future__ import annotations
import json
import random
import re
import time
import traceback
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4
SECRET_KEYS = re.compile(
r"(api[_-]?key|token|password|secret|authorization|cookie)",
re.IGNORECASE,
)
@dataclass
class Event:
run_id: str
event_id: str
type: str
timestamp: float
data: dict[str, Any] = field(default_factory=dict)
def sanitize(value: Any) -> Any:
if isinstance(value, dict):
return {
key: "[redacted]" if SECRET_KEYS.search(str(key)) else sanitize(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize(item) for item in value]
return value
class AgentBlackBox:
def __init__(self, path: str | Path, run_id: str | None = None) -> None:
self.path = Path(path)
self.run_id = run_id or uuid4().hex
self.path.parent.mkdir(parents=True, exist_ok=True)
def record(self, event_type: str, **data: Any) -> None:
event = Event(
run_id=self.run_id,
event_id=uuid4().hex,
type=event_type,
timestamp=time.time(),
data=sanitize(data),
)
with self.path.open("a", encoding="utf-8") as file:
file.write(json.dumps(asdict(event), default=str) + "\n")
@contextmanager
def tool(self, name: str, **tool_input: Any) -> Iterator[None]:
started = time.perf_counter()
self.record("tool_start", tool=name, input=tool_input)
try:
yield
except Exception as exc:
self.record(
"tool_error",
tool=name,
error_type=type(exc).__name__,
error=str(exc),
traceback=traceback.format_exc(limit=6),
duration_ms=round((time.perf_counter() - started) * 1000, 2),
)
raise
else:
self.record(
"tool_end",
tool=name,
ok=True,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
)
class RunStopped(RuntimeError):
pass
def stop_if_needed(
box: AgentBlackBox,
*,
turn: int,
max_turns: int,
spent_usd: float,
max_usd: float,
) -> None:
box.record(
"guard_check",
turn=turn,
max_turns=max_turns,
spent_usd=round(spent_usd, 6),
max_usd=round(max_usd, 6),
)
if turn > max_turns:
box.record("guard_stop", reason="max_turns", turn=turn)
raise RunStopped(f"Stopped at turn {turn}. Max turns is {max_turns}.")
if spent_usd > max_usd:
box.record("guard_stop", reason="budget", spent_usd=spent_usd)
raise RunStopped(f"Stopped at ${spent_usd:.4f}. Budget is ${max_usd:.4f}.")
def search_docs(query: str, api_key: str) -> list[str]:
time.sleep(random.uniform(0.05, 0.2))
if "timeout" in query:
raise TimeoutError("Document search timed out")
return [
"JSONL works well for append-only traces.",
"Context managers are useful around tool calls.",
"DuckDB can query JSON files without a server.",
]
def estimate_cost(input_tokens: int, output_tokens: int) -> float:
return input_tokens * 0.0000005 + output_tokens * 0.0000015
def run_agent(question: str) -> str:
box = AgentBlackBox("traces/run.jsonl")
messages = [{"role": "user", "content": question}]
spent_usd = 0.0
max_turns = 3
max_usd = 0.01
box.record("run_start", question=question, max_turns=max_turns, max_usd=max_usd)
for turn in range(1, max_turns + 1):
stop_if_needed(
box,
turn=turn,
max_turns=max_turns,
spent_usd=spent_usd,
max_usd=max_usd,
)
box.record("turn_start", turn=turn, message_count=len(messages))
query = question if turn == 1 else "python jsonl duckdb traces"
with box.tool("search_docs", query=query, api_key="***"):
docs = search_docs(query=query, api_key="***")
messages.append({"role": "tool", "content": "\n".join(docs)})
turn_cost = estimate_cost(
input_tokens=sum(len(message["content"].split()) for message in messages),
output_tokens=120,
)
spent_usd += turn_cost
box.record(
"turn_end",
turn=turn,
message_count=len(messages),
turn_cost_usd=round(turn_cost, 6),
total_cost_usd=round(spent_usd, 6),
)
answer = "Record every tool call as JSONL, then query failures after the run."
box.record("run_end", answer=answer, total_cost_usd=round(spent_usd, 6))
return answer
if __name__ == "__main__":
print(run_agent("How should I debug Python agent tools?"))
Hay una línea en ese archivo completo que vale la pena observar:
box.record("run_start", question=question, max_turns=max_turns, max_usd=max_usd)
Esa línea cambia la postura del programa.
La ejecución ya no es una conversación privada con un modelo. Es una ejecución registrada con una traza que puedes inspeccionar, consultar y mejorar.
Esa es la diferencia entre una demo y algo en lo que puedes confiar.
¿Qué agregarías después: hashes de prompts, conteos de tokens, screenshots, checkpoints o fixtures de herramientas reproducibles?