AI Mind 的多会话短期记忆容器:如何隔离多个聊天上下文

本文基于 AI Mind 项目的真实实现整理。

GitHub:github.com/HWYD/ai-min...

对应代码版本:v0.4.4

线上链接:ai.hwyblog.cloud/instant-min...

AI Mind 是一个持续迭代中的 Next.js AI 聊天项目。从最基础的本地聊天开始,逐步加入流式传输、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。


到了 v0.4.4,问题从"一个会话内部怎么记住东西",变成了:

多个聊天上下文之间,怎么把短期记忆隔开?

打个比方:我一个会话在讨论博客怎么写,另一个会话在排查部署报错,还有一个在跑 Tasklist Agent 或 Delivery Chain。如果这些内容继续共用同一个对话线程,不同主题的消息、摘要、固定决策和最终回复很快就搅成一锅粥,模型下一轮看到的上下文也可能张冠李戴。

所以 v0.4.4 做的不是继续往单个线程状态里塞东西,而是把原来的单一当前对话记忆,拆成当前浏览器会话下的多个短期记忆容器。

这篇我想梳理的核心问题是:

AI 聊天的多会话不只是加一个侧边栏。真正要命的点是:多个聊天上下文怎么各自管好自己的短期记忆,新建、切换、刷新、流式输出、最终回复写入,每一步都不串线。


1. 背景:为什么单个线程状态不够用了

在 v0.4.2 / v0.4.3 里,AI Mind 的记忆模型可以简化成一条线:

text 复制代码
当前浏览器会话
  ↓
一个对话线程
  ↓
一个线程状态
  ↓
消息 + 摘要 + 固定决策

这个设计用来解决"当前会话内部怎么组织短期记忆"是够用的。

一个会话里,messages 保留最近原文,summary 承接早期上下文,pinned decisions 保存关键决策。工具、MCP/资源、Tasklist Agent、Delivery Chain 的最终用户可见回答,也可以作为最终回复写入当前会话记忆。

但实际用起来,没人会只盯着一个主题聊。我自己的日常可能是:

text 复制代码
会话 A:整理 v0.4.4 博客
会话 B:排查线上部署问题
会话 C:生成版本任务
会话 D:生成交付报告

这些上下文如果还挤在一个线程状态里,问题就很明显了:

text 复制代码
博客上下文污染部署上下文
部署排错内容跑进博客摘要
Tasklist 最终回复和普通聊天搅在一起
Delivery 最终报告被压进不相关会话记忆
固定决策被不同主题反复覆盖

所以 v0.4.4 真正要解决的是:

怎么在当前浏览器会话下,让多个聊天上下文各自管好各自的短期记忆。


2. 核心目标:把一个当前会话扩展成多个短期记忆容器

v0.4.4 的核心变化不是"加一个侧边栏",而是把原来的单一当前对话记忆拆成多个会话容器。

每个持久化会话都对应一个独立的短期记忆线程:

text 复制代码
当前浏览器会话
  ↓
会话注册表
  ↓
会话 A -> 线程状态 A
会话 B -> 线程状态 B
会话 C -> 线程状态 C

这样之后,会话 A 的消息、摘要、固定决策不会进入会话 B;会话 B 的最终回复也不会写回会话 A。

可以用一张图表示:

flowchart TD A[当前浏览器会话] --> B[会话注册表] B --> C[会话 A] B --> D[会话 B] B --> E[会话 C] C --> C1[线程状态 A] D --> D1[线程状态 B] E --> E1[线程状态 C] C1 --> C2[消息 / 摘要 / 固定决策] D1 --> D2[消息 / 摘要 / 固定决策] E1 --> E2[消息 / 摘要 / 固定决策]

这版真正要解决的是会话归属问题:

text 复制代码
哪些会话属于当前浏览器会话?
当前选中的会话是谁?
页面刷新时恢复哪个会话?
模型上下文来自哪个线程状态?
最终回复最后写回哪个线程状态?
流式输出中如何避免切换导致写错会话?

侧边栏只是用户看到的壳,真正吃劲的是这套归属规则。


3. 核心模型:会话注册表 + 会话线程

v0.4.4 的模型可以拆成两层。

第一层是 会话注册表,它回答:

当前浏览器会话下有哪些持久化会话?

第二层是 会话线程,它回答:

某个会话里记住了什么?

会话注册表:管理有哪些会话

会话注册表 是当前浏览器会话下的会话索引。

它大致包含:

