Orleans : des acteurs stateful pour les workloads IA

// Parameters
var BATCH_MODE = "true";

Ce notebook digere l’axe Orleans de la serie The Unexpected AI Stack: C#/.NET (issue #10473) — le dernier axe de la Part 1 non encore distille. Les axes Aspire, OTEL, CSharpRepl, Roslyn, EF Core et Channels sont livres ; Orleans complete le tableau.

Pourquoi Orleans dans un stack IA

Un workload IA conversationnel est un probleme d’etat : chaque session porte un historique, chaque modele consomme des tokens, chaque job a un statut. Trois angles qui interessent la serie :

  1. sessions d’agents : des milliers de conversations concurrentes, chacune avec son etat memoire — l’identite est une cle ("session-alpha"), pas une reference d’objet ;
  2. compteurs partages : consommation de tokens par modele, cumulee par des appelants concurrents — sans lock ni Interlocked ;
  3. grain-to-grain : un grain de session route sa consommation vers le grain compteur du modele — la topologie applicative devient un graphe d’acteurs.

Orleans appelle ces unites des grains : single-threaded, actives a la demande, identifiees par cle. Le billet d’origine y voit le backbone naturel d’agents stateful ; ce notebook le verifie sur un lab reel.

Ce que ce notebook execute

Le dossier adjacent OrleansAgentLab/ contient un vrai projet .NET 10 (package Microsoft.Orleans.Server 10.3.1, silo co-hosté en memoire). Les cellules lancent le build et les scenarios reels et capturent leurs sorties ; les trois exercices se completent dans OrleansAgentLab/Grains.cs puis se re-executent.

// Verifier l'environnement et construire le lab Orleans (projet reel adjacent).
// Le build est un vrai MSBuild : la source du lab (grains + silo) compile ici.
using System.Diagnostics;

var labDir = "OrleansAgentLab";

var buildPsi = new ProcessStartInfo("dotnet", "build -v q")
{
    WorkingDirectory = labDir,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false,
};
var buildProc = Process.Start(buildPsi)!;
string buildOut = buildProc.StandardOutput.ReadToEnd();
buildProc.WaitForExit();
var lignesUtiles = buildOut.Split('\n').Where(l => l.Contains("Erreur") || l.Contains("Error") || l.Contains("avertissement") || l.Contains("Warning")).Take(4).ToList();
if (lignesUtiles.Count == 0) lignesUtiles.Add("(build sans erreur ni avertissement)");
foreach (var l in lignesUtiles) Console.WriteLine(l.TrimEnd());
Console.WriteLine($"[cellule] build exit code = {buildProc.ExitCode}");
    0 Erreur(s)
[cellule] build exit code = 0

Lecture du build

Le projet OrleansAgentLab.csproj reference Microsoft.Orleans.Server en version exacte : c’est ce package qui embarque le runtime Orleans et le generateur de code (les proxys de grains sont produits a la compilation, pas par reflexion a l’execution — depuis Orleans 7, il n’y a plus de codegen runtime). Un exit code 0 ci-dessus signifie que les grains du lab ont leurs classes proxy/invoker generees par MSBuild.

// Scenario demo : silo co-hosté (silo + client dans le meme process), executions reelles.
string demoOutput;
{
    var runPsi = new ProcessStartInfo("dotnet", "run --no-build -- demo")
    {
        WorkingDirectory = labDir,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
    };
    var runProc = Process.Start(runPsi)!;
    demoOutput = runProc.StandardOutput.ReadToEnd();
    string demoErr = runProc.StandardError.ReadToEnd();
    runProc.WaitForExit();
    Console.WriteLine(demoOutput.TrimEnd());
    if (demoErr.Trim().Length > 0) Console.WriteLine("[stderr] " + demoErr.Trim().Split('\n').Take(3).Aggregate((a, b) => a + " | " + b));
    Console.WriteLine($"[cellule] demo exit = {runProc.ExitCode}");
}
[silo] demarre, scenario=demo
[demo] session-alpha : 2 tours ; session-beta : 1 tour
[demo] alpha| user : Resume la reunion de ce matin en 3 points
[demo] alpha| assistant : 1. Objectifs du trimestre... 2. Projet client... 3. Echéances
[demo] beta | user : Traduis le compte rendu en anglais
[demo] tokens gpt-5.6-luna=1625 (3 appels cumules), whisper-1=2100
[demo] concurrence : 50 appels x10 tokens -> total=2125, attendu=2125, COHERENT (pas de course)
[demo] totaux intermediaires distincts vus par les appelants : 50
[demo] identite stable : nouvelle reference sur 'gpt-5.6-luna' -> total=2125 (etat conserve)
[cellule] demo exit = 0

