LLM nie ma pamięci. Każde wywołanie API jest niezależne, a pozorna ciągłość rozmowy bierze się stąd, że aplikacja wysyła przy każdym zapytaniu całą dotychczasową konwersację. Gdy aplikacja się zrestartuje, a historia nie została nigdzie zapisana, agent zaczyna od zera. Podobnie jest z rozpoczęciem nowej rozmowy: nawet jeśli użytkownik podał jakieś informacje w pierwszej, w kolejnej agent tego nie wie.
Microsoft Agent Framework rozdziela te dwa problemy na osobne mechanizmy. Sesja (AgentSession) przechowuje przebieg jednej rozmowy i można ją serializować do JSON-a, zapisać gdziekolwiek i odtworzyć w innym procesie. Provider kontekstu (AIContextProvider) dokłada do każdego wywołania dodatkowe instrukcje i narzędzia, a więc nadaje się do pamięci długotrwałej, która żyje poza pojedynczą rozmową.
Nowicjusz z poprzedniego wpisu umiał przeszukiwać bibliotekę wieży (RAG), ale każdą rozmowę zaczynał od zera i nic nie wiedział o rozmówcy. Obecnie pilnuje bramy akademii i prowadzi księgę gości, gdzie notuje informacje o przybyszu. Gdy widzi kogoś po raz pierwszy, pyta o imię i wpisuje je do rejestru. A jeśli kogoś już zna, to po prostu go wita. Co ważne, aplikacja pamięta historię kontaktu, nawet jeśli system się zrestartuje, agent wczytuje rozmowę z bazy i płynnie wraca do przerwanego wątku.
Spis treści
Pokaż spis treści
Jak agent zapamiętuje
Claude Messages API nie ma pojęcia „rozmowy”. Druga wiadomość do nowicjusza, z imieniem użytkownika, trafia do modelu razem z pierwszą wiadomością i pierwszą odpowiedzią:
{
"model": "claude-haiku-4-5-20251001",
"system": "You are a novice mage, a young apprentice assistant...",
"messages": [
{ "role": "user", "content": "Hi!" },
{
"role": "assistant",
"content": "Oh! Hello there! Welcome to the Magic Academy! ... before I let you into the academy, I need to know your name."
},
{ "role": "user", "content": "I am Dawid." }
]
}
Za złożenie tej tablicy z wiadomościami odpowiada sam Agent Framework. Trafiają do niej wiadomości użytkownika, odpowiedzi modelu i wywołania narzędzi (bloki tool_use i tool_result). Historia rośnie z każdą wymianą, a każdy kolejny request kosztuje tokeny wejściowe wszystkich poprzednich (jest jeszcze cache, ale to na inny wpis). Dodatkowo, jeśli aplikacja „straci” tę tablicę (np. przez restart), model nie ma jak odtworzyć rozmowy.
Pamięć krótkotrwała, czyli sesja
W Agent Framework tablica messages znajduje się w obiekcie sesji. Domyślnie trzyma ją InMemoryChatHistoryProvider, który zapisuje wiadomości w słowniku stanu sesji (StateBag). Sesja po serializacji to zwykły JSON, więc store może być dowolny: plik, baza SQL czy Redis. Za zapis i odczyt odpowiada AgentSessionStore, abstrakcyjna klasa z dwiema metodami: GetSessionAsync i SaveSessionAsync. Klucz sesji (AgentSessionStoreKey) to identyfikator rozmowy, do którego można dodać partycje, na przykład identyfikator użytkownika.
Pamięć długotrwała, czyli kontekst
Tablica messages należy do jednej sesji. Nowa konwersacja dostaje pustą tablicę, a imię użytkownika i inne trwałe informacje powinny w jakiś sposób trafić do nowej sesji. Agent Framework nie ma na to gotowego rozwiązania, ale ma punkt zaczepienia: AIContextProvider. Przed każdym RunAsync, czyli przy każdej wiadomości użytkownika, Agent Framework wywołuje ProvideAIContextAsync, a provider zwraca obiekt AIContext z dodatkowymi instrukcjami i narzędziami.
W demo provider robi dwie rzeczy. Wstrzykuje do instrukcji listę zapamiętanych faktów i daje modelowi narzędzie remember_fact. Wywołanie wygląda tak samo jak w poprzednich wpisach, blok tool_use z nazwą narzędzia i argumentami:
{
"type": "tool_use",
"id": "toolu_0149QaA8V5Lpi3N2sLecqLdo",
"name": "remember_fact",
"input": { "fact": "The user's name is Dawid." }
}
Sesja a fakty
Sesja i fakty leżą w tej samej bazie, ale zawierają różne dane. Sesja to tablica messages jednej rozmowy, zapisana słowo w słowo i bez wybierania. Przy kolejnym pytaniu w tej samej rozmowie cała wraca do modelu.
Fakty to wpisy w księdze gości, kilka krótkich zdań w rodzaju „The user’s name is Dawid.”. Jest to kontekst istotny ponad indywidualną sesją, więc trafia do instrukcji w każdej, nowej rozmowie. Trafia tu tylko to, co model uznał za warte zapamiętania i zapisał przez remember_fact. Sesja pozwala więc wrócić do tej samej rozmowy po restarcie, a fakty pozwalają zacząć nową rozmowę z modelem, który już coś wie na temat użytkownika.
Gdzie fakty trafiają w requeście do Messages API
Provider zwraca fakty w polu Instructions obiektu AIContext, a nie w Messages. Agent Framework dokleja te instrukcje do system promptu agenta, a klient Claude wysyła całość w polu system. Pierwsze „Hi!” w nowej rozmowie, gdy w bazie jest już imię, wygląda tak (pola skrócone):
{
"model": "claude-haiku-4-5-20251001",
"system": "You are a novice mage, a young apprentice assistant...\nFacts you remember about the user from earlier conversations:\n- The user's name is Dawid.\nIf this is the first message of the conversation, greet the user by name...",
"tools": [
{
"name": "remember_fact",
"description": "Saves a lasting fact about the user (name, preferences, goals) so it can be recalled in future conversations.",
"input_schema": {
"type": "object",
"properties": {
"fact": {
"type": "string",
"description": "The fact, as one short sentence."
}
},
"required": ["fact"]
}
}
],
"messages": [{ "role": "user", "content": "Hi!" }]
}
Provider nie dokłada faktów do messages, a jedynym ich śladem jest wywołanie remember_fact w rozmowie, w której fakt zapisano. Instrukcje z faktami provider składa przy pierwszym RunAsync rozmowy i zapisuje w sesji, więc pole system zostaje takie samo do końca rozmowy.
Fakt zapisany w trakcie rozmowy trafia do system dopiero w następnej rozmowie. W bieżącej model zna go z historii: z wiadomości użytkownika i z własnego bloku tool_use. Nie ma więc powodu przebudowywać system promptu w połowie rozmowy.
Projekt i paczki
Projekt z tego samego template’u co w pierwszym wpisie: pusta aplikacja ASP.NET Core z DevUI, klient Claude z kluczem w user secrets i paczki Agent Framework w wersji 1.24.0-preview.261006.1. Nowe są dwie paczki SQLite:
dotnet add package Microsoft.Data.Sqlite --version 10.0.0
dotnet add package SQLitePCLRaw.lib.e_sqlite3 --version 2.1.13
Microsoft.Data.Sqlite to lekki provider ADO.NET dla SQLite. Baza nie wymaga osobnego serwera ani Dockera. Jest to jeden plik memory.db.
Druga paczka jest zależnością Microsoft.Data.Sqlite, a dotnet restore automatycznie pobiera ją w wersji 2.1.11. Ta wersja ma znaną podatność (GHSA-2m69-gcr7-jv3q), więc dotnet build wyświetla ostrzeżenie. Dodanie tej biblioteki bezpośrednio do projektu w wersji 2.1.13 sprawia, że NuGet używa nowszej wersji, a ostrzeżenie znika.
AgentSessionStore i AgentSessionStoreKey są w tej wersji Agent Framework oznaczone jako eksperymentalne (atrybut [Experimental]), więc ich API może się jeszcze zmienić. Kompilator zgłasza przy nich błąd MAAI001 i projekt się nie buduje. Wyłącza go jeden wpis w pliku .csproj:
<NoWarn>$(NoWarn);MAAI001</NoWarn>
Session store w SQLite
Klasa SqliteAgentSessionStore dziedziczy po AgentSessionStore i trzyma sesje w jednej tabeli z kluczem i JSON-em.
Zapis sesji
var json = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken);
// ...
command.CommandText = """
INSERT INTO sessions (key, json) VALUES ($key, $json)
ON CONFLICT(key) DO UPDATE SET json = $json
""";
command.Parameters.AddWithValue("$key", ToKey(agent, key));
command.Parameters.AddWithValue("$json", json.GetRawText());
Serializacją zajmuje się agent, a nie store: SerializeSessionAsync zamienia sesję na JsonElement, razem z historią czatu i stanem providerów. Store nie musi znać struktury tego JSON-a. Zapis to zwykły upsert po kluczu.
Odczyt sesji
if (await command.ExecuteScalarAsync(cancellationToken) is not string json)
{
return null;
}
var sessionElement = JsonSerializer.Deserialize<JsonElement>(json);
return await agent.DeserializeSessionAsync(sessionElement, cancellationToken: cancellationToken);
Brak wiersza oznacza null, a wtedy Agent Framework tworzy nową sesję. JsonSerializer.Deserialize<JsonElement> zwraca element z własną kopią danych, więc sesja może go przechowywać bez pilnowania czasu życia JsonDocument.
Wiersz w tabeli sessions jest identyfikowany kluczem {nazwa agenta}:{identyfikator sesji}, który składa metoda ToKey. Identyfikator sesji jest nadawany przez endpoint OpenAI Responses w aplikacji (szczegóły w sekcji „Agent i rejestracja”). Nazwa agenta jest częścią klucza, żeby dwaj agenci korzystający z jednego store’a nie nadpisywali sobie rozmów o tym samym identyfikatorze.
Pamięć długotrwała w GuestBookMemory
Klasa GuestBookMemory dziedziczy po AIContextProvider i korzysta z tej samej bazy, z tabelą facts zawierającą jedną kolumnę z tekstem faktu.
Instrukcje i narzędzie
// In the constructor:
_instructions = new ProviderSessionState<string>(_ => BuildInstructions(), nameof(GuestBookMemory));
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context, CancellationToken cancellationToken = default)
{
// Built on the first run of a conversation and kept in the session,
// so facts saved later do not change the system prompt until the next conversation.
var instructions = _instructions.GetOrInitializeState(context.Session);
return ValueTask.FromResult(new AIContext
{
Instructions = instructions,
Tools = [AIFunctionFactory.Create(RememberFact, "remember_fact")],
});
}
private string BuildInstructions()
{
var facts = ReadFacts();
// The gate: a stranger must give a name, a known student is welcomed back.
return facts.Count == 0
? "You do not know the user yet. Before you help with anything, stop them at the gate and say: "
+ "\"Before I let you into the academy, I need to know your name.\" "
+ "When the user shares their name or another lasting fact about themselves, call remember_fact."
: "Facts you remember about the user from earlier conversations:\n"
+ string.Join("\n", facts.Select(f => $"- {f}"))
+ "\nIf this is the first message of the conversation, greet the user by name as a returning student, "
+ "for example: \"Ah, <name>! It's nice to see you again.\""
+ "\nWhen the user shares a new lasting fact about themselves, call remember_fact.";
}
Za to, że instrukcje powstają raz na rozmowę, odpowiada ProviderSessionState<string>. To helper z Agent Framework, który trzyma stan providera w StateBag sesji pod kluczem (stateKey) podanym w konstruktorze, tutaj GuestBookMemory. Przy pierwszym RunAsync rozmowy GetOrInitializeState nie znajduje tego klucza, więc wywołuje BuildInstructions i zapisuje wynik w sesji. Kolejne wywołania dostają gotowy tekst i nie czytają tabeli facts. StateBag jest serializowany razem z sesją, więc instrukcje trafiają do SQLite i po restarcie aplikacji rozmowa dostaje te same instrukcje.
Od tego, co jest w bazie, gdy rozmowa się zaczyna, zależy też zachowanie nowicjusza. Pusta tabela faktów oznacza obcego, więc instrukcja nakazuje zatrzymać go przy bramie akademii i zapytać o imię. Gdy w tabeli są już wpisy, instrukcja nakazuje przywitać użytkownika po imieniu. Provider sam nie sprawdza historii. To model widzi w requeście wcześniejsze wiadomości z sesji (w tablicy messages) i ocenia, czy to jest początek rozmowy.
Zapis faktu
[Description("Saves a lasting fact about the user (name, preferences, goals) so it can be recalled in future conversations.")]
private string RememberFact([Description("The fact, as one short sentence.")] string fact)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "INSERT OR IGNORE INTO facts (fact) VALUES ($fact)";
command.Parameters.AddWithValue("$fact", fact);
command.ExecuteNonQuery();
return "Saved.";
}
Opisy w atrybutach Description trafiają do definicji narzędzia i to na ich podstawie model ocenia, kiedy wywołać konkretne narzędzie oraz co jest „trwałym faktem”. INSERT OR IGNORE jest tu trochę naiwnym podejściem, ponieważ sama treść faktu jest kluczem głównym, po którym sprawdzane są duplikaty. Jak wiadomo, LLM-y są niedeterministyczne, więc ten sam fakt może znajdować się w tabeli kilkukrotnie, w trochę zmienionej formie. Zwrócone „Saved.” wraca do modelu jako wynik narzędzia.
Agent i rejestracja
var notebookAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "NoviceMageNotebook",
ChatOptions = new ChatOptions { Instructions = systemPrompt },
AIContextProviders = [new GuestBookMemory(connectionString)],
});
ChatOptions.Instructions to bazowy system prompt nowicjusza, ten sam co w poprzednich wpisach. Tam przypisywany był do parametru instructions w konstruktorze, a tutaj trafia do ChatOptions, ponieważ AIContextProviders są dostępne tylko dla ChatClientAgentOptions. Przy każdym RunAsync GuestBookMemory dokłada do tej bazy instrukcje z BuildInstructions, a Agent Framework łączy oba teksty w jedno pole system (przykład w sekcji „Gdzie fakty trafiają w requeście do Messages API”).
Session store podpina się przy rejestracji agenta w DI:
builder.AddAIAgent("NoviceMageNotebook", (_, _) => notebookAgent)
.WithSessionStore(new SqliteAgentSessionStore(connectionString), withIsolation: false);
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
builder.Services.AddDevUI();
Endpointy OpenAI Responses i DevUI są zarejestrowane tak samo jak w pierwszym wpisie. Nowy jest extension WithSessionStore: dzięki niemu POST /v1/responses sam obsługuje sesje. Przed wywołaniem agenta pobiera sesję ze store’a, a po odpowiedzi ją zapisuje. Aplikacja nie ma do tego własnego endpointu ani kodu, który jawnie wczytuje i zapisuje sesję. Endpoint obsługuje sesje od wersji 1.23, a wcześniej wywoływał agenta bez sesji i WithSessionStore nie miało na niego wpływu.
Identyfikatorem sesji jest identyfikator konwersacji (conv_..., ten sam, który DevUI pokazuje na liście „Conversation”), a gdy request go nie ma, identyfikator poprzedniej odpowiedzi (previous_response_id). Dodatkowo endpoint zapisuje sesję pod identyfikatorem każdej odpowiedzi (resp_...), więc rozmowę można kontynuować od dowolnej z nich.
withIsolation: false wyłącza izolację sesji. Domyślnie Agent Framework dokłada do klucza sesji partycję z identyfikatorem użytkownika od AgentIsolationKeyProvider, a gdy go nie ma, kończy request wyjątkiem. Demo nie ma uwierzytelniania, więc izolacja jest wyłączona (więcej w sekcji „Fakty nie mają właściciela”).
Uruchomienie i demo
Projekt uruchamia się pod adresem http://localhost:5019:
dotnet run --launch-profile http
Pierwsza rozmowa
Tabela facts jest na starcie pusta. DevUI jest dostępne pod http://localhost:5019/devui/, a jedyny agent na liście to NoviceMageNotebook. W pierwszej rozmowie padają dwie wiadomości: samo powitanie „Hi!” i „I am Dawid.”.

