老胡
发布于 2026-09-24 / 1 阅读
0
0

MAF 快速上手(4)让 Agent 长期记住你:自定义记忆

第三集我们用 AgentSession 做到了多轮对话,可那点记忆只活在当前的进程里。程序一关,会话就没了,下次启动又得从头聊。这一集往前走一步:给 Agent 加一份能跨会话、还能落盘保存的长期记忆。

做法不是让你手撸数据库,而是写一个自定义记忆组件,挂在 Agent 上。本集的例子里,组件会自动从对话里认出用户的姓名和年龄,然后跨调用、跨会话一直记得。

示例源码位于:dotnet/samples/01-get-started/04_memory/


一、这一集比第三集多了什么

第三集的记忆是会话级别:同一个 session 内连续对话,换个 session 或重开程序就失忆。第四集引入的是一个记忆组件,它做三件第三集没做的事:

  1. 主动从对话里抽取结构化信息(比如"我叫 ruaidhri,20 岁");
  2. 把这些信息存成状态,随会话一起序列化,可以存文件、下次读回来;
  3. 在每次调用前,把已知信息注入给模型,让 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 自己记;注入时,如果某项还没收集到,就指示模型先问,收集齐了才转为告知。


五、运行时到底发生了什么

把上面的流程走一遍,就能看清记忆是怎么流动的:

  1. 空白会话下问"9 的平方根是多少",模型正常回答,此时它不知道你是谁;
  2. 你说"我叫 ruaidhri",StoreAIContextAsync 从这条消息里抽出用户名并保存;
  3. 你说"我今年 20 岁",年龄也被抽出保存;
  4. 把会话序列化再反序列化,记忆跟着回来了;问"我叫什么、几岁了",ProvideAIContextAsync 把"名字是 ruaidhri、年龄 20"注入给模型,模型于是能答出来;
  5. 通过 GetService 直接读到组件里存的用户名和年龄;
  6. 开新会话并 SetUserInfo 灌入同一份记忆,新会话也能叫出你的名字。

所以"长期记忆"并不是模型变聪明了,而是框架在对话前后分别做了抽取和注入,再借由会话序列化把状态保留了下来。


六、动手改造

跑通之后,试试这几件事,把记忆组件吃透:

  • 加字段:在 UserInfo 里增加"所在城市""偏好语言",并同步改抽取指令和注入指令,看组件能不能一并记住。
  • 落盘保存:把 SerializeSessionAsync 得到的 JsonElementFile.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_agent02_add_tools03_multi_turn

参考资料

  • Microsoft Agent Framework 的 OpenAI 扩展(Microsoft.Agents.AI.OpenAI
  • DeepSeek 官方 API 文档(中文):https://api-docs.deepseek.com/zh-cn/
  • 参考博文:《MAF快速入门(1)化繁为简的AGENT创建范式》,作者 EdisonZhou(cnblogs)

评论