Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 5 sur 10

Donner un outil au modèle

Durée indicative : environ 18 min ; elle dépend surtout des réponses du modèle sur votre processeur.

Le problème que ce module résout

Au module 1, vous avez vu le cycle d'un agent sous forme de JSON simplifié : le modèle demande un outil, votre code l'exécute, le modèle répond. Avant d'automatiser ce cycle dans une boucle (module 7), il faut le voir fonctionner une fois, pas à pas, avec les vrais types .NET — et repérer précisément ce que fait le modèle et ce que fait votre code.

Du JSON du module 1 aux types .NET

Microsoft.Extensions.AI représente l'échange avec des objets plutôt qu'avec du JSON. La correspondance est directe :

Dans le JSON du module 1 Dans Microsoft.Extensions.AI
un message { "role": "user", "content": ... } ChatMessage(ChatRole.User, "...")
la liste tools ChatOptions.Tools, une liste d'AITool
la réponse tool_call avec name et arguments un FunctionCallContent (propriétés Name, Arguments, CallId)
le résultat de l'outil renvoyé au modèle un FunctionResultContent(callId, resultat) dans un message de rôle ChatRole.Tool

Le CallId est un identifiant attribué à chaque demande par le fournisseur ou par la bibliothèque cliente : votre résultat doit le reprendre, pour que le modèle sache à quelle demande il répond.

Pour déclarer l'outil, AIFunctionFactory.Create prend une fonction .NET, un nom et une description, et en déduit le schéma JSON des paramètres. Une fonction F# devient un délégué .NET avec Func<int, string>(getOrder) — l'équivalent du passage d'une méthode là où C# attend un Func<int, string>.

Le script outil.fsx

Créez outil.fsx. Il charge les deux fichiers que vous avez déjà ; tout le reste est l'aller-retour, en quatre temps numérotés :

// outil.fsx — module 05 : un aller-retour d'outil, fait à la main.
#load "llm.fsx" "commandes.fsx"

open System
open System.Text.Json
open Microsoft.Extensions.AI
open Llm
open Commandes

// 1. Décrire l'outil au modèle : nom, description, schéma (déduit de getOrder).
let outilCommande =
    AIFunctionFactory.Create(
        Func<int, string>(getOrder),
        "getOrder",
        "Retourne le statut d'une commande à partir de son numéro."
    )

printfn "[schéma envoyé au modèle] %s" (outilCommande.JsonSchema.GetRawText())

let options = creerOptions ()
options.Tools <- ResizeArray<AITool> [ outilCommande :> AITool ]

let instructions =
    "Tu es l'assistant support d'une boutique en ligne. "
    + "Utilise les outils pour répondre. Réponds en français, brièvement."

let historique =
    ResizeArray<ChatMessage>
        [ ChatMessage(ChatRole.System, instructions)
          ChatMessage(ChatRole.User, "Où en est ma commande 4512 ?") ]

// 2. Premier tour : le modèle répond… par une demande d'outil, ou par du texte.
let reponse1 = client.GetResponseAsync(historique, options).GetAwaiter().GetResult()

let demandes =
    reponse1.Messages
    |> Seq.collect (fun message -> message.Contents)
    |> Seq.choose (fun contenu ->
        match contenu with
        | :? FunctionCallContent as demande -> Some demande
        | _ -> None)
    |> List.ofSeq

printfn "[tour 1] demandes d'outil : %d" demandes.Length

if demandes.IsEmpty then
    printfn "[tour 1] texte du modèle : %s" reponse1.Text
else
    // 3. Votre code exécute l'outil, puis ajoute le résultat à l'historique.
    historique.AddRange(reponse1.Messages)

    for demande in demandes do
        let argsJson = JsonSerializer.Serialize demande.Arguments
        printfn "[le modèle demande] %s %s" demande.Name argsJson
        // Lecture naïve : le module 06 remplacera ce cast par une vraie frontière.
        let numero = (demande.Arguments["orderId"] :?> JsonElement).GetInt32()
        let resultat = getOrder numero
        printfn "[votre code exécute] getOrder %d -> %s" numero resultat
        let contenu = FunctionResultContent(demande.CallId, resultat) :> AIContent
        historique.Add(ChatMessage(ChatRole.Tool, ResizeArray<AIContent> [ contenu ]))

    // 4. Second tour : le modèle lit le résultat et formule la réponse finale.
    let reponse2 = client.GetResponseAsync(historique, options).GetAwaiter().GetResult()
    printfn "[tour 2] réponse finale : %s" reponse2.Text

Quelques éléments de syntaxe nouveaux, tous des ponts vers .NET :

  • ResizeArray<T> est le nom F# de List<T>, la liste modifiable de .NET ; Seq.collect aplatit une séquence de séquences, comme SelectMany en LINQ.
  • :? FunctionCallContent as demande est un test de type dans un match, l'équivalent du is FunctionCallContent demande de C#. La réponse contient une liste de contenus de types variés (texte, demande d'outil…), et Seq.choose ne garde que les demandes.
  • :> AITool et :> AIContent sont des conversions vers le type parent (upcast). C# les fait implicitement ; F# les exige quand il construit une liste d'un type parent. Si vous les oubliez, vous obtiendrez une erreur FS0041 (« No overloads match for method… ») sur la ligne du ResizeArray.
  • :?> JsonElement est la conversion inverse (downcast), comme (JsonElement)x en C#. Elle lève une exception si la valeur n'est pas du type attendu — gardez cette ligne en tête, elle est le sujet du module 6.
  • historique.AddRange(reponse1.Messages) ajoute la demande du modèle à l'historique avant le résultat : le modèle doit relire sa propre demande pour comprendre la réponse de l'outil.

