DEV Community

Christian Gonzales Komiya
Christian Gonzales Komiya

Posted on

(Spanish) Cómo piensa un agente ReAct: por qué el LLM no resuelve todo en una sola llamada

Cuando empecé a jugar con LangGraph y un agente con tools (add, subtract, multiply), me chocó algo que parecía contraintuitivo: si le pido al LLM 5 + 8/12, el modelo no responde el resultado final de una. Hace una llamada, pide una tool, espera, hace otra llamada, pide otra tool, espera, y recién en una tercera llamada da la respuesta.

Mi primera reacción fue pensar que el modelo era "limitado" o que solo podía resolver la parte para la que tenía una tool a la mano. La explicación real es más simple y más interesante: el modelo no puede escribir una tool call cuyo argumento sea un resultado que todavía no existe. Este post explica esa idea con el ejemplo concreto que usé para entenderla.

El código base

Un agente ReAct minimalista en LangGraph, con tres tools aritméticas:

@tool
def add(a: int, b: int) -> int:
    """Herramienta de suma que agrega dos números."""
    return a + b

@tool
def multiply(a: int, b: int):
    """Función de multiplicación"""
    return a * b

tools = [add, subtract, multiply]
model = ChatDeepSeek(model="deepseek-chat").bind_tools(tools)

def model_call(state: AgentState) -> AgentState:
    system_prompt = SystemMessage(content="Eres mi asistente de IA...")
    response = model.invoke([system_prompt] + state["messages"])
    return {"messages": [response]}

def should_continue(state: AgentState):
    last_message = state["messages"][-1]
    if not last_message.tool_calls:
        return "end"
    else:
        return "continue"
Enter fullscreen mode Exit fullscreen mode

El grafo conecta our_agenttoolsour_agent en bucle, hasta que should_continue ya no encuentra tool_calls en la última respuesta.

La confusión inicial: "resuelve solo una parte porque solo tiene esa tool"

Con el input 5 + 8/12, uno esperaría que el modelo "vea" la expresión completa y la resuelva de un tiro. En cambio, pasa esto:

  1. Llamada 1 → pide divide(8, 12)
  2. Llamada 2 → pide add(5, 0.667)
  3. Llamada 3 → responde en texto

Da la impresión de que el modelo "hizo lo que pudo con la tool que tenía a mano, y no siguió". Pero esa lectura es engañosa. El límite no es la disponibilidad de tools — es la dependencia de datos entre los dos pasos.

La razón real: no se puede prometer un resultado futuro

Una tool call necesita valores concretos, no referencias simbólicas. El modelo no puede generar algo como:

{"name": "add", "arguments": {"a": 5, "b": "resultado_de_divide"}}
Enter fullscreen mode Exit fullscreen mode

Eso no es un valor válido en el esquema de tool calling. Solo puede escribir add(5, 0.667) si 0.667 ya existe como número real en el historial. Y ese número solo existe después de que la tool divide se ejecutó y su resultado volvió como respuesta.

Por eso, en la primera llamada, lo único que el modelo puede pedir es la operación que ya tiene todos sus datos disponibles: la división. No es que "decida" resolver solo esa parte — es la única parte que puede ejecutar con la información que tiene en ese instante.

Analogía: mandarle mensajes a un amigo con calculadora

Imagina que sabes exactamente qué cuenta quieres hacer (5 + 8/12), pero no tienes calculadora — solo puedes escribirle a un amigo que sí tiene una, y esperar su respuesta antes de seguir.

Le escribes: "divide 8 entre 12". Ya sabes que después vas a sumar 5 al resultado — pero no puedes escribir eso en el mismo mensaje, porque tu amigo no interpreta instrucciones encadenadas en lenguaje natural: solo ejecuta funciones con números concretos. Así que lo único que puedes mandar es la operación cuyos números ya tienes: la división. La suma queda para el siguiente mensaje, una vez que tengas la respuesta.

Prueba de que no es "una tool por llamada"

Si el límite fuera "el modelo solo puede usar una tool por llamada", entonces con un input como:

Suma 4+2 y suma 7+8
Enter fullscreen mode Exit fullscreen mode

Debería necesitar dos llamadas. Pero no es así — como ninguna de las dos sumas depende de la otra, el modelo pide ambas tool calls en la misma respuesta:

[
  {"name": "add", "arguments": {"a": 4, "b": 2}},
  {"name": "add", "arguments": {"a": 7, "b": 8}}
]
Enter fullscreen mode Exit fullscreen mode

El ToolNode las ejecuta ambas sin volver a llamar al LLM entre medio. Total: 2 llamadas al LLM, no 3. Esto confirma que la regla real es "no puedo pedir algo cuyo argumento depende de un resultado que aún no existe" — no una limitación arbitraria de cantidad de tools por turno.

Qué pasa cuando falta una tool

Con 5 + 8/12, si el modelo tiene tanto add como divide, ambas operaciones las hace código Python con precisión garantizada, en 3 llamadas. Pero:

  • Si solo existe la tool add (no hay divide): el modelo calcula la división mentalmente (como cualquier LLM sin tools, con su propio "cálculo interno"), y usa la tool solo para la suma. Total: 2 llamadas.
  • Si solo existe la tool divide (no hay add): el modelo pide la división como tool call, y luego calcula la suma mentalmente con el resultado. Total: 2 llamadas.

El patrón se mantiene: el número de llamadas depende de cuántos pasos secuenciales con dependencia de datos hay — no de cuántas tools existan en total. Cuando falta una tool para un paso, el modelo simplemente resuelve ese paso con su propio razonamiento en lugar de con código, y sigue adelante.

