本文基于
AI Mind项目的真实实现整理。GitHub:github.com/HWYD/ai-min...
对应代码版本:
v0.4.5线上链接:ai.hwyblog.cloud/instant-min...
AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。
如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。
会话1: 
会话2:
前面几个版本里,AI Mind 先把单会话内的短期记忆理清楚了------最近消息保留原文、早期上下文压缩成摘要、关键结论沉淀为固定决策(v0.4.2 / v0.4.3)。然后又解决了多个会话之间怎么把短期记忆隔开,不让博客讨论和部署排查的内容搅在一起(v0.4.4)。
到了 v0.4.5,问题往前再推了一步:
不同会话之间,能不能共享一些长期的东西?
这个需求其实很自然。我在会话 A 里说过"以后解释技术问题,先用大白话,再补充专业说法",然后在会话 B 里问一个完全不相关的新问题------如果系统还记得这个偏好,回答质量会明显不一样。
但真做起来,硬问题一个接一个就来了:
- 怎么判断哪些东西值得长期记住,而不是"我今天心情不好"这种临时状态?
- 怎么防止模型把身份证号、API key 这些东西也存进去?
- 多个会话之间怎么召回相关的记忆,又不把不相关的塞进上下文?
- 记忆存储挂了,普通聊天会不会跟着炸?
这篇文章就是我在 v0.4.5 里解决这些问题的完整复盘。核心思路只有一句话:模型负责语义理解,程序负责确定性把关。
前情提要(如果你没读过前面几篇):
- ThreadState:每个会话的短期记忆容器,存着消息列表、对话摘要和关键结论
- summary:模型自动生成的对话压缩摘要,替代被截断的早期消息
- pinnedDecisions:从对话中提取的关键结论,比如"用户决定用 React 而不是 Vue"
- per-conversation 隔离:会话 A 的短期记忆不会泄漏到会话 B(v0.4.4 的核心成果)
1. 先想清楚存在哪
长期记忆第一个问题不是"怎么提取",而是"存在哪"。
为什么不能跟短期记忆放一起
v0.4.4 已经给每个会话配了独立的 ThreadState,里面是 messages + summary + pinnedDecisions。这是单会话短期上下文的事实源,跟着 conversation 的生命周期走------会话创建时建立,会话销毁时消失。
长期记忆的生命周期完全不同。它要跨会话存活,不跟任何单个 conversation 绑定。如果硬塞进 ThreadState,会出现:
- 会话 A 存的偏好,会话 B 想用还得去翻 A 的 ThreadState------这直接破坏了 v0.4.4 好不容易打稳的 per-conversation 隔离
- ThreadState 的语义被污染------它本来只承载"当前会话的短期上下文",现在混进了跨会话的长期数据
所以我做的第一条决策就是:长期记忆用独立的 Store,不跟 ThreadState checkpoint 共用任何东西。
LangGraph Store 刚好合适
LangGraph 本身提供了两套存储体系:Checkpoint 管会话状态,BaseStore 管跨会话的持久数据。两套物理隔离,互不干扰:
makefile
Checkpoint(ThreadState):
schema: langgraph_chat_memory
存 messages / summary / pinnedDecisions
Store(UserMemory):
schema: langgraph_user_memory
存长期偏好、稳定指令、工作流偏好等
API 也很直接------put(namespace, key, value)、get(namespace, key)、search(namespace, filter)。上层不需要知道底层是 Postgres 还是内存。
生产环境用 PostgresStore,开发测试用 InMemoryStore,通过环境变量 AI_MIND_USER_MEMORY_STORE 切换。PostgresStore 实例是 process-level 单例,还加了防切换保护------如果运行时 DATABASE_URL 变了,直接抛错而不是悄悄换连接。
命名空间和降级
命名空间是 ['ai-mind', 'user-memory', 'v1', sessionHash]。sessionHash 从 browser session 派生,确保不同浏览器标签页之间的长期记忆完全隔离。这版不做账号级,不做跨设备------作用域就是当前 browser session。
还有一个很重要的设计:Store 挂了怎么办?长期记忆是补充功能,不能成为单点故障。读失败就返回 0 条记忆,写失败就静默跳过。普通聊天、流式输出、ThreadState 全都不受影响------退回到跟没开长期记忆一样。
2. 核心范式:模型提候选,程序做决定
Store 只是"存在哪",真正关键的是"提取什么"和"怎么判断能不能存"。
为什么不让模型直接写
很多系统会给模型一个 save_memory 工具,让模型自己决定什么时候存、存什么。这个方案看起来直接,但有几个隐患:
- 模型可能在 prompt 引导下绕过安全校验------毕竟它是"被要求存"的
- 模型每次升级或 prompt 微调,写入行为可能悄悄变化,没法回归测试
- 长期记忆影响未来所有对话,持久化边界必须是可审计、可复现的
所以 v0.4.5 把链路拆成两段:
css
[模型] 结构化输出 0..N 条 candidates(这只是建议)
↓
[程序] deterministic validation → stable key → dedupe → Store write(这才是决定)
模型做它擅长的------语义理解。程序做它擅长的------确定性校验和边界控制。谁也不越界。
Candidate 里放了什么
模型输出的每条 candidate 是一个结构化的 JSON,字段挺多,但每个都有明确的分工:
type:每条记忆的类型,8 种之一(模型建议)text:记忆正文,第三人称中文事实句,最长 300 字(模型生成)tags:检索锚点标签,2-4 个,不是摘要(模型生成)confidence:置信度 0-1(模型自评)stability:stable/temporary/speculative(模型显式表达,程序消费)identity:结构化身份,subject+ 可选facet+ 可选polarity(模型建议,程序归一化)action:add还是suppress(模型建议)sourceSignal:信号来源,比如explicit_memory_intent(模型标注)reason:提取原因,最长 200 字(模型自述)conflictSignal:是否在否定旧记忆(模型标注)
这里面 stability 和 identity 是最值得展开说的两个设计,它们代表了这套方案跟传统做法最大的不同。
stability:别再让正则猜"稳不稳定"了
传统做法是模型输出一段文本,程序用正则去猜这条东西是不是长期稳定的------匹配"暂时""目前""现在"这类词,命中就拒绝。
问题很明显:关键词列表永远不全;"我现在很喜欢这个"可能是临时情绪,也可能是稳定偏好的表达;正则匹配既容易误杀也容易漏过。
v0.4.5 把"稳不稳定"的判断也交给模型,但要求模型用结构化字段表达,不是用自然语言:
ini
stability = "stable" → 程序放行
stability = "temporary" → 程序直接拒绝
stability = "speculative" → 程序直接拒绝
模型在 prompt 里被明确告知:默认返回空数组,比输出 temporary 或 speculative 更好。只有在你必须显式说"这条不应入库"时,才用这两个值。
这样程序不需要维护任何"临时情绪关键词表",不需要猜语义。模型表达、程序消费,各管各的。
identity:别解析中文句子了,让模型结构化输出
stable key 是去重、更新、冲突处理的基础。如果两条 candidate 的 stable key 相同,说明它们在说同一件事,应该 update 而不是 duplicate。
传统做法是从中文句子里硬拆------"用户喜欢吃桃子" → 提取主语、谓语、宾语 → 拼成 key。这条路线的维护成本极高,中文的灵活性让规则很难覆盖全。
v0.4.5 把身份识别也交给模型的结构化输出。模型不说"这条是关于什么的",而是直接给出结构化身份:
json
// 用户说"记住我喜欢吃桃子"
{ "identity": { "subject": "桃子", "polarity": "prefer" } }
// 用户说"以后解释技术问题,先用大白话,再补充专业说法"
{ "identity": { "subject": "技术解释", "facet": "先大白话再专业" } }
// 用户说"我是一名前端工程师,主要使用 Windows 和 PowerShell"
{ "identity": { "subject": "前端工程师", "facet": "Windows PowerShell" } }
程序拿到 identity 后做 deterministic normalization------whitespace 归一化、长度截断、特殊字符处理------然后拼成 stable key:
makefile
user_preference:prefer-桃子
communication_preference:技术解释-先大白话再专业
stable_user_context:前端工程师-windows-powershell
模型理解语义,程序做确定性归一化。同样的 identity 输入永远产出同样的 stable key,可测试、可审计。
8 种记忆类型
顺便说一下这版定义的 8 种 UserMemoryType。类型不只是标签,它会影响 stable key 的生成规则、召回的评分权重、校验的检查项:
| 类型 | 含义 | 例子 |
|---|---|---|
user_preference |
长期可复用的喜欢/不喜欢 | "用户喜欢吃桃子" |
communication_preference |
回答或解释风格偏好 | "技术解释先用大白话再专业" |
workflow_preference |
做事方式或交付偏好 | "整理提示词时用中文且可直接复制" |
standing_instruction |
长期固定执行指令 | "评估需求时先判断是否 Spec 阶段" |
recurring_constraint |
长期约束或禁止项 | "不要凭空发明不存在的项目功能" |
stable_user_context |
稳定非敏感用户背景 | "前端工程师,主要用 Windows" |
project_context |
长期项目/产品上下文 | "持续围绕个人知识库产品做版本规划" |
risk_preference |
风险边界或保守偏好 | "长期记忆不要保存完整聊天历史" |
Prompt 里几个关键约束
提取 prompt 很长,但有三个约束我认为是这个系统能跑稳的关键。
事实来源优先级 :latest user text > safe pinned decisions / safe summary > assistant final text(safe 指已过滤敏感信息、不含原始对话记录的脱敏版本)。assistant final text 只能辅助理解,不得单独生成新 memory------这防止模型从自己的回答里臆造用户偏好。
默认空数组:如果内容不是 stable memory,就不要输出 candidate。宁可漏掉也不乱存。这可能是长期记忆系统最重要的安全原则。
identity.subject 必须稳定:不要把整句、原因说明、语气词或"用户/记住/以后"这类虚词塞进 subject。subject 是 stable key 的核心输入,塞进虚词去重和更新就全乱了。
3. 提取的输入:只看当前轮,不看全历史
不看完整对话历史
提取的输入严格限制在当前轮的内容:
yaml
latestUserText(截断到 2000 字)
assistantFinalText(截断到 2000 字)
safeShortTermContext:
- summary(截断到 600 字)
- pinnedDecisions(最多 5 条,每条截断到 300 字)
不扫描完整对话记录。这是安全边界,也是成本控制------每次 extraction 的 token 消耗是可预测的。
每轮都触发,不只是"记住"才触发
用户说"记住xxx"是强信号,但不是唯一触发条件。每个符合条件的已完成轮次都会触发一次 extraction------如果这一轮没有长期记忆价值,模型输出 {"candidates": []},系统什么都不写。
这个策略比"先判断有没有记忆意图再决定跑不跑"要干净。判断逻辑统一在模型的结构化输出里,不需要在代码里维护一套 pre-gate 规则。
只有 ordinary_chat 和 tool_assisted_ordinary_chat 才触发 extraction。Tasklist Agent 和 Delivery Chain 的上下文是执行态的,不适合从中提取长期记忆。
PinnedDecision Promotion 是补充路径
conversation 压缩成功后,如果 pinnedDecisions 有新增或变化,系统会把这些变化包装成 candidate,走同一套 validation/write path。具体来说:每条新增或变更的 decision 的文本作为 candidate.text,identity 由程序根据 decision 的类型推断,以固定的 confidence=0.85 提交。只评估差异(新增或变化的),不把 summary 整段写入。这是补充路径,不是主力。
输入准备好了,接下来就是写入流程------但不是模型直接写,而是先过 12 道程序关卡。
4. 写入:12 道程序关卡
写入是后台异步的,不在聊天热路径上。流程很简单:
scss
符合条件的轮次完成
→ 先做完现有的 final-turn ThreadState append
→ 再将提取任务入队(不阻塞回答)
→ extractor 调 LLM 输出 0..N candidates
→ 逐条进入 putCandidate():validation → stable key → 查重 → Store put()
整个管道是进程内尽力而为的------不做持久队列、不做 worker、不做 retry。写入失败不影响已经完成的回答。
validation 做了什么
validateUserMemoryCandidate() 跑 12 步检查,任何一步不通过就直接拒绝:
sourceConversationId必须非空且非草稿前缀------草稿没转正之前不写长期记忆- 严格模式校验------模型多输出一个字段就直接归类为
unsafe - text 非空
- text 不超过 300 字
stability = "temporary"→ 拒绝stability = "speculative"→ 拒绝confidence < 0.7→ 拒绝- 包含身份证、社保、银行卡等敏感信息 → 拒绝
- 包含 GraphState、API key、cookie 等 runtime 数据 → 拒绝
- 包含
[user]、[assistant]等对话记录模式 → 拒绝 - 无关占位文本 → 拒绝
- identity 安全检查 +
user_preference类型必须有polarity
第 2 步的严格模式防注入特别值得提:candidate schema 用了 Zod 的 .strict(),模型输出出现任何未声明字段,直接 unsafe。调试字段、模型提供方元数据或者其他意外内容都绕不过去。
去重不只是 key 相同
putCandidate() 的去重不是只看 stable key。当新 document 和已有 document 在六个维度完全一致时才算重复:stableKey + status + type + identity + text + tags。六个都相同 → rejected/duplicate。有一个不同 → 走 updated。
比如同样是 user_preference:prefer-桃子,"用户喜欢吃桃子"和"用户非常喜欢吃桃子"是 update,不是 duplicate。这样既减少了无意义的写放大,也让测试能明确区分两种场景。
用户说"不要了"怎么处理
当用户说"我现在不太喜欢桃子了,以后别按这个推荐",模型输出 action=suppress + conflictSignal=true,identity 指向旧记忆。程序把匹配的旧 memory 状态从 active 改为 suppressed,记录时间和来源会话。之后这条记忆不再参与召回。
这版不做物理删除。原因很实际:物理删除不可逆,万一判断错了数据就丢了。状态标记是可逆的------后续版本做 Memory Inspector 时,可以展示被压制的记忆并让用户手动恢复或删除。
5. 召回:不靠向量数据库
写入解决"存什么",召回解决"什么时候用"。
为什么不做嵌入向量
这版的作用域是 browser-session 级,Store 里最多几十条记忆。规则召回完全够用。引入向量检索会增加嵌入向量模型成本、pgvector 基础设施和语义漂移等复杂度------对一个基线版本来说,过度设计了。
五维评分
scoreRelevantUserMemory() 对每条活跃记忆打一个相关性分数,由五个维度组成:
| 维度 | 分值 | 说明 |
|---|---|---|
| structured tag overlap | 3 分/命中 | 模型生成的 tags 直接出现在 query 里 |
| identity overlap | 2 分/全命中,1 分/短语 | subject/facet 跟 query 匹配 |
| whole text overlap | 2 分 | 记忆文本完全包含在 query 里 |
| partial text overlap | 按类型加权 1-2 分 | CJK 2-4 字短语匹配 |
| ASCII token overlap | 1.5 分/命中 | 技术名词如 VSCode、React、PowerShell |
注:CJK 指中文/日文/韩文字符,ASCII token 指英文单词或技术名词。两者分开匹配是因为中文按字切分、英文按空格切分,匹配策略不同。
最小分数阈值是 1.5 分,不够的直接过滤。
tags 是检索锚点,不是摘要
这里有一个很重要的设计选择:不让代码维护领域关键词分类表。
传统做法是在代码里维护 food: ["吃", "水果", "桃子"...]、clothing: ["穿", "卫衣"...] 这样的分类表。问题是分类表永远不全,每加一个新领域就要加规则,维护成本线性增长。
v0.4.5 的做法是把这件事前移到了提取阶段:模型在生成 candidate 时同时输出 tags,召回只做结构化匹配。
用户说"记住我喜欢吃桃子" → 模型输出 tags ["桃子", "水果", "吃"]。下次用户问"给我推荐几种水果","水果"直接命中 tag。用户说"以后解释技术问题先用大白话" → tags ["技术解释", "大白话", "专业说明"]。下次问"解释一下 React useEffect","技术解释"命中。
tags 不是摘要,是检索锚点。模型被要求输出"短而有区分度、未来用户查询里可能直接出现的词"。代码只做匹配,不猜语义。
受控类型的特殊规则
project_context、risk_preference、stable_user_context 这三类有一个特殊规则:如果词面分数为 0(没有任何词面重叠),直接归零不参与注入。这三类的共同特点是内容比较"重"------涉及项目全局约束或风险偏好,如果仅靠模糊词面匹配就注入,容易在不相关的对话中产生误导。所以不能因为通用话术就被模糊召回。
但 stable_user_context 有个例外------允许有限的短语匹配来支持"你知道我的工作吗"这类自查询。如果 query 里出现"工作""前端工程师"这些词,可以跟 identity 做短语级重叠。
最终选择
过滤条件:status = active 且 confidence ≥ 0.7。排序:分数降序 → updatedAt 降序。硬限制:最多 3 条,每条 ≤ 300 字,总注入 ≤ 900 字。
6. 注入:补充不是权威
召回的记忆通过一条独立的 SystemMessage 注入模型上下文:
diff
以下是当前 browser session 的长期用户记忆补充上下文。
只在与当前问题相关时参考;如果与 latest user message 冲突,以 latest user message 为准。
- (user_preference) 用户喜欢吃桃子。
- (communication_preference) 用户喜欢技术解释先用大白话,再补充专业说法。
如果没有相关记忆,不构造空 SystemMessage,避免无意义的 token 消耗。
概念上下文顺序是:
sql
system / skill / output policy prompts
→ selected UserMemory(补充)
→ selected conversation summary
→ selected conversation pinnedDecisions
→ selected conversation recent messages
→ latest user message(最高优先级)
UserMemory 在 system prompt 之后、conversation context 之前。比 system prompt 更"软"------可以按相关性取舍;比 conversation context 更"远"------不参与当前对话的直接推理。
"latest user message 优先于 UserMemory"是硬约束。如果用户当前说"我今天不想吃桃子",即使记忆里写了喜欢吃桃子,也以当前输入为准。
还有一条硬边界:UserMemory 不进入 ThreadState、hydration payload(前端页面加载时恢复会话状态的数据)、Conversation Registry(活跃会话注册表)、stream-core chunk(流式协议数据块)、frontend reducer(前端状态管理器)。它是跨会话的补充上下文,不能污染单会话的短期记忆结构。
7. 草稿首条消息的坑
AI Mind 的前端允许用户在还没创建正式会话的空白输入框里直接打字------这叫"草稿"状态。此时 conversation 还没有被持久化,所以没有 conversationId。这就引出一个容易被忽略的细节:用户在空白草稿里说"记住我不吃香菜",如果直接写长期记忆,sourceConversationId 是空的------这条记忆就变成了"不知道从哪来的"。
v0.4.5 的处理是:先让路由创建已持久化会话(转正),拿到 conversationId,等 assistant final turn 完成,此时 sourceConversationId 已经就绪,再将提取任务入队。
如果首条消息失败、取消或被拒绝------不入队,不写入。不会为了写一条记忆去创建 ghost conversation。
8. 刻意没做的事
长期记忆系统很容易被一路扩展成完整用户画像平台。v0.4.5 刻意收住了:
- 不做嵌入向量 / pgvector。几十条记忆用规则召回完全够。
- 不做 Memory Inspector / 管理 UI。先让系统能正确存和取,再考虑用户怎么管理。
- 不给主 assistant 直接 memory-write 工具。写入封装在内部管道里,模型不能绕过校验。
- 不做账号级记忆。作用域就是当前 browser session。
- 不保存完整聊天历史。UserMemory 是经过提取和校验的条目,不是对话记录。
- 不修改 stream-core 和 frontend reducer。注入对前端完全透明,assistant 在普通回答文本中自然确认即可。
9. 回头看
v0.4.5 交付的不是一个完整的用户画像系统,而是一套可验证、可降级、边界清晰的长期记忆基线。
三句话概括核心思路:
sql
模型负责语义理解 → 结构化输出 candidates(含 stability + identity)
程序负责确定性把关 → 12 道校验 + stable key + 去重 + 压制
规则化召回 → 五维评分 + 结构化 tags 重叠
跟前面版本的递进关系也很清楚:
v0.4.2 / v0.4.3:单会话内短期记忆怎么压缩、整理
v0.4.4:多个会话之间短期记忆怎么隔离
v0.4.5:跨会话的长期记忆怎么提取、校验、存储和召回
每一版都在前一版的基础上往前推一步,每一版也都刻意收住了自己的边界。
如果你也在给自己的 AI 应用做长期记忆,我的建议是:
- 先分离 Store 和 ThreadState------不同生命周期的数据别混在一起
- 先做模型提取 + 程序把关------别让模型直接决定持久化
- 先用规则召回------browser-session 级的记忆量,结构化标签足够了
- 先把降级做稳------长期记忆是补充功能,不能成为单点故障
- 先不做 UI 管理------先让系统能正确存和取,再考虑交互
长期记忆的难点从来不是"怎么存",而是"怎么保证存进去的东西是对的、取出来的东西是相关的、挂了的时候不影响正常聊天"。
项目地址
👉 GitHub:github.com/HWYD/ai-min...
👉 线上体验:ai.hwyblog.cloud/instant-min...
如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。