Function calling y tool use con ejemplos en Spring AI

1. Resumen general

Un LLM solo conoce lo que hay en sus datos de entrenamiento. No sabe dónde está tu pedido ORD-1001. Las llamadas de función (también llamadas tool use) resuelven exactamente este problema. Describes al modelo algunas funciones. El modelo decide cuándo necesita invocar qué función y con qué parámetros. Tu aplicación ejecuta la función y devuelve el resultado al modelo.

Este artículo primero repasa el mecanismo a nivel JSON, luego construye un asistente de atención al cliente con Spring AI. El asistente puede consultar el estado de pedidos y cancelarlos. Después de leerlo, sabrás cómo declarar herramientas, pasar contexto de forma segura, manejar errores y escribir tests sin necesidad de llamar al modelo real.

2. Cómo funciona function calling

2.1. El bucle pregunta – invoca – responde

El punto más fácil de confundir: el modelo no ejecuta tu código por sí solo. Solo devuelve una solicitud de invocación de función, en forma de JSON estructurado. Una ronda de conversación con herramientas funciona así:

  1. La aplicación envía la pregunta del usuario, junto con una lista de herramientas (nombre, descripción, JSON Schema de los parámetros).
  2. El modelo devuelve tool_calls en lugar de una respuesta final.
  3. La aplicación ejecuta la función correspondiente y envía el resultado como un mensaje con rol tool.
  4. El modelo lee el resultado. Puede invocar más herramientas o escribir una respuesta final.

2.2. Ver el JSON raw

El formato a continuación sigue el estilo de OpenAI. Otros proveedores usan una estructura similar, solo que con nombres de campos diferentes. La solicitud incluye la sección tools:

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "¿Dónde está mi pedido ORD-1001?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "Consulta el estado y la fecha de entrega prevista de un pedido",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

El modelo no responde inmediatamente. Solicita invocar una función:

{
  "role": "assistant",
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
  }]
}

La aplicación ejecuta la función y envía de vuelta el resultado, vinculando el tool_call_id correcto:

{ "role": "tool", "tool_call_id": "call_abc123",
  "content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }

Ahora el modelo escribe la respuesta final, por ejemplo: “Tu pedido ORD-1001 ya ha sido entregado al transportista, con entrega prevista para el 30/10.”

Dos cosas a recordar. Primero, arguments es una cadena JSON generada por el modelo. Esta cadena podría tener tipos incorrectos o campos faltantes. Segundo, la calidad de la description determina si el modelo elige la herramienta correcta.

Escribir este bucle desde cero no es difícil, pero es bastante tedioso. Spring AI lo hace por ti.

3. Configurar Spring AI

Añade el BOM y el starter para OpenAI. Si usas otro proveedor, solo necesitas cambiar el starter; la parte del código de herramientas se mantiene igual.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

Configura la clave de API y el modelo:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2

Una temperatura baja ayuda al modelo a elegir herramientas de manera más estable. Para tareas de búsqueda, no necesitas “creatividad”.

4. Declarar herramientas con @Tool

Usamos un almacén de pedidos en memoria por simplicidad:

public record Order(String id, String customerId, String status, String eta) {}

public record OrderStatusReply(String orderId, String status, String eta) {}

La clase de herramientas es un bean normal. Cada método anotado con @Tool se convierte en una herramienta:

@Component
public class OrderTools {

    private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
        "ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
        "ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
    ));

    @Tool(description = "Consulta el estado y la fecha de entrega prevista de un pedido")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "Código de pedido, formato ORD-xxxx") String orderId,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        return new OrderStatusReply(order.id(), order.status(), order.eta());
    }

    @Tool(description = "Cancela un pedido que aún no ha sido entregado al transportista. Solo invocar cuando el cliente ha confirmado claramente su intención de cancelar")
    public String cancelOrder(
            @ToolParam(description = "Código de pedido, formato ORD-xxxx") String orderId,
            @ToolParam(description = "Motivo de cancelación proporcionado por el cliente", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "El pedido " + orderId + " está en estado " + order.status() + ", no se puede cancelar");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "Se ha cancelado el pedido " + orderId;
    }

    private Order findOwnedOrder(String orderId, ToolContext context) {
        String customerId = (String) context.getContext().get("customerId");
        Order order = orders.get(orderId);
        if (order == null || !order.customerId().equals(customerId)) {
            throw new IllegalArgumentException("No se encontró el pedido " + orderId);
        }
        return order;
    }
}

