Ir al contenido
wannacode

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:

FakeClient client = new FakeClient();
String question = "¿Cuándo llegará el pedido 1042?";
List<Message> messages =
List.of(new Message("user", List.of(new TextBlock(question))));
Reply reply = client.create(SYSTEM, messages, List.of());
System.out.println("modelo: " + textOf(reply));
Order order = ORDERS.get("1042");
System.out.println("tienda: " + order.status() + ", entrega " + order.eta());
Salida
modelo: El pedido 1042 llegará el viernes.
tienda: enviado, entrega 3 de noviembre

El 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_reason es end_turn y la conversación termina.
  • Con una solicitud. stop_reason es tool_use, y la respuesta tiene un bloque tool_use: un id, el nombre de la herramienta y una entrada, por ejemplo {"order_id": "1042"}.

Ante una solicitud el programa:

  1. Añade la respuesta del modelo al historial, con el rol assistant.
  2. Ejecuta la herramienta con sus propios datos.
  3. Devuelve el resultado como un mensaje user nuevo con un bloque tool_result. En él, tool_use_id es el mismo id, y si hay un error, is_error es verdadero.
  4. 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.

static final List<Tool> TOOLS = List.of(
new Tool("get_order_status",
"Estado y fecha de entrega de un pedido según su número.",
List.of("order_id")));
El contenido de un mensaje es una lista de bloques. Un bloque es de uno de tres tipos, y sealed dice exactamente cuáles:
sealed interface Block permits TextBlock, ToolUse, ToolResult {}
record TextBlock(String text) implements Block {}
record ToolUse(String id, String name, Map<String, String> input) implements Block {}
record ToolResult(String toolUseId, String content, boolean isError) implements Block {}
record Message(String role, List<Block> content) {}
record Reply(List<Block> content, String stopReason, int inputTokens, int outputTokens) {}
record Tool(String name, String description, List<String> required) {}
record Order(String status, String eta, String returnBy) {}

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:

static ToolResult runTool(ToolUse use) {
Tool tool = TOOLS.stream()
.filter(t -> t.name().equals(use.name()))
.findFirst().orElse(null);
if (tool == null) {
return new ToolResult(use.id(), "no hay herramienta " + use.name(), true);
}
for (String field : tool.required()) {
if (!use.input().containsKey(field)) {
return new ToolResult(use.id(), "falta " + field, true);
}
}
String orderId = use.input().get("order_id");
Order order = ORDERS.get(orderId);
if (order == null) {
return new ToolResult(use.id(), "no hay pedido " + orderId, true);
}
String text = orderId + " — " + order.status() + ", entrega " + order.eta();
return new ToolResult(use.id(), text, false);
}

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

static void answer(FakeClient client, String question) {
System.out.println("pregunta: " + question);
List<Message> messages = new ArrayList<>();
messages.add(new Message("user",
List.of(new TextBlock(question))));
for (int call = 1; call <= MAX_CALLS; call++) {
Reply reply = client.create(SYSTEM, messages, TOOLS);
System.out.printf(" llamada %d (~%d tokens): %s%n",
call, reply.inputTokens(), reply.stopReason());
if (!reply.stopReason().equals("tool_use")) {
System.out.println(" respuesta: " + textOf(reply));
return;
}
messages.add(new Message("assistant", reply.content()));
List<Block> results = new ArrayList<>();
for (Block block : reply.content()) {
if (block instanceof ToolUse use) {
ToolResult result = runTool(use);
String shown = (result.isError() ? "✗ " : "")
+ result.content();
System.out.printf(" %s %s → %s%n",
use.id(), describe(use), shown);
results.add(result);
}
}
messages.add(new Message("user", results));
}
System.out.println(" me detengo tras " + MAX_CALLS + " llamadas → a una persona");
}
Paso a paso, en palabras
  1. La pregunta es el primer mensaje. Va al modelo junto con la lista de herramientas (~46 tokens).
  2. El modelo no conoce el estado. Responde con stop_reason tool_use: «ejecuta get_order_status con order_id 1042».
  3. El programa guarda la respuesta del modelo en el historial: el resultado necesita una solicitud a la que responder.
  4. El programa ejecuta la herramienta con sus propios datos. El modelo nunca toca la tienda: solo lo pidió.
  5. El resultado vuelve como mensaje user: tool_result con el mismo id, call_1.
  6. La llamada 2 envía todo el historial otra vez: ~59 tokens en lugar de ~46.
  7. 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:

