Ir al contenido
wannacode

El bucle del agente: plan, acción, observación

En resumen: Un agente de IA es un bucle: el modelo dice un plan y pide una acción, el programa la ejecuta y devuelve la observación, hasta que hay una respuesta, se alcanza el límite de pasos o hace falta una persona.

Problema

El asistente responde, pero no hace el trabajo

El cliente escribe: «Los audífonos del pedido 1187 están dañados. Quiero devolverlos.»

No es una pregunta con una sola respuesta. Alguien tiene que comprobar que el pedido fue entregado, abrir una solicitud de devolución y decirle al cliente qué pasa después. Si el pedido todavía no está entregado, el camino es otro. Si el cliente quiere una compensación, es otro distinto.

El bucle de la lección sobre el uso de herramientas respondía una pregunta: el modelo pide datos, el programa los da, el modelo responde. Aquí tienen que suceder varias acciones una tras otra, y cuál sigue solo se sabe después de la anterior.

Puedes escribirlo con un if para cada caso. Funciona para los casos que previste. Los clientes también escriben los que no previste.

La idea

El agente: plan, acción, observación

Un agente es un programa en el que el modelo elige el siguiente paso hacia una meta. Cada paso son tres cosas:

  1. Plan. El modelo dice con palabras lo que hará: «primero compruebo si el pedido fue entregado».
  2. Acción. El modelo pide una herramienta (un bloque tool_use), y el programa la ejecuta.
  3. Observación. El resultado vuelve al historial (un bloque tool_result). En el siguiente paso el modelo lo ve y elige de nuevo.

Este es el bucle del agente. El programa mantiene tres salidas:

  • Respuesta. El modelo terminó y devuelve end_turn.
  • Límite de pasos. Tras 5 pasos sin respuesta, el programa se detiene y pasa la conversación a un colega.
  • Hace falta una persona. El propio modelo pide la herramienta hand_to_human. El programa no la ejecuta; se detiene.

Las herramientas del agente son seguras: el estado del pedido, la fecha de entrega del mensajero y una solicitud de devolución. La solicitud solo espera a un colega. El dinero nunca pasa por el agente.

Míralo

Las herramientas y los datos

Las cuatro herramientas. La descripción es para el modelo: elige según ella.

static final List<Tool> TOOLS = List.of(
new Tool("get_order_status",
"Estado y fecha de un pedido según su número.", "order_id"),
new Tool("estimate_delivery",
"La fecha de entrega que espera el mensajero.", "order_id"),
new Tool("open_return_request",
"Abre una solicitud de devolución. Un colega la aprueba.", "order_id"),
new Tool("hand_to_human",
"Pasa la conversación a un colega, con el motivo.", "reason"));

El programa ejecuta lo que se pidió contra sus propios datos. La solicitud de devolución va a una lista que revisa un colega:

static ToolResult runTool(ToolUse use) {
String orderId = use.input().getOrDefault("order_id", "");
Order order = ORDERS.get(orderId);
if (order == null) {
return new ToolResult(use.id(), "no hay pedido " + orderId, true);
}
boolean delivered = order.status().equals(DELIVERED);
String text;
switch (use.name()) {
case "get_order_status" -> text = orderId + " — " + order.status()
+ ", entrega " + order.date();
case "estimate_delivery" -> text = delivered
? orderId + " — ya entregado"
: orderId + " — entrega esperada " + order.date() + ", sin nuevo escaneo";
case "open_return_request" -> {
if (!delivered) {
return new ToolResult(use.id(), orderId + " todavía no está entregado", true);
}
String request = "R-" + (REQUESTS.size() + 1) + " para " + orderId;
REQUESTS.add(request);
text = request + " — espera la aprobación de un colega";
}
default -> {
return new ToolResult(use.id(), "no hay herramienta " + use.name(), true);
}
}
return new ToolResult(use.id(), text, false);
}

hand_to_human no está aquí. No es una acción, sino una salida del bucle.

Míralo

El bucle