Algunos puntos a tener en cuenta:

  • @Tool(description = …) es exactamente el description en el JSON de la sección 2.2. Por defecto, el nombre de la herramienta es el nombre del método.
  • @ToolParam describe cada parámetro. required = false excluye el parámetro de la lista required en el schema.
  • Un parámetro de tipo ToolContext no aparece en el schema que se envía al modelo. El modelo no lo ve ni puede rellenarlo. Lo usamos para pasar el customerId desde la sesión de inicio de sesión. Así, el modelo no puede inventar un ID de cliente de otra persona.
  • El tipo de retorno OrderStatusReply se serializa a JSON por Spring AI antes de enviarlo de vuelta al modelo.
  • Para pedidos que no pertenecen al cliente actual, devolvemos “no encontrado” en lugar de “sin permiso”. Esto evita revelar que el pedido existe.

5. Invocar herramientas a través de ChatClient

@Service
public class SupportAssistant {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
            .defaultSystem("""
                Eres un asistente de atención al cliente de la tienda.
                Solo responde basándote en datos obtenidos de las herramientas, no adivines el estado de los pedidos.
                Antes de cancelar un pedido, pide confirmación al cliente.""")
            .build();
        this.orderTools = orderTools;
    }

    public String answer(String customerId, String question) {
        return chatClient.prompt()
            .user(question)
            .tools(orderTools)
            .toolContext(Map.of("customerId", customerId))
            .call()
            .content();
    }
}

.tools(orderTools) escanea todos los métodos @Tool en el bean y envía sus definiciones al modelo. Cuando el modelo devuelve tool_calls, Spring AI automáticamente hace tres cosas: parsea arguments, invoca el método correcto y luego envía el resultado de vuelta. Este bucle se repite hasta que el modelo proporciona una respuesta en texto. .content() solo devuelve la respuesta final.

Pruébalo con un pequeño controlador:

@RestController
@RequestMapping("/support")
public class SupportController {

    private final SupportAssistant assistant;

    public SupportController(SupportAssistant assistant) {
        this.assistant = assistant;
    }

    @PostMapping
    public String ask(@RequestBody String question, Principal principal) {
        return assistant.answer(principal.getName(), question);
    }
}

Cuando el cliente CUS-7 pregunta “¿Dónde está mi pedido ORD-1001?”, obtienes una respuesta como:

Tu pedido ORD-1001 ha sido entregado al transportista y se espera que llegue el 30/10/2026.

El texto exacto variará entre ejecuciones. Los datos siempre se obtienen de la herramienta.

6. Ver dentro: ToolCallback y schema

Spring AI transforma cada método @Tool en un ToolCallback. Puedes crearlos tú mismo para ver qué recibe realmente el modelo:

ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
    System.out.println(cb.getToolDefinition().name());
    System.out.println(cb.getToolDefinition().inputSchema());
}

Resultado (abreviado):

cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"Código de pedido, formato ORD-xxxx"},
 "reason":{"type":"string","description":"Motivo de cancelación proporcionado por el cliente"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"Código de pedido, formato ORD-xxxx"}},
 "required":["orderId"],...}

Como se mencionó en la sección 4, ToolContext no aparece en el schema. Si el modelo elige la herramienta equivocada, deberías imprimir el schema como se muestra arriba para verificar primero.

7. Pruebas sin llamar al modelo

Llamar al modelo real en pruebas unitarias es lento, caro e inestable. En su lugar, pruebas dos cosas: el schema que recibe el modelo y el comportamiento de la herramienta cuando recibe una cadena arguments idéntica a la que enviaría el modelo.

class OrderToolsTest {

    private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
    private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));

    private ToolCallback tool(String name) {
        return Arrays.stream(callbacks)
            .filter(cb -> cb.getToolDefinition().name().equals(name))
            .findFirst().orElseThrow();
    }

    @Test
    void schemaShouldNotExposeToolContext() {
        String schema = tool("getOrderStatus").getToolDefinition().inputSchema();

        assertThat(schema).contains("\"orderId\"").doesNotContain("context");
    }

    @Test
    void shouldReturnStatusAsJson() {
        String result = tool("getOrderStatus")
            .call("{\"orderId\":\"ORD-1001\"}", customer7);

        assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
    }

    @Test
    void shouldHideOrdersOfOtherCustomers() {
        ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));

        assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
            .hasRootCauseInstanceOf(IllegalArgumentException.class)
            .hasRootCauseMessage("No se encontró el pedido ORD-1001");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("El pedido ORD-1001 está en estado SHIPPED, no se puede cancelar");
    }

    @Test
    void shouldCancelPendingOrder() {
        String result = tool("cancelOrder")
            .call("{\"orderId\":\"ORD-1002\",\"reason\":\"Pedido equivocado\"}", customer7);

        assertThat(result).contains("Se ha cancelado el pedido ORD-1002");
    }
}

