Semantic-fleet MultiConnector : router les prompts entre modeles reels

Navigation : Index | << Précédent

Deuxième démonstration C# de la série consommant l’organe réel du sous-module semantic-fleet : le connecteur MultiConnector (Connectors.AI.MultiConnector), qui route chaque prompt vers le modèle le plus économique selon son type — en mesurant coût et latence réels. Là où le notebook précédent (Radix PromptMatcher) routait par correspondance de texte déterministe, celui-ci route par capacité du modèle : prompt simple vers un modèle rapide et bon marché, prompt analytique vers un modèle intermédiaire, raisonnement difficile vers le modèle le plus capable — avec chaîne de secours si un fournisseur tombe.

Objectifs pedagogiques

  1. Comprendre l’architecture MultiConnector : un connecteur principal qui instruit des connecteurs secondaires, des PromptMultiConnectorSettings par type de prompt, et un ConnectorComparer qui arbitre coût contre latence.
  2. Cabler trois fournisseurs reels (OpenAI direct + deux modèles via OpenRouter, endpoint compatible OpenAI) derriere une seule interface ITextGenerationService.
  3. Mesurer sur des appels reels : quelle route chaque type de prompt prend, ce qu’elle coute, combien de temps elle prend.
  4. Composer une chaine de secours : quand le connecteur d’une route echoue, la demande bascule sans changer le code appelant.
// L'organe reel du sous-module semantic-fleet -- via son wrapper console.
//
// Pourquoi un wrapper et pas #r sur la DLL ? L'isolation ALC de .NET Interactive :
// la fermeture de dependances NuGet de l'organe (SK 1.78 + OpenAI SDK +
// Logging.Abstractions) est invisible a un assembly charge localement
// (FileNotFoundException, mesure avec pins exacts). Une application console
// resout toute la fermeture dans un seul ALC -- c'est la surface invocable.
//
// Prerequis : git submodule update --init + dotnet build de
//   semantic-fleet/tools/multiroute/src/SemanticFleet.MultiRoute.csproj
// Clés : OPENAI_API_KEY et OPENROUTER_API_KEY dans l'environnement.
using System.Diagnostics;
using System.IO;

// Chemin relatif au dossier du notebook, resolu AVANT Process.Start :
// mesure -- File.Exists(relatif)=True mais CreateProcess ERROR_FILE_NOT_FOUND
// (les separateurs '/' d'un FileName relatif ne survivent pas a la recherche
// de CreateProcess ; Path.GetFullPath rend un absolu Windows).
var wrapper = Path.GetFullPath("semantic-fleet/tools/multiroute/src/bin/Debug/net9.0/SemanticFleet.MultiRoute.exe");
Console.WriteLine($"Wrapper present : {File.Exists(wrapper)} ({wrapper})");

Process RunWrapper(string args) {
    var p = new Process {
        StartInfo = new ProcessStartInfo {
            FileName = wrapper, Arguments = args,
            RedirectStandardOutput = true, RedirectStandardError = true,
            UseShellExecute = false, CreateNoWindow = true,
        },
    };
    p.Start();
    return p;
}

var smoke = RunWrapper("--prompt \"[simple] Reponds juste: OK\" --json");
smoke.WaitForExit();
Console.WriteLine($"smoke rc   : {smoke.ExitCode}");
Console.WriteLine($"smoke out  : {smoke.StandardOutput.ReadToEnd().Trim()}");
Console.WriteLine($"smoke err  : {smoke.StandardError.ReadToEnd().Trim()}");
Wrapper present : True (D:\Dev\CoursIA\.claude\worktrees\axe6-1210\MyIA.AI.Notebooks\GenAI\SemanticKernel\semantic-fleet\tools\multiroute\src\bin\Debug\net9.0\SemanticFleet.MultiRoute.exe)
smoke rc   : 0
smoke out  : {"type": "simple", "route": "simple", "latency_ms": 2512, "text": "OK"}
smoke err  : 

1. L’organe MultiConnector – architecture

Le principe (herite du notebook 03 du sous-module, modernise ici en 2026 avec des modeles reels) :

  • un connecteur principal (mainTextCompletion) : celui qui repond par defaut et qui sert de reference pour evaluer les autres ;
  • des connecteurs secondaires (otherCompletions) : chaque NamedTextCompletion porte un nom, un cout par requete declare, et recoit le traffic que l’arbitre lui accorde ;
  • des PromptMultiConnectorSettings : la table de routage par type de prompt – chaque type (simple, analytique, raisonnement…) a ses tolerances de cout et de latence ;
  • un ConnectorComparer pondere : l’arbitre trie les candidats d’une route par GetWeightedConnectorComparer(poidsLatence, poidsCout).

