Módulo 2: Tools y Tool Calling

Parallel Tool Calls y Streaming

Descripción de la cápsula

Ya sabes crear tools con @tool, conectarlas a modelos con bind_tools(), y ejecutar el tool execution loop completo. Pero hasta ahora, cada interacción involucraba una sola tool call: el usuario pregunta algo, el modelo llama una tool, tú la ejecutas, y el modelo genera la respuesta final.

En la práctica, los usuarios hacen preguntas que requieren múltiples fuentes de datos simultáneamente. "¿Qué clima hace en Madrid y París?" necesita dos llamadas a la misma tool. "¿Cuál es el precio de Bitcoin y la temperatura en Tokio?" necesita dos tools diferentes. Los modelos modernos resuelven esto con parallel tool calls — en lugar de llamar una tool a la vez, el modelo retorna múltiples tool_calls en un solo AIMessage.

Además, cuando tu aplicación tiene una interfaz de usuario, necesitas que las tool calls lleguen progresivamente — no esperar a que el modelo termine de "pensar" para saber qué tools quiere llamar. Ahí entra el streaming de tool calls: recibir los argumentos de las tools como chunks parciales mientras el modelo los genera.


Parallel tool calls: múltiples llamadas en una respuesta

Cuando un modelo con tools recibe una pregunta que requiere datos de múltiples fuentes, puede retornar varios tool_calls en un solo AIMessage.

Ejemplo básico: clima en dos ciudades

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {
        "Madrid": "Soleado, 22°C",
        "París": "Nublado, 15°C",
        "Tokio": "Lluvioso, 18°C",
    }
    return weathers.get(city, f"Clima no disponible para {city}")

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

response = model_with_tools.invoke("¿Qué clima hace en Madrid y París?")

print(f"Cantidad de tool_calls: {len(response.tool_calls)}")
for tc in response.tool_calls:
    print(f"  {tc['name']}({tc['args']})")
# Output esperado:
# Cantidad de tool_calls: 2
#   get_weather({'city': 'Madrid'})
#   get_weather({'city': 'París'})

Cada tool_call tiene su propio id, name y args. El id es crítico: cuando devuelves el resultado como ToolMessage, debes incluir el tool_call_id correspondiente para que el modelo sepa qué resultado corresponde a qué llamada.


Procesar parallel tool calls

Procesar múltiples tool calls sigue el mismo patrón que una sola, pero iterando sobre response.tool_calls y creando un ToolMessage para cada una.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C", "Tokio": "Lluvioso, 18°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

tools = [get_weather]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

messages = [HumanMessage(content="¿Qué clima hace en Madrid, París y Tokio?")]

# Paso 1: el modelo genera tool_calls
ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

print(f"El modelo quiere llamar {len(ai_message.tool_calls)} tools")

# Paso 2: ejecutar CADA tool call y crear un ToolMessage para cada una
for tc in ai_message.tool_calls:
    tool_fn = tools_by_name[tc["name"]]
    result = tool_fn.invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    print(f"  {tc['args']['city']}: {result}")

# Paso 3: el modelo genera la respuesta final con TODOS los resultados
final_response = model_with_tools.invoke(messages)
print(f"\n{final_response.content}")
# Output esperado:
# El modelo quiere llamar 3 tools
#   Madrid: Soleado, 22°C
#   París: Nublado, 15°C
#   Tokio: Lluvioso, 18°C
#
# Aquí tienes el clima actual en las tres ciudades:
# - **Madrid**: Soleado, 22°C
# - **París**: Nublado, 15°C
# - **Tokio**: Lluvioso, 18°C

Regla fundamental: un ToolMessage por cada tool_call

Cada tool_call debe tener su ToolMessage correspondiente. Si el modelo genera 3 tool calls y solo devuelves 2 ToolMessages, la respuesta será incorrecta o generará un error.


¿Cuándo los modelos usan parallel calls?

El modelo no solo puede llamar la misma tool varias veces — también puede llamar tools diferentes en paralelo (por ejemplo, get_weather + get_population para una misma ciudad). El patrón de procesamiento es idéntico.

EscenarioEjemploTools llamadas
Misma tool, múltiples inputs"Clima en Madrid y París"get_weather × 2
Tools diferentes, mismo sujeto"Clima y población de Tokio"get_weather + get_population
Comparaciones"Compara temperatura de Lima y Santiago"get_weather × 2
Pregunta simple"Clima en Madrid"get_weather × 1 (no parallel)
Sin tool necesaria"¿Cuánto es 2+2?"0 calls, respuesta directa

