记忆 Memory

记忆并不是指持久化会话数据,而是指针对某用户或者某计算机智能持久化一些行为或习惯。

例如:

  • 上周用户告诉你 "我对花生过敏",今天新开会话问 "推荐个零食",你希望模型还记得过敏这件事。
  • 100 个会话之前讨论过某个 bug 的根因,今天遇到类似问题想召回当时的结论。
  • 用户在 A 会话里填过收货地址,B 会话下单时不想再问一遍。

前面几章聊的所有"上下文",历史记录、压缩、注入,都是同一个 session 内的。但真实业务里,要提升用户体验,往往需要跨越很久之前、跨域会话的记忆,也就是某些内容,需要跨 Session 共享,不会因为新开对话而丢失这部分的记忆。


目前来说,关于记忆,没有固定模式或者标准做法,Codex、Claude Code、ZCode、Github Copilot 等 Agent 他们的机制不一样,数据不互通,而基于桌面的 Agent 和 Server 服务器多用户的 Agent ,其模式和机制也不一样。


MAF 是通过 ChatHistoryMemoryProvider,把消息存进向量数据库,每次按当前问题的语义检索相关片段,作为 "记忆" 注入,按照 跨会话、按相关性检索历史。这种模式跟 RAG 比较相似。

本章是讲解清楚 MAF 框架的记忆设计,并不代表这种设计适合你,或者好用。

image-20260723091033756


ChatHistoryMemoryProvider 的定义和配置

ChatHistoryMemoryProvider 继承了 AIContextProvider,它的生命周期跟 agent 的每次调用绑死,分为写入阶段和检索阶段两个钩子。

/// 一个上下文提供程序,将所有聊天历史存储在向量存储中,并能够在之后检索相关的聊天历史以增强当前对话。
public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDisposable
{
    public ChatHistoryMemoryProvider(
        VectorStore vectorStore,
        string collectionName,
        int vectorDimensions,
        Func<AgentSession?, State> stateInitializer,
        ChatHistoryMemoryProviderOptions? options = null,
        ILoggerFactory? loggerFactory = null);

    public override IReadOnlyList<string> StateKeys { get; }

    public void Dispose();

    public sealed class State
    {
        /// 使用指定的存储和搜索作用域初始化 <see cref="State"/> 类的新实例。
        public State(ChatHistoryMemoryProviderScope storageScope, ChatHistoryMemoryProviderScope? searchScope = null);

        /// 获取或设置存储聊天历史消息时使用的作用域。
        public ChatHistoryMemoryProviderScope StorageScope { get; }

        /// 获取或设置搜索聊天历史消息时使用的作用域。
        public ChatHistoryMemoryProviderScope SearchScope { get; }
    }
}

ChatHistoryMemoryProvider 的机制跟 RAG 一样,直接依赖客户端的向量库的 EmbeddingGenerator ,先通过 Embedding 模型转换向量后存到向量数据库,然后对话时通过问题检索相关历史做召回,塞到历史上下文中。


ChatHistoryMemoryProviderScope 是整个记忆系统的灵魂。它有四个维度,全部可空,留空时表示这个维度不限制

public sealed class ChatHistoryMemoryProviderScope
{
    public string? ApplicationId { get; set; }   // 应用维度
    public string? AgentId { get; set; }         // 智能体维度
    public string? SessionId { get; set; }       // 会话维度
    public string? UserId { get; set; }          // 用户维度
}


State 里有两个 scope,写入用 StorageScope,查询用 SearchScope(不传则和 storage 相同):

new ChatHistoryMemoryProvider.State(
    storageScope: new() { UserId = "UID1", SessionId = sessionId },  // 写入时打这两个标签
    searchScope:  new() { UserId = "UID1" })                          // 查询时只过滤用户


意思配置表示,写入记忆时,每个内容都要记录其对话 id,但是检索时只需要区分用户即可,这样就可以实现跨 Session 共享历史对话记忆。


常见 scope 配置模式:

场景storageScopesearchScope效果
用户级长期记忆UserId + SessionIdUserId同一用户跨所有会话召回(最常用)
单会话内回忆UserId + SessionIdUserId + SessionId只在当前会话历史里找
多用户隔离的应用ApplicationId + UserIdApplicationId + UserId不同应用/用户互不串数据
全局知识库留空留空所有人共享(慎用,有数据泄漏风险)