text 复制代码
selectedConversationId(当前选中会话 ID)
conversations(会话列表)
updatedAt(更新时间)

其中会话列表最多保留 10 条,按 lastActiveAt(最后活跃时间)倒序排列。每条会话还包含 hasMessages 标记,用于过滤未持久化消息的条目。

这里有几个关键点:

text 复制代码
只属于当前浏览器会话
不是全局会话表
不是账号级历史
只收纳持久化会话(hasMessages=true)
空白草稿不进入注册表
最多保留 10 条

所以我对它的定位是:

会话注册表是短期记忆容器索引,不是聊天历史数据库。

它负责告诉系统:当前浏览器会话下有哪些可恢复的会话、当前选中哪一个、最近活跃顺序是什么。

但它不直接存聊天消息,也不承担完整历史系统的职责。

会话线程:管理会话记住了什么

每个持久化会话都映射到一个独立的对话记忆线程。

线程里面仍然是原来的 AiMindThreadState

text 复制代码
messages(消息)
summary(摘要)
pinnedDecisions(固定决策)
lastCompactedAt(上次压缩时间)

也就是说,v0.4.4 没有改变线程状态的内部结构。它没有给每条消息新增 conversationId,也没有引入来源标记、Agent 标记或运行时元数据。

这版改的是归属关系,不是状态结构。

可以概括成:

text 复制代码
v0.4.2 / v0.4.3:
一个线程状态内部怎么整理短期记忆。

v0.4.4:
多个线程状态之间怎么隔离和管理。

这个设计避免了消息结构膨胀。会话归属由注册表和会话级线程 ID 解决,线程状态本身继续保持纯文本。

具体来说,v0.4.4 用 chat-conversation:{sessionHash}:{conversationHash} 格式的线程 ID,其中 sessionHash 来自浏览器会话,conversationHash 由会话和 conversationId 共同派生,确保每个会话独享一个记忆线程。v0.4.4 不复用旧版的 chat:{sessionHash} 格式,也不兼容旧版单线程 ID。


4. 新聊天为什么先是空白草稿

做多会话时,一个常见做法是:用户点击"新聊天",系统立刻创建一条持久化会话。

这个方案看起来直接,但会带来一堆后续问题:

text 复制代码
用户只是点了新聊天,但没有输入内容
最近列表里出现空会话
空会话占用 10 条上限
刷新后要处理空会话恢复
多次点击新聊天会产生幽灵条目
后续还要设计空会话清理机制

为了避免这些坑,v0.4.4 选了更干净的语义:

点击"新聊天"只进入空白草稿状态。只有首条用户消息被接受后,才创建正式会话。

具体来说,前端发首条消息时必须显式标记 createConversation: true,服务端收到后才创建正式会话、分配会话级线程,并把它加入注册表。服务端随后通过 X-AI-Mind-Conversation-Id 响应头返回新创建的 conversationId,客户端拿到后把后续请求从草稿模式切到持久化会话模式。

有个细节值得提:聊天请求结构是严格的互斥约束------请求要么带 conversationId(已有会话),要么带 createConversation: true(新建会话),缺了哪个都会被直接拒绝,不存在模糊路径。

生命周期如下:

flowchart TD A[点击新聊天] --> B[空白草稿状态] B --> C{是否发送首条消息?} C -- 否 --> D[不进注册表<br/>不创建持久化线程状态] C -- 是 --> E[发送 createConversation:true 请求] E --> F[服务端创建持久化会话] F --> G[分配会话级线程] G --> H[进入最近注册表] H --> I[后续最终回复写入该线程状态]

这样设计之后,语义很清楚:

text 复制代码
未发送消息的草稿不污染最近列表
草稿不占用 10 条上限
不需要稳定态的空会话清理
刷新未发送草稿时,只恢复安全的空白工作面
只有真正产生用户输入后,会话才值得持久化

点"新聊天"只是进入新的工作面,不代表已经产出有价值的会话。

这个细节看着偏产品,但对记忆语义影响很大。注册表一旦允许空会话进去,就会连带引出空会话恢复、空会话裁剪、空标题、幽灵条目等一长串额外复杂度。


5. 当前选中会话必须由服务端校验

单会话的时候,系统只有一个当前线程,很多地方可以默认落到同一个线程。

多会话之后,默认值就危险了。

典型的情况有:

text 复制代码
前端本地存储里的 selectedConversationId 已经过期
某个会话已被裁剪
客户端传了不存在的 conversationId
用户在多个会话之间快速切换
发请求和 UI 当前选中状态不在同一时刻