Modelos que soportan parallel tool calls

  • ✅ OpenAI GPT-4.1, GPT-4.1 Mini, GPT-4o
  • ✅ Anthropic Claude 4 Sonnet, Claude 4 Opus
  • ✅ Google Gemini 2.5 Pro, Gemini 2.5 Flash
  • ⚠️ Modelos más antiguos o locales pueden no soportarlo

Si un modelo no soporta parallel calls, retornará las tool calls una por una en turnos separados. Tu código debería manejar ambos casos.


Ejecución paralela real con asyncio

Que el modelo retorne múltiples tool calls no significa que tú las ejecutes en paralelo automáticamente. El loop for tc in tool_calls las ejecuta secuencialmente. Para ejecutarlas realmente en paralelo, usa asyncio.gather:

import asyncio
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C", "Tokio": "Lluvioso, 18°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

tools = [get_weather]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

messages = [HumanMessage(content="¿Clima en Madrid, París y Tokio?")]
ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

async def execute_tool(tc):
    tool_fn = tools_by_name[tc["name"]]
    result = await tool_fn.ainvoke(tc["args"])
    return ToolMessage(content=result, tool_call_id=tc["id"])

async def main():
    tool_messages = await asyncio.gather(
        *[execute_tool(tc) for tc in ai_message.tool_calls]
    )
    for tm in tool_messages:
        messages.append(tm)

    final = model_with_tools.invoke(messages)
    print(final.content)

asyncio.run(main())
# Output esperado:
# Aquí tienes el clima actual:
# - **Madrid**: Soleado, 22°C
# - **París**: Nublado, 15°C
# - **Tokio**: Lluvioso, 18°C

Con asyncio.gather, las tres tool calls se ejecutan simultáneamente. Si cada una tarda 1 segundo, el total es ~1 segundo en lugar de ~3. Especialmente valioso cuando las tools hacen llamadas HTTP a APIs externas.


Streaming de tool calls

Con stream(), puedes recibir los tool calls progresivamente como chunks parciales en lugar de esperar a que el modelo termine.

¿Por qué streaming de tool calls?

  • ✅ Mostrar qué tools va a llamar el modelo mientras las genera
  • ✅ Dar feedback visual al usuario sobre el progreso
  • ✅ Iniciar preparación antes de que los argumentos estén completos

Ejemplo: stream con tool calls

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

chunks = []
for chunk in model_with_tools.stream("¿Clima en Madrid?"):
    chunks.append(chunk)
    if chunk.tool_call_chunks:
        print(f"Tool chunk: {chunk.tool_call_chunks}")
# Output esperado (varía según el modelo):
# Tool chunk: [{'name': 'get_weather', 'args': '', 'id': 'call_abc123', 'index': 0, ...}]
# Tool chunk: [{'name': None, 'args': '{"ci', 'id': None, 'index': 0, ...}]
# Tool chunk: [{'name': None, 'args': 'ty":', 'id': None, 'index': 0, ...}]
# Tool chunk: [{'name': None, 'args': ' "Mad', 'id': None, 'index': 0, ...}]
# Tool chunk: [{'name': None, 'args': 'rid"}', 'id': None, 'index': 0, ...}]

El primer chunk suele tener el name y el id. Los siguientes traen fragmentos de los args en formato JSON string parcial.

AIMessageChunk y tool_call_chunks

CampoDescripción
contentTexto parcial (vacío durante tool calls)
tool_call_chunksFragmentos parciales de tool calls
tool_callsTool calls completas (solo disponible al acumular)

Acumular chunks para obtener tool calls completas

Los tool_call_chunks son fragmentos parciales — necesitas acumularlos con el operador + para obtener los tool calls completos:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

chunks = []
for chunk in model_with_tools.stream("¿Clima en Madrid y París?"):
    chunks.append(chunk)

full_message = chunks[0]
for chunk in chunks[1:]:
    full_message = full_message + chunk

print(f"Tool calls completas: {len(full_message.tool_calls)}")
for tc in full_message.tool_calls:
    print(f"  {tc['name']}({tc['args']}) id={tc['id']}")
# Output esperado:
# Tool calls completas: 2
#   get_weather({'city': 'Madrid'}) id=call_abc123
#   get_weather({'city': 'París'}) id=call_def456

Loop completo: stream + execute + stream respuesta

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

tools = [get_weather]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)
messages = [HumanMessage(content="¿Clima en Madrid y París?")]