C’est la version 2026 du multi-connecteur de l’epic #1210 : plus de moqueurs arithmetiques – des fournisseurs reels, des euros reels, des secondes reelles.

// Trois fournisseurs reels, deux cles d'environnement (jamais de litteral inline).
// - OPENAI_API_KEY      : OpenAI direct (gpt-4o-mini -- route [simple])
// - OPENROUTER_API_KEY  : OpenRouter, endpoint compatible OpenAI
//                         (gemini-2.5-flash -- route [analytique],
//                          claude-sonnet-4.5 -- route [raisonnement])
//
// Un prompt par route : la sortie JSON nomme la route prise et la latence.
string[] probes = new[] {
    "--prompt \"[simple] Traduis en une phrase : 'la nuit tous les chats sont gris'.\" --json",
    "--prompt \"[analytique] Compare en trois puces les compromis cout/latence d'un cache LRU face a un cache LFU.\" --json",
    "--prompt \"[raisonnement] Un fermier doit traverser une riviere avec un loup, une chevre et un chou. Donne le plan minimal etape par etape, puis prouve qu'il est minimal.\" --json",
};

foreach (var args in probes) {
    var p = RunWrapper(args);
    p.WaitForExit();
    Console.WriteLine(p.StandardOutput.ReadToEnd().Trim());
    if (p.ExitCode != 0) Console.WriteLine($"[rc={p.ExitCode}] {p.StandardError.ReadToEnd().Trim()}");
}
{"type": "simple", "route": "simple", "latency_ms": 2006, "text": "# \u0022At night, all cats are grey.\u0022"}
{"type": "analytique", "route": "analytique", "latency_ms": 9046, "text": "# Cache LRU vs LFU : Compromis co\u00FBt/latence\n\n\u2022 **Latence d\u0027acc\u00E8s** : LRU offre des acc\u00E8s O(1) avec une liste doublement cha\u00EEn\u00E9e \u002B hash map, tandis que LFU n\u00E9cessite des structures plus complexes (min-heap ou compteurs) donnant O(log n) en impl\u00E9mentation na\u00EFve, bien que O(1) soit possible avec une conception optimis\u00E9e mais plus co\u00FBteuse en m\u00E9moire.\n\n\u2022 **Co\u00FBt m\u00E9moire** : LRU requiert 2 pointeurs par entr\u00E9e (prev/next) \u002B hash map, soit ~16-24 bytes de surcharge par cl\u00E9. LFU n\u00E9cessite des compteurs de fr\u00E9quence \u002B structures additionnelles (listes par fr\u00E9quence), donnant ~24-40 bytes de surcharge, donc 50-70% plus co\u00FBteux.\n\n\u2022 **Latence des \u00E9victions** : LRU \u00E9vince en O(1) (toujours le dernier \u00E9l\u00E9ment), tandis que LFU doit identifier le moins fr\u00E9quent en O(log n) ou maintenir une structure O(1) au prix d\u0027une complexit\u00E9 algorithmique accrue et de mises \u00E0 jour plus fr\u00E9quentes des m\u00E9tadonn\u00E9es \u00E0 chaque acc\u00E8s."}
{"type": "raisonnement", "route": "raisonnement", "latency_ms": 15194, "text": "# Le probl\u00E8me du fermier, du loup, de la ch\u00E8vre et du chou\n\n## Contraintes\n- Le bateau ne peut transporter que le fermier \u002B 1 \u00E9l\u00E9ment\n- Sans surveillance du fermier :\n  - Le loup mange la ch\u00E8vre\n  - La ch\u00E8vre mange le chou\n\n## Solution minimale (7 \u00E9tapes)\n\n**\u00C9tape 1:** Emmener la CH\u00C8VRE \u2192\n- Rive gauche: Loup, Chou\n- Rive droite: Fermier, Ch\u00E8vre \u2713\n\n**\u00C9tape 2:** Retour SEUL \u2190\n- Rive gauche: Fermier, Loup, Chou\n- Rive droite: Ch\u00E8vre \u2713\n\n**\u00C9tape 3:** Emmener le LOUP \u2192\n- Rive gauche: Chou\n- Rive droite: Fermier, Loup, Ch\u00E8vre\n\n**\u00C9tape 4:** Ramener la CH\u00C8VRE \u2190\n- Rive gauche: Fermier, Ch\u00E8vre, Chou\n- Rive droite: Loup \u2713\n\n**\u00C9tape 5:** Emmener le CHOU \u2192\n- Rive gauche: Ch\u00E8vre\n- Rive droite: Fermier, Chou, Loup \u2713\n\n**\u00C9tape 6:** Retour SEUL \u2190\n- Rive gauche: Fermier, Ch\u00E8vre\n- Rive droite: Chou, Loup \u2713\n\n**\u00C9tape 7:** Emmener la CH\u00C8VRE \u2192\n- Rive gauche: \u2205\n- Rive droite: Fermier, Ch\u00E8vre, Chou, Loup \u2713 **SUCC\u00C8S**\n\n## Preuve de minimalit\u00E9\n\n**1. Borne inf\u00E9rieure:**\n- 3 \u00E9l\u00E9ments doivent traverser\n- Le fermier doit faire au moins 3 travers\u00E9es aller (avec chaque \u00E9l\u00E9ment)\n- Entre chaque aller, il doit revenir (sauf la derni\u00E8re fois)\n- Minimum th\u00E9orique: 3 allers \u002B 2 retours = 5 travers\u00E9es\n\n**2. Pourquoi 5 est impossible:**\n- La ch\u00E8vre est le \u0022probl\u00E8me central\u0022 (incompatible avec loup ET chou)\n- Elle doit \u00EAtre transport\u00E9e en premier (sinon conflit imm\u00E9diat)\n- Apr\u00E8s son transport, le fermier doit revenir chercher un autre \u00E9l\u00E9ment\n- En ramenant loup ou chou, on cr\u00E9e un conflit avec la ch\u00E8vre\n- **Il faut obligatoirement ramener la ch\u00E8vre** = travers\u00E9e suppl\u00E9mentaire\n- Cela nous donne: 4 allers \u002B 3 retours = 7 travers\u00E9es\n\n**3. Impossibilit\u00E9 de faire mieux:**\n- Tout chemin de 5 travers\u00E9es laisse la ch\u00E8vre avec loup ou chou\n- Les 7 \u00E9tapes sont donc **minimales**\n\n**Conclusion:** La solution en 7 \u00E9tapes est optimale."}