Exercice — lancer l'aller-retour

dotnet fsi outil.fsx

Comptez deux appels au modèle, donc de quelques secondes à une trentaine de secondes sur processeur seul.

Sortie attendue — les lignes produites par votre code sont exactes ; seule la réponse finale est variable :

[schéma envoyé au modèle] {"type":"object","properties":{"orderId":{"type":"integer"}},"required":["orderId"]}
[tour 1] demandes d'outil : 1
[le modèle demande] getOrder {"orderId":4512}
[votre code exécute] getOrder 4512 -> Commande 4512 : expédiée, suivi TRK-88
[tour 2] réponse finale : <texte variable>

Le schéma a conservé le nom du paramètre F#, orderId, et son type, integer : c'est tout ce que le modèle sait de votre fonction, avec sa description.

Observé le 2026-10-27 avec qwen3.5:4b sur processeur seul, deux exécutions successives du même script, avec la température à 0, ont donné deux réponses finales différentes :

[tour 2] réponse finale : Votre commande 4512 a été expédiée. Le numéro de suivi est TRK-88.
[tour 2] réponse finale : Votre commande 4512 est expédiée. Le numéro de suivi est TRK-88.

La demande d'outil, elle, était identique à chaque fois. C'est pour cela que, dans tout le cours, seules les lignes produites par votre code servent de sortie de référence.

Si votre sortie diffère

  • [tour 1] demandes d'outil : 0, suivi d'un texte : le modèle a répondu sans demander l'outil. Lors des essais de préparation du cours, qwen3.5:4b a demandé getOrder avec le bon numéro dans 16 cas sur 16 pour ce type de question, mais rien ne le garantit. Relancez une fois ; si cela persiste, gardez la sortie ci-dessus comme référence. Au module 7, ce cas deviendra un cas prévu de votre boucle, pas une surprise.
  • Une erreur FS0041 sur une ligne ResizeArray : il manque un :> AITool ou un :> AIContent.
  • [le modèle demande] montre deux demandes : le modèle peut demander plusieurs outils dans une même réponse ; le script les exécute toutes. Ce cas a été observé avec une question portant sur deux commandes (voir l'exercice suivant).

Exercice — qui fait quoi ?

Voici la trace réelle d'une autre exécution du même script, où seule la question a changé : « Donne-moi le statut de mes commandes 4512 et 7781. » (observée le 2026-10-27 avec qwen3.5:4b sur processeur seul ; la ligne du schéma est omise) :

[tour 1] demandes d'outil : 2
[le modèle demande] getOrder {"orderId":4512}
[votre code exécute] getOrder 4512 -> Commande 4512 : expédiée, suivi TRK-88
[le modèle demande] getOrder {"orderId":7781}
[votre code exécute] getOrder 7781 -> Commande 7781 : en préparation
[tour 2] réponse finale : <texte variable>

Sans relire le module, répondez :

  1. Où la fonction getOrder a-t-elle été exécutée ?
  2. Qu'a renvoyé le modèle au tour 1 ?
  3. Que contenait le message envoyé au modèle juste avant le tour 2 ?

Réponses

  1. Dans votre processus dotnet fsi, par votre code, dans la boucle for demande in demandes. Le modèle n'a jamais accès à la fonction.
  2. Deux demandes d'outil (FunctionCallContent), chacune avec un nom, des arguments et un CallId — pas de texte destiné au client.
  3. L'historique complet : les messages précédents, les deux demandes du modèle, puis deux messages de rôle Tool contenant chacun un FunctionResultContent avec le CallId correspondant et le texte renvoyé par getOrder.

Ce que vous venez de faire

Vous avez déclaré une fonction F# comme outil, vu le schéma que le modèle reçoit, lu la demande d'appel du modèle sous la forme d'un FunctionCallContent, exécuté l'outil dans votre code et renvoyé son résultat avec le bon CallId. Vous avez aussi constaté qu'une température à 0 ne rend pas le texte du modèle parfaitement reproductible — contrairement à ce que produit votre code.

Une ligne de ce script devrait vous gêner : :?> JsonElement suivi de GetInt32(). Si le modèle envoie "ABC-12" au lieu d'un nombre, ou oublie l'argument, le script s'arrête sur une exception. Au module suivant, vous remplacez ce cast par une frontière qui transforme chaque défaut possible en un cas nommé, avec Result.

Vérifiez votre compréhension

Trace d'un autre agent, avec un outil `getStock` : `[tour 1] demandes d'outil : 1`, puis `[le modèle demande] getStock {"sku":"A-17"}`, puis `[votre code exécute] getStock A-17 -> 3 en stock`. Que contient la requête envoyée au modèle pour le tour 2 ?

Un collègue affirme : « Avec `AIFunctionFactory.Create`, le modèle reçoit ma fonction F# et l'exécute de son côté. » Que reçoit réellement le modèle ?

Envie d'être prévenu des prochains modules ?

L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.