static void runAgent(FakeModel model, String task) {
System.out.println("tarea: " + task);
List<Message> history = new ArrayList<>();
history.add(new Message("user",
List.of(new TextBlock(task))));
for (int step = 1; step <= MAX_STEPS; step++) {
var reply = model.create(SYSTEM, history, TOOLS);
String head = " paso " + step + ". ";
String text = textOf(reply);
if (!reply.stopReason().equals("tool_use")) {
System.out.println(head + "fin: " + text);
return;
}
ToolUse use = toolUseOf(reply);
System.out.println(head + "plan: " + text);
String action = describe(use);
System.out.println(" acción: " + action);
if (use.name().equals("hand_to_human")) {
System.out.println(" me detengo: hace falta una persona — "
+ use.input().get("reason"));
return;
}
history.add(new Message("assistant",
reply.content()));
ToolResult result = runTool(use);
System.out.println(" observación: "
+ result.content());
history.add(new Message("user", List.of(result)));
}
System.out.println(" me detengo: " + MAX_STEPS + " "
+ "pasos sin respuesta → a un colega");
}
Paso a paso, en palabras
  1. La tarea es el primer mensaje. El agente tiene 5 pasos (MAX_STEPS) y cuatro herramientas.
  2. Paso 1. El modelo dice un plan y pide una acción: get_order_status(1187).
  3. No es hand_to_human, así que el programa la ejecuta. La observación entra en el historial.
  4. Paso 2. El modelo ve «entregado», así que el plan cambia: open_return_request(1187).
  5. La herramienta solo abre la solicitud R-1. Espera a un colega: el agente nunca devuelve dinero.
  6. Paso 3. El modelo responde con end_turn: el bucle termina con una respuesta, después de 3 de 5 pasos.
  7. Quedan listas dos salidas más: el límite de pasos y hand_to_human. Sin ellas el agente puede dar vueltas sin fin.

Las funciones auxiliares encuentran el plan (el bloque de texto) y la acción (el bloque tool_use):

static String textOf(Reply reply) {
for (Block block : reply.content()) {
if (block instanceof TextBlock t) {
return t.text();
}
}
return "";
}
static ToolUse toolUseOf(Reply reply) {
for (Block block : reply.content()) {
if (block instanceof ToolUse use) {
return use;
}
}
return null;
}
static String describe(ToolUse use) {
return use.name() + "(" + String.join(", ", use.input().values()) + ")";
}
static void runAgent(FakeModel model, String task) {
System.out.println("tarea: " + task);
List<Message> history = new ArrayList<>();
history.add(new Message("user",
List.of(new TextBlock(task))));
for (int step = 1; step <= MAX_STEPS; step++) {
var reply = model.create(SYSTEM, history, TOOLS);
String head = " paso " + step + ". ";
String text = textOf(reply);
if (!reply.stopReason().equals("tool_use")) {
System.out.println(head + "fin: " + text);
return;
}
ToolUse use = toolUseOf(reply);
System.out.println(head + "plan: " + text);
String action = describe(use);
System.out.println(" acción: " + action);
if (use.name().equals("hand_to_human")) {
System.out.println(" me detengo: hace falta una persona — "
+ use.input().get("reason"));
return;
}
history.add(new Message("assistant",
reply.content()));
ToolResult result = runTool(use);
System.out.println(" observación: "
+ result.content());
history.add(new Message("user", List.of(result)));
}
System.out.println(" me detengo: " + MAX_STEPS + " "
+ "pasos sin respuesta → a un colega");
}
FakeModel model = new FakeModel();
runAgent(model, "Los audífonos del pedido 1187 están dañados. Quiero devolverlos.");
System.out.println("espera aprobación: " + String.join(", ", REQUESTS));
Salida
tarea: Los audífonos del pedido 1187 están dañados. Quiero devolverlos.
paso 1. plan: Primero compruebo si el pedido fue entregado.
acción: get_order_status(1187)
observación: 1187 — entregado, entrega 26 de octubre
paso 2. plan: Fue entregado. Abro una solicitud de devolución.
acción: open_return_request(1187)
observación: R-1 para 1187 — espera la aprobación de un colega
paso 3. fin: Abrí la solicitud R-1 para devolver el pedido 1187. Un colega la aprobará y te escribirá.
espera aprobación: R-1 para 1187

En ninguna parte el código dice «después del estado, abre una solicitud». El modelo lo elige porque la observación dice «1187 — entregado, entrega 26 de octubre». El programa mantiene el límite, ejecuta y registra.

El modelo de aquí es un cliente falso, como en las lecciones anteriores: según las palabras de la tarea y las observaciones hasta ahora, elige el siguiente paso. Su regla está en «Más».

Pruébalo

Completa

¿Qué acción no ejecuta el programa, sino que detiene el bucle?

