Function calling / tool use par l'exemple avec Spring AI

1. Aperçu

Un LLM ne connaît que ce qui se trouve dans ses données d’entraînement. Il ne sait pas où se trouve votre commande ORD-1001. Function calling (aussi appelé tool use) résout exactement ce problème. Vous décrivez au modèle un certain nombre de fonctions. Le modèle décide quand appeler quelle fonction, avec quels paramètres. Votre application exécute la fonction et renvoie le résultat au modèle.

Cet article passe d’abord par le mécanisme au niveau JSON, puis construit un assistant de support client avec Spring AI. Cet assistant peut rechercher l’état des commandes et annuler des commandes. Après l’avoir lu, vous serez capable de déclarer des outils, de passer un contexte sécurisé, de gérer les erreurs et d’écrire des tests sans appeler le modèle réel.

2. Comment function calling fonctionne

2.1. La boucle demander – appeler – répondre

Le point le plus souvent mal compris : le modèle n’exécute pas votre code lui-même. Il retourne simplement une demande d’appel de fonction, sous forme de JSON structuré. Un tour de conversation avec des outils se déroule comme suit :

  1. L’application envoie la question de l’utilisateur, accompagnée d’une liste d’outils (nom, description, schéma JSON des paramètres).
  2. Le modèle retourne tool_calls au lieu d’une réponse finale.
  3. L’application exécute la fonction correspondante et renvoie le résultat sous forme de message avec le rôle tool.
  4. Le modèle lit le résultat. Il peut appeler d’autres outils ou écrire une réponse finale.

2.2. Regarder le JSON brut

Le format ci-dessous suit le style OpenAI. Les autres fournisseurs utilisent une structure similaire, seuls les noms de champs diffèrent. La requête envoyée comporte une section tools :

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "Où est ma commande ORD-1001 ?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "Rechercher le statut et la date de livraison estimée d'une commande",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

Le modèle ne répond pas immédiatement. Il demande à appeler une fonction :

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

L’application exécute la fonction et renvoie le résultat, en joignant le tool_call_id correct :

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

À ce moment, le modèle écrit une réponse finale, par exemple : “Votre commande ORD-1001 a été confiée à la société de transport et devrait arriver le 30/10.”

Deux choses à retenir. D’abord, arguments est une chaîne JSON générée par le modèle. Cette chaîne peut avoir un type incorrect ou des champs manquants. Deuxièmement, la qualité de description détermine si le modèle choisit le bon outil ou non.

Écrire cette boucle soi-même n’est pas difficile mais assez verbeux. Spring AI le fait déjà pour vous.

3. Configuration de Spring AI

Ajoutez le BOM et le starter pour OpenAI. Si vous utilisez un autre fournisseur, vous n’avez qu’à changer le starter, la partie code des outils reste la même.

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

Configurez la clé API et le modèle :

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

Une température faible aide le modèle à choisir les outils plus régulièrement. Pour des tâches de recherche, vous n’avez pas besoin de “créativité”.

4. Déclarer un outil avec @Tool

Nous utilisons un référentiel de commandes en mémoire par souci de simplicité :

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

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

La classe d’outil est un bean ordinaire. Chaque méthode marquée avec @Tool deviendra un outil :

@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 = "Rechercher le statut et la date de livraison estimée d'une commande")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "Numéro de commande, format ORD-xxxx") String orderId,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        return new OrderStatusReply(order.id(), order.status(), order.eta());
    }

    @Tool(description = "Annuler une commande qui n'a pas encore été confiée au transporteur. À appeler uniquement si le client a confirmé clairement son intention d'annuler")
    public String cancelOrder(
            @ToolParam(description = "Numéro de commande, format ORD-xxxx") String orderId,
            @ToolParam(description = "Raison de l'annulation fournie par le client", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "La commande " + orderId + " a le statut " + order.status() + ", impossible d'annuler");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "Commande " + orderId + " annulée";
    }

    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("Commande " + orderId + " non trouvée");
        }
        return order;
    }
}

