Function calling / tool use por exemplo com Spring AI

1. Visão geral

Um LLM só conhece o que está em seus dados de treinamento. Ele não sabe onde está seu pedido ORD-1001. Function calling (também chamado de tool use) resolve exatamente esse problema. Você descreve para o modelo um conjunto de funções. O modelo decide quando chamar qual função, com quais parâmetros. Sua aplicação executa a função e retorna o resultado ao modelo.

Este artigo passa primeiro pelo mecanismo em nível JSON, depois constrói um assistente de atendimento ao cliente com Spring AI. Este assistente pode consultar o status do pedido e cancelar pedidos. Depois de ler, você será capaz de declarar tools, passar contexto seguro, tratar erros e escrever testes sem chamar o modelo de verdade.

2. Como function calling funciona

2.1. Loop pergunta – chamada – resposta

O ponto mais fácil de confundir: o modelo não executa seu código. Ele apenas retorna uma solicitação de chamada de função, em formato JSON estruturado. Uma rodada de conversa com tools funciona assim:

  1. A aplicação envia a pergunta do usuário, junto com a lista de tools (nome, descrição, JSON Schema dos parâmetros).
  2. O modelo retorna tool_calls em vez de uma resposta final.
  3. A aplicação executa a função correspondente e envia o resultado de volta sob a forma de uma mensagem com role tool.
  4. O modelo lê o resultado. Ele pode chamar outra tool, ou escrever a resposta final.

2.2. Vendo o JSON puro

O formato abaixo segue o estilo OpenAI. Outros provedores usam estrutura similar, apenas com nomes de campos diferentes. A requisição enviada tem a seção tools:

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "Onde está meu pedido ORD-1001?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "Consulta o status e data de entrega estimada de um pedido",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

O modelo não responde imediatamente. Ele solicita uma chamada de função:

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

A aplicação executa a função e envia o resultado de volta, vinculando o tool_call_id correto:

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

Agora o modelo escreve a resposta final, algo como: “Seu pedido ORD-1001 foi entregue à unidade de transporte, com previsão de chegada em 30/10.”

Duas coisas a lembrar. Primeiro, arguments é uma string JSON gerada pelo modelo. Essa string pode ter tipo errado ou estar faltando campos. Segundo, a qualidade da description determina se o modelo escolhe a tool certa.

Escrever esse loop manualmente não é difícil, mas é bastante verboso. Spring AI faz isso para você.

3. Instalando Spring AI

Adicione o BOM e o starter para OpenAI. Se usar outro provedor, você só precisa trocar o starter, a parte do código da tool permanece a mesma.

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

Configure a chave de API e o 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

Temperatura baixa ajuda o modelo a escolher tools de forma mais estável. Para tarefas de consulta, você não precisa de “criatividade”.

4. Declarando tools com @Tool

Usamos um repositório de pedidos em memória para simplificar:

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

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

A classe de tools é um bean comum. Cada método marcado com @Tool se torna uma tool:

@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 o status e data de entrega estimada de um pedido")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "ID do 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 um pedido que ainda não foi entregue à transportadora. Apenas chame quando o cliente tiver confirmado claramente que deseja cancelar")
    public String cancelOrder(
            @ToolParam(description = "ID do pedido, formato ORD-xxxx") String orderId,
            @ToolParam(description = "Motivo do cancelamento fornecido pelo cliente", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "O pedido " + orderId + " está em status " + order.status() + ", não pode ser cancelado");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "Pedido " + orderId + " cancelado";
    }

    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("Pedido " + orderId + " não encontrado");
        }
        return order;
    }
}

Alguns pontos dignos de nota:

  • @Tool(description = …) é exatamente a description no JSON da seção 2.2. Por padrão, o nome da tool é o nome do método.
  • @ToolParam descreve cada parâmetro. required = false remove o parâmetro da lista required no schema.
  • Um parâmetro do tipo ToolContext não aparece no schema enviado ao modelo. O modelo não o vê e não pode preenchê-lo. Usamos para passar customerId da sessão de login. Assim o modelo não pode “inventar” o ID de cliente de outra pessoa.
  • O tipo de retorno OrderStatusReply é serializado para JSON pelo Spring AI antes de ser enviado de volta ao modelo.
  • Para pedidos que não pertencem ao cliente atual, retornamos “não encontrado” em vez de “sem permissão”. Dessa forma evitamos revelar que o pedido existe.

5. Chamando tools via ChatClient