Lo que el LLM sabe y lo que no sabe desde la primera llamada

Sí sabe, desde el inicio: qué tools tiene disponibles y sus esquemas (nombre, descripción, parámetros). Eso se manda en cada llamada — es literalmente lo que hace bind_tools(tools): adjuntar esa lista de forma fija a cada invocación.

No sabe, ni falta que sepa: cuántas llamadas totales se van a necesitar. El modelo no planea "esto me va a tomar 3 vueltas" — es reactivo, no planificador de antemano. En cada llamada individual mira el historial actual y decide un solo paso siguiente: pedir tool(s) o responder en texto. Quien determina que habrá otra llamada es LangGraph (should_continue + el edge tools → our_agent), no el modelo.

El input y output real de cada llamada

Un punto clave: el LLM nunca ve clases de Python como AIMessage o ToolMessage. Esas son abstracciones de LangChain. Lo que realmente viaja a la API es JSON con un campo role:

Clase de LangChain role real
SystemMessage system
HumanMessage user
AIMessage assistant
ToolMessage tool

Así se ve la secuencia completa para 5 + 8/12 (con tools add y divide):

Llamada 1 — input:

[
  {"role": "system", "content": "Eres mi asistente de IA..."},
  {"role": "user", "content": "5 + 8/12"}
]
Enter fullscreen mode Exit fullscreen mode

Llamada 1 — output:

{"role": "assistant", "content": "", "tool_calls": [
  {"id": "call_001", "function": {"name": "divide", "arguments": "{\"a\": 8, \"b\": 12}"}}
]}
Enter fullscreen mode Exit fullscreen mode

Llamada 2 — input (todo lo anterior + el resultado de la tool):

[
  {"role": "system", "content": "Eres mi asistente de IA..."},
  {"role": "user", "content": "5 + 8/12"},
  {"role": "assistant", "content": "", "tool_calls": [
    {"id": "call_001", "function": {"name": "divide", "arguments": "{\"a\": 8, \"b\": 12}"}}
  ]},
  {"role": "tool", "tool_call_id": "call_001", "content": "0.6666666666666666"}
]
Enter fullscreen mode Exit fullscreen mode

Llamada 2 — output:

{"role": "assistant", "content": "", "tool_calls": [
  {"id": "call_002", "function": {"name": "add", "arguments": "{\"a\": 5, \"b\": 0.6666666666666666}"}}
]}
Enter fullscreen mode Exit fullscreen mode

Llamada 3 — input (historial completo hasta ahora):

[
  {"role": "system", "content": "Eres mi asistente de IA..."},
  {"role": "user", "content": "5 + 8/12"},
  {"role": "assistant", "content": "", "tool_calls": [{"id": "call_001", "function": {"name": "divide", "arguments": "{\"a\": 8, \"b\": 12}"}}]},
  {"role": "tool", "tool_call_id": "call_001", "content": "0.6666666666666666"},
  {"role": "assistant", "content": "", "tool_calls": [{"id": "call_002", "function": {"name": "add", "arguments": "{\"a\": 5, \"b\": 0.6666666666666666}"}}]},
  {"role": "tool", "tool_call_id": "call_002", "content": "5.666666666666667"}
]
Enter fullscreen mode Exit fullscreen mode

Llamada 3 — output (ya sin tool_calls):

{"role": "assistant", "content": "5 + 8/12 = 5.666666666666667 (aproximadamente 5.67).", "tool_calls": []}
Enter fullscreen mode Exit fullscreen mode

Nota clave: el tool_call_id (call_001, call_002) es el hilo que conecta cada tool call con su respuesta correspondiente — así el modelo sabe cuál ToolMessage responde a cuál petición suya.

Output del modelo vs. input acumulado: quién arma el historial

Algo que vale la pena separar bien: el output del LLM es solo el mensaje nuevo, no el historial repetido. model.invoke(...) te devuelve únicamente el assistant que acaba de generar.

Quien acumula todo es LangGraph, gracias al reducer definido en el estado:

class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], add_messages]
Enter fullscreen mode Exit fullscreen mode

Cuando el nodo hace return {"messages": [response]}, el reducer add_messages no sobreescribe state["messages"] — hace un append. Así, para la siguiente llamada, state["messages"] ya contiene todo lo acumulado hasta ese momento, y model_call vuelve a mandar la lista completa ([system_prompt] + state["messages"]) en cada invocación.

Es la diferencia entre "el modelo te contesta una carta" y "tú guardas copia de cada carta en una carpeta, y le mandas la carpeta completa junto con la carta nueva la próxima vez".

Resumen: el patrón completo

  1. El modelo recibe todo el historial acumulado hasta ese punto (nunca recuerda nada por sí mismo — no tiene memoria entre llamadas).
  2. Con esa información, decide un solo paso: pedir tool(s) para las que ya tiene todos los datos concretos, o responder en texto si no falta nada.
  3. Cada tool call se ejecuta como código Python normal (ToolNode), sin costo de LLM.
  4. LangGraph vuelve a invocar al modelo con el historial actualizado — y el ciclo se repite hasta que la respuesta ya no trae tool_calls.
  5. El número de llamadas al LLM depende de cuántos pasos secuenciales con dependencia de datos tiene la tarea — no de cuántas tools existan en total. Tools independientes se piden en paralelo, en una sola llamada.

Cada llamada al LLM es una petición HTTP real, facturable en tokens, y el costo de entrada crece en cada vuelta porque se reenvía todo el historial acumulado — algo a tener en cuenta al diseñar agentes con muchos pasos encadenados.

Top comments (0)