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"
El grafo conecta our_agent → tools → our_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:
- Llamada 1 → pide
divide(8, 12) - Llamada 2 → pide
add(5, 0.667) - 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"}}
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
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}}
]
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 haydivide): 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 hayadd): 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"}
]
Llamada 1 — output:
{"role": "assistant", "content": "", "tool_calls": [
{"id": "call_001", "function": {"name": "divide", "arguments": "{\"a\": 8, \"b\": 12}"}}
]}
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"}
]
Llamada 2 — output:
{"role": "assistant", "content": "", "tool_calls": [
{"id": "call_002", "function": {"name": "add", "arguments": "{\"a\": 5, \"b\": 0.6666666666666666}"}}
]}
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"}
]
Llamada 3 — output (ya sin tool_calls):
{"role": "assistant", "content": "5 + 8/12 = 5.666666666666667 (aproximadamente 5.67).", "tool_calls": []}
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]
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
- El modelo recibe todo el historial acumulado hasta ese punto (nunca recuerda nada por sí mismo — no tiene memoria entre llamadas).
- 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.
- Cada tool call se ejecuta como código Python normal (
ToolNode), sin costo de LLM. - LangGraph vuelve a invocar al modelo con el historial actualizado — y el ciclo se repite hasta que la respuesta ya no trae
tool_calls. - 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)