Agentes single-step (anti‑patrón) + fixes + código

  • Reconoce la trampa antes de enviarla a prod.
  • Ve qué se rompe cuando el modelo se equivoca seguro.
  • Copia defaults seguros: permisos, budgets, idempotency.
  • Sabe cuándo no usar un agente.
Señales de detección
  • Tool calls por run suben (o repiten mismo args hash).
  • Gasto/tokens suben sin mejorar el resultado.
  • Retries pasan de raros a constantes (429/5xx).
Un “agente single-step” suele ser una completion pegada a side effects. Por qué se rompe en producción y cómo se ve un loop mínimo operable.
En esta página
  1. El problema (en producción)
  2. Por qué esto se rompe en producción
  3. 1) Sin feedback loop = sin recovery
  4. 2) Budgets y stop reasons llegan demasiado tarde
  5. 3) El tool output se ignora o se usa mal
  6. 4) Los writes se vuelven una moneda al aire
  7. Cuándo single-step es suficiente (sí, a veces)
  8. Regla de routing dura (la que te salva)
  9. Ruta de migración (single-step → loop)
  10. Ejemplo de implementación (código real)
  11. Evidencia del fallo (cómo se ve cuando se rompe)
  12. Caso de fallo (composite)
  13. 🚨 Incidente: cierre prematuro de tickets
  14. Trade-offs
  15. Cuándo NO usarlo
  16. Checklist (copiar/pegar)
  17. Config segura por defecto
  18. FAQ
  19. Páginas relacionadas
  20. Takeaway de producción
  21. Qué se rompe sin esto
  22. Qué funciona con esto
  23. Mínimo para shippear
En resumen

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

Concrete metric

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:

  1. Llamar al modelo una vez
  2. Parsear un tool call
  3. Ejecutarlo
  4. Devolver lo que haya pasado
Truth

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

Failure analysis

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”.

Truth

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.

TEXT
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:

PYTHON
# 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.

PYTHON
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)
JAVASCRIPT
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 });
}
Note

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:

JSON
{"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)

Incident

🚨 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

  1. Routear acciones con side effects a un looped runner
  2. Policy del tool gateway + audit logs
  3. Aprobaciones para ticket.close

Trade-offs

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

Don’t
  • 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)

Production checklist
  • [ ] 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

YAML
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

FAQ
¿Los agentes single-step alguna vez están bien?
Sí — cuando no hay tools y no hay side effects. En ese punto es una completion, no un agente.
¿Un loop no es más lento?
Puede ser. Aproximadamente: single-step es ~1 call LLM + 1 call tool; un loop de 3 steps es ~3 calls LLM + 2 calls tool. Eso suele ser ~3× latencia. Los budgets capean el worst case — y la velocidad no importa si está mal o es inoperable.
¿Cuál es la gobernanza mínima para un loop?
Step limits, budgets de tool calls, policy default-deny y stop reasons.
¿Dónde saco un buen patrón de loop?
Empieza con un runner estilo ReAct acotado y un tool gateway. No inventes tu loop sin budgets y traces.

Páginas relacionadas

Related

Takeaway de producción

Production takeaway

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

  1. Regla de routing (read-only puede ser single-step; side effects no)
  2. Bounded runner (budgets + stop reasons)
  3. Tool gateway (deny by default)
  4. Capa de validación (antes de writes)

No sabes si este es tu caso?

Disena tu agente ->
⏱️ 9 min de lecturaActualizado Mar, 2026Dificultad: ★★★
Implementar en OnceOnly
Safe defaults for tool permissions + write gating.
Usar en OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Permisos de herramientas (allowlist / blocklist)
  • Kill switch y parada por incidente
  • Idempotencia y dedupe
  • Audit logs y trazabilidad
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.