# Fase 1: stream de tool calls
print("Generando tool calls...")
chunks = []
for chunk in model_with_tools.stream(messages):
    chunks.append(chunk)
    if chunk.tool_call_chunks:
        for tcc in chunk.tool_call_chunks:
            if tcc["name"]:
                print(f"  → Tool detectada: {tcc['name']}")

full_message = chunks[0]
for c in chunks[1:]:
    full_message = full_message + c
messages.append(full_message)

# Fase 2: ejecutar tools
print("\nEjecutando tools...")
for tc in full_message.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    print(f"  {tc['name']}({tc['args']}) → {result}")

# Fase 3: stream de la respuesta final
print("\nRespuesta:")
for chunk in model_with_tools.stream(messages):
    if chunk.content:
        print(chunk.content, end="", flush=True)
print()
# Output esperado:
# Generando tool calls...
#   → Tool detectada: get_weather
#   → Tool detectada: get_weather
#
# Ejecutando tools...
#   get_weather({'city': 'Madrid'}) → Soleado, 22°C
#   get_weather({'city': 'París'}) → Nublado, 15°C
#
# Respuesta:
# El clima actual es:
# - **Madrid**: Soleado, 22°C
# - **París**: Nublado, 15°C

Este es el patrón completo para una UI de chat con tools: stream las tool calls para mostrar progreso, ejecuta las tools, y stream la respuesta final.


Comparación: parallel calls vs sequential calls

MétricaSequential (una por turno)Parallel
Llamadas al modeloN + 12
Latencia total (modelo)(N + 1) × latencia_por_call2 × latencia_por_call
Tokens consumidosMás (contexto crece cada turno)Menos (contexto compacto)

Con 5 tools, la versión secuencial necesita 6 llamadas al modelo; la paralela solo necesita 2. La diferencia en latencia y costo es significativa.


Conexión con el proyecto

En el Asistente con Herramientas Externas (Cápsula 08), implementarás parallel tool calls para escenarios como "¿Qué clima hace en las 3 capitales de mi itinerario?" y el streaming será esencial para la UX: mientras el modelo genera tool calls mostrarás indicadores de progreso, y la respuesta final llegará con streaming de tokens.


Troubleshooting

Problema 1: El modelo retorna tool calls secuenciales en vez de paralelas

Causa: El modelo o proveedor no soporta parallel tool calls, o la pregunta no es suficientemente clara. Solución: Usa un modelo que soporte parallel calls (GPT-4.1, Claude 4 Sonnet). Reformula la pregunta para ser explícita:

# Más probable que active parallel calls
response = model_with_tools.invoke("Dame el clima de Madrid Y París")

Problema 2: ToolMessage faltante causa error

Causa: No estás creando un ToolMessage para cada tool_call. Solución: Siempre itera sobre todos los tool_calls:

for tc in ai_message.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

Problema 3: tool_call_chunks está vacío durante streaming

Causa: No todos los chunks contienen tool_call_chunks. Solución: Verifica antes de acceder:

for chunk in model_with_tools.stream("¿Clima en Madrid?"):
    if chunk.tool_call_chunks:
        for tcc in chunk.tool_call_chunks:
            print(tcc)

Problema 4: tool_calls está vacío después de acumular chunks

Causa: La acumulación no se hizo correctamente. Solución: Usa el operador + entre AIMessageChunk:

full = chunks[0]
for chunk in chunks[1:]:
    full = full + chunk
print(full.tool_calls)

Ejercicios

Ejercicio 1: Parallel calls básico (Fácil)

Crea dos tools: get_weather(city) y get_time(city). Envía una pregunta que pida clima y hora de una ciudad. Verifica que el modelo retorna 2 tool calls en paralelo, ejecútalas, y obtén la respuesta final.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "Tokio": "Lluvioso, 18°C"}
    return weathers.get(city, f"Clima no disponible para {city}")

@tool
def get_time(city: str) -> str:
    """Obtiene la hora actual de una ciudad."""
    times = {"Madrid": "14:30 CET", "Tokio": "22:30 JST"}
    return times.get(city, f"Hora no disponible para {city}")

tools = [get_weather, get_time]
tools_by_name = {t.name: t for t in tools}

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

messages = [HumanMessage(content="¿Qué clima y hora es en Madrid?")]
ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)

print(f"Tool calls: {len(ai_message.tool_calls)}")
for tc in ai_message.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    print(f"  {tc['name']}({tc['args']}) → {result}")