Quelques points à noter :

  • @Tool(description = …) est exactement description dans le JSON à la section 2.2. Par défaut, le nom de l’outil est le nom de la méthode.
  • @ToolParam décrit chaque paramètre. required = false exclut le paramètre de la liste required dans le schéma.
  • Un paramètre de type ToolContext n’apparaît pas dans le schéma envoyé au modèle. Le modèle ne le voit pas et ne peut pas le remplir. Nous l’utilisons pour transmettre customerId depuis la session de connexion. Cela empêche le modèle de “fabriquer” l’ID client de quelqu’un d’autre.
  • Le type de retour OrderStatusReply est sérialisé par Spring AI en JSON avant d’être renvoyé au modèle.
  • Pour une commande qui n’appartient pas au client actuel, nous retournons “non trouvée” plutôt que “pas d’autorisation”. Cela évite de révéler l’existence de la commande.

5. Appeler un outil 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("""
                Vous êtes l'assistant de support client du magasin.
                Répondez uniquement en fonction des données obtenues via l'outil, ne devinez pas le statut de la commande.
                Avant d'annuler une commande, demandez une confirmation au client.""")
            .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) scanne chaque méthode @Tool dans le bean et envoie sa définition au modèle. Quand le modèle retourne tool_calls, Spring AI fait automatiquement trois choses : analyser arguments, appeler la bonne méthode, puis renvoyer le résultat. Cette boucle se répète jusqu’à ce que le modèle réponde par du texte. .content() ne retourne que la réponse finale.

Essayez avec un petit contrôleur :

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

Quand le client CUS-7 demande “Où est ma commande ORD-1001 ?”, vous recevez un résultat du type :

Votre commande ORD-1001 a été confiée au transporteur et devrait arriver le 30/10/2026.

Le libellé exact variera d’une exécution à l’autre. Les données proviennent toujours de l’outil.

6. Voir à l’intérieur : ToolCallback et schéma

Spring AI transforme chaque méthode @Tool en un ToolCallback. Vous pouvez les créer vous-même pour voir ce que le modèle reçoit réellement :

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

Résultat (abrégé) :

cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"Numéro de commande, format ORD-xxxx"},
 "reason":{"type":"string","description":"Raison de l'annulation fournie par le client"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"Numéro de commande, format ORD-xxxx"}},
 "required":["orderId"],...}

Comme mentionné à la section 4, ToolContext ne figure pas dans le schéma. Quand le modèle choisit le mauvais outil, vous devriez imprimer le schéma comme ci-dessus pour vérifier d’abord.

7. Tester sans appeler le modèle

Appeler le modèle réel dans un test unitaire est lent, coûteux et donne des résultats instables. Au lieu de cela, testez deux choses : le schéma que le modèle reçoit et le comportement de l’outil lorsqu’il reçoit une chaîne arguments identique à celle que le modèle enverra.

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("Commande ORD-1001 non trouvée");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("La commande ORD-1001 a le statut SHIPPED, impossible d'annuler");
    }

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

        assertThat(result).contains("Commande ORD-1002 annulée");
    }
}

MethodToolCallback encapsule toute exception de votre méthode dans ToolExecutionException. Par conséquent, les tests ci-dessus vérifient la cause racine au lieu du type d’exception originel. Cette suite de tests s’exécute en quelques millisecondes et teste exactement la partie que vous contrôlez.

Le comportement du modèle (s’il choisit le bon outil, s’il demande une confirmation avant d’annuler) relève d’un type de test différent. Vous devriez l’exécuter avec une suite d’évaluation séparée utilisant le modèle réel, et non l’inclure dans les tests unitaires.

8. Gérer les erreurs des outils

Que se passe-t-il lorsque cancelOrder lève une exception lors de l’exécution réelle ? Par défaut (spring.ai.tools.throw-exception-on-error=false), Spring AI ne casse pas la demande. Il prend le message de l’exception et le renvoie au modèle comme résultat d’outil. Le modèle lit le message et l’explique au client :

Malheureusement, la commande ORD-1001 a déjà été confiée au transporteur et ne peut donc pas être annulée.
Voulez-vous que je vous guide sur la procédure d'échange à la réception ?

