Use Agent Memory for Long-Term Knowledge Across Sessions
By default, an agent's knowledge disappears when the conversation ends. AgentMemory gives agents persistent, searchable memory that survives across sessions. It uses an embedding model to store information as vectors, then automatically recalls relevant memories when the agent processes new messages. You store facts, experiences, and procedures into three memory types, serialize the memory to disk, and reload it in the next session.
For background on memory architectures, see the AI Agent Memory glossary entry.
Why This Matters
Two production problems that agent memory solves:
- Lost context between sessions. A support agent that forgets previous interactions forces users to repeat themselves. With memory, the agent recalls that "the customer upgraded to the Enterprise plan last week" and picks up where it left off.
- Growing expertise over time. A coding assistant that stores solutions to past debugging sessions builds a personal knowledge base. When a similar issue appears months later, it recalls the fix without searching documentation again.
Prerequisites
| Requirement | Minimum |
|---|---|
| .NET SDK | 8.0+ |
| VRAM | 4+ GB (for embedding model) + 4+ GB (for chat model) |
You need two models: one for embeddings (to index and search memories) and one for chat (the agent itself).
Step 1: Create the Project
dotnet new console -n AgentMemoryDemo
cd AgentMemoryDemo
dotnet add package LM-Kit.NET
Step 2: Understand the Three Memory Types
AgentMemory organizes information into three MemoryType categories, following cognitive science principles:
| Type | Purpose | Examples |
|---|---|---|
Semantic |
General knowledge and facts | "C# supports pattern matching since version 7.0" |
Episodic |
Specific events and experiences | "On 2025-01-15, the user reported a timeout bug in the payment module" |
Procedural |
Processes and how-to knowledge | "To deploy to production: run tests, build Release, push to main, trigger CI" |
All three types are stored in the same AgentMemory instance and searched together during recall. The memory type is attached as metadata, so you can filter by type if needed.
Step 3: Create and Populate Memory
using System.Text;
using LMKit.Model;
using LMKit.Agents;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// Load embedding model for memory indexing
Console.WriteLine("Loading embedding model...");
using LM embeddingModel = LM.LoadFromModelID("qwen3-embedding:0.6b",
loadingProgress: p => { Console.Write($"\rLoading embeddings: {p * 100:F0}% "); return true; });
Console.WriteLine();
// Create memory
var memory = new AgentMemory(embeddingModel);
// Store semantic knowledge
memory.SaveInformation(
"dotnet_knowledge",
"The .NET garbage collector uses three generations (0, 1, 2) to manage memory efficiently.",
"gc_generations",
MemoryType.Semantic);
memory.SaveInformation(
"dotnet_knowledge",
"ASP.NET Core middleware runs in the order it is registered in Program.cs.",
"middleware_order",
MemoryType.Semantic);
// Store episodic memory (past events)
memory.SaveInformation(
"project_history",
"The team migrated from .NET 6 to .NET 8 in March 2025. Key issue was breaking changes in the JSON serializer.",
"migration_2025",
MemoryType.Episodic);
// Store procedural memory (how-to knowledge)
memory.SaveInformation(
"team_procedures",
"To release a hotfix: create a branch from main, apply the fix, run all tests, get one approval, merge and tag.",
"hotfix_procedure",
MemoryType.Procedural);
Console.WriteLine($"Memory populated: {memory.DataSources.Count} data source(s)");
The SaveInformation parameters:
| Parameter | Purpose |
|---|---|
dataSourceIdentifier |
Groups related memories (e.g., "dotnet_knowledge") |
text |
The content to store |
sectionIdentifier |
Unique ID within the data source |
memoryType |
Semantic, Episodic, or Procedural |
Tip: If you want facts to be extracted and stored automatically from conversations (instead of populating memory manually), see Remember Facts Across Chat Sessions with Automatic Memory Extraction.
Step 4: Attach Memory to an Agent
// Load chat model
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:4b",
loadingProgress: p => { Console.Write($"\rLoading chat: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// Build agent with memory
Agent agent = Agent.CreateBuilder(chatModel)
.WithPersona("Senior .NET Developer")
.WithMemory(memory)
.Build();
var executor = new AgentExecutor();
// The agent automatically recalls relevant memories when answering
AgentExecutionResult result = executor.Execute(agent,
"How does .NET manage memory with the garbage collector?");
Console.WriteLine(result.Content);
When the agent processes a message, it searches its memory for relevant content, injects the best matches into the context, and uses them to inform its response. This happens automatically with no extra code.
Step 5: Serialize and Restore Memory Across Sessions
string memoryPath = "agent_memory.bin";
// Save memory to disk
memory.Serialize(memoryPath);
Console.WriteLine($"Memory saved to {memoryPath}");
// --- Later, in a new session ---
// Restore memory from disk (requires the same embedding model)
AgentMemory restoredMemory = AgentMemory.Deserialize(memoryPath, embeddingModel);
Console.WriteLine($"Memory restored: {restoredMemory.DataSources.Count} data source(s)");
// Attach to a new agent
Agent newAgent = Agent.CreateBuilder(chatModel)
.WithPersona("Senior .NET Developer")
.WithMemory(restoredMemory)
.Build();
Serialization supports three targets:
| Method | Target |
|---|---|
Serialize(string path) |
File on disk |
Serialize(Stream stream) |
Any writable stream |
Serialize() |
Returns byte[] |
And corresponding Deserialize overloads for each.
Step 6: Monitor Memory Recall with Events
Subscribe to MemoryRecall on MultiTurnConversation to see which memories are used and optionally filter them.
using LMKit.TextGeneration;
var conversation = new MultiTurnConversation(chatModel)
{
Memory = memory
};
conversation.MemoryRecall += (sender, args) =>
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[Recall] Source: {args.MemoryCollection}, Type: {args.MemoryType}");
Console.WriteLine($" ID: {args.MemoryId}");
Console.WriteLine($" Content: {args.MemoryText[..Math.Min(args.MemoryText.Length, 80)]}...");
Console.ResetColor();
// Optionally skip this memory
// args.Cancel = true;
};
var response = conversation.Submit("What was the key issue during the .NET migration?");
Console.WriteLine(response);
MemoryRecallEventArgs properties:
| Property | Type | Purpose |
|---|---|---|
MemoryCollection |
string |
Data source identifier |
MemoryText |
string |
Recalled content |
MemoryId |
string |
Section identifier |
MemoryType |
MemoryType |
Semantic, Episodic, or Procedural |
Metadata |
MetadataCollection |
Additional metadata |
Prefix |
string |
Optional prefix for injected content (writable) |
Cancel |
bool |
Set true to skip this memory (writable) |
Step 7: Control the Recall Budget
MaximumRecallTokens limits how many tokens of memory are injected per turn. This prevents memory from consuming too much context.
var conversation = new MultiTurnConversation(chatModel)
{
Memory = memory,
MaximumRecallTokens = 512 // Default: ContextSize / 4, max: ContextSize / 2
};
Step 8: Manage Memory Data Sources
Add, remove, and inspect data sources programmatically.
// Check if memory is empty
Console.WriteLine($"Empty: {memory.IsEmpty()}");
// List data sources
foreach (var ds in memory.DataSources)
{
MemoryType type = AgentMemory.GetMemoryType(ds);
Console.WriteLine($" [{type}] {ds.Identifier}");
}
// Remove a data source
memory.RemoveDataSource("project_history");
// Clear everything
memory.Clear();
Step 9: Configure Capacity Limits and Eviction
Over time, memory grows unbounded. Use MaxMemoryEntries to cap the total number of entries, and EvictionPolicy to control which entries are removed when the limit is reached.
using LMKit.Agents.Memory;
// Allow up to 200 entries; evict the least important first
memory.MaxMemoryEntries = 200;
memory.EvictionPolicy = MemoryEvictionPolicy.LowestImportanceFirst;
// Check current usage
Console.WriteLine($"Entries: {memory.EntryCount} / {memory.MaxMemoryEntries}");
Available eviction policies:
| Policy | Behavior |
|---|---|
OldestFirst |
Removes the oldest entries first (default) |
LowestImportanceFirst |
Removes Low importance entries before Medium and High; oldest first within the same importance |
OldestLowestImportanceFirst |
Removes the oldest entries first; uses importance as a tiebreaker |
All memory entries (both manually stored and auto-extracted) receive a created_at timestamp automatically. This timestamp drives eviction ordering.
To monitor or prevent evictions:
memory.MemoryEvicted += (sender, e) =>
{
Console.WriteLine($"Evicting: {e.Text} (from {e.DataSourceIdentifier})");
// Cancel eviction for critical data sources
if (e.DataSourceIdentifier == "critical_knowledge")
{
e.Cancel = true;
}
};
Step 10: Apply Time-Decay Scoring
Over time, older memories may become less relevant. Time-decay scoring multiplies each memory's retrieval similarity by an exponential decay factor based on its age, making recent context surface more prominently.
// Memories older than 30 days score 50% of their original similarity.
// 60-day-old memories score 25%, 90-day-old score 12.5%, and so on.
memory.TimeDecayHalfLife = TimeSpan.FromDays(30);
| Half-life | Use case |
|---|---|
TimeSpan.FromDays(30) |
Gentle decay for long-term knowledge bases |
TimeSpan.FromDays(7) |
Moderate decay for active project contexts |
TimeSpan.FromDays(1) |
Aggressive decay for ephemeral session data |
TimeSpan.Zero |
No decay (default) |
Entries without a created_at timestamp receive no decay penalty and are treated as brand new.
Step 11: Consolidate Similar Memories
As memory grows, related entries accumulate. ConsolidateAsync scans all entries, clusters semantically similar ones, and uses an LLM to merge each cluster into a single concise entry.
using LMKit.Agents.Memory;
// Use the chat model (or a lighter model) for summarization
var result = await memory.ConsolidateAsync(chatModel);
Console.WriteLine($"Merged {result.ClustersMerged} cluster(s).");
Console.WriteLine($"Entries: {result.EntryCountBefore} -> {result.EntryCountAfter}");
Control merge behavior:
| Property | Default | Purpose |
|---|---|---|
ConsolidationSimilarityThreshold |
0.7 |
Minimum pairwise similarity to group entries into a cluster |
MinClusterSize |
2 |
Minimum entries in a cluster before it is merged |
The consolidated entry inherits the earliest created_at timestamp and the highest importance from the cluster. A consolidated metadata flag marks merged entries.
To inspect or cancel merges:
memory.BeforeMemoryConsolidated += (sender, e) =>
{
Console.WriteLine($"Merging {e.OriginalEntries.Count} entries in {e.DataSourceIdentifier}");
Console.WriteLine($" Into: {e.ConsolidatedText}");
// Protect a data source from consolidation
if (e.DataSourceIdentifier == "critical_knowledge")
{
e.Cancel = true;
}
};
Step 12: Summarize Conversations into Episodic Memory
At the end of a session, call SummarizeConversationAsync to compress the full conversation into 1-3 episodic memory entries that capture the key topics and decisions.
// Before disposing the agent or starting a new session
var result = await memory.SummarizeConversationAsync(
executor.ChatHistory, chatModel);
Console.WriteLine($"Stored {result.EntriesCreated} episodic entry(s):");
foreach (var summary in result.Summaries)
Console.WriteLine($" - {summary}");
Set MaxConversationSummaries to control the maximum number of entries per conversation (default 3). Conversations with fewer than 2 user messages are skipped automatically.
Step 13: Isolate Memory Per User
Use UserScopedMemory to namespace data sources by user ID when serving multiple users from a single shared memory store.
using LMKit.Agents.Memory;
var sharedMemory = new AgentMemory(embeddingModel);
// Each user gets an isolated view
var aliceMemory = new UserScopedMemory(sharedMemory, "alice");
var bobMemory = new UserScopedMemory(sharedMemory, "bob");
// Data is automatically namespaced: "alice::preferences", "bob::preferences"
await aliceMemory.SaveInformationAsync("preferences", "Prefers dark mode.", "theme");
await bobMemory.SaveInformationAsync("preferences", "Prefers light mode.", "theme");
// Scope retrieval to a single user before attaching to an agent
aliceMemory.ApplyFilter();
var agent = Agent.CreateBuilder(chatModel)
.WithMemory(sharedMemory)
.Build();
// Clear a specific user's data without affecting others
bobMemory.ClearUserData();
What to Read Next
- Remember Facts Across Chat Sessions with Automatic Memory Extraction: let the LLM extract and store facts from conversations automatically, instead of populating memory manually
- Build a Conversational Assistant with Memory: in-session conversation memory
- Build a RAG Pipeline Over Your Own Documents: document-level retrieval
- Save and Restore Conversation Sessions: session persistence
- AI Agent Memory: memory architecture concepts