Lecture du demo — l’identite est la cle, l’etat vivait dans le grain

  1. Sessions isolees : session-alpha et session-beta sont deux grains distincts ; l’historique d’alpha ne contient que ses tours. Aucun Dictionary<string, List<string>> global n’a ete ecrit — la granularite EST le partitionnement.
  2. Compteur par modele : le grain gpt-5.6-luna cumule ses trois appels et affiche 1 625 tokens ; whisper-1 vit sa propre vie avec 2 100. Deux cles, deux etats, zero collision.
  3. Concurrence : 50 appels simultanes de RecordUsage(10) sur le meme grain ajoutent 500 tokens aux 1 625 deja cumules par les appels du modele, d’ou le total de 2 125 imprime. Ce total est une somme — 1 625 + 500 — et non le produit naif du seul appel unitaire par le nombre d’appels, qui ne rendrait que 500 : c’est bien le cumul precedent qui est lu, parce que le runtime Orleans serialise les appels vers un meme grain (turn-based). Le code du grain (_total += tokens;) n’a ni verrou ni Interlocked, et pourtant aucune ecriture n’est perdue. Les 50 totaux intermediaires distincts vus par les appelants montrent que les appels s’executent bien en sequence observable.
  4. Identite stable : une NOUVELLE reference obtenue par GetGrain sur la meme cle voit le meme total — la reference C# est un handle, l’identite durable est (type, cle).
// Verifier les invariants du demo contre la sortie reelle ci-dessus (pas de nombre magique en prose).
// Gardes fail-loud (Debug.Assert + throw) leves au-dela de la simple impression.
using System.Text.RegularExpressions;
using System.Collections.Generic;
using System.Diagnostics;

// Split sans literal caractere special : on passe par chr(10) au runtime, robuste a l'encodage du source.
char LF = (char)10;
long totalFinal = long.Parse(Regex.Match(demoOutput, @"concurrence : .*total=(\d+)").Groups[1].Value);
long attendu = long.Parse(Regex.Match(demoOutput, @"attendu=(\d+)").Groups[1].Value);
bool coherent = demoOutput.Contains("COHERENT (pas de course)");
bool etatConserve = demoOutput.Contains("(etat conserve)");
int toursAlpha = demoOutput.Split(LF).Count(l => l.StartsWith("[demo] alpha|"));
int toursBeta  = demoOutput.Split(LF).Count(l => l.StartsWith("[demo] beta |"));

Console.WriteLine($"total final observe = {totalFinal}, attendu = {attendu}, ecart = {totalFinal - attendu}");
Console.WriteLine($"concurrence sans course : {coherent}");
Console.WriteLine($"identite/etat conserves apres nouvelle reference : {etatConserve}");
Console.WriteLine($"historique alpha = {toursAlpha} lignes, beta = {toursBeta} ligne -- isolation par cle");