服务端要是在这时候"自动找一个默认会话",串线几乎是必然的:

text 复制代码
恢复错会话
上下文用错线程状态
最终回复写回错误会话
A 会话看到 B 会话的消息 / 摘要 / 固定决策

所以在 v0.4.4 里,服务端校验过的当前选中会话是事实源。

也就是说:

text 复制代码
页面恢复以服务端验证过的选中会话为准
模型可见上下文以服务端验证过的选中会话为准
完成回复写入以服务端验证过的选中会话为准
本地存储只能作为恢复提示
缺失 / 无效的 conversationId 不能回退到旧线程或另一个会话

这不是多余防御,而是多会话记忆的基本前提。

在 AI 聊天里,UI 显示错数据已经是 bug;模型要是拿错上下文继续生成,问题更隐蔽,也更难查。


6. 恢复、上下文、写入都不能串线

多会话隔离不能只看 UI 切没切对,还要看三条链路是不是都隔离了:

text 复制代码
恢复(hydrate):恢复哪个会话
上下文(context):模型看到哪个会话的上下文
写入(append):最终回答写回哪个会话

恢复只能加载当前选中会话

我切到会话 A,页面就只能恢复 A 的最近消息。

不能因为 B 最近更新过,就把 B 的内容拿回来;也不能因为 A 恢复失败,就悄悄展示另一个会话的数据。

恢复接口不是"找一个可用的会话",而是:

恢复用户明确选中的持久化会话。

具体来说,恢复请求必须携带 conversationId,服务端校验该会话是否属于当前浏览器会话。校验失败时返回 404 错误或安全空态,不会偷偷替换成其他会话的数据。恢复返回的数据包含 conversationIdthreadId(可选)、messagessummaryPreview(可选)、pinnedDecisionsrestored 标记,且必须通过禁字段检查,确保不暴露原始检查点、会话 ID 或其他内部状态。

失败时应该返回安全空态或可恢复错误态,而不是跨会话兜底。

上下文只能来自当前选中会话

比 UI 显示更重要的是模型上下文。

如果会话 A 在讨论博客,会话 B 在排查部署,那 A 的下一轮模型上下文不能包含 B 的摘要或固定决策。

正确链路应该是:

text 复制代码
当前选中会话 A
  ↓
线程状态 A
  ↓
摘要 A + 固定决策 A + 最近消息 A
  ↓
最新用户输入

而不是:

text 复制代码
所有会话的消息混合
最近更新的线程状态
前端传来的完整历史

多会话真正危险的地方,不是页面显示错了,而是模型基于错误上下文继续回答。

最终回复写入流开始时的会话

v0.4.3 已经把普通聊天、工具/MCP/资源、Tasklist 最终回答、Delivery 最终报告纳入最终回复记忆。

到了 v0.4.4,这些最终回复还要解决归属:

写回哪个会话?

流式输出的时候,UI 当前选中状态可能会变。所以最终回复不能按"输出结束时 UI 选中的会话"写入,而应该写回请求开始时捕获的会话。

也就是:

活跃请求归属在请求开始时捕获,最终回复写入流开始时的会话。

flowchart TD A[会话 A 发送消息] --> B[捕获流归属: A] B --> C[助手流式输出] C --> D[完成最终回复] D --> E[写入线程状态 A] F[会话 B] -. 不参与本次写入 .-> E

这样,UI 状态变化不会影响服务端写入归属。


7. 流式输出中为什么禁止新建和切换

从 UI 上看,流式输出中禁用"新聊天"和会话切换,只是一个交互限制。

但在 AI 聊天运行时里,它是记忆写入安全的一部分。

助手正在输出的时候允许切会话,会出现这些问题:

text 复制代码
当前助手输出归属哪个会话?
最终回复应该写入哪个线程状态?
线程记忆状态会不会显示到另一个会话?
流中断后是否触发错误恢复?
刚输出的助手内容会不会被切换后的恢复清掉?

当然,也可以做更复杂的方案:允许切换、后台继续输出、按流 ID 重新路由数据块、最后把结果写回原会话。

但这对 v0.4.4 来说太重了。

当前 AI Mind 页面同一时刻只支持一个活跃聊天流,MVP 阶段最稳的策略就是:

text 复制代码
助手流式输出中禁用新聊天
助手流式输出中禁用会话切换
待审核状态中也禁用这些操作
流归属绑定到请求开始时的会话

这不是为了少做功能,而是先把归属关系做清楚。

