Des événements SSE contenant du JSON composent la réponse « Hello world » dans un terminal illustré du logo ChatGPT

Lorsque ChatGPT ou un autre assistant affiche sa réponse mot après mot, on pourrait croire qu’un WebSocket sophistiqué se cache nécessairement derrière l’interface.

Souvent, la réalité est beaucoup plus simple : une requête HTTP dont la réponse reste ouverte et livre ses événements au fur et à mesure. Ce mécanisme porte un nom : Server-Sent Events, ou SSE.

SSE n’est ni nouveau, ni réservé à l’intelligence artificielle. Sa spécification a été publiée comme recommandation du W3C le 3 février 2015. On l’utilise depuis longtemps pour afficher des notifications, des journaux, la progression d’un traitement ou les données d’un tableau de bord en temps réel. Mais l’arrivée massive des API d’IA lui a offert une seconde jeunesse particulièrement méritée.

Qu’est-ce que Server-Sent Events ?

Avec une requête HTTP classique, le client appelle un serveur, attend la réponse complète, la lit, puis la connexion peut être réutilisée ou fermée.

Avec SSE, le début est identique : le client ouvre une requête HTTP. Le serveur répond avec le type de contenu text/event-stream, mais il ne termine pas immédiatement la réponse. Il conserve le flux ouvert et y écrit de nouveaux événements dès qu’ils sont disponibles.

Échange SSE entre un client .NET et une API : une requête HTTP reçoit plusieurs événements traités dès leur réception

Une seule requête HTTP, plusieurs événements : le client traite chaque fragment dès sa réception. Les noms token et completed sont propres à cet exemple ; ils ne sont pas imposés par SSE.

Une réponse peut ressembler à ceci :

event: token
id: 42
data: {"text":"Bonjour"}

event: token
id: 43
data: {"text":" tout le monde"}

event: completed
data: {"finishReason":"stop"}

La ligne vide est importante : elle marque la fin d’un événement. Chaque événement peut contenir plusieurs champs définis par le protocole :

  • data contient les données, souvent du texte ou du JSON ;
  • event donne un type à l’événement, par exemple token, progress ou completed ;
  • id identifie l’événement et peut aider à reprendre un flux interrompu ;
  • retry suggère au client un délai avant une reconnexion ;
  • une ligne commençant par : est un commentaire, souvent utilisé comme heartbeat pour empêcher les intermédiaires de considérer la connexion comme inactive.

Plusieurs lignes data consécutives appartiennent au même événement et sont réunies avec un saut de ligne.

Le protocole est unidirectionnel : une fois la requête envoyée, les événements circulent du serveur vers le client. Cela ne signifie pas que SSE est limité aux requêtes GET. Une API d’IA peut parfaitement recevoir un prompt dans une requête POST, puis diffuser sa réponse dans le corps HTTP de cette même requête. En revanche, l’API JavaScript native EventSource des navigateurs est, elle, principalement conçue autour de GET.

SSE, polling ou WebSocket ?

Ces trois solutions répondent à des besoins proches, mais pas identiques.

Avec le polling, le client demande régulièrement au serveur s’il existe de nouvelles données. C’est facile à comprendre, mais on multiplie les requêtes inutiles et on ajoute une latence pouvant aller jusqu’au prochain passage.

Un WebSocket ouvre un canal bidirectionnel : le client et le serveur peuvent s’envoyer des messages à tout moment. C’est idéal pour un jeu multijoueur, un éditeur collaboratif ou une conversation réellement interactive dans les deux sens.

SSE occupe un espace intermédiaire très intéressant :

  • une connexion HTTP standard ;
  • des événements reçus dès leur émission ;
  • un format texte simple à inspecter ;
  • aucune couche bidirectionnelle à gérer lorsque le serveur est le seul à devoir pousser des données.

Autrement dit, si le client envoie une commande puis écoute une suite de résultats, SSE est souvent exactement le bon outil. Il ne sert à rien de construire une autoroute à huit voies lorsque tout le trafic va dans la même direction.

Pourquoi les fournisseurs d’IA utilisent autant SSE

