cd ../back to blog
$Guide//June 4, 2026//8 min read

Construye un bucle de agente de IA: cuatro barreras antes de producción

Construye un bucle de agente real contra una API compatible con OpenAI: tope de iteraciones, despacho de tools validado y un presupuesto de gasto por ejecución leído de tokens honestos.

Un agente es lo que obtienes cuando pones uso de tools en un bucle. Una llamada a una tool responde una pregunta; un agente llama a una tool, lee el resultado, decide qué hacer a continuación y sigue hasta que la tarea está realmente terminada: busca, luego lee el primer resultado, luego consulta un precio, luego escribe la respuesta. El mecanismo es sencillo y siempre son los mismos cuatro compases: el modelo pide una tool, tú la ejecutas, le devuelves el resultado, vuelves a llamar al modelo. Lo difícil no es el bucle. Son las barreras que evitan que gire para siempre o que te facture en silencio 40 dólares en una sola ejecución.

Este artículo construye un bucle de agente real y ejecutable contra https://api.brievio.com/v1 con el SDK de OpenAI para Python, y luego lo envuelve en los cuatro controles que lo hacen seguro para producción: un tope de iteraciones estricto, despacho de tools validado, un presupuesto de coste por ejecución leído de conteos de tokens honestos y un manejo sensato para los casos en que el modelo se porta mal. Cada fragmento se ejecuta tal cual; cambia claude-sonnet-4-6 por gemini-2.5-pro y el mismo código maneja un modelo distinto.

El bucle, y por qué necesita un techo

Aquí está el motor completo. Es el bucle de uso de tools que ya conoces, con una sola adición que lo cambia todo: for step in range(MAX_ITERS) en lugar de while True.

agent_loop.py
# El bucle del agente con un tope de iteraciones estricto. Modelo -> tool_calls ->
# ejecutar -> devolver resultados -> repetir, hasta que el modelo responda en
# prosa o lleguemos al techo. El tope es la diferencia entre "agente" y "factura
# descontrolada".
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

MAX_ITERS = 8   # la mayoria de tareas terminan en 2-4 rondas; 8 es margen generoso.

def run_agent(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [
        {"role": "system", "content": "You are a helpful research agent. "
         "Use the tools when you need live data. Answer directly when you "
         "already know enough."},
        {"role": "user", "content": question},
    ]

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = resp.choices[0].message

        # No se pidio ninguna tool -> esta es la respuesta final. Listo.
        if not msg.tool_calls:
            return msg.content

        # Anade el turno del asistente EXACTAMENTE como llego: lleva los
        # tool_call ids que los siguientes mensajes deben referenciar.
        messages.append(msg)

        # Ejecuta cada llamada pedida y anade un mensaje tool por cada id.
        for call in msg.tool_calls:
            result = dispatch(call)              # ver el siguiente fragmento
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,          # DEBE coincidir con el id de la llamada
                "content": json.dumps(result),
            })
        # Bucle: el modelo ya ve la salida de la tool y continua.

    # Llegamos al tope sin resolver. Falla a gritos: no repitas en silencio para siempre.
    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

Ese for acotado es la línea más importante de un agente. Un modelo capaz en una tarea bien delimitada termina en dos a cuatro rondas. Pero los modelos se confunden: llaman dos veces a la misma búsqueda, persiguen un callejón sin salida o —el fallo clásico— llaman a una tool, obtienen un resultado que no les gusta y vuelven a llamarla con argumentos casi idénticos, sin fin. Un while True convierte eso en una factura ilimitada y una petición colgada. El tope convierte «gira para siempre» en «falla tras 8 intentos con un error claro», que es algo que puedes capturar, registrar y del que puedes recuperarte. Elige el número según tu tarea: una consulta de un solo paso necesita 2; un agente de investigación de varios pasos, quizá 10. Defínelo deliberadamente; no lo dejes sin acotar.

