给 AI Chat 加上长期记忆:模型提取 + 程序把关 + 规则召回

0 阅读19分钟

本文基于 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: 长期记忆写入.png

会话2:

长期记忆读取.png 前面几个版本里,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 管跨会话的持久数据。两套物理隔离,互不干扰:

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 把链路拆成两段:

[模型] 结构化输出 0..N 条 candidates(这只是建议)
         ↓
[程序] deterministic validation → stable key → dedupe → Store write(这才是决定)

模型做它擅长的——语义理解。程序做它擅长的——确定性校验和边界控制。谁也不越界。

Candidate 里放了什么

模型输出的每条 candidate 是一个结构化的 JSON,字段挺多,但每个都有明确的分工:

  • type:每条记忆的类型,8 种之一(模型建议)
  • text:记忆正文,第三人称中文事实句,最长 300 字(模型生成)
  • tags:检索锚点标签,2-4 个,不是摘要(模型生成)
  • confidence:置信度 0-1(模型自评)
  • stabilitystable / temporary / speculative(模型显式表达,程序消费)
  • identity:结构化身份,subject + 可选 facet + 可选 polarity(模型建议,程序归一化)
  • actionadd 还是 suppress(模型建议)
  • sourceSignal:信号来源,比如 explicit_memory_intent(模型标注)
  • reason:提取原因,最长 200 字(模型自述)
  • conflictSignal:是否在否定旧记忆(模型标注)

这里面 stabilityidentity 是最值得展开说的两个设计,它们代表了这套方案跟传统做法最大的不同。

stability:别再让正则猜"稳不稳定"了

传统做法是模型输出一段文本,程序用正则去猜这条东西是不是长期稳定的——匹配"暂时""目前""现在"这类词,命中就拒绝。

问题很明显:关键词列表永远不全;"我现在很喜欢这个"可能是临时情绪,也可能是稳定偏好的表达;正则匹配既容易误杀也容易漏过。

v0.4.5 把"稳不稳定"的判断也交给模型,但要求模型用结构化字段表达,不是用自然语言:

stability = "stable"      → 程序放行
stability = "temporary"   → 程序直接拒绝
stability = "speculative" → 程序直接拒绝

模型在 prompt 里被明确告知:默认返回空数组,比输出 temporary 或 speculative 更好。只有在你必须显式说"这条不应入库"时,才用这两个值。

这样程序不需要维护任何"临时情绪关键词表",不需要猜语义。模型表达、程序消费,各管各的。

identity:别解析中文句子了,让模型结构化输出

stable key 是去重、更新、冲突处理的基础。如果两条 candidate 的 stable key 相同,说明它们在说同一件事,应该 update 而不是 duplicate。

传统做法是从中文句子里硬拆——"用户喜欢吃桃子" → 提取主语、谓语、宾语 → 拼成 key。这条路线的维护成本极高,中文的灵活性让规则很难覆盖全。

v0.4.5 把身份识别也交给模型的结构化输出。模型不说"这条是关于什么的",而是直接给出结构化身份:

// 用户说"记住我喜欢吃桃子"
{ "identity": { "subject": "桃子", "polarity": "prefer" } }

// 用户说"以后解释技术问题,先用大白话,再补充专业说法"
{ "identity": { "subject": "技术解释", "facet": "先大白话再专业" } }

// 用户说"我是一名前端工程师,主要使用 Windows 和 PowerShell"
{ "identity": { "subject": "前端工程师", "facet": "Windows PowerShell" } }

程序拿到 identity 后做 deterministic normalization——whitespace 归一化、长度截断、特殊字符处理——然后拼成 stable key:

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. 提取的输入:只看当前轮,不看全历史

不看完整对话历史

提取的输入严格限制在当前轮的内容:

latestUserText(截断到 2000 字)
assistantFinalText(截断到 2000 字)
safeShortTermContext:
  - summary(截断到 600 字)
  - pinnedDecisions(最多 5 条,每条截断到 300 字)

不扫描完整对话记录。这是安全边界,也是成本控制——每次 extraction 的 token 消耗是可预测的。

每轮都触发,不只是"记住"才触发

用户说"记住xxx"是强信号,但不是唯一触发条件。每个符合条件的已完成轮次都会触发一次 extraction——如果这一轮没有长期记忆价值,模型输出 {"candidates": []},系统什么都不写。

这个策略比"先判断有没有记忆意图再决定跑不跑"要干净。判断逻辑统一在模型的结构化输出里,不需要在代码里维护一套 pre-gate 规则。

只有 ordinary_chattool_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 道程序关卡

写入是后台异步的,不在聊天热路径上。流程很简单:

符合条件的轮次完成
  → 先做完现有的 final-turn ThreadState append
  → 再将提取任务入队(不阻塞回答)
  → extractor 调 LLM 输出 0..N candidates
  → 逐条进入 putCandidate():validation → stable key → 查重 → Store put()

整个管道是进程内尽力而为的——不做持久队列、不做 worker、不做 retry。写入失败不影响已经完成的回答。

validation 做了什么

