Crea tu primer agente de IA (seguro, con código)

Empieza pequeño: un tool, budgets duros y un kill switch. El camino más rápido a un agente que no te haga quedar mal en producción.
En esta página
  1. El problema
  2. Por qué importa en sistemas reales
  3. El playbook (20 minutos, sin heroicidades)
  4. Qué significa de verdad “un solo tool”
  5. Paso 0: elige una tarea que no te arruine
  6. Paso 1: budgets (lo que convierte un loop en sistema)
  7. Paso 2: loggea el action trace
  8. Paso 2.5: haz los tools deterministas (o no podrás depurar)
  9. Código (mínimo, pero con forma de producción)
  10. Hazlo real (un skeleton pequeño y ejecutable)
  11. Paso 3: una “tool policy” pequeña (incluso en read-only)
  12. Tool contracts (make the model play inside the lines)
  13. Paso 4: un rollout que no duele
  14. Paso 4.5: cancelación (deja de gastar cuando el usuario se va)
  15. Paso 5: métricas de éxito (para saber que mejora)
  16. Dónde ejecutarlo (serverless vs workers)
  17. Hardening v2 (sigue siendo pequeño, sigue siendo seguro)
  18. Hash de args + dedupe
  19. Add per-tool budgets
  20. Add a kill switch
  21. Add a “stop reason”
  22. El primer write tool (cómo no liarla)
  23. Errores típicos del primer agente (los hemos cometido)
  24. Fallo real
  25. Cuándo NO construir un agente (todavía)
  26. Siguiente

El problema

Tu primer agente va a intentar hacerlo todo.

Va a navegar por la web, escribir en bases de datos, “optimizar” configs y, en general, comportarse como un becario con acceso root.

No empieces ahí.

Empieza con un tool, read-only, con budgets y logs.

Por qué importa en sistemas reales

La primera vez que shipeas tool calling vas a descubrir:

  • timeouts que faltan
  • que no tenías idempotencia
  • que tus logs no sirven

Mejor descubrirlo con un tool que con doce.

El playbook (20 minutos, sin heroicidades)

  1. Elige un único tool read-only (web.search o kb.search).
  2. Añade budgets: steps + time (+ $ si puedes).
  3. Loggea cada tool call (nombre, hash de args, duración, estado).
  4. Detecta loops por args repetidos.
  5. Todavía no añadas tools de “send” o “write”.

Qué significa de verdad “un solo tool”

“Un solo tool” no es “un tool más una salida de emergencia escondida”.

Significa:

  • puedes señalar la allowlist y contarla con una mano
  • no hay un tool genérico http.request con acceso total a internet
  • no hay run_shell “para depurar”

Si tu primer agente puede hacer fetch de URLs arbitrarias, ya te saltaste la fase segura.

Paso 0: elige una tarea que no te arruine

Buenas primeras tareas:

  • “encuentra 5 docs relevantes en nuestra KB”
  • “resume incidentes recientes del directorio interno de postmortems”
  • “redacta una respuesta usando plantillas conocidas”

Malas primeras tareas:

  • “navega por la web hasta estar seguro”
  • “arregla la config de prod”
  • “ejecuta comandos en servidores”

Tu primer agente debería ser aburrido y barato.

Paso 1: budgets (lo que convierte un loop en sistema)

Los budgets no son un “nice to have”. Son la diferencia entre:

  • una request que se detiene
  • una request que sigue cobrando

Usamos como mínimo:

  • max steps
  • max seconds

Si puedes estimar el gasto en $, añádelo pronto. Se paga solo.

Paso 2: loggea el action trace

No necesitas un sistema de tracing perfecto el día 1. Necesitas lo suficiente para responder:

  • ¿qué tools llamó?
  • ¿con qué argumentos?
  • ¿cuánto duró cada call?
  • ¿por qué se detuvo?

Si no puedes responder eso, no puedes shippear esto.

Paso 2.5: haz los tools deterministas (o no podrás depurar)

