cool-solution — dev.blog
Tecnologie

Streaming LLM in ASP.NET Core: risposte token per token con SSE e Microsoft.Extensions.AI

Un endpoint che aspetta trenta secondi e poi scarica tutta la risposta in un colpo solo è il modo più rapido per far sembrare rotto un assistente AI. I prodotti che usi ogni giorno — ChatGPT, Claude, Copilot — mostrano i token mano a mano che il modello li genera. In questo tutorial replico quel comportamento in ASP.NET Core: `GetStreamingResponseAsync` di Microsoft.Extensions.AI legge lo stream da un modello locale con Ollama, e i Server-Sent Events nativi di .NET 10 (`TypedResults.ServerSentEvents`) lo portano fino al browser. Una Minimal API, nessuna libreria di terze parti per il trasporto.

Schema dello streaming LLM: una domanda entra in una Minimal API .NET, il modello Ollama genera la risposta token per token e i frammenti fluiscono verso il browser come eventi Server-Sent Events, palette Cool Solution.

🐢 Perché lo streaming: il problema della risposta monolitica

Un LLM genera testo un token alla volta: la risposta completa a una domanda articolata può richiedere decine di secondi. Se l'endpoint aspetta la fine della generazione per rispondere, l'utente fissa uno spinner per tutto quel tempo — e i timeout di proxy e load balancer iniziano a scattare.

Lo streaming ribalta l'esperienza: il primo frammento può arrivare molto prima della risposta completa e l'utente legge mentre il modello scrive. La latenza totale non cambia, ma la latenza percepita crolla. Il tempo effettivo dipende da modello, hardware e carico.

Risposta monolitica vs. streaming

Senza streaming

  • Attesa di decine di secondi a schermo vuoto
  • Timeout di proxy e gateway sempre in agguato
  • Tutta la risposta bufferizzata in memoria
  • L'annullamento arriva a generazione già pagata

Con streaming

  • Primo token visibile in meno di un secondo
  • Frammenti periodici che riducono il rischio di timeout per inattività
  • Ogni frammento inoltrato appena generato
  • Chiudi la pagina → la generazione si ferma

Stessa latenza totale, esperienza opposta: la percezione la fa il primo token.

🔬 Token dopo token: GetStreamingResponseAsync

In Microsoft.Extensions.AI lo streaming è un metodo dell'interfaccia `IChatClient`: `GetStreamingResponseAsync` restituisce un `IAsyncEnumerable<ChatResponseUpdate>`. Ogni update è un frammento della risposta — di solito uno o pochi token di testo nella proprietà `Text`.

Il bello è che è la stessa astrazione di sempre: il codice che consuma lo stream non sa se dietro c'è Ollama, OpenAI o Azure. E un `await foreach` è tutto ciò che serve per leggerlo, senza mai bufferizzare la risposta completa in memoria.

Da token del modello a ChatResponseUpdate
Flusso dello streaming con Microsoft.Extensions.AI: il modello genera token; GetStreamingResponseAsync li espone come sequenza di ChatResponseUpdate consumata con await foreach; ogni update.Text viene inoltrato subito al client invece di attendere la risposta completa.

Il modello genera, IChatClient espone gli update, await foreach li inoltra: nessun buffer intermedio.

🛰️ SSE: il canale giusto (e ora è nativo in .NET 10)

Per portare i frammenti dal server al browser serve un canale unidirezionale server → client. È esattamente ciò per cui esistono i Server-Sent Events: un normale HTTP con content-type `text/event-stream`, dove ogni evento è testo prefissato da `data:`. È il trasporto usato dalle API di streaming di OpenAI e Anthropic.

