14. Construir el bucle del agente
En el Capítulo 13 aprendimos cómo ejecutar las solicitudes de llamada a herramientas del LLM y devolver los resultados. Sin embargo, ese trabajo asumía que cada tarea podía resolverse en una única llamada a herramienta. En la práctica, las llamadas a herramientas a menudo necesitan continuar a lo largo de varias rondas hasta que el LLM haya reunido toda la información que necesita para una respuesta final.
Considera la solicitud "encuentra la población de Francia y luego multiplícala por dos". Esta tarea requiere al menos dos llamadas a herramientas. Primero tienes que consultar la población; solo entonces puedes ejecutar el cálculo. La segunda llamada depende del primer resultado, así que no hay forma de resolverla en una única llamada a herramienta.
En este capítulo, convertimos el ciclo único del Capítulo 13 en un bucle. Mientras el LLM solicite llamadas a herramientas, seguimos ejecutándolas, repitiendo hasta que el LLM deje de solicitar herramientas por sí mismo. Luego añadimos límites de seguridad para evitar que el bucle se ejecute indefinidamente, y cubrimos la gestión de errores para que el agente no falle cuando una herramienta produzca un error.
14.1) Del ciclo único al bucle
14.1.1) ¿Cómo funciona el bucle del agente?
Como vimos en la introducción, las tareas en las que la siguiente acción depende del resultado del paso anterior a menudo no pueden resolverse con una única llamada a herramienta. El LLM necesita llamar a una herramienta, comprobar el resultado y decidir de nuevo. Esto es lo que hace el bucle del agente, y funciona en tres etapas:
- Pensar — El LLM lee la conversación hasta el momento y decide qué hacer a continuación. Si necesita una herramienta, solicita una llamada a herramienta mediante
tool_calls. Si no, devuelve una respuesta final. - Actuar — Ejecuta la herramienta especificada en
tool_calls. - Observar — Comprueba el resultado de la ejecución de la herramienta y lo añade a la conversación en un
ToolMessage.
El bucle del agente repite estas tres etapas hasta que el LLM ya no solicita ninguna herramienta. Este patrón también se conoce como ReAct (Reason + Act), y la idea clave es alternar entre razonamiento y acción.
Si tool_calls está presente, ejecuta la herramienta, añade el resultado a la conversación y llama de nuevo al LLM. Si tool_calls está vacío, el LLM ha devuelto una respuesta final y el bucle termina. Ahora pongámoslo en código.
14.1.2) Implementar el bucle Pensar-Actuar-Observar
Convirtamos el bucle Pensar-Actuar-Observar de la sección anterior en código. Primero, preparamos las herramientas y el modelo.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
# Define las herramientas
@tool
def get_weather(city: str) -> str:
"""Obtiene el clima actual de una ciudad."""
fake_data = {"Tokyo": "18°C, nublado", "Cairo": "31°C, soleado"}
return fake_data.get(city, f"No hay datos meteorológicos para {city}.")
@tool
def calculate(expression: str) -> str:
"""Evalúa una expresión aritmética simple, p. ej. '3 * 21'."""
return str(eval(expression)) # Advertencia: eval() es un riesgo de seguridad. No lo uses en producción.
# Vincula las herramientas
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}En el Capítulo 13, ejecutamos una herramienta una vez y nos detuvimos. Ahora repetimos hasta que el LLM deje de solicitar llamadas a herramientas. Dentro de un bucle while True, llamamos al LLM, y si la respuesta contiene tool_calls, ejecutamos las herramientas y llamamos de nuevo al LLM. Si no hay tool_calls, el LLM ha devuelto una respuesta final, así que salimos del bucle.
def run_agent(user_input: str) -> str:
"""Ejecuta el bucle Pensar-Actuar-Observar hasta que el LLM devuelve una respuesta final."""
messages = [
SystemMessage(content="Eres un asistente útil."),
HumanMessage(content=user_input),
]
while True:
# PENSAR: Pide al LLM que decida la siguiente acción
print("PENSAR: Pidiendo al LLM que decida")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# Condición de salida: sin tool_calls significa que esta es la respuesta final
if not ai_message.tool_calls:
return ai_message.content
# ACTUAR + OBSERVAR: Ejecuta las herramientas solicitadas y añade los resultados a la conversación
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
print(f"ACTUAR: llamando a '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVAR: {tool_message.content}")
messages.append(tool_message)Comparemos esto con el código del Capítulo 13. En el Capítulo 13, después de ejecutar las herramientas, llamábamos al LLM una última vez para obtener la respuesta. En el Capítulo 14, ponemos ese mismo proceso dentro de un while True y comprobamos tool_calls en cada iteración para decidir si continuar. Los componentes básicos son los mismos que en el Capítulo 13; simplemente los hemos envuelto en un bucle.
Ejecutémoslo.
answer = run_agent("¿Qué clima hace en Tokio y hace suficiente calor para dar un paseo?")
print(f'Respuesta final: {answer}')Salida:
PENSAR: Pidiendo al LLM que decida
ACTUAR: llamando a 'get_weather', args={'city': 'Tokyo'}
OBSERVAR: 18°C, nublado
PENSAR: Pidiendo al LLM que decida
Respuesta final: Ahora mismo en Tokio hace 18°C (unos 64°F) y está nublado.
Esa temperatura es generalmente templada y cómoda para dar un paseo para la mayoría de las personas.La salida muestra el flujo PENSAR → ACTUAR → OBSERVAR → PENSAR. En la primera iteración el LLM solicitó una llamada a get_weather, y en la segunda iteración vio el resultado del clima y generó una respuesta final. Como tool_calls estaba vacío, se devolvió la respuesta final y el bucle terminó.
Ahora probemos el escenario de pasos dependientes de la introducción, donde la siguiente llamada solo puede ocurrir después de ver el resultado de la anterior.
answer = run_agent("Obtén la temperatura en El Cairo y luego multiplica el número por 3.")
print(f'Respuesta final: {answer}')Salida:
PENSAR: Pidiendo al LLM que decida
ACTUAR: llamando a 'get_weather', args={'city': 'Cairo'}
OBSERVAR: 31°C, soleado
PENSAR: Pidiendo al LLM que decida
ACTUAR: llamando a 'calculate', args={'expression': '31 * 3'}
OBSERVAR: 93
PENSAR: Pidiendo al LLM que decida
Respuesta final: Temperatura actual en El Cairo: 31°C. Multiplicada por 3 = 93.Esta vez el bucle se ejecutó en tres iteraciones.
- Primera iteración — El LLM solicita
get_weather("Cairo"). - Segunda iteración — Después de ver el resultado
"31°C, soleado", el LLM solicitacalculate("31 * 3"). Solo pudo formar la expresión después de ver la temperatura. - Tercera iteración — Después de ver tanto el resultado
"31°C, soleado"como el"93", el LLM devolvió la respuesta final.
14.2) Añadir límites de seguridad
El bucle que construimos arriba tiene una sola condición de salida: cuando el LLM responde sin tool_calls, salimos del bucle. En circunstancias normales esto es suficiente, pero ¿qué pasa si el LLM nunca deja de solicitar llamadas a herramientas?
Por ejemplo, si una herramienta siempre devuelve resultados ambiguos, el LLM puede seguir llamándola con la esperanza de obtener algo mejor. Como el bucle es while True, si el LLM no se detiene, el programa tampoco lo hace. Los costes de las llamadas a la API se van acumulando mientras el programa se ejecuta indefinidamente.
La solución más simple es poner un tope al número de veces que el bucle puede ejecutarse. Reemplaza while True por for step in range(max_steps), y el bucle tiene garantizado que terminará después de max_steps iteraciones sin importar lo que haga el LLM.
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""Ejecuta el bucle Pensar-Actuar-Observar dentro de max_steps iteraciones."""
messages = [
SystemMessage(content="Eres un asistente útil."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# PENSAR
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# Condición de salida: sin tool_calls significa que esta es la respuesta final
if not ai_message.tool_calls:
return ai_message.content
# ACTUAR + OBSERVAR
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
# max_steps alcanzado: el bucle terminó sin una respuesta final
return f"[Detenido tras alcanzar el máximo de iteraciones ({max_steps})]"Comparado con el código anterior, dos cosas cambiaron. while True pasó a ser for step in range(max_steps), y se añadió un valor de retorno para cuando el bucle alcanza max_steps. El bucle ahora termina de una de dos maneras: el LLM devuelve una respuesta final por sí mismo (terminación natural), o se alcanza max_steps (terminación de seguridad).
Verifiquemos que el límite de seguridad realmente funciona. Crearemos una herramienta que nunca devuelve resultados útiles, forzando al LLM a una situación en la que nunca deja de solicitar llamadas a herramientas.
@tool
def unhelpful_search(query: str) -> str:
"""Busca información."""
return "No se encontraron resultados. Intenta reformular tu consulta."
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
messages = [
SystemMessage(content=(
"SIEMPRE debes usar la herramienta unhelpful_search para encontrar información. "
"NO tienes permitido responder con tu propio conocimiento. "
"Si la herramienta no devuelve resultados, DEBES reformular y buscar de nuevo. "
"Sigue buscando hasta que encuentres la respuesta."
)),
HumanMessage(content=user_input),
]
for step in range(max_steps):
print(f"--- Paso {step + 1} ---")
ai_message = llm_with_bad_tool.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
for tool_call in ai_message.tool_calls:
selected_tool = tool_map_bad[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
print(f"ACTUAR: '{tool_call['name']}' → {tool_message.content}")
messages.append(tool_message)
return f"[Detenido tras alcanzar el máximo de iteraciones ({max_steps})]"
answer = run_agent_bad("¿Cuál es la población de Francia?")
print(f"\nRespuesta final: {answer}")Salida:
--- Paso 1 ---
ACTUAR: 'unhelpful_search' → No se encontraron resultados. Intenta reformular tu consulta.
--- Paso 2 ---
ACTUAR: 'unhelpful_search' → No se encontraron resultados. Intenta reformular tu consulta.
--- Paso 3 ---
ACTUAR: 'unhelpful_search' → No se encontraron resultados. Intenta reformular tu consulta.
--- Paso 4 ---
ACTUAR: 'unhelpful_search' → No se encontraron resultados. Intenta reformular tu consulta.
--- Paso 5 ---
ACTUAR: 'unhelpful_search' → No se encontraron resultados. Intenta reformular tu consulta.
Respuesta final: [Detenido tras alcanzar el máximo de iteraciones (5)]Sin max_steps, este bucle se habría ejecutado indefinidamente. Gracias a max_steps=5, se detuvo forzosamente después de cinco iteraciones.
El valor adecuado para max_steps depende de la complejidad de tu agente. Demasiado bajo, y las tareas complejas se cortan antes de tiempo. Demasiado alto, y un agente que se comporta mal acumula costes antes de ser detenido. De 15 a 25 es un punto de partida habitual; ajústalo según tus cargas de trabajo reales.
14.3) Gestionar errores de herramientas en el bucle
El bucle que construimos en 14.1 y 14.2 asume que las herramientas siempre se ejecutan con éxito. Pero ¿qué pasa si una herramienta lanza una excepción? El código actual no tiene gestión de excepciones, así que si ocurre una excepción durante la ejecución de una herramienta, todo el agente falla.
En el Capítulo 12 aprendimos cómo capturar excepciones dentro de la propia herramienta con try/except y devolver los mensajes de error como cadenas. Si una herramienta se construye de esa manera, no hay problema. Pero no todas las herramientas gestionan los errores internamente. Las herramientas que llaman a bibliotecas o API externas pueden lanzar excepciones inesperadas.
Para protegerse contra esto, es buena idea gestionar los errores también a nivel del bucle. El enfoque es sencillo: envuelve la ejecución de la herramienta en un try/except, y si ocurre una excepción, pon el mensaje de error en un ToolMessage y pásalo al LLM. El LLM puede leer este mensaje de error y reintentar con argumentos corregidos o elegir un enfoque diferente. Esto se llama autocorrección.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
# Define las herramientas
@tool
def calculate(expression: str) -> str:
"""Evalúa una expresión aritmética simple, p. ej. '3 * 21'."""
return str(eval(expression)) # Advertencia: eval() es un riesgo de seguridad. No lo uses en producción.
# Vincula las herramientas
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""Bucle del agente que pasa los errores de herramientas al LLM, permitiendo la autocorrección."""
messages = [
SystemMessage(content="Eres un asistente útil."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# PENSAR
print("PENSAR: Pidiendo al LLM que decida")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
# ACTUAR + OBSERVAR
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
try:
print(f"ACTUAR: llamando a '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVAR: {tool_message.content}")
except Exception as e:
print(f"OBSERVAR: Error - {e}")
tool_message = ToolMessage(
content=f"Error: {e}",
tool_call_id=tool_call["id"],
)
messages.append(tool_message)
return f"[Detenido tras alcanzar el máximo de iteraciones ({max_steps})]"Comparado con el código de 14.2, el único cambio es el try/except. Si una herramienta lanza una excepción, el mensaje de error se coloca en un ToolMessage y se añade a la conversación. Ten en cuenta que incluso una llamada fallida debe tener un ToolMessage con el tool_call_id correspondiente. El LLM verá este error en la siguiente iteración y decidirá qué hacer a continuación.
Verifiquemos que la autocorrección funciona. Provocaremos una excepción pidiendo a la herramienta calculate que divida por cero.
answer = run_agent("Usa la herramienta de calculadora para calcular 10 / 0")
print(f"Respuesta final: {answer}")Salida:
PENSAR: Pidiendo al LLM que decida
ACTUAR: llamando a 'calculate', args={'expression': '10 / 0'}
OBSERVAR: Error - division by zero
PENSAR: Pidiendo al LLM que decida
Respuesta final: Usé la herramienta de calculadora y devolvió un error: "division by zero."
Explicación: 10 / 0 no está definido en la aritmética ordinaria, así que no puede producir un número finito.
¿Te gustaría que:
- calcule los límites laterales,
- muestre el resultado en punto flotante IEEE-754,
- o evalúe una expresión diferente?En la primera iteración, el LLM solicitó calculate("10 / 0") y se lanzó un ZeroDivisionError. El try/except capturó la excepción y pasó el mensaje de error al LLM. En la segunda iteración, el LLM vio el mensaje de error y devolvió una respuesta final explicando que dividir por cero es imposible. Sin el try/except, el programa habría fallado en el primer ZeroDivisionError.