@Service
public class SupportAssistant {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
            .defaultSystem("""
                Você é um assistente de atendimento ao cliente da loja.
                Responda apenas com base em dados obtidos das tools, não adivinhe o status do pedido.
                Antes de cancelar um pedido, peça confirmação ao 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) escaneia todos os métodos @Tool no bean e envia suas definições ao modelo. Quando o modelo retorna tool_calls, Spring AI automaticamente faz três coisas: analisa arguments, chama o método correto, depois envia o resultado de volta. Este loop se repete até que o modelo responda com texto. .content() retorna apenas a resposta final.

Tente com um pequeno controller:

@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);
    }
}

Quando o cliente CUS-7 pergunta “Onde está meu pedido ORD-1001?”, você recebe um resultado como:

Seu pedido ORD-1001 foi entregue à unidade de transporte e deve chegar em 30/10/2026.

A redação exata varia entre as execuções. Os dados sempre vêm de tools.

6. Olhando por dentro: ToolCallback e schema

Spring AI transforma cada método @Tool em um ToolCallback. Você pode criar os deles para ver exatamente o que o modelo recebe:

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":"ID do pedido, formato ORD-xxxx"},
 "reason":{"type":"string","description":"Motivo do cancelamento fornecido pelo cliente"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"ID do pedido, formato ORD-xxxx"}},
 "required":["orderId"],...}

Como mencionado na seção 4, ToolContext não aparece no schema. Quando o modelo escolhe a tool errada, você deve imprimir o schema como acima para verificar primeiro.

7. Testando sem chamar o modelo

Chamar o modelo de verdade em testes unitários é lento, caro e gera resultados instáveis. Em vez disso, teste duas coisas: o schema que o modelo recebe e o comportamento da tool ao receber uma string arguments idêntica à que o modelo enviaria.

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))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("Pedido ORD-1001 não encontrado");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("O pedido ORD-1001 está em status SHIPPED, não pode ser cancelado");
    }

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

        assertThat(result).contains("Pedido ORD-1002 cancelado");
    }
}

MethodToolCallback envolve todas as exceções do seu método em ToolExecutionException. Portanto, os testes acima verificam a causa raiz em vez do tipo de exceção original. Este conjunto de testes roda em alguns milissegundos e verifica exatamente a parte que você controla.

O comportamento do modelo (escolhe a tool certa, pede confirmação antes de cancelar) é um tipo diferente de teste. Você deve executá-lo com um conjunto de avaliação separado com o modelo de verdade, não incluindo em testes unitários.

8. Tratando erros de tools

O que acontece se cancelOrder lançar uma exceção durante a execução real? Por padrão (spring.ai.tools.throw-exception-on-error=false), Spring AI não quebra a requisição. Ele pega a mensagem da exceção e a envia de volta ao modelo como um resultado de tool. O modelo lê a mensagem e explica ao cliente:

Desculpe, o pedido ORD-1001 já foi entregue à transportadora, portanto não pode mais ser cancelado.
Gostaria que eu orientasse o procedimento de troca ao receber a encomenda?

Esse comportamento é muito conveniente, mas tem duas consequências que você precisa notar:

  • A mensagem da exceção será lida pelo modelo e geralmente contada para o usuário. Não lance exceções com stack trace, queries SQL ou informações internas.
  • Se quiser que erros de infraestrutura (banco indisponível, por exemplo) passem direto para o nível acima, defina:
spring.ai.tools.throw-exception-on-error=true

Uma abordagem sensata: erros de negócio (pedido já enviado, pedido não encontrado) retornam ao modelo, enquanto erros de infraestrutura passam para monitoring.

9. Erros comuns e quando não usar

Aviso: Trate arguments gerados pelo modelo como entrada não confiável do usuário. Injeção de prompt pode fazer o modelo chamar cancelOrder com qualquer ID de pedido. Todas as verificações de permissão devem estar no código da tool, baseadas em ToolContext ou contexto de segurança, não em instruções no system prompt.

Erros comuns:

  • Descrição vaga. Escrever “Obter pedido” torna difícil para o modelo diferenciar de “Obter histórico de pedido”. Descreva claramente o que a tool faz, quando usá-la e quando não.
  • Muitas tools em uma requisição. Cada definição de tool consome tokens, e o modelo escolhe pior com listas longas. Envie apenas as tools relevantes para o caso de uso atual.
  • Tool com efeitos colaterais sem confirmação. Para operações de cancelamento, reembolso ou envio de email, considere dividir em duas etapas. A primeira tool apenas prepara e retorna um resumo. A operação real só ocorre após confirmação do usuário na UI, não apenas após o modelo “pensar” que o cliente concordou.
  • Falta de idempotência. O modelo pode chamar a mesma tool duas vezes. Projete cancelOrder de forma que a segunda chamada não cause danos.
  • Retornar dados muito grandes. Despejar 500 linhas de histórico de pedido no contexto consome tokens e confunde o modelo. Filtre e resuma dentro da tool.
  • Esquecer que cada chamada de tool é um round-trip adicional ao modelo. Uma pergunta que precisa de três tools pode levar alguns segundos. Meça a latência antes de usar em fluxos que precisam de respostas rápidas.

Quando não usar function calling:

  • O fluxo de processamento já é fixo e conhecido antecipadamente (sempre consulta o pedido antes de responder). Nesse caso, chame o serviço diretamente, depois coloque o resultado no prompt. Assim é mais barato, rápido e previsível.
  • Você só precisa que o modelo retorne dados estruturados. Use structured output (por exemplo, .call().entity(MyRecord.class)) em vez de simular com uma tool.
  • Operação sensível, irreversível (transferência de dinheiro, exclusão de dados) sem confirmação do usuário.

10. Conclusão

Function calling não é mágica. O modelo apenas retorna uma solicitação de chamada de função em JSON, e sua aplicação é quem executa. Spring AI torna a execução mais simples: @Tool e @ToolParam geram o schema, ChatClient.tools(...) gerencia o loop de chamadas e respostas, e ToolContext passa dados da sessão que o modelo não pode tocar.

O mais importante é colocar o limite de confiança no lugar certo. Escreva descrições claras e verifique permissões dentro da tool. Para operações com efeitos colaterais, exija confirmação do usuário. Por fim, teste tools via ToolCallback com strings JSON reais para ter certeza de que sua parte do código funciona, independentemente do que o modelo responde.

llmspring-aifunction-callingtool-usejava