第三集我们用 AgentSession 做到了多轮对话,可那点记忆只活在当前的进程里。程序一关,会话就没了,下次启动又得从头聊。这一集往前走一步:给 Agent 加一份能跨会话、还能落盘保存的长期记忆。
做法不是让你手撸数据库,而是写一个自定义记忆组件,挂在 Agent 上。本集的例子里,组件会自动从对话里认出用户的姓名和年龄,然后跨调用、跨会话一直记得。
示例源码位于:
dotnet/samples/01-get-started/04_memory/
一、这一集比第三集多了什么
第三集的记忆是会话级别:同一个 session 内连续对话,换个 session 或重开程序就失忆。第四集引入的是一个记忆组件,它做三件第三集没做的事:
- 主动从对话里抽取结构化信息(比如"我叫 ruaidhri,20 岁");
- 把这些信息存成状态,随会话一起序列化,可以存文件、下次读回来;
- 在每次调用前,把已知信息注入给模型,让 Agent 表现得"记得你"。
建客户端、造 Agent 的写法和第一集一样,这里不再重复,没配好密钥的回第一集看环境变量那一节。
二、先搞懂:记忆组件靠两个钩子工作
MAF 里写记忆组件,就是继承 AIContextProvider 并实现两个方法:
StoreAIContextAsync(对话后):每轮对话结束后被调用。这里去读本轮的消息,把能抽出的信息(姓名、年龄)更新到状态里。ProvideAIContextAsync(对话前):每轮真正调模型之前被调用。这里把已经知道的信息写成一段指令,注入给模型,模型据此回答。
状态本身用 ProviderSessionState<T> 来管理,它按会话隔离,并且会自动跟着会话一起序列化。也就是说,你不用自己管"这段记忆属于哪个会话、要不要存",框架都包了。
这份记忆默认在内存里,跟着会话走。想真正长期保存,靠的是下面要讲的会话序列化,而不是组件自己落盘。生产环境里通常要按用户 ID 把状态写进数据库,并在同一个用户的不同会话间共享。
三、核心代码逐行讲
// 创建 Agent,把自定义记忆组件挂到它创建的所有会话上。
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
ModelId = model,
Instructions = "你是一个友好的助手。请始终用用户的名字称呼对方。",
},
AIContextProviders = [new UserInfoMemory(extractionClient)]
});
// 开一个会话,先什么都不告诉它。
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine(">> 使用空白记忆的会话\n");
Console.WriteLine(await agent.RunAsync("你好,9 的平方根是多少?", session));
Console.WriteLine(await agent.RunAsync("我叫 ruaidhri", session));
Console.WriteLine(await agent.RunAsync("我今年 20 岁", session));
// 把会话序列化,记忆组件的状态会被一起带进去。
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
// 反序列化后带着旧记忆继续聊。
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
Console.WriteLine(await agent.RunAsync("我叫什么、几岁了?", deserializedSession));
// 直接通过 GetService 拿到记忆组件,读取它记下的内容。
var userInfo = agent.GetService<UserInfoMemory>()?.GetUserInfo(deserializedSession);
Console.WriteLine($"记忆 - 用户名:{userInfo?.UserName}");
Console.WriteLine($"记忆 - 用户年龄:{userInfo?.UserAge}");
// 开一个新会话,但把上一轮的记忆灌进去,让它也"认识"这个用户。
var newSession = await agent.CreateSessionAsync();
if (userInfo is not null && agent.GetService<UserInfoMemory>() is UserInfoMemory newSessionMemory)
{
newSessionMemory.SetUserInfo(newSession, userInfo);
}
Console.WriteLine(await agent.RunAsync("我叫什么、几岁了?", newSession));
几个值得停下来看的地方:
AIContextProviders = [new UserInfoMemory(extractionClient)]:组件在AsAIAgent时挂上去,之后这个 Agent 创建的每个会话都会带上它。SerializeSessionAsync/DeserializeSessionAsync:这两个方法把整个会话(含记忆状态)变成JsonElement再还原。这就是记忆能"跨进程"的关键,存成文件下次读回即可。agent.GetService<UserInfoMemory>():框架用依赖注入管理组件,想直接读写记忆,就通过GetService取。SetUserInfo(newSession, userInfo):手动把一份记忆绑到某个会话上,于是新会话不用重聊一遍也能记得你。
四、记忆组件长什么样
真正干活的是 UserInfoMemory,它继承 AIContextProvider。摘录两个核心方法:
// 对话后:从用户消息里抽姓名和年龄,更新状态。
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
var userInfo = this._sessionState.GetOrInitializeState(context.Session);
if ((userInfo.UserName is null || userInfo.UserAge is null) && context.RequestMessages.Any(x => x.Role == ChatRole.User))
{
var result = await this._chatClient.GetResponseAsync<UserInfo>(
context.RequestMessages,
new ChatOptions()
{
Instructions = "如果消息里出现了用户的姓名和年龄就抽取出来,没有出现就返回 null。"
},
cancellationToken: cancellationToken);
userInfo.UserName ??= result.Result.UserName;
userInfo.UserAge ??= result.Result.UserAge;
}
this._sessionState.SaveState(context.Session, userInfo);
}
// 对话前:把已知信息写成指令,注入给模型。缺哪样就先让模型开口问哪样。
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var userInfo = this._sessionState.GetOrInitializeState(context.Session);
StringBuilder instructions = new();
instructions
.AppendLine(userInfo.UserName is null
? "向用户询问姓名,并礼貌地拒绝回答任何问题,直到对方提供姓名为止。"
: $"用户的名字是 {userInfo.UserName}。")
.AppendLine(userInfo.UserAge is null
? "向用户询问年龄,并礼貌地拒绝回答任何问题,直到对方提供年龄为止。"
: $"用户的年龄是 {userInfo.UserAge}。");
return new ValueTask<AIContext>(new AIContext { Instructions = instructions.ToString() });
}
这里有两个细节:抽取用户信息时,组件对消息单独调一次模型(GetResponseAsync<UserInfo>),让模型按 JSON 结构返回姓名和年龄,而不是靠 Agent 自己记;注入时,如果某项还没收集到,就指示模型先问,收集齐了才转为告知。
五、运行时到底发生了什么
把上面的流程走一遍,就能看清记忆是怎么流动的:
- 空白会话下问"9 的平方根是多少",模型正常回答,此时它不知道你是谁;
- 你说"我叫 ruaidhri",
StoreAIContextAsync从这条消息里抽出用户名并保存; - 你说"我今年 20 岁",年龄也被抽出保存;
- 把会话序列化再反序列化,记忆跟着回来了;问"我叫什么、几岁了",
ProvideAIContextAsync把"名字是 ruaidhri、年龄 20"注入给模型,模型于是能答出来; - 通过
GetService直接读到组件里存的用户名和年龄; - 开新会话并
SetUserInfo灌入同一份记忆,新会话也能叫出你的名字。
所以"长期记忆"并不是模型变聪明了,而是框架在对话前后分别做了抽取和注入,再借由会话序列化把状态保留了下来。
六、动手改造
跑通之后,试试这几件事,把记忆组件吃透:
- 加字段:在
UserInfo里增加"所在城市""偏好语言",并同步改抽取指令和注入指令,看组件能不能一并记住。 - 落盘保存:把
SerializeSessionAsync得到的JsonElement用File.WriteAllText写进文件,下次启动用File.ReadAllText读回再DeserializeSessionAsync,验证记忆真的跨进程保留。 - 接数据库:把
ProviderSessionState的读写换成读库、写库,做成按用户 ID 共享的多会话记忆。
七、小结
这一集你学会了用 AIContextProvider 写自定义记忆:对话后用 StoreAIContextAsync 抽取并更新状态,对话前用 ProvideAIContextAsync 把已知信息注入模型,状态由 ProviderSessionState<T> 按会话管理、随 SerializeSessionAsync 一起持久化。再配合 GetService<T> 读取、SetUserInfo 跨会话注入,Agent 就拥有了能跨进程的长期记忆。
本系列四集到此把"单轮、工具、多轮、记忆"四块拼齐了。更复杂的编排(多 Agent 协作、循环任务)可以接着看仓库里 05_first_workflow 等示例。
示例源码
- 本示例:
dotnet/samples/01-get-started/04_memory/Program.cs - 上一篇:
dotnet/samples/01-get-started/03_multi_turn/使用说明书.md - 系列参考:同目录
01_hello_agent、02_add_tools、03_multi_turn等
参考资料
- Microsoft Agent Framework 的 OpenAI 扩展(
Microsoft.Agents.AI.OpenAI) - DeepSeek 官方 API 文档(中文):https://api-docs.deepseek.com/zh-cn/
- 参考博文:《MAF快速入门(1)化繁为简的AGENT创建范式》,作者 EdisonZhou(cnblogs)