n4nAI

Semantic Kernel agent tutorial: adding memory to agents

A hands-on tutorial for adding persistent memory to Semantic Kernel agents using vector stores and memory skills, with complete runnable code.

n4n Team2 min read539 words

Audio narration

Coming soon — every post will get a voice note here.

This semantic kernel agent memory tutorial walks you through adding persistent, queryable memory to a Semantic Kernel agent. You will build a working agent that recalls facts across sessions, using a local vector store for embeddings and the built-in memory skill for retrieval. The code targets Semantic Kernel 1.19+ with .NET 8.

Prerequisites

  • .NET 8 SDK installed
  • An OpenAI-compatible API key (OpenAI, Azure OpenAI, or a gateway like n4n.ai)
  • Basic familiarity with Semantic Kernel concepts: kernels, plugins, and planners

Create a new console project and add the required packages:

dotnet new console -n SKAgentMemory
cd SKAgentMemory
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Plugins.Memory
dotnet add package Microsoft.SemanticKernel.Connectors.Memory.Qdrant
dotnet add package Microsoft.Extensions.VectorData.Qdrant

Qdrant runs locally in Docker for this tutorial. Start it now:

docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant

Project structure

SKAgentMemory/
├── Program.cs
├── Models/
│   └── MemoryRecord.cs
├── Services/
│   └── MemoryService.cs
└── Agents/
    └── MemoryAgent.cs

Step 1: Define the memory record

Semantic Kernel’s vector store abstraction expects a record class with vector and data properties. Create Models/MemoryRecord.cs:

namespace SKAgentMemory.Models;

public sealed class MemoryRecord
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }

    [VectorStoreRecordData]
    public string Content { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public string Collection { get; set; } = "default";

    [VectorStoreRecordData]
    public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;

    [VectorStoreRecordData]
    public Dictionary<string, string> Metadata { get; set; } = new();
}

The VectorStoreRecordVector attribute specifies the embedding dimension. OpenAI’s text-embedding-3-small produces 1536 dimensions. Adjust if you use a different model.

Step 2: Build the memory service

The memory service wraps collection management, ingestion, and search. Create Services/MemoryService.cs:

using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Memory;
using SKAgentMemory.Models;

namespace SKAgentMemory.Services;

public sealed class MemoryService : IDisposable
{
    private readonly IVectorStore _vectorStore;
    private readonly VectorStoreRecordCollection<string, MemoryRecord> _collection;
    private readonly ITextEmbeddingGenerationService _embeddingService;
    private bool _disposed;

    public MemoryService(
        IVectorStore vectorStore,
        ITextEmbeddingGenerationService embeddingService,
        string collectionName = "agent-memory")
    {
        _vectorStore = vectorStore;
        _embeddingService = embeddingService;
        _collection = _vectorStore.GetCollection<string, MemoryRecord>(collectionName);
    }

    public async Task InitializeAsync(CancellationToken ct = default)
    {
        await _collection.CreateCollectionIfNotExistsAsync(ct);
    }

    public async Task<string> StoreAsync(
        string content,
        string collection = "default",
        Dictionary<string, string>? metadata = null,
        CancellationToken ct = default)
    {
        var embedding = await _embeddingService.GenerateEmbeddingAsync(content, ct);
        var record = new MemoryRecord
        {
            Content = content,
            Collection = collection,
            Embedding = embedding,
            Metadata = metadata ?? new Dictionary<string, string>()
        };
        await _collection.UpsertAsync(record, ct);
        return record.Id;
    }

    public async Task<IReadOnlyList<MemoryRecord>> SearchAsync(
        string query,
        int limit = 5,
        double minRelevanceScore = 0.7,
        string? collection = null,
        CancellationToken ct = default)
    {
        var queryEmbedding = await _embeddingService.GenerateEmbeddingAsync(query, ct);
        var filter = collection is not null
            ? new VectorSearchFilter().EqualTo(r => r.Collection, collection)
            : null;

        var results = await _collection.VectorizedSearchAsync(
            queryEmbedding,
            limit,
            filter,
            ct: ct);

        var records = new List<MemoryRecord>();
        await foreach (var result in results.Results)
        {
            if (result.Score >= minRelevanceScore)
            {
                records.Add(result.Record);
            }
        }
        return records;
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _disposed = true;
        }
    }
}

Step 3: Wire up the kernel and memory

Update Program.cs to configure the kernel, embedding service, Qdrant vector store, and the memory service:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using Microsoft.SemanticKernel.Embeddings;
using Microsoft.SemanticKernel.Memory;
using SKAgentMemory.Agents;
using SKAgentMemory.Services;

var builder = WebApplication.CreateBuilder(args);

