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.
🐢 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.
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.
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.
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.
# 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"🧱 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.
# 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 OllamaSharpMicrosoft.Extensions.AI · Microsoft Learn ↗
⚙️ 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.
{
"Ollama": {
"Endpoint": "http://localhost:11434",
"ChatModel": "llama3.1"
}
}🧩 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.
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;
}
}📡 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.
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 ↗
🚀 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.
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();🖥️ 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.
<!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>🧪 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.
$ 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]🔎 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.
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.
// 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());🛡️ 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.
// 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");✅ 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`.
- 01Ollama + modellollama3.1; endpoint 11434
- 02Progetto + NuGetExtensions.AI + OllamaSharp
- 03appsettings.jsonendpoint e modello
- 04AddStreamingChatIChatClient + UseLogging
- 05Endpoint SSETypedResults.ServerSentEvents
- 06Pagina HTMLwwwroot + file statici
- 07curl -Neventi data/id sul filo
- 08Client fetchreader + split su riga vuota
- 09Palettidone, 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.