⚠️ 安全提醒(源码 XML 注释里专门强调了):从向量库召回的内容会原样注入 LLM 上下文,不做任何校验。如果库里有恶意内容(间接提示注入),会影响模型行为。多租户场景务必用 UserId/ApplicationId 做严格隔离;消息里可能含 PII,库要配好加密和访问控制。


ChatHistoryMemoryProviderOptions 控制检索行为和工具暴露,几个重要属性:

属性默认值作用
SearchTimeBeforeAIInvoke检索时机(见下一节)
MaxResults3每次召回最多几条
ContextPrompt"## Memories\nConsider the following memories..."注入记忆时的前缀提示词
FunctionToolName"Search"按需检索时暴露的工具名
FunctionToolDescription"Allows searching for related previous chat history..."工具描述
EnableSensitiveTelemetryDatafalse日志是否打印敏感数据(用户 ID、消息文本)
Redactor占位符 <redacted>脱敏器,自定义日志里的敏感字段处理
StateKey类型名 ChatHistoryMemoryProviderStateBag 里的存储 key(同 session 多实例时区分)
SearchInputMessageFilter只含 External 消息构造查询文本时,从请求消息里筛哪些
StorageInputRequestMessageFilter只含 External 消息存储时,从请求消息里筛哪些
StorageInputResponseMessageFilter不过滤存储时,从响应消息里筛哪些


几个消息过滤器的默认值要注意:默认只把 AgentRequestMessageSourceType.External(即用户真实输入)拿去生成查询和入库,自动跳过来自历史、来自其它 provider 的消息,避免重复存储、避免合成消息污染记忆。要改这个行为就传自定义 filter,比如 msgs => msgs(全收)。


ChatHistoryMemoryProvider 的写入和检索

① 写入阶段(StoreAIContextAsync,每轮结束时触发)

把这一轮的请求消息 + 响应消息,逐条转成向量库记录 Upsert 进去。每条记录的字段的定义 VectorStoreCollectionDefinition

字段含义是否建索引
KeyGuid,主键主键
Roleuser/assistant/...
MessageId消息 ID
AuthorName作者名
ApplicationId / AgentId / UserId / SessionId四个 scope 维度✓ 全部
Content消息文本✓(全文索引)
CreatedAt创建时间(ISO 8601)
ContentEmbeddingembedding 向量(维度由构造参数定)向量字段


写入时带上 storageScope 的四个标签(ApplicationId/AgentId/UserId/SessionId),后面检索就靠这些标签过滤。但是要注意,如果存储失败,是不会抛出异常的,因为存储记忆失败不可以导致对话崩溃,所以在对话正常的时候,实际上写入记忆不一定成功。


② 检索阶段(ProvideMessagesAsync / ProvideAIContextAsync,每轮开始时触发)

把当前这轮的请求消息文本拼成一句查询 → 向量化 → 在向量库里做语义搜索(带 searchScope 过滤)→ 取最相关的 N 条(默认 3)→ 拼成一条消息注入。注入格式(默认 prompt):

## Memories
Consider the following memories when answering user questions:
<相关消息 1 的文本>
<相关消息 2 的文本>
<相关消息 3 的文本>


这条消息以 ChatRole.User 注入,前置在真实请求之前。检索失败也是只记日志、返回空列表,不影响主流程。


使用 Redis Stack 存储记忆

这一小节介绍怎么使用 ChatHistoryMemoryProvider。

Redis Stack 自带 RediSearch 模块,原生支持向量相似度搜索,是个很合适的轻量级选择。需要安装几个 nuget 包,不过要注意 NRedisStack 版本要最新的,否则 Redis Stack 的协议跟客户端版本协议不一样,导致报错。

笔者踩过这个坑。

# Redis 向量库连接器(提供 RedisVectorStore,实现 Microsoft.Extensions.VectorData)
Microsoft.SemanticKernel.Connectors.Redis
# Redis 客户端
StackExchange.Redis
NRedisStack