if (use.name().equals("")) {

La idea

Cuando el agente no se detiene

El cliente escribe «¿Cuándo llega 1042? El mensajero dijo que hoy.». El mensajero no tiene ningún escaneo nuevo, y el modelo pregunta una y otra vez:

FakeModel model = new FakeModel();
runAgent(model, "¿Cuándo llega 1042? El mensajero dijo que hoy.");
Salida
tarea: ¿Cuándo llega 1042? El mensajero dijo que hoy.
paso 1. plan: Le pregunto al mensajero por la fecha.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
paso 2. plan: Sin cambios. Pregunto otra vez.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
paso 3. plan: Sin cambios. Pregunto otra vez.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
paso 4. plan: Sin cambios. Pregunto otra vez.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
paso 5. plan: Sin cambios. Pregunto otra vez.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
me detengo: 5 pasos sin respuesta → a un colega

Cada paso envía todo el historial, así que cada paso cuesta más que el anterior (los números están en la lección sobre el costo). Sin el límite, el programa da vueltas hasta que se acaba el dinero. Con él, el cliente llega a una persona después de 5 pasos.

El límite es la última defensa, no la única. Aquí el modelo pide lo mismo cinco veces, y el programa podría haberlo visto en el segundo paso. Ese es el ejercicio de la lección.

Pruébalo

Predice la salida

El cliente quiere una compensación. ¿Qué imprime runAgent?

FakeModel model = new FakeModel();
runAgent(model, "Quiero 20 € de compensación porque 1042 se retrasa.");
Respuesta

Pruébalo

Encuentra el error

Un colega escribe runAgent, y cada conversación termina con «5 pasos sin respuesta», incluso cuando el cliente recibió una respuesta. ¿En qué línea está 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 está 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 clases y métodos están en la documentación del proveedor.

La primera solicitud lleva la tarea y las cuatro herramientas. El nombre del modelo es un marcador:

{
"model": "<nombre del modelo>",
"max_tokens": 1024,
"system": "Eres el agente de soporte de una tienda en línea. Usa las herramientas; sobre el dinero decide una persona.",
"tools": [
{
"name": "get_order_status",
"description": "Estado y fecha de un pedido según su número.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "estimate_delivery",
"description": "La fecha de entrega que espera el mensajero.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "open_return_request",
"description": "Abre una solicitud de devolución. Un colega la aprueba.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "hand_to_human",
"description": "Pasa la conversación a un colega, con el motivo.",
"input_schema": {
"type": "object",
"properties": {"reason": {"type": "string"}},
"required": ["reason"]
}
}
],
"messages": [
{
"role": "user",
"content": "Los audífonos del pedido 1187 están dañados. Quiero devolverlos."
}
]
}

La respuesta trae tanto el plan como la acción: un bloque de texto y un bloque tool_use en un mismo mensaje. Los números de usage son aproximados, según la regla de la lección sobre el uso de herramientas:

{
"content": [
{
"type": "text",
"text": "Primero compruebo si el pedido fue entregado."
},
{
"type": "tool_use",
"id": "call_1",
"name": "get_order_status",
"input": {"order_id": "1187"}
}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 88, "output_tokens": 12}
}

Cada solicitud se envía con el cliente HTTP estándar del lenguaje, y el bucle que la rodea es runAgent. 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());

La idea

Cuándo duele

  • El camino es conocido. Si «estado del pedido» siempre es la misma solicitud, escríbela en el código. El agente es más lento, cuesta más y puede elegir algo distinto cada vez. Vale la pena cuando los pasos dependen de lo que aparece por el camino.
  • Acciones con consecuencias. Reembolsos, cancelaciones, un correo a todos los clientes. El agente las prepara, una persona las aprueba. El modelo puede elegir el pedido equivocado, y el texto que lee puede engañarlo. Cómo ocurre eso está en la lección sobre prompt injection, y las comprobaciones alrededor del modelo están en la lección sobre guardrails.
  • Sin límite. Un límite de pasos, de tokens (o euros) y de tiempo. Cada uno detiene al agente y pasa la conversación a una persona en vez de dar vueltas.
  • Sin registro. Cuando el agente se equivoca, necesitas ver el plan, la acción y la observación de cada paso. La salida de runAgent es justo ese registro.
  • Sin pruebas. Un agente se mide como cualquier app con un modelo: un conjunto de pruebas de tareas con comprobaciones de la respuesta final, pero también del camino (qué acciones, cuántos pasos).
Más precisamente, para curiosos

Una acción por paso. Un modelo real puede pedir varias herramientas en una respuesta, como en la lección sobre el uso de herramientas. Aquí cada paso es una acción para que el orden se vea. Un programa con varias acciones las ejecuta todas y devuelve todos los resultados en un mensaje user.

El plan es texto. El modelo escribe «primero compruebo», pero nada lo obliga a hacer justo eso. El programa no confía en el plan; ejecuta solo la acción, y solo si está en la lista de herramientas. El plan sirve para el registro y para la persona que lo lee después.

