记忆 Memory
记忆并不是指持久化会话数据,而是指针对某用户或者某计算机智能持久化一些行为或习惯。
例如:
- 上周用户告诉你 "我对花生过敏",今天新开会话问 "推荐个零食",你希望模型还记得过敏这件事。
- 100 个会话之前讨论过某个 bug 的根因,今天遇到类似问题想召回当时的结论。
- 用户在 A 会话里填过收货地址,B 会话下单时不想再问一遍。
前面几章聊的所有"上下文",历史记录、压缩、注入,都是同一个 session 内的。但真实业务里,要提升用户体验,往往需要跨越很久之前、跨域会话的记忆,也就是某些内容,需要跨 Session 共享,不会因为新开对话而丢失这部分的记忆。
目前来说,关于记忆,没有固定模式或者标准做法,Codex、Claude Code、ZCode、Github Copilot 等 Agent 他们的机制不一样,数据不互通,而基于桌面的 Agent 和 Server 服务器多用户的 Agent ,其模式和机制也不一样。
MAF 是通过 ChatHistoryMemoryProvider,把消息存进向量数据库,每次按当前问题的语义检索相关片段,作为 "记忆" 注入,按照 跨会话、按相关性检索历史。这种模式跟 RAG 比较相似。
本章是讲解清楚 MAF 框架的记忆设计,并不代表这种设计适合你,或者好用。

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 配置模式:
| 场景 | storageScope | searchScope | 效果 |
|---|---|---|---|
| 用户级长期记忆 | UserId + SessionId | UserId | 同一用户跨所有会话召回(最常用) |
| 单会话内回忆 | UserId + SessionId | UserId + SessionId | 只在当前会话历史里找 |
| 多用户隔离的应用 | ApplicationId + UserId | ApplicationId + UserId | 不同应用/用户互不串数据 |
| 全局知识库 | 留空 | 留空 | 所有人共享(慎用,有数据泄漏风险) |
⚠️ 安全提醒(源码 XML 注释里专门强调了):从向量库召回的内容会原样注入 LLM 上下文,不做任何校验。如果库里有恶意内容(间接提示注入),会影响模型行为。多租户场景务必用
UserId/ApplicationId做严格隔离;消息里可能含 PII,库要配好加密和访问控制。
ChatHistoryMemoryProviderOptions 控制检索行为和工具暴露,几个重要属性:
| 属性 | 默认值 | 作用 |
|---|---|---|
SearchTime | BeforeAIInvoke | 检索时机(见下一节) |
MaxResults | 3 | 每次召回最多几条 |
ContextPrompt | "## Memories\nConsider the following memories..." | 注入记忆时的前缀提示词 |
FunctionToolName | "Search" | 按需检索时暴露的工具名 |
FunctionToolDescription | "Allows searching for related previous chat history..." | 工具描述 |
EnableSensitiveTelemetryData | false | 日志是否打印敏感数据(用户 ID、消息文本) |
Redactor | 占位符 <redacted> | 脱敏器,自定义日志里的敏感字段处理 |
StateKey | 类型名 ChatHistoryMemoryProvider | StateBag 里的存储 key(同 session 多实例时区分) |
SearchInputMessageFilter | 只含 External 消息 | 构造查询文本时,从请求消息里筛哪些 |
StorageInputRequestMessageFilter | 只含 External 消息 | 存储时,从请求消息里筛哪些 |
StorageInputResponseMessageFilter | 不过滤 | 存储时,从响应消息里筛哪些 |
几个消息过滤器的默认值要注意:默认只把 AgentRequestMessageSourceType.External(即用户真实输入)拿去生成查询和入库,自动跳过来自历史、来自其它 provider 的消息,避免重复存储、避免合成消息污染记忆。要改这个行为就传自定义 filter,比如 msgs => msgs(全收)。
ChatHistoryMemoryProvider 的写入和检索
① 写入阶段(StoreAIContextAsync,每轮结束时触发)
把这一轮的请求消息 + 响应消息,逐条转成向量库记录 Upsert 进去。每条记录的字段的定义 VectorStoreCollectionDefinition:
| 字段 | 含义 | 是否建索引 |
|---|---|---|
Key | Guid,主键 | 主键 |
Role | user/assistant/... | ✓ |
MessageId | 消息 ID | ✓ |
AuthorName | 作者名 | — |
ApplicationId / AgentId / UserId / SessionId | 四个 scope 维度 | ✓ 全部 |
Content | 消息文本 | ✓(全文索引) |
CreatedAt | 创建时间(ISO 8601) | ✓ |
ContentEmbedding | embedding 向量(维度由构造参数定) | 向量字段 |
写入时带上 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。

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

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

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

当作工具注入记忆
前面做了一个 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
});