// Replace with your endpoint and key
const string endpoint = "https://api.openai.com/v1";
const string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "your-key";
const string embeddingModel = "text-embedding-3-small";
const string chatModel = "gpt-4o-mini";

builder.Services.AddSingleton(sp =>
{
    var qdrantClient = new QdrantClient("localhost", 6333);
    return new QdrantVectorStore(qdrantClient);
});

builder.Services.AddSingleton<ITextEmbeddingGenerationService>(sp =>
    new OpenAITextEmbeddingGenerationService(embeddingModel, apiKey, endpoint: endpoint));

builder.Services.AddSingleton<MemoryService>();

builder.Services.AddKernel()
    .AddOpenAIChatCompletion(chatModel, apiKey, endpoint: endpoint);

builder.Services.AddSingleton<MemoryAgent>();

var app = builder.Build();

using var scope = app.Services.CreateScope();
var memoryService = scope.ServiceProvider.GetRequiredService<MemoryService>();
await memoryService.InitializeAsync();

var agent = scope.ServiceProvider.GetRequiredService<MemoryAgent>();
await agent.RunInteractiveLoopAsync();

app.Run();

Install the Qdrant client package if missing:

dotnet add package Qdrant.Client

Step 4: Create the memory-enabled agent

The agent uses a KernelFunction that wraps the memory service for both storage and retrieval. Create Agents/MemoryAgent.cs:

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using SKAgentMemory.Services;

namespace SKAgentMemory.Agents;

public sealed class MemoryAgent
{
    private readonly Kernel _kernel;
    private readonly IChatCompletionService _chat;
    private readonly MemoryService _memory;
    private readonly ChatHistory _history = [];

    public MemoryAgent(Kernel kernel, MemoryService memory)
    {
        _kernel = kernel;
        _memory = memory;
        _chat = kernel.GetRequiredService<IChatCompletionService>();

        _history.AddSystemMessage("""
            You are a helpful assistant with persistent memory.
            When the user shares a fact, preference, or instruction, call the remember function.
            When the user asks something that might be answered from memory, call the recall function first.
            Always use the tools when appropriate. Do not hallucinate memory.
            """);

        RegisterMemoryFunctions();
    }

    private void RegisterMemoryFunctions()
    {
        var remember = KernelFunctionFactory.CreateFromMethod(
            RememberAsync,
            functionName: "remember",
        );

        var recall = KernelFunctionFactory.CreateFromMethod(
            RecallAsync,
            functionName: "recall",
        );

        _kernel.Plugins.AddFromFunctions("Memory", [remember, recall]);
    }

    public async Task<string> RememberAsync(
        Kernel kernel,
        [Description("The information to store")] string content,
        [Description("Optional category: fact, preference, instruction, context")] string category = "fact",
        [Description("Optional tags as comma-separated values")] string tags = "",
        CancellationToken ct = default)
    {
        var metadata = new Dictionary<string, string>
        {
            ["category"] = category,
            ["tags"] = tags
        };

        var id = await _memory.StoreAsync(content, "agent-memory", metadata, ct);
        return $"Stored in memory (id: {id[..8]}). Category: {category}";
    }

    public async Task<string> RecallAsync(
        Kernel kernel,
        [Description("The query to search memory for")] string query,
        [Description("Maximum results to return")] int limit = 5,
        CancellationToken ct = default)
    {
        var results = await _memory.SearchAsync(query, limit, 0.65, "agent-memory", ct);

        if (results.Count == 0)
        {
            return "No relevant memories found.";
        }

        var lines = results.Select((r, i) =>
            $"{i + 1}. [{r.Metadata.GetValueOrDefault("category", "fact")}] {r.Content} (score: {r.Metadata.GetValueOrDefault("score", "N/A")})");

        return "Relevant memories:\n" + string.Join("\n", lines);
    }

    public async Task RunInteractiveLoopAsync()
    {
        Console.WriteLine("Memory agent ready. Type 'exit' to quit.\n");

        while (true)
        {
            Console.Write("> ");
            var input = Console.ReadLine();

            if (string.IsNullOrWhiteSpace(input) || input.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase))
            {
                break;
            }

            _history.AddUserMessage(input);

            var executionSettings = new OpenAIPromptExecutionSettings
            {
                ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
            };

            var result = await _chat.GetChatMessageContentAsync(_history, executionSettings, _kernel);
            _history.Add(result);

            Console.WriteLine($"\n{result}\n");
        }
    }
}

Step 5: Run and verify

Set your API key and run:

export OPENAI_API_KEY=sk-...
dotnet run

Checkpoint 1: Store a fact