Un nombre de los artículos. El ritmo «pensamiento, acción, observación» se conoce como ReAct (reason + act). También hay bibliotecas de agentes que ejecutan el bucle por ti. Por dentro son las mismas tres cosas y las mismas salidas.

El modelo falso por dentro. Junta las observaciones del historial y las cuenta. Para una devolución: primero el estado; si es «entregado», una solicitud; luego una respuesta con el número de la solicitud. Para una compensación: el estado y luego hand_to_human. Para el mensajero: pregunta de nuevo en cada paso. Los ids de tool_use son call_1, call_2.

// Hace las veces del modelo real: respuestas grabadas,
// sin red y siempre las mismas.
class FakeModel {
static final String NOT_DELIVERED = "El pedido todavía no está entregado. Podrás devolverlo después de la entrega.";
static final String THANKS = "¡De nada! Escribe si tienes una pregunta.";
private int lastId = 0;
Reply create(String system, List<Message> messages, List<Tool> tools) {
String task = ((TextBlock) messages.get(0).content().get(0)).text();
List<String> seen = new ArrayList<>();
for (Message m : messages) {
for (Block b : m.content()) {
if (b instanceof ToolResult r) {
seen.add(r.content());
}
}
}
int n = seen.size();
String id = task.contains("1187") ? "1187" : "1042";
if (task.contains("devolver")) {
if (n == 0) {
return act("Primero compruebo si el pedido fue entregado.", "get_order_status", "order_id", id);
}
if (n == 1 && seen.get(0).contains("entregado")) {
return act("Fue entregado. Abro una solicitud de devolución.", "open_return_request", "order_id", id);
}
if (n == 1) {
return say(NOT_DELIVERED);
}
String request = seen.get(n - 1).split(" ")[0];
return say("Abrí la solicitud " + request + " para devolver el pedido " + id
+ ". Un colega la aprobará y te escribirá.");
}
if (task.contains("compensación")) {
if (n == 0) {
return act("Compruebo el pedido.", "get_order_status", "order_id", id);
}
return act("Una persona decide sobre la compensación, no yo.", "hand_to_human", "reason", "compensación por " + id);
}
if (task.contains("mensajero")) {
String plan = n == 0 ? "Le pregunto al mensajero por la fecha." : "Sin cambios. Pregunto otra vez.";
return act(plan, "estimate_delivery", "order_id", id);
}
return say(THANKS);
}
// Un plan (texto) y una acción (tool_use) en una respuesta.
private Reply act(String plan, String tool, String key, String value) {
lastId++;
ToolUse use = new ToolUse("call_" + lastId, tool, Map.of(key, value));
return new Reply(List.of(new TextBlock(plan), use), "tool_use");
}
private static Reply say(String text) {
return new Reply(List.of(new TextBlock(text)), "end_turn");
}
}
Los tipos. Los mismos records que en la lección sobre el uso de herramientas: sealed interface Block con tres tipos de bloque. El switch de runTool usa flechas, así que no hay break, y default devuelve un error para una herramienta desconocida.

Ejercicio

La misma acción dos veces

Resuélvelo en tu editor.

El cliente escribe «¿Cuándo llega 1042? El mensajero dijo que hoy.», y el agente le pregunta cinco veces al mensajero antes de detenerse. Añade una comprobación a runAgent: si el modelo pide la misma acción que en el paso anterior (el mismo nombre y la misma entrada), el programa imprime me detengo: la misma acción dos veces → a un colega y se detiene.

Salida
tarea: ¿Cuándo llega 1042? El mensajero dijo que hoy.
paso 1. plan: Le pregunto al mensajero por la fecha.
acción: estimate_delivery(1042)
observación: 1042 — entrega esperada 3 de noviembre, sin nuevo escaneo
paso 2. plan: Sin cambios. Pregunto otra vez.
acción: estimate_delivery(1042)
me detengo: la misma acción dos veces → a un colega

Más tareas

  • Un presupuesto de tokens. Cuenta los tokens de cada paso (palabras × 1,3, como en la lección sobre el uso de herramientas) y detente cuando el total pase de 300. ¿En qué paso se detiene la tarea del mensajero?
  • Un pedido todavía no entregado. Ejecuta una tarea de devolución para el pedido 1042, que todavía no está entregado. ¿Qué salida toma el agente y qué le dice al cliente?

Test

Ponte a prueba

1. ¿Qué diferencia el bucle del agente de una sola solicitud al modelo?
2. ¿Cuáles son las tres salidas de runAgent?
3. ¿Por qué open_return_request solo abre una solicitud en vez de devolver el dinero?
4. El modelo pide estimate_delivery(1042) por quinta vez y la observación no cambia. ¿Qué debe hacer el programa?

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