多会话最怕的不是功能少一点,而是串线。


8. 最近列表为什么最多 10 个

v0.4.4 的注册表、桌面侧边栏和移动端抽屉都最多展示 10 个持久化会话。

这个 10 不是技术限制,而是产品边界。

如果一开始保留太多会话,很自然会被拖向完整历史系统:

text 复制代码
是否需要搜索?
是否需要分页?
是否需要删除?
是否需要重命名?
是否需要归档?
是否需要跨设备同步?

这些都不是 v0.4.4 要解决的问题。

这版只支持当前浏览器会话下的几个短期上下文切换,所以 10 个最近活跃会话已经足够。

排序按最后活跃时间,而不是创建时间。

规则是:

text 复制代码
首条用户消息提升时更新 lastActiveAt(创建时即设置)
后续用户发送消息更新 lastActiveAt(通过 touchConversation)
完成助手回复更新 lastActiveAt(通过 touchConversation)
单纯切换会话只更新 selectedConversationId,不更新 lastActiveAt
超过 10 个后,裁剪最久未活跃的持久化会话

"最近会话"更接近"最近使用",不是"最近创建"。

同时,单纯点进去看一眼不应该频繁扰动列表;真正发送消息或收到完成的助手回复,才说明这个会话被继续使用。此外,selectConversation 方法只负责切换选中状态,不会触发列表重排;只有 touchConversation 才会更新 lastActiveAt 并影响排序。


9. 侧边栏只是入口,不是完整历史管理

v0.4.4 做了桌面侧边栏和移动端会话选择器,但我不想把这篇文章写成"仿 ChatGPT 侧边栏"。

UI 只是入口,真正吃劲的是背后的会话归属关系。

桌面端只做最小能力:

text 复制代码
品牌区
新聊天入口
最近会话列表
当前会话高亮
折叠 / 展开

移动端也只做最小可用版本:

text 复制代码
顶部当前选中会话入口
抽屉式会话选择
新聊天入口
最近会话列表
当前会话高亮

这些能力足够表达会话注册表和当前选中会话。

但这版没有继续扩展成完整历史管理:

text 复制代码
搜索
分页
删除
重命名
归档
分享
项目入口
文件库入口
悬浮操作菜单
完整 ChatGPT 侧边栏复刻

视觉上继续继承 instant-mind 聊天页的壳层和主题变量。组件实现上优先复用本地 apps/webapp/components/ui/ 里的 shadcn/ui 基础组件;缺的基础组件在本地补齐就好,不依赖 MCP 或远程注册表作为运行时组件来源,也不直接搬整套官方侧边栏模块。

跟这版的定位一致:

UI 只需要表达会话注册表和当前选中会话,不该把 v0.4.4 拖成完整应用导航系统。


10. Tasklist 和 Delivery:只接最终文本,不接管运行时

v0.4.4 需要保持 v0.4.3 的最终回复记忆能力。

换句话说,普通聊天、工具/MCP/资源、Tasklist 最终回答、Delivery 最终报告,最终都要写入当前选中会话记忆。

但这不等于会话记忆要接管 Tasklist 或 Delivery 的运行态。

Tasklist

Tasklist Agent 仍然有自己的:

text 复制代码
图状态
检查点
人工中断
Agent 运行状态
恢复线程标识

这些不进入会话线程状态。

v0.4.4 只做一件事:

Tasklist 跑完后,把最终用户可见文本写入当前选中会话记忆。

展开来说,Tasklist Agent 启动时会接收 conversationId 参数,完成后把最终回答写入该会话对应的线程状态。会话只收最终对话结果,不管 Tasklist 的执行和恢复。

Delivery

Delivery Chain 仍然是本地运行的工作流。

它内部的:

text 复制代码
运行时产物
工作流进度
子 Agent 原始调用/结果
管理器追踪

也不进入聊天记忆。

v0.4.4 只把已完成 / 已阻塞的最终报告文本写入当前选中会话记忆。

因此,Tasklist / Delivery 的边界一句话总结:

多会话只改变最终文本写入哪个会话,不动 Tasklist 和 Delivery 的运行时本体。

Tasklist 和 Delivery 通过接收 conversationId 参数来确定最终文本写入哪个会话,它们的内部执行和恢复机制跟会话记忆完全隔离。

不然会话注册表很容易被扩成 Agent 运行注册表,或者把 Delivery 变成持久化工作流历史,这已经跑出 v0.4.4 的范围了。


11. 范围收口:为什么暂时不做完整聊天历史系统