final = model_with_tools.invoke(messages)
print(f"\n{final.content}")
# Output esperado:
# Tool calls: 2
#   get_weather({'city': 'Madrid'}) → Soleado, 22°C
#   get_time({'city': 'Madrid'}) → 14:30 CET
#
# En Madrid: clima soleado de 22°C y son las 14:30 CET.

Explicación: El modelo detecta que la pregunta pide dos tipos de datos diferentes y genera dos tool calls en paralelo, una para cada tool.

Ejercicio 2: Contar tool calls según la pregunta (Fácil)

Crea una tool get_weather y envía tres preguntas: una ciudad, dos ciudades, y una pregunta sin tools. Imprime cuántas tool calls genera cada una.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    return "Soleado, 22°C"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

questions = [
    "¿Qué clima hace en Madrid?",
    "¿Qué clima hace en Madrid y París?",
    "¿Cuánto es 2 + 2?",
]

for q in questions:
    response = model_with_tools.invoke(q)
    n = len(response.tool_calls)
    print(f"'{q}' → {n} tool call(s)")
    if n == 0:
        print(f"  Respuesta directa: {response.content[:60]}...")
# Output esperado:
# '¿Qué clima hace en Madrid?' → 1 tool call(s)
# '¿Qué clima hace en Madrid y París?' → 2 tool call(s)
# '¿Cuánto es 2 + 2?' → 0 tool call(s)
#   Respuesta directa: 2 + 2 es igual a 4...

Explicación: El modelo decide cuántas tool calls generar basándose en la pregunta. Sin tools necesarias, responde directamente con 0 calls.

Ejercicio 3: Streaming con feedback de tools (Medio)

Haz streaming de una pregunta que genere tool calls. Imprime un mensaje cada vez que detectes una nueva tool. Al final, acumula los chunks y muestra los tool calls completos.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    return "Soleado, 22°C"

@tool
def get_population(city: str) -> str:
    """Obtiene la población de una ciudad."""
    return "3.2 millones"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, get_population])

chunks = []
detected = []
for chunk in model_with_tools.stream("Dame clima y población de Madrid"):
    chunks.append(chunk)
    if chunk.tool_call_chunks:
        for tcc in chunk.tool_call_chunks:
            if tcc.get("name"):
                detected.append(tcc["name"])
                print(f"  [Detectada] → {tcc['name']}")

full = chunks[0]
for c in chunks[1:]:
    full = full + c

print(f"\nDetectadas durante streaming: {detected}")
print(f"Tool calls completas: {len(full.tool_calls)}")
for tc in full.tool_calls:
    print(f"  {tc['name']}({tc['args']})")
# Output esperado:
#   [Detectada] → get_weather
#   [Detectada] → get_population
#
# Detectadas durante streaming: ['get_weather', 'get_population']
# Tool calls completas: 2
#   get_weather({'city': 'Madrid'})
#   get_population({'city': 'Madrid'})

Explicación: Durante el streaming puedes detectar qué tools se llamarán tan pronto llega el primer chunk de cada tool call (el que contiene el name), dando feedback inmediato al usuario.

Ejercicio 4: Loop completo con streaming en 3 fases (Medio)

Implementa el flujo profesional: stream de tool calls → ejecutar tools → stream de la respuesta final. Usa get_weather y get_time con una pregunta que active ambas.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C"}
    return weathers.get(city, f"No disponible para {city}")

@tool
def get_time(city: str) -> str:
    """Obtiene la hora actual de una ciudad."""
    times = {"Madrid": "14:30 CET", "París": "14:30 CET"}
    return times.get(city, f"No disponible para {city}")

tools = [get_weather, get_time]
tools_by_name = {t.name: t for t in tools}
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)
messages = [HumanMessage(content="¿Clima y hora en Madrid y París?")]

# Fase 1: stream de tool calls
print("=== Fase 1: Detectando tools ===")
chunks = []
for chunk in model_with_tools.stream(messages):
    chunks.append(chunk)
    if chunk.tool_call_chunks:
        for tcc in chunk.tool_call_chunks:
            if tcc.get("name"):
                print(f"  → {tcc['name']}")

full = chunks[0]
for c in chunks[1:]:
    full = full + c
messages.append(full)

# Fase 2: ejecutar tools
print(f"\n=== Fase 2: Ejecutando {len(full.tool_calls)} tools ===")
for tc in full.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    print(f"  {tc['name']}({tc['args']}) → {result}")