Tu agente es un loop. Los loops ya son bastante duros. Ahora añade tools que devuelven resultados distintos en cada run (timeouts, APIs flaky, páginas que cambian). Enhorabuena: has construido un sistema no determinista y vas a depurarlo a las 03:00.

El truco que usamos pronto: record/replay. Para un run, registra:

  • nombre del tool
  • hash de args
  • respuesta (o error)

Luego puedes reproducir el mismo “mundo” para probar:

  • cambios de prompt
  • cambios de modelo
  • ajustes del loop guard

Código conceptual mínimo:

PYTHON
class TapeTools(SafeTools):
  def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
      super().__init__(allow=allow, impl=impl)
      self.tape = tape if tape is not None else []

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      try:
          out = super().call(name, args=args, request_id=request_id)
          self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
          return out
      except Exception as e:
          self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
          raise


def replay(tape: list[dict]):
  # Replace real tools with deterministic playback.
  i = 0
  def impl(name: str, **args):
      nonlocal i
      item = tape[i]
      i += 1
      assert item["tool"] == name
      if not item["ok"]:
          raise RuntimeError(item["err"])
      return item["out"]
  return impl
JAVASCRIPT
export class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
  super({ allow, impl });
  this.tape = tape || [];
}

async call(name, { args, requestId }) {
  try {
    const out = await super.call(name, { args, requestId });
    this.tape.push({ tool: name, args, ok: true, out });
    return out;
  } catch (e) {
    this.tape.push({
      tool: name,
      args,
      ok: false,
      err: String(e && e.message ? e.message : e),
    });
    throw e;
  }
}
}

export function replay(tape) {
let i = 0;
return async function call(name, args) {
  const item = tape[i];
  i += 1;
  if (!item || item.tool !== name) throw new Error("tape mismatch");
  if (!item.ok) throw new Error(item.err);
  return item.out;
};
}

¿Es glamuroso? No. ¿Te permite correr una “golden suite” de 20 tareas antes de shippear? Sí. Así evitas enterarte de regresiones por clientes reales.

Código (mínimo, pero con forma de producción)

PYTHON
from dataclasses import dataclass
import time
from typing import Any


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


class Tools:
  def __init__(self):
      self.allow = {"web.search"}

  def call(self, name: str, *, args: dict[str, Any]) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"tool not allowed: {name}")
      return tool_impl(name, args=args)  # (pseudo)


def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
  started = time.time()
  last = None

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return "stopped: time budget exceeded"

      action = llm_pick_action(question)  # (pseudo): either {"tool":..., "args":...} or {"finish":...}

      if action.get("finish"):
          return action["finish"]

      key = f"{action['tool']}:{action['args']}"
      if key == last:
          return "stopped: loop guard (same call twice)"
      last = key

      obs = tools.call(action["tool"], args=action["args"])
      question = update_state(question, action, obs)  # (pseudo)

  return "stopped: step budget exceeded"
JAVASCRIPT
export class Tools {
constructor() {
  this.allow = new Set(["web.search"]);
}

async call(name, { args }) {
  if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
  return toolImpl(name, { args }); // (pseudo)
}
}

export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) {
    return "stopped: time budget exceeded";
  }

  const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
  if (action.finish) return action.finish;

  const key = String(action.tool) + ":" + JSON.stringify(action.args);
  if (key === last) return "stopped: loop guard (same call twice)";
  last = key;

  const obs = await tools.call(action.tool, { args: action.args });
  question = updateState(question, action, obs); // (pseudo)
}

return "stopped: step budget exceeded";
}

Hazlo real (un skeleton pequeño y ejecutable)

El ejemplo mínimo de arriba es intencionalmente corto. Aquí tienes un “single file” skeleton un poco más completo, que parece algo que podrías poner detrás de una API:

PYTHON
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


def args_hash(args: dict[str, Any]) -> str:
  raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
  return hashlib.sha256(raw).hexdigest()[:12]


class LoopGuard(RuntimeError):
  pass


class Monitor:
  def __init__(self, *, max_repeat: int = 2):
      self.max_repeat = max_repeat
      self.counts: dict[str, int] = {}

  def mark(self, tool: str, args: dict[str, Any]) -> None:
      key = f"{tool}:{args_hash(args)}"
      self.counts[key] = self.counts.get(key, 0) + 1
      if self.counts[key] >= self.max_repeat:
          raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")


