In questo articolo
- 🧠 Il problema: l'LLM non ricorda niente
- 🏗️ Architettura: lo store ricorda tutto, il modello vede poco
- ⚙️ Setup: progetto e pacchetti
- 🗄️ Persistenza su MongoDB: $push, non replace
- ✂️ IChatReducer: taglio secco o riassunto
- 🏭 La factory: strategia da configurazione
- 🔁 Il cuore: carica, riduci, chiedi, persisti
- 🌐 Endpoint e wiring: Minimal API fino in fondo
- 🐳 Dipendenze in Docker: Mongo, Ollama e il pull del modello
- 🔬 La memoria osservabile: i numeri a ogni risposta
- 🧪 Test senza Mongo e senza Ollama
- ⚠️ Limiti e trade-off da conoscere
- 📦 GitHub repo
- ✅ Checklist finale: la memoria in sei mosse
🧠 Il problema: l'LLM non ricorda niente
Un `IChatClient` è stateless: ogni chiamata riparte da zero e vede solo i messaggi che gli passo in quella richiesta. La «memoria» di una chat è quindi un'illusione che costruisce il chiamante: mantengo io la lista dei turni e la ripasso tutta al modello a ogni messaggio nuovo.
L'approccio ingenuo — una `List<ChatMessage>` che cresce per sempre — ha tre problemi che si presentano in quest'ordine: i token (e quindi costi e latenza) crescono a ogni turno; la context window del modello prima o poi trabocca e i provider troncano o rifiutano; e la lista in memoria sparisce al primo riavvio del processo. Servono due cose distinte: persistenza dello storico e controllo di quanto storico arriva al modello.
- Token e latenza: prompt sempre più lunghi a ogni turno, lineare col numero di messaggi.
- Context window: oltre il limite del modello la conversazione si rompe, non rallenta.
- Volatilità: la storia in RAM muore col processo; una chat vera sopravvive al deploy.
// Funziona. Poi la conversazione arriva a 60 turni…
List<ChatMessage> history = [];
while (true)
{
history.Add(new(ChatRole.User, Console.ReadLine()!));
// TUTTA la storia, a OGNI turno: token e latenza lineari,
// context window a tempo, zero persistenza.
var response = await client.GetResponseAsync(history);
history.AddMessages(response);
}IChatClient · Microsoft Learn ↗
🏗️ Architettura: lo store ricorda tutto, il modello vede poco
Il pattern che implemento separa due responsabilità che l'approccio ingenuo confonde: cosa conservo e cosa mando al modello. MongoDB tiene la trascrizione completa di ogni conversazione — è l'audit trail, non si tocca mai. Il prompt per il modello invece viene ricostruito e ridotto a ogni turno: system prompt, più la vista che il reducer decide.
Il flusso di ogni messaggio è: carico la conversazione da Mongo, ricompongo il prompt completo, lo passo all'`IChatReducer`, mando al modello solo il ridotto, salvo il nuovo turno (domanda + risposta) con un update parziale. La riduzione è una vista in lettura: non cancella niente dallo store.
MongoDB (storico)
- Trascrizione completa, per sempre
- Cresce con $push, mai riscritta
- Sopravvive a riavvii e deploy
- Serve ad audit, debug, UI
Prompt al modello
- Ricostruito a ogni turno
- Ridotto da IChatReducer
- Ultimi N o riassunto + coda
- Dimensione sotto controllo, sempre
Lo store è la verità completa; il prompt è una proiezione usa-e-getta costruita a ogni turno.
⚙️ Setup: progetto e pacchetti
Tre pacchetti: Microsoft.Extensions.AI per `IChatClient` e i reducer, OllamaSharp che implementa `IChatClient` sopra Ollama, MongoDB.Driver per la persistenza. La chat reduction è marcata sperimentale (diagnostica `MEAI001`): la silenzio nel `.csproj` con un `NoWarn` consapevole — l'API può cambiare, il pattern no.
Il progetto è una Minimal API .NET 9 pulita: niente Controllers, DTO tipizzati come record, un servizio per il flusso di chat e un repository per Mongo. La struttura completa è nel repo linkato in fondo all'articolo.
$ dotnet new webapi -n ChatMemory.Api --no-https
$ cd ChatMemory.Api
$ dotnet add package Microsoft.Extensions.AI # 10.9.0
$ dotnet add package OllamaSharp # 5.4.30
$ dotnet add package MongoDB.Driver # 3.11.0
# nel .csproj: i reducer sono experimental (MEAI001)
# <NoWarn>$(NoWarn);MEAI001</NoWarn>Microsoft.Extensions.AI · Microsoft Learn ↗
🗄️ Persistenza su MongoDB: $push, non replace
Ogni conversazione è un documento: id, titolo, timestamp e l'array `messages` con ruolo, testo e data di ogni turno. La scelta importante è come scrivo: append con `$push`, mai replace del documento. Due turni concorrenti sulla stessa conversazione si accodano entrambi invece di sovrascriversi a vicenda, e non riscrivo mai byte che non sono cambiati.
Il repository espone tre operazioni — crea, leggi, accoda — dietro un'interfaccia: nei test la sostituisco con una versione in memoria senza toccare il resto del codice.
// Update parziale di proposito: $push del nuovo turno,
// mai il replace dell'intero documento.
public Task AppendMessagesAsync(
string id, IReadOnlyList<StoredMessage> messages, CancellationToken ct) =>
collection.UpdateOneAsync(
c => c.Id == id,
Builders<Conversation>.Update
.PushEach(c => c.Messages, messages)
.Set(c => c.UpdatedAt, DateTime.UtcNow),
cancellationToken: ct);✂️ IChatReducer: taglio secco o riassunto
`Microsoft.Extensions.AI` definisce IChatReducer — un'unica operazione, `ReduceAsync`, che prende la lista di messaggi e ne restituisce una più corta — e ne fornisce due implementazioni. MessageCountingChatReducer tiene i messaggi di sistema più gli ultimi N: costo zero, ma tutto quello che esce dalla finestra è dimenticato. SummarizingChatReducer invece, superata una soglia, chiede a un LLM di riassumere i turni vecchi e sostituisce quei messaggi col riassunto, conservando la coda recente.
Il summarizing ha due parametri che vale la pena capire: `targetCount` (quanti messaggi recenti sopravvivono) e `threshold` — quanti messaggi oltre il target tollero prima di ri-riassumere. La soglia evita di pagare una chiamata LLM extra a ogni singolo turno: il riassunto si rifà solo quando la coda è cresciuta abbastanza. Nel progetto la strategia è configurazione, non codice: una factory legge `appsettings.json` e monta il reducer giusto.
- counting: gratis e prevedibile; perde il contesto vecchio del tutto.
- summarizing: conserva il senso dei turni vecchi; costa una chiamata LLM quando scatta.
- none: nessuna riduzione — utile come baseline per misurare la differenza.
Il counting taglia, il summarizing comprime: in entrambi i casi lo storico su Mongo resta intero.
🏭 La factory: strategia da configurazione
La factory è l'unico punto che conosce le tre strategie. Il `SummarizingChatReducer` riceve lo stesso `IChatClient` della chat: il riassunto lo genera lo stesso modello locale, nessuna dipendenza in più. In produzione posso puntarlo a un modello più piccolo e veloce dedicato alla summarization.
public sealed class ChatReducerFactory(IOptions<ChatMemoryOptions> options)
{
// "counting" tiene gli ultimi N; "summarizing" piega i vecchi
// turni in un riassunto LLM. Tutto il resto = nessuna riduzione.
public IChatReducer? Create(IChatClient chatClient) =>
options.Value.Strategy.ToLowerInvariant() switch
{
"counting" => new MessageCountingChatReducer(
options.Value.TargetMessageCount),
"summarizing" => new SummarizingChatReducer(
chatClient,
options.Value.TargetMessageCount,
options.Value.SummarizationThreshold),
_ => null,
};
}SummarizingChatReducer · Microsoft Learn ↗
🔁 Il cuore: carica, riduci, chiedi, persisti
Il `ChatService` è il flusso intero in quattro passi. Nota l'ordine: la riduzione avviene dopo aver ricomposto il prompt completo e prima della chiamata al modello; la persistenza salva il turno non ridotto. Lo store non sa nemmeno che esiste un reducer.
La risposta HTTP riporta tre numeri che rendono la memoria osservabile: quanti messaggi ha lo storico completo, quanti ne sono arrivati al modello, e un'anteprima del prompt effettivo. Sono la differenza tra «credo che stia riassumendo» e vederlo succedere turno per turno.
// 1. Prompt completo: system + storico persistito + turno nuovo.
List<ChatMessage> fullPrompt =
[
new(ChatRole.System, options.Value.SystemPrompt),
.. conversation.Messages.Select(m =>
new ChatMessage(new ChatRole(m.Role), m.Text)),
new(ChatRole.User, request.Text),
];
// 2. Riduci: lo store tiene tutto, il modello vede il ridotto.
var prompt = reducer.Value is null
? fullPrompt
: (await reducer.Value.ReduceAsync(fullPrompt, ct)).ToList();
// 3. Chiedi al modello — solo il prompt ridotto.
var response = await chatClient.GetResponseAsync(prompt, cancellationToken: ct);
// 4. Persisti il turno integro (nessuna riduzione in scrittura).
await repository.AppendMessagesAsync(conversationId, turn, ct);
return new SendMessageResponse(
response.Text, fullPrompt.Count, prompt.Count, preview);IChatReducer · Microsoft Learn ↗
🌐 Endpoint e wiring: Minimal API fino in fondo
Tre endpoint bastano: creo una conversazione, mando un messaggio, rileggo la trascrizione. Il wiring in `Program.cs` registra Mongo, l'`OllamaApiClient` come `IChatClient` via `AddChatClient`, la factory e il servizio — niente Controllers, niente cerimonia.
builder.Services.AddSingleton<IConversationRepository,
MongoConversationRepository>();
builder.Services.AddChatClient(sp =>
{
var ollama = sp.GetRequiredService<IOptions<OllamaOptions>>().Value;
return new OllamaApiClient(new Uri(ollama.Endpoint), ollama.Model);
});
builder.Services.AddSingleton<ChatReducerFactory>();
builder.Services.AddSingleton<ChatService>();
var app = builder.Build();
app.MapConversationEndpoints(); // POST /, POST /{id}/messages, GET /{id}
app.Run();Minimal APIs · Microsoft Learn ↗
🐳 Dipendenze in Docker: Mongo, Ollama e il pull del modello
Fuori dall'app servono due servizi: MongoDB e Ollama. Il `docker-compose.yml` del repo li porta su entrambi con healthcheck, più un container one-shot che scarica `llama3.2:3b` nel volume di Ollama ed esce: al primo avvio il pull richiede qualche minuto (~2 GB), dal secondo è istantaneo. Sul portatile non installo niente oltre a Docker.
L'`appsettings.json` punta già a `localhost:27017` e `localhost:11434`, le porte pubblicate dal compose: `docker compose up -d`, `dotnet run`, e la chat risponde.
services:
mongodb:
image: mongo:7
ports: ["27017:27017"]
volumes: [mongo-data:/data/db]
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
volumes: [ollama-data:/root/.ollama]
ollama-init: # one-shot: scarica il modello ed esce
image: ollama/ollama:latest
depends_on: { ollama: { condition: service_healthy } }
environment: ["OLLAMA_HOST=http://ollama:11434"]
entrypoint: ["ollama", "pull", "llama3.2:3b"]🔬 La memoria osservabile: i numeri a ogni risposta
Qui il pattern si vede al lavoro. Dopo una decina di turni lo storico su Mongo è a 23 messaggi, ma `messagesSentToModel` resta a 6: system, il riassunto generato dal reducer, la coda recente e la domanda nuova. Il `promptPreview` mostra esattamente cosa ha visto il modello — compreso il messaggio-riassunto, che riconosco perché condensa i primi turni in una riga.
Questi tre campi in produzione diventano metriche: se `messagesSentToModel` cresce senza tetto, il reducer non sta lavorando; se il riassunto perde informazioni che servivano, lo vedo nel preview prima che l'utente se ne accorga dalla qualità delle risposte.
La riga gialla nel promptPreview è il riassunto: i primi turni compressi in un messaggio.
🧪 Test senza Mongo e senza Ollama
I reducer e il flusso di chat si testano senza servizi esterni: un finto `IChatClient` con risposta programmata (che registra i messaggi ricevuti) e un repository in memoria. Il test più utile verifica la proprietà centrale del pattern: dopo la riduzione il modello ha visto 5 messaggi ma lo store ne conserva 22.
Il fake fa anche da summarizer: quando testo il `SummarizingChatReducer`, la «chiamata LLM» di riassunto restituisce una stringa fissa e posso asserire che finisce nel prompt ridotto. Sei test, sette millisecondi, zero container.
var response = await service.SendAsync(
conversation.Id, new SendMessageRequest("Latest question"), ct);
Assert.Equal(22, response.MessagesInHistory); // system + 20 + nuovo
Assert.Equal(5, response.MessagesSentToModel); // system + ultimi 4
Assert.Equal("Latest question",
chatClient.LastMessages![^1].Text); // il fake registra tutto
var stored = await repository.GetAsync(conversation.Id, ct);
Assert.Equal(22, stored!.Messages.Count); // lo store tiene TUTTO⚠️ Limiti e trade-off da conoscere
La chat reduction è sperimentale (`MEAI001`): l'API può cambiare tra le release di `Microsoft.Extensions.AI` — il pattern store-completo-più-vista-ridotta invece resta valido anche se domani il reducer lo scrivo a mano. Il `SummarizingChatReducer` esclude dal riassunto i messaggi con function call: se uso il tool calling, la parte «attrezzi» della conversazione non viene compressa.
Il riassunto è a sua volta un output LLM: può perdere dettagli, e con un modello piccolo come `llama3.2:3b` succede. Le difese pratiche: coda recente generosa (`targetCount` alto), soglia che eviti ri-riassunti continui, e un occhio al `promptPreview` quando le risposte peggiorano. Per requisiti forti — «ricorda il numero d'ordine detto 40 turni fa» — il riassunto non basta: serve una memoria semantica con embeddings e retrieval, che è il passo successivo naturale di questa architettura.
- API experimental: NoWarn consapevole, pattern stabile anche se l'API cambia.
- Riassunto lossy: dettagli fini possono sparire; il preview li fa notare subito.
- Costo del summarizing: una chiamata LLM extra quando scatta — la soglia la ammortizza.
- Oltre il riassunto: fatti puntuali a lungo termine → embeddings + retrieval, non summarization.
📦 GitHub repo
Tutto il codice dell'articolo è in un repository pubblico: la Minimal API completa (repository Mongo, factory dei reducer, servizio di chat, endpoint), i sei test xunit con il fake `IChatClient`, il `docker-compose.yml` con Mongo, Ollama e il pull automatico del modello, più un `requests.http` pronto per provare i tre endpoint.
Clonato il repo: `docker compose up -d`, `dotnet run`, e la prima conversazione con memoria è questione di un `curl`. La strategia si cambia da `appsettings.json` senza toccare codice.
$ git clone https://github.com/fscamuzzi/chat-memory-llm-dotnet-mongodb.git
$ cd chat-memory-llm-dotnet-mongodb
$ docker compose up -d # Mongo + Ollama + pull llama3.2:3b
$ docker logs -f chatmemory-ollama-init # primo avvio: ~2 GB
$ dotnet test # 6 test, zero servizi esterni
$ dotnet run --project src/ChatMemory.Api
$ curl -s -X POST http://localhost:5210/api/conversations \
-H "Content-Type: application/json" -d '{"title":"Roma"}'chat-memory-llm-dotnet-mongodb · GitHub ↗
✅ Checklist finale: la memoria in sei mosse
Ricapitolo il percorso. La regola che mi porto a casa: lo store e il prompt sono due cose diverse — il primo è la verità completa e non si riduce mai, il secondo è una proiezione usa-e-getta che tengo piccola con il reducer giusto per il caso d'uso. Tutto il resto — strategia, soglie, modello — è configurazione.
- 01Storico su Mongoun documento per conversazione, $push per turno
- 02Prompt ricostruitosystem + storico + messaggio nuovo, a ogni turno
- 03Reducer in mezzocounting o summarizing, da appsettings.json
- 04Modello localeOllama via OllamaSharp come IChatClient
- 05Risposta osservabilemessagesInHistory vs messagesSentToModel
- 06Test col fakeIChatClient programmato, niente container nei test
Domande frequenti su memoria conversazionale LLM
Cos'è un IChatReducer di Microsoft.Extensions.AI?
È un'interfaccia con una sola operazione, ReduceAsync, che prende la lista dei messaggi di una conversazione e ne restituisce una versione più corta da mandare al modello. La libreria fornisce due implementazioni: MessageCountingChatReducer, che tiene i messaggi di sistema più gli ultimi N, e SummarizingChatReducer, che piega i turni più vecchi in un riassunto generato da un LLM. L'API è marcata sperimentale (MEAI001).
Meglio MessageCountingChatReducer o SummarizingChatReducer?
Dipende da quanto conta il contesto vecchio. Il counting è gratis e prevedibile: perfetto se i turni lontani non servono più (supporto operativo, comandi). Il summarizing costa una chiamata LLM quando scatta ma conserva il senso della conversazione: giusto per chat lunghe dove «cosa ci siamo detti all'inizio» resta rilevante. Nel progetto la strategia è configurazione, quindi si può cambiare e misurare senza toccare codice.
Il riassunto del SummarizingChatReducer può perdere informazioni?
Sì: è un output LLM, quindi lossy per natura, e con modelli piccoli la perdita si nota di più. Le difese sono una coda recente generosa, una soglia che eviti di ri-riassumere a ogni turno e l'osservabilità del prompt effettivo per accorgersi subito dei buchi. Per fatti puntuali da ricordare a lungo termine la strada giusta è una memoria semantica con embeddings e retrieval, non il riassunto.
Perché salvare su MongoDB la trascrizione completa se il modello ne vede solo una parte?
Perché store e prompt hanno responsabilità diverse. La trascrizione completa serve ad audit, debug, interfaccia utente e a ricostruire il prompt con qualsiasi strategia futura; la riduzione è solo una vista in lettura costruita al momento della chiamata. Ridurre anche lo storico sarebbe una perdita irreversibile di dati per risparmiare byte che Mongo gestisce senza problemi.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.



