.NETC#AI EngineeringDevOpsRésilienceMicrosoft.Extensions.AI

Rendez un service IA .NET plus fiable avec le routage de modèles, le repli contrôlé, des tests de panne et des signaux DevOps actionnables.

Yva Hajatiana
20 août 2026
13 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale abstraite sur le routage, la résilience et les services IA

Résilience IA .NET : routage et failover testés

Un service IA en production dépend rarement d’un seul modèle immuable. Une limite de débit, une indisponibilité régionale, une hausse de latence ou un budget dépassé peuvent rendre une bonne intégration locale insuffisante. Répéter la même requête sans stratégie peut au contraire amplifier l’incident et la facture.

La version 10.9.0 de Microsoft.Extensions.AI introduit des clients de routage expérimentaux : RoutingChatClient, SemanticRoutingChatClient, FailoverChatClient et OrderedFailoverChatClient. Ils implémentent tous IChatClient. On peut donc composer une stratégie de sélection et de repli autour des mêmes clients de modèles, sans réécrire le reste de l’application.

Ce tutoriel construit une couche de réponse résiliente : un modèle principal traite la demande, un modèle de secours prend le relais lorsqu’un appel échoue avant d’avoir produit une réponse, et une suite de tests prouve ce comportement sans appeler un fournisseur. Le but n’est pas de masquer les pannes ; il est de préserver un service utile tout en rendant les dégradations visibles à l’équipe DevOps.

API expérimentales. Les types de routage sont marqués [Experimental] avec le diagnostic MEAI001. Épinglez la version NuGet, isolez l’avertissement au plus près du code concerné et validez chaque montée de version dans une branche de test avant de la promouvoir.

Ce que le failover garantit — et ce qu’il ne garantit pas

OrderedFailoverChatClient essaie les clients dans l’ordre fourni. Si le premier lève une exception avant de retourner une réponse, le suivant est essayé. Pour une réponse en streaming, le repli n’est possible que tant qu’aucune mise à jour n’a été exposée à l’appelant : une fois qu’un fragment est visible, basculer vers un autre modèle créerait une réponse incohérente.

SituationComportement à concevoir
Timeout, erreur réseau ou surcharge avant réponseLe client de secours peut être essayé.
Réponse non pertinente mais techniquement réussiePas de repli automatique : la qualité relève d’une évaluation distincte.
Échec après le premier fragment de streamingÉchec terminal : affichez un état de reprise ou demandez une nouvelle tentative.
Annulation par l’utilisateurPas de nouvelle tentative.
401, quota épuisé ou mauvaise configurationN’insistez pas : alertez et corrigez l’identité ou la configuration.

Cette frontière est saine. Un failover est une politique de disponibilité, non un juge de pertinence. Pour traiter les régressions de contenu, complétez-le par un corpus et des évaluations CI, comme dans le guide Évaluer une IA .NET dans une pipeline CI.

Prérequis

Il faut le SDK .NET 10 ou ultérieur, deux déploiements ou fournisseurs de chat compatibles, et une application qui utilise déjà IChatClient ou peut y être adaptée. Les tests unitaires présentés plus loin ne nécessitent aucune clé API.

Créez une solution de démonstration :

dotnet new sln --name ResilientChat
dotnet new console --framework net10.0 --name ResilientChat.Api
dotnet new xunit --framework net10.0 --name ResilientChat.Tests
dotnet sln ResilientChat.sln add ResilientChat.Api ResilientChat.Tests

dotnet add ResilientChat.Api package Microsoft.Extensions.AI --version 10.9.0
dotnet add ResilientChat.Api package Microsoft.Extensions.AI.OpenAI
dotnet add ResilientChat.Tests reference ResilientChat.Api
dotnet add ResilientChat.Tests package Microsoft.Extensions.AI --version 10.9.0
dotnet add ResilientChat.Tests package Moq

L’adaptateur OpenAI n’est qu’un exemple. IChatClient a précisément pour rôle de découpler le code applicatif d’un fournisseur : vous pouvez adapter un client Azure OpenAI, OpenAI, un modèle local ou un client interne, à condition de vérifier les capacités réellement communes.

Définissez les variables localement, sans les écrire dans appsettings.json ni dans le dépôt :

$env:OPENAI_API_KEY = "votre-cle-locale"
$env:PRIMARY_CHAT_MODEL = "votre-modele-principal"
$env:BACKUP_CHAT_MODEL = "votre-modele-secours"