多会话做出来以后,其实很容易一路往完整历史系统滑。

搜索、分页、重命名、删除、归档、跨设备同步、账号级历史,都会变成很自然的下一步。

但 v0.4.4 没有往这个方向走。

原因是这版的目标不是做一个完整聊天历史产品,而是先把"当前浏览器会话下多个短期记忆容器"这件事做稳。

也就是:

text 复制代码
会话可以被创建、选择和恢复
每个会话有独立线程状态
恢复、上下文、最终回复写入不串线
流式输出中不会因为切换导致写错线程
v0.4.2 / v0.4.3 的记忆内核不被破坏

所以这版没加 ChatSession / ChatMessage 业务表,也没做搜索、分页、删除、归档、跨设备同步或长期记忆。

这些能力不是没价值,而是属于后续"完整历史系统"的范围。v0.4.4 先把短期记忆容器的归属关系打稳。


12. 这版改归属关系,不改记忆内核

v0.4.4 看起来改动面挺大:注册表、草稿、路由、侧边栏、移动端抽屉、创建/切换/恢复都有变化。

但它的核心是:

在已有聊天记忆外围加一层会话归属关系。

它没有重写记忆内核。

保持不变的部分包括:

text 复制代码
线程状态继续保持纯文本
聊天线程消息继续只保存 id / 角色 / 文本 / 创建时间
安全恢复继续只返回安全文本
服务端权威上下文继续有效
摘要压缩按会话隔离
固定决策按会话隔离
v0.4.3 最终回复记忆继续工作
Tasklist 检查点/恢复语义不变
Delivery 本地运行语义不变
流核心数据块合并不变
前端 reducer 公开结构不变
Prisma 业务结构不新增 ChatSession / ChatMessage

这也是 v0.4.4 最值得记录的地方。

它表面上是多会话 UI,实际是一次记忆归属扩展:不破坏原来的短期记忆结构,只把它从单线程拉成会话级线程。

验证也不能只看 UI 能不能点,还要覆盖:

text 复制代码
注册表数量限制
草稿提升
会话切换
按会话恢复隔离
按会话上下文隔离
按会话最终回复写入隔离
流式输出防护
禁字段安全
v0.4.2 / v0.4.3 无回归

13. 总结:多会话的重点是上下文归属

v0.4.4 最后交出来的不是完整聊天历史系统,而是一个多会话短期记忆容器。

它解决的从来不是"怎么做一个侧边栏",而是:

text 复制代码
当前浏览器会话下有哪些持久化会话
新聊天什么时候才值得转成正式会话
当前选中会话由谁确认
每个会话如何拥有独立线程状态
恢复 / 上下文 / 写入如何避免串线
流式输出中如何保护活跃请求归属

回头看 v0.4.2 / v0.4.3,它解决的是:

一个会话内部,短期记忆怎么压缩、整理和控制上下文。

那么 v0.4.4 解决的就是:

多个会话之间,短期记忆怎么隔离、选择和写回。

侧边栏只是外面能看到的壳。真正重要的是它背后的 会话注册表空白草稿服务端校验选中会话会话级线程状态

对 AI 聊天来说,多会话不是简单多开几个窗口,而是给每个上下文一个清晰、可验证、不会串线的归属。

项目地址

👉 GitHub:github.com/HWYD/ai-min...

👉 线上体验:ai.hwyblog.cloud/instant-min...

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。

相关推荐
阿拉斯攀登2 小时前
Prompt 工程与答案生成优化
prompt·agent·memory·知识库·向量数据库·rag
Briwisdom2 小时前
Agent 帮我写 CUDA(续):当 Self-Attention 遇上 Causal Mask 和 Cross-Attention
agent·算子开发·flash attention
CaffeinePro2 小时前
Langchain环境搭建与LCEL核心语法
人工智能·agent
人工智能培训2 小时前
大模型驱动下传统大数据架构的变革方向
大数据·人工智能·重构·架构·agent·agi
倾颜3 小时前
AI Mind 的单线程短期记忆设计:如何压缩、整理并控制当前会话上下文
前端·langchain·llm
掉鱼的猫3 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·llm·agent
小月土星4 小时前
Agent 开发思考笔记:LLM + Memory + Tool + RAG + MCP + Skills
langchain·llm·agent
小开_ALSKai4 小时前
Obsidian 最强的 AI 功能,就是它没有 AI 功能
人工智能·agent
带刺的坐椅4 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·ai·llm·agent·solon·restapi·openapi