
本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.4.5线上体验https://ai.hwyblog.cloud/instant-mindAI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。AI 会话1AI 会话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 而不是 Vueper-conversation 隔离会话 A 的短期记忆不会泄漏到会话 Bv0.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管跨会话的持久数据。两套物理隔离互不干扰CheckpointThreadState: schema: langgraph_chat_memory 存 messages / summary / pinnedDecisions StoreUserMemory: 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是否在否定旧记忆模型标注这里面stability和identity是最值得展开说的两个设计它们代表了这套方案跟传统做法最大的不同。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 keyuser_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 textsafe 指已过滤敏感信息、不含原始对话记录的脱敏版本。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_chat和tool_assisted_ordinary_chat才触发 extraction。Tasklist Agent 和 Delivery Chain 的上下文是执行态的不适合从中提取长期记忆。PinnedDecision Promotion 是补充路径conversation 压缩成功后如果pinnedDecisions有新增或变化系统会把这些变化包装成 candidate走同一套 validation/write path。具体来说每条新增或变更的 decision 的文本作为candidate.textidentity 由程序根据 decision 的类型推断以固定的confidence0.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 步检查任何一步不通过就直接拒绝sourceConversationId必须非空且非草稿前缀——草稿没转正之前不写长期记忆严格模式校验——模型多输出一个字段就直接归类为unsafetext 非空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。这样既减少了无意义的写放大也让测试能明确区分两种场景。用户说不要了怎么处理当用户说我现在不太喜欢桃子了以后别按这个推荐模型输出actionsuppressconflictSignaltrueidentity 指向旧记忆。程序把匹配的旧 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_context、risk_preference、stable_user_context这三类有一个特殊规则如果词面分数为 0没有任何词面重叠直接归零不参与注入。这三类的共同特点是内容比较重——涉及项目全局约束或风险偏好如果仅靠模糊词面匹配就注入容易在不相关的对话中产生误导。所以不能因为通用话术就被模糊召回。但stable_user_context有个例外——允许有限的短语匹配来支持你知道我的工作吗这类自查询。如果 query 里出现工作前端工程师这些词可以跟 identity 做短语级重叠。最终选择过滤条件status active且confidence ≥ 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 应用做长期记忆我的建议是先分离 Store 和 ThreadState——不同生命周期的数据别混在一起先做模型提取 程序把关——别让模型直接决定持久化先用规则召回——browser-session 级的记忆量结构化标签足够了先把降级做稳——长期记忆是补充功能不能成为单点故障先不做 UI 管理——先让系统能正确存和取再考虑交互长期记忆的难点从来不是怎么存而是怎么保证存进去的东西是对的、取出来的东西是相关的、挂了的时候不影响正常聊天。项目地址 GitHubhttps://github.com/HWYD/ai-mind 线上体验https://ai.hwyblog.cloud/instant-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。