Ce comportement est très pratique mais a deux conséquences à noter :

  • Le message de l’exception sera lu par le modèle et généralement relayé à l’utilisateur. Ne levez pas d’exceptions contenant des stack traces, des requêtes SQL ou des informations internes.
  • Si vous voulez que les erreurs d’infrastructure (par exemple, base de données en panne) remontent directement, utilisez :
spring.ai.tools.throw-exception-on-error=true

Une approche raisonnable : retournez les erreurs métier (commande déjà livrée, commande non trouvée) au modèle, mais laissez les erreurs d’infrastructure remonter pour que la surveillance les détecte.

9. Erreurs courantes et quand ne pas utiliser function calling

Attention : Traitez les arguments générés par le modèle comme des données non fiables de la part de l’utilisateur. L’injection de prompt pourrait amener le modèle à appeler cancelOrder avec n’importe quel numéro de commande. Toutes les vérifications d’autorisation doivent se trouver dans le code de l’outil, basées sur ToolContext ou le contexte de sécurité, et non sur les instructions du system prompt.

Erreurs courantes :

  • Description vague. Si vous écrivez “Obtenir une commande”, le modèle a du mal à le distinguer de “Obtenir l’historique des commandes”. Décrivez clairement ce que fait l’outil, quand l’utiliser et quand ne pas l’utiliser.
  • Trop d’outils dans une requête. Chaque définition d’outil coûte des tokens, et le modèle choisit moins bien quand la liste est longue. Ne passez que les outils pertinents pour le cas d’usage actuel.
  • Outil avec effet secondaire sans étape de confirmation. Pour les opérations comme l’annulation de commande, les remboursements ou l’envoi d’e-mails, envisagez de diviser en deux étapes. Le premier outil prépare simplement et retourne un résumé. L’opération réelle s’exécute uniquement après que l’utilisateur clique sur confirmer dans l’interface, pas simplement parce que le modèle “pense” que le client a accepté.
  • Manque d’idempotence. Le modèle peut appeler le même outil deux fois. Concevez cancelOrder pour que le deuxième appel ne cause aucun dégât.
  • Retourner trop de données. Verser 500 lignes d’historique de commandes dans le contexte coûte des tokens et fait “perdre” le modèle. Filtrez et résumez dans l’outil.
  • Oublier que chaque appel d’outil est un aller-retour supplémentaire vers le modèle. Une question nécessitant trois outils peut prendre quelques secondes. Mesurez la latence avant de l’ajouter aux flux nécessitant une réponse rapide.

Quand ne pas utiliser function calling :

  • Le flux de traitement est déjà fixe et prévisible (toujours rechercher d’abord la commande avant de répondre). Dans ce cas, appelez directement le service, puis mettez le résultat dans le prompt. C’est moins cher, plus rapide et plus prévisible.
  • Vous n’avez besoin que le modèle retourne des données structurées. Utilisez la sortie structurée (par exemple .call().entity(MyRecord.class)) au lieu de simuler avec un outil.
  • Opérations sensibles et irréversibles (transfert d’argent, suppression de données) sans étape de confirmation humaine.

10. Conclusion

Function calling n’est pas de la magie. Le modèle retourne simplement une demande d’appel de fonction en JSON, et c’est votre application qui l’exécute réellement. Spring AI rend la partie exécution plus concise : @Tool et @ToolParam génèrent le schéma, ChatClient.tools(...) gère la boucle d’appel-résultat, et ToolContext transmet les données de session que le modèle ne peut pas toucher.

Le plus important est de placer la limite de confiance au bon endroit. Écrivez des descriptions claires et vérifiez les autorisations directement dans l’outil. Pour les opérations avec effet secondaire, une confirmation de l’utilisateur est nécessaire. Enfin, testez l’outil via ToolCallback avec de vraies chaînes JSON pour vous assurer que votre code fonctionne correctement, indépendamment de ce que retourne le modèle.

llmspring-aifunction-callingtool-usejava