class SafeTools:
  def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
      self.allow = allow
      self.impl = impl

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
      started = time.time()
      try:
          return self.impl[name](**args)
      finally:
          ms = int((time.time() - started) * 1000)
          print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")


def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
  request_id = uuid.uuid4().hex
  started = time.time()
  monitor = Monitor(max_repeat=2)
  state = {"question": question, "notes": []}

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return f"[{request_id}] stopped: time budget"

      action = llm_pick_action(state)  # (pseudo)
      if action.get("finish"):
          return action["finish"]

      tool = action["tool"]
      args = action["args"]
      monitor.mark(tool, args)

      obs = tools.call(tool, args=args, request_id=request_id)
      state = update_state(state, action, obs)  # (pseudo)

  return f"[{request_id}] stopped: step budget"
JAVASCRIPT
import crypto from "node:crypto";

function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}

export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}

export class LoopGuard extends Error {}

export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
  this.maxRepeat = maxRepeat;
  this.counts = new Map();
}

mark(tool, args) {
  const key = tool + ":" + argsHash(args);
  const next = (this.counts.get(key) || 0) + 1;
  this.counts.set(key, next);
  if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}

export class SafeTools {
constructor({ allow, impl }) {
  this.allow = allow;
  this.impl = impl;
}

async call(name, { args, requestId }) {
  if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
  const started = Date.now();
  try {
    return await this.impl[name](args);
  } finally {
    const ms = Date.now() - started;
    console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
  }
}
}

export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";

  const action = await llmPickAction(state); // (pseudo)
  if (action.finish) return action.finish;

  const tool = action.tool;
  const args = action.args;
  monitor.mark(tool, args);

  const obs = await tools.call(tool, { args, requestId });
  state = updateState(state, action, obs); // (pseudo)
}

return "[" + requestId + "] stopped: step budget";
}

Esto no es “el framework perfecto”. Es un loop pequeño y acotado con:

  • allowlisted tools
  • budgets
  • loop guard
  • basic logs

Es suficiente para empezar a aprender sin prender fuego producción.

Paso 3: una “tool policy” pequeña (incluso en read-only)

Incluso los tools read-only deberían estar allowlisted explícitamente. Te obliga a hacer visible la superficie.

Puedes extender la clase juguete Tools de arriba a algo operable:

  • add args hashing
  • add timing
  • add error classification (retryable vs fatal)

No lo sobre‑ingenierices. Hazlo depurable.

Tool contracts (make the model play inside the lines)

The model will happily invent args:

  • wrong field names
  • huge strings
  • weird nested objects

Si tu wrapper acepta “any dict”, tu runtime se convierte en la capa de validación. Eso significa que vas a depurar errores de validación en producción.

Empieza simple:

  • define an args shape per tool
  • validate types and bounds
  • reject unknown fields

Ejemplo conceptual:

PYTHON
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
  q = str(args.get("q", "")).strip()
  if not q or len(q) > 200:
      raise ValueError("invalid q")

  k = int(args.get("k", 5))
  if k < 1 or k > 8:
      raise ValueError("invalid k")

  return {"q": q, "k": k}
JAVASCRIPT
export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");

const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");

return { q, k };
}

Esto parece tonto. También es lo que evita que tu agente llame a web.search con un prompt dump de 10.000 caracteres. Y te da un error limpio que puedes tratar como fatal (no “reintentar para siempre”).

Paso 4: un rollout que no duele

Así es como shipeamos la primera versión:

  1. Solo interno (tú y un compañero)
  2. Read-only (sin write tools)
  3. Canary (un slice pequeño de tráfico)
  4. Kill switch (parada del operador)

Si te saltas el canary, la primera vez que loopee será con tu cliente más importante.

Paso 4.5: cancelación (deja de gastar cuando el usuario se va)