static String describe(ToolUse use) {
return use.name() + "(" + use.input().getOrDefault("order_id", "") + ")";
}
static String textOf(Reply reply) {
for (Block block : reply.content()) {
if (block instanceof TextBlock t) {
return t.text();
}
}
return "";
}
static void answer(FakeClient client, String question) {
System.out.println("pregunta: " + question);
List<Message> messages = new ArrayList<>();
messages.add(new Message("user",
List.of(new TextBlock(question))));
for (int call = 1; call <= MAX_CALLS; call++) {
Reply reply = client.create(SYSTEM, messages, TOOLS);
System.out.printf(" llamada %d (~%d tokens): %s%n",
call, reply.inputTokens(), reply.stopReason());
if (!reply.stopReason().equals("tool_use")) {
System.out.println(" respuesta: " + textOf(reply));
return;
}
messages.add(new Message("assistant", reply.content()));
List<Block> results = new ArrayList<>();
for (Block block : reply.content()) {
if (block instanceof ToolUse use) {
ToolResult result = runTool(use);
String shown = (result.isError() ? "✗ " : "")
+ result.content();
System.out.printf(" %s %s → %s%n",
use.id(), describe(use), shown);
results.add(result);
}
}
messages.add(new Message("user", results));
}
System.out.println(" me detengo tras " + MAX_CALLS + " llamadas → a una persona");
}
FakeClient client = new FakeClient();
answer(client, "¿Cuándo llegará el pedido 1042?");
Salida
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.stopReason().equals("")) {

Pruébalo

Predice la salida

El cliente solo da las gracias. ¿Qué imprime answer?

FakeClient client = new FakeClient();
answer(client, "Gracias, eso es todo.");
Respuesta

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:

FakeClient client = new FakeClient();
answer(client, "¿Dónde está el pedido 9999?");
answer(client, "¿Dónde está mi paquete?");
Salida
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:

FakeClient client = new FakeClient();
answer(client, "No recuerdo el número, era más o menos 1050.");
Salida
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 persona

Por 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.

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:

// requestJson es el JSON de la solicitud de arriba.
// La cabecera exacta de la clave está en la documentación del proveedor.
HttpClient http = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://<proveedor>/messages"))
.header("Authorization", "Bearer " + System.getenv("LLM_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

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_id viene 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 {
static final String MADE_UP = "El pedido 1042 llegará el viernes.";
static final String ASK_ID = "¿Cuál es el número de tu pedido?";
static final String NOT_FOUND = "No encuentro ese pedido. Revisa el número, por favor.";
static final String THANKS = "¡De nada! Escríbenos si tienes otra pregunta.";
private int lastId = 0;
Reply create(String system, List<Message> messages, List<Tool> tools) {
String question = ((TextBlock) messages.get(0).content().get(0)).text();
List<Block> last = messages.get(messages.size() - 1).content();
List<Block> content = new ArrayList<>();
if (tools.isEmpty()) {
content.add(new TextBlock(MADE_UP));
} else if (question.contains("más o menos")) {
// Adivina el siguiente número hasta que alguien lo detenga.
int guess = 1050 + messages.size() / 2;
ask(content, tools, "get_order_status", String.valueOf(guess));
} else if (last.get(0) instanceof ToolResult) {
content.add(new TextBlock(afterResults(last)));
} else {
if (question.contains("1042")) {
ask(content, tools, "get_order_status", "1042");
}
if (question.contains("9999")) {
ask(content, tools, "get_order_status", "9999");
}
if (question.contains("1187")) {
ask(content, tools, "get_return_window", "1187");
}
if (question.contains("paquete")) {
ask(content, tools, "get_order_status", null);
}
if (content.isEmpty()) {
content.add(new TextBlock(THANKS));
}
}
int input = tokens(system);
for (Tool t : tools) {
input += tokens(t.name() + " " + t.description());
}
for (Message m : messages) {
for (Block b : m.content()) {
input += tokens(words(b));
}
}
int output = 0;
for (Block b : content) {
output += tokens(words(b));
}
String stop = content.get(0) instanceof ToolUse ? "tool_use" : "end_turn";
return new Reply(content, stop, input, output);
}
private void ask(List<Block> content, List<Tool> tools, String name, String orderId) {
if (tools.stream().noneMatch(t -> t.name().equals(name))) {
return;
}
Map<String, String> input = orderId == null ? Map.of() : Map.of("order_id", orderId);
lastId++;
content.add(new ToolUse("call_" + lastId, name, input));
}
// Un modelo real lo cuenta con otras palabras; este solo pega los resultados.
private static String afterResults(List<Block> results) {
List<String> parts = new ArrayList<>();
for (Block b : results) {
ToolResult r = (ToolResult) b;
if (r.isError()) {
return r.content().contains("order_id") ? ASK_ID : NOT_FOUND;
}
parts.add(r.content());
}
return "Lo revisé: " + String.join("; ", parts) + ".";
}
static String words(Block block) {
return switch (block) {
case TextBlock t -> t.text();
case ToolUse u -> u.name() + " " + String.join(" ", u.input().values());
case ToolResult r -> r.content();
};
}
// A grandes rasgos: palabras × 1,3, redondeado hacia arriba.
static int tokens(String text) {
String t = text.strip();
int words = t.isEmpty() ? 0 : t.split("\\s+").length;
return (words * 13 + 9) / 10;
}
}
Los records por dentro. sealed interface Block permits … le dice al compilador que hay exactamente tres bloques. Por eso el switch de words no tiene default: si añades un cuarto tipo de bloque, el código no compila hasta que lo trates. instanceof ToolUse use comprueba el tipo y da el nombre use de una vez.

Ejercicio

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:

Salida
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:

Salida
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 answer el cliente que pregunta. runTool devuelve 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

1. El modelo respondió con stop_reason tool_use y un bloque get_order_status con order_id 1042. ¿Quién busca el pedido en la base de datos?
2. Una respuesta tiene dos bloques tool_use. ¿Cómo sabe el modelo qué resultado corresponde a cada solicitud?
3. El cliente pregunta por dos cosas y el modelo pide las dos herramientas en una sola respuesta. ¿Cuántas llamadas al modelo hace answer()?
4. ¿Por qué el ciclo tiene un límite, MAX_CALLS?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·