> My name is Alex and I prefer TypeScript over Python for new projects.

Expected output (abbreviated):

Stored in memory (id: a1b2c3d4). Category: fact

Got it. I've saved that you're Alex and prefer TypeScript for new projects.

Checkpoint 2: Recall in a new turn

> What language should I use for the new API service?

Expected output:

Relevant memories:
1. [fact] My name is Alex and I prefer TypeScript over Python for new projects.

Based on your preference, use TypeScript for the new API service.

Checkpoint 3: Cross-session persistence

Stop the process (Ctrl+C), restart dotnet run, then ask:

> What's my name and language preference?

Expected output:

Relevant memories:
1. [fact] My name is Alex and I prefer TypeScript over Python for new projects.

Your name is Alex and you prefer TypeScript over Python for new projects.

The memory survives process restarts because Qdrant persists to disk by default.

Step 6: Add structured memory with collections

For multi-tenant or multi-domain agents, partition memory by collection. Update MemoryAgent.cs to accept a collection parameter:

public async Task<string> RememberAsync(
    Kernel kernel,
    [Description("The information to store")] string content,
    [Description("Collection name for namespacing")] string collection = "default",
    [Description("Optional category: fact, preference, instruction, context")] string category = "fact",
    [Description("Optional tags as comma-separated values")] string tags = "",
    CancellationToken ct = default)
{
    var metadata = new Dictionary<string, string>
    {
        ["category"] = category,
        ["tags"] = tags
    };

    var id = await _memory.StoreAsync(content, collection, metadata, ct);
    return $"Stored in memory (id: {id[..8]}, collection: {collection}). Category: {category}";
}

public async Task<string> RecallAsync(
    Kernel kernel,
    [Description("The query to search memory for")] string query,
    [Description("Collection to search")] string collection = "default",
    [Description("Maximum results to return")] int limit = 5,
    CancellationToken ct = default)
{
    var results = await _memory.SearchAsync(query, limit, 0.65, collection, ct);
    // ... same formatting as before
}

Now the agent can keep separate memory namespaces per user, project, or domain without collisions.

Production considerations

Embedding model consistency

The vector store dimension must match the embedding model. If you switch from text-embedding-3-small (1536) to text-embedding-3-large (3072), you must recreate the collection or migrate vectors. Pin the model in configuration and treat dimension as a contract.

Relevance threshold tuning

The minRelevanceScore parameter (0.65 in the example) controls recall precision. Lower values return more results but increase noise. Log the scores during development to pick a threshold that matches your domain:

// During development, log scores to tune threshold
foreach (var result in results.Results)
{
    _logger.LogDebug("Memory hit: score={Score}, content={Content}", result.Score, result.Record.Content[..50]);
}

Token budget management

Each recalled memory consumes tokens in the prompt. Implement a token budget in the recall function:

public async Task<string> RecallAsync(/* ... */)
{
    var results = await _memory.SearchAsync(query, limit, 0.65, collection, ct);
    var budget = 1500; // tokens
    var used = 0;
    var lines = new List<string>();

    foreach (var r in results)
    {
        var tokens = EstimateTokens(r.Content);
        if (used + tokens > budget) break;
        used += tokens;
        lines.Add($"[{r.Metadata.GetValueOrDefault("category")}] {r.Content}");
    }

    return lines.Count > 0
        ? "Relevant memories:\n" + string.Join("\n", lines)
        : "No relevant memories found within token budget.";
}

Concurrency and scaling

Qdrant handles concurrent reads well. For write-heavy workloads, consider batching UpsertAsync calls or using a background queue. The MemoryService is thread-safe for concurrent reads; wraps writes in a semaphore if you observe contention.

Observability

Add structured logging around every memory operation:

_logger.LogInformation("Memory store: collection={Collection}, category={Category}, chars={Length}",
    collection, category, content.Length);

_logger.LogInformation("Memory recall: query={Query}, results={Count}, latencyMs={Latency}",
    query, results.Count, stopwatch.ElapsedMilliseconds);

Correlate with request IDs to trace memory hits through the planner.

Next steps

  • Replace the interactive loop with a proper API endpoint (ASP.NET Core Minimal API or Controller)
  • Add a forget function for GDPR/right-to-be-forgotten compliance
  • Implement memory summarization: periodically condense old memories into higher-level concepts
  • Explore Semantic Kernel’s TextMemoryPlugin for a zero-code alternative if your needs are simpler

The pattern here — explicit remember and recall functions backed by a vector store — gives you full control over what enters memory, how it’s namespaced, and how it’s retrieved. That control matters when you move from demos to systems that users trust.

Tagssemantic-kernelagentmemorytutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel planners & agents posts →