Un WebSocket sarebbe sovradimensionato: è bidirezionale, richiede un handshake dedicato e complica proxy e autenticazione. Con .NET 10 i SSE sono diventati first-class in ASP.NET Core: `TypedResults.ServerSentEvents` accetta un `IAsyncEnumerable<SseItem<T>>` e gestisce content-type, serializzazione e formato sul filo. Heartbeat, timeout e policy dei proxy restano responsabilità dell'applicazione e dell'infrastruttura.

  • SSE: HTTP puro e generalmente semplice da inoltrare tramite proxy e CDN, che vanno comunque configurati per non bufferizzare. Perfetto per l'output del modello.
  • WebSocket: bidirezionale e stateful; ha senso per collaborazione o chat multi-utente, non per un flusso di token a senso unico.
  • Polling: semplice ma latente e rumoroso; con un LLM significa perdere proprio l'effetto «scrive mentre leggi».
  • `SseItem<T>` (namespace `System.Net.ServerSentEvents`): incapsula `Data`, `EventType` e `EventId` di ogni evento.

🏗️ L'architettura della soluzione

Costruisco una Minimal API .NET 10 con un solo endpoint `POST /chat/stream`. Il modello gira in locale con Ollama esposto come `IChatClient` da OllamaSharp; l'endpoint trasforma gli `ChatResponseUpdate` in `SseItem<string>` e li restituisce con `TypedResults.ServerSentEvents`.

La catena è tutta lazy: il modello genera un token, Ollama lo spinge sullo stream HTTP locale, `IChatClient` lo espone come update, l'endpoint lo inoltra come evento SSE. Nessun anello della catena aspetta il successivo.

  • Modello: Ollama in locale (`llama3.1`), zero token a pagamento e dati che non escono dalla macchina.
  • Streaming dal modello: `GetStreamingResponseAsync` → `IAsyncEnumerable<ChatResponseUpdate>`.
  • Trasporto verso il client: `TypedResults.ServerSentEvents` + `SseItem<string>`, nativi di ASP.NET Core 10.
  • Pacchetti NuGet: Microsoft.Extensions.AI e OllamaSharp. Per il trasporto non serve nulla.
Architettura: Ollama → IChatClient → SSE
Architettura della Minimal API di streaming: il browser invia POST /chat/stream; l'endpoint chiama IChatClient (Ollama via OllamaSharp) con GetStreamingResponseAsync; gli update tornano come IAsyncEnumerable e vengono inoltrati al browser come eventi Server-Sent Events tramite TypedResults.ServerSentEvents.

Una catena lazy da bordo a bordo: il token generato ora è nel browser un istante dopo.

⬇️ Passo 1 · Ollama e un modello locale

Ollama fa girare il modello sul tuo computer, sull'endpoint http://localhost:11434, e fa streaming di default. Per questo tutorial va bene qualsiasi modello di chat: `llama3.1` è un buon equilibrio tra qualità e requisiti hardware.

Se hai già seguito i miei tutorial su RAG o function calling hai tutto pronto: lo stack è lo stesso, cambia solo come consumo la risposta.

Installa Ollama e scarica il modello
# scarica il modello (una volta sola)
$ ollama pull llama3.1

# il server di solito parte da solo; altrimenti:
$ ollama serve                 # http://localhost:11434

# prova lo streaming dal terminale: i token arrivano uno alla volta
$ ollama run llama3.1 "Spiegami i Server-Sent Events in due frasi"

Ollama · Docs

Già da terminale vedi l'effetto streaming: è lo stesso flusso che porterò fino al browser.

🧱 Passo 2 · Progetto e pacchetti NuGet

Creo una Minimal API con .NET 10 e aggiungo due soli pacchetti: le astrazioni Microsoft.Extensions.AI e OllamaSharp, il cui `OllamaApiClient` implementa direttamente `IChatClient`.

Il punto notevole è ciò che non serve: niente pacchetti per i Server-Sent Events. `TypedResults.ServerSentEvents` e `SseItem<T>` sono parte del framework in .NET 10.

Crea il progetto e aggiungi i pacchetti
# nuova Minimal API su .NET 10
$ dotnet --version             # deve iniziare con 10.
$ dotnet new web -n StreamingChatDemo --framework net10.0 && cd StreamingChatDemo