Fíjate en la otra barrera que está a plena vista: maneja el caso en que el modelo no llama a ninguna tool. Cuando msg.tool_calls está vacío, eso es el modelo decidiendo que ya tiene suficiente para responder: esa es tu salida, no un error. Un bucle que asume que cada turno produce una llamada a una tool o se cae o nunca termina. Bifurca según ambos desenlaces en cada iteración.

Despacho de tools: el modelo propone, tu código dispone

El modelo nunca toca tus sistemas. Emite un nombre de función y un string JSON de argumentos y se detiene; tu código decide si honrar eso. Esa frontera es toda la historia de seguridad de un agente, así que la función de despacho es donde vive la validación: no como un detalle, sino porque cada argumento es salida no confiable del modelo, exactamente como un campo de formulario que rellenó un desconocido.

dispatch.py
# Despacho de tools con validacion. El modelo PROPONE una llamada; tu codigo
# DISPONE de ella. Cada argumento es salida no confiable del modelo: parsealo,
# comprueba que el nombre es uno que registraste y valida los tipos antes de ejecutar.
def get_weather(city: str, unit: str = "celsius") -> dict:
    if not isinstance(city, str) or not city.strip():
        raise ValueError("city must be a non-empty string")
    if unit not in ("celsius", "fahrenheit"):
        raise ValueError(f"unsupported unit: {unit!r}")
    return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}

# Lista blanca: un modelo solo puede llamar a lo que registraste explicitamente.
TOOL_IMPLS = {"get_weather": get_weather}

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                         "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}]

def dispatch(call) -> dict:
    name = call.function.name
    fn = TOOL_IMPLS.get(name)
    if fn is None:
        # El modelo alucino una tool. No tumbes el bucle: devuelve el error
        # como resultado de tool para que el modelo pueda corregirse solo.
        return {"error": f"unknown tool: {name}"}

    try:
        args = json.loads(call.function.arguments)   # siempre es un STRING JSON
    except json.JSONDecodeError:
        return {"error": "arguments were not valid JSON"}

    try:
        return fn(**args)
    except (TypeError, ValueError) as e:
        # Argumentos malos (tipo erroneo, campo faltante, fuera de rango). Devuelve
        # el mensaje; el modelo suele reintentar con una llamada corregida.
        return {"error": str(e)}

Aquí se manejan tres modos de fallo, y todos devuelven el error al modelo en lugar de tumbar el bucle. Un nombre de tool alucinado que el modelo inventó pero que tú nunca registraste: la lista blanca lo atrapa. JSON mal formado en el string de argumentos: raro en un buque insignia genuino, pero lo parseas a la defensiva de todos modos. Y valores de argumento incorrectos: tipo erróneo, campo requerido faltante, un enum que el modelo se inventó. En todos los casos, devolver {"error": "..."} como resultado de la tool es mejor que lanzar una excepción, porque el modelo lee ese mensaje en el siguiente turno y normalmente corrige su propia llamada. Un agente que puede recuperarse de sus propios errores es mucho más robusto que uno que muere al primer argumento malo.

Mantén la lista blanca ajustada. TOOL_IMPLS.get(name) significa que un modelo —genuino o no— solo puede invocar funciones que registraste explícitamente. Ese único diccionario es tu radio de impacto. Si una tool borra datos, cobra una tarjeta o envía un correo, protégela detrás de una confirmación explícita en lugar de dejar que el bucle la dispare de forma autónoma.

La guarda de presupuesto: los bucles reenvían un contexto creciente

El tope de iteraciones acota cuántas veces llamas al modelo. No acota cuánto cuesta cada llamada, y en un bucle el coste sube en cada ronda. La razón es estructural: cada turno reenvía toda la conversación hasta ese punto, más cada resultado de tool añadido a ella. El primer turno podrían ser 800 tokens de input; el sexto, después de que se hayan acumulado cinco salidas de tools, pueden ser 6.000. Ocho rondas baratas suman en silencio una ejecución no tan barata. La solución es un segundo techo, independiente, sobre el gasto, calculado a partir de los conteos reales de tokens que cada llamada devuelve:

