Problema (lo primero que se rompe)
En dev, tu agente “funciona”.
Luego cambias algo aburrido:
- una clave del schema del tool
- defaults de retry/backoff
- la condición de parada
- la versión del modelo
Y de repente producción se ve así:
- 3× más tool calls
- 2× más coste de un día a otro
- runs que nunca llegan a
finishy acaban en “stop: budget”
Si no puedes reproducir un run de forma determinista, no tienes un bug — tienes arqueología.
Por qué esto falla en producción
Los agentes fallan distinto al software normal, porque están dirigidos por:
- un planner probabilista (el modelo)
- un loop runtime (tu orquestación)
- side effects (tools)
- inestabilidad externa (429/5xx, respuestas parciales, timeouts)
Muchos equipos “testean” el prompt. No alcanza. Hay que testear el contrato del loop:
- input → actions → tool calls → trace → stop_reason
Si stop reasons y traces no son estables, nada más lo es.
Diagrama: cómo se ve un agente unit‑testeable
Código real: un loop testeable (Python + JS)
El truco es aburrido: inyección de dependencias. Tu loop acepta dos cosas que no controla:
llm.next_action(...)tools.call(...)
Todo lo demás debería ser determinista y comprobable.
from dataclasses import dataclass
from typing import Any, Dict, List, Protocol
@dataclass(frozen=True)
class Budget:
max_steps: int = 10
max_tool_calls: int = 10
class LLM(Protocol):
def next_action(self, state: Dict[str, Any]) -> Dict[str, Any]: ...
class Tools(Protocol):
def call(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: ...
def run_agent(task: str, *, llm: LLM, tools: Tools, budget: Budget) -> Dict[str, Any]:
trace: List[Dict[str, Any]] = []
tool_calls = 0
state: Dict[str, Any] = {"task": task, "notes": []}
for step in range(budget.max_steps):
action = llm.next_action(state)
trace.append({"step": step, "action": action})
if action.get("type") == "finish":
return {"output": action.get("answer", ""), "trace": trace, "stop_reason": "finish"}
if action.get("type") != "tool":
return {"output": "", "trace": trace, "stop_reason": "invalid_action"}
tool_calls += 1
if tool_calls > budget.max_tool_calls:
return {"output": "", "trace": trace, "stop_reason": "max_tool_calls"}
obs = tools.call(action["tool"], action.get("args", {}))
trace.append({"step": step, "observation": obs, "tool": action["tool"]})
state["notes"].append(obs)
return {"output": "", "trace": trace, "stop_reason": "max_steps"}
# --- unit test (pytest style) ---
class FakeLLM:
def __init__(self):
self.n = 0
def next_action(self, state):
self.n += 1
if self.n == 1:
return {"type": "tool", "tool": "http.get", "args": {"url": "https://example.com"}}
return {"type": "finish", "answer": "ok"}
class FakeTools:
def __init__(self):
self.calls = []
def call(self, name, args):
self.calls.append((name, args))
return {"ok": True, "status": 200, "body": "hello"}
def test_unit_loop_contract():
out = run_agent(
"fetch once and finish",
llm=FakeLLM(),
tools=FakeTools(),
budget=Budget(max_steps=5, max_tool_calls=3),
)
assert out["stop_reason"] == "finish"
assert len(out["trace"]) >= 2export function runAgent(task, { llm, tools, budget }) {
const trace = [];
let toolCalls = 0;
const state = { task, notes: [] };
for (let step = 0; step < budget.maxSteps; step++) {
const action = llm.nextAction(state);
trace.push({ step, action });
if (action?.type === "finish") {
return { output: action.answer ?? "", trace, stop_reason: "finish" };
}
if (action?.type !== "tool") {
return { output: "", trace, stop_reason: "invalid_action" };
}
toolCalls += 1;
if (toolCalls > budget.maxToolCalls) {
return { output: "", trace, stop_reason: "max_tool_calls" };
}
const obs = tools.call(action.tool, action.args || {});
trace.push({ step, tool: action.tool, observation: obs });
state.notes.push(obs);
}
return { output: "", trace, stop_reason: "max_steps" };
}
// --- unit test (jest style) ---
test("unit loop contract", () => {
const llm = {
n: 0,
nextAction() {
this.n += 1;
if (this.n === 1) return { type: "tool", tool: "http.get", args: { url: "https://example.com" } };
return { type: "finish", answer: "ok" };
},
};
const tools = { calls: [], call(name, args) { this.calls.push([name, args]); return { ok: true, status: 200 }; } };
const out = runAgent("fetch once and finish", {
llm,
tools,
budget: { maxSteps: 5, maxToolCalls: 3 },
});
expect(out.stop_reason).toBe("finish");
expect(tools.calls.length).toBe(1);
});Fallo real (del que duele)
Una vez cambiamos un default de retries en un wrapper de tool compartido. Nada “se cayó”.
Pero en una ruta con tráfico:
- tool calls/run: 8 → 16
- impacto en coste: +~$900/día (tokens + créditos de tools)
- on‑call: ~3 horas para demostrar que no era “el modelo raro”
El fix no fue un prompt mejor. El fix fue un unit test que verifica:
- un bound sobre tool calls/run
- que la taxonomía de stop reasons no drifte
- que el gateway de tools no reintenta dos veces (agent retry + tool retry = tormenta)
Compromisos
- Los unit tests no prueban que el modelo sea “smart”. Prueban que tu loop es seguro.
- Los stubs deterministas esconden flakiness real (para eso están los replay tests).
- Vas a escribir más código aburrido. Vas a recibir menos pagers.
Cuándo NO usar esto
No intentes unit testear “calidad de prompt” como si fuera una función determinista. Si el objetivo es estilo/tono: sampling + evals.
Sí unit‑testea:
- budgets
- allowlists de tools
- stop reasons
- validación de schema de action
- comportamiento de idempotencia
Checklist (copiar/pegar)
- [ ] Inyecta
llmytoolscomo interfaces (sin globals). - [ ] Haz asserts sobre
stop_reasonen cada test. - [ ] Haz asserts sobre tool calls: cantidad + orden + hash de args (no args crudos si son sensibles).
- [ ] Testea “bad paths”: acción inválida, error de tool, stop por budget.
- [ ] Un golden test por incidente de producción (sí).
Config segura por defecto (YAML)
agent_tests:
budgets:
max_steps: 25
max_tool_calls: 12
invariants:
stop_reason_required: true
action_schema_strict: true
tool_allowlist_required: true
golden_tasks:
- id: "fetch_once"
task: "Fetch https://example.com and summarize in 3 bullets."
expect_stop_reason: "finish"
max_tool_calls: 2
replay:
enabled: true
mode: "record_then_replay"
store: ".agent-replays/"
FAQ (3–5)
Usado por patrones
Fallos relacionados
- AI Agent Infinite Loop (Detectar + arreglar, con código)
- Explosión de presupuesto (cuando un agente quema dinero) + fixes + código
- Tool Spam Loops (fallo del agente + fixes + código)
- Incidentes de exceso de tokens (prompt bloat) + fixes + código
- Corrupción de respuestas de tools (schema drift + truncation) + código
Gobernanza requerida
Q: ¿Esto no es solo mockear el modelo?
A: Sí — a propósito. Los unit tests son para tu contrato de loop: budgets, gateway de tools, stop reasons y forma de la traza.
Q: ¿Qué debería comprobar?
A: Stop reason, número de tool calls, decisiones de allowlist y forma de la traza. No el texto exacto de salida.
Q: ¿Cómo testeo flakiness de tools?
A: Record/replay de fixtures (o integration tests en sandbox). Los unit tests deben ser deterministas.
Q: ¿Necesito evals si ya tengo unit tests?
A: Sí. Los unit tests previenen incidentes. Los evals detectan drift de calidad. Son fallos distintos.
Páginas relacionadas (3–6 links)
- Fundamentos: Tool calling de un agente de IA (con código) · Qué hace a un agente apto para producción (guardrails + código)
- Fallos: Tool Spam Loops (fallo del agente + fixes + código) · Explosión de presupuesto (cuando un agente quema dinero) + fixes + código
- Gobernanza: Budget Controls para agentes IA (pasos, tiempo, $) + Código
- Stack de producción: Stack de producción para agentes de IA (lo que hay entre tu agente y el desastre)