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# deList<T>, la liste modifiable de .NET ;Seq.collectaplatit une séquence de séquences, commeSelectManyen LINQ.:? FunctionCallContent as demandeest un test de type dans unmatch, l'équivalent duis FunctionCallContent demandede C#. La réponse contient une liste de contenus de types variés (texte, demande d'outil…), etSeq.choosene garde que les demandes.:> AIToolet:> AIContentsont 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 erreurFS0041(« No overloads match for method… ») sur la ligne duResizeArray.:?> JsonElementest la conversion inverse (downcast), comme(JsonElement)xen 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:4ba demandégetOrderavec 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
FS0041sur une ligneResizeArray: il manque un:> AIToolou 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 :
- Où la fonction
getOrdera-t-elle été exécutée ? - Qu'a renvoyé le modèle au tour 1 ?
- Que contenait le message envoyé au modèle juste avant le tour 2 ?
Réponses
- Dans votre processus
dotnet fsi, par votre code, dans la bouclefor demande in demandes. Le modèle n'a jamais accès à la fonction. - Deux demandes d'outil (
FunctionCallContent), chacune avec un nom, des arguments et unCallId— pas de texte destiné au client. - L'historique complet : les messages précédents, les deux demandes du modèle, puis deux messages de rôle
Toolcontenant chacun unFunctionResultContentavec leCallIdcorrespondant et le texte renvoyé pargetOrder.
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.