budget_guard.py
# Una guarda de presupuesto de coste/tokens por ejecucion. Cada turno del bucle
# reenvia un contexto CRECIENTE (historial + salidas de tools), asi que el coste
# sube en cada ronda. Lee el objeto usage honesto despues de cada llamada,
# tarificalo y para cuando la ejecucion supere su presupuesto, con independencia
# del tope de iteraciones.
from decimal import Decimal

# Tarifas publicadas de Brievio, USD por 1M de tokens (~15% por debajo de la lista oficial).
RATES = {
    "claude-sonnet-4-6": {"in": Decimal("2.55"), "out": Decimal("12.75")},
    "claude-haiku-4-5":  {"in": Decimal("0.85"), "out": Decimal("4.25")},
}

def call_cost(model: str, usage) -> Decimal:
    r = RATES[model]
    m = Decimal("1000000")
    return usage.prompt_tokens * r["in"] / m + usage.completion_tokens * r["out"] / m

RUN_BUDGET = Decimal("0.10")   # 10 centavos por ejecucion del agente, techo estricto.

def run_agent_budgeted(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [{"role": "user", "content": question}]
    spent = Decimal("0")

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model, messages=messages, tools=TOOLS, tool_choice="auto",
        )
        spent += call_cost(model, resp.usage)   # suma los tokens REALES, en cada turno
        if spent > RUN_BUDGET:
            raise RuntimeError(f"run exceeded ${RUN_BUDGET} (spent ${spent:.4f})")

        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content

        messages.append(msg)
        for call in msg.tool_calls:
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(dispatch(call))})

    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

La clave es que resp.usage en Brievio lleva los conteos honestos de tokens de input y output que el modelo genuino realmente procesó, así que el total acumulado es dinero real, no una estimación. Leer usage después de cada turno y parar en RUN_BUDGET significa que un agente confundido que de otro modo quemaría ocho rondas caras se corta en el momento en que cruza un centavo, sin importar cuántas iteraciones le tomó. Dos techos, dos modos de fallo distintos cubiertos: el tope de iteraciones detiene los bucles infinitos, el presupuesto detiene los caros. Quieres ambos, porque un bucle puede ser corto y caro o largo y barato, y ninguno por sí solo te protege del otro.

Vale la pena saberlo para las cuentas: las llamadas fallidas 4xx/5xx no se facturan en Brievio, así que un reintento contra una tool inestable o un error transitorio aguas arriba no agota el presupuesto de la ejecución: solo sumas coste por las llamadas que de verdad devolvieron un resultado. Eso mantiene la curva de gasto siguiendo el trabajo hecho, no los errores absorbidos. El patrón completo para acotar el gasto por llamada y por usuario está en la guía de cómo limitar el gasto de la API.

Mantener a raya la factura de tokens a medida que el bucle crece

Acotar el coste es una cosa; reducirlo es otra. Como cada turno reenvía un prefijo creciente, el mismo contexto se paga una y otra vez, que es exactamente la forma para la que está hecha la caché de prompts. Marca las partes estáticas de la petición (el system prompt, las definiciones de tools) como cacheables, y a partir del segundo turno pagas una fracción de la tarifa de input sobre todo lo que no cambió. En un bucle que reenvía el mismo catálogo de tools de varios miles de tokens y el mismo system prompt en cada ronda, esa es la mayor palanca sobre la factura.

Un par de hábitos prácticos también ayudan. Mantén el system prompt y las definiciones de tools estables durante la ejecución: una tool añadida a mitad del bucle o una marca de tiempo en el system prompt invalida la caché y duplica en silencio tu coste de input. Y si una tool puede devolver un muro de datos (una página web completa, una consulta de mil filas), resume o trunca el resultado antes de añadirlo a messages; el modelo rara vez necesita todo, y cada byte que añades se reenvía en cada turno posterior. El bucle del agente es especialmente sensible al exceso de contexto precisamente porque el contexto se reenvía N veces, no una.