2. La table de routage : chaque prompt vers son modele

Le coeur pedagogique : le routage n’est pas ecrit dans le code appelant. Il vit dans une table declarative – les PromptMultiConnectorSettings de l’organe – que le wrapper tools/multiroute du sous-module porte dans son Program.cs. On y declare trois types de prompt, chacun avec son ordre de preference et sa tolerance de cout ; l’organe applique, mesure, et peut re-apprendre apres analyse (LoadSuggestedSettingsFromAnalysis). Le notebook consomme cette table par l’interface du wrapper : il ne nomme jamais le modele qui repond.

// La table de routage VIT DANS LE WRAPPER (Program.cs du sous-module) --
// c'est le contrat declaratif de l'organe MultiConnector :
//
//   [simple]       -> gpt-4o-mini       (OpenAI direct, ~0.00015 $/k tokens)
//   [analytique]   -> gemini-2.5-flash  (OpenRouter, ~0.0003 $/k tokens)
//   [raisonnement] -> claude-sonnet-4.5 (OpenRouter, ~0.003 $/k tokens)
//                     + chaines de secours par type (l'arbitre GetWeightedConnectorComparer(1, 1)
//                     trie chaque type par cout ET latence declares)
//
// Le prefixe signature du prompt selectionne la route (semantique
// SimpleMatchPromptSettings de l'organe : StartsWith, ordre sensible).
// Un prefixe inconnu -> exit 2, fail-closed. Demonstration :
var unknown = RunWrapper("--prompt \"[inconnu] test\"");
unknown.WaitForExit();
Console.WriteLine($"prefixe inconnu : rc={unknown.ExitCode}, msg={unknown.StandardError.ReadToEnd().Trim()}");

// Et le prompt SANS prefixe part sur le connecteur principal (claude-sonnet-4.5) :
var nomatch = RunWrapper("--prompt \"Dis bonjour en trois mots.\" --json");
nomatch.WaitForExit();
Console.WriteLine(nomatch.StandardOutput.ReadToEnd().Trim());
prefixe inconnu : rc=2, msg=Unknown signature prefix '[inconnu]' — expected [simple], [analytique] or [raisonnement].
{"type": "Dis bonjour en trois mots.", "route": "(no match — main connector serves)", "latency_ms": 1760, "text": "Bonjour \u00E0 toi."}
// Trois prompts reels via l'organe -- mesure de route et latence (sortie --json).
var sw = System.Diagnostics.Stopwatch.StartNew();
var demo = RunWrapper("--demo");
demo.WaitForExit();
sw.Stop();
Console.WriteLine(demo.StandardOutput.ReadToEnd().Trim());
Console.WriteLine($"-- total wrapper (3 appels reels, 2 fournisseurs, 2 cles) : {sw.ElapsedMilliseconds} ms");
[simple      ] route simple              1745 ms | # "At night, all cats are grey."
[analytique  ] route analytique          7665 ms | # Cache LRU vs LFU : Compromis coût/latence