MethodToolCallback envuelve todas las excepciones del método en ToolExecutionException. Por lo tanto, las pruebas anteriores verifican la causa raíz en lugar del tipo de excepción original. Este conjunto de pruebas se ejecuta en milisegundos y verifica exactamente la parte que puedes controlar.

El comportamiento del modelo (si elige la herramienta correcta, si pide confirmación antes de cancelar) es un tipo diferente de prueba. Deberías ejecutarlo con un conjunto de evaluación separado con el modelo real, no incluirlo en pruebas unitarias.

8. Manejo de errores de herramientas

¿Qué sucede cuando cancelOrder lanza una excepción durante la ejecución? Por defecto (spring.ai.tools.throw-exception-on-error=false), Spring AI no rompe la solicitud. Toma el mensaje de la excepción y lo envía al modelo como resultado de la herramienta. El modelo lee el mensaje y lo explica al cliente:

Lamentablemente, el pedido ORD-1001 ya ha sido entregado al transportista, por lo que no se puede cancelar.
¿Deseas que te guíe a través del proceso de devolución cuando recibas el producto?

Este comportamiento es muy conveniente, pero conlleva dos consecuencias que debes tener en cuenta:

  • El mensaje de la excepción será leído por el modelo y generalmente contado al usuario. No lances excepciones que contengan stack traces, consultas SQL o información interna.
  • Si quieres que los errores de infraestructura (como una base de datos caída) se propaguen hacia arriba, configura:
spring.ai.tools.throw-exception-on-error=true

Un enfoque razonable: pasar errores de negocio (pedido ya entregado, pedido no encontrado) al modelo, pero dejar que los errores de infraestructura se propaguen para que la monitorización los detecte.

9. Errores comunes y cuándo no usar function calling

Nota: Trata los arguments generados por el modelo como entrada del usuario no confiable. La inyección de prompts podría hacer que el modelo invoque cancelOrder con cualquier código de pedido. Toda comprobación de permisos debe estar en el código de la herramienta, basada en ToolContext o el contexto de seguridad, no en las instrucciones del system prompt.

Errores comunes:

  • Descripción vaga. Escribir “Obtener pedido” es difícil de distinguir de “Obtener historial de pedidos”. Describe claramente qué hace la herramienta, cuándo usarla y cuándo no.
  • Demasiadas herramientas en una solicitud. Cada definición de herramienta consume tokens, y el modelo elige peor con listas largas. Solo pasa las herramientas relevantes para el caso de uso actual.
  • Herramientas con efectos secundarios sin paso de confirmación. Para operaciones como cancelación de pedidos, reembolsos o envío de correos, considera dividirlas en dos pasos. La herramienta inicial solo prepara y devuelve un resumen. La operación real solo se ejecuta después de que el usuario haga clic en confirmar en la UI, no solo después de que el modelo “piense” que el cliente ha aceptado.
  • Falta de idempotencia. El modelo podría invocar la misma herramienta dos veces. Diseña cancelOrder para que una segunda invocación no cause daño.
  • Devolver datos demasiado grandes. Volcar 500 líneas de historial de pedidos en el contexto desperdicia tokens y confunde al modelo. Filtra y resume en la herramienta.
  • Olvidar que cada llamada a herramienta es un round-trip adicional al modelo. Una pregunta que necesita tres herramientas puede tardar varios segundos. Mide la latencia antes de incluirla en flujos que requieren respuestas rápidas.

Cuándo no usar function calling:

  • Cuando el flujo de procesamiento es fijo y predecible (siempre consultar el pedido antes de responder). En su lugar, llama al servicio directamente e incluye el resultado en el prompt. Esto es más barato, rápido y predecible.
  • Cuando solo necesitas que el modelo devuelva datos estructurados. Usa structured output (por ejemplo, .call().entity(MyRecord.class)) en lugar de simular con una herramienta.
  • Operaciones sensibles e irreversibles (transferencias, eliminación de datos) sin un paso de confirmación humana.

10. Conclusión

Function calling no es magia. El modelo solo devuelve una solicitud de invocación de función en JSON, mientras que tu aplicación es la que la ejecuta. Spring AI hace la parte de ejecución más concisa: @Tool y @ToolParam generan el schema, ChatClient.tools(...) maneja el bucle de invocación-resultado, y ToolContext pasa datos de sesión que el modelo no puede tocar.

Lo más importante es establecer los límites de confianza correctamente. Escribe descripciones claras y verifica permisos directamente en la herramienta. Para operaciones con efectos secundarios, necesitas confirmación del usuario. Finalmente, prueba la herramienta a través de ToolCallback con cadenas JSON reales para asegurar que tu código funciona correctamente, sin importar cómo responda el modelo.

llmspring-aifunction-callingtool-usejava