La génération d’une réponse par un modèle peut prendre plusieurs secondes, parfois beaucoup plus. Attendre que tout le texte soit produit avant de l’afficher donne l’impression que l’application est bloquée.

En activant le streaming, l’API envoie des fragments dès qu’ils sont disponibles. L’utilisateur voit le début de la réponse rapidement, même si le temps total de génération ne change pas. On améliore donc surtout le temps jusqu’au premier résultat visible, et par conséquent la perception de réactivité.

SSE permet également d’envoyer autre chose que du texte :

  • la création de la réponse ;
  • les fragments de texte successifs ;
  • les arguments d’un appel d’outil en cours de construction ;
  • un changement d’état ;
  • les informations d’usage ;
  • la fin de la génération ou une erreur.

C’est précisément le modèle adopté par de nombreux fournisseurs. L’API Responses d’OpenAI émet des Server-Sent Events lorsque stream vaut true. L’API Chat de Mistral peut envoyer les tokens sous forme d’événements, jusqu’au message final [DONE]. De son côté, l’API Gemini propose streamGenerateContent pour retourner les morceaux de la réponse au fil de leur génération.

Le choix est logique : le client envoie un prompt une fois, puis le serveur produit une séquence ordonnée d’événements. Une connexion HTTP unidirectionnelle suffit dans la majorité des cas.

Produire des SSE avec ASP.NET Core

Depuis .NET 10, ASP.NET Core sait retourner nativement un flux SSE avec TypedResults.ServerSentEvents. Une Minimal API complète peut tenir en quelques lignes :

using System.Runtime.CompilerServices;

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

app.MapGet("/events", (CancellationToken cancellationToken) =>
{
    return TypedResults.ServerSentEvents(
        GetEventsAsync(cancellationToken),
        eventType: "message");
});

app.Run();

