你有没有发现,ChatGPT左边那个能找回旧对话的侧边栏,用起来很顺手?但自己搭AI应用时,每次重启服务,AI就像失忆了一样。微软刚开源的Agent框架,给这个问题提供了一个挺有意思的解法。
AI的"金鱼记忆"问题
![]()
大语言模型(LLM)本质上是无状态的。你问"超级马里奥64有多少关",它答对了。紧接着问"有多少颗星星",它可能一脸懵——完全没意识到你还在聊同一款游戏。
原因很直接:每次请求都是独立的。要让AI理解上下文,你得把整段对话历史打包发过去。问得越多,token消耗越夸张,账单也越厚。
微软Agent框架的解决方案分两层:先解决"短期记忆",再搞定"长期存档"。
短期记忆:Agent Session怎么工作
框架引入了Agent Session的概念。不是直接调agent.runAsync("问题"),而是先创建一个session,之后每次调用都带着它。
框架会在后台自动把新消息追加到列表里,下次调用时一并发送。代码看起来是这样的:
// 创建Agent Session来存储短期上下文
var session = await agent.GetNewSessionAsync();
// 每次请求都带上session
var response1 = await agent.RunAsync("超级马里奥64有多少关?", session);
var response2 = await agent.RunAsync("有多少颗星星?", session);
// AI现在知道你还在聊那款游戏了
但这里有个坑:默认情况下,存储只在内存里。应用关闭或服务重启,AI的记忆就清零了。这对于生产环境显然不够用。
长期记忆:ChatHistoryProvider的设计
要实现类似ChatGPT侧边栏那种"找回旧对话"的功能,就需要持久化存储。框架提供了ChatHistoryProvider来解决这个。
核心设计是一个叫StateBag的东西——每个session配一个灵活的键值存储。你可以在里面放一个唯一session ID(比如GUID),作为数据库或文件系统的引用键。把ID和聊天历史分开存,既能安全引用,又能灵活恢复。
具体实现需要继承ChatHistoryProvider类,重写两个方法:
// 第一步:保存
public override async Task StoreChatHistoryAsync(ChatHistoryContext context)
{
// 从StateBag取出Session ID
var sessionId = context.Session.StateBag["SessionId"].ToString();
// 获取最新消息
var newRequest = context.RequestMessages;
var newResponse = context.ResponseMessages;
// 序列化保存到数据库或文件
await SaveMessagesToDatabaseAsync(sessionId, newRequest, newResponse);
}
// 第二步:恢复
public override async Task> ProvideChatHistoryAsync(ChatHistoryContext context)
{
// 检查StateBag是否已有Session ID
if (!context.Session.StateBag.TryGetValue("SessionId", out var sessionIdObj))
{
// 新session,创建唯一ID并存入StateBag
context.Session.StateBag["SessionId"] = Guid.NewGuid().ToString();
}
// ...从数据库加载历史消息
}
这张图在说什么
原文配了一张架构图,把这套机制拆成了几个层次。咱们一层层看。
最底层是LLM本身——无状态、孤立请求、按token计费。这是约束条件,不是bug。所有上层设计都是在给这个"傻底子"打补丁。
往上是Agent Session层。框架在这里做了消息自动追加,开发者不用手动拼接对话历史。但内存存储的局限性也很诚实:适合单次会话,不适合跨会话。
再往上是ChatHistoryProvider层。StateBag的设计挺巧妙——它不负责存数据,只存一个引用ID。真正的存储逻辑交给开发者自己实现,数据库、文件系统、甚至云端对象存储都行。这种"接口开放、实现自由"的思路,很微软。
最顶层是用户体验层。侧边栏的历史对话列表、点击恢复、多设备同步——这些都不是框架自带的,但框架给了你能做出这些功能的基础设施。
为什么StateBag设计成键值存储
这里有个产品思维的细节。StateBag不是硬编码几个字段,而是开放的键值对。除了SessionId,你还可以塞用户ID、对话主题标签、权限级别……任何你需要跟着session走的元数据。
这种设计牺牲了一定的类型安全,换来了灵活性。对于框架来说,这是正确的trade-off——它不知道你的业务需要什么,所以干脆不假设。
但也带来一个隐形成本:开发者得自己管理键名的一致性。拼错"SessionId"和"SessionID",就是两个不同的key,调试时能找半天。
存储策略的取舍
原文没说的是,这个设计把"怎么存"的难题抛给了开发者。但我们可以从代码结构里反推出几种典型方案。
方案一是关系型数据库。SessionId做主键,消息序列化成JSON塞text字段。查询方便,但大对话历史可能撑爆单行容量。
方案二是对象存储(S3之类)。每条对话存一个文件,SessionId做文件名前缀。成本低,但列表查询要额外索引。
方案三是混合:元数据放数据库,消息体放对象存储。这是ChatGPT这种规模的产品的常见做法。
框架不做选择,只提供钩子。这是好事也是坏事——好事是灵活,坏事是新手可能不知道选哪个。
和LangChain、LlamaIndex的对比
原文没提竞品,但我们能从设计差异里看出定位。
LangChain的Memory模块更"重",内置了多种记忆类型(缓冲记忆、摘要记忆、向量记忆),但这也意味着更高的学习曲线。LlamaIndex更偏向RAG(检索增强生成),对话历史管理不是其核心场景。
微软Agent框架的选择是"轻量可扩展"。它只解决最基础的状态隔离和持久化钩子,复杂功能(比如自动摘要、长期记忆的语义检索)留给生态或开发者自己叠。
这种策略符合微软近年的开源风格:把基础设施做扎实,不抢应用层的风头。
一个容易被忽略的细节
注意StoreChatHistoryAsync的签名:它接收的是ChatHistoryContext,里面同时有RequestMessages和ResponseMessages。这意味着你存的不只是用户说了什么,还有AI回了什么。
为什么重要?因为恢复对话时,AI需要看到完整的往返记录,才能保持角色一致性。如果只存用户输入,AI恢复后可能会"人格分裂"——同样的问题给出不同的回答风格。
这也解释了为什么token消耗会累积:每次都要把历史对话的双方发言都发过去。框架帮你自动拼接,但省不了这笔钱。
什么时候该用这套方案
如果你在做客服机器人、编程助手、或者任何需要多轮对话的Agent,这个框架能省不少样板代码。特别是已经有.NET技术栈的团队,集成成本很低。
但如果是单次问答场景,或者对话历史极短(比如只问一句话),直接调API更简单。Agent Session的 overhead(额外开销)在这种场景下是浪费。
另一个考量是团队规模。小团队可能更愿意用托管服务(比如OpenAI的Assistants API,内置线程管理),把状态问题外包出去。微软这套方案的优势在于可控——数据存在哪、存多久、怎么加密,全由你定。
框架没解决的事
读完原文,有几个问题框架没给答案。
一是历史消息的清理策略。对话长了怎么办?自动摘要?滑动窗口截断?还是硬限制轮数?这些业务逻辑需要开发者自己实现。
二是并发安全。如果同一个SessionId被两个请求同时修改,StateBag会不会冲突?原文没提线程安全机制。
三是跨模态状态。如果对话里混了图片、文件引用,StateBag里的字符串ID还能管用吗?序列化方案得自己设计。
这些不是框架的缺陷,而是刻意留白。微软画好了地基图纸,上面的楼层怎么盖,看各家需求。
最后一点观察
这套API设计有个小细节:GetNewSessionAsync是异步的。创建session可能要初始化存储连接、预分配资源,所以给了async入口。但StateBag的读写是同步的键值访问——说明框架假设这些操作在内存里完成,延迟足够低。
这个分层很清晰:IO边界(创建、加载、保存)全异步,内存操作同步。符合现代.NET的编程习惯,也避免了不必要的async/await传染。
对于要在.NET生态里做AI应用的开发者,这个框架值得放进技术选型清单。它不会帮你解决所有问题,但至少把"给AI装脑子"这件事,从手搓电路板变成了组装乐高。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.