Nowicjusz jeszcze nie zna użytkownika, więc zatrzymuje gościa przy bramie i pyta o imię. Pod drugą odpowiedzią jest ikona klucza z liczbą 1. W panelu Events po prawej widnieje zdarzenie function_call.complete z wywołaniem remember_fact. Zanim nowicjusz odpowiedział, model zwrócił blok tool_use, dokładnie taki jak w przykładzie z początku wpisu. Aplikacja zapisała fakt i odesłała tool_result, a dopiero wtedy model napisał powitanie. Ta wymiana to dwa wywołania modelu, razem 1616 tokenów wejściowych. DevUI v0.1.0 pokazuje argumenty w postaci surowego JSON-a, dlatego apostrof w „user’s” jest widoczny jako \u0027. W tabeli facts jest jeden wiersz:
The user's name is Dawid.
Nowa rozmowa
Drugi test dotyczy pamięci długotrwałej. Przycisk „New Conversation” w DevUI otwiera nową rozmowę bez historii, a wiadomość to znowu samo powitanie:

Tym razem brama jest otwarta i nowicjusz od progu wita gościa po imieniu. Rozmowa jest pusta, więc imię mogło przyjść tylko z tabeli facts. Provider wstrzyknął informacje do instrukcji razem z poleceniem powitania stałego bywalca, a model sam rozpoznał, że to pierwsza wiadomość w rozmowie. Historia jest pusta, a system prompt różni się listą faktów i poleceniem powitania.
Urwana rozmowa po restarcie aplikacji
Ostatni test dotyczy pamięci krótkotrwałej, czyli samej sesji. DevUI nie wznawia rozmów po restarcie aplikacji (szczegóły niżej w sekcji „Ograniczenia”). Endpoint zapisuje jednak sesję w SQLite także pod identyfikatorem każdej odpowiedzi, więc rozmowę da się kontynuować przez previous_response_id. Dlatego ten test używa curl zamiast DevUI. Przed nim tabela facts została wyczyszczona, żeby nowicjusz znowu zaczął od pytania o imię przybysza. Pierwsza wiadomość to samo powitanie:
curl -s http://localhost:5019/v1/responses \
-H "Content-Type: application/json" \
-d '{"agent":{"name":"NoviceMageNotebook"},"input":"Hi!"}'
Odpowiedź ma pole id z identyfikatorem resp_..., a druga wiadomość podaje go w previous_response_id:
curl -s http://localhost:5019/v1/responses `
-H "Content-Type: application/json" `
-d '{"agent":{"name":"NoviceMageNotebook"},"previous_response_id":"resp_8xRIjys3XBEAMPaXycpZUEjh1aZGXbeuPQ3zqnE3gNGn39FR","input":"I am Dawid."}'
Przebieg był ten sam co w DevUI: brama, imię i wywołanie remember_fact. Po zatrzymaniu i ponownym uruchomieniu aplikacji pamięć procesu znika, a plik memory.db zostaje. Trzecia wiadomość wskazuje drugą odpowiedź i zadaje pytanie, na które odpowiedź jest tylko w historii czatu, a nie w faktach:
curl -s http://localhost:5019/v1/responses \
-H "Content-Type: application/json" \
-d '{"agent":{"name":"NoviceMageNotebook"},"previous_response_id":"resp_srN3YFBWrlgykRmFpJGW9hdrRcAcSPswn0S4lMT9Sq0WlBYm","input":"What did you ask me before you let me in?"}'
Odpowiedź (po skróceniu):
Before I let you in, I asked you for your name. I said I needed to know your name before you could enter the academy.
And you told me your name is Dawid.
Endpoint wczytał z SQLite sesję NoviceMageNotebook:resp_srN3..., agent ją zdeserializował i podał modelowi razem z nowym pytaniem. W tabeli sessions jest wiersz z JSON-em całej rozmowy. Wywołanie remember_fact jest tam zapisane jako bloki functionCall i functionResult, razem z identyfikatorem toolu_... od Claude. W tym samym JSON-ie, w stateBag pod kluczem GuestBookMemory, leży instrukcja bramy z pierwszego „Hi!”. Tabela facts ma już jeden wiersz z imieniem, ale po restarcie system w requeście nadal zawiera bramę, bo instrukcje przyszły z sesji, a nie z bazy.
Ograniczenia
DevUI nie wznowi rozmowy po restarcie
Sesje są w SQLite, ale lista konwersacji DevUI nie. Endpointy OpenAI Conversations trzymają ją w InMemoryConversationStorage. Po restarcie stara rozmowa znika z listy, a request z jej conv_... zwraca 404, choć sesja z tym identyfikatorem znajduje się w bazie. Za listę odpowiadają IConversationStorage i IAgentConversationIndex, ale oba interfejsy i ich implementacje są internal, więc nie da się podpiąć własnej implementacji, która przetrwa restart. Tego dotyczy otwarte zgłoszenie #3000 w repozytorium Agent Framework.
Endpoint zapisuje też sesję pod identyfikatorem dla każdej odpowiedzi, a każdy taki wiersz zawiera całą historię rozmowy do tego momentu. Po teście z restartem tabela sessions ma trzy wiersze, po jednym na odpowiedź. Na produkcji taka tabela potrzebuje czyszczenia lub archiwizacji „starych” sesji. Można sobie łatwo wyobrazić, że taka baza strasznie puchnie kiedy każda tura rozmowy z każdym użytkownikiem jest osobno zapisywana wraz z całą dotychczasową historią.
Fakty nie mają właściciela
Tabela facts jest wspólna dla wszystkich rozmów. To wystarcza dla jednego użytkownika, ale w produkcyjnej aplikacji fakty muszą być przypisane do uwierzytelnionej osoby, inaczej agent opowie jednemu użytkownikowi o faktach podanych przez innego. Tę samą uwagę dokumentacja paczki robi o identyfikatorach sesji pochodzących od klienta: aplikacja powinna powiązać je z uwierzytelnionym użytkownikiem, zanim użyje ich jako klucza. W demo izolacja jest wyłączona (withIsolation: false), więc każdy, kto zna conv_... albo resp_..., może kontynuować cudzą rozmowę. Fakty są też danymi osobowymi, więc potrzebują możliwości przeglądu i usunięcia.
Model decyduje o tym, co zapisać
Narzędzie remember_fact zapisuje to, co model uzna za ważne, a nie to, co ważne jest dla aplikacji. W demo do tabeli trafiło tylko imię, ale w dłuższej rozmowie model równie dobrze może uznać za „trwały fakt” coś, co jest prawdą tylko dziś. Można zawęzić opis narzędzia do wybranych kategorii (imię, preferencje) albo dodać narzędzie do usuwania faktów.
Podsumowanie
Nowicjusz wznawia urwaną rozmowę po restarcie aplikacji dzięki sesji zapisanej w SQLite przez własny AgentSessionStore, a fakty o użytkowniku przenosi do nowych rozmów dzięki księdze gości, czyli providerowi GuestBookMemory i narzędziu remember_fact. Ten sam provider decyduje, czy nowicjusz zatrzyma gościa przy bramie, czy przywita go po imieniu. Wczytywanie, zapis i serializację sesji, składanie tablicy wiadomości i pętlę wywołań narzędzia bierze na siebie Agent Framework. Wybór store’a czy przypisanie faktów do użytkownika pozostaje po stronie aplikacji.
Pełny kod: github.com/nowakdawid-blog/MageMemoryDemo.