validateUserMemoryCandidate() 跑 12 步检查,任何一步不通过就直接拒绝:

  1. sourceConversationId 必须非空且非草稿前缀——草稿没转正之前不写长期记忆
  2. 严格模式校验——模型多输出一个字段就直接归类为 unsafe
  3. text 非空
  4. text 不超过 300 字
  5. stability = "temporary" → 拒绝
  6. stability = "speculative" → 拒绝
  7. confidence < 0.7 → 拒绝
  8. 包含身份证、社保、银行卡等敏感信息 → 拒绝
  9. 包含 GraphState、API key、cookie 等 runtime 数据 → 拒绝
  10. 包含 [user][assistant] 等对话记录模式 → 拒绝
  11. 无关占位文本 → 拒绝
  12. 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 overlap3 分/命中模型生成的 tags 直接出现在 query 里
identity overlap2 分/全命中,1 分/短语subject/facet 跟 query 匹配
whole text overlap2 分记忆文本完全包含在 query 里
partial text overlap按类型加权 1-2 分CJK 2-4 字短语匹配
ASCII token overlap1.5 分/命中技术名词如 VSCode、React、PowerShell

注:CJK 指中文/日文/韩文字符,ASCII token 指英文单词或技术名词。两者分开匹配是因为中文按字切分、英文按空格切分,匹配策略不同。

最小分数阈值是 1.5 分,不够的直接过滤。

tags 是检索锚点,不是摘要

这里有一个很重要的设计选择:不让代码维护领域关键词分类表

传统做法是在代码里维护 food: ["吃", "水果", "桃子"...]clothing: ["穿", "卫衣"...] 这样的分类表。问题是分类表永远不全,每加一个新领域就要加规则,维护成本线性增长。

v0.4.5 的做法是把这件事前移到了提取阶段:模型在生成 candidate 时同时输出 tags,召回只做结构化匹配。

用户说"记住我喜欢吃桃子" → 模型输出 tags ["桃子", "水果", "吃"]。下次用户问"给我推荐几种水果","水果"直接命中 tag。用户说"以后解释技术问题先用大白话" → tags ["技术解释", "大白话", "专业说明"]。下次问"解释一下 React useEffect","技术解释"命中。

tags 不是摘要,是检索锚点。模型被要求输出"短而有区分度、未来用户查询里可能直接出现的词"。代码只做匹配,不猜语义。

受控类型的特殊规则

project_contextrisk_preferencestable_user_context 这三类有一个特殊规则:如果词面分数为 0(没有任何词面重叠),直接归零不参与注入。这三类的共同特点是内容比较"重"——涉及项目全局约束或风险偏好,如果仅靠模糊词面匹配就注入,容易在不相关的对话中产生误导。所以不能因为通用话术就被模糊召回。

stable_user_context 有个例外——允许有限的短语匹配来支持"你知道我的工作吗"这类自查询。如果 query 里出现"工作""前端工程师"这些词,可以跟 identity 做短语级重叠。

最终选择

过滤条件:status = activeconfidence ≥ 0.7。排序:分数降序 → updatedAt 降序。硬限制:最多 3 条,每条 ≤ 300 字,总注入 ≤ 900 字。


6. 注入:补充不是权威

召回的记忆通过一条独立的 SystemMessage 注入模型上下文:

以下是当前 browser session 的长期用户记忆补充上下文。
只在与当前问题相关时参考;如果与 latest user message 冲突,以 latest user message 为准。

- (user_preference) 用户喜欢吃桃子。
- (communication_preference) 用户喜欢技术解释先用大白话,再补充专业说法。

如果没有相关记忆,不构造空 SystemMessage,避免无意义的 token 消耗。

概念上下文顺序是:

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 交付的不是一个完整的用户画像系统,而是一套可验证、可降级、边界清晰的长期记忆基线。

三句话概括核心思路:

模型负责语义理解 → 结构化输出 candidates(含 stability + identity)
程序负责确定性把关 → 12 道校验 + stable key + 去重 + 压制
规则化召回 → 五维评分 + 结构化 tags 重叠

跟前面版本的递进关系也很清楚:

v0.4.2 / v0.4.3:单会话内短期记忆怎么压缩、整理
v0.4.4:多个会话之间短期记忆怎么隔离
v0.4.5:跨会话的长期记忆怎么提取、校验、存储和召回

每一版都在前一版的基础上往前推一步,每一版也都刻意收住了自己的边界。

如果你也在给自己的 AI 应用做长期记忆,我的建议是:

  1. 先分离 Store 和 ThreadState——不同生命周期的数据别混在一起
  2. 先做模型提取 + 程序把关——别让模型直接决定持久化
  3. 先用规则召回——browser-session 级的记忆量,结构化标签足够了
  4. 先把降级做稳——长期记忆是补充功能,不能成为单点故障
  5. 先不做 UI 管理——先让系统能正确存和取,再考虑交互

长期记忆的难点从来不是"怎么存",而是"怎么保证存进去的东西是对的、取出来的东西是相关的、挂了的时候不影响正常聊天"。


项目地址

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

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

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