# astrazioni AI + provider Ollama; per i SSE non serve nulla
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package OllamaSharp

Microsoft.Extensions.AI · Microsoft Learn

Due pacchetti in tutto: il trasporto SSE è nativo di ASP.NET Core 10.

⚙️ Passo 3 · appsettings.json

Endpoint e modello di Ollama vivono in appsettings.json: cambiare modello — o passare domani a un provider cloud — non deve toccare il codice.

appsettings.json
{
  "Ollama": {
    "Endpoint": "http://localhost:11434",
    "ChatModel": "llama3.1"
  }
}
Modello e host stanno qui: la configurazione cambia senza ricompilare.

🧩 Passo 4 · Registrare il chat client

Il wiring sta in una extension method `AddStreamingChat`: registra `OllamaApiClient` come `IChatClient` con la pipeline di Microsoft.Extensions.AI. `UseLogging` è prezioso in sviluppo per vedere gli update passare.

Come sempre con queste astrazioni, l'endpoint dipenderà solo dall'interfaccia: per passare a OpenAI o Azure cambierei solo questa riga di registrazione.

ChatServiceExtensions.cs
using Microsoft.Extensions.AI;
using OllamaSharp;

namespace StreamingChatDemo.Extensions;

// Un'unica extension method registra il chat client: OllamaApiClient
// implementa IChatClient, quindi lo streaming arriva gratis.
public static class ChatServiceExtensions
{
    public static IServiceCollection AddStreamingChat(this IServiceCollection services, IConfiguration config)
    {
        var ollama = config.GetSection("Ollama");
        var chatClient = new OllamaApiClient(new Uri(ollama["Endpoint"]!), ollama["ChatModel"]!);

        services.AddChatClient(chatClient)
            .UseLogging();

        return services;
    }
}

OllamaSharp · GitHub

Il provider sta dietro IChatClient: cambiare modello o cloud è una riga.

📡 Passo 5 · L'endpoint SSE con TypedResults.ServerSentEvents

Il cuore del tutorial. L'endpoint `POST /chat/stream` restituisce `TypedResults.ServerSentEvents` alimentato da un iterator asincrono: un `await foreach` legge gli update dal modello e li rilancia come `SseItem<string>`. Ogni evento porta un `EventId` progressivo; a fine generazione emetto un evento `done` esplicito.

Nota il `CancellationToken`: ASP.NET Core lo collega alla richiesta HTTP, e `[EnumeratorCancellation]` lo propaga dentro l'iterator fino a Ollama. Se l'utente chiude la pagina, la generazione si ferma davvero — non resta un modello che scalda la GPU per nessuno.

ChatEndpoints.cs
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;

namespace StreamingChatDemo.Endpoints;

public record ChatStreamRequest(string Question);

public static class ChatEndpoints
{
    // POST /chat/stream — la risposta del modello fluisce come Server-Sent Events.
    public static void MapChatEndpoints(this IEndpointRouteBuilder app) =>
        app.MapPost("/chat/stream", (ChatStreamRequest req, IChatClient chat, CancellationToken ct) =>
            TypedResults.ServerSentEvents(StreamAnswerAsync(req, chat, ct)));

    // Iterator asincrono: ogni ChatResponseUpdate diventa un SseItem<string>.
    // [EnumeratorCancellation] propaga il token della richiesta HTTP fino a Ollama:
    // client disconnesso => generazione fermata.
    private static async IAsyncEnumerable<SseItem<string>> StreamAnswerAsync(
        ChatStreamRequest req,
        IChatClient chat,
        [EnumeratorCancellation] CancellationToken ct)
    {
        List<ChatMessage> messages =
        [
            new(ChatRole.System, "Rispondi in modo conciso e in italiano."),
            new(ChatRole.User, req.Question)
        ];

        var eventId = 0;
        await foreach (var update in chat.GetStreamingResponseAsync(messages, cancellationToken: ct))
        {
            // alcuni update non portano testo (metadati, usage): li salto
            if (string.IsNullOrEmpty(update.Text)) continue;

            yield return new SseItem<string>(update.Text) { EventId = eventId++.ToString() };
        }

        // evento terminale esplicito: il client sa che lo stream e' completo
        yield return new SseItem<string>("[DONE]", eventType: "done");
    }
}