Si tienes UI, los usuarios abandonarán requests. Si no propagas la cancelación, el agente sigue corriendo igual. Sigue llamando tools. Sigue quemando tokens. Sigue generando output que nadie lee.

Haz de la cancelación un stop reason de primera clase:

  • client disconnect → abort del run actual
  • el abort debe propagarse a model calls y tool calls
  • loggea stop_reason = client_cancel

Incluso una implementación cutre es mejor que “seguir gastando hasta que muera el budget”.

Paso 5: métricas de éxito (para saber que mejora)

Elige 3 números y síguelos:

  • completion rate (¿termina?)
  • coste medio por run ($ o tokens o créditos de tools)
  • p95 runtime

Si el completion rate es alto pero el coste explota, no construiste budgets bien. Si el coste es bajo pero el completion rate es terrible, probablemente tu contrato de tool es malo.

Dónde ejecutarlo (serverless vs workers)

Tu primer agente probablemente corra como una API route. Está bien… hasta que deja de estarlo.

Dos trampas comunes:

  • cold starts: cold starts largos + un loop = mal UX
  • time limits: las plataformas serverless tienen límites de ejecución; tu “budget de 90 segundos” puede no caber

A menudo lo separamos así:

  • entra la request (rápido)
  • el run del agente ocurre en un worker (con budgets)
  • la UI hace poll / stream del progreso

Si eso suena a “demasiada arquitectura”, mantenlo simple: pon budgets lo bastante bajos como para que tu plataforma pueda parar el run de verdad. La forma más rápida de conseguir 500s es un loop largo dentro de un request handler sin guardrails.

Hardening v2 (sigue siendo pequeño, sigue siendo seguro)

Cuando la versión de “un tool” sea estable, esto es lo siguiente que añadimos:

Hash de args + dedupe

Si el agente llama el mismo tool con los mismos args repetidamente, párarlo. Esto captura los loops infinitos más fáciles.

Add per-tool budgets

Los budgets globales paran el run. Los budgets por tool evitan que una dependencia flaky se coma el run.

Example:

  • web.search max 3 calls

Add a kill switch

Incluso para un agente pequeño. Si corre en producción, quieres poder pararlo sin deploy.

Add a “stop reason”

Haz que el stop reason sea visible en logs y UI:

  • time budget
  • step budget
  • loop detected
  • tool denied

Esto evita que los usuarios machaquen refresh.

El primer write tool (cómo no liarla)

Cuando por fin añadas un write tool:

  1. make it a separate tool (don’t reuse read tool name)
  2. add idempotency keys
  3. require approval by default
  4. log an audit event for the intended write

Si te saltas la idempotencia, vas a shippear escrituras duplicadas. No es “quizá”. Es “cuándo”.

Errores típicos del primer agente (los hemos cometido)

  • Deriva de “solo un tool más”. Empiezas con web.search y acabas con http.request y un token admin.
  • Retries sin límites. “Manejas errores” y montas un loop infinito sin querer.
  • Sin stop reason en la UI. La gente refresca porque no sabe qué pasó, y pagas dos veces.
  • Loggear solo la respuesta final. Luego no puedes explicar por qué llamó al tool 17 veces.

Si evitas esas cuatro, ya vas por delante de la mayoría de “agent demos”. Y: ejecútalo con un tool falso que falle una vez. Si el loop sobrevive sin spam de retries, bien. Si se derrite, arréglalo ya. En serio, ya.

Fallo real

Hemos visto un “primer agente” en producción con 0 budgets. Recibió un prompt raro, empezó a navegar y se quedó atascado.

Resultado:

  • el usuario esperó ~2 minutos
  • el agente quemó dinero
  • todo el mundo culpó al modelo

No fue el modelo. Fueron los budgets que faltaban.

Cuándo NO construir un agente (todavía)

  • Si un script lo puede hacer, escribe el script.
  • Si un workflow lo puede hacer, construye el workflow.
  • Si no puedes añadir tool permissions + audit logs, no shipees tool calling.

Siguiente

No sabes si este es tu caso?

Disena tu agente ->
⏱️ 14 min de lecturaActualizado Mar, 2026Dificultad: ★★☆
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.