En production, remplacez la clé par une identité managée ou le mécanisme d’authentification approprié au fournisseur. Le compte de l’application doit seulement appeler les déploiements nécessaires ; il n’a aucun besoin d’être administrateur de la ressource IA.

1. Construire un client principal et un secours

Placez cette fabrique dans ResilientChat.Api/ResilientChatClientFactory.cs. Chaque route possède son propre client et donc ses propres options. Le routeur choisit un client ; il ne doit pas modifier les paramètres du client choisi au hasard selon une tentative précédente.

using Microsoft.Extensions.AI;
using OpenAI;

namespace ResilientChat.Api;

public static class ResilientChatClientFactory
{
    #pragma warning disable MEAI001
    public static IChatClient Create(string apiKey, string primaryModel, string backupModel)
    {
        var openAi = new OpenAIClient(apiKey);

        IChatClient primary = openAi
            .GetChatClient(primaryModel)
            .AsIChatClient();

        IChatClient backup = openAi
            .GetChatClient(backupModel)
            .AsIChatClient();

        return new OrderedFailoverChatClient([primary, backup])
        {
            MaximumAttemptsPerRequest = 2,
        };
    }
    #pragma warning restore MEAI001
}

Le MaximumAttemptsPerRequest est une limite de sécurité : elle évite qu’un ajout futur de routes augmente silencieusement le nombre d’appels facturés par requête. Dans cet exemple, le second modèle est un secours, pas une répétition du premier ; les deux devraient idéalement appartenir à des zones de défaillance différentes. Tester deux noms de modèles qui partagent le même quota ou la même région ne protège pas contre l’incident que l’on prétend absorber.

Appelez ensuite le client depuis le point d’entrée HTTP. Gardez un timeout global : un repli ne doit jamais permettre à une requête utilisateur de vivre indéfiniment.

using Microsoft.Extensions.AI;
using ResilientChat.Api;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/answer", async (Question request, CancellationToken requestAborted) =>
{
    string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
        ?? throw new InvalidOperationException("OPENAI_API_KEY is required.");
    string primary = Environment.GetEnvironmentVariable("PRIMARY_CHAT_MODEL")
        ?? throw new InvalidOperationException("PRIMARY_CHAT_MODEL is required.");
    string backup = Environment.GetEnvironmentVariable("BACKUP_CHAT_MODEL")
        ?? throw new InvalidOperationException("BACKUP_CHAT_MODEL is required.");

    using var timeout = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
    timeout.CancelAfter(TimeSpan.FromSeconds(20));

    using IChatClient chatClient = ResilientChatClientFactory.Create(apiKey, primary, backup);
    ChatResponse response = await chatClient.GetResponseAsync(
        [new ChatMessage(ChatRole.User, request.Text)],
        cancellationToken: timeout.Token);

    return Results.Ok(new { answer = response.Text });
});

app.Run();

public sealed record Question(string Text);

Pour une API qui reçoit un trafic significatif, créez les clients une fois au démarrage et enregistrez-les dans l’injection de dépendances ; ne créez pas un client réseau par requête. Ici, la création est laissée dans le handler pour rendre l’exemple autonome. Vérifiez aussi les règles de cycle de vie du SDK fournisseur avant de disposer un client partagé.

2. Tester le repli sans dépendre d’un modèle

Un test de résilience ne doit pas attendre une panne réelle ni consommer des jetons. Avec Moq, simulez le premier client qui échoue et le second qui répond. Ajoutez ResilientChat.Tests/OrderedFailoverChatClientTests.cs :

using Microsoft.Extensions.AI;
using Moq;

namespace ResilientChat.Tests;