Minimal API responses · Microsoft Learn

TypedResults.ServerSentEvents gestisce content-type, serializzazione e formato: tu produci gli item.

🚀 Passo 6 · Program.cs: due righe di wiring

Come sempre in una Minimal API ben fattorizzata, il `Program.cs` resta minuscolo: la registrazione del chat client e la mappatura dell'endpoint.

Program.cs
using StreamingChatDemo.Endpoints;
using StreamingChatDemo.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddStreamingChat(builder.Configuration);

var app = builder.Build();

app.UseDefaultFiles();          // serve wwwroot/index.html su /
app.UseStaticFiles();

app.MapChatEndpoints();   // POST /chat/stream

app.Run();
Registrazione e un endpoint: tutta la logica vive nei servizi e nell'iterator.

🖥️ Passo 7 · La pagina HTML minima

Creo `wwwroot/index.html`: contiene tutti gli elementi usati dal JavaScript e carica `stream-client.js`. Poiché pagina ed endpoint sono serviti dalla stessa Minimal API, non serve configurare CORS.

wwwroot/index.html
<!doctype html>
<html lang="it">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Streaming Chat Demo</title>
</head>
<body>
  <form id="chat-form">
    <label for="question">Domanda</label>
    <input id="question" required value="Cos'è un Server-Sent Event?">
    <button>Invia</button>
    <button id="cancel" type="button" disabled>Annulla</button>
  </form>
  <p id="status" role="status"></p>
  <pre id="output"></pre>
  <script src="/stream-client.js"></script>
</body>
</html>
Una pagina completa e senza dipendenze: form, stato, output e pulsante di annullamento.

🧪 Passo 8 · Avvia e prova con curl

Con `curl -N` (no buffering) vedi lo stream nel formato reale sul filo: ogni evento è un blocco `data:`/`id:`, e il flusso si chiude con l'evento `done`. Se i token arrivano uno alla volta, lo streaming end-to-end funziona.

Prova lo stream da terminale
$ dotnet run --urls http://localhost:5000

# in un secondo terminale:
$ curl -N http://localhost:5000/chat/stream \
    -H "Content-Type: application/json" \
    -d '{"question":"Cos e un Server-Sent Event?"}'

# data: Un
# id: 0
#
# data:  Server-Sent
# id: 1
#
# ...
#
# event: done
# data: [DONE]
curl -N disattiva il buffering: ogni blocco data/id è un evento SSE appena generato.

🔎 Sul filo: come appare lo stream

Vale la pena guardare una volta il traffico per capire quanto è semplice il protocollo: testo, righe `data:`, una riga vuota come separatore. Niente frame binari, niente handshake: per questo i SSE attraversano senza drammi proxy, gateway e CDN.

È anche il formato che usano le API di streaming di OpenAI e Anthropic: imparare a produrlo e consumarlo torna utile ben oltre questo tutorial. In produzione verifica sempre buffering e timeout di ogni proxy o CDN attraversato.

Lo stream SSE visto da curl
Terminale che mostra lo stream Server-Sent Events: la richiesta curl a /chat/stream, poi una sequenza di eventi con id progressivo e righe data contenenti i frammenti di testo generati dal modello, e infine l'evento done con data [DONE].

Eventi separati da riga vuota, id progressivi, un evento done in coda: tutto qui il protocollo.

🌐 Passo 9 · Consumare lo stream nel browser

Lato client uso `fetch` con un reader sullo stream del body: l'`EventSource` nativo del browser supporta solo GET, mentre qui la domanda viaggia in un body POST. Normalizzo CRLF/LF, verifico status e body, e considero valida solo una risposta terminata dall'evento `done`.