static async IAsyncEnumerable<string> GetEventsAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    for (var index = 1; index <= 10; index++)
    {
        yield return $"Événement numéro {index}";
        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

ASP.NET Core se charge du format text/event-stream et écrit chaque valeur dans la réponse sans attendre la fin de l’énumération. Le CancellationToken est essentiel : si le client ferme la connexion, le serveur doit arrêter le travail devenu inutile.

Il est également possible de retourner des SseItem<T> pour définir le type, l’identifiant et les données structurées de chaque événement. Les objets autres que les chaînes sont alors sérialisés en JSON.

Consommer un flux SSE en .NET

.NET 10 fournit aussi SseParser dans l’espace de noms System.Net.ServerSentEvents. Pour consommer correctement le flux avec HttpClient, il faut demander à recevoir la réponse dès que ses en-têtes sont disponibles, récupérer son Stream, créer le parser, puis l’énumérer :

using System.Net.Http.Headers;
using System.Net.ServerSentEvents;

using var httpClient = new HttpClient();
using var cancellationSource =
    new CancellationTokenSource(TimeSpan.FromMinutes(1));

var cancellationToken = cancellationSource.Token;

using var request = new HttpRequestMessage(
    HttpMethod.Get,
    "https://localhost:5001/events");

request.Headers.Accept.Add(
    new MediaTypeWithQualityHeaderValue("text/event-stream"));

using var response = await httpClient.SendAsync(
    request,
    HttpCompletionOption.ResponseHeadersRead,
    cancellationToken);

response.EnsureSuccessStatusCode();

await using var stream =
    await response.Content.ReadAsStreamAsync(cancellationToken);

var parser = SseParser.Create(stream);

await foreach (var item in parser.EnumerateAsync(cancellationToken))
{
    Console.WriteLine($"[{item.EventType}] {item.Data}");
}

Le détail à ne surtout pas manquer est HttpCompletionOption.ResponseHeadersRead. Sans lui, HttpClient considère normalement l’opération comme terminée après avoir lu tout le contenu. Or, un flux SSE peut rester ouvert pendant des minutes ou des heures : attendre sa fin avant de commencer à le traiter annule tout l’intérêt du streaming.

Ce code fonctionne et utilise les API natives. Mais il faut tout de même créer la requête, positionner l’en-tête Accept, choisir le bon mode de lecture, vérifier la réponse, ouvrir le flux et brancher le parser. Et ce n’est que le chemin heureux : dans une application réelle, il faudra aussi décider comment gérer l’annulation, les erreurs réseau et les éventuelles reconnexions.

Avec Tiny.RestClient, c’est beaucoup, beaucoup plus simple

J’avais présenté les bases de la bibliothèque dans mon article consacré à Tiny.RestClient.

La version 2.0 de Tiny.RestClient sur GitHub prend nativement en charge les Server-Sent Events sur .NET Standard 2.1, .NET 8 et .NET 10.

Le package est disponible sur NuGet :

dotnet add package Tiny.RestClient --version 2.0.0

La consommation du même endpoint devient :

using Tiny.RestClient;

var client = new TinyRestClient(
    new HttpClient(),
    "https://localhost:5001");

await foreach (var sse in client
    .GetRequest("events")
    .ExecuteAsSSEAsync(cancellationToken))
{
    Console.WriteLine($"id    : {sse.Id}");
    Console.WriteLine($"event : {sse.Event}");
    Console.WriteLine($"data  : {sse.Data}");
    Console.WriteLine($"retry : {sse.Retry}");
}

C’est tout.

ExecuteAsSSEAsync ouvre la connexion en streaming et retourne chaque événement dès sa réception sous la forme d’un IAsyncEnumerable<ServerSentEvent>. La réponse n’est pas mise en mémoire en attendant sa fin. Les champs standards du protocole sont directement disponibles avec Data, Event, Id et Retry, tandis que les commentaires et les champs inconnus sont ignorés.

L’API fonctionne sur la requête Tiny.RestClient elle-même. Elle reste donc compatible avec ses verbes, ses en-têtes, son authentification et ses contenus. Pour une API d’IA générique utilisant un POST, le principe reste le même :

var body = new
{
    model = "my-model",
    input = "Explique-moi les Server-Sent Events",
    stream = true,
};

await foreach (var sse in client
    .PostRequest("v1/generate", body)
    .WithOAuthBearer(apiKey)
    .ExecuteAsSSEAsync(cancellationToken))
{
    Console.WriteLine($"{sse.Event}: {sse.Data}");
}

Le contenu de Data dépend naturellement du fournisseur et doit généralement être désérialisé depuis du JSON. Tiny.RestClient s’occupe ici du transport SSE ; votre code reste responsable de la sémantique des événements propres à l’API appelée.

Les points à surveiller en production

SSE est simple, mais une connexion longue durée mérite quelques précautions :

  • transmettez toujours un CancellationToken et annulez-le lorsque l’utilisateur quitte l’écran ou abandonne la génération ;
  • réutilisez HttpClient, idéalement via l’injection de dépendances, au lieu d’en créer un pour chaque appel ;
  • vérifiez les timeouts des proxies, load balancers et passerelles placés devant l’API ;
  • désactivez la mise en tampon de la réponse dans les intermédiaires qui la pratiquent ;
  • envoyez des heartbeats si le flux peut rester silencieux longtemps ;
  • prévoyez une stratégie de reconnexion lorsque le cas d’usage l’exige, en tenant compte de retry et du dernier id reçu ;
  • considérez chaque data comme une entrée externe : validez le JSON et gérez les nouveaux types d’événements sans faire tomber tout le flux.

Une reconnexion automatique fait partie du comportement de l’API EventSource des navigateurs, mais pas de tous les clients SSE. Avec un client .NET, cette politique doit être explicite afin d’éviter aussi bien les abandons silencieux que les boucles de reconnexion agressives.

Conclusion

SSE, c’est finalement une connexion HTTP qui n’a pas encore fini de vous raconter sa vie. Et avec Tiny.RestClient, une requête et un await foreach suffisent pour l’écouter.

De mon côté, j’utilise beaucoup SSE dans iolys. Autant dire que cet article n’est pas né uniquement de ma passion pour les lignes vides du protocole.

Au fait, je vous ai parlé de iolys ?

Ce sera pour un prochain article :) Même en streaming, je ne vais pas tout vous envoyer d’un coup !

Pour aller plus loin

Happy coding 🙂