• **Coût mémoire** : LRU nécessite une structure de données ordonnée (liste doublement chaînée + hash map) avec O(1...
[raisonnement] route raisonnement       16417 ms | # Problème du fermier, du loup, de la chèvre et du chou

## Contraintes
- Le bateau ne peut transporter que le fermier + 1 élément
- Si laissés seuls : le loup ...
-- total wrapper (3 appels reels, 2 fournisseurs, 2 cles) : 26161 ms

3. Lecture des resultats

Les sorties ci-dessus sont des appels reels factures (trois fournisseurs, deux cles). Trois choses a lire :

  1. La latence suit la capacite demande : la route [simple] doit etre la plus courte si le routage fonctionne ; la route [raisonnement] porte le modele le plus capable, donc le plus lent.
  2. Le texte revele la capacite : la reponse au plan de traversée doit etre structuree (etapes + argument de minimalite) – c’est ce que [raisonnement] achete.
  3. La route est declarative : rien dans les cellules appelantes ne nomme un modele. Changer la table dans le source du wrapper change le comportement sans toucher le code consommateur – et un prefixe inconnu echoue proprement (exit 2), jamais de routage silencieux au hasard.
// Exercice 1 -- Quatrieme type de prompt.
// Ouvre semantic-fleet/tools/multiroute/src/Program.cs et ajoute un type
// "[code]" (extrait de code a expliquer) a la table : gemini-2.5-flash en
// premiere preference, claude-sonnet-4.5 en secours -- inspire-toi de la
// methode PromptType(...) et de l'entree "[analytique]".
// Etape 1 : declare la nouvelle entree dans BuildKernel, puis
//   dotnet build semantic-fleet/tools/multiroute/src
// Etape 2 : reviens ici et appelle le wrapper avec un prompt "[code] ..." --
//   verifie que la route code est prise (champ "route" du JSON).

Console.WriteLine("Exercice a completer");
Exercice a completer
// Exercice 2 -- Chaine de secours.
// Dans Program.cs, retire claude-sonnet-4.5 des connecteurs du type
// "[raisonnement]" (garde les deux autres), rebuild, puis relance la cellule
// mc7 avec le prompt de traversée. L'organe doit servir la demande avec le
// suivant de la liste -- c'est le contrat de secours.
// Etape 1 : retire l'entree de l'appel PromptType("[raisonnement] ", ...).
// Etape 2 : relance et observe quelle route repond. Que devient la latence ?

Console.WriteLine("Exercice a completer");
Exercice a completer
// Exercice 3 -- Arbitre cout x 2.
// GetWeightedConnectorComparer(1, 1) pondere egalement latence et cout.
// Dans Program.cs, construis un arbitre qui penalise le cout deux fois plus
// (poids latence 1, poids cout 2), rebuild, et re-mesure les trois routes :
// lesquelles changent de modele ? Lesquelles ne changent pas, et pourquoi ?

Console.WriteLine("Exercice a completer");
Exercice a completer

Conclusion

L’organe MultiConnector du sous-module semantic-fleet fait ce que l’epic #1210 demandait : une version 2026 du notebook 03, sans moqueurs – trois fournisseurs reels derriere une interface unique, une table de routage declarative, un arbitre cout/latence mesurable, et une chaine de secours. Le contraste avec le notebook Radix PromptMatcher est le morceau pedagogique a retenir : router par texte (deterministe, gratuit, exact) et router par capacite (economique, probabiliste, facture) sont deux problemes differents que la meme serie traite avec deux organes differents du meme sous-module.

Genealogie

Predecesseur : Semantic-fleet-Radix-PromptMatcher (routing deterministe par radix tree, organe tools/radix).

Ancetre du sous-module : semantic-fleet/dotnet/notebooks/03-multiConnector-intro-with-arithmetic-mocks.ipynb (multi-connecteur a moqueurs arithmetiques, sans fournisseur reel).

References : epic #1210 axe 6 ; PR #18987 (premiere demo, organ radix) ; PR semantic-fleet#85 (wrapper console tools/multiroute, organe invocable) ; Connectors.AI.MultiConnector (tete du sous-module).

Retour au sommet