Ogni frammento viene appeso al DOM appena arriva. Un `AbortController` permette di annullare esplicitamente la richiesta; chiudere o ricaricare la pagina annulla comunque la connessione.

stream-client.js
// Consuma POST /chat/stream: EventSource supporta solo GET,
// quindi leggo lo stream SSE direttamente dal body della fetch.
async function streamAnswer(question, onToken, signal) {
  const response = await fetch("/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ question }),
    signal,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  if (!response.body) throw new Error("Streaming non supportato");

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");

    // gli eventi SSE sono separati da una riga vuota
    const events = buffer.split("\n\n");
    buffer = events.pop() ?? "";

    for (const rawEvent of events) {
      const isDone = rawEvent.split("\n").includes("event: done");
      const data = rawEvent
        .split("\n")
        .filter((line) => line.startsWith("data: "))
        .map((line) => line.slice(6))
        .join("\n");

      if (isDone) return;
      if (data) onToken(data);
    }
  }

  throw new Error("Stream chiuso senza evento done");
}

const form = document.querySelector("#chat-form");
const question = document.querySelector("#question");
const output = document.querySelector("#output");
const status = document.querySelector("#status");
const cancel = document.querySelector("#cancel");
let controller;

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  controller?.abort();
  controller = new AbortController();
  output.textContent = ""; status.textContent = "Generazione…"; cancel.disabled = false;
  try {
    await streamAnswer(question.value, token => output.textContent += token, controller.signal);
    status.textContent = "Completato";
  } catch (error) {
    status.textContent = error.name === "AbortError" ? "Annullato" : `Errore: ${error.message}`;
  } finally { cancel.disabled = true; }
});
cancel.addEventListener("click", () => controller?.abort());

Server-sent events · MDN

Reader sul body, split sugli eventi, righe data: estratte: il client completo sta in trenta righe.

🛡️ Cancellazione, errori e paletti

Lo streaming introduce modalità di fallimento sue: la connessione può cadere a metà risposta, il client può sparire, il modello può rallentare. La buona notizia è che la catena `CancellationToken` + SSE gestisce già i casi principali; restano alcune attenzioni.

  • Cancellazione end-to-end: il token della richiesta HTTP, propagato con `[EnumeratorCancellation]`, ferma la generazione su Ollama quando il client si disconnette. Verificalo nei log: è la differenza tra GPU libera e GPU sprecata.
  • Evento terminale esplicito: la riga `event: done` distingue «stream completato» da «connessione caduta a metà». Il client può fidarsi solo delle risposte che terminano con `done`.
  • Errori a stream iniziato: dopo il primo evento lo status è già 200 e non puoi più cambiarlo. Se il modello fallisce a metà, emetti un evento `error` dedicato invece di troncare in silenzio.
  • Riconnessione: solo `EventSource` si riconnette automaticamente e gestisce `Last-Event-ID`. Il client `fetch` POST di questo tutorial non lo fa: per il resume servono logica applicativa, persistenza degli eventi e invio esplicito dell'ultimo ID.
  • Proxy e buffering: in produzione disattiva il buffering della risposta sul reverse proxy (es. `proxy_buffering off` su nginx), o i token arriveranno a blocchi.
Un evento error dedicato a stream iniziato
// Dopo il primo SseItem lo status HTTP e' gia' partito: un errore del
// modello a meta' stream va comunicato come EVENTO, non come status code.
var completed = false;
var failed = false;

await using var updates = chat.GetStreamingResponseAsync(messages, cancellationToken: ct)
    .GetAsyncEnumerator(ct);

while (!completed && !failed)
{
    try { completed = !await updates.MoveNextAsync(); }
    catch (OperationCanceledException) when (ct.IsCancellationRequested) { yield break; }
    catch (Exception) { failed = true; }   // niente stack trace sul filo

    if (!completed && !failed && !string.IsNullOrEmpty(updates.Current.Text))
        yield return new SseItem<string>(updates.Current.Text);
}

