En resumen: Los “agentes” single-step (una llamada al modelo → ejecutar → listo) no tienen dónde poner validación, no tienen recovery loop y no tienen stop reasons. Fallan porque los sistemas de producción son ruidosos. Si tienes tools o side effects, necesitas un loop acotado + gobernanza.
Aprenderás: Cuándo single-step sí está bien • La regla mínima de routing segura • Una interfaz de loop acotado • Stop reasons • Un smell test de incidente real
Single-step: la validación no tiene dónde vivir • el recovery pasa por “prompts clever” • los writes ocurren demasiado pronto
Looped runner: budgets • tool gateway • stop reasons • safe-mode
Impacto: menos incidentes + fallos debuggeables en vez de “execute & pray”
El problema (en producción)
Alguien dice: “construimos un agente”.
El código es:
- Llamar al modelo una vez
- Parsear un tool call
- Ejecutarlo
- Devolver lo que haya pasado
Eso no es un agente. Es una function call con argumentos impredecibles.
En una demo se siente rápido. En producción falla por la razón por la que construiste agentes en primer lugar: los sistemas reales son ruidosos y necesitas feedback + control.
Por qué esto se rompe en producción
1) Sin feedback loop = sin recovery
Producción está llena de timeouts, respuestas parciales, 429s, datos stale y schema drift. Un diseño single-step no tiene dónde poner recovery logic, así que los equipos empujan “recovery” al prompt y luego lo ejecutan a ciegas.
2) Budgets y stop reasons llegan demasiado tarde
Los equipos dicen: “no puede loopear, así que no necesitamos budgets”.
Luego agregan retries en tools, retries en la llamada al modelo y un segundo tool call “por si acaso”.
Felicidades: reinventaste loops sin gobernanza.
3) El tool output se ignora o se usa mal
Si solo llamas un tool una vez, ¿qué haces con el output? Normalmente lo devuelves. Eso significa sin validación, sin invariants y sin un check de “¿realmente resolvimos la tarea?”.
4) Los writes se vuelven una moneda al aire
En un diseño single-step, el modelo puede proponer un write inmediatamente. No hay policy de “read first, write later”. El blast radius llega temprano.
Cuándo single-step es suficiente (sí, a veces)
Single-step está bien cuando todo esto es cierto:
- No tools (o los tools son estrictamente read-only)
- No side effects (sin cambios de estado)
- El output se usa como texto, no como comando
- Puedes validar el output con un schema estricto (o no lo necesitas)
Framework de decisión: single-step es OK solo si todo es true:
- ✅ Read-only (sin side effects)
- ✅ Output fuertemente tipado (o sin tools)
- ✅ El fallo es barato (bajo blast radius)
- ✅ No necesitas retries / recovery loop
Si alguna es falsa, routea a un looped runner.
Regla de routing dura (la que te salva)
Si el siguiente paso puede causar side effects, el camino single-step no está permitido.
if action.has_side_effects:
run_looped_runner()
else:
run_single_step()
Suena obvio. No lo es cuando la demo funciona y nadie ha recibido un pager todavía.
Ruta de migración (single-step → loop)
Esto es lo que los equipos suelen shippear, y por qué se rompe:
# v1: single-step (fast, unsafe)
result = tool(llm_decide(task)) # damage can happen before validation
# v2: add validation (still unsafe if the tool already ran)
result = tool(llm_decide(task))
if not valid(result):
raise RuntimeError("too late: side effect already happened")
# v3: bounded loop (safe enough to operate)
for step in range(max_steps):
action = llm_decide(state)
if action.kind == "tool":
obs = tool_gateway.call(action.name, action.args) # policy + budgets
state = update(state, obs)
else:
return action.final_answer
Ejemplo de implementación (código real)
Este patrón mantiene single-step donde pertenece (safe, read-only) y routea todo lo demás a un bounded loop runner.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
@dataclass(frozen=True)
class Budgets:
max_steps: int = 25
max_tool_calls: int = 12
max_seconds: int = 60
class Stopped(RuntimeError):
def __init__(self, stop_reason: str):
super().__init__(stop_reason)
self.stop_reason = stop_reason
def is_side_effecting(action: dict[str, Any]) -> bool:
# Production: decide side-effect class in code, not by prompt vibes.
return action.get("kind") in {"write", "payment", "email", "ticket_close"}
def run_single_step(task: str, *, llm) -> dict[str, Any]:
"""
Safe single-step: no tools, no writes.
This is a completion, not an agent.
"""
text = llm.text({"task": task, "style": "direct"}) # (pseudo)
return {"status": "ok", "stop_reason": "single_step", "answer": text}
def run_looped(task: str, *, budgets: Budgets, runner) -> dict[str, Any]:
"""
Delegate to a bounded runner that has:
- tool gateway
- output validation
- stop reasons
"""
return runner.run(task, budgets=budgets) # (pseudo)
def route(task: str, *, llm, budgets: Budgets, runner) -> dict[str, Any]:
# First decision is read-only: are we about to do anything with side effects?
action = llm.json(
{
"task": task,
"rule": "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
"examples": [{"task": "Summarize this text", "kind": "read_only"}, {"task": "Close ticket #123", "kind": "side_effects"}],
}
) # (pseudo)
if action.get("kind") == "side_effects":
return run_looped(task, budgets=budgets, runner=runner)
return run_single_step(task, llm=llm)export class Stopped extends Error {
constructor(stopReason) {
super(stopReason);
this.stop_reason = stopReason;
}
}
export function runSingleStep(task, { llm }) {
// Safe single-step: no tools, no writes.
return llm.text({ task, style: "direct" }).then((text) => ({ status: "ok", stop_reason: "single_step", answer: text })); // (pseudo)
}
export function runLooped(task, { budgets, runner }) {
// Delegate to a bounded runner with tool gateway + stop reasons.
return runner.run(task, { budgets }); // (pseudo)
}
export async function route(task, { llm, budgets, runner }) {
const action = await llm.json({
task,
rule: "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
examples: [
{ task: "Summarize this text", kind: "read_only" },
{ task: "Close ticket #123", kind: "side_effects" },
],
}); // (pseudo)
if (action.kind === "side_effects") return await runLooped(task, { budgets, runner });
return await runSingleStep(task, { llm });
}Esto no se ve “agentic”. Se ve operable. Ese es el punto.
Evidencia del fallo (cómo se ve cuando se rompe)
Los fallos single-step se ven como “una mala decisión con blast radius inmediato”.
Un trace que explica el incidente en 5 líneas:
{"run_id":"run_44a1","step":0,"event":"tool_call","tool":"ticket.close","args_hash":"b5d0aa","decision":"allow"}
{"run_id":"run_44a1","step":0,"event":"tool_result","tool":"ticket.close","ok":true}
{"run_id":"run_44a1","step":0,"event":"stop","reason":"success","note":"single-step"}
Si eso te incomoda, bien.
Caso de fallo (composite)
🚨 Incidente: cierre prematuro de tickets
System: agente single-step para “cerrar tickets resueltos”
Duration: menos de 1 hora
Impact: 18 tickets cerrados incorrectamente
Qué pasó
El agente llamó ticket.close inmediatamente basándose en un snippet. Leyó sarcasmo como “resuelto”.
Lo peor: nadie podía explicar por qué. No había estado de loop, no había stop reasons útiles y no había oportunidad de validar.
Fix
- Routear acciones con side effects a un looped runner
- Policy del tool gateway + audit logs
- Aprobaciones para
ticket.close
Trade-offs
- Un loop es más código que una sola llamada al modelo.
- Más steps pueden significar más latencia (los budgets ayudan).
- Necesitas observabilidad (pero la necesitabas igual).
Cuándo NO usarlo
- Si de verdad tienes una transformación determinista, no lo llames agente.
- Si tu tarea necesita feedback de tools y recovery, single-step será frágil.
- Si no puedes loggear traces y stop reasons, arregla observabilidad primero.
Checklist (copiar/pegar)
- [ ] Si tienes side effects, necesitas un looped runner
- [ ] Routea tareas con side effects fuera de single-step
- [ ] Agrega budgets (steps, tool calls, segundos)
- [ ] Usa un tool gateway (allowlist default-deny)
- [ ] Valida tool outputs antes de actuar
- [ ] Devuelve stop reasons (y loggea)
- [ ] Requiere aprobaciones para writes
Config segura por defecto
routing:
allow_single_step_only_when: "read_only"
budgets:
max_steps: 25
max_tool_calls: 12
max_seconds: 60
tools:
allow: ["search.read", "kb.read", "http.get"]
writes:
require_approval: true
stop_reasons:
return_to_user: true
FAQ
Páginas relacionadas
Takeaway de producción
Qué se rompe sin esto
- ❌ Los writes ocurren antes de validar
- ❌ El “recovery” vive en prompts y retries de tools
- ❌ No hay stop reasons que expliquen el comportamiento
Qué funciona con esto
- ✅ Side effects se routean a un runner acotado
- ✅ Budgets + tool gateway hacen el run controlable
- ✅ Los fallos se pueden explicar (stop reasons + traces)
Mínimo para shippear
- Regla de routing (read-only puede ser single-step; side effects no)
- Bounded runner (budgets + stop reasons)
- Tool gateway (deny by default)
- Capa de validación (antes de writes)