Uso de herramientas: el modelo pide, el programa actúa
En resumen: El uso de herramientas permite que el modelo pida al programa ejecutar una función, por ejemplo consultar el estado de un pedido, y responda con el resultado en lugar de responder de memoria.
Problema
El modelo no conoce tus pedidos
El cliente pregunta: «¿Cuándo llegará el pedido 1042?» El asistente le pregunta al modelo sin darle los datos:
client = FakeClient()question = "¿Cuándo llegará el pedido 1042?"messages = [{"role": "user", "content": [{"type": "text", "text": question}]}]reply = client.create(SYSTEM, messages, [])print(f"modelo: {text_of(reply)}")order = ORDERS["1042"]print(f"tienda: {order['status']}, entrega {order['eta']}")modelo: El pedido 1042 llegará el viernes.tienda: enviado, entrega 3 de noviembreEl modelo dice «el viernes», y la tienda dice 3 de noviembre. No ha visto tu base de datos. Solo tiene el texto que le enviaste y inventa algo verosímil.
En la lección sobre el prompt los datos del pedido fueron a la plantilla. Eso funciona cuando sabes de antemano qué datos hacen falta. Pero el cliente puede preguntar por otro pedido, por una devolución o por dos cosas a la vez. No puedes meter toda la tienda en el prompt: el contexto es limitado, y cada palabra cuesta dinero.
La idea
El modelo pide, el programa actúa
Le das al modelo una lista de herramientas. Cada una tiene un nombre, una descripción y un esquema de su entrada: el mismo tipo de esquema que en la lección sobre la salida estructurada. Ahora el modelo puede responder de dos maneras:
- Con texto.
stop_reasonesend_turny la conversación termina. - Con una solicitud.
stop_reasonestool_use, y la respuesta tiene un bloquetool_use: unid, el nombre de la herramienta y una entrada, por ejemplo{"order_id": "1042"}.
Ante una solicitud el programa:
- Añade la respuesta del modelo al historial, con el rol
assistant. - Ejecuta la herramienta con sus propios datos.
- Devuelve el resultado como un mensaje
usernuevo con un bloquetool_result. En él,tool_use_ides el mismoid, y si hay un error,is_errores verdadero. - Vuelve a preguntar al modelo, con todo el historial.
Este es el ciclo de herramientas (tool use). El modelo no ejecuta nada y no ve la base de datos. Solo dice qué necesita, y el programa decide si lo hace y cómo. Por eso las herramientas del asistente solo leen: el estado de un pedido, el plazo de una devolución.
La entrada de la herramienta viene del modelo, es decir, de fuera. El programa la comprueba como comprueba un formulario.Míralo
La herramienta y los bloques
La herramienta que el modelo puede pedir. La descripción es para el modelo: con ella decide cuándo llamar a la herramienta.
TOOLS = [ { "name": "get_order_status", "description": "Estado y fecha de entrega de un pedido según su número.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"], }, },]type dice el tipo, y tool_use tiene id, name e input. En Python y JavaScript la herramienta de arriba tiene exactamente la forma que acepta la API.El programa ejecuta lo que se pidió. Primero comprueba que existe esa herramienta y que la entrada tiene los campos obligatorios, y luego busca el pedido:
def run_tool(use): tool = next((t for t in TOOLS if t["name"] == use["name"]), None) if tool is None: return error(use, f"no hay herramienta {use['name']}") for field in tool["input_schema"]["required"]: if field not in use["input"]: return error(use, f"falta {field}") order_id = use["input"]["order_id"] order = ORDERS.get(order_id) if order is None: return error(use, f"no hay pedido {order_id}") text = f"{order_id} — {order['status']}, entrega {order['eta']}" return {"type": "tool_result", "tool_use_id": use["id"], "content": text}
def error(use, text): return {"type": "tool_result", "tool_use_id": use["id"], "content": text, "is_error": True}Cada error es un resultado con is_error, no una excepción. El modelo tiene que entender qué pasó y decidir qué decirle al cliente.
Míralo
El ciclo
def answer(client, question): print(f"pregunta: {question}") messages = [{"role": "user", "content": [{"type": "text", "text": question}]}] for call in range(1, MAX_CALLS + 1): reply = client.create(SYSTEM, messages, TOOLS) print(f" llamada {call} (~{reply['input_tokens']} tokens):", reply["stop_reason"]) if reply["stop_reason"] != "tool_use": print(f" respuesta: {text_of(reply)}") return messages.append({"role": "assistant", "content": reply["content"]}) results = [] for block in reply["content"]: if block["type"] == "tool_use": result = run_tool(block) shown = ("✗ " if result.get("is_error") else "") shown += result["content"] print(f" {block['id']} {describe(block)} → {shown}") results.append(result) messages.append({"role": "user", "content": results}) print(f" me detengo tras {MAX_CALLS} llamadas → a una persona")def run_tool(use): tool = next((t for t in TOOLS if t["name"] == use["name"]), None) if tool is None: return error(use, f"no hay herramienta {use['name']}") for field in tool["input_schema"]["required"]: if field not in use["input"]: return error(use, f"falta {field}") order_id = use["input"]["order_id"] order = ORDERS.get(order_id) if order is None: return error(use, f"no hay pedido {order_id}") text = f"{order_id} — {order['status']}, entrega {order['eta']}" return {"type": "tool_result", "tool_use_id": use["id"], "content": text}
def error(use, text): return {"type": "tool_result", "tool_use_id": use["id"], "content": text, "is_error": True}Paso a paso, en palabras
- La pregunta es el primer mensaje. Va al modelo junto con la lista de herramientas (~46 tokens).
- El modelo no conoce el estado. Responde con stop_reason tool_use: «ejecuta get_order_status con order_id 1042».
- El programa guarda la respuesta del modelo en el historial: el resultado necesita una solicitud a la que responder.
- El programa ejecuta la herramienta con sus propios datos. El modelo nunca toca la tienda: solo lo pidió.
- El resultado vuelve como mensaje user: tool_result con el mismo id, call_1.
- La llamada 2 envía todo el historial otra vez: ~59 tokens en lugar de ~46.
- Ahora el modelo tiene los datos y responde con end_turn. Dos llamadas al modelo para una pregunta.
El ciclo completo. Dos funciones auxiliares dicen qué pidió el modelo y cuál es el texto de la respuesta:
def describe(use): return f"{use['name']}({use['input'].get('order_id', '')})"
def text_of(reply): return next((b["text"] for b in reply["content"] if b["type"] == "text"), "")def answer(client, question): print(f"pregunta: {question}") messages = [{"role": "user", "content": [{"type": "text", "text": question}]}] for call in range(1, MAX_CALLS + 1): reply = client.create(SYSTEM, messages, TOOLS) print(f" llamada {call} (~{reply['input_tokens']} tokens):", reply["stop_reason"]) if reply["stop_reason"] != "tool_use": print(f" respuesta: {text_of(reply)}") return messages.append({"role": "assistant", "content": reply["content"]}) results = [] for block in reply["content"]: if block["type"] == "tool_use": result = run_tool(block) shown = ("✗ " if result.get("is_error") else "") shown += result["content"] print(f" {block['id']} {describe(block)} → {shown}") results.append(result) messages.append({"role": "user", "content": results}) print(f" me detengo tras {MAX_CALLS} llamadas → a una persona")client = FakeClient()answer(client, "¿Cuándo llegará el pedido 1042?")pregunta: ¿Cuándo llegará el pedido 1042? llamada 1 (~46 tokens): tool_use call_1 get_order_status(1042) → 1042 — enviado, entrega 3 de noviembre llamada 2 (~59 tokens): end_turn respuesta: Lo revisé: 1042 — enviado, entrega 3 de noviembre.Una pregunta del cliente hace dos llamadas al modelo. La segunda envía todo el historial y es más grande: ~59 tokens en lugar de ~46.
El modelo aquí es un cliente falso, como en las lecciones anteriores: por las palabras de la pregunta pide las herramientas que tiene, y después de los resultados construye una respuesta con ellos. Su regla está abajo, en «Más precisamente, para curiosos».
Pruébalo
Rellena el hueco
¿Cuándo se detiene el ciclo e imprime la respuesta? ¿Qué falta en la comprobación?
if reply["stop_reason"] != "":tool_use. Mientras el modelo pida herramientas, el programa las ejecuta y vuelve a preguntar. Cualquier otro stop_reason (end_turn, pero también max_tokens o refusal) significa que el modelo terminó.
Pruébalo
Predice la salida
El cliente solo da las gracias. ¿Qué imprime answer?
client = FakeClient()answer(client, "Gracias, eso es todo.")Una llamada. El modelo tiene una herramienta pero no está obligado a usarla: para unas gracias no hay nada que consultar, así que responde directamente con end_turn. El ciclo no ejecuta nada y termina en la primera comprobación.
La idea
Cuando una herramienta falla
Las herramientas fallan: el número no existe, falta un campo, la base de datos no responde. El programa no lanza una excepción ni esconde el error. Se lo devuelve al modelo como un tool_result con is_error, y el modelo decide qué decir:
client = FakeClient()answer(client, "¿Dónde está el pedido 9999?")answer(client, "¿Dónde está mi paquete?")pregunta: ¿Dónde está el pedido 9999? llamada 1 (~46 tokens): tool_use call_1 get_order_status(9999) → ✗ no hay pedido 9999 llamada 2 (~55 tokens): end_turn respuesta: No encuentro ese pedido. Revisa el número, por favor.pregunta: ¿Dónde está mi paquete? llamada 1 (~45 tokens): tool_use call_2 get_order_status() → ✗ falta order_id llamada 2 (~50 tokens): end_turn respuesta: ¿Cuál es el número de tu pedido?- El pedido 9999 no existe. El modelo se lo dice al cliente en lugar de inventar un estado.
- La pregunta no tiene número. El modelo pidió la herramienta de todos modos, pero sin
order_id. La comprobación detecta el campo que falta, y el modelo le pide el número al cliente.
Algunos modelos no se detienen. Este adivina el número y pide uno tras otro:
client = FakeClient()answer(client, "No recuerdo el número, era más o menos 1050.")pregunta: No recuerdo el número, era más o menos 1050. llamada 1 (~51 tokens): tool_use call_1 get_order_status(1050) → ✗ no hay pedido 1050 llamada 2 (~60 tokens): tool_use call_2 get_order_status(1051) → ✗ no hay pedido 1051 llamada 3 (~69 tokens): tool_use call_3 get_order_status(1052) → ✗ no hay pedido 1052 llamada 4 (~78 tokens): tool_use call_4 get_order_status(1053) → ✗ no hay pedido 1053 me detengo tras 4 llamadas → a una personaPor eso el ciclo tiene un límite: como máximo 4 llamadas, y luego la conversación pasa a una persona. Sin el límite el programa da vueltas hasta que se acaba el dinero.
Pruébalo
Encuentra el error
Un colega escribe un runTool más corto. El programa envía el resultado, pero la API rechaza la segunda llamada: el tool_use no tiene respuesta. ¿Qué línea tiene el error?
Elige la línea con el error.
El resultado está atado al nombre de la herramienta, no al id de la solicitud. La API busca un tool_result cuyo tool_use_id sea igual al id de cada tool_use. El nombre no es único: dos bloques pueden pedir get_order_status para dos pedidos. En la línea con el error usa use["id"].
La idea
Ejecútalo en tu computadora
Este programa no forma parte de la comprobación de la lección: necesitas una cuenta con un proveedor de modelos, una clave y dinero para cada llamada. La clave va en LLM_API_KEY, nunca en el código. Los proveedores ofrecen SDK oficiales para Java, C#, Python y JavaScript con la misma forma (mensajes, roles, tokens, herramientas); los nombres exactos de las clases y los métodos están en la documentación del proveedor. Esto es lo que viaja por la red para la pregunta sobre el 1042. La primera solicitud lleva las herramientas, y el nombre del modelo es un marcador:
{ "model": "<nombre del modelo>", "max_tokens": 1024, "system": "Eres el asistente de soporte de una tienda en línea. Pregunta a las herramientas por los pedidos.", "tools": [ { "name": "get_order_status", "description": "Estado y fecha de entrega de un pedido según su número.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } } ], "messages": [ { "role": "user", "content": "¿Cuándo llegará el pedido 1042?" } ]}La respuesta es una solicitud de herramienta:
{ "content": [ { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1042"} } ], "stop_reason": "tool_use", "usage": {"input_tokens": 46, "output_tokens": 3}}La segunda solicitud lleva todo el historial: la pregunta, el tool_use del modelo y el tool_result del programa con el mismo id:
{ "model": "<nombre del modelo>", "max_tokens": 1024, "system": "Eres el asistente de soporte de una tienda en línea. Pregunta a las herramientas por los pedidos.", "tools": [ { "name": "get_order_status", "description": "Estado y fecha de entrega de un pedido según su número.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } } ], "messages": [ { "role": "user", "content": "¿Cuándo llegará el pedido 1042?" }, { "role": "assistant", "content": [ { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1042"} } ] }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_1", "content": "1042 — enviado, entrega 3 de noviembre" } ] } ]}{ "content": [ { "type": "text", "text": "Lo revisé: 1042 — enviado, entrega 3 de noviembre." } ], "stop_reason": "end_turn", "usage": {"input_tokens": 59, "output_tokens": 12}}Si hay un error, el resultado lleva is_error:
{ "type": "tool_result", "tool_use_id": "call_1", "content": "no hay pedido 9999", "is_error": true}Cada solicitud sale con el cliente HTTP estándar del lenguaje, y el ciclo que la rodea es el mismo que en answer. La dirección es un marcador:
# request_json es el JSON de la solicitud de arriba.# La cabecera exacta de la clave está en la documentación del proveedor.import osimport urllib.request
request = urllib.request.Request( "https://<proveedor>/messages", data=request_json.encode("utf-8"), headers={"Authorization": "Bearer " + os.environ["LLM_API_KEY"], "Content-Type": "application/json"},)with urllib.request.urlopen(request) as response: print(response.read().decode("utf-8"))Los id reales son cadenas aleatorias largas. El cliente falso los cuenta sin más: call_1, call_2.
La idea
Dónde duele
- Una herramienta que actúa en lugar de leer. «Cancela el pedido» o «devuelve el dinero» no se le da al modelo sin una persona que confirme. El modelo puede equivocarse de pedido, y aquí un error cuesta dinero.
- Demasiadas herramientas. Cada herramienta es una descripción que se envía en cada llamada. Veinte herramientas son cientos de tokens y un modelo que elige la equivocada más a menudo.
- Descripciones vagas. El modelo elige por la descripción. «Devuelve datos» no dice nada. «Estado y fecha de entrega de un pedido según su número» dice cuándo usarla.
- Confiar en la entrada.
order_idviene del modelo. El programa comprueba que el pedido existe y que pertenece a este cliente antes de mostrar nada. Si una herramienta lee texto ajeno (un correo, una página web), ese texto puede contener instrucciones para el modelo. Ese ataque (prompt injection) tiene su propia lección en el módulo de agentes. - Un ciclo sin límite. Cada llamada envía todo el historial y cuesta más que la anterior. Cuánto exactamente está en la lección sobre costo y velocidad. Si el asistente responde bien cien preguntas, no solo una, se mide con un conjunto de pruebas.
Más precisamente, para curiosos
Por qué la respuesta del modelo entra en el historial. La API no tiene memoria. En la segunda llamada el modelo solo ve lo que le envías. Un tool_result sin un tool_use antes es una respuesta sin pregunta, y la API lo rechaza.
Dos herramientas en una llamada. El modelo puede pedir varias herramientas a la vez. El programa las ejecuta todas y devuelve todos los resultados en un solo mensaje user, cada uno con su propio tool_use_id. Así hay menos llamadas al modelo.
Forzar una elección. La API también tiene un campo tool_choice. El valor por defecto, auto, deja que el modelo decida si usa una herramienta. Los modelos más nuevos no aceptan que los obliguen a usar una herramienta, por eso esta lección no lo hace.
El cliente falso por dentro. Si no hay herramientas, inventa una fecha. Si la pregunta dice «más o menos 1050», adivina número tras número. Si el último mensaje tiene resultados, construye una respuesta con ellos, y ante un error pide el número o dice que no encuentra el pedido. En otro caso pide la herramienta según el número de la pregunta: 1042 y 9999 para el estado, 1187 para una devolución. Los tokens son palabras × 1,3, y las descripciones de las herramientas se cuentan en cada llamada.
# Ocupa el lugar del modelo real: respuestas grabadas,# sin red y siempre las mismas.class FakeClient: MADE_UP = "El pedido 1042 llegará el viernes." ASK_ID = "¿Cuál es el número de tu pedido?" NOT_FOUND = "No encuentro ese pedido. Revisa el número, por favor." THANKS = "¡De nada! Escríbenos si tienes otra pregunta."
def __init__(self): self.last_id = 0
def create(self, system, messages, tools): question = messages[0]["content"][0]["text"] last = messages[-1]["content"] content = [] if not tools: content.append({"type": "text", "text": self.MADE_UP}) elif "más o menos" in question: # Adivina el siguiente número hasta que alguien lo detenga. guess = 1050 + len(messages) // 2 self.ask(content, tools, "get_order_status", str(guess)) elif last[0]["type"] == "tool_result": content.append({"type": "text", "text": self.after_results(last)}) else: if "1042" in question: self.ask(content, tools, "get_order_status", "1042") if "9999" in question: self.ask(content, tools, "get_order_status", "9999") if "1187" in question: self.ask(content, tools, "get_return_window", "1187") if "paquete" in question: self.ask(content, tools, "get_order_status", None) if not content: content.append({"type": "text", "text": self.THANKS}) input_tokens = tokens(system) input_tokens += sum(tokens(t["name"] + " " + t["description"]) for t in tools) input_tokens += sum(tokens(words(b)) for m in messages for b in m["content"]) output_tokens = sum(tokens(words(b)) for b in content) stop = "tool_use" if content[0]["type"] == "tool_use" else "end_turn" return {"content": content, "stop_reason": stop, "input_tokens": input_tokens, "output_tokens": output_tokens}
def ask(self, content, tools, name, order_id): if all(t["name"] != name for t in tools): return tool_input = {} if order_id is None else {"order_id": order_id} self.last_id += 1 content.append({"type": "tool_use", "id": f"call_{self.last_id}", "name": name, "input": tool_input})
# Un modelo real lo cuenta con otras palabras; este solo pega los resultados. def after_results(self, results): parts = [] for r in results: if r.get("is_error"): return self.ASK_ID if "order_id" in r["content"] else self.NOT_FOUND parts.append(r["content"]) return "Lo revisé: " + "; ".join(parts) + "."
def words(block): if block["type"] == "text": return block["text"] if block["type"] == "tool_use": return block["name"] + " " + " ".join(block["input"].values()) return block["content"]
# A grandes rasgos: palabras × 1,3, redondeado hacia arriba.def tokens(text): return (len(text.split()) * 13 + 9) // 10Ejercicio
El plazo de devolución
Resuélvelo en tu editor.
El cliente pregunta: «¿Cuándo llega el 1042 y hasta cuándo puedo devolver el 1187?» Ahora el modelo solo tiene get_order_status y responde la mitad:
pregunta: ¿Cuándo llega el 1042 y hasta cuándo puedo devolver el 1187? llamada 1 (~54 tokens): tool_use call_1 get_order_status(1042) → 1042 — enviado, entrega 3 de noviembre llamada 2 (~67 tokens): end_turn respuesta: Lo revisé: 1042 — enviado, entrega 3 de noviembre.Añade una segunda herramienta get_return_window con la descripción «El último día en que se puede devolver un pedido, según su número.» y la entrada order_id. En runTool devuelve 1187 — devolución hasta el … con el campo de devolución del pedido. El modelo pedirá las dos herramientas en una sola llamada:
pregunta: ¿Cuándo llega el 1042 y hasta cuándo puedo devolver el 1187? llamada 1 (~73 tokens): tool_use call_1 get_order_status(1042) → 1042 — enviado, entrega 3 de noviembre call_2 get_return_window(1187) → 1187 — devolución hasta el 9 de noviembre llamada 2 (~100 tokens): end_turn respuesta: Lo revisé: 1042 — enviado, entrega 3 de noviembre; 1187 — devolución hasta el 9 de noviembre.Más tareas
- El pedido de otra persona. Añade a cada pedido el cliente al que pertenece, y a
answerel cliente que pregunta.runTooldevuelve un error para el pedido de otro cliente. ¿Qué dice entonces el modelo? - Un presupuesto de tokens. Detén el ciclo también cuando la suma de los tokens de entrada pase de 100. ¿En qué llamada se detiene la pregunta «más o menos 1050»?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Los límites del modelo · Llamar a un modelo a través de una API · El prompt es código · Salida estructurada: JSON según un esquema
- Continúa en: El bucle del agente: plan, acción, observación · Prompt injection: el correo que da órdenes · Guardrails: comprobaciones antes y después del modelo · El asistente de soporte
- Ejercicios sobre el tema: Aplicaciones con LLM