Capítulo 7 de 21 · intermedio
Múltiples herramientas y ciclos de herramientas
Qué cubre esta sesión
La semana pasada el modelo hizo una sola llamada a función y tú le devolviste el resultado. Eso alcanza para "cuánto son 26.2 millas en km". No alcanza para "qué artículos de menos de $30 hay en stock y cuánto costaría uno de cada uno" — una pregunta que nadie puede responder en una sola llamada, porque primero tienes que descubrir qué existe antes de poder ponerle precio. Esta hora convierte el viaje de ida y vuelta en un ciclo: dale al modelo varias tools, sigue ejecutando lo que te pida, y detente cuando deje de pedir. Ese ciclo tiene un nombre por el que la gente cobra dinero — un agente — y para el final de la hora habrás escrito uno en unas quince líneas.
Las tres piezas móviles son un dict de despacho que mapea nombres de tools a
funciones de Python, una conversación a la que sigues agregando turnos, y un
while acotado. El modelo planea la cadena por sí mismo: qué tool, en qué
orden, cuántas veces. Tu código solo ejecuta y mantiene honesta la
transcripción.
OpenAI
El ejemplo básico es una tiendita de artículos de oficina con dos tools — una lista los nombres de los productos, la otra da el precio de un artículo específico. La pregunta obliga a encadenar: el modelo tiene que listar, luego pedir el precio de varios artículos, y después filtrar y sumar.
"""Week 6 - Multi-tool calling and tool loops (OpenAI).
Last week was one tool, one round trip. Real questions need several
calls the model plans itself: look one thing up, then another, then
combine. The pattern is a while loop — keep executing whatever the
model asks for until it stops asking and answers.
"""
import json
import os
import sys
from dotenv import find_dotenv, load_dotenv
from openai import OpenAI
load_dotenv(find_dotenv())
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
client = OpenAI()
# region: the-tools
# A tiny office-supplies store. Two tools the model must combine: one to
# discover what exists, one to price a specific item.
PRODUCTS = {
"notebook": {"price": 3.50, "stock": 120},
"pen": {"price": 1.25, "stock": 0},
"stapler": {"price": 11.00, "stock": 14},
"monitor stand": {"price": 42.99, "stock": 3},
"desk lamp": {"price": 27.50, "stock": 8},
}
def list_products() -> dict:
return {"products": sorted(PRODUCTS)}
def get_product(name: str) -> dict:
item = PRODUCTS.get(name.lower().strip())
if item is None:
return {"error": f"no product called {name!r}"}
return {"name": name, "price": item["price"], "stock": item["stock"]}
# The dispatch table: tool name -> Python function. Adding a tool is one
# schema entry and one dict entry; the loop below never changes.
DISPATCH = {"list_products": list_products, "get_product": get_product}
# endregion
TOOLS = [
{
"type": "function",
"name": "list_products",
"description": "List the names of every product in the store.",
"parameters": {"type": "object", "properties": {}},
},
{
"type": "function",
"name": "get_product",
"description": "Price and stock for one product, by name.",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
]
QUESTION = ("Which items under $30 are actually in stock, and what would "
"one of each of those cost together?")
# region: the-loop
# The agent loop. Each iteration: send the conversation, execute every
# function call the model asked for, append the results, repeat. When a
# response has no function calls, it's the answer. The range() is the
# safety rail — never ship an unbounded loop.
input_list = [{"role": "user", "content": QUESTION}]
for _ in range(10):
response = client.responses.create(
model="gpt-5.4-nano",
input=input_list,
tools=TOOLS,
)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output # keep the model's tool-request turn
for call in calls:
args = json.loads(call.arguments)
print(f"tool: {call.name}({args})")
result = DISPATCH[call.name](**args)
input_list.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
# endregion
print(response.output_text)
El cuerpo del ciclo es la lección. Cada iteración envía la conversación
completa, recolecta cada function_call de la respuesta — los modelos agrupan
varias llamadas en un solo turno todo el tiempo, y por eso existe el for
interno — ejecuta cada una a través del dict de despacho, y agrega los
outputs. Cuando una respuesta no trae llamadas, esa respuesta es la solución.
Y el range(10): un ciclo de agente sin límite es una caída en producción
esperando a que un modelo confundido pida la misma tool por siempre. Ponle
tope, siempre.
Gemini
Misma tienda, mismo ciclo, y a estas alturas la ortografía de Gemini ya te es
familiar: haces crecer a mano una lista de turnos Content — el turno de
llamada a función del modelo, luego un turno de usuario que carga cada parte
function_response — y reenvías todo.
"""Week 6 - Multi-tool calling and tool loops (Gemini).
Same store, same loop, Gemini spelling. The conversation is a list of
Content turns you grow by hand: the model's function-call turn, then a
user turn carrying every function_response part.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types
load_dotenv(find_dotenv())
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# region: the-tools
PRODUCTS = {
"notebook": {"price": 3.50, "stock": 120},
"pen": {"price": 1.25, "stock": 0},
"stapler": {"price": 11.00, "stock": 14},
"monitor stand": {"price": 42.99, "stock": 3},
"desk lamp": {"price": 27.50, "stock": 8},
}
def list_products() -> dict:
return {"products": sorted(PRODUCTS)}
def get_product(name: str) -> dict:
item = PRODUCTS.get(name.lower().strip())
if item is None:
return {"error": f"no product called {name!r}"}
return {"name": name, "price": item["price"], "stock": item["stock"]}
DISPATCH = {"list_products": list_products, "get_product": get_product}
# endregion
CONFIG = types.GenerateContentConfig(
tools=[types.Tool(function_declarations=[
types.FunctionDeclaration(
name="list_products",
description="List the names of every product in the store.",
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
),
types.FunctionDeclaration(
name="get_product",
description="Price and stock for one product, by name.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"name": types.Schema(type=types.Type.STRING)},
required=["name"],
),
),
])],
)
QUESTION = ("Which items under $30 are actually in stock, and what would "
"one of each of those cost together?")
# region: the-loop
# Same loop shape as the OpenAI side: send, execute, append, repeat,
# bounded. Gemini batches several function calls into one turn routinely,
# so the inner for matters here too.
contents = [types.Content(role="user", parts=[types.Part(text=QUESTION)])]
for _ in range(10):
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=contents,
config=CONFIG,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
args = dict(fc.args)
print(f"tool: {fc.name}({args})")
result = DISPATCH[fc.name](**args)
parts.append(types.Part.from_function_response(name=fc.name, response=result))
contents.append(types.Content(role="user", parts=parts))
# endregion
print(response.text)
Un hábito para quedarte de este par: los errores de las tools regresan como
datos, no como excepciones. Devuelve {"error": "no product called 'notebok'"}
y el modelo lo lee, corrige el nombre y reintenta — el ciclo se cura solo. Si
en cambio lanzas la excepción, cambiaste un error recuperable del modelo por
un 500.
Ponlo a trabajar
Tres aplicaciones web con docker-compose bajo code/showcase/<slug>/, la misma
rutina de siempre: bash bootstrap-secrets.sh, docker compose up --build,
http://localhost:3000. Cada una necesita una cadena genuina — ninguna
pregunta que valga la pena hacerle a estos demos se responde con una sola
llamada.
Showcase 1 — Analista de CSV
Haz preguntas sobre un trimestre de datos de órdenes que el modelo nunca ve.
Descubre el esquema con list_columns, y luego encadena llamadas a
filter_rows y aggregate hasta tener el número. Ese es el patrón de acceso
que quieres contra una tabla real de warehouse que no cabe en un prompt — y un
adelanto de por qué los capítulos de retrieval más adelante en el libro
funcionan como funcionan.
Showcase 2 — Automatización del hogar
Una casa simulada de cinco cuartos y un comando condicional: "apaga las luces de todos los cuartos desocupados". El modelo no puede actuar a ciegas — tiene que leer el estado, decidir qué cuartos califican, y entonces escribir. La respuesta agrega al final una lista de cambios calculada en Python a partir del diff entre el estado anterior y el posterior, porque los modelos a veces reportan mal sus propios efectos secundarios — el reporte sale del estado, no de la memoria del modelo sobre lo que hizo.
Showcase 3 — Presupuestador de viajes
"Cuatro días en Tokio y tres en París, en pesos" toma cinco llamadas: dos
consultas de costo por ciudad, un tipo de cambio, y llamadas a la calculadora
para los totales. La calculadora es un evaluador con whitelist de AST —
números y cuatro operadores, nada de eval — para que la aritmética de varios
pasos que los modelos fallan con toda confianza siempre corra en Python.
Pregunta por una ciudad que no está en la tabla y el error regresa como datos
listando cuáles sí están; mira al modelo recuperarse.
Las tres corren el mismo backend/main.py, despachando según PROVIDER, así
que PROVIDER=gemini docker compose up --build cambia de SDK sin tocar la
lección. La decisión de diseño que las mantiene honestas es el ciclo acotado
más los errores-como-datos: el modelo tiene espacio para planear y
recuperarse, y físicamente no puede desbocarse.
Córrelo
El README de esta carpeta tiene la versión de Python, la línea de instalación,
las dos variables de entorno, y los comandos exactos para los ejemplos básicos
y cada showcase. Las llaves vienen del .env sin trackear en la raíz del
curso, igual que todas las semanas.
Conclusiones
Un agente es un ciclo while con un dict de despacho — aférrate a eso, porque la industria va a intentar vendértelo de vuelta con un logo. Lo que hace que uno sea digno de producción no es el ciclo, es la disciplina alrededor: un tope duro de iteraciones, errores de tools devueltos como datos que el modelo puede leer y de los que puede recuperarse, y efectos secundarios reportados desde el estado calculado en vez de la palabra del modelo. El modelo planea, tu código ejecuta y verifica. La próxima semana las tools dejan de ser juguetes: embeddings, y el camino hacia retrieval.