yield return failed
    ? new SseItem<string>("generation_failed", eventType: "error")
    : new SseItem<string>("[DONE]", eventType: "done");
Il client distingue tre esiti: done, error, o connessione caduta senza evento terminale.

✅ Checklist finale e prossimi passi

Ricapitolo il percorso: una Minimal API .NET 10 che legge lo stream di un LLM locale con `GetStreamingResponseAsync` e lo inoltra al browser con i Server-Sent Events nativi — primo frammento visibile appena disponibile, cancellazione end-to-end, zero dipendenze per il trasporto.

Da qui i pezzi si compongono: aggiungi il function calling per far agire il modello mentre lo streaming mostra il ragionamento, o la RAG per rispondere dai tuoi documenti. Le astrazioni sono le stesse: è questo il valore di costruire su `IChatClient`.

Il tutorial in 9 mosse
  1. 01
    Ollama + modellollama3.1; endpoint 11434
  2. 02
    Progetto + NuGetExtensions.AI + OllamaSharp
  3. 03
    appsettings.jsonendpoint e modello
  4. 04
    AddStreamingChatIChatClient + UseLogging
  5. 05
    Endpoint SSETypedResults.ServerSentEvents
  6. 06
    Pagina HTMLwwwroot + file statici
  7. 07
    curl -Neventi data/id sul filo
  8. 08
    Client fetchreader + split su riga vuota
  9. 09
    Palettidone, error, cancellazione

Da qui: streaming + function calling, o streaming + RAG, sulla stessa base IChatClient.

Domande frequenti su streaming LLM ASP.NET Core

Cosa sono i Server-Sent Events (SSE)?

Un meccanismo standard per inviare eventi dal server al client su una normale connessione HTTP: content-type text/event-stream e eventi testuali prefissati da data:. È il trasporto usato dalle API di streaming di OpenAI e Anthropic, e da .NET 10 è supportato nativamente in ASP.NET Core.

Meglio SSE o WebSocket per lo streaming di un LLM?

Per il solo output del modello, SSE: è unidirezionale come il flusso di token e usa HTTP standard. Proxy e CDN vanno configurati per non bufferizzare. La riconnessione automatica è disponibile con EventSource; con il client fetch POST del tutorial va implementata. Il WebSocket ha senso quando serve davvero la bidirezionalità.

Come si fa streaming da un LLM in .NET?

Con Microsoft.Extensions.AI: GetStreamingResponseAsync su IChatClient restituisce un IAsyncEnumerable di ChatResponseUpdate. Ogni update porta un frammento di testo in Text; un await foreach li consuma senza bufferizzare la risposta completa.

Cosa serve per usare TypedResults.ServerSentEvents?

Solo ASP.NET Core in .NET 10: nessun pacchetto aggiuntivo. L'endpoint restituisce TypedResults.ServerSentEvents con un IAsyncEnumerable di SseItem; il framework gestisce content-type, formato sul filo e serializzazione. Eventuali heartbeat e timeout vanno configurati separatamente.

Cosa succede se l'utente chiude la pagina durante la generazione?

Se il CancellationToken della richiesta è propagato fino al provider — con [EnumeratorCancellation] sull'iterator — la disconnessione del client annulla la chiamata a Ollama e la generazione si ferma, liberando subito CPU o GPU.

Perché EventSource non basta come client?

L'EventSource nativo del browser supporta solo richieste GET senza body. Se la domanda viaggia in un POST JSON, si legge lo stream SSE dal body di una fetch con un reader: il parsing sono poche righe, come mostrato nel tutorial.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.

Altri articoli del blog

tips-codex-full-auto-luglio-2026.md17 luglio 2026opencode-tutorial-minimal-api-dotnet.md17 luglio 2026limiti-token-codex-claude-code-2026.md14 luglio 2026