示例代码:

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.Redis;
using OpenAI;
using OpenAI.Chat;
using Serilog;
using Serilog.Sinks.OpenTelemetry;
using StackExchange.Redis;
using System.ClientModel;
using System.ComponentModel;

// 对话客户端(复用同一个 OpenAI 兼容网关,模型按需替换)
OpenAIClient openAIClient = new(
    credential: new ApiKeyCredential("1234"),
    options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") });

ChatClient chatClient = openAIClient.GetChatClient("qwen/qwen3.5-9b");

// 1) 连 Redis Stack
ConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync("192.168.50.199:6379");
IDatabase database = redis.GetDatabase();

// 2) embedding 生成器,使用 Qwen3-Embedding-4B(输出 2560 维)
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator =
    openAIClient.GetEmbeddingClient("text-embedding-qwen3-embedding-4b").AsIEmbeddingGenerator();

// 3) 创建向量库,并把 embeddingGenerator 绑到 VectorStore(写入和检索时都会用它自动算向量)
VectorStore vectorStore = new RedisVectorStore(database, new RedisVectorStoreOptions
{
    EmbeddingGenerator = embeddingGenerator,
});

// 4) 记忆 Provider:存得细(按 用户+会话),查得粗(只按用户,跨会话语义召回)
string userId = "UID1";
string sessionId = Guid.NewGuid().ToString();

var memoryProvider = new ChatHistoryMemoryProvider(
    vectorStore,
    collectionName: "chathistory",
    vectorDimensions: 2560,   // 必须和 Qwen3-Embedding-4B 的输出维度一致
    stateInitializer: _ => new ChatHistoryMemoryProvider.State(
        storageScope: new() { UserId = userId, SessionId = sessionId },  // 存:用户 + 会话
        searchScope:  new() { UserId = userId }),                         // 查:只按用户
    options: new ChatHistoryMemoryProviderOptions
    {
        MaxResults = 3,
        ContextPrompt = "## 历史记忆\n回答时请参考以下记忆:",
    });


AIAgent agent = chatClient
    .AsAIAgent(new ChatClientAgentOptions
    {
        Name = "Helper",
        Description = "一个示例 AI Agent,使用 OpenAI Chat Completion API。",
        ChatOptions = new ChatOptions
        {
            Instructions = "你是一个乐于助人的助手,必要时调用工具",
            Tools = [AIFunctionFactory.Create(GetWeather)]
        },
        AIContextProviders = [memoryProvider]
    })
    .AsBuilder()
    .UseOpenTelemetry(sourceName: "AAA", configure: cfg =>
    {
        cfg.EnableSensitiveData = true;
    })
    .Build();

AgentSession session = await agent.CreateSessionAsync();

while (true)
{
    Console.WriteLine("请输入问题:");
    string question = Console.ReadLine() ?? "";
    if (string.IsNullOrWhiteSpace(question)) break;
    await foreach (var u in agent.RunStreamingAsync(question, session))
        Console.Write(u);
}

注意 Redis 向量库的两个限制:维度(vectorDimensions)一旦 collection 建好就改不了(要换维度得删了重建 index);超大规模(千万级向量)下性能不如专用向量库,中小型 agent 场景完全够用。


第一次对话后,会创建两个 key。

image-20260723100449949


第二次对话时,又增加了两个 Key。

image-20260723100711572


也就是说,实际上ChatHistoryMemoryProvider 把用户问题、AI 回复,都作为一个单独的内容向量化到 Redis,然后在每次提问时都会先对问题向量化,然后在向量数据库中检索,然后把检索结果塞进去再进行一轮回答。


image-20260723100821894



不过这样比较浪费 tokens,其机制跟 RAG 差不多。

image-20260723110344847


当作工具注入记忆

前面做了一个 Redis Stack 按用户隔离存储记忆的机制,但是每次对话都会自动搜索。

每轮开始都跑一次 embedding + 向量搜索,把结果拼成消息塞进去。优点是无脑、模型一定能看到;缺点是每轮都消耗一次 embedding 调用 + 一次向量搜索,"今天天气怎么样" 这种和记忆无关的问题也照搜不误。

new ChatHistoryMemoryProvider(vectorStore, "chathistory", 3072, stateInit,
    options: new ChatHistoryMemoryProviderOptions
    {
        SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
        MaxResults = 3,
    })

比较好的做法时,把记忆检索当作一个工具,在需要的时候才会被 AI 模型调用。方法是注册一个名叫 Search 的工具,让模型判断 "这个问题需不需要翻历史" 再调。源码里走的是 ProvideAIContextAsync 分支,返回的是 Tools 而不是 Messages

new ChatHistoryMemoryProvider(vectorStore, "chathistory", 3072, stateInit,
    options: new ChatHistoryMemoryProviderOptions
    {
        SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling,
        FunctionToolName = "回忆一下",
        FunctionToolDescription = "当用户的问题可能和之前聊过的内容有关时,调用此工具检索历史对话。",
    })


由模型判断是否需要召回记忆,可能会更加准确和智能,因为可以由 AI 决定召回的关键词,但是如果它判断错误,没必要的问题也进行召回,或者该召回的问题却没有召回。

另一方面缺点是,这样会出现多轮对话,如果后续 AI 模型可能会觉得进行检索 Redis Stack 有没有相关记忆,就会导致更多轮的对话消耗的 tokens 直线上升。


BoundedChatHistoryProvider 短期长期记忆组合

单用 ChatHistoryMemoryProvider 有个问题:它不存完整历史,只做语义召回,可能漏掉最近几轮的上下文(比如刚说过的话、刚做的工具调用)。理想姿势是"短期窗口 + 长期记忆"两层叠加:

最近 N 条消息   → InMemoryChatHistoryProvider,每次原样拼(保证近期上下文完整)
溢出的老消息    → 归档到向量库(ChatHistoryMemoryProvider),需要时语义召回

假设 maxMessages = 2,进来 [S, A, B, C, D](S 是系统消息),它用 Queue<ChatMessage> 维护一个滑动窗口:

遍历: S → A → B → C → D
系统消息 S:单独拎出来,不进计数窗口
A、B  入队 retained(队列 [A, B],满了)
C      队列已满 → 把最老的 A 踢出去(A 进 removed),C 入队 → [B, C]
D      队列已满 → 把最老的 B 踢出去(B 进 removed),D 入队 → [C, D]

最终返回:       [S, C, D]     ← 最近 2 条 + 系统消息
RemovedMessages:[A, B]        ← 被砍掉的,留给外层归档


完整逻辑(注释标在每一分支):

public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
    ChatMessage? systemMessage = null;
    Queue<ChatMessage> retained = new(capacity: this._maxMessages);  // 滑动窗口:最近 N 条
    List<ChatMessage> removed = [];                                   // 被砍掉的,待归档

    foreach (var message in messages)
    {
        if (message.Role == ChatRole.System)
        {
            // 规则 ①:系统消息(人设、规则)永远保留,且不占窗口名额
            systemMessage ??= message;
        }
        else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
        {
            // 规则 ②:普通消息进滑动窗口,满了就把最老的挤进 removed
            if (retained.Count >= this._maxMessages)
                removed.Add(retained.Dequeue());
            retained.Enqueue(message);
        }
        // 规则 ③:含工具调用/结果的消息直接丢弃,既不进窗口,也不进 removed
    }

    this.RemovedMessages = removed;  // ← 关键副作用:把"垃圾"交给外层,而不是真的扔掉

    IEnumerable<ChatMessage> result = systemMessage is not null
        ? new[] { systemMessage }.Concat(retained)
        : retained;
    return Task.FromResult(result);
}

挂载方式和普通 ChatHistoryProvider 一样:

var boundedProvider = new BoundedChatHistoryProvider(
    maxSessionMessages: 4,                  // 内存里最多留 4 条非系统消息
    vectorStore,                            // 你的 Redis Stack VectorStore
    collectionName: "chathistory-overflow",
    vectorDimensions: 3072,
    session => new ChatHistoryMemoryProvider.State(
        storageScope: new() { UserId = "UID1", SessionId = sessionId },
        searchScope:  new() { UserId = "UID1" }));

AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    ChatHistoryProvider = boundedProvider,   // 注意:是 ChatHistoryProvider,不是 AIContextProviders
});