var echecs = new List<string>();
if (totalFinal != attendu) echecs.Add($"concurrence : total {totalFinal} != attendu {attendu}");
if (!coherent) echecs.Add("absence de la mention '(pas de course)' dans la sortie du demo");
if (!etatConserve) echecs.Add("identite/etat non conserves apres nouvelle reference GetGrain");
if (toursAlpha != 2) echecs.Add($"historique alpha attendu = 2 lignes, observe = {toursAlpha}");
if (toursBeta != 1) echecs.Add($"historique beta attendu = 1 ligne, observe = {toursBeta}");
Debug.Assert(totalFinal == attendu, $"concurrence : total {totalFinal} != attendu {attendu}");
Debug.Assert(coherent, "sortie demo sans mention '(pas de course)'");
Debug.Assert(etatConserve, "identite/etat non conserves apres nouvelle reference GetGrain");
Debug.Assert(toursAlpha == 2, $"historique alpha attendu = 2 lignes, observe = {toursAlpha}");
Debug.Assert(toursBeta == 1, $"historique beta attendu = 1 ligne, observe = {toursBeta}");

if (echecs.Count > 0)
    throw new InvalidOperationException("Invariants demo Orleans casses : " + string.Join("; ", echecs));
total final observe = 2125, attendu = 2125, ecart = 0
concurrence sans course : True
identite/etat conserves apres nouvelle reference : True
historique alpha = 2 lignes, beta = 1 ligne -- isolation par cle

Interpretation — ce que le modele acteur achete ici

La cellule de verification ci-dessus ferme le raisonnement par la mesure plutot que par l’affirmation : 2 125 tokens observes contre 2 125 attendus, ecart 0, et un historique rendant 2 lignes pour alpha, 1 pour beta — l’isolation par cle est constatee, pas supposee.

Le test decisif est la ligne concurrence : en POO classique, un compteur partage entre 50 taches concurrentes exige un Interlocked.Increment ou un lock — et l’oubli se paie par des totaux aleatoirement faux (race condition). Ici le grain Orleans promet un seul appel a la fois : l’invariant est garanti par le runtime, pas par la discipline du codeur. Pour un workload IA ou des centaines de requetes touchent le meme compteur de tokens ou la meme session, c’est un bug de concurrence de moins par construction.

La deuxieme propriete est l’activation a la demande : les grains n’existent pas en memoire tant qu’aucune cle n’est adressee — et leur identite survit aux references C# (le runtime les re-active depuis la cle). C’est ce qui permet de modeliser « une session par conversation » sans gerer soi-meme un registre de sessions.

// Exercice 1 — estimation de cout (methode encore stub : sortie temoin attendue).
{
    var ex1Psi = new ProcessStartInfo("dotnet", "run --no-build -- ex1")
    {
        WorkingDirectory = labDir,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
    };
    var ex1Proc = Process.Start(ex1Psi)!;
    string ex1Out = ex1Proc.StandardOutput.ReadToEnd();
    ex1Proc.WaitForExit();
    Console.WriteLine(ex1Out.TrimEnd());
}
[silo] demarre, scenario=ex1
Exercice 1 a completer : EstimateCostAsync retourne -1 par defaut
[ex1] total=1625 tokens, cout estime a 40 cents/1k -> -1 cents
[ex1] EstimateCostAsync est encore le stub : complete Grains.cs puis relance cette cellule

Exercice 1 — estimation du cout

Ouvrir OrleansAgentLab/Grains.cs et completer TokenCounterGrain.EstimateCostAsync : retourner le cout total en cents pour un tarif de centsPerThousandTokens (arrondi a 2 decimales, Math.Round). Le scenario cumule 1625 tokens a 40 cents/1k, soit 65 cents attendus. Puis relancer la cellule ci-dessus — la ligne de temoin [ex1] ... stub doit laisser place au calcul.

// Exercice 2 — dernier extrait de session (methode encore stub : sortie temoin attendue).
{
    var ex2Psi = new ProcessStartInfo("dotnet", "run --no-build -- ex2")
    {
        WorkingDirectory = labDir,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
    };
    var ex2Proc = Process.Start(ex2Psi)!;
    string ex2Out = ex2Proc.StandardOutput.ReadToEnd();
    ex2Proc.WaitForExit();
    Console.WriteLine(ex2Out.TrimEnd());
}
[silo] demarre, scenario=ex2
Exercice 2 a completer : LastExcerptAsync retourne une chaine vide par defaut
[ex2] dernier extrait -> ""
[ex2] LastExcerptAsync est encore le stub : complete Grains.cs puis relance cette cellule