# Fase 3: stream de respuesta final
print("\n=== Fase 3: Respuesta ===")
for chunk in model_with_tools.stream(messages):
    if chunk.content:
        print(chunk.content, end="", flush=True)
print()
# Output esperado:
# === Fase 1: Detectando tools ===
#   → get_weather
#   → get_time
#   → get_weather
#   → get_time
#
# === Fase 2: Ejecutando 4 tools ===
#   get_weather({'city': 'Madrid'}) → Soleado, 22°C
#   get_time({'city': 'Madrid'}) → 14:30 CET
#   get_weather({'city': 'París'}) → Nublado, 15°C
#   get_time({'city': 'París'}) → 14:30 CET
#
# === Fase 3: Respuesta ===
# - **Madrid**: Soleado, 22°C — 14:30 CET
# - **París**: Nublado, 15°C — 14:30 CET

Explicación: Las 3 fases dan feedback continuo: la Fase 1 muestra qué tools se preparan, la Fase 2 ejecuta y muestra resultados, la Fase 3 hace streaming de la respuesta final.

Ejercicio 5: Función genérica de tool execution con streaming (Difícil)

Crea stream_tool_loop(model_with_tools, tools, question) que encapsule: stream tool calls → acumular → ejecutar → verificar si hay más tool calls → stream final. Debe manejar múltiples rondas.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

def stream_tool_loop(model_with_tools, tools: list, question: str, max_rounds: int = 5):
    """Tool loop completo con streaming y soporte multi-ronda."""
    tools_by_name = {t.name: t for t in tools}
    messages = [HumanMessage(content=question)]

    for round_num in range(max_rounds):
        chunks = []
        for chunk in model_with_tools.stream(messages):
            chunks.append(chunk)
            if chunk.tool_call_chunks:
                for tcc in chunk.tool_call_chunks:
                    if tcc.get("name"):
                        print(f"  [R{round_num + 1}] → {tcc['name']}")
            if chunk.content:
                print(chunk.content, end="", flush=True)

        full = chunks[0]
        for c in chunks[1:]:
            full = full + c
        messages.append(full)

        if not full.tool_calls:
            print()
            return full

        for tc in full.tool_calls:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
            print(f"  [Resultado] {tc['name']}{result}")

    return None

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {"Madrid": "Soleado, 22°C", "París": "Nublado, 15°C"}
    return weathers.get(city, f"No disponible para {city}")

tools = [get_weather]
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)

stream_tool_loop(model_with_tools, tools, "¿Clima en Madrid y París?")
# Output esperado:
#   [R1] → get_weather
#   [R1] → get_weather
#   [Resultado] get_weather → Soleado, 22°C
#   [Resultado] get_weather → Nublado, 15°C
# Madrid: Soleado, 22°C. París: Nublado, 15°C.

Explicación: La función genérica maneja múltiples rondas automáticamente. Si después de ejecutar las tools el modelo genera más tool calls, el loop continúa. max_rounds previene loops infinitos.


Resumen

En esta cápsula aprendiste:

  • Parallel tool calls permiten al modelo llamar múltiples tools en un solo turno — reduciendo llamadas al modelo y latencia total
  • Cada tool_call tiene name, args e id — debes crear un ToolMessage con el tool_call_id correspondiente para cada uno
  • Los modelos activan parallel calls cuando la pregunta requiere datos independientes de múltiples fuentes
  • Para ejecución paralela real, usa asyncio.gather con las versiones async de las tools
  • El streaming de tool calls llega como tool_call_chunks — fragmentos parciales que acumulas con +
  • El patrón profesional tiene 3 fases: stream tool calls → ejecutar tools → stream respuesta final
  • No todos los modelos soportan parallel calls — tu código debe manejar ambos escenarios

Próxima cápsula: Structured Output con Tools — cómo usar tool calling no solo para ejecutar acciones, sino también para extraer datos estructurados de texto.


Recursos adicionales

  1. Tool Calling — LangChain Docs — Guía conceptual oficial de tool calling
  2. How to call tools in parallel — Tutorial de parallel tool calls
  3. How to stream tool calls — Streaming de tool call chunks
  4. AIMessageChunk API Reference — Referencia de AIMessageChunk
  5. ToolMessage API Reference — Referencia de ToolMessage
  6. OpenAI Parallel Function Calling — Implementación de OpenAI
  7. Anthropic Tool Use — Tool calling en Anthropic
  8. AsyncIO Documentation — Para ejecución paralela real de tools

Módulo 2 — LangChain & LangGraph: From Chains to Agents