La boucle d'agent
Durée indicative : environ 28 min ; elle dépend surtout des réponses du modèle sur votre processeur.
Le problème que ce module résout
Le script du module 5 faisait exactement deux tours, écrits l'un après l'autre. Un vrai agent ne sait pas à l'avance combien de tours il lui faut : le modèle peut répondre directement, demander un outil, en demander plusieurs, ou en redemander un après avoir lu le résultat du premier. Il faut donc une boucle — et une boucle qui dépend des décisions d'un modèle doit avoir une fin garantie.
Ce module assemble tout ce que vous avez écrit : llm.fsx (module 2), commandes.fsx (modules 3–4) et decodage.fsx (module 6).
Ce que le modèle peut produire à un tour
À chaque tour, la réponse du modèle a l'une de trois formes, que vous connaissez depuis le module 1 :
type AgentStep =
| FinalAnswer of text: string
| CallTools of calls: (string * Result<ToolCall, ToolError>) list
| InvalidResponse of reason: string
FinalAnswer: un texte destiné au client ; la boucle s'arrête.CallTools: une liste de demandes, parce qu'un modèle peut en envoyer plusieurs à la fois (vous l'avez vu au module 5 avec deux commandes). Chaque demande garde sonCallId(lastring) et le résultat de votre frontière du module 6 : une demande valideOk (GetOrder 4512), ou une erreur nommée.InvalidResponse: ni texte, ni demande — par exemple une réponse vide.
C'est votre troisième union discriminée, et elle réutilise les deux autres : ToolCall et ToolError sont imbriqués dans CallTools.
Il faut aussi nommer les façons dont l'agent peut s'arrêter sans réponse finale :
type AgentError =
| TurnLimitReached of maxTurns: int
| InvalidModelResponse of reason: string
L'agent renverra donc un Result<string, AgentError> : Ok avec la réponse au client, ou Error avec la raison de l'arrêt.
Une boucle bornée, sans while
En C#, vous écririez probablement for (var tour = 1; tour <= maxTours; tour++) { ... break; }. En F#, la boucle s'écrit naturellement comme une fonction récursive, let rec, qui s'appelle elle-même pour le tour suivant — sans variable mutable :
let rec boucle tour =
if tour > maxTours then
Error(TurnLimitReached maxTours)
else
// appeler le modèle, classer la réponse, puis :
// FinalAnswer -> Ok texte (fin)
// InvalidResponse -> Error ... (fin)
// CallTools -> exécuter, puis boucle (tour + 1)
Chaque chemin se termine par une valeur, ou par un appel avec tour + 1. Comme tour augmente à chaque appel et que la première ligne coupe au-delà de maxTours, la boucle s'arrête forcément.
Pourquoi maxTours = 4 ? Une question sur une commande demande deux tours (une demande d'outil, puis la réponse). Quatre laissent de la marge pour une demande corrigée, tout en bornant le pire cas : sur processeur seul, à raison de quelques secondes à une quinzaine de secondes par tour, quatre tours tiennent en une minute environ.
Exercice — compléter agent.fsx
Créez agent.fsx. Ce fichier définit l'agent mais ne le lance pas : un autre script posera les questions. Tout est fourni, sauf la branche CallTools de la boucle, que vous écrivez :
// agent.fsx — modules 07 et 08 : boucle d'agent bornée ; chaque tour est une union discriminée.
// Ce fichier définit l'agent sans le lancer : demander.fsx et scenarios.fsx l'utilisent.
#load "llm.fsx" "commandes.fsx" "decodage.fsx"
open System
open System.Text.Json
open Microsoft.Extensions.AI
open Llm
open Commandes
open Decodage
// Ce que le modèle a produit à ce tour : une forme parmi trois.
type AgentStep =
| FinalAnswer of text: string
| CallTools of calls: (string * Result<ToolCall, ToolError>) list
| InvalidResponse of reason: string
// Pourquoi l'agent s'est arrêté sans réponse finale.
type AgentError =
| TurnLimitReached of maxTurns: int
| InvalidModelResponse of reason: string
let decrireErreur (erreur: AgentError) : string =
match erreur with
| TurnLimitReached limite -> $"limite de {limite} tours atteinte"
| InvalidModelResponse raison -> $"réponse du modèle inutilisable ({raison})"
let maxTours = 4
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 outilCommande =
AIFunctionFactory.Create(
Func<int, string>(getOrder),
"getOrder",
"Retourne le statut d'une commande à partir de son numéro."
)
// Adaptateur : une demande d'outil .NET -> la frontière du module 06.
let decoderDemande (demande: FunctionCallContent) : Result<ToolCall, ToolError> =
decoder demande.Name (JsonSerializer.Serialize demande.Arguments)
let classer (reponse: ChatResponse) : AgentStep =
let demandes =
reponse.Messages
|> Seq.collect (fun message -> message.Contents)
|> Seq.choose (fun contenu ->
match contenu with
| :? FunctionCallContent as demande -> Some demande
| _ -> None)
|> List.ofSeq
if not demandes.IsEmpty then
CallTools [ for demande in demandes -> (demande.CallId, decoderDemande demande) ]
elif String.IsNullOrWhiteSpace reponse.Text then
InvalidResponse "réponse vide"
else
FinalAnswer(reponse.Text.Trim())
// Seuls les cas de ToolCall peuvent être exécutés : c'est la liste autorisée.
let executer (demande: Result<ToolCall, ToolError>) : string =
match demande with
| Ok(GetOrder numero) -> getOrder numero
| Error erreur -> sprintf "Erreur : %A" erreur
let executerAgent (client: IChatClient) (question: string) : Result<string, AgentError> =
let options = creerOptions ()
options.Tools <- ResizeArray<AITool> [ outilCommande :> AITool ]
let historique =
ResizeArray<ChatMessage>
[ ChatMessage(ChatRole.System, instructions)
ChatMessage(ChatRole.User, question) ]
let rec boucle tour =
if tour > maxTours then
Error(TurnLimitReached maxTours)
else
let reponse = client.GetResponseAsync(historique, options).GetAwaiter().GetResult()
match classer reponse with
| FinalAnswer texte ->
printfn "[tour %d] FinalAnswer" tour
Ok texte
| InvalidResponse raison ->
printfn "[tour %d] InvalidResponse %s" tour raison
Error(InvalidModelResponse raison)
| CallTools demandes ->
// À vous : afficher la trace, ajouter la réponse à l'historique,
// exécuter chaque demande, ajouter son résultat, passer au tour suivant.
boucle 1
Trois détails de ce fichier méritent un regard avant d'écrire :
executerAgentreçoit leclienten paramètre au lieu d'utiliser directementLlm.client. Ce choix vous servira au module 8.executerne sait exécuter qu'une chose :Ok (GetOrder numero). Une demande en erreur n'est pas exécutée : son erreur est transformée en texte, qui sera renvoyé au modèle comme résultat d'outil, pour qu'il puisse corriger sa demande ou s'excuser auprès du client.decoderDemanderéutilise votre frontière :JsonSerializer.Serialize demande.Argumentsremet les arguments sous forme de texte JSON, l'entrée attendue pardecoder.
Votre branche CallTools doit, dans cet ordre :
- afficher
[tour N] CallToolssuivi de la liste des demandes décodées (indice :sndest le pendant defstet renvoie le second élément ;List.map snd demandesextrait donc la partieResultde chaque paire, et%Al'affiche) ; - ajouter
reponse.Messagesà l'historique, comme au module 5 ; - pour chaque paire
(idAppel, demande): appelerexecuter, afficher-> <résultat>, et ajouter à l'historique un messageChatRole.Toolcontenant unFunctionResultContent(idAppel, resultat); - appeler
boucle (tour + 1).
Le module 5 contient déjà les points 2 et 3 sous une autre forme.
Solution
| CallTools demandes ->
printfn "[tour %d] CallTools %A" tour (List.map snd demandes)
historique.AddRange(reponse.Messages)
for (idAppel, demande) in demandes do
let resultat = executer demande
printfn " -> %s" resultat
let contenu = FunctionResultContent(idAppel, resultat) :> AIContent
historique.Add(ChatMessage(ChatRole.Tool, ResizeArray<AIContent> [ contenu ]))
boucle (tour + 1)
Exercice — poser de vraies questions
Créez demander.fsx, qui charge l'agent et lui pose deux questions, avec le vrai modèle :
// demander.fsx — module 07 : poser de vraies questions à l'agent (modèle local).
#load "agent.fsx"
open Agent
let questions =
[ "Où en est ma commande 4512 ?"
"Où en est ma commande 9999 ?" ]
for question in questions do
printfn "=== %s" question
match executerAgent Llm.client question with
| Ok texte -> printfn "Réponse : %s" texte
| Error erreur -> printfn "Arrêt : %s" (decrireErreur erreur)
demander.fsx ne charge que agent.fsx, qui charge lui-même les trois autres fichiers : chacun n'est ainsi chargé qu'une fois. Llm.client reste accessible par son nom complet.
dotnet fsi demander.fsx
Comptez quatre appels au modèle, soit de quelques dizaines de secondes à une minute sur processeur seul.
Sortie attendue — les marqueurs de tour et les résultats d'outil sont produits par votre code ; seules les réponses finales sont variables :
=== Où en est ma commande 4512 ?
[tour 1] CallTools [Ok (GetOrder 4512)]
-> Commande 4512 : expédiée, suivi TRK-88
[tour 2] FinalAnswer
Réponse : <texte variable>
=== Où en est ma commande 9999 ?
[tour 1] CallTools [Ok (GetOrder 9999)]
-> Commande 9999 introuvable
[tour 2] FinalAnswer
Réponse : <texte variable>
Exemple de réponses réelles, observées le 2026-10-27 avec qwen3.5:4b sur processeur seul (environ 25 secondes pour l'ensemble) : « Votre commande 4512 est expédiée. Voici votre numéro de suivi : TRK-88. » puis « Votre commande 9999 n'existe pas dans nos systèmes. »
Ce que vous vérifiez : au moins une séquence demande d'outil → exécution par votre code → réponse finale, avec les données de votre outil, et une fin propre même pour une commande qui n'existe pas.
Si votre sortie diffère
[tour 1] FinalAnswerdirectement, sansCallTools: le modèle a répondu sans consulter l'outil. Votre boucle l'a traité comme un cas prévu et s'est arrêtée proprement — c'est exactement son rôle. Relancez ; si cela persiste, gardez la sortie ci-dessus comme référence.[tour 1] CallTools [Error (...)]: le modèle a envoyé une demande que votre frontière refuse. Regardez le tour suivant : l'erreur lui a été renvoyée comme résultat d'outil. Lors des essais de préparation, cela ne s'est pas produit pour ces deux questions ; le module 8 rejoue ce cas de façon reproductible.- Un avertissement
FS0025surmatch classer reponse with: la brancheCallToolsmanque. Une erreurFS0058(« possible incorrect indentation ») : la branche existe mais est vide ou mal indentée — son contenu doit être plus à droite que le|.
L'automatique existe, et c'est voulu de ne pas s'en servir ici
Microsoft.Extensions.AI sait faire cette boucle à votre place : ChatClientBuilder(client).UseFunctionInvocation().Build() produit un client qui exécute lui-même les outils demandés et ne vous rend que la réponse finale. Lors des essais de préparation, il a appelé getOrder 4512 et renvoyé une réponse en un seul appel apparent. C'est pratique — mais l'union AgentStep, le décodage et la décision « exécuter ou refuser » disparaissent de votre code. Le but de ce module était de les voir ; une fois qu'ils sont compris, choisir l'automatique devient une décision éclairée, et non plus un mystère.
Ce que vous venez de faire
Vous avez écrit une boucle d'agent bornée par une limite explicite, dont chaque tour est classé en FinalAnswer, CallTools ou InvalidResponse, et vous avez traité chaque cas dans un match. Votre code exécute les outils autorisés, renvoie leurs résultats — ou leurs erreurs — au modèle, et s'arrête toujours : sur une réponse finale, sur une réponse inutilisable, ou à la limite de tours.
Au module suivant, vous allez provoquer ce qui n'arrive presque jamais en essai mais arrivera un jour en production : un outil interdit, un argument absurde, un modèle qui boucle, un serveur arrêté, un délai dépassé. Chaque panne deviendra un cas nommé — et le compilateur vous aidera à n'en oublier aucun.
Vérifiez votre compréhension
Dans votre boucle, `maxTours = 4`, et le test `if tour > maxTours then Error(TurnLimitReached maxTours)` vient avant l'appel au modèle. Le modèle demande un outil à chaque tour sans jamais conclure. Combien d'appels au modèle, et que renvoie `executerAgent` ?
Au premier tour, le modèle renvoie une réponse sans aucune demande d'outil et avec un texte vide. Que fait votre agent du module 7 ?
Vous ajoutez un quatrième cas à `AgentStep`, `NeedsHumanReview of reason: string`, sans rien changer d'autre dans `agent.fsx`. Que signale le compilateur ?
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.