public sealed class OrderedFailoverChatClientTests
{
    [Fact]
    public async Task Uses_backup_when_primary_fails_before_a_response()
    {
        var primary = new Mock<IChatClient>(MockBehavior.Strict);
        var backup = new Mock<IChatClient>(MockBehavior.Strict);

        primary
            .Setup(client => client.GetResponseAsync(
                It.IsAny<IEnumerable<ChatMessage>>(),
                It.IsAny<ChatOptions>(),
                It.IsAny<CancellationToken>()))
            .ThrowsAsync(new HttpRequestException("Primary provider is unavailable."));

        backup
            .Setup(client => client.GetResponseAsync(
                It.IsAny<IEnumerable<ChatMessage>>(),
                It.IsAny<ChatOptions>(),
                It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Réponse de secours.")));

        #pragma warning disable MEAI001
        using var client = new OrderedFailoverChatClient([primary.Object, backup.Object])
        {
            MaximumAttemptsPerRequest = 2,
        };
        #pragma warning restore MEAI001

        ChatResponse response = await client.GetResponseAsync(
            [new ChatMessage(ChatRole.User, "État du déploiement ?")]);

        Assert.Equal("Réponse de secours.", response.Text);
        primary.VerifyAll();
        backup.VerifyAll();
    }

    [Fact]
    public async Task Stops_after_the_configured_attempt_limit()
    {
        var first = new Mock<IChatClient>();
        var second = new Mock<IChatClient>();
        var third = new Mock<IChatClient>();

        first.Setup(client => client.GetResponseAsync(
            It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
            .ThrowsAsync(new HttpRequestException("first failed"));
        second.Setup(client => client.GetResponseAsync(
            It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
            .ThrowsAsync(new HttpRequestException("second failed"));

        #pragma warning disable MEAI001
        using var client = new OrderedFailoverChatClient([first.Object, second.Object, third.Object])
        {
            MaximumAttemptsPerRequest = 2,
        };
        #pragma warning restore MEAI001

        await Assert.ThrowsAsync<HttpRequestException>(() => client.GetResponseAsync(
            [new ChatMessage(ChatRole.User, "Bonjour")]));

        first.VerifyAll();
        second.VerifyAll();
        third.Verify(client => client.GetResponseAsync(
            It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()), Times.Never);
    }
}

Lancez les tests :

dotnet test ResilientChat.Tests

Ajoutez aussi un test d’annulation adapté à votre client : annulez le jeton avant l’appel et vérifiez qu’aucun secours n’est sollicité. Pour le streaming, testez explicitement l’expérience produit après le premier fragment : le contrat du framework est de ne pas mélanger deux modèles au milieu d’une réponse. Préparez un message de reprise côté interface, par exemple « La génération a été interrompue, réessayez », plutôt qu’un second texte qui contredirait le premier.

3. Router par coût ou par capacité, puis mettre un secours derrière

Le repli seul choisit toujours le premier client disponible. Si votre application contient des demandes de difficulté différente, placez un routeur simple dans la première position du failover :

#pragma warning disable MEAI001
IChatClient cheapClient = CreateClient("modele-rapide");
IChatClient capableClient = CreateClient("modele-raisonnement");
IChatClient emergencyClient = CreateClient("modele-secours");

IChatClient routed = RoutingChatClient.Create((context, cancellationToken) =>
{
    string prompt = context.Messages.LastOrDefault()?.Text ?? string.Empty;
    bool needsReasoning = prompt.Length > 1_000 || prompt.Contains("analyse", StringComparison.OrdinalIgnoreCase);

    return new ValueTask<IChatClient>(needsReasoning ? capableClient : cheapClient);
});

IChatClient resilient = new OrderedFailoverChatClient([routed, emergencyClient])
{
    MaximumAttemptsPerRequest = 2,
};
#pragma warning restore MEAI001

La règle ci-dessus est volontairement déterministe et facile à tester. Commencez par des signaux métier explicites : type de tâche, taille contrôlée du prompt, besoin de vision, sortie structurée ou région autorisée. Une règle cachée dans un prompt est difficile à auditer et à faire évoluer.

SemanticRoutingChatClient est pertinent lorsque les intentions sont nombreuses et stables : il compare le dernier message utilisateur à des exemples fournis pour chaque client. Il faut toutefois mesurer son coût : les exemples sont vectorisés puis mis en cache, mais chaque nouveau message doit tout de même être vectorisé. Pour une conversation multi-tour, épinglez la route dans votre état de session après une réponse réussie. Re-router chaque tour peut perdre le cache de prompt et, avec certains modèles de raisonnement, rendre des artefacts de conversation non transférables.

Le placement des composants change aussi le comportement. Un routeur autour d’un client qui invoque des outils conserve la même route pour toute la boucle d’outils. Si vous souhaitez employer un modèle puissant pour décider puis un modèle économique après le résultat d’un outil, le routeur doit être intégré plus bas dans la chaîne. Documentez ce choix : il touche à la fois le coût, la qualité et l’identité qui accède aux outils.

4. Transformer les tentatives en signaux DevOps

Chaque tentative de FailoverChatClient expose le client appelé, sa durée, l’exception éventuelle, le fait que la réponse est complète et, en streaming, le délai avant le premier fragment. Ces données sont plus utiles que le seul taux d’erreur HTTP de l’API : elles permettent de voir qu’un fournisseur est lent mais que le service reste disponible grâce au secours.

Suivez au minimum :

  • le nombre de requêtes par route et par modèle ;
  • le taux de bascule et le nombre moyen de tentatives ;
  • les erreurs classées (timeout, limitation, authentification, autre) ;
  • la durée de chaque tentative et le temps au premier fragment ;
  • les requêtes terminées après une sortie de streaming commencée ;
  • les tokens et le coût, par route et par environnement.

N’enregistrez ni prompt ni réponse brute par défaut. Les attributs de route, de fournisseur, de modèle, de statut et de catégorie d’erreur suffisent généralement à alerter. Pour relier ces signaux aux traces applicatives, conservez le même Activity que la requête HTTP et appliquez les précautions de contenu décrites dans Observer un LLM .NET avec OpenTelemetry et Aspire.

Une alerte utile n’est pas « une bascule a eu lieu ». Un unique secours peut être normal. Préférez par exemple : « taux de bascule supérieur à 5 % pendant 10 minutes », « aucun client n’a répondu pendant 3 minutes » et « erreur d’authentification observée ». La dernière doit ouvrir un incident de configuration, pas déclencher une nouvelle vague de retries.

5. Exécuter les bons contrôles dans la CI

Les tests unitaires de sélection et de repli s’exécutent à chaque pull request. Un test d’intégration réel, plus coûteux, est réservé à une branche protégée ou à une exécution planifiée. Il vérifie que les deux fournisseurs, leurs identités et leurs limites sont encore compatibles sans confier de secret à des forks.

name: verify-resilient-chat

on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "17 5 * * 1-5"

permissions:
  contents: read

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - run: dotnet test ResilientChat.Tests --configuration Release

  provider-smoke:
    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
    needs: unit
    runs-on: ubuntu-latest
    environment: ai-smoke
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      # Authentifiez ici une identité fédérée limitée à l'inférence.
      - run: dotnet test ResilientChat.IntegrationTests --configuration Release
        env:
          PRIMARY_CHAT_MODEL: ${{ vars.PRIMARY_CHAT_MODEL }}
          BACKUP_CHAT_MODEL: ${{ vars.BACKUP_CHAT_MODEL }}

La variable de modèle peut être publique si votre organisation le décide ; un jeton, une clé ou une chaîne de connexion ne le doit pas. Le filtre de fork n’est pas une option décorative : GitHub ne transmet pas les secrets aux forks par défaut, et contourner cette protection pour tester une IA revient à offrir une exfiltration de credentials à toute pull request.

Avant une promotion, exécutez une campagne de panne contrôlée dans un environnement de test : rendez le primaire indisponible, vérifiez le repli, puis rendez le secours indisponible et vérifiez le message d’erreur ainsi que l’alerte. Ne provoquez jamais cette panne en production sans runbook, limite de temps et fenêtre approuvée.

Points de vigilance

  • Ne basculez pas sur toute exception. Classez les erreurs si le SDK le permet. Un refus d’authentification ou une requête invalide doit échouer vite ; un timeout transitoire peut justifier le secours.
  • Évitez les doubles effets de bord. Une requête qui déclenche un outil, une écriture ou une transaction doit avoir une clé d’idempotence. Répéter un appel après une erreur ambiguë peut exécuter l’action deux fois.
  • Conservez les capacités. Le secours doit accepter les mêmes entrées importantes : format structuré, outils, taille de contexte, image ou contraintes régionales. Sinon, refusez la fonctionnalité avant l’appel plutôt que de dégrader silencieusement le résultat.
  • Prévenez le coût en cascade. Fixez un maximum de tentatives, un timeout total et, si nécessaire, un budget par tenant. Un modèle plus cher ne doit pas devenir le secours universel sans observabilité.
  • Ne cachez pas l’incident. Une bascule réussie est une dégradation gérée. Elle mérite un tableau de bord, une alerte de seuil et une procédure de retour à la normale.
  • Préparez les mises à jour. Les APIs sont expérimentales ; conservez les tests de limite, d’annulation et de streaming lors de toute mise à niveau de Microsoft.Extensions.AI.

Ressources officielles

Le routage ne rend pas un modèle infaillible. Il donne cependant à une équipe .NET un contrat plus réaliste : une panne de fournisseur est contenue, ses effets sont mesurés, et l’application conserve une réponse cohérente ou échoue explicitement. C’est une fondation simple pour faire évoluer disponibilité, coût et qualité sans cacher les compromis à l’exploitation.

Mots-clés :.NETC#AI EngineeringDevOpsRésilienceMicrosoft.Extensions.AI
Y

Yva Hajatiana

Articles techniques sur l'ingénierie logicielle, .NET, le cloud et l'intelligence artificielle appliquée aux applications.