Exercice 2 — dernier extrait

Completer AgentSessionGrain.LastExcerptAsync : retourner la partie extrait (apres le separateur " : ") du dernier element de _history, ou une chaine vide si aucun tour. L’etat du grain est une List<string> privee — l’exercice force a lire l’etat depuis l’interieur du grain, la ou il vit : aucun appelant n’a d’accreditation pour lire _history directement.

// Exercice 3 — grain-a-grain : la session route sa consommation vers le compteur du modele.
{
    var ex3Psi = new ProcessStartInfo("dotnet", "run --no-build -- ex3")
    {
        WorkingDirectory = labDir,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
    };
    var ex3Proc = Process.Start(ex3Psi)!;
    string ex3Out = ex3Proc.StandardOutput.ReadToEnd();
    ex3Proc.WaitForExit();
    Console.WriteLine(ex3Out.TrimEnd());
}
[silo] demarre, scenario=ex3
Exercice 3 a completer : RouteTokensAsync ne route rien par defaut
Exercice 3 a completer : RouteTokensAsync ne route rien par defaut
Exercice 3 a completer : RouteTokensAsync ne route rien par defaut
[ex3] compteur qwen3-coder=0 (attendu 1700), session-route-a=0 (attendu 800), session-route-b=0 (attendu 900)
[ex3] RouteTokensAsync est encore le stub : complete Grains.cs puis relance cette cellule

Exercice 3 — routage grain-a-grain

Completer AgentSessionGrain.RouteTokensAsync : cumuler dans _tokenTotal et deleguer au grain ITokenCounterGrain identifie par modelKey (via GetGrainFactory()). C’est le motif central d’une topologie d’agents : un grain qui en appelle un autre, sans service bus ni orchestrateur externe. Verifie par le scenario : compteur global 1700, sessions 800 et 900 — chaque session cumule localement ET le compteur du modele agrege les deux.

// Garde SOTA : le lab utilise le VRAI package Orleans (pas de reimplementation jouet).
using System.IO;
{
    string csproj = File.ReadAllText(Path.Combine(labDir, "OrleansAgentLab.csproj"));
    bool orleansRef = csproj.Contains("Microsoft.Orleans.Server") && csproj.Contains("10.3.1");
    bool hostingReel = File.ReadAllText(Path.Combine(labDir, "Program.cs")).Contains("UseOrleans");
    int nbGrains = File.ReadAllText(Path.Combine(labDir, "Grains.cs")).Split("interface I").Length - 1;
    Console.WriteLine($"Microsoft.Orleans.Server 10.3.1 reference : {orleansRef}");
    Console.WriteLine($"silo reel via UseOrleans (co-host en memoire) : {hostingReel}");
    Console.WriteLine($"interfaces de grains dans le lab : {nbGrains}");
}
Microsoft.Orleans.Server 10.3.1 reference : True
silo reel via UseOrleans (co-host en memoire) : True
interfaces de grains dans le lab : 2

Ce que ce notebook ne couvre pas (limites honnetes)

  • Persistence : l’etat des grains ici est volatil (memoire du silo). En production, Orleans branche un fournisseur de storage (AddAdoNetGrainStorage, DynamoDB…) — hors scope de la Part 1.
  • Clustering : le lab utilise UseLocalhostClustering ; la mise a l’echelle multi-silo (plusieurs process, placement automatique des grains) est le sujet des episodes suivants de la serie.
  • Timers/Reminders, Streams, Transactions : non exerces ici.
  • Placement : le silo local place tout chez lui ; la politique de placement (charge, localite) est invisible dans un lab mono-silo.

Ou aller ensuite

Le registre de la serie (distilled-axes-registry.md) suit les parutions : la veille #10475 distille les prochains billets des qu’ils paraissent. Cote Orleans, le saut naturel est le grain IGrainWithStringKey vers IGrainWithGuidKey (identites generees) et le co-hosting dans un AppHost Aspire — les deux axes deja livres de la serie.

Retour au sommet