1. 先把需求想清楚再做ai-memory到底解决什么问题1.1 大模型没有记忆这是核心痛点用了半年多各类AI助手说实话最大的感受是它们每个单轮对话都很聪明但一到跨天、跨事项的协同就原形毕露。你今天跟它确认了家里Wi-Fi密码是哪个明天换台设备再问它一脸茫然你跟它说“三号出差带护照”过两天想让它帮查个清单它连这件事本身都忘了。这就是典型的大模型没有持久记忆——每次对话都是无状态的API调用聊完即焚。ai-memory这个项目我定义得非常明确给本地AI搭一个长期记忆系统。它不是多模态模型也不是花哨的Agent框架而是一条“记忆管道”——把散落在对话、日志、配置里的信息结构化地存起来需要的时候能按语义捞回来并且时间一长还能自动淘汰没用的部分。适合谁来看这篇东西如果你自己跑着开源大模型或者用API调各种聊天机器人的时候被“记不住事”反复折磨过如果你想在本地搭一个越用越懂你的知识底座但瞟了一眼RAG检索增强生成的官方文档觉得太抽象又或者你就是个效率工具控想给自己的中枢系统加一块“记事本”模块——那这个项目的思路和代码基本可以直接抄。1.2 我要的记忆系统具备哪四种能力动手写代码之前我先花了两天把“记忆”这个词拆成了四个刚性能力缺一个我都觉得不完整能记住系统要能从人和AI的对话里抽取关键信息而不是让用户手动写标签。谁、什么时间、发生了什么事、涉及哪个对象这些字段得有。能想起语义检索要够准。你问“上次修水管的老王电话多少”它不能只靠关键词匹配“水管”“老王”得理解这是个人物图谱里的联系人。能忘记同样是“明天上午开会”三周前的那条和今天这条权重必须不同。记忆不是无限积灰要有衰减曲线。能主动记忆不只是被动查询还得能做触发器。比如“下班路过超市买牛奶”系统应该在你下午五点推送提醒里把它捞出来。其中“能主动”这一点是市面上大多数记忆方案都不做的。很多RAG项目能回答“我什么时候说过什么”但不会主动把相关记忆喂给后续任务。我把这四条立成需求清单后后面所有设计决策都有了判断依据——凡是偏离这四条的方案不管技术多花哨砍。1.3 为什么不用现成的向量库直接开干坦白讲一开始我想得挺简单把每句话切块扔给Embedding模型塞进向量数据库完事。但做了一周原型发现这条路走不通原因有三第一裸向量没有结构性。你知道“老王”是一个人名但向量只告诉你“老王”和“维修工”语义相近。没有实体链接、没有关系边、没有时间属性所谓记忆就是一堆漂浮的浮点数组翻旧账的时候根本拼不出完整事件。第二纯向量召回的时间维度是废的。向量库擅长做相似度排序但“三天前的指令”和“去年同一天的指令”在向量空间里可能长得一模一样。记忆系统没有时间衰减就会把旧的、过期的重要信息和新信息混在一起反而污染上下文。第三本地部署的资源账要算。我手上跑的是7B量级的本地模型机器内存本来就紧张再塞一个ES或者Milvus这种重服务为了记住几件小事背个大数据库不值当。于是我把方案改成了SQLite做结构化底座 轻量向量索引做语义召回的混搭架构。听起来朴素但实测在家庭服务器和普通笔记本上都能跑得很轻。这是我认为ai-memory整个项目最值得分享的决策过程下面展开讲。2. 整体架构与技术选型给“记忆”找个合适的数据结构2.1 数据模型设计用快照和条款管理记忆记忆系统的数据模型很多人一上来就画ER图搞一堆表关联。我的做法更简单粗暴全部记忆只分成两种形态快照Snapshot一条完整的陈述比如“用户2024年5月20日请老王上门修了厨房水管费用300元”。快照是叙事单元保留完整上下文。条款Fact从快照里抽出来的原子事实比如“老王是水管维修工”“小王家的厨房水管在5月20日修过”“维修费用300元”。条款是检索单元负责被语义召回。为什么这么分因为存储和检索的需求是矛盾的存储要完整否则记了等于没记检索要精准否则捞出一大段废话。快照负责“存得全”条款负责“查得准”中间通过一条外键关联。我现在可以告诉你这个设计的实际好处当一条记忆需要更新时只需要把对应的快照作废拆出新条款旧条款的关联历史也一并保留下来完全不会出现“改一处丢一串”的尴尬。具体到表结构我用三张表就装下了表名关键字段作用snapshotsid, content, created_at, expires_at, status存原始记忆文本带过期时间factsid, snapshot_id, entity, predicate, object, importance, last_accessed_at存原子事实用三元组表达triggersid, keyword, memory_id, action, active_window存主动提醒的触发规则你不是在做企业级数据仓库别把表拆得跟雪花模型一样。三张表配合正确的索引足够支撑一个家庭或小型工作室的用量。2.2 为什么选SQLite与SQLite-VSS先解释一个可能有人会问的问题既然要语义检索为什么不上pgvector原因是我这套系统需要跟随主程序单文件分发迁移、备份都要简单。SQLite解决了“存储零运维”的问题整个数据库就是一个文件拷走就是备份复制到新机器就能跑。向量索引部分我在SQLite-VSS和单独跑一个轻量向量服务之间犹豫了一周。最后选了SQLite-VSS理由就一条它把向量索引作为虚拟表挂在SQLite里这样“标量过滤 向量召回”可以写进同一条SQL。比如“查水管维修相关记忆且只查今年的”一条带WHERE语句的查询直接完成不需要先向量召回再内存里过滤性能损失小得多。你可能会担心SQLite-VSS的社区活跃度。这个担心我也有过但实测下来基础功能稳定对中文Embedding的支持主要看上游模型跟它本身无关。对于记忆量在十万条以内的场景它的排序质量和专门向量库没有肉眼可见的差距。记住一个指标个人AI记忆系统的数据量撑死了十万级别为千万级的数据规模提前搞一堆分布式组件那是给自己的机器上刑。2.3 分层存储短期、长期、重要记忆只有一个记忆池是不够的。用户跟AI聊天的内容里有“今天早餐吃了包子”这样的流水账也有“奶奶对花生过敏”这种一辈子都不能忘的硬信息。不分层级混在一个池子里语义召回时就会被无关信息淹没。我设计了一个三层漏斗工作记忆层最近7天产生的事实召回时优先默认给最高权重。长期记忆层超过30天且被多次访问或关联的事实降权但保留。核心事实层带importancehigh标记的关键信息如过敏史、家人姓名、重要日程除非用户显式删除否则永不衰减。分层不只是逻辑概念落到代码上其实就是给facts表加一列memory_tier写入时根据规则打标召回时按层加权。别把架构想太玄分层存储的本质是给每条事实算一笔账它到底值不值得长期占用存储和计算资源。而这个记账规则我会在下一节的衰减算法里详说。3. 核心实现能记住、能想起、能忘记3.1 记忆写入事件驱动的Commander模式先看写入怎么设计。每天系统会跑着好几个任务有跟模型API的对话任务有后台定时任务。这些渠道产生的信息我不希望它们直接操作SQLite表——那会导致写入逻辑四处开花改个表结构就爆炸。我借鉴了CQRS里写命令的思路定义了一个MemoryCommander入口所有的写入都走它class MemoryCommander: def handle(self, event: MemoryEvent): # 事件校验 if not self.validate(event): raise InvalidEvent(event) # 保存快照 snapshot_id self.save_snapshot(event.content, event.meta) # 拆解事实条款 facts self.extract_facts(event.content) self.save_facts(snapshot_id, facts) # 触发后续动作 self.dispatch_trigger(event.meta.get(trigger_rules))这套设计解决的最重要问题是“谁负责拆解事实”。我看到不少DIY项目把事实抽取扔给LLM每次写都调一次大模型慢且贵。我的做法是两层抽取规则层用正则和命名实体识别抽时间、人名、地名、数值。比如“明天下午3点开周会”“周会”是一个固定短语“明天下午3点”可以直接转成时间戳。模型层只有当规则层抽出的置信度过低时才调用大模型做一次三元组抽取。模型反而成了一个兜底校准器。在实测里规则层能覆盖大约七成常见记忆剩下三成调用模型整体写入成本降了一大半。如果规则层设计得像样一点比如写清楚各类日期格式、号码格式的正则这个比例还可以提高。3.2 记忆检索RAG召回的两级排序调用记忆的过程就是我最常说的“把数据库当成一个会联想的搜索框”。检索入口是一个MemoryRetriever它对用户的输入做三步处理第一步向量召回。当前问题转成向量跟所有未过期的facts向量算余弦相似度取Top 50。这一步不是最终结果只是候选池。第二步相关性重排。把候选事实拼成一个列表由本地模型重排打分筛掉语义相似但信息冗余的项留下Top 10。这一步我实测非常关键因为向量相似度高不代表有用很多闲聊词汇的向量会跟正经事实混在一起。第三步上下文组装。把选出的Top 10事实按时间顺序排好并标注每条的来源时点拼成一个结构化的“记忆卡”{ facts: [ {text: 老王是水管维修工, time: 2024-05-20, source: 对话快照#1024}, {text: 厨房水管维修完成费用300元, time: 2024-05-20, source: 对话快照#1024} ] }这类记忆卡在组装完以后才会作为系统提示词的一部分送进大模型的上下文窗口。这样模型看到的不再是“一堆散落的候选片段”而是“一个按时间线梳理好的事实清单”。两级排序最大的收益是让大模型回复里几乎不出现自相矛盾的陈述——比如它不会一边说“老王家在朝阳区”一边又说“老王家在上海”。3.3 记忆衰减与遗忘给记忆加时间戳记忆不衰减堆到一定量级查询会越来越慢召回会越来越糊。我做了一个模拟人脑遗忘曲线的算法本质上是给每条事实定期算“新鲜度”。公式我简化成score importance * pow(0.95, days_since_last_access)。也就是说重要性从1到5打分每超过一天分数乘以0.9530天后大约剩0.21。当分数低于0.1时该事实自动降级到归档层不再参与常规检索。衰减是靠定时任务跑的我设成每6小时执行一次。跑的时候不只是算分还会检查expires_at字段——像“明天下午三点的会议”这种临时事实过期后直接标记失效不会出现在任何召回结果里。# 衰减任务伪代码 def decay_memories(): for fact in db.fetch_all_facts(): age (now - fact.last_accessed_at).days fact.score fact.importance * (0.95 ** age) if fact.score 0.1: fact.memory_tier archive if fact.expires_at and fact.expires_at now: fact.status expired这里有个我踩过的坑衰减任务不应该对所有事实一视同仁。你想想如果用户昨天刚确认过“老婆生日是3月14日”这条事实的last_accessed_at被刷新了那衰减分数会拉高——这是对的。但如果你因为某条事实被检索了两次就让它“永生”那就会导致那些老被问到的琐碎信息永远排在前面。所以我额外加了限制只有在“用户主动确认”场景里的访问才能刷新last_accessed_at单纯的检索命中不算。这个细节你们做的时候一定要写清楚否则衰减机制会变成刷分机制。3.4 主动提醒让系统在关键节点把记忆送回来“被动检索”只是记忆的第一步ai-memory真正让记忆发挥价值的是主动触发。我在系统里跑了一个轻量轮询任务扫描triggers表里的规则看看当前时间、当前场景是否命中了某条记忆。“下班路过超市买牛奶”怎么变成主动提醒核心是拆成两个半句前半句“下班”是时间或行为触发点后半句“买牛奶”是动作内容。系统每天做一个扫描如果发现这个触发点在当天有效就把对应的提醒写到待办区等用户在下午五点打开主界面时直接弹出来。# 提醒触发逻辑 def scan_triggers(now): reminders [] for t in db.fetch_active_triggers(): if t.is_triggered(now): # 判断时间窗口 reminders.append(t.to_reminder()) t.last_triggered_at now # 防止重复触发 return reminders还有一类更聪明的主动方式基于关联的主动推送。假设系统记忆里存了“孩子周六要参加钢琴考级”而这个周六就快到了那么只要用户问了任何跟“周末安排”相关的内容系统都会自动把考级记忆插入上下文。这个其实是上面的两级排序在起作用但需要我在MemoryRetriever里额外加了一个“热点扫描器”扫描临近时间窗口的关键日期实体。我强烈建议你把这部分做出来哪怕只做最简单的时间窗口触发。因为用过你就知道了——AI能主动想起来事儿跟你问一句它答一句体验完全是两个物种。4. 实操中踩过的坑与排查清单4.1 向量召回不准embedding模型的选择血泪史ai-memory的语义检索质量很大程度上取决于Embedding模型而非数据库。我在最早版本里用了一个英文社区颇受欢迎的轻量模型结果对中文短句的召回准确率惨不忍睹——“维修水管”跟“管道疏通”在向量空间里距离非常远。后来我换成了一组中文本地化调优过的Embedding模型情况才有改善。具体选型我不报型号因为模型更新太快但给你一个思路在本地跑一组中文语义测试集自己造50个“问法不同、指向同一事实”的问题跑一遍Recall5。让人工一个一个看结果比什么都准。我那一轮测完淘汰了两个曾经全网吹得很厉害的模型。还有一点要提醒如果你跑本地模型Embedding的推理速度反而比大模型更影响体验。因为每次检索要把输入句子转成向量如果这个动作要卡3秒钟整个系统就废了。我最后是把Embedding模型量化了一层才压到可接受的速度。4.2 数据量上来之后索引管理与性能暴跌系统跑了一个多月后事实表从几百条涨到几万条我开始明显感觉到查询变慢。排查后发现问题出在两处第一SQLite的全文索引和向量索引是两回事而我一开始建普通B-Tree索引时漏掉了facts.entity和facts.predicate这两个高频过滤字段。导致关联查询要全表扫描。第二SQLite-VSS的向量索引索引文件会膨胀当频繁插入和标记过期后底层文件碎片化严重。解决办法是定期执行向量表的REBUILD我现在的策略是每周日凌晨自动跑一次。这个系统跑久了你会意识到记忆系统的运维本质上跟数据库运维一样只是体量小。但是“小”不等于“不需要维护”千万别到爆了才想起来优化。4.3 会话上下文冲突重复写入与覆盖的坑最让我头疼的Bug来自一个看似逻辑正确的操作用户跟AI说“把明天的会议改到后天”系统识别到这是一个更新操作把旧的事实标记过期写入新事实。但如果用户连续说了三次改期系统就会产生三条互相矛盾的历史事实同时过期状态又不彻底导致检索时把三个时间的会议全捞出来了。我的解决方案是给每个实体对加入“互斥链”概念当某一组(subject, predicate, object)发生替换时新的写入事件会携带一个supersede_id指向被替代的旧事实ID。检索时凡是存在supersede_id链条的只保留链尾的最新事实。def resolve_supersede(facts): latest {} for f in sorted(facts, keycreated_at): latest[f.subject_predicate] f # 后写入的覆盖先写入的 return [f for f in latest.values() if f.status active]这类问题排查难度很高因为它不是崩溃性Bug而是“看起来没什么问题但就是隐隐觉得不对”。如果你也发现AI偶尔会说出与最近日程矛盾的答案多半就是这种覆盖逻辑不完善导致的。4.4 隐私与安全本地优先是底线说了这么多技术细节最想强调的一件事ai-memory这类系统的数据敏感性远超一般应用。你存进去的是家庭关系、健康信息、工作日程、财务条款这些东西一旦出问题不是丢个密码那么简单。我的处理原则是“本地优先、网络隔离、加密备份”三件事所有记忆数据默认完全保存在本机任何组件不通过公网API回传数据。即便要用大模型增强抽取也优先选择本地模型而不是把所有事实发给云端。备份时对快照内容做字段级加密而不是整个文件加密。这样即使备份文件泄露了单个事实类字段仍是乱码攻击者无法拼出完整事件。给facts表打了行级权限标签比如allow_list字段指定某个记忆块只允许在特定设备上被读取。我之前在家里的电视端连着同一个数据库结果电视上把家里的所有对话记录都给检索出来了当场吓出一身冷汗。再补一句别把记忆系统做成“永久在线”的服务。我建议在没有主动请求时让检索进程休眠用系统级触发器唤醒而不是常驻轮询。这样既能省电又能降低攻击面。5. 写在后面这套记忆还能怎么延伸如果你把我这套东西跑通了下一步最自然的演进方向是“多设备记忆同步”。我现在已经在尝试把SQLite的变更流导出成操作日志同步到家里的另一台机器上。以后在书房问过AI的事到客厅再问一遍它同样记得。还有一条路线是把这套记忆系统接进语音助手或者智能家居中枢让它成为“家庭大脑”的公共底座。冰箱里的牛奶快没了、下周要交水电费、最近在追的剧更新了——这些本来零散的碎片现在都有了统一的去处。按我的实际体验ai-memory这个名字取得挺贴切它让AI第一次在你面前显得有“连贯人格”。不是那种每次打招呼都像陌生人的机械应答而是真正能说出“我记得上周你说过你更喜欢浅烘焙的豆子”的那种鲜活感。如果你也被“AI没记性”折磨过照着上面的思路拆一个自己的版本一个月后回来你会感谢自己。