本文基于
AI Mind项目的真实实现整理。
GitHub:github.com/HWYD/ai-min…
对应代码版本:v0.4.2 - v0.4.3
线上链接:ai.hwyblog.cloud/instant-min…AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。
如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。
做到 v0.4.x 这一阶段时,我遇上了一个看起来很基础、但很容易做偏的问题:
AI Chat 到底应该怎么记住当前会话?
第一反应很简单——把 messages 存起来不就行了?页面刷新后恢复一下,体验就接上了。
但真放到 AI Runtime 里看,事情没那么单纯。
记忆不只是恢复 UI。它还会影响下一轮模型的上下文。普通聊天、工具调用、资源读取、Agent 执行、Workflow 过程,每一步都在产生状态。这些状态如果全塞进 chat memory,后面上下文会越来越乱——搞不好内部运行态就泄漏到前端了,或者被拼进下一轮模型输入里。
所以这两个版本我既没直奔长期记忆,也没做 ChatGPT 左侧那种多会话历史列表,而是先给当前浏览器会话补了一层底子:
安全、可恢复、有界的单线程短期记忆。
这篇文章不侧重"刷新后怎么恢复页面",而是整理这套短期记忆的设计思路:当前会话怎么分层、长对话怎么压缩、关键结论怎么保留,工具和智能体的最终回答怎么纳入同一套记忆边界。
1. 记忆不只是保存 messages
很多 AI Chat Demo 的早期做法都很直接:前端维护 messages,每次请求把完整历史发给后端,后端调模型,前端流式追加回答。
项目早期这么跑完全没问题。普通问答、流式输出、基本上下文连续,够用。
但 AI Mind 能力一多,问题就出来了。
前端 messages 本质上是 UI 状态。它可能包含半截流式消息、错误状态、工具卡片、资源卡片,跟服务端状态也不一定对齐。把它当长期可信的模型上下文来源,后面迟早翻车。
完整历史也不能无限注入模型。对话一长,上下文越来越大,成本涨,响应慢,早期无关内容还会干扰当前问题。
还有一个更隐蔽的问题:真实的 AI 应用不是只有文本聊天。Tool Calling、MCP/resource、Tasklist Agent、Delivery Chain 都会产生中间执行态。它们看起来都跟"上下文"沾边,但不都应该进 chat memory。
比如下面这些,就不适合进普通聊天记忆:
工具参数 / 工具结果
MCP 信封 / 资源原始内容
Tasklist GraphState / HITL checkpoint
Delivery RuntimeArtifact / 工作流进度
子智能体原始调用 / 结果
原始提示词 / 模型响应 / 堆栈跟踪
说穿了,这个阶段真正要解决的不是"怎么存 messages",而是:
怎么把当前会话整理成一份可控的短期记忆。
2. 什么是单线程短期记忆
这里说的"单线程",不是操作系统线程,是当前浏览器会话对应的那一条聊天运行时线程。
它只服务当前浏览器里的当前聊天——不跨浏览器,不跨多个会话,不做左侧历史列表,不做长期用户画像,不做向量长期记忆,不保存完整 Agent 执行轨迹。
范围很克制。我想先解决的就是当前会话内的连续性:用户刷新后,最近对话还在;继续追问时,模型能看到整理后的上下文;对话变长后,早期内容被压缩掉;工具和智能体的最终回答能被记住,但中间执行过程不污染 chat memory。
整体结构可以简化成这样:
flowchart TD
A[当前浏览器会话] --> B["chat:sessionHash"]
B --> C[AiMindThreadState]
C --> D["recent messages<br/>最近用户可见文本"]
C --> E["summary<br/>早期对话摘要"]
C --> F["pinned decisions<br/>关键决策与边界"]
C --> G["lastCompactedAt<br/>最近压缩时间"]
核心就是 AiMindThreadState。它既不是完整聊天历史表,也不是 Agent 运行时快照,就是当前会话的一份短期记忆状态。
3. ThreadState:把当前会话整理成三层记忆
AI Mind 的短期记忆没有设计成一组无限增长的 messages,而是拆成三层:
AiMindThreadState
├── messages(最多 8 条,即最近 4 轮对话)
│ └── 最近的用户可见文本消息
├── summary(最多 2500 字)
│ └── 更早对话的有界摘要
├── pinnedDecisions(最多 20 条,每条 ≤300 字)
│ └── 用户明确决策、架构边界、关键结论
└── lastCompactedAt
└── 最近一次成功压缩时间
这三个字段各解决不同问题。
recent messages:最近内容保留原文
最近几轮对话跟下一轮问题最相关,适合保留原文。
比如我刚跟 AI 讨论完"这篇博客重点不放在刷新恢复,而是放在短期记忆如何压缩、整理和控制上下文",下一轮我继续问"那标题和大纲怎么调整?"——这时候最近几轮原文很重要。它不只是信息,还包含上下文节奏、措辞偏好和刚拍板的方向。过早压缩成摘要反而容易丢细节。
所以 messages 的职责就是保留最近的用户 / 助手文本消息,上限 8 条(4 轮)。
但这里有一个硬边界:必须保持纯文本。
一个可持久化的 ChatThreadMessage 只有四个字段:id、role、text、createdAt。不存 source、turnId、displayKind,也不存工具参数、资源内容、Agent 步骤、Workflow 进度。
这个设计看起来保守,但很重要。只要 messages 里混进运行时对象,后面的 hydration、context builder、compaction 都会变复杂。更严重的是,内部执行态可能被展示给前端,或者被拼进下一轮模型上下文。
规则很明确:最近内容可以保留原文,但只能保留用户可见文本。
summary:早期内容压缩成摘要
短期记忆不能无限增长。每轮都完整保留的话,当前会话也会慢慢变成"无限历史"。模型上下文越来越长,请求成本越来越高,早期无关信息干扰当前问题。
所以有了 summary,用来保存更早对话的有界摘要(最多 2500 字)。它不是原始 checkpoint,不是原始提示词,不是模型响应,就是一段安全的自然语言文本。
我对它的定位很简单:recent messages 保留最近原文,summary 承接更早上下文。
比如前面聊过很多版本规划、技术取舍和博客方向,后面不需要每次都把完整过程塞给模型。更合适的做法是把早期内容压成一段背景摘要,让模型知道大方向就行。
pinned decisions:关键决策单独保留
summary 适合记录背景,但有些内容不能只放在摘要里。
比如用户明确拍板的设计方向、版本明确不做的范围、架构边界、后续回答必须遵守的约束、博客或面试里必须准确表达的结论——这些内容如果混在 summary 里,多次压缩后可能被弱化、改写,甚至丢掉。
所以 AI Mind 单独设计了 pinnedDecisions,最多 20 条,每条不超过 300 字。可以理解为当前会话里一份"关键结论清单"。
举个例子:
- 这篇博客不按 v0.4.2 / v0.4.3 分段写,而是融合成单线程短期记忆设计文章。
- 文章重点不放在刷新恢复,而是放在短期记忆如何压缩、整理和控制上下文。
- 不把该能力描述为长期记忆、多会话历史或完整 Agent Memory。
这些不是普通聊天背景,是后续回答需要持续遵守的决策。pinned decisions 的价值就是把"不能忘的结论"从普通摘要里提出来,单独保留。
4. 写入策略:完成后写一次,不是每个 chunk 都写
AI Chat 通常是流式输出的。模型一边生成一边返回 token,工具调用和 Agent 执行过程里还可能夹杂工具卡片、资源卡片、进度事件、中断事件等中间状态。
但 memory 不能跟着每个 chunk 写。流式过程里的内容不一定是最终结果——助手回答还没生成完、用户取消了请求、工具调用失败后模型重新解释、Agent 命中 HITL pause、Workflow 中途异常——这些半截内容如果提前写进 memory,下一轮模型上下文就被污染了。
所以 AI Mind 的写入策略很明确:只在整个回合确认完成后写一次。
flowchart TD
A[用户输入] --> B[模型 / 工具 / Agent / Workflow 执行]
B --> C[流式输出中间内容]
C --> D{是否得到最终用户可见回答?}
D -- 否 --> E[不写入已完成记忆]
D -- 是 --> F[确认已完成]
F --> G[追加用户文本 + 助手最终文本]
具体约束:
- 只在
completed、blocked或final状态下写入,cancelled、failed、interrupted、paused都不写 - 不按流式块写,一次回合只写一次
- 空白文本不写
- memory 失败不影响用户已经收到的回答(上层 catch 住,降级处理)
所以短期记忆虽然表面上像"保存消息",实际更像一个运行时追加边界。它要判断的不只是"有没有文本",还要判断这轮对话是否真的完成了、这段文本是否适合作为下一轮上下文、它是不是只是运行过程里的临时状态。
5. 压缩策略:recent messages + summary + pinned decisions
当 messages 超过 8 条后,就不能继续无限追加了。这时候触发 compaction,把当前会话重新整理成三层结构:
flowchart LR
A[压缩前<br/>turn1...turn12] --> B[Compaction]
B --> C[summary<br/>早期对话摘要]
B --> D[pinned decisions<br/>关键决策]
B --> E[recent messages<br/>最近 4 轮原文]
压缩前可能是 12 轮对话,共 24 条消息。压缩后变成:
summary:
- turn1 到 turn8 的背景摘要(≤2500 字)
pinnedDecisions:
- 用户确认不做长期记忆
- 用户确认博客重点放在短期记忆压缩和整理
- 用户确认工具和智能体只保存最终回答
recentMessages(最近 4 轮,共 8 条):
- turn9 user
- turn9 assistant
- turn10 user
- turn10 assistant
- turn11 user
- turn11 assistant
- turn12 user
- turn12 assistant
这套设计同时做三件事。
第一,早期对话进入 summary——保留背景,不保留完整原文。
第二,关键结论进入 pinned decisions——保留明确决策和架构边界,避免在摘要里被稀释。
第三,最近 4 轮继续保留原文——保证下一轮对话仍然自然,不会因为过早压缩而丢掉细节。
所以模型下一轮真正看到的上下文不是全部历史 messages,而是:
summary + pinned decisions + recent messages + 最新用户输入
这个设计解决的是一个很现实的问题:长对话不是不能记,而是不能用"完整历史无限追加"的方式记。
压缩用的是模型生成(deepseek/deepseek-v4-pro,temperature 0,structured output),但只信任模型输出的 summary 和 pinned decisions。recentMessages 和 lastCompactedAt 这类结构性字段由本地派生,不信任模型生成。如果压缩失败,不覆盖已有 ThreadState,不影响用户已经收到的本轮回答——下一轮仍然可以继续。
6. 服务端 ThreadState 才是模型上下文事实源
在没有服务端 memory 之前,前端把完整 messages 发给后端,后端直接构造模型输入——很多 AI Chat Demo 都这么干。
引入 ThreadState 后,这个模式就得调整了。前端 messages 更多是 UI 数据,不是可信的上下文事实源。它可能过长、可能跟服务端 memory 重复、可能包含 UI 状态、可能带来历史残留。某些消息只应该展示,不应该进模型。服务端已经有压缩后的 summary / pinnedDecisions / recentMessages 了。
所以在 AI Mind 里,模型上下文以后端 ThreadState 为准:
前端请求:
- 当前用户最新输入
- 兼容性的 messages 数据
服务端上下文:
- ThreadState.summary
- ThreadState.pinnedDecisions
- ThreadState.recentMessages
- 最新前端用户输入
也就是说:前端负责展示,服务端 ThreadState 负责决定模型真正看到什么。这个转变还挺关键的——AI Chat 的 memory 一旦进入服务端,就不再只是"恢复 UI",而是参与模型输入构造。既然它会影响模型回答,就必须由服务端统一整理、压缩和过滤。否则两个上下文来源(前端完整历史 + 服务端 ThreadState)一起注入,轻则重复,重则冲突。
7. 工具和智能体的结果,如何进入短期记忆
普通聊天的短期记忆结构稳定后,下一个问题是:工具调用、MCP/resource、Tasklist Agent、Delivery Chain 的结果要不要进 memory?
不进,体验断裂——完成了一次工具辅助回答,刷新后普通直答能恢复,但工具回答不见了。执行了一次 Delivery Chain,最终报告生成了,刷新后这轮对话丢了。这不符合用户对"当前会话"的直觉。
但直接塞进去,又破坏边界。
所以 AI Mind 的策略是:最终用户可见回答进入 memory,中间执行过程不进入 memory。
flowchart TD
A[用户输入] --> B[Tool / MCP / Agent / Workflow 运行时]
B --> C[中间运行时状态]
C --> C1[工具参数 / 结果]
C --> C2[MCP 信封 / 资源原始内容]
C --> C3[GraphState / checkpoint]
C --> C4[RuntimeArtifact / 工作流进度]
B --> D[最终用户可见回答]
D --> E[最终回合适配器]
E --> F[ThreadState 纯文本消息]
C -. 禁止 .-> X[不持久化到聊天记忆]
这里有一个很重要的判断:工具和智能体的执行过程属于运行时,不属于聊天记忆。聊天记忆只管这一轮最终对用户说了什么。
这样既能让用户刷新后看到工具 / 智能体的最终回答,又不会把执行轨迹、artifact、checkpoint 混进普通聊天上下文。
8. 两个容易越界的例子:Tasklist Agent 和 Delivery Chain
Tasklist Agent 和 Delivery Chain 都不是简单文本回答,各有自己的运行时结构。
Tasklist Agent:只保存最终文本摘要
Tasklist Agent 有自己的 GraphState、checkpoint、HITL interrupt、AgentRun 状态。这些东西是 Agent 执行和恢复需要的业务运行态,不是聊天历史。
把它们写进 chat memory 会导致:普通聊天上下文混入 Agent 内部状态、hydration 可能暴露 checkpoint 或 HITL 数据、Tasklist 恢复的线程 ID 和 chat memory 线程 ID 混淆、artifact markdown 被误当成普通助手消息。
所以 Tasklist 只保存用户目标和最终回答文本摘要,不保存 tasklist artifact markdown、GraphState、checkpoint、interrupt 数据、AgentRun 内部状态。
关键取舍:Tasklist artifact 是 Agent 产物,不是聊天记忆。聊天记忆只保留这轮对话最终对用户呈现的文本摘要。
Delivery Chain:只保存已完成 / 已阻塞的最终报告
Delivery Chain 更像一次单次本地工作流。内部产生计划阶段输出、任务阶段输出、审查阶段输出、工作流进度、RuntimeArtifact、子智能体调用/结果、管理器追踪——这些对运行过程有意义,但不应该进当前聊天记忆。
用户刷新后真正需要看到的是:这轮 Delivery Chain 最终给了什么报告。
所以 Delivery 只保存用户输入和 completed / blocked 状态的最终报告文本,不保存工作流进度、RuntimeArtifact、管理器追踪、子智能体原始调用/结果、失败 / 异常 / 取消的输出。
另外,Delivery 最终报告可能很长。为了避免单个报告撑爆 memory,保存前做确定性截断(上限 8000 字,超出部分截断并追加提示),而不是再调模型生成执行摘要。这个选择比较克制——当前版本要解决的是"最终文本可以进短期记忆",不是要引入新的摘要系统。
9. Hydration 只是出口:恢复的是安全文本,不是运行时快照
虽然这篇文章重点不是"刷新恢复",但 hydration 是检验 memory 边界是否清晰的出口。
如果一个 memory 系统的恢复接口返回了原始 checkpoint、模型响应、GraphState、RuntimeArtifact,那说明它已经把运行时状态和公开聊天历史混在一起了。
AI Mind 的 hydration 只返回安全 DTO:
GET /api/chat/thread
↓
读取 ThreadState
↓
转换为前端可展示的文本消息
↓
返回 threadId / messages / summaryPreview / pinnedDecisions / restored
而且 hydration 出口还有一层安全校验(assertNoForbiddenHydrationFields),递归检查返回的 DTO 里是否包含 apiKey、checkpoint、cookie、deliveryChain、graphState、mcpEnvelope、providerConfig、rawPrompt、resourceContent、runtimeArtifact、sessionId、stack、subagentInvocation、toolArgs、toolCall、toolResult、turnId、tasklist、workflowProgress 等禁止字段。如果出现,直接抛错,不返回。
也就是说:Hydration 返回的是整理后的聊天记忆,不是运行时快照。这个边界很重要——一旦 hydration 暴露内部状态,后续前端展示、调试工具、日志,甚至模型上下文都有可能被污染。
10. 让短期记忆可控:去重、截断和失败降级
memory 是增强体验的运行时支撑能力,不能反过来拖垮主流程。下面几个细节看起来小,但处理不好就容易出问题。
同一轮最多写一次
一轮回答可能被多个运行时层观察到完成——orchestrator 捕获最终助手文本、agent coordinator 拿到最终回答、workflow runtime 也生成最终报告。如果没有去重,同一轮可能重复写入。
AI Mind 的处理是:追加前读取当前 ThreadState,有稳定消息 ID 时按 ID 去重,没有稳定 ID 时按最近两条消息(用户 + 助手)的文本完全匹配去重。不需要持久化 turnId,也能避免重复最终回合。
长文本做确定性截断
Delivery 最终报告上限 8000 字,超出部分截断并追加提示标记。没有引入模型摘要,因为截断最直接、可测试、没有额外模型调用、不引入新的产品语义。
compaction 失败不破坏已有记忆
compaction 涉及模型生成,可能失败、超时、格式不合法。所以只信任模型输出里的 summary 和 pinned decisions(经过 Zod schema 校验),recentMessages 和 lastCompactedAt 由本地派生。如果 compaction 失败,不覆盖已有 ThreadState,不影响用户已经收到的本轮回答。
memory 不可用时安全降级
checkpoint 存储可能不可用,数据库也可能临时异常。这时候普通聊天降级为无记忆聊天,不暴露原始数据库错误、checkpoint 错误、模型栈。上层(chat-orchestrator、delivery-chain)统一 catch 住,记录日志后继续——"Chat memory 是可降级能力,失败不影响已经完成的用户回答。"
11. 为什么没有做长期记忆和历史表
这两个版本还有一个明显特点:克制。
没有做多会话历史、ChatGPT 左侧历史列表、ChatSession / ChatMessage 业务表、历史分页、历史搜索、编辑/删除持久化、长期记忆、向量记忆、pgvector、LangGraph Store / PostgresStore、Memory Inspector、Agent 运行摘要、工具观察摘要、执行摘要。
这些东西不是没价值,只是它们属于另一个阶段的事。
当前阶段的重点是:先把当前会话里的短期记忆边界打稳。
如果一开始就把长期记忆、多会话历史、Agent 执行历史、工具记录持久化全放进来,memory 系统会迅速膨胀,反而看不清最核心的边界——什么是用户可见消息、什么是模型上下文、什么是运行时内部状态、什么可以恢复、什么可以压缩、什么绝对不能进 memory。
AI Mind 这一阶段选择先做当前聊天 ThreadState,说白了就是先打地基。只有地基清楚了,后续再考虑多会话历史、长期记忆或更复杂的 Agent Memory,才不会把不同层级的状态混在一起。
12. 总结
AI Mind 这两个版本最后形成的不是一个聊天历史表,而是一套当前会话 ThreadState 设计。它把当前会话拆成三层:recent messages(最近 4 轮原文)、summary(早期上下文摘要)、pinned decisions(关键决策单独保留)。
工具、MCP/resource、Tasklist Agent、Delivery Chain 的最终用户可见回答,也可以进入同一套纯文本记忆——但它们的执行过程不会进来。
回过头看,实际上解决的就三件事:刷新后当前会话能恢复,不是空白聊天;长对话后模型看到的是整理过的上下文,不是无限历史;工具和智能体的最终回答可以成为聊天记忆,但中间执行态留在运行时边界内,不乱跑。
AI Chat 的短期记忆,重点不是"保存更多",而是:只保存该保存的,并且让这些内容以可控方式参与下一轮上下文。
项目地址
👉 GitHub:github.com/HWYD/ai-min…
👉 线上体验:ai.hwyblog.cloud/instant-min…
如果这篇文章或者 AI Mind 项目对你有一点帮助,也欢迎顺手帮项目点个 Star⭐。你的支持确实会让我更有动力继续写后续版本的设计取舍和踩坑复盘。