Memoria de conversación: qué llevar entre ejecuciones

Todo lo anterior es memoria dentro de una sola ejecución: la lista messages es la memoria de trabajo del agente, y añadirle elementos es como el modelo recuerda lo que ya consultó. Para un agente multiturno que habla con un usuario a lo largo de varias peticiones, arrastras esa lista hacia adelante: persiste messages por sesión (Redis, una columna de base de datos, donde sea), recárgala en la siguiente petición y añade el nuevo turno del usuario. El bucle es idéntico; solo cambia el estado inicial.

Lo que hay que gestionar es el crecimiento sin límite. Una sesión de larga vida acumula historial hasta que resulta cara en cada llamada y acaba desbordando la ventana de contexto. Dos estrategias comunes: mantener una ventana móvil de los últimos N turnos y descartar los más antiguos, o resumir periódicamente el historial más viejo en una nota compacta y reemplazar los turnos en bruto por ella. Ambas cambian algo de fidelidad por un tamaño de contexto acotado y predecible. Sea cual sea la que elijas, la guarda de presupuesto por ejecución de arriba sigue aplicando: es la red de seguridad que atrapa una sesión que creció más de lo que planeaste.

Una sola clave para un agente que escala

Una propiedad útil de construir esto detrás de Brievio: un agente puede cambiar qué modelo usa a mitad de tarea sin cambiar nada más. Ejecuta las rondas baratas en un modelo más pequeño y escala a un buque insignia solo cuando la tarea sea difícil: enruta el despacho fácil de tools por Haiku 4.5 a $0.85 de input / $4.25 de output, y recurre a Sonnet o a otra familia para la respuesta final con más razonamiento. Como una sola clave cubre todos los modelos detrás de un único base_url, esa escalada es un cambio de una línea en el string model dentro del bucle: sin un segundo SDK, sin un segundo esquema de autenticación, sin una segunda relación de facturación. El contrato completo de petición/respuesta, incluidos los campos de tools, está en la documentación de Chat Completions, y la lista de modelos en vivo con los ids exactos está en la página de modelos.

Por supuesto, solo importa si el modelo del otro lado es genuino: un bucle de agente es implacable con un sustituto degradado, porque un modelo que se equivoca con los argumentos de una tool o ignora una tool quemará iteraciones y gasto persiguiendo sus propios errores. Brievio sirve los modelos genuinos de primera mano, honra el uso nativo de tools y reporta conteos de tokens honestos, que es lo que hace que tanto el bucle como las cuentas del presupuesto funcionen de verdad.

La conclusión: cuatro barreras, y a producción

El bucle en sí son una docena de líneas. Lo que lo vuelve listo para producción es la frontera a su alrededor:

  • Acota las iteraciones. Un for acotado en lugar de while True. Falla a gritos en el techo en vez de girar para siempre.
  • Maneja el caso sin tool. Un tool_calls vacío es la salida, no un error. Bifurca según él en cada turno.
  • Valida cada despacho. Pon los nombres de tools en lista blanca, parsea los argumentos, comprueba los tipos, y devuelve los errores al modelo en lugar de caerte.
  • Pon presupuesto a la ejecución. Lee usage en cada turno, tarifícalo contra las tarifas publicadas, para en un techo de gasto estricto. Vigila el contexto creciente, y cachea el prefijo estático para mantener bajo el coste reenviado.

Acierta en esas cuatro y tienes un agente que hace trabajo real de varios pasos, se recupera de sus propios errores y tiene un coste en el peor caso que tú elegiste en lugar de descubrir en una factura. Empieza por la guía de uso de tools si primero necesitas la mecánica de una sola llamada, y luego envuélvela en el bucle y las cuatro barreras de arriba.