用 SQLite + float32 blobs + cosine similarity 替代向量数据库,实现语义+情节双层记忆。完全自主可控,含记忆合并和 LLM bug 排查经验。
TL;DR:我为一个自托管 AI Agent 添加了长期记忆。记忆保存在用户自己的服务器上——不用云服务,也不用向量数据库。Embedding 以 float32 blob 的形式存储在 SQLite 中,并使用普通的 numpy 余弦相似度进行比较。存储记忆很容易,真正困难的是整合记忆(add / update / delete);而两个 LLM bug 给了我最深刻的教训。
大多数「AI 记忆」教程,第一步都是启动一个向量数据库,然后把你的数据发送到别人的云端。这两件事我都做不了。
我参与开发的 AI Agent 是 Autafy 的 AIDA。它采用自托管方式——运行在用户自己的服务器上,并使用用户自己的 API key。它存在的意义,就是确保你的数据始终掌握在自己手中。因此,当我加入记忆功能时,记忆也必须保存在那里:放在你自己的机器上,存进一个你可以打开、备份或删除的文件中。仅仅这一项约束,就决定了整个设计。
大多数记忆功能只会存储事实——比如「偏好简洁的回答」「从事房地产行业」。这些信息很有用,但过于扁平。我希望实现两个层次:
语义记忆(Semantic memory)——持久的事实:你的偏好、项目,以及与你合作的人。
情景记忆(Episodic memory)——值得记住的时刻:比如「拿下第一位客户时非常兴奋」。
正是情景记忆这一层,让助手给人的感觉是它真正了解你,而不只是保存了一张个人资料卡。这两类记忆归根结底都只是 SQLite 中的记录。
在单个用户的数据规模下,向量数据库有些杀鸡用牛刀。一个人通常只会积累几百到几千条记忆,而不是几百万条。因此,Embedding 会以原始 float32 blob 的形式,与记忆文本一起存储;检索则直接使用 numpy 暴力计算余弦相似度:
python import numpy as np
memories store their embedding as a float32 blob in SQLite
def cosine_search(query_vec, rows, top_k=3): q = query_vec / np.linalg.norm(query_vec) scored = [] for mem_id, blob in rows: v = np.frombuffer(blob, dtype=np.float32) scored.append((mem_id, float(np.dot(q, v / np.linalg.norm(v))))) scored.sort(key=lambda x: x[1], reverse=True) return scored[:top_k]
对于几千条记录,这段代码的运行时间远低于一毫秒。跳过向量数据库,还能直接获得以下好处:
不需要同步第二套存储——一个 SQLite 文件就是全部记忆。
隔离性——记忆的 Embedding 永远不会接触文档/RAG 存储;它们使用不同的文件,也走不同的代码路径。
真正意义上的所有权——全部记忆都放在一个由用户控制、可以随身迁移的文件中,而不是存放在某个不属于用户的数据库里。
任何人都会追加记录。真正的问题会出现在第十次对话中——当你开始推翻自己之前说过的话。
「我最喜欢的颜色是绿色。」→ 存下来。
之后又说:「其实我讨厌绿色,蓝色才是我的颜色。」→ 此时应该重写原来的记录,而不是再添加一条。
「我已经不吃素了。」→ 此时应该删除原来的事实,而不是存下一条奇怪的否定信息。
因此,一个夜间任务(通过 cron 定期扫描,绝不放在聊天的热路径上)会检查当天的每段对话,提取候选事实,然后让 LLM 结合已有记忆作出判断:
给定一个 NEW 事实,以及最多 3 条 EXISTING 记忆,选择以下 ONE 项:
ADD → 确实是新信息
UPDATE → 主题相同,但信息发生了变化;重写对应记忆
DELETE → 新事实与旧事实矛盾,而且没有可替代的新信息需要保留
NOOP → 重复信息,或者不值得保留
每一次决策都会被记录下来,包括具体的 op,以及它与最近几条现有记忆之间的距离。这样以后就可以根据真实数据调整阈值,而不是一开始全靠猜。
整合操作一直默认执行 ADD——矛盾的记忆从未被删除,需要更新的记忆也从未被更新。
原因在于:我使用了一个便宜的推理模型,同时把 max_tokens 上限设得很低。推理模型会先消耗 token 进行思考,然后才给出答案。结果,token 预算全被推理过程耗尽,响应在输出内容之前就被截断,最终什么也没有返回。随后 JSON 解析失败,fallback 又悄悄选择了 ADD。
因为整个过程没有抛出错误,这种状态持续运行了三轮扫描。
教训是:给推理模型留出足够空间;调试时记录完整的 API response body;永远不要让 fallback 掩盖真正的失败。
提取器开始把助手自己的回复当成新事实——参考记忆生成的回答,又被包装成新的「事实」写回记忆,甚至连用户提出的问题也会变成虚假事实。
解决办法是在提取 prompt 中加入一条至关重要的规则:只有用户陈述的内容才能作为事实来源。助手的对话轮次永远不能视为事实。
由于系统运行在用户自己的服务器上,所以它的卖点不是「请相信我们会妥善处理你的数据」,而是可审计性。
因此,所有控制能力都会随功能一并提供:记忆功能默认关闭;每条记忆都可见、可编辑;每条记忆都会展示其来源;每段对话都可以开启无痕模式;此外,还有一个「忘掉一切」按钮。
没有控制权的记忆,不过是监控而已。
对于人类日常使用规模下的单用户记忆,SQLite blob + numpy cosine 在简洁性和可移植性方面胜过向量数据库。
存储记忆很简单;整合记忆(add / update / delete / noop)才是真正的工作。
警惕静默 fallback——一次解析失败,如果默认执行某个看起来无害的操作,就可能让一条已经损坏的 pipeline 隐藏数天而不被发现。
如果你的提取器会读取整段对话,请务必确保它不会把助手自己说的话当成 ground truth。
这套方案已经内置在 AIDA 中。AIDA 是一款来自 autafy.ca 的自托管 AI Agent——不过,这种方法适用于任何你更愿意自己拥有记忆、而不是向别人租用记忆的助手。
我真心希望能和大家交流记忆整合方面的经验,因为这正是我最不确定自己是否真正做好的部分。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。