当前位置: 首页 > news >正文

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

本文为作者原创,首发于掘金,现同步发布到 CSDN。
内容整理自AI Mind项目的真实开发过程。
GitHub:https://github.com/HWYD/ai-mind
对应代码版本:v0.4.5
线上体验:https://ai.hwyblog.cloud/instant-mind

AI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目,项目从本地大模型聊天起步,逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。

如果这篇文章或 AI Mind 项目对你有所帮助,也欢迎到 GitHub 给项目点个 Star⭐,这会是对我继续整理后续版本复盘很大的鼓励。

AI 会话1:

AI 会话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管跨会话的持久数据。两套物理隔离,互不干扰:

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:https://github.com/HWYD/ai-mind
👉 线上体验:https://ai.hwyblog.cloud/instant-mind

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。

http://www.jsqmd.com/news/1216663/

相关文章:

  • Minecraft RPG服务器新手入门:从客户端配置到躺平变强攻略
  • 2026年7月最新广州番禺区市桥街道亨得利官方名表服务中心电话公示 - 亨得利官方
  • Android应用签名机制详解与安全实践
  • 劳力士中国官方售后服务中心|全部网点地址及电话权威信息通知(2026年7月更新) - 劳力士服务中心
  • OpenClaw技能生态与十大必备Skills安装指南
  • Vue计算属性详解:原理、用法与性能优化
  • 股票复盘工具推荐:把资料、财报和记录整理成可复用的研究流程
  • 嵌入式系统开发实战:SYSC与WUGEN寄存器配置详解
  • 空中熊猫官方网站www.kzxm.cn官方电话15108405949空中熊猫无人机 - 资讯焦点
  • 老龄化社会挑战与银发经济机遇分析
  • iOS集成支付宝支付全流程解析与最佳实践
  • 构建实时闭环CNC系统:工业传感器+边缘AI+PLC硬连接
  • 【YOLO26多模态涨点改进】ECCV 2026 | 特征融合改进篇 | 引入SFS-FF分流频率-空间特征融合模块,实现了基于结构信息的频域-空间信息聚合,助力多模态融合目标检测任务,有效涨点
  • 安防摄像头的数字孪生
  • 反思日志:结构化思考与认知复利的实践指南
  • AWS云安全实战:六年生产环境验证的六大核心控制点
  • 电容器动态分析:电压不变与电量不变模型全解析
  • 单窗口单IP搭建适配:独享云代理IP服务全维度解析 - 互联网科技品牌测评
  • 2026年北京地区优质设备回收服务商综合实力盘点 - 品牌鉴赏官2026
  • 嵌入式深度精讲|STM32引脚全方位底层原理、配置逻辑、硬件规范、高频面试题
  • 观鸟活动中的英语学习:词汇构建与实战技巧
  • 宝玑中国官方售后服务中心|服务热线及门店详细地址权威信息通告(2026年7月更新) - 亨得利钟表维修中心
  • Boost智能指针深度解析:从RAII原理到C++内存管理实战
  • Android回调事件处理机制详解与实战
  • Steam创意工坊跨平台下载解决方案:WorkshopDL使用深度指南
  • C++笔记之IO相关概念详解
  • 鸿蒙 ArkTS 实战:Route Maze 从路线迷宫到交互闭环完整解析
  • Amazon Transcribe企业级语音理解实战:从方言识别到业务数据生成
  • 互联网产品运营实习经验分享与成长总结
  • Hibernate与NHibernate ORM框架对比与实战指南