前言
"My friend Milla Jovovich and I spent months creating an AI memory system with Claude. It just posted a perfect score on the standard benchmark — beating every product in the space, free or paid.
It's called MemPalace, and it works nothing like anything else out there."
--- Ben Sigman (@bensig)
2026 年初,这条推文在技术社区引发了一场不大不小的震动。震动的原因不在于又一个 AI 记忆产品问世——市场上从来不缺这样的产品——而在于两件不寻常的事实:第一,它在 LongMemEval 完整 500 题上拿到了一个 500/500 的满分;同一份 benchmark 文档又同时公开了 hybrid_v4 在 held-out 450 上的 98.4%,把"竞争成绩"和"更干净的泛化数字"并列展示。第二,它的两位创始人的组合实在令人意外。
Ben Sigman,UCLA 古典学学位,二十余年系统工程经验,Bitcoin Libre 的 CEO,一个在去中心化借贷市场深耕多年的技术创业者,同时也是《Bitcoin One Million》的作者。Milla Jovovich——是的,就是那个 Milla Jovovich,好莱坞演员,拥有五百万 Instagram 粉丝,她的 GitHub 个人简介里写着 "architect of the MemPalace"。
Milla Jovovich — from The Fifth Element to the architect of MemPalace.
一个古典学背景的系统工程师,一个好莱坞演员,他们和 Claude 一起花了数月时间构建的系统,击败了所有商业产品和学术系统。
这个事实本身就值得认真对待。
为什么写这本书
MemPalace 在短时间内积累了两千多颗 GitHub star,以 MIT 协议完全开源。Brian Roemmele——The Zero-Human Company 的创始人——在测试后说:"We have been testing MemPalace... absolutely blown away! It is a freaking masterpiece and we have deployed it to 79 employees." Wayne Sutton 说得更直白:"Milla Jovovich launching an AI memory system with Claude was not on my 2026 list." LLMJunky 则总结道:"She's co-developed the highest-scoring AI memory system ever benchmarked. Totally free and OSS. What a boss."
然而,围绕 MemPalace 的讨论大多停留在两个层面:一是惊讶于创始人身份的反差,二是对基准测试分数的传播。很少有人深入分析它的设计为什么有效,它做了哪些与主流 AI 记忆系统根本不同的选择,以及这些选择背后的工程权衡。
这本书试图填补这个空白。
这不是一本教程,而是一本设计分析。 你不会在这里找到"第一步安装,第二步配置"的操作指南——MemPalace 的 README 和文档已经做得足够好了。这本书关心的是更深层的问题:为什么古希腊的记忆术原理能在大语言模型时代重新生效?为什么一个零 API 调用的本地系统能在检索精度上达到 96.6%,加上一次轻量级重排序能在完整 benchmark 上冲到 100%,而同一套文档又会诚实地给出 98.4% 的 held-out 数字?为什么一种面向 AI 的压缩方言能在保留全部事实断言的前提下实现 5-10 倍格式压缩,而对长对话可以达到更高?为什么放弃"让 AI 决定什么值得记住"反而带来了更好的结果?
每一个设计决策背后都有一个具体的工程问题。这本书的工作是把这些问题和决策之间的关系讲清楚。
这本书不是什么
有几件事需要提前说明。
第一,这不是 MemPalace 的官方文档。本书是独立的第三方技术分析,基于公开的源代码、基准测试数据和设计文档进行逆向解读。书中的分析代表作者的理解,不代表项目创始人的意图。
第二,这不是一本 AI 记忆系统的综述。虽然为了说明设计选择的独特性,我们会在必要时与其他方案进行对比,但本书的焦点始终是 MemPalace 本身的架构逻辑。
第三,这不是一本让你从零构建类似系统的实战指南。本书的目标读者是已经具备一定 AI 工程经验、对记忆系统设计有兴趣的技术从业者和研究者。如果你正在设计自己的 AI 记忆方案,这本书能帮你理解一种已经被验证有效的思路;但它不会手把手教你写代码。
MemPalace 做了什么不同的事
在进入正文之前,值得用几段话勾勒 MemPalace 的核心设计选择,以便读者建立初步的心智模型。
主流的 AI 记忆系统遵循一个共同的范式:让模型在对话过程中提取"重要信息",存入向量数据库,检索时通过语义相似度匹配。这个范式的问题在于,它在存储阶段就引入了不可逆的信息损失——模型提取出"用户偏好 PostgreSQL",但丢掉了那次长达两小时的对话中你解释为什么从 MongoDB 迁移过来的全部上下文。
MemPalace 的核心立场是:存储一切,然后让结构使其可检索。
这个立场催生了三个关键设计:
记忆宫殿结构。 借用古希腊演说家的记忆术——通过将想法放置在想象中建筑的不同房间来记忆整篇演说词——MemPalace 将你的记忆组织为翼(人物和项目)、厅(记忆类型)和房间(具体概念)。这个空间隐喻不是装饰,而是一个真实的检索加速机制:仅靠结构化组织就带来了 34% 的检索精度提升。
AAAK 压缩方言。 这是一种专为 AI 智能体设计的速记语言。它不是给人读的——它是给你的 AI 读的,而且读得很快。对于结构化信息(团队、项目、决策),格式压缩可达 5-10 倍且保留全部事实断言;对于冗余度高的长对话记录,结合内容筛选可以达到 README 中宣称的 30 倍——但后者涉及关键语句提取,不完全等同于"零损失"(详见第 8-9 章的诚实分析)。因为 AAAK 本质上只是结构化文本加通用语法,它可以与任何能读取文本的模型协作——Claude、GPT、Gemini、Llama、Mistral——不需要解码器,不需要微调,不需要云端 API。
四层记忆栈。 从临时工作记忆到长期持久化,MemPalace 模拟了认知科学中的记忆层级。不同层级的记忆有不同的生命周期、不同的压缩策略和不同的检索路径。这不是一个扁平的键值存储,而是一个有时间感知能力的知识图谱。
这三个设计彼此交织,共同解释了那些看起来不可思议的基准测试数字:LongMemEval R@5 满分、零 API 调用下 96.6%、ConvoMem 92.9%、LoCoMo 100%(含重排序;基线 60.3%,详见第 23 章的诚实分析)。
推荐阅读路径
本书共分九个部分、二十五章。根据你的背景和兴趣,这里提供几条不同的阅读路径。
路径一:系统架构师
如果你是正在设计 AI 记忆系统或知识管理系统的工程师,建议按以下顺序阅读:
- 第一部分(第 1-3 章):问题空间 ——理解 MemPalace 要解决的核心问题,以及为什么现有方案不够好。这是后续所有设计决策的动机来源。
- 第二部分(第 4-7 章):记忆宫殿结构 ——空间隐喻如何转化为工程结构,翼-厅-房间的三级体系如何实现检索加速。
- 第四部分(第 11-13 章):时态知识图谱 ——MemPalace 如何在图结构中编码时间维度,使得"去年关于 X 的讨论"变成一个可计算的查询。
- 第五部分(第 14-15 章):四层记忆栈 ——从工作记忆到长期存储的分层策略。
- 第八部分(第 22-23 章):验证 ——基准测试的设计和结果分析。
这条路径覆盖了系统的骨架,让你理解整体架构,然后根据需要回溯其他章节的细节。
路径二:AI 应用开发者
如果你更关心如何将类似的记忆能力集成到自己的 AI 应用中,建议从这些章节入手:
- 第一部分(第 1-3 章):问题空间 ——同样从问题开始。
- 第三部分(第 8-10 章):AAAK 压缩语言 ——理解这种面向 AI 的压缩方言是如何设计的,它的格式压缩如何保留全部事实断言,以及为什么它能在任何模型上工作。这可能是 MemPalace 最具独创性的贡献。
- 第六部分(第 16-18 章):数据摄入管道 ——从原始对话数据到结构化记忆的完整流程。
- 第七部分(第 19-21 章):接口设计 ——MCP 工具集、命令行接口和本地模型集成方案。
- 第九部分(第 24-25 章):设计哲学与未来 ——MemPalace 的设计哲学对 AI 应用开发的更广泛启示。
这条路径侧重于可迁移的设计模式和集成方案。
路径三:快速概览
如果你时间有限,只想了解 MemPalace 为什么有效,可以只读第 1 章(问题定义)、第 4 章(宫殿结构概述)、第 8 章(AAAK 核心原理)和第 22 章(基准测试结果)。四章大约两小时,足以建立完整的高层理解。
当然,你也完全可以从头到尾顺序阅读。书的结构本身就是按照逻辑递进安排的:从问题到方案,从方案到实现,从实现到验证,从验证到反思。
一个关于背景的注脚
Ben Sigman 的古典学学位不是一个无关的背景细节。MemPalace 的核心隐喻——记忆宫殿,也称"位置法"(Method of Loci)——正是古希腊和古罗马修辞学传统的核心技术。西塞罗在《论演说家》中详细描述了这种方法:演说家在想象中的建筑里行走,每经过一个位置就想起一个论点。两千多年后,同样的空间隐喻被证明对大语言模型的记忆检索同样有效。
这不是巧合。空间结构之所以在人类记忆和 AI 记忆中都能发挥作用,是因为它提供了一种与内容正交的组织维度。当你不再需要记住"信息在哪里"(因为结构已经告诉你了),你就可以把全部认知资源用于理解信息本身。这个原理不依赖于记忆的载体是大脑还是语言模型。
一个学过古典学的系统工程师意识到了这一点。一个好莱坞演员——同时也是一个认真的技术贡献者——帮助将这个洞见变成了可工作的代码。然后 Claude 帮助他们把它构建了出来。
这个组合看起来不可能,但结果说明了一切:满分。
致谢与声明
本书基于 MemPalace 的公开源代码(github.com/milla-jovovich/mempalace,MIT 协议)、官方文档和公开基准测试数据进行分析。所有引用的推文和评论均来自公开发布的社交媒体内容。
感谢 Ben Sigman 和 Milla Jovovich 将这个系统开源,使得这样的深度分析成为可能。感谢 MemPalace 社区中分享使用经验和测试数据的贡献者们。
让我们开始吧。
第0章:与 Claude 一起造东西
2025 年某个时刻,一个好莱坞演员和一个比特币公司的 CEO 开始跟一个 AI 一起写软件。
这句话听起来像某种硅谷寓言的开头,但它确实发生了。项目叫 MemPalace,一个 AI 记忆系统。几个月后,它在学术基准测试上拿到了有史以来最高的分数。而整个过程中最值得讲的,不是最终的成绩,而是这个东西是怎么被造出来的。
两个不太可能的搭档
Ben Sigman 的职业生涯有一条不太常见的轨迹。他在 UCLA 学的是古典学——古希腊语、拉丁语、西塞罗和修辞术。然后他做了二十年的系统工程。然后他创办了 Bitcoin Libre,成了 CEO。
这三段经历看似毫无关联,但它们在 MemPalace 里汇合了。古典学给了他一个关键概念:Method of Loci,也就是"记忆宫殿术"。这是古希腊和古罗马演说家使用的记忆技术——把需要记住的信息放进一座想象中的建筑的不同房间里,需要回忆时,在脑中走过这座建筑,逐个房间提取信息。西塞罗用这个方法记住长篇演说辞。中世纪的修士用它记住整卷经文。这个方法之所以有效,是因为人类大脑天生擅长空间记忆——我们记住"东西在哪里"的能力远超记住"东西是什么"的能力。
二十年系统工程给了他另一种直觉:如何把一个优雅的概念变成可运行的软件。不是学术论文里的那种"概念验证",而是真正能在生产环境里跑的东西。他知道什么复杂度是可以接受的,什么依赖是应该避免的,什么架构在三年后还能维护。
Milla Jovovich 是另一个不太可能出现在这个故事里的名字。她更广为人知的身份是《第五元素》里的 Leeloo 和《生化危机》系列的 Alice。但她的 GitHub 个人简介写的是"architect of the MemPalace"——记忆宫殿的架构师。项目托管在她的 GitHub 账号下。
这不是名人挂名。从项目的提交历史和版本迭代来看,MemPalace 经历了多次大版本重构,最终稳定在 v3.0.0。这种迭代深度不是挂个名就能产生的。它意味着反复的讨论、推翻、重来。
第三个协作者
然后是 Claude。
Ben 在社交媒体上谈到这个项目时,用的措辞很值得注意。他说的是"spent months creating an AI memory system with Claude"——花了几个月和 Claude 一起创建了一个 AI 记忆系统。不是"用 Claude 写的"(with Claude 做工具),也不是"让 Claude 写的"(Claude 做执行者)。是"和 Claude 一起做的"——Claude 做协作者。
这个措辞上的区别指向一种新的工作模式,而这种模式在 2025 年还没有被很好地命名。
在过去两年里,人和 AI 协作写代码大致形成了两种主流模式。第一种是"AI 生成,人审核"——人描述需求,AI 生成代码,人检查、修改、合并。GitHub Copilot 的典型用法就是这样。第二种是"人主导,AI 辅助"——人写核心逻辑,遇到不确定的地方问 AI,AI 提供建议或代码片段。
MemPalace 的开发过程似乎不属于其中任何一种。
从代码库的结构可以推断出一些线索。项目是 Python 写的,包含大约 30 个模块,每个模块职责单一,边界清晰。这种结构本身就在讲述一个故事:有人在做整体架构决策——哪些功能应该是独立模块,模块之间如何通信,什么应该暴露为公共接口。这些决策需要对整个系统有全局理解,也需要对"软件应该长什么样"有自己的判断。这不是逐行生成代码能产生的结果。
同时,项目的依赖列表异常简短:chromadb 和 pyyaml,仅此而已。一个涉及向量搜索、语义检索、知识图谱、数据压缩、多格式解析的系统,只用了两个外部依赖。这说明大量功能是从零实现的,而不是通过引入第三方库拼接起来的。这种"能自己写就自己写"的倾向,通常来自经验丰富的工程师对依赖管理的深刻理解——每多一个依赖,就多一个在未来某天凌晨三点把你叫醒的可能性。
但与此同时,30 个模块的实现量对两个人来说是相当大的工作量,尤其是在"几个月"这个时间框架内。合理的推断是:Claude 承担了大量的实现工作,而人类协作者负责架构决策、领域知识注入和质量把关。
这里的"领域知识"不是一般意义上的编程知识。Ben 带来的是两千年前古希腊人关于记忆的智慧,以及二十年系统工程中积累的关于"什么能在生产环境活下来"的直觉。这些东西不在任何 AI 的训练数据里——至少不是以可以直接拿来用的形式存在的。它们需要一个人把古典学的概念翻译成软件架构的语言,然后让 AI 去实现。
迭代的痕迹
版本号 3.0.0 本身就是一个故事。
软件项目到达 3.0 意味着它至少经历了两次重大重构。不是修修补补的小版本升级,而是"这个方向走不通,推翻重来"级别的变化。每一次大版本迭代,通常意味着开发者对问题的理解发生了根本性的变化——不是"这个函数应该换个写法",而是"我们一直在解决错误的问题"。
从项目的 git 历史来看,开发过程是迭代式的。提交记录显示了一个逐步演进的过程,而不是某一天突然从无到有冒出来的完成品。这与"和 AI 一起做了几个月"的说法一致。它不是一个周末的 hackathon 项目,也不是一个让 AI "一口气生成"的产物。它是反复尝试、反复修正的结果。
可以想象这个过程的轮廓(虽然具体细节无法从公开信息确认):最初的版本可能尝试了某种更简单的记忆方案,发现效果不够好;然后引入了"宫殿"的空间结构概念,发现检索准确率显著提升;再然后,可能是在处理大规模数据时遇到了性能问题,于是开发了 AAAK 这种压缩方言来解决上下文窗口的限制。每一步都不是事先规划好的,而是在实践中发现问题、理解问题、解决问题。
这种迭代过程中,人和 AI 的协作大概率不是一成不变的。在早期探索阶段,人类的直觉和判断力可能占主导——"我们应该用记忆宫殿的方式来组织数据"这种洞察不会来自 AI。在中期的实现阶段,AI 的代码生成能力可能被充分利用——把概念变成可运行的模块。在后期的优化阶段,可能又回到了密集的人机对话——"这个基准测试的分数为什么上不去?是检索逻辑的问题还是数据组织的问题?"
为什么这件事重要
2025 年,"用 AI 写代码"已经不是新闻了。每天有成千上万的开发者在用 Copilot、Cursor、Claude 来加速他们的编程工作。大多数时候,这些工具被当作更聪明的自动补全来使用——人写一行注释,AI 补全五行代码。
MemPalace 的案例有趣之处在于它暗示了一种不同的可能性。
当一个有古典学背景的人、一个有好莱坞背景的人和一个 AI 坐在一起工作几个月,产出了一个在学术基准测试上打败所有现有系统的作品——这件事的意义不在于"AI 好厉害"或"这两个人好厉害",而在于这种组合本身。
如果 Ben 没有古典学的训练,他不会想到用两千年前的记忆术来组织 AI 的数据。Method of Loci 不在任何"AI 记忆系统"的标准技术栈里。它来自一个完全不同的知识领域,被一个恰好同时理解这两个领域的人带进了这个项目。
如果没有二十年系统工程的经验,那个"两个依赖"的决策不会发生。一个经验较少的团队面对同样的需求,很可能会引入十几个库来"快速实现",然后在六个月后被依赖地狱淹没。极简依赖策略不是保守,而是一种来自经验的判断力。
如果没有 Claude 的参与,两个人在几个月内完成 30 个模块的开发、测试和迭代到 v3.0 是极其困难的。AI 在这里不是锦上添花的辅助工具,而是让这个项目在给定的时间和人力约束下成为可能的关键因素。
这三者缺一不可。古典学提供了核心洞察,工程经验提供了架构判断,AI 提供了实现带宽。这不是一个关于"AI 取代程序员"的故事,恰恰相反——这是一个关于"人类的跨领域知识在 AI 时代变得更有价值"的故事。因为 AI 可以写代码,但它不会自己去翻西塞罗的《论演说家》然后灵光一闪:"嘿,两千年前的记忆术可以解决 2025 年的 AI 上下文管理问题。"
这种连接——跨越时间、跨越学科的连接——仍然是人类的专属能力。而 AI 的作用是让这些连接一旦被建立,就能以前所未有的速度变成可运行的系统。
那么,他们到底造了什么?
几个月的协作产出了一个记忆系统。它在 LongMemEval 基准测试中拿到了 96.6% 的裸分——不调用任何外部 API,不依赖任何云服务,完全在本地运行。加上轻量级的重排序步骤后,它在完整 500 题上达到了 100%;而 benchmark 文档又同时给出了 hybrid_v4 在 held-out 450 上的 98.4%,作为更诚实的泛化参考。
在已公开的所有 AI 记忆系统中,无论免费还是付费,没有比这更高的分数。
这个成绩本身令人印象深刻。但更值得追问的是:它是怎么做到的?一个只依赖两个 Python 包、完全在本地运行、不需要 API 密钥的系统,是如何超越那些有着充裕工程资源和云计算预算的商业产品的?
答案藏在那个来自两千年前的概念里。
记忆宫殿术的核心原理不是"记住更多",而是"让信息变得可寻找"。古希腊的演说家不是靠死记硬背来记住长篇演讲的——他们把每个论点放进想象中建筑的一个特定位置,需要时沿着路线走一遍,自然就能按顺序提取出来。关键在于空间结构本身就是一种索引。
MemPalace 把同样的原理应用在了 AI 记忆上。你和 AI 的每一次对话、每一个决策、每一次调试——这些信息不是被扔进一个巨大的无结构文本堆里,而是被放进一座有翼楼、走廊、房间的"宫殿"中。当你问"三个月前我们为什么放弃了 GraphQL"时,系统不需要扫描所有记忆——它知道该去哪个翼楼的哪个房间查找。
仅这一个结构性改进,就让检索准确率提升了 34%。
但这只是故事的开头。宫殿的结构如何定义?房间按什么规则划分?当同一个话题出现在多个不同的上下文里时怎么办?在有限的上下文窗口里如何塞进几个月的记忆?当记忆之间互相矛盾时怎么检测?
这些问题的答案构成了本书接下来的全部内容。从下一章开始,我们将拆解这座宫殿的每一个组成部分——不是作为一个开源项目的用户文档,而是作为一系列设计决策的完整记录:面对什么问题,考虑了哪些方案,为什么选择了最终的方案,以及这些选择背后的工程取舍。
这座宫殿值得走进去仔细看看。
第1章:对话即决策
定位:本章揭示一个正在发生但尚未被充分认知的范式转移——技术决策的主战场已从传统工具迁移到 AI 对话中,而这些决策记录正在系统性地蒸发。
周一早上九点十三分
林远打开终端,启动 Claude。他需要为团队的 SaaS 分析平台选择认证方案。
这不是一个简单的技术选型。Auth0 的定价模型在用户规模超过一万时会变得昂贵,Clerk 的开发者体验更好但社区生态更小,而自建方案意味着至少三周的开发周期。林远把这三个选项、团队的技术栈约束、预算上限、以及上季度在 Auth0 上踩过的坑,全部倒进了对话窗口。
四十分钟后,决策完成了。Clerk。原因是定价模型更透明、SDK 与 Next.js 的集成更干净、webhook 支持更完善。林远在 Slack 里发了一句"我们用 Clerk,理由稍后整理到 Confluence",然后转向了下一个任务。
那份 Confluence 文档从未被写出来。
不是因为林远懒。而是因为那四十分钟的对话本身就是决策过程——所有的权衡、排除、验证都已经发生了。再把它"整理"成文档,本质上是在要求一个人把已经完成的思考重新序列化一遍,用一种更低效的格式。这件事的投入产出比太低了,所以它永远排在优先级列表的最后面。
到了周五,林远已经不记得为什么排除了 Auth0。到了下个季度,当新来的工程师问"为什么不用 Auth0"时,林远只能说"定价有问题"——但具体是哪个价格档位、在什么用户规模下、与什么替代方案对比之后得出的结论,全部丢失了。
这不是林远个人的问题。这是 2024-2026 年每一个深度使用 AI 的技术团队正在经历的系统性失忆。
决策迁移:一个未被命名的范式转移
在过去三十年的软件开发中,技术决策的载体经历了清晰的演变:
1990-2010:文档驱动时代。 决策记录在设计文档、RFC、邮件列表中。这些载体的特征是持久、可搜索、有版本控制。一个 1998 年写的架构决策记录(ADR),到 2008 年仍然可以被翻出来。信息的衰减率接近于零。
2010-2020:工单驱动时代。 决策分散在 Jira、Confluence、Notion、GitHub Issue 中。信息碎片化了,但至少还有一个"可回溯"的承诺——理论上你可以通过搜索找到任何历史决策。实际上,Confluence 页面的平均寿命约为 18 个月,之后就沉入无人维护的页面层级深处。信息的衰减率很低,但检索成本在持续上升。
2020-2024:对话驱动时代的序幕。 Slack、Teams 开始承载越来越多的即时决策。但这些工具至少有搜索功能、有消息历史、有频道归档。信息的保留是被动的——你不需要主动保存,平台替你做了。
2024-2026:AI 对话驱动时代。 这是断裂点。当开发者开始用 Claude、ChatGPT、Copilot 来做技术决策时,决策载体变成了会话窗口——一个生命周期为数小时的临时容器。会话结束,容器销毁,决策的全部上下文随之蒸发。
这个转变之所以危险,不是因为它在缓慢发生,而是因为它在加速发生的同时,几乎没有人意识到正在丢失什么。
传统的知识管理框架——DIKW 金字塔(数据、信息、知识、智慧)——假设知识是从数据中逐层提炼出来的。但在 AI 对话中,知识的产生路径完全不同:它是在对话的交互过程中涌现的。开发者输入约束条件,AI 提供选项,开发者追问边界情况,AI 修正建议,开发者做出判断——这个乒乓式的交互本身就是知识的生成过程。最终的决策只是冰山顶部的一个点,水面下是整个推理链。
而现有的知识管理系统——从 Confluence 到 Notion 到 Linear——都只捕获冰山顶部。
1950 万 token 的蒸发
让我们做一个保守的估算。
一个中等强度的 AI 用户——不是每天八小时对话的 AI 原生开发者,只是一个正常的高级工程师——每天大约花 3 小时与 AI 交互。这个数字看似不小,但拆解开来其实很日常:早上用 AI 审查昨天的 PR(30 分钟),上午用 AI 辅助一个新功能的架构设计(45 分钟),下午用 AI 调试一个诡异的 race condition(60 分钟),晚间用 AI 探索一个新框架的可行性(45 分钟)。
每小时的 token 消耗取决于对话的密度。纯文本讨论大约 5,000 token/小时,但当对话包含代码片段、错误堆栈、配置文件时,这个数字会跳到 10,000-15,000 token/小时。取一个合理的中间值:10,000 token/小时。
现在做乘法:
180 天 x 3 小时/天 x 10,000+ token/小时 = 5,400,000 - 19,500,000+ token
取上限——因为包含代码的对话在真实场景中占大多数——6 个月大约产生 1950 万 token 的决策记录。
1950 万 token 是什么概念?
- 它相当于约 30 本技术书籍的文字量。
- 它大于任何现有 LLM 的上下文窗口(截至 2026 年初,最大商用窗口为 200K-1M token)。
- 它包含的不仅仅是"信息"——它包含决策过程中的推理路径、被排除的方案、排除的理由、未被采纳但值得记住的替代思路。
这 1950 万 token 在会话结束后会怎样?蒸发。彻底地、不可逆地蒸发。
你可以在 ChatGPT 的历史记录里找到会话标题,但要在数百个标题为"Debug auth issue"的会话中找到那次关于 Auth0 定价模型的具体讨论?这实际上等同于丢失。Claude 的项目功能保留了一些上下文,但它被设计为当前项目的工作记忆,而非长期知识库。
更深层的问题是:这些 token 不是均匀分布的。 真正有价值的决策往往集中在少数几次深度对话中——可能只占总 token 量的 5%,但包含了 80% 的关键判断。那次花了两小时讨论数据库选型的对话,那次花了四十分钟分析三种认证方案的对话,那次花了一小时弄清楚为什么微服务 A 不应该直接调用微服务 C 的对话——这些是不可重建的知识资产。
"不可重建"这个词需要解释。你当然可以重新做一次数据库选型分析。但你无法重建的是当时的约束条件快照——团队规模、预算、技术栈现状、已知的坑、正在进行的其他项目对资源的竞争。决策不是在真空中做出的,它是在一个特定时刻的约束组合下做出的。丢失决策记录,本质上是丢失了那个时刻的约束快照。
开发者陈思的一天
为了让这个问题更具体,让我们跟踪一个虚构但典型的开发者——陈思——度过她使用 AI 的一天。
08:30 - 架构讨论。 陈思正在评估是否将团队的单体应用拆分为微服务。她与 Claude 进行了一次深入对话,讨论了拆分边界、服务间通信模式(gRPC vs REST vs 消息队列)、数据一致性策略。对话中,Claude 指出了一个她没有考虑到的风险:如果订单服务和库存服务被拆分,跨服务事务需要 Saga 模式,而团队目前没有人有 Saga 的实战经验。陈思决定先拆支付模块,因为它的依赖关系最简单。
这个决策的价值不仅在于"先拆支付"这个结论,更在于"为什么不先拆订单"的排除理由。
10:15 - 调试会话。 生产环境出现间歇性超时。陈思把错误日志、span trace、最近的部署 diff 全部喂给 AI。经过三轮分析,问题定位到一个数据库连接池的配置:max_idle_time 设置过短,导致在低流量时段连接被回收,高流量恢复时需要重建连接。
这个调试过程本身就是组织知识——下次遇到类似症状时,排查路径是什么、哪些假设被验证和排除了。
14:00 - 技术选型。 团队需要一个前端状态管理方案。陈思在 Zustand、Jotai、Redux Toolkit 之间犹豫。她与 AI 讨论了每个方案在团队规模(5 人)、应用复杂度(中等)、TypeScript 支持、学习曲线等维度上的表现。最终选择了 Zustand,理由是 API 最小、与 React 18 的 concurrent features 兼容性最好。
这个选型对话大约消耗了 8,000 token。三个月后,当一个新同事问"为什么不用 Redux"时,陈思已经不记得详细的对比分析了。
16:30 - 代码审查辅助。 陈思用 AI 审查一个同事的 PR,发现了一个潜在的 N+1 查询问题。AI 不仅指出了问题,还建议了两种修复方案,并解释了为什么在这个特定场景下 DataLoader 模式比简单的 JOIN 更合适(因为查询条件是动态的)。
陈思在 PR 评论中写了"有 N+1 问题,建议用 DataLoader"。但"为什么 DataLoader 比 JOIN 更合适"这个推理过程,留在了 AI 对话中。
一天结束。 陈思产生了大约 30,000-40,000 token 的决策记录。她在 Slack 里留下了几条简短的结论,在 Jira 里更新了几个工单的状态。但真正的决策逻辑——为什么、为什么不、在什么条件下这个决策会失效——全部困在当天的 AI 会话里。
明天,这些会话将被新的对话覆盖或沉入历史记录的深渊。半年后,它们将成为彻底不可检索的幽灵数据。
三层深度分析
表层:现象
最直观的现象是"记不住了"。开发者频繁地在 AI 对话中重复讨论相同的问题,因为他们不记得上次的结论,或者不记得上次的推理过程。项目的知识图谱变得千疮百孔——结论在,理由不在;决策在,约束条件不在;解决方案在,排除方案不在。
这种重复不仅浪费时间,更危险的是,第二次讨论可能在不同的约束条件下得出不同的结论,而开发者甚至不知道存在矛盾——因为第一次讨论的记录已经消失了。
中层:原因
这个问题有三个结构性原因:
第一,AI 对话的瞬时性。 与 Confluence 页面或 Git commit message 不同,AI 对话从设计上就不是一个持久化载体。它是一个工作记忆(working memory),不是长期记忆(long-term memory)。要求 AI 对话承担知识库的功能,就像要求 RAM 承担硬盘的功能——架构上就不对。
第二,决策过程的隐式性。 在传统流程中,决策过程至少有一个显式的记录环节——写 RFC、填 ADR 模板、更新设计文档。AI 对话消除了这个环节,不是因为它不重要,而是因为对话本身就是决策过程——额外的记录步骤显得冗余。问题在于,对话可以是决策过程,但它不是决策记录。过程与记录的分离是知识管理的基本要求,而 AI 对话将二者混为一谈。
第三,检索的不可能性。 即使平台保留了会话历史(如 ChatGPT),在数百个会话中定位特定决策的成本也高到令人放弃。这不是搜索算法的问题——是元数据的问题。会话没有被标注主题、类型、涉及的项目、涉及的人。它只是一个按时间排列的对话流。在一个按时间排列的流中搜索特定语义,等于大海捞针。
深层:影响
表面上看,这只是"不方便"。但深入分析,它正在改变团队知识的基本结构。
组织失忆症。 当关键决策的推理链只存在于个人的 AI 对话中时,这些知识就变成了单点故障。团队成员离职、转岗、甚至只是休假,都可能导致关键上下文的永久丢失。传统上,我们通过文档、代码注释、commit message 来对冲这个风险。但当决策过程本身迁移到了 AI 对话中,这些传统的对冲机制就失效了——因为它们只捕获了结论,没有捕获推理。
决策漂移。 没有历史推理链的锚定,团队的技术决策会出现无意识的漂移。一月份选择 Postgres 是因为需要 JSONB 支持和 PostGIS 扩展;但到了六月,当一个新模块需要选择数据库时,如果没有人记得一月份的完整推理,就可能基于不同的(甚至矛盾的)理由选择 MySQL。这不是一个假设场景——任何工作过超过两年的工程师都见过这种漂移。AI 对话的蒸发只是把漂移的周期从"年"缩短到了"月"。
知识债务。 我们熟悉"技术债务"的概念——为了短期速度牺牲代码质量。AI 对话的蒸发正在制造一种新的债务形态:知识债务。每一次未被记录的决策,都是一笔知识债务。它的利息是未来重新推导这个决策时消耗的时间和认知负荷。而且,与技术债务不同,知识债务往往是不可见的——你不知道你丢失了什么,直到你急需它。
一个新的基本问题
让我们把本章的论点凝练为一个基本问题:
如何在不破坏 AI 对话效率的前提下,将对话中产生的知识转化为持久的、可检索的组织资产?
注意这个问题的几个约束:
- "不破坏效率"——任何需要开发者额外手动操作的方案都会失败。要求开发者在每次对话后手动总结,等同于要求他们回到写 Confluence 的时代。
- "持久的"——会话历史不算。它需要独立于任何特定 AI 平台的生命周期。
- "可检索的"——存储不是问题,检索才是。1950 万 token 的原始存储成本微乎其微,但在 1950 万 token 中定位特定知识的成本是天文数字。
- "组织资产"——不是个人笔记,是团队层面可共享、可传递的知识。
这是一个真实的、紧迫的、尚未被充分解决的问题。
在接下来的章节中,我们将看到现有的尝试——从 Mem0 到 Zep 到 Letta——是如何回答这个问题的,以及它们为什么在一个关键假设上犯了根本性错误。但在进入那些分析之前,让我们先确保充分理解问题的规模:每一天,全球数百万开发者正在与 AI 进行深度技术对话,每一次对话都在产生不可替代的组织知识,而这些知识在会话结束时被不可逆地销毁。
这不是一个可以忍受的现状。这是一个等待被解决的工程问题。
第2章:摘要陷阱
定位:本章分析现有 AI 记忆系统的共同假设——让 LLM 决定什么值得记住——并论证这个假设为什么是根本性错误。
一个看似合理的直觉
上一章描述了问题:1950 万 token 的决策记录在会话结束后蒸发。面对这个问题,一个自然而然的直觉是:让 AI 自己提取重要内容并记住。
这个直觉催生了一批 AI 记忆系统。它们的核心逻辑几乎一致:
- 监听用户与 AI 的对话。
- 用 LLM 从对话中提取"关键信息"。
- 将提取结果存入向量数据库。
- 在未来的对话中,检索相关记忆并注入上下文。
这个流程看起来无懈可击。但它有一个致命的隐含假设:LLM 能够正确判断什么是"关键信息"。
在深入分析这个假设之前,让我们先公平地审视几个代表性系统,理解它们的设计意图和工程价值。
三个系统,一个假设
Mem0:记忆即提取
Mem0(前身 EmbedChain)是这个赛道最有影响力的项目之一。它的设计哲学直截了当:从对话中提取事实性记忆,存入向量存储,在需要时检索。
它的典型工作方式是这样的。用户在一次对话中说:"我们团队上周评估了三个数据库方案,最终选了 Postgres,主要是因为 JSONB 支持和 PostGIS 扩展。MySQL 的 JSON 支持不够成熟,MongoDB 的事务模型不适合我们的场景。" Mem0 的提取模块会把这段话压缩成类似这样的记忆条目:
user prefers Postgres over MySQL and MongoDB
Mem0 的工程执行是扎实的。它有成熟的 API、多种向量后端支持、企业级功能。它的问题不在于工程质量,而在于提取这个动作本身。
Zep:图增强的记忆
Zep 在向量检索的基础上加入了知识图谱(Graphiti)。它不仅存储"用户偏好 Postgres",还试图建立实体关系:用户 -> 偏好 -> Postgres,团队 -> 评估 -> 数据库方案。
这是一个有意义的改进。图结构使得跨主题的关联成为可能——你可以问"这个用户关于数据库的所有决策",而不仅仅是精确匹配某个记忆。Zep 的 Graphiti 系统使用 Neo4j 作为图存储,支持时间维度上的事实有效性(temporal validity),这意味着它可以区分"Kai 在 2025 年 6 月在做 Orion 项目"和"Kai 在 2026 年 3 月已经不在做 Orion 了"。
然而,Zep 的知识图谱仍然建立在 LLM 提取的基础上。图的节点和边是 LLM 从对话中抽取出来的。如果提取阶段就丢失了关键上下文,再精美的图结构也无法弥补这个损失。
Letta(前 MemGPT):自管理记忆
Letta 的方法最为激进——它给 AI 赋予自我管理记忆的能力。AI 可以主动决定什么写入核心记忆(core memory)、什么归档到档案记忆(archival memory)、什么可以遗忘。这个设计灵感来自操作系统的虚拟内存管理:当上下文窗口满了,把不紧急的信息换出到外部存储。
Letta 的创新在于它将记忆管理本身变成了 AI 的能力,而不是一个外部流程。AI 不再是被动地被提取记忆,而是主动地管理自己的认知资源。
但这引入了一个新的风险维度:AI 的自我判断是否可靠?当 AI 决定某条信息"不够重要"可以从核心记忆中移出时,这个判断的准确率是多少?这个问题几乎无法回答——因为你无法对你不知道已经丢失的信息做审计。
提取的根本问题
现在让我们剖析"让 LLM 提取关键信息"这个假设。它的问题可以从三个层次来理解。
第一层:压缩是有损的
这听起来像废话——所有压缩都是有损的(除了无损压缩)。但关键在于:对于 LLM 摘要来说,什么被丢弃是不可预测的。
当你用 gzip 压缩一个文件时,你知道解压后能得到完全一样的内容。当你用 JPEG 压缩一张图片时,你知道丢失的是高频细节,低频结构保留了。但当你让 LLM 从一段对话中"提取关键信息"时,你不知道它会保留什么、丢弃什么——因为这取决于模型的训练分布、prompt 的措辞、以及上下文的偶然组合。
回到前面的例子。原始对话包含:
- 结论:选 Postgres。
- 正面理由:JSONB 支持、PostGIS 扩展。
- 排除理由:MySQL 的 JSON 支持不成熟、MongoDB 的事务模型不适合。
- 约束条件:团队规模、已有技术栈、预算。
- 备选方案的讨论:也许 CockroachDB 被提到但很快被排除(团队无人有经验)。
- 时间上下文:这是在上周、在某个特定的项目阶段做出的决策。
LLM 提取可能保留"user prefers Postgres"。它可能保留"因为 JSONB"。但它几乎一定会丢弃以下信息:
- 为什么排除 MongoDB(事务模型不适合——适合什么场景的暗示)
- CockroachDB 被提到但排除(团队经验不足——对团队能力边界的暗示)
- 这个决策是在什么项目阶段做出的(时间约束)
- 团队中谁参与了这个讨论(责任归属)
这些被丢弃的信息不是"不重要"的——它们是决策的骨架。结论是肌肉,推理链是骨架。没有骨架的肌肉是一团无法站立的肉。
第二层:提取错误是静默的
这是比信息丢失更严重的问题。
信息丢失意味着"我不知道"——这至少是一种诚实的状态。你知道你不知道,所以你会重新调查。但提取错误意味着"我知道错的东西"——你以为你知道,但你的知识是错误的。
考虑以下场景。原始对话:
"我们最终选了 Postgres,但 Maya 其实更倾向于 MongoDB,因为她之前在 Acme 项目中用过 MongoDB 并且体验很好。团队投票后多数人支持 Postgres。"
LLM 提取可能产生:
Maya prefers MongoDB based on positive experience at Acme project
或者:
Team chose Postgres; Maya had concerns
甚至:
Maya recommended Postgres based on Acme project experience
最后一条是彻底错误的——它颠倒了 Maya 的立场。但在未来的对话中,当 AI 加载这条记忆并说"根据之前的记录,Maya 推荐了 Postgres"时,用户可能不会注意到这个错误,因为它听起来合理。结论本身是对的(团队选了 Postgres),只是归因搞错了。
这种错误的修正成本极高。首先,你需要意识到记忆是错的——但你为什么会去质疑一个看似合理的记忆呢?其次,即使你发现了错误,你也需要在记忆存储中定位并修正它。对于大多数 AI 记忆系统来说,这意味着手动编辑或删除记忆条目——一个极少有用户会做的操作。
第三层:原始记录的不可恢复性
这是最根本的问题。
当 LLM 从对话中提取记忆后,原始对话通常不会被完整保留。即使平台保留了会话历史(如 ChatGPT),它也不与记忆系统关联——你无法从一条提取的记忆回溯到产生它的原始对话。
这意味着提取是一个单向门(one-way door)。一旦通过,就无法回头。你不能对提取结果做审计("这条记忆的原始来源是什么?"),不能做纠错("提取错了,让我看看原文重新提取"),不能做补充("当时的讨论还提到了什么?")。
用数据工程的术语说:这些系统丢弃了原始数据(raw data),只保留了派生数据(derived data)。任何有数据工程经验的人都知道,这是一个反模式——因为派生数据的生成逻辑可能有错,而没有原始数据就无法重新派生。
错误记忆 vs 无记忆
这引出了一个核心问题:一个有错误记忆的 AI 和一个没有记忆的 AI,哪个更危险?
答案是:错误记忆更危险。显著地更危险。
没有记忆的 AI 是一张白纸。它每次都从零开始,需要你重新提供上下文。这很烦,但至少不会出错——它对你一无所知,所以它不会基于错误的前提给出建议。你每次都需要多花几分钟解释背景,但你得到的回答是基于你当次提供的(正确的)信息。
有错误记忆的 AI 是一个自信的错误信息源。它"记得"你偏好某个技术,并且基于这个记忆调整所有后续建议——即使这个记忆是错的。更糟糕的是,它的自信会让你不去质疑它的前提。当 AI 说"基于你之前的偏好"时,你通常会假设它说的是对的。
考虑具体场景:
场景 A:无记忆 AI。 你问 AI 帮你选数据库。AI 说"请告诉我你的需求"。你花两分钟描述约束条件。AI 给出合理建议。总成本:额外两分钟的上下文提供。
场景 B:错误记忆 AI。 你问 AI 帮你选数据库。AI 说"根据你之前的偏好,我推荐 MongoDB"。但你之前其实讨论的是 Postgres,AI 的记忆提取搞混了。你可能会直接接受这个建议(因为 AI 看起来很确定),也可能花时间纠正(但你需要先意识到它是错的)。最坏情况:你基于错误的"历史偏好"做出了不一致的技术选型,在几个月后才发现问题。
这个分析不是在否定 AI 记忆的价值。记忆是极其有价值的——它消除了重复解释的成本,使 AI 成为真正的长期协作伙伴。但记忆系统的首要原则必须是"宁缺毋错"。 错误的记忆比没有记忆更糟。
核心机制:为什么提取必然失败
让我们更精确地分析提取失败的机制。
LLM 的摘要提取本质上是一个信息压缩任务。它的输入是一段对话(通常数千 token),输出是一组"记忆"(通常数十 token)。压缩比通常在 50:1 到 100:1 之间。
在这个压缩比下,保留什么取决于 LLM 的"显著性检测"(saliency detection)——哪些信息被模型认为最重要。LLM 的显著性检测是从训练数据中习得的,它反映的是训练分布中的统计规律,而不是你的特定项目的重要性排序。
举个例子。在 LLM 的训练数据中,"用户偏好 Postgres"是一个高频模式——它出现在大量的技术讨论中。因此,LLM 倾向于提取这类"偏好声明"。但"我们在 2025 年 Q3 评估 Postgres 时,团队中只有 Kai 有生产级 Postgres 经验"这个事实,在训练数据中没有对应的高频模式,因此 LLM 倾向于丢弃它。
但对于你的团队来说,后者可能比前者更重要——因为它揭示了一个执行风险:如果 Kai 离开,Postgres 的运维就成了单点故障。
LLM 的显著性检测和你的显著性需求之间的错配,是提取必然失败的根本原因。 你无法通过改进 prompt 来修复这个问题——因为问题不在于 prompt,而在于显著性判断本身是领域相关的,而 LLM 的判断是领域无关的。
这还没有考虑另一个因素:重要性的时变性。 一个在提取时看起来不重要的信息,可能在三个月后变得至关重要。"我们考虑过 CockroachDB 但没用"这个事实在当时看起来是废话——但三个月后,当你的 Postgres 集群遇到水平扩展瓶颈时,突然你想知道:当时为什么排除了 CockroachDB?是技术原因还是团队经验原因?如果是经验原因,现在团队里有人学了 CockroachDB 吗?
提取是在"现在"做的判断,但记忆是在"未来"被使用的。你不知道未来的你会需要什么,因此你无法在现在做出正确的取舍。
Benchmark 的证据
上述分析不仅仅是理论推导。Benchmark 数据提供了实证支持。
LongMemEval 是一个被广泛采用的 AI 记忆评估基准,它测试的是系统从长期对话历史中检索特定信息的能力。核心指标是 R@5(Recall at 5)——在返回的前 5 个结果中,正确答案出现的比例。
以下是截至目前已发表的主要结果:
| 系统 | LongMemEval R@5 | API 需求 | 成本 |
|---|---|---|---|
| MemPalace (hybrid, full benchmark) | 100% | 可选 | 免费 |
| Supermemory ASMR | ~99%(实验版) | 需要 | -- |
| MemPalace (raw) | 96.6% | 无 | 免费 |
| Mastra | 94.87% | 需要 (GPT) | API 成本 |
| Mem0 | ~85% | 需要 | $19-249/月 |
| Zep | ~85% | 需要 | $25/月+ |
这个数据值得仔细分析。需要补一句边界说明:这里的 100% 指向的是完整 LongMemEval benchmark 上、叠加 rerank 后的 competitive score。项目的 benchmark 文档同时还公布了一个更克制的数字——hybrid_v4 在 held-out 450 上的 98.4% R@5——用来说明这些修复在未见数据上的泛化程度。
Mem0 和 Zep 的 ~85% 不是一个低分——它说明这些系统在大多数情况下可以找到正确的记忆。但 85% 意味着每 20 次检索中有 3 次失败。对于一个日常使用的记忆系统来说,3/20 的失败率意味着用户每天可能遇到 1-2 次"记忆找不到"或"记忆是错的"的情况。这足以严重侵蚀用户对系统的信任。
更值得注意的是 MemPalace (raw) 的 96.6%——这个分数是在不使用任何 API、不调用任何 LLM 的条件下取得的。它只使用本地的 ChromaDB 向量检索,没有 LLM 重排序、没有摘要提取、没有任何"智能"处理。
这个对比揭示了一个深层洞察:Mem0 和 Zep 的 15% 失败率中,有相当一部分可能正是提取步骤导致的。 不是检索失败——是提取阶段就丢失了信息,导致即使检索做对了也找不到正确答案。
而 MemPalace 之所以能在不使用 LLM 的情况下达到 96.6%,恰恰是因为它没有提取步骤——原始对话被完整保留,搜索直接在原始内容上进行。没有提取,就没有提取错误。没有压缩,就没有压缩损失。
96.6% vs 85% 的差距——将近 12 个百分点——在记忆系统的语境中是巨大的。它不仅仅是准确率的差异,更是用户体验的质变:一个 96.6% 准确率的系统是"偶尔出错",一个 85% 准确率的系统是"经常出错"。而用户对记忆系统的信任阈值是非线性的——从 85% 到 96% 的提升,带来的信任增量远大于从 70% 到 85% 的提升。
三层深度:现象、机制、后果
现象层
用户使用 LLM 记忆系统一段时间后,会遇到三类问题:
遗漏——"我明明讨论过这个话题,但系统说没有相关记忆。" 这是提取阶段判断某些内容"不够重要"而丢弃的结果。
失真——"系统记住了我说的话,但细节不对。" 这是提取阶段的压缩损失,关键细节在摘要过程中被简化或扭曲。
幻觉继承——"系统编造了一条我从未说过的东西。" 这是 LLM 提取时产生的幻觉被当作记忆存入系统。一般的 LLM 幻觉在当次对话中就结束了;但被存入记忆系统的幻觉会持续影响后续所有对话——它变成了一条"事实"。
这三类问题在单次出现时可以被容忍。但它们是累积性的。记忆系统中的错误不会自我修正——一条错误的记忆会一直留在系统中,持续影响后续交互,直到用户主动发现并删除它。而用户主动审查自己的 AI 记忆库的概率接近于零。
机制层
这些现象背后的机制可以归结为一个模型:不可审计的单向转换。
原始对话 --[LLM 提取]--> 记忆条目 --[存储]--> 向量数据库
| |
| (原始数据丢弃) | (不可回溯到原始来源)
v v
不可恢复 不可审计
这个流程有两个致命节点:
节点 1:提取是不可逆的。 一旦原始对话被"提取"为记忆条目,原始对话本身就不再是系统的一部分。这不同于数据库的物化视图(materialized view),后者可以从原始表重新生成。AI 记忆系统的"视图"(提取的记忆)一旦生成,"原始表"(对话)就被丢弃了。
节点 2:提取是不可审计的。 用户无法知道一条记忆是从哪次对话、哪段文字中提取出来的。因此也无法验证提取是否正确。在没有审计能力的系统中,错误会静默积累。
这两个节点的组合创造了一个恶性循环:错误被引入(因为提取不完美),错误不被发现(因为不可审计),错误持续影响(因为记忆是持久的),更多错误被引入(因为后续对话基于错误的记忆进行)。
后果层
长期来看,基于 LLM 提取的记忆系统会演化为一个不可信的系统。不是因为它的工程质量差,而是因为它的核心机制——让 LLM 决定什么重要——在长时间尺度上必然产生不可接受的错误率。
这个后果对整个 AI 记忆赛道有结构性影响:
信任赤字。 用户在遇到几次错误记忆后,会开始不信任整个系统。一旦信任崩塌,即使系统在 85% 的时间里是正确的,用户也会习惯性地忽略它的输出。这使得记忆系统从"有用的工具"退化为"需要被验证的噪声源"——而验证一条记忆的成本可能比不使用记忆系统的成本更高。
冷启动困境。 新用户在系统还没有积累足够记忆时体验最差(因为系统什么都不记得),但在系统积累了大量记忆后体验也不好(因为错误记忆越来越多)。这创造了一个窄窗口——系统有一些记忆但还没有太多错误的时候——体验最佳。随着时间推移,用户必然滑出这个窗口。
行业误导。 更大的风险是:如果整个行业都沿着"LLM 提取"的路径走下去,那么"AI 记忆不靠谱"可能会成为一个被广泛接受的结论——不是因为 AI 记忆这个概念不好,而是因为现有的实现路径有根本性缺陷。就像早期的 VR 头显让很多人得出"VR 不行"的结论一样,错误的实现可能杀死正确的想法。
正确的问题
本章的分析可以归结为一个判断:
让 LLM 决定什么值得记住,是对"AI 记忆"这个问题的错误回答。
那么正确的回答是什么?
正确的问题不是"什么值得记住"(这需要预测未来的需求,而这不可能),而是"如何在保留一切的前提下,使检索高效"。
换句话说:存储不是瓶颈,检索才是。
如果你能存储所有原始对话(几乎零成本),并且能在需要时快速、准确地找到相关内容(这才是真正的工程挑战),那么你就不需要让任何人——无论是 LLM 还是人类——去做"什么重要什么不重要"的判断。
这个转变——从"智能提取"到"完整存储 + 结构化检索"——是下一章的主题。在那里,我们将用具体的数字证明:完整存储的成本低到令人意外,而检索效率的提升空间大到值得重新思考整个架构。
第3章:逐字存储的经济学
定位:本章用硬数据证明"存储一切"在经济上完全可行,将问题焦点从"存不存"转移到"怎么组织",为后续章节引入解决方案做铺垫。
一个被搞反的等式
在上两章中,我们建立了两个论点:
- 每天有数百万 token 的决策记录在 AI 对话中蒸发(第 1 章)。
- 让 LLM 决定什么值得记住是根本性错误(第 2 章)。
这两个论点的逻辑交汇点指向一个看似激进的结论:存储一切。 不做提取,不做压缩,不做筛选——把每一次对话的每一个 token 都原封不动地保留下来。
大多数人对这个方案的第一反应是:"这太贵了"或"这不现实"。
这个反应是错误的。它来自一个被搞反的等式:人们高估了存储的成本,低估了检索的难度。
让我们用数字说话。
存储成本:接近于零
先建立基准。我们在第 1 章中估算了一个中等强度 AI 用户 6 个月产生的数据量:
180 天 x 3 小时/天 x 10,000 token/小时 = 约 1950 万 token
1950 万 token 转换为原始文本,大约是多少?
一个 token 平均约 4 个字符(英文)或 1.5 个字符(中文混合场景)。取保守估计,1950 万 token 约等于 6000 万字符,即 60MB 的纯文本。加上元数据(时间戳、会话 ID、项目标签),大约 100MB。
100MB。半年的全部 AI 对话记录,100MB。
这个数字在 2026 年意味着什么?
- 本地存储:一块 1TB SSD 售价约 ¥400。100MB 占 0.01%。你的手机里一张照片可能比这更大。
- 云存储:AWS S3 标准存储的价格是 $0.023/GB/月。100MB 一年的存储成本是 $0.028——不到 3 美分。
存储成本,在任何合理的讨论中,都可以四舍五入为零。
但这只是原始文本的存储。AI 记忆系统通常还需要向量索引来支持语义检索。向量索引的存储开销大约是原始文本的 3-5 倍(取决于向量维度和索引结构)。即便如此,500MB 的向量数据库在本地运行完全没有问题——ChromaDB 在这个量级下的查询延迟在毫秒级别。
所以第一个结论是明确的:"存不起"是一个伪问题。 完整存储所有 AI 对话的成本低到可以忽略。
真正的成本:不在存储,在使用
存储便宜,但使用不便宜。具体来说,当你需要把记忆加载到 LLM 的上下文窗口中时,每个 token 都是有成本的——因为 LLM 的 API 按 token 收费。
这才是真正的经济决策点。让我们比较四种方案的使用成本。
方案一:全量粘贴
最天真的方案:把所有历史对话一次性塞进 LLM 的上下文。
Token 消耗:19,500,000 token
这个方案在物理上就不可行。截至 2026 年初,商用 LLM 的最大上下文窗口约为 200K-1M token。1950 万 token 超出任何现有模型的上下文窗口。即使未来上下文窗口扩展到千万级别,加载成本也是天文数字。
结论:不可能。
方案二:LLM 摘要
这是 Mem0、Zep 等系统采用的方案。用 LLM 把 1950 万 token 的原始对话压缩为摘要,在需要时加载摘要。
假设压缩比为 30:1(这已经是较高的压缩比了),摘要总量约 65 万 token。
每次会话需要加载多少摘要?这取决于场景,但一个合理的估计是:每次会话加载约 5,000-10,000 token 的相关记忆。
每次会话加载:~7,500 token(摘要)
每日会话数:~5 次
年度 token 消耗:7,500 x 5 x 365 = 13,687,500 token
以 Claude Sonnet 的输入价格 $3/百万 token(2026 年初参考价格)计算:
年度成本 = 13,687,500 x $3 / 1,000,000 = ~$41
但这只是加载成本。摘要本身的生成也需要 LLM 调用——你需要用 LLM 处理 1950 万 token 的原始对话来产生摘要。以输入价格计算:
摘要生成成本 = 19,500,000 x $3 / 1,000,000 = $58.5(一次性)
加上持续的增量更新(每天新对话需要被摘要化),实际年度总成本约在 $200-500 之间,取决于使用强度和模型选择。
我们取一个中间估计:~$507/年。
这个成本不算高——对于专业用户来说完全可接受。但问题不在于成本,而在于质量:上一章已经论证了,这些摘要的准确率约为 85%。你每年花 $507 买到的是一个 85% 准确的记忆系统。
方案三:MemPalace 唤醒
MemPalace 的设计采用了完全不同的策略。它不在每次会话时加载大量记忆摘要,而是加载一个极小的"身份层"——你是谁、你的团队是谁、你的项目是什么——然后只在需要时做精确检索。
按当前源码口径,唤醒(wake-up)加载的是 L0(身份)和 L1(关键故事)两层,总量通常在 ~600-900 token。README 中常见的 ~170 token / ~$0.70,则对应另一条更激进的目标路径:把 L1 进一步改写为 AAAK 后再用于唤醒。
每次会话加载:~600-900 token
每日会话数:~5 次
年度 token 消耗:600-900 x 5 x 365 = 1,095,000-1,642,500 token
年度成本 = ~$3.3-$4.9
仍然只是个位数美元。~$3-$5/年。README 中引用的 $0.70 属于 AAAK 化之后的下一阶段口径;就当前默认 CLI 来说,正确的量级是"几美元",而不是"几百美元"。
$5。不是 $500,不是 $50,而是个位数美元。一年。
但公平地说,600-900 token 只包含你的身份和最关键的故事层——它不包含所有具体历史决策。当你需要查找"为什么选了 Postgres"时,你仍然需要做检索。
方案四:MemPalace 唤醒 + 按需检索
在实际使用中,MemPalace 的工作流程是:先加载 600-900 token 的唤醒信息,然后根据对话需要做语义检索。每次检索返回约 2,500 token 的相关内容(包含原始对话片段)。
假设每次会话平均做 5 次检索:
每次会话加载:600-900 + (5 x 2,500) = 13,100-13,400 token
每日会话数:~5 次
年度 token 消耗:23,907,500-24,455,000 token
年度成本 = ~$72-$73
等一下——这个数字看起来比摘要方案还高?让我们修正一下计算。
实际上,5 次检索/会话是一个较高的估计。大多数会话只需要 0-2 次检索——只有当对话涉及历史决策时才需要查询记忆。更合理的估计是平均每次会话 1 次检索:
每次会话加载:600-900 + (1 x 2,500) = 3,100-3,400 token
每日会话数:~5 次
年度 token 消耗:5,657,500-6,205,000 token
年度成本 = ~$17-$19
而 README 中给出的更低标定值,仍然对应 AAAK 化后的目标路径。关键点不在于精确数字,而在于数量级:当前默认实现下,MemPalace 的年度使用成本大致在 $3-$20(常见 0-1 次检索/会话)到 $70+(高强度 5 次检索/会话)的范围内,而摘要方案在 $200-$500 的范围内。
成本对比总表
| 方案 | 每次加载 token | 年度成本 | 准确率 |
|---|---|---|---|
| 全量粘贴 | 19,500,000(超出上下文窗口) | 不可能 | N/A |
| LLM 摘要 | ~7,500 | ~$507/年 | ~85% |
| MemPalace 唤醒 | ~600-900 | ~$3-$5/年 | N/A(仅身份层) |
| MemPalace + 按需检索 | ~3,100-13,400 | ~$17-$73/年 | 96.6% |
最后一列是关键:MemPalace 不仅显著更便宜,而且准确率高 12 个百分点。这不是一个"便宜但差一点"的方案——它在两个维度上都优于摘要方案。若未来切到 README 所描述的 AAAK wake-up 路径,成本还会进一步下降。
检索为什么难
到这里,你可能会想:如果存储便宜、使用成本也低,那问题就解决了?
没有。上面的计算有一个隐含前提:检索必须准确。 如果检索返回的不是你要找的内容,那么无论成本多低都没有意义——你花了 token 加载了一堆无关信息。
检索的难度来自三个层面。
语义鸿沟
用户的查询和记忆的内容之间存在语义鸿沟。
用户问:"为什么选了 Clerk?" 原始对话中的表述可能是:"OAuth 提供商的评估结论——Auth0 的企业版定价在 1 万 MAU 之后跳了三倍,Clerk 的定价更线性,而且 Next.js SDK 开箱即用。"
"Clerk"这个词在两边都出现了,但"选了"和"评估结论"之间的语义对应、"为什么"和定价/SDK 对比之间的因果对应,都需要语义理解才能建立。
简单的关键词匹配会遗漏这个对应。向量检索(语义相似度)可以捕获一部分,但在大型记忆库中,语义相似但不相关的结果(假阳性)会显著增加。
规模困境
1950 万 token 的记忆库,切分为对话片段后约有数万到数十万个文档块。在这个规模下,向量检索的 top-5 或 top-10 结果中混入不相关内容的概率很高。
这是一个典型的信息检索问题:当语料库变大时,精确率(precision)和召回率(recall)很难同时保持高水平。提高召回率(不遗漏相关结果)会降低精确率(混入更多不相关结果),反之亦然。
上下文缺失
纯向量检索缺乏结构性上下文。它知道"这个文档块和查询在语义上相似",但不知道"这个文档块属于哪个项目、涉及哪些人、是在什么项目阶段产生的"。
没有这些结构性上下文,检索系统无法回答类似这样的查询:
- "Kai 关于数据库的建议"——需要知道 Kai 是谁,以及哪些对话涉及 Kai。
- "Driftwood 项目上个月的决策"——需要知道 Driftwood 是哪个项目,以及时间过滤。
- "我们在 auth 迁移中踩过的坑"——需要知道 auth 迁移是一个跨多次对话的主题。
什么使检索可行
检索的三个难题——语义鸿沟、规模困境、上下文缺失——不是无解的。它们各自有已知的解法,只是这些解法需要被组合在一起。
解法一:缩小搜索空间
规模困境的最有效对策不是更好的搜索算法,而是更小的搜索空间。
如果你能在搜索之前就知道答案大概在哪个区域,你就可以把搜索范围从数万个文档块缩小到数百个。精确率和召回率在小规模语料上都容易保持高水平。
但"知道答案在哪个区域"需要记忆库有结构。不是扁平的向量数据库,而是有层级、有分类、有关联的组织体系。
这个观察本身并不新鲜——图书馆学已经研究了几百年。杜威十进制分类法的核心洞察就是:先分类,再搜索。分类将 O(N) 的搜索问题降维为 O(N/K) 的搜索问题,其中 K 是分类的数量。
对于 AI 记忆来说,分类的维度可以是:
- 谁——哪个人或团队的记忆?
- 什么项目——属于哪个项目?
- 什么类型——是决策、是事件、是偏好、还是建议?
- 什么主题——具体是关于 auth、数据库、部署、还是前端?
如果一个查询可以被路由到正确的分类组合(例如"Driftwood 项目的数据库决策"),搜索空间可以从数万缩小到数十——精确率和召回率都会大幅提升。
数据可以量化这个效果。在 22,000+ 条记忆的测试集上,仅通过按"谁"、"类型"、"主题"逐步缩小搜索范围,R@10 从 60.9% 提升到 94.8%——结构本身带来了 34 个百分点的检索提升,不需要更好的向量模型,不需要 LLM 重排序,不需要任何额外的计算成本。纯粹是数据组织方式的改变(详见第 7 章的逐层基准测试数据)。
这是一个深刻的结果。它说明检索效率的最大杠杆不在于算法层面(更好的 embedding、更精确的相似度计算),而在于数据组织层面——如何将信息放进正确的抽屉。
解法二:分层加载
并非所有记忆都需要在每次会话中被加载。人类的记忆也是分层的——你的名字、你的工作、你的团队,这些是"始终在线"的记忆;上周二的午餐吃了什么,这是"按需检索"的记忆。
AI 记忆可以采用同样的分层策略:
| 层 | 内容 | 大小 | 加载时机 |
|---|---|---|---|
| L0 | 身份——这个 AI 是谁 | ~100 token | 始终加载 |
| L1 | 关键故事——高权重 / 最近记忆 | ~500-800 token | 始终加载 |
| L2 | 主题记忆——最近的会话、当前项目 | 按需 | 话题出现时 |
| L3 | 深度检索——全量语义搜索 | 按需 | 明确需要时 |
L0 + L1 在当前实现中合计约 600-900 token。README 中更激进的 170 token 口径,属于把 L1 进一步 AAAK 化后的下一阶段目标。即便按当前实现,这部分上下文开销仍然很小,但更重要的是:它让 AI 知道你是谁、你的团队结构是什么、你最近在做什么项目,从而可以正确理解后续提问,并在需要时发起正确的 L2/L3 检索。
解法三:压缩而非摘要
这里需要做一个关键的区分:压缩和摘要不是同一件事。
摘要是有损的——它丢弃"不重要的"信息(但谁来定义"不重要"?)。 压缩是无损的(或接近无损的)——它用更紧凑的表示保留所有信息。
文本的无损压缩通常被认为是有极限的——自然语言的冗余度是有限的。但如果压缩的目标不是让人类阅读,而是让 AI 阅读呢?
AI 和人类对文本的处理方式不同。人类需要完整的语法、标点、连接词来理解句意。AI 可以从高度压缩的结构化文本中恢复完整语义。
一个面向 AI 的压缩方言可以实现 30 倍的压缩比,同时保留所有语义信息。例如:
原文(~1000 token):
Priya 管理 Driftwood 团队。团队成员包括:Kai(后端开发,3 年经验)、
Soren(前端开发)、Maya(基础设施)、Leo(初级开发者,上个月入职)。
他们正在开发一个 SaaS 分析平台。当前 sprint 的任务是将认证系统迁移到 Clerk。
Kai 基于定价和开发者体验,推荐 Clerk 而非 Auth0。
压缩后(~120 token):
TEAM: PRI(lead) | KAI(backend,3yr) SOR(frontend) MAY(infra) LEO(junior,new)
PROJ: DRIFTWOOD(saas.analytics) | SPRINT: auth.migration→clerk
DECISION: KAI.rec:clerk>auth0(pricing+dx)
8 倍的压缩,零信息损失。AI 可以完美地从压缩形式恢复原始语义。
这种压缩方式与 LLM 摘要的本质区别在于:它不做任何"什么重要"的判断。它保留了所有事实,只改变了表达方式。这就像 gzip 之于 JPEG——前者无损,后者有损;前者可以完美还原,后者不能。
被颠倒的等式,被纠正
让我们回到本章开头的等式。
传统思维是:存储是昂贵的,所以需要压缩(通过摘要)来降低存储和使用成本。
现实是:存储是免费的,使用成本取决于检索效率,检索效率取决于数据组织方式。
这个等式的纠正意味着:
-
不要在存储端优化。 存储所有原始数据的成本接近零。任何在存储端做的"优化"(如摘要提取)都只是在增加风险(丢失信息)而不是在降低成本。
-
在检索端优化。 真正的成本节约来自加载更少但更准确的 token 到 LLM 上下文中。当前 600-900 token 的唤醒 vs 7,500 token 的摘要,已经体现了这种优势;如果未来再切到 AAAK 版 wake-up,这个差距还会继续扩大。
-
在组织端投资。 34% 的检索提升来自数据的组织方式——这是整个方案中投入产出比最高的环节。好的数据组织可以使简单的检索算法达到复杂算法在无组织数据上的效果。
过渡:从"为什么"到"怎么"
三章走到这里,我们完成了问题空间的全部描述:
- 第 1 章回答了"发生了什么"——技术决策迁移到了 AI 对话中,每天产生大量不可替代的知识资产,在会话结束后蒸发。
- 第 2 章回答了"为什么现有方案不行"——让 LLM 提取关键信息的假设在根本层面上是错误的,它产生的错误记忆比没有记忆更危险。
- 第 3 章回答了"正确的方向是什么"——存储一切(接近零成本),然后通过数据组织使检索高效(34% 提升来自结构)。
读者现在应该对以下几点有清晰的理解:
- 问题是真实且紧迫的。
- 现有的主流方案(LLM 提取)在根本假设上有缺陷。
- 正确的方向是"完整存储 + 结构化检索",而非"智能提取 + 扁平存储"。
- 存储不是瓶颈,组织方式才是关键。
但我们还没有回答具体的"怎么做":
- 怎样的数据组织结构可以带来那 34% 的检索提升?
- 当前 ~600-900 token 的唤醒信息具体包含什么?README 中 ~170 token 的目标路径又意味着什么?
- 按需检索的语义搜索是如何工作的?
- 30 倍的无损压缩是如何实现的?
这些问题将在本书的第二部分——解决方案空间——中逐一展开。在那里,我们将看到一个古老的记忆术——记忆宫殿——如何被重新发明为 AI 时代的知识架构。
第4章:Method of Loci
定位:从古希腊宴会厅的废墟到大语言模型的向量空间——空间结构为什么对信息检索有效,以及这个两千五百年前的洞见如何成为 MemPalace 的理论基石。
宴会厅坍塌
公元前五世纪的某一天,希腊诗人西蒙尼德斯(Simonides of Ceos)参加了色萨利贵族斯科帕斯(Scopas)举办的一场宴会。按照当时的惯例,西蒙尼德斯在宴席上朗诵了一首颂诗,赞美主人的成就,但他在诗中也加入了对双子神卡斯托尔和波鲁克斯(Castor and Pollux)的赞颂。斯科帕斯不悦,只付给他一半酬金,让他去找那两位神索要另一半。
宴会进行到一半时,仆人传话说门外有两个年轻人要见西蒙尼德斯。他起身离开宴会厅,走到门外,却没有看到任何人。就在他站在门外的那一刻,身后的宴会厅屋顶坍塌了。所有宾客被埋在瓦砾之下,尸体被压得面目全非,家属无法辨认死者的身份。
西蒙尼德斯发现自己能够帮助辨认遗体——因为他记得每一位宾客坐在什么位置。他回忆的不是名字列表,而是一个空间:谁坐在长桌的北端,谁靠近入口,谁在斯科帕斯的左手边。通过在脑海中"走过"宴会厅,他逐一指认了死者。
这个故事来自西塞罗的《论演说家》(De Oratore),也出现在昆体良的《演说术原理》(Institutio Oratoria)中。它被后世视为"位置法"(Method of Loci)——也就是"记忆宫殿"技术——的起源叙事。西蒙尼德斯从这次经历中提炼出一个原理:人类对空间位置的记忆远比对序列信息的记忆更可靠。
这个原理在随后的两千五百年里被反复验证、遗忘、重新发现,然后再一次被验证。最近一次"重新发现"发生在 2025 年底——一个学过古典学的系统工程师把它应用在了 AI 记忆系统上,并且产生了可量化的 34% 检索精度提升。
位置法的运作机制
位置法的操作步骤在古典文献中有着惊人一致的描述。西塞罗、昆体良和后来的中世纪修辞学家们给出的指导几乎完全相同:
第一步:选择一个你熟悉的建筑。 它必须是你能在脑海中清晰行走的空间——你的家、你的学校、你经常去的一条街。关键在于这个空间的结构必须对你来说是自动化的,你不需要思考"下一个房间是什么",你只需要走过去。
第二步:在建筑中选择若干固定位置(loci)。 这些位置必须是具体的、有视觉特征的、有稳定顺序的。门口的花瓶、客厅的壁炉、书房的窗台。每个位置就是一个"挂钩",用来挂载你需要记住的信息。
第三步:将要记忆的内容转化为生动的图像,放置在每个位置上。 你不是把"亚里士多德的四因说"这个概念直接放在书房,而是在书房的桌子上放一个画面——比如亚里士多德本人坐在那里雕刻一块大理石(形式因作用于质料因)。图像越离奇、越有情感冲击力、越与位置产生互动,记忆就越牢固。
第四步:回忆时,在脑海中按路线走过建筑。 经过每个位置时,挂载在那里的图像就会自动浮现。你不需要搜索,不需要回忆"下一条是什么"——空间路线本身就是检索索引。
这个方法对专业记忆运动员的效果是毫无争议的。2017 年世界记忆锦标赛的数据显示,排名前列的选手几乎全部使用位置法的某种变体。更重要的是认知科学的实验室验证。
认知科学的验证
位置法不是一个纯粹的轶事传统。过去三十年的认知心理学和神经科学研究对它进行了系统的实证检验。
2017 年,Dresler 等人在 Neuron 发表了一项里程碑式的研究。他们对比了世界记忆锦标赛选手和普通人的大脑结构与功能,发现记忆冠军的大脑在解剖学上与普通人没有显著差异——他们并不拥有更大的海马体或更密集的神经连接。真正的差异在于功能连接模式:当记忆冠军使用位置法时,他们的大脑激活模式呈现出空间导航网络和记忆编码网络之间的高度协同。
更引人注目的是训练效果。研究者让普通人接受六周的位置法训练,每天 30 分钟。训练结束后,这些普通人的记忆表现接近了竞赛选手的水平,而且他们的大脑功能连接模式也向选手的模式靠拢。这意味着位置法不是一种天赋,而是一种可习得的认知技术——它通过激活大脑的空间导航系统来增强记忆编码。
为什么空间记忆如此特殊?进化心理学提供了一个合理的解释。在人类演化的绝大部分时间里,记住"什么在哪里"是生存的基本能力——食物来源的位置、水源的方向、危险的区域。这种空间记忆能力经过了数百万年的自然选择压力,已经深深刻入了大脑的硬件架构。而记忆一串抽象的序列信息——比如电话号码、购物清单、演讲要点——是近代文明才产生的需求,大脑并没有为此进化出专门的硬件支持。
位置法的精妙之处在于,它把一个大脑不擅长的任务(记忆序列信息)转化为了一个大脑极其擅长的任务(记忆空间位置)。它不是在对抗大脑的工作方式,而是在顺应它。
O'Keefe 和 Moser 夫妇因发现大脑中的"位置细胞"(place cells)和"网格细胞"(grid cells)而获得 2014 年诺贝尔生理学或医学奖。这些细胞构成了大脑的内部 GPS——一个精确的、自动运行的空间定位系统。位置法之所以有效,正是因为它征用了这个已经存在的、高度优化的神经系统来辅助一般性记忆。
你不需要建造新的记忆基础设施。你只需要把信息放进已经存在的基础设施里。
不是谣传:证据的三个层级
位置法偶尔被归入"流行心理学"或"记忆技巧"的范畴,甚至被质疑为"谣传"。这种质疑值得认真回应,因为 MemPalace 的整个设计前提建立在位置法的有效性之上。
证据可以按三个层级审视:
文献层:西塞罗《论演说家》(De Oratore,公元前 55 年)和昆体良《演说术原理》(Institutio Oratoria,公元 95 年)独立记载了同一套技术。这不是孤证——两位不同时代的罗马修辞学家描述了相同的操作方法,且中世纪修辞学教材持续传承了这一传统。西蒙尼德斯的宴会厅故事本身可能经过了文学加工,但位置法作为一种操作性技术的存在,有连续一千五百年的教学文献支撑。
神经科学层:如上文所述,Dresler 2017 的 Neuron 研究提供了 fMRI 级别的证据——位置法激活的脑区与空间导航脑区重叠,且效果可通过训练复现。O'Keefe/Moser 的位置细胞和网格细胞发现(2014 诺贝尔奖)提供了解剖学基础——大脑确实拥有专门的空间编码硬件,位置法征用的正是这套硬件。
竞技层:世界记忆锦标赛(World Memory Championships)自 1991 年举办至今,排名前列的选手几乎全部使用位置法或其变体。这不是天赋筛选的结果——Dresler 的研究证明了普通人经过六周训练即可接近选手水平。位置法是一种可教、可学、可验证的认知技术。
三个层级的证据相互独立且相互印证。位置法的有效性不依赖于西蒙尼德斯故事的历史真实性——即使那个故事完全是虚构的,神经科学和竞技数据仍然成立。
Ben 的古典学
理解了位置法的认知科学基础之后,Ben Sigman 的学术背景就不再是一个趣闻轶事,而是理解 MemPalace 设计逻辑的关键线索。
Ben 在 UCLA 获得古典学学位。古典学(Classics)的核心课程包括古希腊语和拉丁语原典阅读、古代修辞学理论和古代哲学。修辞学——特别是西塞罗和昆体良的修辞学传统——正是位置法的学术故乡。一个学过古典学的人不会把"记忆宫殿"当作一个比喻或一个流行心理学概念。对他来说,它是一个经过两千多年实践检验的、有完整理论基础的认知技术。
这个区别至关重要。如果"记忆宫殿"只是一个隐喻——意思是"把信息组织得整齐一点"——那么任何分层文件系统都可以声称自己是"记忆宫殿"。但位置法不是关于"整齐"的。它是关于空间结构作为检索索引的。每一个位置(locus)不是一个标签,而是一个坐标。你不是在标签中搜索信息,你是在空间中行走时遇到信息。
这个区别在 MemPalace 的设计中有非常具体的体现。它的核心想法不是"把所有记忆扔进一个大向量池里再盲搜",而是先用空间化的语义结构缩小候选范围。Wing、Hall、Room 这些名字表达的是一种拓扑直觉:你不是在标签列表里查找信息,而是在一个分区明确的建筑中逐步逼近目标。当前开源实现里,这个先验最直接地落在显式的 wing / room 过滤和按 room 聚合上;更完整的 Hall 叙事更接近设计语言,而不是默认运行时的每一步。
Ben 不是从软件工程的"分区"思想出发设计 MemPalace 的。他是从修辞学的"位置"思想出发的。这两条路径在表面上可能产生类似的结构,但在设计决策的细节上会导向非常不同的选择。一个数据库分区方案会追求均匀的数据分布和最优的查询计划;一个记忆宫殿会追求语义连贯性和认知自然性——即使这意味着某些"房间"比其他房间大得多。
从人类记忆到 AI 记忆
这里出现了一个需要认真回答的问题:位置法对人类大脑有效,并不意味着它对 AI 系统也有效。人类大脑有位置细胞和网格细胞,有经过演化优化的空间导航硬件。向量数据库没有这些。那么,把一个依赖大脑空间硬件的技术应用到 AI 记忆系统上,凭什么能成功?
答案在于:位置法的效力不仅来自空间硬件本身,更来自空间结构提供的先验约束。
考虑一个没有任何结构的记忆系统。你有 22,000 条记忆(这是 MemPalace 实际基准测试中使用的数据规模),存储在一个向量数据库中。当你搜索一个查询时,系统需要在 22,000 个向量中找到最相关的几条。这个搜索完全依赖于向量之间的余弦距离。
问题是,在高维向量空间中,余弦距离的区分度会随着维度增加而退化——这就是所谓的"维度灾难"(curse of dimensionality)。当嵌入维度达到 384 维(all-MiniLM-L6-v2 的默认维度),很多语义上不同的文本之间的距离差异变得非常小。搜索结果中排名第一和排名第十的文档之间的距离差可能只有 0.02。在这样的精度下,一个"几乎正确"的结果和一个完全正确的结果之间的距离差异可能被噪声淹没。
现在考虑一个有结构的记忆系统。同样的 22,000 条记忆被组织在 8 个 Wing 中,每个 Wing 包含若干 Room。无论这个结构先验是由用户显式提供,还是由更高层的路由器近似推断出来,只要检索首先发生在某个语义子空间里,候选集就会显著缩小。假设目标 Wing 包含 2,750 条记忆(22,000 / 8),搜索空间缩小到原来的 1/8。
但关键不在于搜索空间缩小了——一个随机的 8 分区方案也能做到这一点。关键在于结构是语义连贯的。同一个 Wing 中的记忆在语义上彼此相关,而不同 Wing 中的记忆在语义上相对正交。这意味着在 Wing 内部做向量检索时,干扰项(semantically similar but irrelevant documents)大量减少。你不再需要在 22,000 个点中区分微小的距离差异——你只需要在 2,750 个语义相关的点中做区分,而在这个子空间中,正确结果和错误结果之间的距离差异被显著放大了。
这就是位置法在 AI 系统中的等价物:空间结构不是帮助"记住"信息(信息本来就存储在向量数据库中),而是帮助"找到"信息。 它通过引入一个与内容正交的组织维度,降低了检索任务的难度。
在人类大脑中,这个正交维度是物理空间(建筑的房间)。在 MemPalace 中,这个正交维度是语义拓扑(Wing/Hall/Room 的层级结构)。底层机制不同,但信息论上的效果是相同的:结构作为先验,减少了检索过程中的不确定性。
三层深度:从隐喻到机制到工程
对"记忆宫殿"这个概念,可以在三个层次上理解它。
第一层:隐喻。 最浅的理解是把它当作一个名字——"我们的系统叫记忆宫殿,因为它把信息组织成一个建筑的样子"。这一层没有任何实质内容。任何树状结构都可以被称为"宫殿"。
第二层:认知原理。 更深一层的理解是认识到位置法揭示了一个关于记忆和检索的普遍原理:空间结构降低检索成本。这个原理不依赖于人类大脑的特殊硬件——它是一个信息论层面的洞见。只要检索系统面临"在大量候选项中找到正确答案"的问题,引入一个正交的组织维度就能降低这个问题的难度。
第三层:工程约束。 最深层的理解是把位置法的原理翻译成具体的工程约束:Wing 的边界必须是语义边界而非任意分区。Hall 的分类必须是认知类别而非数据库索引。Room 的命名必须是人类可理解的概念节点而非哈希值。隧道(Tunnel)必须是同一概念在不同领域中的自然涌现,而非人工定义的链接。
这三个层次的递进关系解释了为什么其他系统没有做到同样的事情。大多数 AI 记忆系统停留在第一层——它们可能也有"分区"或"分类",但这些分区和分类是为了数据库性能而设计的,不是为了检索精度而设计的。MemPalace 的设计从第二层出发(认知原理 -> 信息论洞见),落地在第三层(具体的工程约束),然后用第一层的隐喻(Wing/Hall/Room)来命名这些约束。
顺序不同,结果就不同。
从概念到代码
本章没有讨论任何源代码——这是有意的。位置法的核心洞见是一个与实现无关的原理:空间(或类空间)结构作为检索的先验约束,能够显著降低检索任务的难度。这个原理在人类大脑中通过位置细胞和网格细胞实现,在 MemPalace 中通过 Wing/Hall/Room 的层级元数据实现,但原理本身比任何一种实现都更基本。
下一章将进入实现层面:Wing、Hall、Room、Closet、Drawer 这五层结构各自是什么,为什么这样设计,以及每一层的设计权衡是什么。如果本章回答的是"为什么空间结构有效",那么下一章回答的是"MemPalace 如何把空间结构变成工程现实"。
这里有一个值得预告的关键设计决策:MemPalace 的五层结构不是从上往下设计的("我们需要一个五层架构"),而是从检索需求倒推出来的。每一层都对应着一个检索失败模式:没有 Wing,不同领域的语义噪声会互相干扰;没有 Room,同一主题里的概念边界会变模糊;而 Hall/Closet 这些更高层分类,则更多表达了系统想要追求的认知组织方式,而不一定在当前开源实现里全部以硬元数据或默认查询步骤出现。
西蒙尼德斯在公元前五世纪发现,人类的空间记忆可以被征用来增强序列记忆。两千五百年后,同样的原理以一种西蒙尼德斯无法预见的方式——通过向量数据库的元数据过滤——在 AI 系统中被重新验证。这不是因为 AI 和人类大脑工作方式相同,而是因为两者面临的检索问题在信息论层面是同构的。
记忆宫殿不是一个比喻。它是一个方法论。
第5章:Wing / Hall / Room / Closet / Drawer
定位:MemPalace 五层结构的设计动机、实现细节与工程权衡——从源码中理解每一层为什么存在、为什么是这个形状。
五层,不多不少
上一章建立了核心论点:空间结构作为检索先验能够显著提升信息检索精度。但"空间结构"是一个抽象概念——它可以是两层的(分区/文档),可以是十层的(层层嵌套的分类体系),也可以是完全扁平的(一个大型向量空间加元数据标签)。MemPalace 选择了五层。这个选择不是任意的。
五层结构是:Wing(翼) -> Hall(厅) -> Room(房间) -> Closet(壁橱) -> Drawer(抽屉)。每一层对应一个不同粒度的语义分区,每一层解决一个不同的检索失败模式。
在进入逐层分析之前,先看整体架构:
graph TD
P[Palace] --> W1[Wing: wing_kai]
P --> W2[Wing: wing_driftwood]
P --> W3[Wing: wing_priya]
W1 --> H1[Hall: hall_facts]
W1 --> H2[Hall: hall_events]
W1 --> H3[Hall: hall_discoveries]
W1 --> H4[Hall: hall_preferences]
W1 --> H5[Hall: hall_advice]
H2 --> R1[Room: auth-migration]
H2 --> R2[Room: oauth-debugging]
R1 --> C1[Closet: 压缩摘要]
C1 --> D1[Drawer: 原始对话文本]
C1 --> D2[Drawer: 原始对话文本]
这幅图展示的是从 Palace 到 Drawer 的概念路径。一个查询"Kai 上周在 auth 迁移上做了什么",在认知模型上会沿着这条路径导航:首先进入 wing_kai(限定人物),然后进入 hall_events(限定记忆类型为事件),然后到达 auth-migration(限定具体概念),最后从壁橱中找到指向原始文本的抽屉。需要补一句实现口径上的区分:当前 MCP/CLI 搜索显式暴露的是 wing 和 room 过滤;Hall 和 Closet 在 v3.0.0 中更多是解释设计的中间层,而不是独立的查询参数。
每一步都在做同一件事:缩小搜索空间,同时保持语义连贯性。
第一层:Wing——语义边界
Wing 是什么
Wing 是最粗粒度的组织单元。每个人物、项目或主题领域拥有自己的 Wing。在 MemPalace 的 AAAK 规范中,预定义的 Wing 包括:
wing_user, wing_agent, wing_team, wing_code,
wing_myproject, wing_hardware, wing_ue5, wing_ai_research
(mcp_server.py:112)
但 Wing 并不限于这些预定义名称。config.py 中的配置系统允许用户定义任意 Wing:
DEFAULT_TOPIC_WINGS = [
"emotions", "consciousness", "memory",
"technical", "identity", "family", "creative",
]
(config.py:14-22)
在搜索层面,Wing 是一个 ChromaDB 的 where 过滤条件。当你指定 wing="wing_kai" 进行搜索时,searcher.py 构建如下过滤器:
where = {"wing": wing}
(searcher.py:33)
这意味着向量检索只在属于 wing_kai 的文档中进行——其他 Wing 的文档完全不参与距离计算。
为什么这样设计
Wing 的设计动机是解决跨领域语义干扰问题。
考虑一个具体场景。你在一个项目(Driftwood)中讨论了"auth 迁移到 Clerk"的决策,同时在另一个项目(Orion)中也讨论了 auth 相关的话题。如果没有 Wing 分隔,搜索"我们为什么选择 Clerk"可能会同时返回两个项目的结果——因为在向量空间中,两段关于 auth 的讨论确实在语义上相近。但你的意图明确指向 Driftwood。
Wing 通过硬过滤(不是软加权)消除了这种干扰。这是一个有代价的设计选择——如果用户确实想跨项目搜索 auth 相关的所有内容,他们需要省略 Wing 过滤。但 MemPalace 的基准测试表明,在绝大多数真实查询中,用户确实只关心一个特定领域内的信息。
权衡
Wing 使用硬过滤而非软加权,这意味着:
优势:搜索空间的缩减是确定性的。如果 Palace 有 8 个 Wing,指定 Wing 后搜索空间大约缩减到 1/8。在 22,000 条记忆的规模下,这意味着从 22,000 缩小到约 2,750——向量检索的精度在较小的候选集上显著提升。
代价:如果 Wing 分配错误,目标文档会被完全排除。这不是"排名下降"的问题——是"完全看不到"的问题。这就是为什么 Wing 的分配必须是高置信度的决策,通常基于明确的元数据(文件来自哪个项目目录、对话中提到了哪个人名),而不是模糊的语义推断。
第二层:Hall——认知分类
Hall 是什么
Hall 是 Wing 内部的第二级分区,按照记忆的认知类型进行分类。MemPalace 定义了五种固定的 Hall:
hall_facts — 已确定的事实和决策
hall_events — 事件、会议、里程碑
hall_discoveries — 突破、新发现、洞见
hall_preferences — 偏好、习惯、观点
hall_advice — 建议、推荐、解决方案
(mcp_server.py:111)
这五种 Hall 在每一个 Wing 中都存在。它们是"走廊"——连接同一个 Wing 内的不同 Room,同时按认知类型给记忆打上标签。
在宫殿图(palace graph)中,Hall 作为边的属性出现。palace_graph.py 在构建图时将每个 Room 关联到它所属的 Hall:
room_data[room]["halls"].add(hall)
(palace_graph.py:60)
在图的边中,Hall 描述了连接两个 Wing 的隧道所经过的"走廊"类型:
edges.append({
"room": room,
"wing_a": wa,
"wing_b": wb,
"hall": hall,
"count": data["count"],
})
(palace_graph.py:75-84)
为什么固定五种
五种 Hall 的选择不是随意的——它反映了一个关于人类认知分类的假设:人们倾向于用相对固定的几种方式对记忆进行分类。
你记住的每一件事,大致可以归入以下类别之一:它是一个事实("我们用 PostgreSQL")、一个事件("昨天的部署出了问题")、一个发现("原来连接池设置太小了")、一个偏好("我更喜欢用 Terraform 而不是 Pulumi")、还是一个建议("下次遇到这种情况应该先检查日志")。
这个分类体系的关键特征是互斥且穷举。同一条记忆通常只属于一种类型——"我们决定用 Clerk"是一个事实,不是一个事件;"Kai 推荐了 Clerk"是一条建议,不是一个偏好。少数边界情况可能模糊(一个发现可能同时也是一个事实),但绝大多数记忆的分类是清晰的。
为什么不是三种?因为三种(比如事实/事件/观点)太粗糙——"Kai 推荐了 Clerk"和"我更喜欢 Clerk"在三分类下都是"观点",但它们的查询意图完全不同。一个是关于"谁说了什么"(回溯建议来源),另一个是关于"我的立场是什么"(确认偏好)。
为什么不是十种?因为分类越细,分类决策的准确率就越低。五种是一个在区分度和分类准确度之间的平衡点。每种 Hall 有足够明确的语义边界,使得自动分类(无论是基于关键词还是基于 LLM)能达到较高的准确率。
权衡
Hall 的固定性既是它的优势也是它的局限。
优势:因为 Hall 的类型是预定义的,所有 Wing 的内部结构是一致的。这使得跨 Wing 的比较变得可能——你可以对比 wing_kai/hall_advice 和 wing_priya/hall_advice,看不同人对同一话题给出了什么建议。如果每个 Wing 的内部分类方案不同,这种比较将无法实现。
局限:五种 Hall 可能无法覆盖所有类型的记忆。例如,"情感记忆"("当时我对这个决定感到非常焦虑")和"元认知记忆"("这次调试让我意识到我对连接池的理解有误")可能不完全适合任何一种现有 Hall。config.py 中的 DEFAULT_HALL_KEYWORDS 实际上定义了一组不同于五种 Hall 的分类维度(emotions, consciousness, memory 等),这暗示了系统在演化过程中曾经考虑过不同的分类方案。
第三层:Room——命名概念节点
Room 是什么
Room 是宫殿中最重要的语义单元。每个 Room 代表一个命名的概念——一个足够具体、足够独立、值得拥有自己空间的想法。
Room 使用 slug 格式命名:连字符分隔的小写英文字符串。例如:
auth-migration
chromadb-setup
gpu-pricing
riley-college-apps
(mcp_server.py:113)
在搜索层面,Room 是与 Wing 平行的另一个过滤维度。当同时指定 Wing 和 Room 时,searcher.py 构建一个组合过滤器:
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
(searcher.py:30-31)
在宫殿图中,Room 是图的节点。palace_graph.py 的 build_graph() 函数遍历 ChromaDB 中的所有元数据,为每个 Room 构建一个节点记录:
room_data = defaultdict(lambda: {
"wings": set(), "halls": set(),
"count": 0, "dates": set()
})
(palace_graph.py:47)
每个 Room 节点记录了它出现在哪些 Wing 中、属于哪些 Hall、包含多少条记忆、以及最近的日期。当一个 Room 出现在多个 Wing 中时,它就形成了一个 Tunnel——这是下一章的主题。
为什么用 slug
更准确地说,MemPalace 鼓励 Room 使用 slug 风格命名,而不是在运行时严格强制某一种唯一格式。AAAK 规范里给出的例子是连字符风格(auth-migration、gpu-pricing),但当前代码对 room 元数据本身没有额外校验,交互式新增 room 时甚至会把空格转成下划线。也正因为如此,这里的讨论应理解为"推荐的工程命名风格",而不是"代码已经强制执行的规范"。
Room 名称使用 slug 风格(auth-migration 而非 "Auth Migration" 或带空格的自由文本)有三个工程原因:
第一,slug 是无歧义的标识符。 它不包含空格、大写字母、特殊字符或日期后缀。这意味着同一个概念在不同 Wing 中的 Room 名称是完全相同的字符串——auth-migration 就是 auth-migration,不会因为一个 Wing 写成 "Auth Migration" 而另一个写成 "auth_migration" 而导致隧道检测失败。
第二,slug 是人类可读的。 不同于哈希值或数字 ID,slug 携带语义信息。当你看到 gpu-pricing 这个 Room 名称时,你立即知道它是关于 GPU 定价的。这对于调试、日志分析和用户界面展示都至关重要。
第三,slug 是可组合的。 在 ChromaDB 的元数据系统中,slug 可以直接用作 where 过滤条件的值,不需要任何编码或转义。在文件系统中,slug 可以直接作为目录名使用。在 URL 中,slug 可以直接出现在路径中。这种通用性减少了在不同系统之间传递 Room 标识时的摩擦。
Room 的涌现性
一个关键的设计决策是:Room 不是预先定义的,而是从数据中涌现的。没有一个"Room 列表"规定你只能有哪些 Room。当 MemPalace 挖掘你的对话时,它根据对话内容创建 Room——如果你讨论了 GraphQL 迁移,就会出现一个 graphql-switch Room;如果你讨论了 Riley 的大学申请,就会出现一个 riley-college-apps Room。
这与 Hall 的设计形成了鲜明对比:Hall 是预定义的、固定的、全局一致的;Room 是动态的、数据驱动的、每个 Wing 可以不同的。这个对比反映了一个深层的设计直觉:记忆的类型是有限的,但记忆的内容是无限的。 Hall 编码类型(你在记住什么样的东西),Room 编码内容(你在记住什么东西)。类型可以预定义,内容必须从数据中生长。
第四层:Closet——压缩的入口
Closet 是什么
在五层模型中,Closet 是一个中间层,位于 Room 和 Drawer 之间。作为概念层,它包含原始内容的压缩索引——足够让 AI 判断"这个抽屉里是否有我需要的信息",但不包含原始文本的全部细节。
在当前的实现中,Closet 还没有变成一个显式的数据结构。它的功能主要通过 ChromaDB 的嵌入向量和元数据系统隐式实现。每条记忆的嵌入向量就是它的"壁橱门上的标签"——搜索引擎通过比对嵌入向量来决定是否"打开这个壁橱"查看里面的抽屉。searcher.py 的 search_memories() 函数返回的结果中包含原始文本(Drawer 内容)和少量元数据(当前可见的 Closet 线索):
hits.append({
"text": doc, # Drawer: 原始文本
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": ..., # Closet: 来源信息
"similarity": ..., # Closet: 匹配度
})
(searcher.py:128-134)
README 中提到了 AAAK 压缩将在未来版本中被引入 Closet 层:"In our next update, we'll add AAAK directly to the closets, which will be a real game changer --- the amount of info in the closets will be much bigger, but it will take up far less space and far less reading time for your agent."
为什么需要中间层
Closet 的存在解决了一个信息检索中的经典问题:你需要在"读所有内容"和"只看标题"之间找到一个平衡点。
如果 AI 需要读取 Room 中每一个 Drawer 的完整内容才能判断相关性,那么当一个 Room 包含数百条记忆时,token 消耗会变得不可接受。但如果 AI 只看 Room 名称就做出判断,精度又不够——auth-migration 这个 Room 可能包含关于迁移原因、迁移过程、迁移中遇到的 bug 和迁移后的回顾等完全不同的内容。
Closet 提供了一个中间精度的视图:它告诉 AI "这个壁橱里大概有什么",让 AI 决定是否要打开抽屉查看原文。在当前 v3.0.0 中,这个视图主要由 embedding + metadata 近似承担;README 所描述的"把 AAAK 直接放进 Closet"则属于下一阶段,让这个中间层从隐式索引进一步升级为显式的压缩表达。
第五层:Drawer——原始真相
Drawer 是什么
Drawer 是 MemPalace 中存储原始内容的最底层单元。每个 Drawer 包含一段原文——对话的一个片段、文档的一个章节、代码的一个注释。MemPalace 的核心承诺是:Drawer 中的内容永远是原始的、逐字的、未经任何摘要或修改的。
在 MCP 服务器中,添加一个 Drawer 的工具函数明确要求内容是"verbatim"的:
"content": {
"type": "string",
"description": "Verbatim content to store "
"--- exact words, never summarized",
},
(mcp_server.py:626-629)
当添加一个新 Drawer 时,系统会先进行重复检查——如果内容与已有 Drawer 的相似度超过 90%,添加会被拒绝:
dup = tool_check_duplicate(content, threshold=0.9)
if dup.get("is_duplicate"):
return {
"success": False,
"reason": "duplicate",
"matches": dup["matches"],
}
(mcp_server.py:259-265)
每个 Drawer 的 ID 包含 Wing、Room 和内容哈希,确保唯一性:
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5(
(content[:100] + datetime.now().isoformat()
).encode()).hexdigest()[:16]}"
(mcp_server.py:267)
为什么必须是原始的
这是 MemPalace 最核心的设计决策之一,也是它与绝大多数竞争系统的根本分歧点。
主流 AI 记忆系统的典型做法是:在存储阶段就让 LLM 提取"重要信息"。Mem0 使用 LLM 提取事实;Mastra 使用 GPT 观察对话并生成结构化记录。这些系统在存储阶段就做了一个不可逆的决策——什么是"重要的"。
问题在于,"重要性"是上下文相关的。在存储时看起来不重要的细节("Kai 提到他之前在一家用 Auth0 的公司工作过"),在未来的某个查询中可能变得至关重要("谁有 Auth0 的实际使用经验?")。一旦存储阶段丢弃了这个细节,它就永远无法被检索到。
MemPalace 的立场是:存储一切,让检索阶段决定什么是重要的。 Drawer 中保存原始文本,Closet 提供快速导航,Wing/Hall/Room 提供结构化过滤——但信息本身永远不被修改或丢弃。
MCP API 中的五层结构
在 MCP 服务器中,五层结构通过一组工具暴露给 AI 智能体:
mempalace_list_wings — 列出所有 Wing(第一层)
mempalace_list_rooms — 列出 Wing 内的 Room(第三层)
mempalace_get_taxonomy — 完整的 Wing -> Room -> 计数树
mempalace_search — 搜索,可选 Wing/Room 过滤
mempalace_add_drawer — 添加原始内容到指定 Wing/Room
(mcp_server.py:441-637)
tool_get_taxonomy() 函数构建了完整的层级视图:
for m in all_meta:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
if w not in taxonomy:
taxonomy[w] = {}
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1
(mcp_server.py:163-168)
返回的 taxonomy 对象是一个嵌套字典:{wing: {room: count}}。这让 AI 智能体可以在一次调用中了解整个宫殿的结构,然后决定在哪个 Wing 和 Room 中搜索。
注意一个微妙的设计决策:MCP API 直接暴露了 Wing 和 Room,但没有单独暴露 Hall。Hall 作为元数据存在于 Drawer 中,但不是一个独立的过滤维度。这反映了一个务实的判断:在当前的使用模式中,Wing + Room 的组合已经提供了足够的搜索精度(+34%),Hall 的额外过滤收益相对较小,但会增加 API 的复杂性和分类错误的风险。
设计的完整图景
graph LR
subgraph "搜索路径"
Q[查询] --> WF{Wing 过滤}
WF --> RF{Room 过滤}
RF --> VS[向量搜索]
VS --> CL[Closet 匹配]
CL --> DR[Drawer 返回原文]
end
subgraph "精度提升"
A[全量搜索: 60.9%] --> B[+Wing: 73.1%]
B --> C[+Hall: 84.8%]
C --> D[+Room: 94.8%]
end
五层结构的检索效果是累积的——每增加一层结构约束,R@10 从 60.9% 逐步提升到 94.8%,总计 +34 个百分点(详见第 7 章的完整基准测试分析)。这不是因为每一层都做了"更精细的过滤"——而是因为每一层都消除了一类特定的干扰源。Wing 消除跨领域干扰,Hall 消除跨类型干扰,Room 消除跨概念干扰。
这三种干扰在向量空间中的表现是不同的。跨领域干扰是最强的(两个不同项目的 auth 讨论在语义上高度相似),跨类型干扰是中等的(同一项目中关于 auth 的事实和关于 auth 的建议语义相关但不相同),跨概念干扰是最弱的(同一项目中 auth 和 billing 的讨论语义相关度较低)。这就解释了为什么每一层带来的提升是递减的:12% -> 12% -> 10%。越容易区分的干扰源,越不需要结构约束来消除。
回到古希腊
五层结构的设计可以被理解为西蒙尼德斯位置法的一个高保真翻译。
在位置法中,你首先选择一栋建筑(Wing),然后进入一层楼或一个功能区域(Hall),然后到达一个具体的房间(Room),然后注意到房间中的一个特定物品(Closet),最后从物品中取出你需要的信息(Drawer)。
MemPalace 没有试图"改进"位置法——它试图尽可能忠实地将位置法的结构翻译成可计算的形式。Wing 不是"数据库分区"——它是"建筑的一翼"。Room 不是"文件夹"——它是"你在脑海中行走时会经过的一个特定位置"。这些命名不是装饰性的隐喻——它们约束着设计决策。
当一个工程师听到"数据库分区"时,他会追求均匀的数据分布。当他听到"建筑的一翼"时,他会接受不同的翼有不同的大小——因为在真实建筑中,厨房和卧室不需要一样大。这种微妙的认知框架差异会影响数十个后续的设计选择,最终导向一个与纯工程驱动的设计显著不同的系统。
下一章将讨论五层结构中最独特的涌现性特征——隧道(Tunnel):当同一个 Room 出现在多个 Wing 中时,自动形成的跨领域连接。
第6章:隧道——跨领域发现
定位:Tunnel 机制的设计与实现——一个零成本的图构建策略如何从 ChromaDB 的元数据中自动涌现出跨领域的知识连接。
同一个房间,不同的翼
上一章描述了 Wing、Hall、Room 如何逐层缩小搜索空间。但这些层级结构有一个固有的副作用:它们倾向于创建孤岛。如果所有搜索都限定在单一 Wing 内,你就永远无法发现"Kai 在 auth 迁移上的经验"和"Driftwood 项目的 auth 迁移决策"之间的联系——因为它们分属不同的 Wing。
MemPalace 用一个极其简洁的机制解决了这个问题:隧道(Tunnel)。
隧道的定义只有一句话:当同一个 Room 出现在两个或更多 Wing 中时,这些 Wing 之间就自动形成了一条隧道。 不需要任何人工链接,不需要任何额外的索引构建,不需要任何 LLM 推理。同名即连接。
graph LR
subgraph wing_kai
R1[auth-migration]
end
subgraph wing_driftwood
R2[auth-migration]
end
subgraph wing_priya
R3[auth-migration]
end
R1 ---|tunnel| R2
R2 ---|tunnel| R3
R1 ---|tunnel| R3
style R1 fill:#4a9eff,color:#fff
style R2 fill:#4a9eff,color:#fff
style R3 fill:#4a9eff,color:#fff
在这个例子中,auth-migration 这个 Room 出现在三个 Wing 中——wing_kai(Kai 的个人经验和工作记录)、wing_driftwood(项目级别的决策和进展)和 wing_priya(Priya 作为技术负责人的审批和建议)。三个 Wing 通过这个共享的 Room 自动形成了三条隧道。
README 中的例子清晰地展示了这种连接的语义含义:
wing_kai / hall_events / auth-migration
-> "Kai debugged the OAuth token refresh"
wing_driftwood / hall_facts / auth-migration
-> "team decided to migrate auth to Clerk"
wing_priya / hall_advice / auth-migration
-> "Priya approved Clerk over Auth0"
同一个话题(auth 迁移),三个视角(执行者、项目、决策者),三种记忆类型(事件、事实、建议)。隧道把这些视角连接起来,让你可以从任何一个起点出发,发现其他相关的记忆。
但这里也要补一句实现口径上的区分:这组例子来自 README 的 hall-rich 宫殿叙事。当前公开源码里,projects / convos 两条主摄入链路稳定写入的是 wing、room、source_file 一类元数据;hall 只有在 diary 等少数写入路径、或者 benchmark/README 里的实验性结构中才会稳定出现。也就是说,Tunnel 在当前产品里的最小成立条件是"同名 room 跨 wing 复现",而不是"每条 drawer 都已经带着完整 hall 坐标"。
图从元数据构建
隧道的实现依赖于 palace_graph.py 中的 build_graph() 函数。这个函数是整个隧道机制的核心,它的设计体现了一个关键的工程洞见:不需要额外的图数据库。
build_graph() 的工作方式是遍历 ChromaDB 中所有文档的元数据,从中提取 Room、Wing,并在元数据存在时顺带收集 Hall 信息,然后在内存中构建一个图。代码如下:
room_data = defaultdict(lambda: {
"wings": set(), "halls": set(),
"count": 0, "dates": set()
})
(palace_graph.py:47)
对每一条记忆记录,函数提取其元数据并更新对应 Room 的节点信息:
for meta in batch["metadatas"]:
room = meta.get("room", "")
wing = meta.get("wing", "")
hall = meta.get("hall", "")
if room and room != "general" and wing:
room_data[room]["wings"].add(wing)
if hall:
room_data[room]["halls"].add(hall)
room_data[room]["count"] += 1
(palace_graph.py:52-63)
注意 room_data[room]["wings"] 是一个 set。当同一个 Room 从不同的 Wing 被添加时,这个集合自然地积累了该 Room 跨越的所有 Wing。隧道的检测就是检查这个集合的大小是否大于 1:
for room, data in room_data.items():
wings = sorted(data["wings"])
if len(wings) >= 2:
for i, wa in enumerate(wings):
for wb in wings[i + 1:]:
for hall in data["halls"]:
edges.append({
"room": room,
"wing_a": wa, "wing_b": wb,
"hall": hall,
"count": data["count"],
})
(palace_graph.py:70-84)
这段代码的逻辑值得仔细分析。对于每一个跨越两个以上 Wing 的 Room,函数会先在 nodes 里记录它跨越了哪些 wing;如果该 Room 同时带有 hall 元数据,再进一步为每个 hall 生成 Wing 两两组合的边。如果一个 Room 出现在 3 个 Wing 中,就可能生成 3 条边(A-B, A-C, B-C);但这个"可能"依赖于 data["halls"] 非空。当前默认写入链路往往只有 wing/room,没有 hall,因此跨 wing 关系在 nodes 和 find_tunnels() 中仍然成立,但 edges 与 halls 列表可能为空。
零额外存储成本。 这是设计中最值得注意的一点。图不存储在 ChromaDB 中,也不存储在任何外部数据库中。它在每次需要时从 ChromaDB 的元数据动态构建。对当前产品运行时来说,真正稳定存在的是 wing 和 room;hall 更像可选增强字段,而不是所有写入路径都保证携带的坐标。隧道仍然可以从这些已有元数据中涌现出来,不需要额外的数据写入;只是当 hall 缺席时,图会退化成"按 room 跨 wing 连接"的更简版本。
这种设计的权衡是显而易见的:每次查询都需要重新构建图。在 22,000 条记忆的规模下,build_graph() 需要分批读取所有元数据(每批 1000 条),这意味着至少 22 次 ChromaDB 调用。对于实时交互场景,这可能引入可感知的延迟。但 MemPalace 的选择是接受这个延迟,换取零额外存储和零数据一致性维护成本。
BFS 遍历:从一个房间出发
知道了图的存在还不够——你需要能在图上行走。palace_graph.py 中的 traverse() 函数实现了广度优先搜索(BFS)遍历,让你可以从一个起始 Room 出发,发现所有可达的相关 Room。
def traverse(start_room, col=None, config=None,
max_hops=2):
(palace_graph.py:99)
遍历的逻辑是标准的 BFS,但连接关系的定义是独特的:两个 Room 之间有连接,当且仅当它们共享至少一个 Wing。
frontier = [(start_room, 0)]
while frontier:
current_room, depth = frontier.pop(0)
if depth >= max_hops:
continue
current_wings = set(current.get("wings", []))
for room, data in nodes.items():
if room in visited:
continue
shared_wings = current_wings & set(data["wings"])
if shared_wings:
visited.add(room)
results.append({
"room": room,
"hop": depth + 1,
"connected_via": sorted(shared_wings),
})
(palace_graph.py:128-154)
max_hops 参数(默认为 2)控制遍历的深度。设定为 2 意味着你可以发现"与起始 Room 直接共享 Wing 的 Room"(1 跳),以及"与那些 Room 又共享了另一个 Wing 的 Room"(2 跳)。两跳之内通常已经能覆盖所有在语义上有意义的连接;更远的连接往往太间接,失去了信息价值。
遍历结果按 (hop_distance, -count) 排序:
results.sort(key=lambda x: (x["hop"], -x["count"]))
return results[:50]
(palace_graph.py:157-158)
优先展示跳数最少的连接,在同等跳数下优先展示出现次数最多的 Room。出现次数高的 Room 通常是更重要的概念节点——它们积累了更多的记忆条目,意味着这个话题被更频繁地讨论。
隧道发现
除了从某个起点出发的图遍历,MemPalace 还提供了一个专门的隧道发现工具:find_tunnels()。
def find_tunnels(wing_a=None, wing_b=None,
col=None, config=None):
(palace_graph.py:161)
这个函数的目的不是导航——而是发现。它回答的问题是:"哪些话题连接了这两个领域?"
for room, data in nodes.items():
wings = data["wings"]
if len(wings) < 2:
continue
if wing_a and wing_a not in wings:
continue
if wing_b and wing_b not in wings:
continue
tunnels.append({
"room": room, "wings": wings,
"halls": data["halls"],
"count": data["count"],
})
tunnels.sort(key=lambda x: -x["count"])
(palace_graph.py:169-189)
你可以不指定任何 Wing(查看所有隧道),指定一个 Wing(查看与该 Wing 相关的所有隧道),或指定两个 Wing(查看这两个特定领域之间的桥接话题)。
在 MCP 服务器中,这个功能通过 mempalace_find_tunnels 工具暴露给 AI 智能体:
"mempalace_find_tunnels": {
"description": "Find rooms that bridge two wings "
"--- the hallways connecting different "
"domains.",
...
}
(mcp_server.py:571-581)
工具描述中将隧道称为"connecting different domains"的"hallways"。这个措辞反映了隧道的本质:它不是一个人工创建的索引或链接,而是当你在不同领域讨论相同话题时自然涌现的连接。
隧道的信息论意义
隧道机制看起来简单到近乎平凡——不就是"同名房间自动关联"吗?但这种简单性掩盖了一个深层的设计洞见。
在知识管理系统中,最有价值的信息通常不在领域内部,而在领域之间的交叉点。一个完全在 wing_code 内部的搜索可以告诉你"我们的 auth 模块怎么工作",但无法告诉你"为什么 auth 模块是这样设计的"——因为设计的原因可能记录在 wing_team 的某次会议纪要中,或者在 wing_priya 的某条技术建议中。
传统的知识管理系统处理这种跨领域连接的方式有两种:
手动链接。 让用户或管理员显式地创建跨领域的关联。这种方式精确但脆弱——它依赖人的记忆和勤勉,而随着数据量增长,维护这些链接的成本会指数级增加。
全局语义搜索。 放弃领域分区,在整个数据库中做向量检索。这种方式不需要维护链接,但会回到本书第四章讨论的问题——在大规模向量空间中的高维退化导致检索精度下降。
MemPalace 的隧道机制是第三条路:让结构自动产生连接。 你不需要手动标注"Kai 的 auth 经验和 Driftwood 的 auth 决策有关"——当你在两个不同的 Wing 中使用了相同的 Room 名称 auth-migration,这个关联就已经存在了。当前实现里,build_graph() 做的是对 room 元数据的字符串精确匹配;如果团队采用稳定的 slug 风格命名,这种匹配会更可靠,但这不是运行时自动保证的约束。
这种设计的前提是一个合理的假设:如果两个不同领域的记忆被归入了同一个 Room 名称,那么它们之间确实存在语义关联。 这个假设在绝大多数情况下成立——你不会偶然地在两个无关的领域使用完全相同的 Room 名称。
图统计:宫殿的全局视图
graph_stats() 函数提供了宫殿图的全局统计信息:
def graph_stats(col=None, config=None):
nodes, edges = build_graph(col, config)
tunnel_rooms = sum(
1 for n in nodes.values()
if len(n["wings"]) >= 2
)
return {
"total_rooms": len(nodes),
"tunnel_rooms": tunnel_rooms,
"total_edges": len(edges),
"rooms_per_wing": dict(wing_counts...),
"top_tunnels": [...],
}
(palace_graph.py:193-213)
这个统计视图让用户和 AI 智能体可以了解宫殿的整体拓扑:有多少个 Room,其中有多少个形成了隧道,Wing 之间的连接有多密集,最活跃的隧道是哪些。
top_tunnels 列表按 Wing 数量降序排列——出现在最多 Wing 中的 Room 排在前面。这些高连接度的 Room 通常代表用户最核心的关切——那些在多个项目、多个人际关系、多个时间段中反复出现的主题。
设计权衡的诚实清单
隧道机制的优雅不应掩盖它的局限性。以下是需要正视的权衡:
命名一致性依赖。 隧道检测完全依赖 Room 名称的精确匹配。如果一个 Wing 中用 auth-migration,另一个 Wing 中用 clerk-migration,即使它们讨论的是同一件事,也不会形成隧道。这对 Room 命名的一致性提出了很高的要求,但要注意:这是数据侧的命名纪律,不是当前运行时已经强制执行的 slug 规范。命名函数或人工校对必须足够稳定,才能让同一概念在不同上下文中获得相同的 Room 名称。
图的动态重建成本。 如前所述,每次图操作都需要重新从 ChromaDB 构建图。在大规模数据(数万条记忆)下,这意味着数十次数据库读取操作。一个可能的改进方向是引入图的缓存层——但这会引入缓存一致性的复杂性。
稠密隧道的噪声。 如果一个 Room 出现在所有 Wing 中(比如一个极为通用的概念如 general-discussion),它会形成过多的隧道连接,降低隧道的信息价值。build_graph() 通过过滤掉 room == "general" 来缓解这个问题(palace_graph.py:57),但对于其他高频但低信息量的 Room,目前没有系统性的过滤机制。
这些限制都是可解的工程问题,不是根本性的设计缺陷。隧道机制的核心价值——从已有的元数据中零成本地涌现跨领域连接——是完整的,不受这些实现层面的限制所动摇。
连接的价值
隧道机制在概念上完成了记忆宫殿从"建筑"到"网络"的转变。
在前两章描述的结构中,宫殿是一棵严格的层级树:Palace -> Wing -> Hall -> Room -> Closet -> Drawer。信息的组织是从上到下的,搜索是在树的某个子树中进行的。这种结构高效且清晰,但它是封闭的——每个子树是独立的岛屿。
隧道打破了这种封闭性。它在树的横向方向上添加了边,把一棵树变成了一个图。你不再只能在一个 Wing 内上下移动——你可以通过隧道从一个 Wing 跨越到另一个 Wing,发现在纯层级结构中不可见的连接。
这就是为什么 palace_graph.py 的文件头注释中将这个模块描述为"a navigable graph"——不是"a navigable tree"。树是层级的、确定性的、自上而下的;图是网络的、涌现的、可以从任意节点开始探索的。MemPalace 的五层结构提供了树的效率,隧道机制提供了图的发现能力。
下一章将用基准测试数据证明,这种"结构 + 连接"的组合带来的检索提升不是理论推演——它是可量化的、可复现的 34%。
第7章:34% 的检索提升不是巧合
定位:用数据证明结构即产品——34% 的检索精度提升从何而来,为什么它是可复现的,以及它对 AI 记忆系统设计的更广泛意义。
四个数字
在 22,000 条以上的真实对话记忆上进行基准测试,MemPalace 记录了以下 R@10(Recall at 10,前 10 条结果中包含正确答案的概率)数据:
| 搜索范围 | R@10 | 相对基线提升 |
|---|---|---|
| 全量搜索(无结构) | 60.9% | -- |
| Wing 内搜索 | 73.1% | +12.2% |
| Wing + Hall | 84.8% | +23.9% |
| Wing + Room | 94.8% | +33.9% |
graph LR
A["全量搜索<br/>R@10: 60.9%"] --> B["Wing 过滤<br/>R@10: 73.1%<br/>+12%"]
B --> C["Wing+Hall<br/>R@10: 84.8%<br/>+24%"]
C --> D["Wing+Room<br/>R@10: 94.8%<br/>+34%"]
style D fill:#2d5,color:#fff
这组数据需要被仔细解读。
60.9% 是基线——把所有记忆放在一个扁平的向量数据库中,不做任何结构化组织,直接用 ChromaDB 的默认嵌入模型(all-MiniLM-L6-v2)进行语义检索。这个基线代表了"纯向量搜索"的能力上限。
94.8% 是在使用了 Wing + Room 过滤后的结果。同样的数据,同样的嵌入模型,同样的检索算法——唯一的变量是搜索前施加了元数据过滤。从 60.9% 到 94.8%,提升了 33.9 个百分点,完全来自结构。没有更好的模型,没有更大的嵌入维度,没有 LLM 重排序。只是告诉搜索引擎"在这个 Wing 的这个 Room 里找"。
这组数据的可信度取决于三个因素:数据规模、测试方法和可复现性。22,000 条记忆不是一个小规模的玩具数据集——它代表了数月的真实使用积累。测试方法遵循标准的信息检索评估范式(R@K 指标)。至于可复现性,MemPalace 在 benchmarks/ 目录下提供了完整的基准测试运行脚本,任何人都可以在自己的数据上重复这些测试。
第一层提升:Wing 消除跨领域干扰
从 60.9% 到 73.1%,Wing 带来了 12.2 个百分点的提升。这是最大的单层提升,它的原因也是最直观的。
考虑一个查询:"我们为什么选择 Clerk 做 auth?"
在全量搜索中,ChromaDB 会在 22,000 条记忆中寻找与这个查询语义最相似的 10 条。问题在于,如果你在多个项目中都讨论过 auth 相关的话题——比如 Driftwood 选择 Clerk、Orion 使用 Auth0、个人项目用 Firebase Auth——这三个领域的 auth 讨论在向量空间中的位置会非常接近。三组记忆争夺 top-10 的位置,正确答案(Driftwood 选择 Clerk 的决策记录)可能被推到第 11 或第 12 位。
添加 Wing 过滤后(wing="wing_driftwood"),搜索空间缩小到 Driftwood 项目的记忆。三组语义相似的 auth 讨论中的两组被直接排除。正确答案不再需要和无关领域的结果竞争。
这个提升本质上是在利用先验知识。当用户或 AI 智能体知道它在问一个关于 Driftwood 的问题时,这个知识可以被编码为 Wing 过滤——将搜索问题从"在 22,000 条记忆中找正确答案"简化为"在约 2,750 条 Driftwood 记忆中找正确答案"。
向量搜索的理论基础是:正确答案在嵌入空间中应该与查询最近。但当候选集中包含大量"近但不正确"的干扰项时,这个理论假设就会失效。Wing 通过移除最强的干扰源——来自完全不同领域但语义相似的文档——恢复了这个假设的有效性。
第二层提升:Hall 区分记忆类型
从 73.1% 到 84.8%,Hall 在 Wing 的基础上又带来了 11.7 个百分点的提升。
这个提升更加微妙。即使在同一个 Wing 内部,不同类型的记忆之间也存在语义重叠。"我们决定用 Clerk"(事实)、"Kai 推荐了 Clerk 因为定价和开发者体验更好"(建议)、"上周三的会上敲定了 Clerk 的采用"(事件)——这三条记忆在向量空间中的距离可能非常近,因为它们都包含 "Clerk"、"auth"、"decision" 等关键词。
但查询的意图通常只指向其中一种类型。如果你问"我们为什么选择 Clerk",你要的是建议(hall_advice)或者事实(hall_facts),不是事件记录。如果你问"Clerk 是在哪次会议上确定的",你要的是事件(hall_events),不是技术建议。
Hall 过滤通过区分记忆类型来消除这种同领域内的干扰。五种 Hall(facts、events、discoveries、preferences、advice)对应了五种不同的查询意图模式。当搜索系统能够正确推断查询的类型意图时,它可以进一步缩小候选集,排除类型不匹配的结果。
值得注意的是,Hall 带来的提升(11.7%)与 Wing 带来的提升(12.2%)几乎相等。这意味着跨类型干扰的强度与跨领域干扰的强度大致相当——这是一个不太直觉的发现。你可能会认为来自完全不同领域的干扰应该远强于同一领域内不同类型的干扰,但向量空间中的距离分布并不总是符合人类的直觉。在高维空间中,同一领域内的不同类型的文本之间的距离差异可能和不同领域之间的距离差异一样小。
第三层提升:Room 精确定位概念
从 84.8% 到 94.8%,Room 带来了最后 10 个百分点的提升。
如果说 Wing 消除了领域干扰、Hall 消除了类型干扰,那么 Room 消除的是概念干扰。即使在同一个 Wing 的同一个 Hall 中,也可能存在多个不同的概念。wing_driftwood/hall_facts 中可能包含关于 auth 迁移的事实、关于数据库选型的事实、关于部署策略的事实和关于团队组织的事实。它们都是"事实",都属于"Driftwood",但它们关于不同的东西。
Room 通过命名概念节点(auth-migration、database-selection、deploy-strategy)将最后一级的语义歧义消除。当搜索限定在 wing_driftwood/room=auth-migration 时,候选集只包含关于 auth 迁移的记忆——这时向量搜索只需要在少量高度相关的文档中区分"最相关的几条",这是一个向量搜索擅长处理的问题。
Room 的 10% 提升虽然是三层中最小的,但它把检索精度从 84.8% 推到了 94.8%——越过了 90% 这个在工程实践中通常被视为"可用"门槛的分界线。从用户体验的角度看,84.8% 意味着大约每六次搜索就有一次找不到正确答案;94.8% 意味着大约每二十次才有一次未命中。这个差异在日常使用中是可感知的。
为什么结构有效:向量空间的高维退化
上面的分析解释了每一层"做了什么",但还没有回答一个更根本的问题:为什么仅靠元数据过滤就能带来这么大的提升? 元数据过滤不改变嵌入向量的质量,不改变距离计算的方法——它只是减少了参与比较的候选项数量。为什么减少候选项就能提升精度?
答案与高维向量空间的一个基本性质有关:维度灾难(curse of dimensionality)。
MemPalace 使用的默认嵌入模型 all-MiniLM-L6-v2 生成 384 维的向量。在 384 维空间中,一个被反复验证的现象是:随着数据集规模增大,数据点之间的距离分布趋向于集中——最近邻和最远邻之间的距离差异变得越来越小。
用更直观的方式表述:想象你站在一个 384 维空间的中心,周围有 22,000 个点。这些点到你的距离可能分布在 0.3 到 0.7 之间。现在你需要找到距离你最近的 10 个点。问题在于,在这个距离范围内,可能有几百个点的距离落在 0.31 到 0.35 之间——它们之间的距离差异小于测量噪声。在这个精度下,"排名第一"和"排名第五十"之间的距离差异可能只有 0.01——远小于有意义的区分阈值。
现在,如果你通过 Wing 过滤将候选集从 22,000 缩小到 2,750,距离分布的集中度会降低。在较小的候选集中,正确答案和最近的干扰项之间的距离间隔会增大。从信息论的角度说,你提高了信噪比——不是通过增强信号(更好的嵌入),而是通过减少噪声(移除无关候选项)。
这就是结构的价值:结构不是更好的搜索算法,而是更好的搜索前提条件。
结构作为先验
贝叶斯统计中有一个核心概念:先验(prior)。在观察数据之前,你对问题的答案有一个初始信念分布。先验越信息丰富(informative),你需要的数据越少就能得到准确的后验。
Wing/Hall/Room 结构在检索中扮演的正是先验的角色。
没有结构时,搜索系统的"先验"是均匀分布——22,000 条记忆中每一条都有相同的概率是正确答案。嵌入距离是唯一的证据来源。
有结构时,搜索系统的"先验"被大幅更新——在指定 Wing 后,只有约 1/8 的记忆有合理的概率是正确答案;在进一步指定 Room 后,可能只有几十条记忆在候选范围内。嵌入距离仍然是证据来源,但它现在只需要在一个小得多的候选集中做区分——这是一个容易得多的任务。
34% 的提升本质上是在量化先验信息的价值。当你告诉搜索系统"答案在这个 Wing 的这个 Room 里"时,你提供了大约 7-8 bit 的先验信息(从 22,000 缩小到几十条)。这些信息不来自更好的模型或更多的计算——它来自数据的组织方式。
这也解释了为什么这个提升是稳健的——它不依赖于嵌入模型的选择、查询的类型或数据的领域。只要以下条件成立,结构就能带来提升:
- 数据集足够大,使得全量搜索面临高维退化;
- 结构分区是语义连贯的,使得正确答案大概率落在正确的分区内;
- 分区之间的语义距离大于分区内部的语义距离。
这三个条件在绝大多数真实世界的 AI 记忆场景中都成立。
对照组:没有结构的系统
为了验证结构确实是关键变量,值得对比 MemPalace 与不使用结构的系统在同一基准测试上的表现。
在 LoCoMo 基准测试(1,986 个多跳问答对)上,不同系统的对比如下:
| 系统 | 方法 | R@10 | 备注 |
|---|---|---|---|
| MemPalace (session, 无结构) | 纯向量搜索 | 60.3% | 基线 |
| MemPalace (hybrid v5) | 向量 + 关键词 + 时间加权 | 88.9% | 混合评分 |
| MemPalace (hybrid + Sonnet rerank) | 混合 + LLM 重排序 | 100% | 所有类别全部满分 |
60.3% 的基线与上面提到的 60.9% 几乎一致——这不是巧合,而是验证了同一个规律:在万级规模的记忆集上,纯向量搜索的 R@10 大约在 60% 左右。
从 60.3% 到 88.9%(hybrid v5),提升了 28.6 个百分点。这个提升来自关键词重叠评分、时间加权和人名提升——本质上是在向量距离之外引入了额外的排序信号。这些信号不是结构性的(它们不依赖 Wing/Room 分区),而是启发式的。
从基线到 100%(加上 Sonnet rerank),总提升 39.7 个百分点。其中 LLM 重排序贡献了从 88.9% 到 100% 的最后 11.1 个百分点。
将这些数据与结构提升对比:
- 纯结构(Wing + Room 过滤):+34%
- 混合评分启发式:+28.6%
- LLM 重排序:+11.1%
结构提升和混合启发式的提升幅度在同一个数量级上。但两者的成本差异是巨大的:结构提升的计算成本为零(仅增加一个 where 子句),而混合启发式需要额外的文本处理、分词和评分计算。LLM 重排序更是需要 API 调用和额外的延迟。
结构是最廉价的精度来源。
可复现性
在基准测试领域,一个无法复现的结果等于不存在。MemPalace 在 benchmarks/ 目录下提供了完整的复现路径。
核心的基准测试脚本包括:
longmemeval_bench.py— LongMemEval 基准测试运行器locomo_bench.py— LoCoMo 基准测试运行器membench_bench.py— MemBench 基准测试运行器
每个脚本接受数据集路径和模式参数,输出标准格式的结果文件。benchmarks/BENCHMARKS.md 记录了从 96.6% 基线到完整 500 题上的 100% 改进过程,也同时公开了 held-out 450 上的 98.4% 作为更干净的泛化数字——不是只给一个最好看的营销结果,而是把不同口径的结论一起摆出来。
例如,从 96.6% 到 97.8% 的第一次改进(混合评分 v1),动机是发现了一类特定的失败模式:查询中包含精确术语(如 "PostgreSQL"、"Dr. Chen"),但纯嵌入相似度会将语义相近但术语不匹配的文档排在精确匹配之前。修复方法是在嵌入距离之上叠加关键词重叠的加权。
从 99.4% 到 100% 的最后一步,是先分析两种独立架构(hybrid v3 和 palace mode)共同失败的三个问题,再把定向修复落实到 hybrid v4:引号短语提取、人名加权和记忆/怀旧模式匹配。这种"分析失败 -> 设计修复 -> 验证效果"的循环是工程上可靠的改进方法——不是调参,而是理解失败原因。
结构即产品
本章的核心论点可以用一句话概括:在 AI 记忆系统中,数据的组织方式比检索算法的选择更重要。
34% 的提升不需要更好的嵌入模型——all-MiniLM-L6-v2 是一个 2020 年发布的、参数量较小的模型,远不是当前最先进的嵌入技术。它不需要 LLM 参与——整个提升过程中没有任何 API 调用。它不需要复杂的后处理——没有重排序、没有查询扩展、没有伪相关反馈。
它只需要三件事:
- 数据在存储时被赋予了有意义的元数据标签(wing、hall、room);
- 搜索时利用这些标签缩小候选集;
- 标签体系是语义连贯的——同一个 Wing 中的数据确实在语义上相关,不同 Wing 中的数据确实在语义上不同。
这三件事都不需要 AI。它们需要的是一个好的分类设计——而这个分类设计来自一个两千五百年前的认知技术。
MemPalace 的 README 中有一句话值得重复:"Wings and rooms aren't cosmetic. They're a 34% retrieval improvement. The palace structure is the product." 这不是营销口号——这是基准测试数据的直接总结。
当你的数据组织方式本身就是你的产品时,添加更好的算法是锦上添花,不是从零到一。34% 的提升是结构给你的起跑线。在这个起跑线之上,混合评分再加 28%,LLM 重排序再加 11%,最终达到 100%。但如果没有那个起跑线,你从 60% 开始——这意味着你需要用算法和 LLM 填补更大的差距,而这些都有成本。
结构是免费的。这就是它的意义。
下一步
本章和前三章共同完成了"记忆宫殿"部分的论述:从位置法的认知科学基础(第 4 章),到五层结构的设计与实现(第 5 章),到隧道机制的跨领域发现能力(第 6 章),到基准测试数据的验证(本章)。
但记忆宫殿只是 MemPalace 三大核心设计之一。结构解决了"如何找到信息"的问题,但还有另一个同样关键的问题没有回答:当你找到信息后,如何在极小的 token 预算内传递给 AI?
一个 Wing 中可能有数千条记忆。即使结构过滤将候选集缩小到了几十条,把这几十条完整文本全部塞进 AI 的上下文窗口仍然是昂贵的(可能需要数千甚至上万个 token)。你需要一种压缩方式——不是摘要(摘要会丢失信息),而是一种无损的、AI 可直接阅读的压缩编码。
这就是 AAAK 方言要解决的问题。第三部分将深入分析这种 30 倍压缩、零信息损失的 AI 专用语言是如何设计的。
第8章:压缩的约束空间
定位:本章是第三部分"AAAK 压缩语言"的起点。我们暂时离开宫殿的空间结构,转向一个完全不同的问题:如何将大量文本信息压缩到极小的 token 空间内,同时不丢失任何东西。本章不展示 AAAK 的具体语法(那是第 9 章的工作),而是通过约束满足分析,推导出"可行解"必须长什么样。
压缩问题的本质
前面几章讨论了记忆宫殿的空间结构如何将检索精度提升 34%。但结构只解决了"从哪里找"的问题,没有解决"装得下多少"的问题。
让我们回到序章中的那个核心数字:六个月的日常 AI 使用产生约 1950 万个 token。即便经过宫殿结构的组织,当 AI 需要在会话开始时加载"这个人是谁、在做什么项目、和谁一起工作"这样的基础上下文时,数据量仍然庞大得令人绝望。
这个问题的直觉表述很简单:我们能不能把一段 1000 token 的自然语言描述,压缩成 30-120 个 token,同时保证一个语言模型读完压缩结果后能准确还原出所有原始信息?
这个看起来不可能的要求,正是 AAAK 试图回答的。
但在讨论 AAAK 如何做到之前,有必要先严格定义"做到"意味着什么。工程中最常见的错误不是解法不够好,而是问题没有被正确定义。一个模糊的目标会产生无限多个看似合理的方案,而每一个最终都会在某个未被预见的维度上失败。
四个不可妥协的约束
MemPalace 的压缩需求可以被精确表述为四个约束条件。它们不是"最好能满足"的偏好,而是"必须同时满足"的硬性要求。任何一个不满足,整个方案就不可用。
约束一:30 倍压缩比
六个月 1950 万 token 的上下文,即使经过记忆层级的过滤(第 14-15 章将详细讨论),L1 层——也就是 AI 每次启动时必须加载的关键事实层——仍然需要容纳大量信息。
MemPalace 的 README 宣称 AAAK 可以达到 30 倍压缩。这个数字需要拆解。这里还需要先区分两件事:一是 README 和书里讨论的设计目标,二是当前开源仓库里 dialect.compress() 这类 plain-text 压缩器的具体实现。前者在讨论"什么样的压缩语言才有机会满足约束",后者则是一个带明显启发式筛选的当前版本。
AAAK 实际上做了两件不同的事:
- 结构化压缩目标(去停用词、缩写实体名、管道分隔结构化)——这是 README 和本章分析的理想方向:尽量用更紧凑的形式表达同一批事实断言。短示例里,它可以带来 5-10 倍 量级的压缩。
- 启发式筛选实现(关键语句提取、主题提取、实体/情感/标志截断)——这是当前 plain-text
compress()的真实路径:它会从长文本中选择"值得保留的内容",而不是逐断言做形式等价转换。
对于冗余度高的长对话记录(充满 "well, I think that maybe we should consider..." 这样的表达),两步叠加确实可以达到 30 倍。但对于已经比较紧凑的技术描述,压缩比通常在 5-10 倍。
因此,"30 倍"是长对话场景的上界,不是所有文本的典型值。更重要的是,当前实现中的筛选步骤不只发生在 _extract_key_sentence():topics、entities、emotions、flags 也都带有 top-k 或启发式裁剪。也就是说,今天仓库里的 plain-text AAAK 更接近"高压缩索引"而不是"严格无损编码"——尽管原文仍然保存在 Drawer 中可以回溯。
这个区分对于理解 AAAK 至关重要:README 里的无损 AAAK 是设计目标;当前 open-source plain-text compressor 是有损启发式索引;30 倍来自结构化表达与内容筛选共同作用。
约束二:事实断言的完整性
这是与所有"摘要式"记忆系统的关键分界线。
当 Mem0 或 Zep 的系统从你的对话中提取"用户偏好 Postgres"时,它丢掉了你花了两个小时解释为什么从 MongoDB 迁移过来的全部上下文。当一个 LLM 被要求"总结这段对话的要点"时,它必须做出判断——哪些是要点,哪些不是——而这个判断本身就是一种不可逆的信息丢弃。
MemPalace 的立场是:理想中的 AAAK 设计目标 应该尽可能保留事实断言。README 使用"零信息损失"(lossless)来描述这个目标,但需要给它一个精确的操作性定义;同时也要诚实地区分这个目标与当前 heuristic 实现。
在 AAAK 的语境中,"无损"更适合作为一个设计约束定义:对于原始文本中的每一个事实断言(谁、做了什么、什么时候、为什么、结果如何),理想的 AAAK 编码都应当存在对应表示,使得一个合格的语言模型能够正确还原该断言。
但这个定义有一个重要的边界:它描述的是理想约束,不是对当前 dialect.compress() 的逐行事实陈述。今天的 plain-text 实现会在 key sentence、topics、entities、emotions、flags 上做筛选;被筛掉的事实断言就不会出现在 AAAK 输出中。MemPalace 的安全网是:原文始终保存在 Drawer 中,AAAK 更像一个高压缩索引,而不是唯一副本。丢失的不是底层存储里的记忆,而是压缩索引的覆盖范围。
这个定义也有意排除了文体风格、修辞手法和措辞偏好——MemPalace 存储的是记忆,不是文学作品。
约束三:任何文本模型可读
这个约束的含义比它字面上看起来更加严格。
"任何文本模型"意味着 Claude、GPT-4、Gemini、Llama、Mistral——包括任何未来可能出现的、能够处理自然语言文本的大语言模型。压缩格式不能依赖于某个特定模型的训练数据、特殊 token 或微调行为。
这个约束直接排除了所有基于向量嵌入的压缩方案。一个由 OpenAI 的 text-embedding-ada-002 生成的向量,对 Llama 来说就是一串无意义的浮点数。即使是同一家公司的不同版本模型,嵌入空间也未必兼容。
它同样排除了所有基于特定分词器(tokenizer)行为的编码方案。不同模型对同一段文本的分词方式不同——BPE、SentencePiece、WordPiece 各有差异——任何利用分词边界来编码信息的方案都会在模型切换时失效。
这个约束的深层含义是:压缩格式必须在"文本"的层面上工作,而不是在"模型内部表示"的层面上。无论哪个模型来读这段压缩后的文本,它都应该能够仅通过语言理解能力——而不是某种特殊的解码能力——来获取其中的信息。
约束四:无需解码器或特殊工具
这是最后一个约束,也是最容易被低估的一个。
在传统的数据压缩领域,压缩和解压是成对出现的操作。gzip 需要 gunzip,zstd 需要 zstd -d,任何压缩格式都附带一个解码器。这在传统计算中不是问题,因为运行一个解码程序的成本极低。
但在 LLM 记忆系统中,这个假设不再成立。当 AI 在会话开始时加载上下文时,它看到的就是文本。没有中间层可以运行一个解码程序把压缩格式还原成自然语言,然后再传给模型。压缩后的文本必须直接作为模型输入被理解——不经过任何预处理、解码或转换。
更具体地说,这个约束排除的是任何需要在模型推理之外运行额外代码的方案。模型的上下文窗口就是唯一的"运行环境"。压缩格式必须在这个环境内自解释。
排除分析:什么方案不可能工作
定义清楚四个约束之后,我们可以系统性地排除那些看似可行但实际上必然违反至少一个约束的方案。这种排除分析不是为了贬低被排除的方案——它们中的许多在其他场景下非常有效——而是为了缩小可行解的搜索空间。
方案 A:二进制编码
最直观的高压缩比方案是二进制编码。将文本信息编码为某种紧凑的二进制格式,理论上可以实现远超 30 倍的压缩比。Protocol Buffers、MessagePack、CBOR 等格式在系统间通信中被广泛使用,压缩效率远高于文本。
违反约束三。 没有任何大语言模型被训练来理解二进制格式。当你把一段 protobuf 编码的字节流放入 GPT-4 的上下文窗口时,模型看到的是乱码。它无法提取其中的任何信息,就像一个只会读英文的人面对一段中文盲文一样。
问题的根源在于:大语言模型的训练语料是文本,它们的世界模型建立在对文本模式的统计学习之上。二进制格式处于这个世界模型的覆盖范围之外。
方案 B:JSON 压缩
既然二进制不行,那把信息组织成结构化 JSON 如何?JSON 是文本格式,所有模型都见过大量 JSON 训练数据,理解起来没有问题。
{
"team": {
"name": "Driftwood",
"lead": "Priya",
"members": [
{"name": "Kai", "role": "backend", "tenure": "3yr"},
{"name": "Soren", "role": "frontend"},
{"name": "Maya", "role": "infrastructure"},
{"name": "Leo", "role": "junior", "status": "new"}
]
},
"project": "saas_analytics",
"sprint": "auth_migration_to_clerk",
"decision": {
"by": "Kai",
"choice": "clerk_over_auth0",
"reasons": ["pricing", "developer_experience"]
}
}
违反约束一。 数一下 token:这段 JSON 大约 180 个 token。而它承载的信息,用自然语言描述大约需要 250 个 token。压缩比不到 1.5 倍。JSON 的语法开销——大括号、方括号、引号、冒号、逗号、键名——占据了大量空间,这些都是模型必须处理但不携带实际信息的 token。
你可以通过缩短键名、删除缩进来优化,但 JSON 的结构性冗余是内生的。"name": 这五个字符中只有值是有用的,键本身的含义可以从上下文推断。但 JSON 格式要求你必须写出每一个键。
更深层的问题是:JSON 是为机器解析设计的,它的冗余是有意为之——为了明确性和容错性。而我们需要的是为 LLM 阅读设计的格式,LLM 有上下文推断能力,不需要那么多显式标注。
方案 C:LLM 摘要
让一个语言模型阅读原始文本,输出一段简洁的摘要。这是大多数商业 AI 记忆系统采用的方案。
Summary: Priya leads a team working on SaaS analytics.
Key members include backend developer Kai (3yr) and
junior developer Leo (new). Currently migrating auth to Clerk.
违反约束二。 摘要是有损压缩的定义。上面的摘要中,Soren 和 Maya 完全消失了。"Kai recommended Clerk over Auth0 based on pricing and DX"这个具体的决策归因也丢失了。
你可以要求模型"不要遗漏任何细节",但这只是把信息损失从显式变成了隐式。模型仍然需要判断什么是"细节"——而这个判断本身就是一种有损操作。更何况,摘要越详尽,压缩比越低,最终趋向于复述原文。
摘要的另一个致命问题是不可逆性。当你发现摘要遗漏了某个关键细节时,你无法从摘要本身恢复它——你必须回到原始文本。这意味着摘要不能作为唯一的压缩表示,它最多只能是一个索引。但 MemPalace 已经有了一个更好的索引系统(宫殿结构),不需要再用另一个有损索引来补充。
方案 D:自定义编码表
设计一套自定义的编码表:为常见概念分配短代码,用查找表来编码和解码。类似于莫尔斯电码,但面向语义而非字母。
例如:T1=Priya T2=Kai T3=Soren R1=backend R2=frontend P1=Driftwood,然后用 T1(lead,P1) T2(R1,3yr) T3(R2) 来表示团队结构。
违反约束四。 读这段文本的模型不知道 T1 是 Priya、R1 是 backend。它需要一个编码表——一个解码器——来理解这些代码。而这个编码表本身也是需要加载到上下文中的文本,占用额外 token,进一步降低实际压缩比。
更严重的是,编码表是与特定数据集绑定的。换一组人物、换一个项目,就需要一套新的编码表。这使得系统变成了一个需要维护的有状态组件,违背了 MemPalace "简单到不可能出错"的设计哲学。
当然,如果编码足够直觉——比如用 PRI 表示 Priya,用 KAI 表示 Kai——那么即使没有显式的编码表,模型也能推断出代码对应的实体。但这时候,"编码"就不再是任意的符号映射,而是一种基于自然语言直觉的缩写系统。这个区分很重要,因为它指向了可行解的方向。
方案 E:向量嵌入压缩
将文本编码为向量嵌入(embedding),用 384 维或 768 维的浮点数组来"记忆"语义信息。
同时违反约束三和约束四。 前面已经讨论过,嵌入向量对其他模型来说是无意义的数字。而且,从嵌入中还原原始信息需要一个解码器(或至少一个相似度匹配引擎),这又违反了约束四。
向量嵌入在 MemPalace 中有其位置——ChromaDB 使用它来驱动语义搜索——但它是检索工具,不是存储格式。这个区分在设计中被严格维护。
可行解的形状
五种方案被排除后,约束空间急剧收窄。让我们从排除结果中反向推导可行解必须具备的特征:
必须是文本格式——因为约束三要求任何文本模型可读,而所有文本模型的公共能力交集就是理解文本。
必须是自解释的——因为约束四要求无需解码器,压缩文本本身必须携带足够的上下文让模型理解它的含义。
必须保留所有事实断言——因为约束二要求格式压缩阶段不丢弃任何实体、关系、属性和事件。
必须极度紧凑——因为约束一要求高压缩比(格式层 5-10 倍,结合内容筛选可达 30 倍),每个 token 必须携带远高于自然英语的信息密度。
把这四个特征放在一起,一个结论开始浮现:可行解必须是某种极度缩写的自然语言。
不是一种新发明的编码——因为那需要解码器。不是一种全新的语法——因为模型没有见过它的训练数据。它必须是英语(或更准确地说,是任何自然语言的极度浓缩形式),利用大语言模型已经具备的语言理解能力来"解码"缩写。
这个推导结果有一个重要的性质:它不是从 AAAK 的设计中逆向推出的,而是从约束条件中正向推出的。即使 MemPalace 的设计者从未存在过,任何面对同样四个约束的工程师,经过同样的排除分析,都会到达同样的结论——可行解必须是极度缩写的英语。
自然语言的信息冗余
既然可行解必须基于自然语言的缩写,一个自然的追问是:自然语言到底有多少冗余可以被移除?
信息论给出了一个定量的回答。Claude Shannon 在 1951 年的实验中估计,英语的信息熵约为每字符 1.0-1.5 比特,而英语字母表的最大熵约为每字符 4.7 比特。这意味着英语文本中大约有 70% 的内容是冗余的——它们的存在是为了帮助人类处理(语法标记、功能词、形态变化),而不是为了传递独立的信息。
但 Shannon 的估计是针对字母级别的。在词级别上,冗余的来源更加多样:
语法冗余。 "The team is currently working on the project" 中,"the"、"is"、"currently"、"on"、"the" 都是语法功能词,不携带团队、工作或项目的信息。去掉它们,"team working project" 仍然传达了核心语义。
修辞冗余。 "Kai, who has been with the team for three years and has extensive experience in backend development, recommended Clerk." 中,"who has been with the team for"、"and has extensive experience in" 都是修辞润色,核心信息是 "Kai(backend,3yr) rec:Clerk"。
解释冗余。 当你说 "Priya manages the Driftwood team" 时,"manages" 暗含了 Priya 是 leader 的信息。在压缩表示中,"PRI(lead)" 就足够了——"manage" 这个动词所传达的额外语义(监督、决策权、汇报关系)可以从 "lead" 这个角色标签中由模型自行推断。
叙事冗余。 自然语言倾向于线性叙述——先说背景,再说事件,最后说结论。压缩表示可以打破这个顺序,直接用结构化的方式陈列事实,由模型在需要时自行重构叙事。
这些冗余加在一起,为格式压缩的 5-10 倍提供了充足的空间。当叠加内容筛选(从长对话中提取关键语句),压缩比可以进一步推高到 README 宣称的 30 倍——但后者涉及对"什么是关键"的判断,不再是纯粹的去冗余。关键在于:去除冗余不等于丢失信息。冗余是信息的额外包装,去掉包装不改变内容物,前提是接收方有能力从裸露的内容物中重建完整的理解。
大语言模型恰好具备这种能力。它们在数万亿词的文本上训练,已经深度内化了英语的语法规则、语义关系和世界知识。当它们看到 "KAI(backend,3yr)" 时,不需要有人告诉它们这意味着 "Kai is a backend developer with 3 years of experience"——它们的语言模型自动完成了这个推断。
这就是可行解的核心洞见:大语言模型本身就是解码器。 不需要外部的解码程序,因为模型的语言理解能力就是解码能力。约束四看似排除了所有需要解码器的方案,但实际上,它只排除了需要外部解码器的方案——而利用模型自身作为解码器的方案,完美地满足了这个约束。
从约束到设计空间
总结一下本章的推导链:
- 问题定义:将大量上下文信息压缩到极小的 token 空间,供 LLM 即时加载。
- 约束定义:高倍压缩(格式层 5-10x,叠加内容筛选可达 30x)、事实断言完整性、模型通用、无需解码器。四个约束缺一不可。
- 排除分析:二进制(模型不可读)、JSON(压缩比不够)、LLM 摘要(有损)、自定义编码(需解码器)、向量嵌入(模型不可读且需解码器)——全部出局。
- 正向推导:可行解必须是文本格式、自解释、保留全部事实、极度紧凑。交集:极度缩写的自然语言。
- 理论基础:自然语言的 70% 冗余为高倍压缩提供了空间,而 LLM 的语言理解能力充当了隐式解码器。
这条推导链的意义在于:它将 AAAK 从一个看似随意的"发明",重新定位为一个在严格约束下唯一可行的设计方向。AAAK 不是因为"聪明"而被设计出来的,而是因为在给定的四个约束条件下,解空间只剩下了这一个角落。
当然,"极度缩写的自然语言"仍然是一个很大的设计空间。缩写什么、保留什么、用什么符号来标记结构——这些具体的语法决策仍然有大量的自由度。下一章将深入 AAAK 的具体语法设计,分析它在这个收窄后的设计空间中做出的每一个选择。
但在此之前,值得记住一件事:AAAK 之所以能够工作,不是因为它的语法有多巧妙,而是因为它正确地识别了约束空间的形状。好的工程从来不是从解法开始的——它从约束开始。
第9章:AAAK 的语法设计
定位:上一章通过约束满足分析,推导出可行的压缩方案必须是"极度缩写的自然语言"。本章进入具体的语法层面:AAAK 用了哪些缩写规则、什么分隔符、什么标记系统,以及这些选择如何在
dialect.py中被实现。从这里开始,我们将看到真实的代码和真实的压缩对照。
从英文到 AAAK:一个完整的对照
在分析语法规则之前,先看一个完整的对照示例。这是 MemPalace README 中给出的核心演示:
英文原文(约 1000 token):
Priya manages the Driftwood team: Kai (backend, 3 years),
Soren (frontend), Maya (infrastructure), and Leo (junior,
started last month). They're building a SaaS analytics platform.
Current sprint: auth migration to Clerk. Kai recommended Clerk
over Auth0 based on pricing and DX.
AAAK 压缩(约 120 token):
TEAM: PRI(lead) | KAI(backend,3yr) SOR(frontend) MAY(infra) LEO(junior,new)
PROJ: DRIFTWOOD(saas.analytics) | SPRINT: auth.migration->clerk
DECISION: KAI.rec:clerk>auth0(pricing+dx) | ****
原文约 250 个字符的自然语言段落,被压缩成约 180 个字符的 AAAK 表示。但 token 层面的压缩比更加显著,因为 AAAK 的结构化格式对分词器更友好——英文原文的冠词、介词、连接词各自占据独立 token,而 AAAK 中这些冗余全部被移除。
更关键的是信息的完整性。让我们逐项核对:
| 英文原文中的事实 | AAAK 中的对应表示 |
|---|---|
| Priya 是团队负责人 | PRI(lead) |
| Kai 做后端,3 年经验 | KAI(backend,3yr) |
| Soren 做前端 | SOR(frontend) |
| Maya 做基础设施 | MAY(infra) |
| Leo 是初级,刚入职 | LEO(junior,new) |
| 团队名叫 Driftwood | DRIFTWOOD |
| 在做 SaaS 分析平台 | saas.analytics |
| 当前 sprint 是认证迁移 | SPRINT: auth.migration |
| 迁移目标是 Clerk | ->clerk |
| Kai 推荐了 Clerk | KAI.rec:clerk |
| Clerk 优于 Auth0 | clerk>auth0 |
| 原因是价格和开发体验 | (pricing+dx) |
| 这是一个重要决策 | **** |
十三个事实断言,全部保留。零损失。而 token 数从原文的约 70 个降低到压缩后的约 35 个——实际上这个简短示例的压缩比约为 2 倍。但当应用到更长的文本(数千 token 的完整对话记录)时,自然语言中的叙事冗余、过渡句、重复引用被大量消除,30 倍的压缩比便成为可能。
六种核心语法元素
AAAK 的语法可以分解为六种核心元素。每一种都对应一个具体的压缩策略。
元素一:三字母实体编码
这是 AAAK 最基础的语法规则:人名、项目名和其他命名实体被缩写为三个大写字母。
Priya → PRI Kai → KAI Soren → SOR
Maya → MAY Leo → LEO Driftwood → DRI
规则很简单:取名字的前三个字符,转为大写。dialect.py 中的实现如下:
# dialect.py:378-379
def encode_entity(self, name: str) -> Optional[str]:
...
# Auto-code: first 3 chars uppercase
return name[:3].upper()
这个实现位于 Dialect.encode_entity 方法中(dialect.py:367-379)。方法首先检查是否有预定义的实体映射(通过构造函数传入的 entities 参数),如果没有,则回退到"取前三字符"的自动编码策略。
三个字母的选择不是任意的。两个字母(如 PR、KA)的碰撞概率太高——26^2 = 676 种组合在一个有几十个实体的系统中很容易产生歧义。四个字母(如 PRIY、KAIS)的收益递减——额外的一个字符带来的区分度提升不值得它在每次出现时多占的 token 空间。三个字母(26^3 = 17,576 种组合)在区分度和紧凑性之间取得了最佳平衡。
更重要的是,三字母编码保持了对原始名字的直觉关联。当一个模型看到 PRI 时,如果上下文中曾出现过 Priya,它能立即建立联系。这正是第 8 章中讨论的"自解释"要求:编码本身携带足够的语义线索,不需要外部编码表。
元素二:管道分隔符
AAAK 使用竖线 | 作为字段分隔符,替代自然语言中的逗号、句号和换行。
0:PRI+KAI|backend_auth|"switched to Clerk"|determ+convict|DECISION
这个结构在 dialect.py 的 compress 方法中被构建(dialect.py:539-602)。方法将检测到的实体、主题、关键引语、情感和标记分别作为字段,用管道符连接:
# dialect.py:600-602
parts = [f"0:{entity_str}", topic_str]
if quote_part:
parts.append(quote_part)
...
lines.append("|".join(parts))
管道符的选择有两个工程理由。第一,它在自然语言中极少出现,因此不会与内容本身产生歧义——不像逗号,既是分隔符又是英语标点。第二,大语言模型在训练数据中见过大量的管道分隔格式(命令行输出、Markdown 表格、日志文件),已经学会了将 | 解释为"字段边界"。
元素三:箭头因果关系
AAAK 使用 -> 表示因果、方向或转变关系:
auth.migration->clerk # 迁移方向
fear->trust->peace # 情感弧线
KAI.rec:clerk>auth0 # 推荐(克拉克优于 Auth0)
箭头的语义在不同上下文中略有不同:在动作上下文中表示方向(从 A 到 B),在情感弧线中表示时间推移(先恐惧,后信任,最后平静),在比较中表示偏好。但核心含义始终是"从左到右的流动"——这是一个几乎所有文化和所有语言模型都理解的隐喻。
情感弧线在 dialect.py 中通过 ARC: 前缀标记(dialect.py:742),模型可以从 ARC:fear->trust->peace 中直接读出一个人的情感变化轨迹。
元素四:星级重要性标记
AAAK 使用一到五颗星来标记信息的重要程度:
DECISION: KAI.rec:clerk>auth0(pricing+dx) | ****
这个标记系统的精妙之处在于它的认知透明性。任何人(以及任何模型)看到四颗星就知道"这很重要",不需要解释什么是"importance level 4"。星级在 MCP 服务器的 AAAK 规范中被定义(mcp_server.py:109):
IMPORTANCE: * to ***** (1-5 scale).
在 dialect.py 的 zettel 编码路径中,重要性通过 emotional_weight 数值(0.0-1.0)表达(dialect.py:697),而在 AAAK 规范层面,星级提供了一种更直觉的替代表示。
元素五:情感标记
AAAK 用简短的情感代码标记文本的情感基调。dialect.py 中定义了一个完整的情感编码表(dialect.py:47-88):
# dialect.py:47-52 (节选)
EMOTION_CODES = {
"vulnerability": "vul",
"joy": "joy",
"fear": "fear",
"trust": "trust",
"grief": "grief",
"wonder": "wonder",
...
}
编码规则是取情感词的前三到四个字符作为缩写:vulnerability 变成 vul,tenderness 变成 tender,exhaustion 变成 exhaust。encode_emotions 方法(dialect.py:381-388)将情感列表转换为 + 连接的紧凑字符串,最多保留三个情感标记:
# dialect.py:381-388
def encode_emotions(self, emotions: List[str]) -> str:
codes = []
for e in emotions:
code = EMOTION_CODES.get(e, e[:4])
if code not in codes:
codes.append(code)
return "+".join(codes[:3])
情感标记在 AI 记忆系统中可能看起来多余——为什么记忆需要记录情感?但 MemPalace 的设计者显然意识到,人类的决策上下文往往包含情感维度。"我们在极度焦虑中选择了 Clerk"和"我们在充分论证后冷静地选择了 Clerk"传达的不仅是情感差异,更是决策质量的信号。当模型在后续对话中被问到"那个决策靠谱吗"时,情感标记提供了额外的判断依据。
在 MCP 服务器中,AAAK 规范使用了一种更具表现力的情感标记语法(mcp_server.py:107):
EMOTIONS: *action markers* before/during text.
*warm*=joy, *fierce*=determined, *raw*=vulnerable, *bloom*=tenderness.
这种以星号包裹的动作标记更接近文学写作中的"舞台指示"——它不只是标注"这里有悲伤",而是标注"这里的语气是脆弱的"。这为 AI 在回忆过去事件时提供了语气线索。
元素六:语义标志位
AAAK 定义了一组固定的标志位来标记信息的类型和性质(dialect.py:29-36):
ORIGIN = 起源时刻(某事物的诞生)
CORE = 核心信念或身份支柱
SENSITIVE = 需要极度小心处理
PIVOT = 情感转折点
GENESIS = 直接导致了某个现存事物
DECISION = 显式的决策或选择
TECHNICAL = 技术架构或实现细节
_FLAG_SIGNALS 字典(dialect.py:117-152)定义了从自然语言关键词到标志位的映射规则:
# dialect.py:117-125 (节选)
_FLAG_SIGNALS = {
"decided": "DECISION",
"chose": "DECISION",
"switched": "DECISION",
"founded": "ORIGIN",
"created": "ORIGIN",
"turning point": "PIVOT",
"core": "CORE",
...
}
当压缩引擎在文本中检测到 "decided"、"chose"、"switched" 等关键词时,自动添加 DECISION 标志。这些标志位的作用类似于数据库中的索引标签——它们不改变内容本身,但极大地加速了后续的检索和过滤。当 AI 被问到"我们做过哪些重要决策"时,只需过滤 DECISION 标志即可,不必对全部记忆做语义匹配。
压缩流水线:从原始文本到 AAAK
理解了六种语法元素之后,来看它们如何在 Dialect.compress 方法中被组合。这是 MemPalace 中将任意文本转换为 AAAK 的核心入口(dialect.py:539-602)。
压缩流水线分为五个阶段:
阶段一:实体检测。 _detect_entities_in_text 方法(dialect.py:510-537)在文本中寻找已知实体(通过预定义映射)或自动检测首字母大写的单词作为潜在实体名。检测到的实体被编码为三字母代码,用 + 连接。
阶段二:主题提取。 _extract_topics 方法(dialect.py:430-455)从文本中提取关键主题词。它的策略是统计词频,同时加权处理大写词(可能是专有名词)和包含下划线或连字符的词(可能是技术术语)。停用词列表(dialect.py:155-289)确保 "the"、"is"、"was" 等无信息量的词不会污染主题提取结果。
阶段三:关键句抽取。 _extract_key_sentence 方法(dialect.py:457-508)选出文本中最"重要"的一个句子片段作为引语。评分标准偏好包含决策词("decided"、"because"、"instead")的短句——这些句子通常携带最高的信息密度。
阶段四:情感和标志检测。 _detect_emotions(dialect.py:408-417)和 _detect_flags(dialect.py:419-428)分别通过关键词匹配检测情感基调和语义标志。
阶段五:组装。 所有检测到的组件被组装成管道分隔的 AAAK 格式行:
# dialect.py:596-602
parts = [f"0:{entity_str}", topic_str]
if quote_part:
parts.append(quote_part)
if emotion_str:
parts.append(emotion_str)
if flag_str:
parts.append(flag_str)
lines.append("|".join(parts))
如果有元数据(来源文件、所属翼、房间、日期),则会在内容行之前添加一个头部行(dialect.py:583-589)。
graph TD
A[原始文本] --> B[实体检测与编码]
B --> C[主题提取]
C --> D[关键语句筛选]
D --> E[情感/标志检测]
E --> F[AAAK 组装输出]
整个流水线的设计哲学是:宁可多保留,不可少保留。每个阶段都有上限(情感最多 3 个,标志最多 3 个,主题最多 3 个),但没有下限——如果检测到的信息少于上限,全部保留。
压缩比的计算
Dialect 类提供了一个 compression_stats 方法(dialect.py:936-946)来量化压缩效果:
# dialect.py:932-934
@staticmethod
def count_tokens(text: str) -> int:
"""Rough token count (1 token ~ 3 chars for structured text)."""
return len(text) // 3
这个 token 计数使用了"每 3 个字符约等于 1 个 token"的近似——这对于结构化文本来说是合理的估计(自然英语约为每 4 个字符 1 个 token,但 AAAK 的大写字母和符号使得分词更密集)。
在实际使用中,压缩比取决于原始文本的性质。纯叙述性的对话记录(充满"well, I think that maybe we should consider..."这样的表达)可以达到 30 倍以上的压缩比。结构化的技术描述(已经比较紧凑)的压缩比通常在 5-10 倍。MemPalace README 中宣称的 30 倍是对典型对话记录的代表性数字。
Zettel 编码:结构化数据的压缩路径
除了纯文本压缩,dialect.py 还保留了一条面向结构化 zettel 数据的编码路径。这是 AAAK 的原始设计——针对已经被分解为 zettel(卡片)格式的记忆条目进行编码。
encode_zettel 方法(dialect.py:681-710)处理单张 zettel 卡片:
# dialect.py:681-685
def encode_zettel(self, zettel: dict) -> str:
zid = zettel["id"].split("-")[-1]
entity_codes = [self.encode_entity(p) for p in zettel.get("people", [])]
entity_codes = [e for e in entity_codes if e is not None]
...
输出格式遵循 dialect.py 头部注释中定义的规范(dialect.py:15-18):
Header: FILE_NUM|PRIMARY_ENTITY|DATE|TITLE
Zettel: ZID:ENTITIES|topic_keywords|"key_quote"|WEIGHT|EMOTIONS|FLAGS
Tunnel: T:ZID<->ZID|label
Arc: ARC:emotion->emotion->emotion
encode_file 方法(dialect.py:720-751)将一个完整的 zettel JSON 文件(包含多张 zettel 和它们之间的 tunnel 连接)编码为一个多行的 AAAK 文本块。头部行包含文件编号、主要实体、日期和标题,随后是每张 zettel 的编码行和 tunnel 连接行。
这两条路径——纯文本压缩和 zettel 编码——服务于不同的使用场景。纯文本压缩(compress 方法)用于实时处理新输入的内容,zettel 编码(encode_zettel / encode_file)用于处理已经过预处理和结构化的历史数据。
AAAK 规范的传递机制
一个语法设计再精妙,如果模型不知道它的规则,也毫无用处。AAAK 解决这个问题的方式出人意料地直接:在 MCP 服务器的状态响应中内嵌完整的规范文本。
mcp_server.py 中定义了一个 AAAK_SPEC 常量(mcp_server.py:102-119):
# mcp_server.py:102-103
AAAK_SPEC = """AAAK is a compressed memory dialect
that MemPalace uses for efficient storage.
It is designed to be readable by both humans
and LLMs without decoding.
...
"""
这个规范被嵌入到 mempalace_status 工具的响应中(mcp_server.py:85-86):
# mcp_server.py:84-86
return {
...
"protocol": PALACE_PROTOCOL,
"aaak_dialect": AAAK_SPEC,
}
这意味着:当 AI 第一次调用 mempalace_status,并且 palace collection 已经存在时,它在响应中就收到了 AAAK 的完整语法规范。从这一刻起,它就知道如何读写 AAAK 了。源码里的前置条件也要一并看到:如果 palace 尚未初始化,status 会先返回 _no_palace() 的错误与提示,而不是这段规范文本。
这个设计的精彩之处在于:规范本身也是自然语言文本。模型不需要"学习"一种新的编码——它只需要阅读一段关于这种编码的说明,就像人类阅读一份格式说明书一样。AAAK 规范可以用 AAAK 的术语来自我描述,这是一个递归的自洽性。
在实测中,Claude、GPT-4、Gemini 等模型在第一次看到 AAAK 规范后就能正确地读取和生成 AAAK 文本。不需要微调,不需要 few-shot 示例,不需要迭代训练。这验证了第 8 章的核心论点:AAAK 不是一种新语言,而是一种极度缩写的英语,模型已有的语言能力足以"解码"它。
模型的第一次接触
为了更具体地说明"第一次看到就能读写"这个性质,考虑以下场景:
一个从未见过 AAAK 的模型收到了这段文本:
TEAM: PRI(lead) | KAI(backend,3yr) SOR(frontend) MAY(infra) LEO(junior,new)
即使没有任何规范说明,模型也能推断出:这描述了一个团队;PRI 是某个人的缩写,可能是 team lead;KAI 做 backend,有 3 年经验;SOR 做 frontend;等等。因为这些缩写和结构利用的是英语的通用模式——括号内是属性,逗号分隔属性,大写缩写是名字。
而当模型同时收到了 AAAK 规范时,理解变得更加确定:它不再需要"猜测" PRI 是人名缩写,因为规范明确说了"ENTITIES: 3-letter uppercase codes"。
这种"即使没规范也能大致理解,有规范后就能精确理解"的性质,正是 AAAK 设计的关键成就。它让压缩格式在两个层面上都"工作":在语言直觉层面(利用模型的语言理解能力),以及在规范层面(通过显式的格式说明消除歧义)。
情感信号的自动检测
dialect.py 中一个值得注意的设计是情感的自动检测机制。_EMOTION_SIGNALS 字典(dialect.py:91-114)将日常英语中的情感关键词映射到 AAAK 情感代码:
# dialect.py:91-99 (节选)
_EMOTION_SIGNALS = {
"decided": "determ",
"prefer": "convict",
"worried": "anx",
"excited": "excite",
"frustrated": "frust",
"love": "love",
"hope": "hope",
...
}
这意味着当你写 "I'm worried about the deadline",压缩引擎会自动检测到 "worried" 并标记 anx(anxiety)。你不需要手动注释情感——系统从文本本身推断。
类似地,_FLAG_SIGNALS(dialect.py:117-152)通过关键词检测自动添加语义标志。"decided to use GraphQL" 触发 DECISION,"this was a turning point" 触发 PIVOT,"I created a new repo" 触发 ORIGIN。
这种基于关键词的检测显然不是完美的——它会漏掉委婉表达("I'm not sure this is the right approach" 不会触发 doubt),也可能误判("I love this bug" 中的 "love" 显然是反讽)。但在 MemPalace 的设计哲学中,粗粒度的自动检测优于无检测。即使只标记了 60% 的情感信号,这 60% 在后续检索中仍然提供了有价值的过滤维度。
层级一生成:全局压缩
dialect.py 中最复杂的方法是 generate_layer1(dialect.py:784-902),它从全部 zettel 文件中提取最关键的记忆,生成一个压缩的"层级一"唤醒文件。
这个方法的逻辑分三步:
- 筛选:遍历所有 zettel,只保留情感权重超过阈值(默认 0.85)或带有
ORIGIN、CORE、GENESIS标志的条目。 - 分组:按日期对筛选后的条目进行分组,生成
=MOMENTS[date]=段落。 - 编码:对每个条目应用 AAAK 编码,输出管道分隔的紧凑行。
输出示例:
## LAYER 1 -- ESSENTIAL STORY
## Auto-generated from zettel files. Updated 2026-04-07.
=MOMENTS[2025-11]=
PRI+KAI|auth decision|"chose Clerk for DX and pricing"|0.92|DECISION
KAI|backend architecture|"GraphQL over REST"|0.88|DECISION+TECHNICAL
=MOMENTS[2025-12]=
LEO|onboarding|"first PR merged"|0.85|ORIGIN
=TUNNELS=
auth decision connects KAI and PRI
这更准确地说是 AAAK 工具链里可以生成的一种 Layer 1 产物——把整个团队数月的关键历史浓缩到不到 120 个 token 中。需要和当前默认运行时区分的是:公开仓库里的 mempalace wake-up 目前走的仍然是 layers.py 的 L0 + L1 文本路径,典型输出约 600-900 token,而不是直接加载这里的 generate_layer1() 结果。
一个语法设计的总结
回顾 AAAK 的六种核心语法元素,一个模式变得清晰:每一种元素都遵循同一个原则——利用模型已有的语言直觉,而不是发明模型需要学习的新规则。
- 三字母编码利用了"大写缩写 = 名字"的直觉
- 管道分隔利用了"竖线 = 字段边界"的直觉
- 箭头利用了"从左到右 = 因果/方向"的直觉
- 星级利用了"更多星 = 更重要"的直觉
- 情感代码利用了"缩写 = 原词"的直觉
- 标志位利用了"大写词 = 标签"的直觉
没有一种元素需要模型"学习"新的语义。它们全部建立在模型训练数据中已经大量出现的模式之上。这不是巧合,而是第 8 章推导出的约束的直接后果:当你不能使用解码器时,你唯一的"解码器"就是模型的既有知识。而利用既有知识的最可靠方式,就是使用它已经理解的模式。
下一章将探讨这个设计选择的一个深远后果:因为 AAAK 只是缩写的英语,而不是某种模型特定的编码,它天然地具备跨模型通用性。
第10章:跨模型通用性
定位:前两章分别推导了 AAAK 的设计约束和具体语法。本章关注一个更深层的问题:为什么 AAAK 能被任何文本模型理解?这不是一个偶然的副作用,而是一个可以从语言学层面解释的必然结果。以及,这个技术特性带来了怎样的产品含义——记忆系统与模型供应商的解耦。
一个意外的发现
2026 年初,当 MemPalace 的基准测试结果发布后,技术社区的第一反应集中在分数本身——LongMemEval 满分,96.6% 零 API 调用。但随后浮现的一个问题可能更值得关注:这些结果是否绑定在 Claude 上?如果换成 GPT-4、Gemini、或者一个完全离线运行的 Llama 模型,AAAK 还能工作吗?
MemPalace 的 README 给出了一个简洁到近乎挑衅的回答:
Works with: Claude, ChatGPT, Gemini, Llama, Mistral -- any model that reads text.
"Any model that reads text。" 这不是市场营销的夸张,而是一个可以从设计原理上严格论证的技术断言。理解它为什么成立,需要回到语言学的基本层面。
语言学解释:省略而非发明
AAAK 与其他 AI 专用格式的根本区别在于:它没有发明任何新东西。
考虑一些需要特定模型或特定训练才能理解的格式:
- 向量嵌入:一个由模型 A 生成的 768 维向量,对模型 B 来说是无意义的数字序列。因为嵌入空间是模型训练的产物,不同模型的嵌入空间之间没有对应关系。
- 特殊标记:某些系统使用自定义的 token(如
<memory>或[FACT])来标记信息类型。这些标记只在被训练识别它们的模型中有效。 - 函数调用格式:OpenAI 的函数调用语法(JSON 结构的 function_call)只有被训练支持这种格式的模型才能正确处理。
这些格式的共同点是:它们在自然语言之上添加了一层新的约定。接收方必须事先"知道"这层约定才能正确解读。
AAAK 走了相反的方向。它不是在英语之上添加新规则,而是从英语中移除冗余。让我们通过一个具体的例子来分析这个过程:
第一层移除——功能词:
原文: Priya manages the Driftwood team
第一层:Priya manages Driftwood team
移除: the
冠词 "the" 在这里的功能是标记 "Driftwood team" 为特指。但当 "Driftwood" 本身就是一个专有名词时,这个标记完全冗余——不存在"另一个 Driftwood team"需要区分。
第二层移除——动词形态:
第一层:Priya manages Driftwood team
第二层:Priya lead Driftwood
移除: manages(替换为角色标签 lead),team(可从上下文推断)
"manages" 这个动词告诉我们 Priya 是领导角色。直接用 lead 这个角色标签替代,信息不减,token 更少。"team" 可以从 AAAK 的 TEAM: 前缀中推断。
第三层移除——全名:
第二层:Priya lead Driftwood
第三层:PRI(lead) DRIFTWOOD
移除: riya(名字的后部字符)
"Priya" 的前三个字母 "PRI" 足以在给定上下文中唯一标识这个人。后续的 "ya" 不携带额外的区分信息。
每一层移除的都是英语中"帮助人类处理但不携带独立信息"的成分。没有一步涉及发明新的语法规则或符号系统。最终结果 PRI(lead) DRIFTWOOD 仍然是——从语言学的角度看——英语。一种极度浓缩的、省略了大量语法功能成分的英语,但仍然是英语。
这就是 AAAK 跨模型通用的根本原因:任何能读英文的模型都能读 AAAK,因为 AAAK 就是英文。
省略的语言学边界
当然,并非所有省略都是安全的。语言学中有一个关键的区分:可恢复省略(recoverable ellipsis)和不可恢复省略(unrecoverable ellipsis)。
可恢复省略是指接收方能够从上下文中重建被省略的成分。"谁来?""我。"——这里省略了"我来"中的"来",但听者可以毫无困难地恢复它。
不可恢复省略是指被省略的信息无法从上下文中推断。如果原文是"Kai has been working with Priya for three years on backend systems, and he recommended Clerk because of its superior developer experience and competitive pricing",把它压缩成"KAI rec Clerk"就是不可恢复省略——三年合作关系、推荐原因中的具体细节都丢失了。
AAAK 的语法设计严格地将自身限制在可恢复省略的范围内。KAI(backend,3yr) 省略了"Kai is a backend developer with 3 years of experience"中的"is a"、"developer"、"with"、"of experience",但这些词都可以由模型从 (backend,3yr) 的结构中自动恢复。KAI.rec:clerk>auth0(pricing+dx) 省略了"Kai recommended Clerk over Auth0 based on pricing and developer experience"中的大量词汇,但每个被省略的成分都可以从保留的结构中推断:.rec = recommended,: = 对象分隔,> = over/优于,(pricing+dx) = 基于价格和开发体验。
而不可恢复的信息——Kai 和 Priya 共事了三年——则不会被省略,它会以 3yr 的形式被保留下来。
这个"只做可恢复省略"的原则,与第 8 章的约束二(零信息损失)形成了完美的对应。零信息损失在语言学上的精确表述就是:所有省略都必须是可恢复的。
可恢复性的保证:谁来恢复?
可恢复省略有一个关键前提:必须有一个"恢复者"具备足够的知识来重建被省略的成分。在人类对话中,这个恢复者是听者,依靠的是共享的语言知识和世界知识。在 AAAK 的场景中,这个恢复者是大语言模型。
那么问题变成:不同的模型是否具备相同的恢复能力?
答案是:对于 AAAK 使用的省略类型——是的。
AAAK 依赖的恢复能力可以分为三类:
语法恢复——从 KAI(backend) 恢复出 "Kai is a backend developer"。这需要模型理解"名字后接括号表示属性"的模式。所有主流大语言模型都在训练数据中见过大量的这种模式(编程语言的函数调用、技术文档的参数说明、百科条目的属性列表),这是一种通用的文本理解能力。
词汇恢复——从 rec 恢复出 "recommended",从 3yr 恢复出 "3 years"。这需要模型理解常见的英语缩写模式。同样,所有模型在训练数据中都见过大量的缩写(msg = message,yr = year,info = information),这是基础的语言能力。
结构恢复——从 TEAM: PRI(lead) | KAI(backend) 恢复出"有一个团队,PRI 是 lead,KAI 做 backend"。这需要模型理解管道分隔和层级结构。如前所述,管道分隔在命令行输出、Markdown 表格等格式中大量出现,所有模型都具备这种理解能力。
这三类恢复能力有一个共同的特征:它们不依赖于任何特定模型的专有训练。它们是从通用文本语料中学习到的通用能力。任何在 Common Crawl、Wikipedia、GitHub 等标准语料上训练的模型——也就是说,任何主流的大语言模型——都具备这些能力。
这就是为什么 MemPalace 可以自信地声称 AAAK "works with any model that reads text"。不是因为它在每个模型上都做了兼容性测试,而是因为它依赖的能力是所有模型的最大公约数。
产品含义:解耦
技术特性本身不产生价值——价值来自于技术特性所启用的产品可能性。AAAK 的跨模型通用性启用的最重要的可能性是:记忆系统与模型供应商的完全解耦。
解耦意味着什么
在当前的 AI 应用生态中,记忆通常与模型深度绑定。使用 OpenAI 的 memory 功能意味着你的记忆存储在 OpenAI 的服务器上,只有通过 OpenAI 的 API 才能访问。切换到 Claude 意味着从零开始——你的历史、你的偏好、你积累了六个月的上下文,全部归零。
这种绑定是一种隐性的锁定。你可能因为竞品更便宜、更快、或在某个任务上更擅长而想切换模型,但切换成本太高——不是技术上的成本,而是知识的成本。你的 AI 助手"认识"你的一切都锁在一个供应商的围墙里。
AAAK 打破了这个绑定。因为 AAAK 文本可以被任何模型理解,你的记忆变成了一种可携带的资产。今天用 Claude,明天用 GPT-4,后天用本地的 Llama——只要你把同一份 AAAK 文本交给新模型,它就能在极小的上下文成本内恢复对你的了解。README 用约 120 个 token 来描述这种压缩后的上下文规模;这是 AAAK 作为表达格式的目标优势,而不是当前 wake-up 默认输出的实测值。
这不是理论上的可能性。MemPalace 的架构已经支持这种用法:ChromaDB 存储在本地,AAAK 本身是纯文本,MCP 工具和 CLI 搜索都不绑定任何特定模型。更准确地说,当前仓库已经证明了"AAAK 作为跨模型可携带文本"这件事,而 wake-up 这条默认入口仍然主要输出原文式的 L0 + L1;一旦把 AAAK 完整接到唤醒路径,这种切换的摩擦会进一步降低。
完全离线的记忆栈
AAAK 的跨模型通用性与 MemPalace 的本地架构结合,产生了一个更激进的可能性:在本地依赖和默认嵌入资产已经准备完成后,整个记忆栈可以完全离线运行。
考虑这个技术栈:
- 存储:ChromaDB,运行在本地机器上,无需网络
- 模型:Llama 3 或 Mistral,通过 llama.cpp 或 Ollama 在本地运行
- 压缩:AAAK,纯 Python 实现,零外部依赖
- 搜索:ChromaDB 的内置向量搜索,本地嵌入模型
更准确地说,在本地依赖和默认嵌入资产都已经就绪之后,从数据摄入到记忆存储、从搜索到上下文加载,都可以不再依赖互联网连接。没有 API 调用,没有云服务,没有数据离开你的机器。
这个特性对特定用户群体具有极高的价值。处理敏感数据的企业(法律、医疗、金融)无法将记忆数据发送到第三方服务器。安全研究者不希望他们的工作记录出现在任何人的训练数据中。在网络不稳定的环境中工作的开发者需要一个不依赖网络连接的记忆系统。
对这些场景来说,"离线"不是一个降级选项,而是一个硬性需求。AAAK 使得满足这个需求成为可能——不是通过牺牲功能,而是通过一种从设计层面就不需要在线组件的架构。
无 Vendor Lock-in
将前面两点综合起来,AAAK 实现了一种真正意义上的无 vendor lock-in:
数据层无锁定。 记忆存储在本地的 ChromaDB 中,你拥有数据的完全控制权。导出是一个简单的文件复制操作。
格式层无锁定。 AAAK 是纯文本格式,不依赖于任何专有的编码或解码工具。你可以用任何文本编辑器打开 AAAK 文件,虽然它不太适合人类阅读,但它完全是可读的。
模型层无锁定。 AAAK 文本可以被任何模型理解。从 Claude 切换到 GPT 不需要任何数据转换或格式迁移。
运行时层无锁定。 MCP 服务器是一个标准的 JSON-RPC 接口。CLI 工具是独立的命令行程序。Python API 是开源库。你可以在任何支持 Python 的环境中运行整个栈。
这种全栈无锁定不是常见的。大多数 AI 记忆产品至少在一个层面上存在绑定——Mem0 绑定在它的云服务上,Zep 绑定在 Neo4j 和它的 API 上,OpenAI Memory 绑定在 OpenAI 的生态中。MemPalace 在每一个层面上都是可替换的,这使得它成为了一个真正的基础设施组件,而不是一个带有锁定的服务。
通用性的边界
诚实地说,AAAK 的跨模型通用性有其边界。
能力下限。 AAAK 依赖模型具备基本的英语理解和缩写还原能力。一个极小的语言模型(例如参数量在 10 亿以下的模型)可能无法可靠地执行这些推断。在实践中,7B 参数以上的模型通常能够可靠地理解 AAAK,但更小的模型可能出现降级。
文化假设。 AAAK 的缩写规则主要基于英语。虽然 PRI(lead) 这样的结构对任何见过英语文本的模型都是可理解的,但如果原始内容是中文或日文,三字母实体编码(基于英文名的前三个字符)就不太适用。MemPalace 通过可配置的实体映射(Dialect.from_config)来缓解这个问题,但 AAAK 的核心语法假设仍然是英语优先的。
压缩粒度。 AAAK 的自动压缩基于关键词检测和词频分析,这些启发式方法在某些文本类型上效果较差。高度技术性的文本(充满代码片段和数学公式)或高度情感性的文本(隐喻和暗示多于直陈)可能不适合自动压缩,需要人工调整或选择性保留原文。
这些边界是真实的,但它们不改变核心论点:对于主流的大语言模型和英语为主的使用场景,AAAK 的跨模型通用性是一个可靠的、有语言学理论支撑的技术特性。
一个更大的洞见
本章的分析揭示了一个超越 MemPalace 本身的洞见:面向 AI 的数据格式设计,应该利用模型已有的能力,而不是要求模型获得新的能力。
这个原则听起来显而易见,但它在实践中经常被违反。每当一个系统设计者发明一种新的标记语法、自定义一套 token 协议、或要求模型按照特定的格式输出时,他们都在要求模型做它可能没有被训练做的事情。有些模型能学会,有些不能。有些版本能处理,升级后不一定。
AAAK 的策略是相反的:不要求模型做任何新的事情,只把信息呈现为模型已经知道如何处理的形式。这使得 AAAK 对模型升级、切换和版本差异具有天然的鲁棒性——因为它依赖的不是模型的特定行为,而是模型的通用能力。
这个设计原则可以被推广:如果你正在设计一个需要与多种 LLM 交互的系统,在格式选择上应该偏好"自然语言的子集"而不是"自然语言的超集"。省略比添加更安全。缩写比发明更可靠。利用模型的既有知识,而不是测试模型的学习极限。
MemPalace 用 AAAK 证明了这个原则:一种没有发明任何新语法的"语言",反而成为了最具通用性的 AI 记忆格式。不是因为它足够复杂以覆盖所有需求,而是因为它足够简单以被所有模型理解。
这种简单性不是设计的起点,而是约束满足的终点。第 8 章的四个约束像漏斗一样过滤掉了所有复杂的方案,剩下的就是这个——一种极度缩写的英语,任何能读文本的模型都能理解,不需要解码器,不需要训练,不需要任何特殊的东西。
有时候,最好的设计就是去掉一切不必要的东西之后,剩下的那个。
第11章:时态知识图谱
定位:第四部分"时间维度"的起始章。从 MemPalace 的知识图谱源码出发,解析时态三元组的设计哲学:事实不是永恒的,它们有生命周期。本章是理解第 12 章矛盾检测和第 13 章时间线叙事的基础。
事实会过期
在讨论技术实现之前,先说一个最基本的认知问题。
"Kai 在做 Orion 项目。" 这句话在 2025 年 6 月是事实。到了 2026 年 3 月,Kai 转去了另一个项目,这句话就不再成立了。但在一个传统的知识图谱里,这条记录仍然静静地躺在那里,没有人告诉系统它已经过期。下一次有人问"Kai 现在在做什么项目",系统会自信地给出一个错误答案。
这不是假设。这是几乎所有基于静态三元组的知识系统都面临的真实问题。Wikipedia 的信息框里,每天都有成千上万条过期事实等待人类志愿者手动更新。企业知识库里,项目归属、人员职责、技术栈选型——这些信息的保鲜期通常以周计算,而更新频率以月甚至年计算。
MemPalace 对这个问题的回应是:不要假装事实是永恒的。给每一条事实一个明确的时间窗口。
这就是时态知识图谱(Temporal Knowledge Graph)的核心思想。
静态 KG 与时态 KG
传统的知识图谱存储的是三元组:主语-谓语-宾语。比如 (Kai, works_on, Orion)。这条三元组表达了一个关系,但它没有回答三个关键问题:
- 这个关系从什么时候开始的?
- 这个关系现在还成立吗?
- 在某个特定的历史时间点,这个关系是否成立?
静态 KG 无法回答这些问题,因为它的数据模型里根本没有时间维度。你能做的只是覆盖旧值(丢失历史)或者追加新值(产生矛盾)。
时态 KG 在三元组的基础上增加了两个时间戳:valid_from(生效时间)和 valid_to(失效时间)。同一对主语和宾语之间可以存在多条同类关系,每条关系覆盖不同的时间段。这样,知识图谱就从一张静态快照变成了一本编年史。
用一个表格来对比:
| 能力 | 静态 KG | 时态 KG |
|---|---|---|
| 存储当前事实 | 可以 | 可以 |
| 存储历史事实 | 覆盖即丢失 | 完整保留 |
| 回答"现在 X 是否成立" | 可以(但可能过期) | 精确回答 |
| 回答"2025 年 1 月 X 是否成立" | 不可以 | 可以 |
| 检测过期信息 | 不可以 | 可以 |
| 支持时间线叙事 | 不可以 | 可以 |
MemPalace 选择了时态 KG。这个选择的直接后果是:每一条进入知识图谱的事实都必须携带时间信息,而每一条从知识图谱出去的查询结果都可以按时间过滤。
Schema 设计
打开 knowledge_graph.py,从 _init_db() 方法(knowledge_graph.py:55)可以看到完整的数据库结构。MemPalace 的时态 KG 建立在两张 SQLite 表之上。
entities 表
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT DEFAULT 'unknown',
properties TEXT DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
(knowledge_graph.py:58-63)
实体表的设计极简。id 是实体名称的标准化形式(小写、空格替换为下划线),name 保留原始显示名称,type 标记实体类型(人、项目、工具、概念),properties 是一个 JSON 字段,用于存储实体的附加属性(比如生日、性别等)。
值得注意的是 type 字段默认值为 'unknown'。这意味着实体可以在没有完整类型信息的情况下被创建——系统不会因为缺少元数据而拒绝存储一条关系。这是一个典型的"宽容输入"设计:先把信息存下来,类型信息以后再补。
triples 表
CREATE TABLE IF NOT EXISTS triples (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from TEXT,
valid_to TEXT,
confidence REAL DEFAULT 1.0,
source_closet TEXT,
source_file TEXT,
extracted_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subject) REFERENCES entities(id),
FOREIGN KEY (object) REFERENCES entities(id)
);
(knowledge_graph.py:65-78)
这是时态 KG 的核心。除了标准的 subject/predicate/object 三元组字段之外,有五个额外字段值得逐一解读:
valid_from 和 valid_to —— 时间窗口。两个字段都允许 NULL。valid_from 为空表示"不知道什么时候开始的"(而不是"从创世之初就存在");valid_to 为空表示"目前仍然有效"。这个约定至关重要:通过检查 valid_to IS NULL,系统可以立刻区分当前事实和历史事实。
confidence —— 置信度,默认 1.0(完全确信)。这个字段为将来的概率推理留了空间。当一条事实来自不太可靠的来源(比如从非正式对话中推断出的关系),可以将置信度设为低于 1.0 的值。
source_closet —— 指向记忆宫殿中的 closet 的可选指针。这是知识图谱和宫殿结构之间预留出来的桥梁:如果调用方在 add_triple() 或 mempalace_kg_add 时提供了这个字段,三元组就可以追溯到它来自哪个 closet,从而继续追溯到原始记忆。需要更谨慎地说的是:当前公开源码把它暴露成一个可选输入,但并没有在默认挖掘流程里自动填充它。
source_file —— 原始文件路径。比 source_closet 更底层、也通常更容易在当前实现中实际落地的溯源信息。
extracted_at —— 三元组被录入系统的时间。注意这与 valid_from 不同:一条事实可能在 2025 年就开始生效,但直到 2026 年才被录入系统。
最后,看索引设计(knowledge_graph.py:82-84):
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
四个索引,分别覆盖三元组的三个维度加上时间窗口。idx_triples_valid 是复合索引,同时索引 valid_from 和 valid_to,这使得时间范围查询可以高效执行。
erDiagram
ENTITIES {
int id PK
text name
text type
text properties
}
TRIPLES {
int id PK
text subject
text predicate
text object
text valid_from
text valid_to
real confidence
text source_closet
}
ENTITIES ||--o{ TRIPLES : "subject/object"
写入:add_triple()
add_triple() 方法(knowledge_graph.py:110-167)是知识图谱的主要写入接口。方法签名如下:
def add_triple(
self,
subject: str,
predicate: str,
obj: str,
valid_from: str = None,
valid_to: str = None,
confidence: float = 1.0,
source_closet: str = None,
source_file: str = None,
):
有几个设计细节值得注意。
自动创建实体。 在插入三元组之前,方法会自动为 subject 和 object 创建实体记录(如果它们不存在的话):
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject))
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj))
(knowledge_graph.py:134-135)
INSERT OR IGNORE 意味着如果实体已存在就跳过。这让调用方不需要关心"这个实体是否已经被注册过"——你只管添加三元组,实体会自动出现在图里。这进一步强化了"宽容输入"的设计理念。
去重检查。 在插入新三元组之前,方法会检查是否已经存在一条完全相同的、尚未失效的三元组:
existing = conn.execute(
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchone()
if existing:
conn.close()
return existing[0] # Already exists and still valid
(knowledge_graph.py:139-146)
注意查询条件中的 valid_to IS NULL——只检查当前有效的三元组。如果同一条关系曾经存在过但已经被标记为结束(valid_to 不为空),那么重新添加同一条关系会创建一条新记录,而不是复活旧记录。这符合直觉:如果 Kai 曾经在 Orion 项目上工作,后来离开了,现在又回来了,那应该是两段独立的工作经历,而不是一段连续的。
三元组 ID 生成。 每条三元组的 ID 是一个组合字符串:t_{subject}_{predicate}_{object}_{hash},其中 hash 基于 valid_from 和当前时间戳的 MD5 值取前 8 位(knowledge_graph.py:148)。这保证了即使同一对实体之间存在多条同类关系(覆盖不同时间段),每条记录也有唯一 ID。
查询:query_entity()
query_entity() 方法(knowledge_graph.py:186-241)是最核心的查询接口。它的参数设计精确地体现了时态 KG 的查询模型:
def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing"):
三个参数,三个维度:
name:查询的实体。as_of:可选的时间快照。如果提供,只返回在该时间点有效的事实。direction:关系方向。"outgoing"查询该实体作为主语的关系(实体 -> ?),"incoming"查询该实体作为宾语的关系(? -> 实体),"both"查询两个方向。
as_of 参数的 SQL 实现是这段查询逻辑的精华(knowledge_graph.py:201-203):
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
这个条件的含义是:一条事实在 as_of 时间点有效,当且仅当:
- 它的生效时间在
as_of之前或等于as_of(或者生效时间未知),且 - 它的失效时间在
as_of之后或等于as_of(或者尚未失效)。
valid_from IS NULL 被当作"始终有效"处理,valid_to IS NULL 被当作"尚未结束"处理。这意味着一条没有时间信息的事实会在所有时间点都被视为有效——这是一个合理的默认行为,因为它避免了"因为缺少时间标注就把事实过滤掉"的情况。
查询结果中包含一个 current 字段(knowledge_graph.py:215):
"current": row[5] is None,
row[5] 是 valid_to。如果为 None(即 NULL),说明这条事实仍然有效。这让调用方可以一眼区分当前事实和历史事实。
一个具体的查询例子
假设知识图谱中存在以下三元组:
Kai → works_on → Orion (valid_from: 2025-06-01, valid_to: 2026-03-01)
Kai → works_on → Nova (valid_from: 2026-03-15, valid_to: NULL)
Kai → recommended → Clerk (valid_from: 2026-01-01, valid_to: NULL)
调用 kg.query_entity("Kai") 不带 as_of 参数,返回所有三条记录,其中第一条的 current 为 False,后两条为 True。
调用 kg.query_entity("Kai", as_of="2025-12-01"),只返回第一条(Orion),因为在 2025 年 12 月,Kai 还没有推荐 Clerk,也还没有转去 Nova。
调用 kg.query_entity("Kai", as_of="2026-04-01"),返回后两条(Nova 和 Clerk),因为到 2026 年 4 月,Kai 已经离开了 Orion。
这就是时态查询的力量:同一个实体在不同时间点呈现不同的事实面貌。
失效:invalidate()
invalidate() 方法(knowledge_graph.py:169-182)用于标记一条事实的结束:
def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
"""Mark a relationship as no longer valid (set valid_to date)."""
sub_id = self._entity_id(subject)
obj_id = self._entity_id(obj)
pred = predicate.lower().replace(" ", "_")
ended = ended or date.today().isoformat()
conn = self._conn()
conn.execute(
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(ended, sub_id, pred, obj_id),
)
conn.commit()
conn.close()
设计要点:
- 只更新当前有效的记录(
valid_to IS NULL)。不会意外修改已经结束的历史记录。 - 默认结束日期是今天(
ended or date.today().isoformat())。大多数时候你是在"现在"意识到某件事不再成立的。 - 不删除数据。失效不是删除,而是设置结束时间。历史查询仍然可以看到这条记录。
这种"软删除"策略意味着知识图谱是一个只增长、不收缩的数据结构。每一条曾经为真的事实永远留在图谱中。这听起来可能会带来存储压力,但对于个人或小团队规模的知识图谱来说,SQLite 数据库文件即使积累几万条三元组也不过几 MB——完全不是问题。
为什么是 SQLite
MemPalace 的时态知识图谱直接竞争的对象是 Zep 的 Graphiti。README 中有一段直接的对比(README.md:359-366):
| 特性 | MemPalace | Zep (Graphiti) |
|---|---|---|
| 存储 | SQLite (local) | Neo4j (cloud) |
| 成本 | Free | $25/mo+ |
| 时态 | Yes | Yes |
| 自托管 | Always | Enterprise only |
| 隐私 | Everything local | SOC 2, HIPAA |
Zep 的 Graphiti 使用 Neo4j 作为底层图数据库。Neo4j 是图数据库领域的标杆产品,支持原生图遍历、Cypher 查询语言、分布式集群部署。它的能力毋庸置疑——但对于 MemPalace 的使用场景来说,这些能力大部分是过剩的。
MemPalace 的知识图谱查询模式非常集中:以实体为中心查询直接关系,带可选的时间过滤。它不需要多跳遍历("找到与 Kai 有三度关系的所有人"),不需要复杂的图算法(最短路径、社区检测),不需要水平扩展到多节点集群。
对于这种查询模式,SQLite 有三个决定性的优势:
零运维。 SQLite 是一个嵌入式数据库,不需要启动服务、配置连接、管理进程。它就是一个文件。~/.mempalace/knowledge_graph.sqlite3,仅此而已。不需要 Docker,不需要数据库管理员,不需要凌晨三点被告警叫醒。
本地优先。 数据永远在你的机器上。不需要网络连接,不需要身份验证,不需要担心第三方服务的隐私政策变更。你的知识图谱就在你的文件系统里,和你的代码、你的笔记、你的照片放在一起。
够用。 一个人或一个小团队在几年内能积累多少知识图谱数据?几千个实体,几万条三元组,这已经是相当充实的知识图谱了。SQLite 处理这个量级的数据,查询时间在毫秒级。MemPalace 的索引设计(四个索引覆盖主要查询路径)保证了即使数据量翻十倍,性能也不会成为瓶颈。
当然,选择 SQLite 也意味着放弃了一些东西:没有原生的图遍历算法,没有可视化查询界面,没有多用户并发写入的能力。但这些在个人 AI 记忆系统的场景下都不是硬需求。这是一个典型的工程权衡:用放弃不需要的能力来换取运维成本为零。
query_relationship():按关系类型查询
除了以实体为中心的查询,MemPalace 还提供了以关系类型为中心的查询接口(knowledge_graph.py:243-272):
def query_relationship(self, predicate: str, as_of: str = None):
这个方法返回所有具有特定关系类型的三元组。比如 kg.query_relationship("works_on") 返回所有"在做某个项目"的关系,kg.query_relationship("works_on", as_of="2026-01-01") 则只返回在 2026 年 1 月 1 日仍然有效的工作关系。
这种查询模式在矛盾检测中特别有用。当系统需要验证"Soren 完成了 auth migration"这个声明时,它可以调用 query_relationship("assigned_to") 来查看 auth-migration 项目实际上分配给了谁。我们将在第 12 章详细讨论这个机制。
从已知事实播种
seed_from_entity_facts() 方法(knowledge_graph.py:338-384)展示了知识图谱如何被初始化。它接受一个结构化的实体事实字典,然后批量创建实体和三元组:
def seed_from_entity_facts(self, entity_facts: dict):
"""
Seed the knowledge graph from fact_checker.py ENTITY_FACTS.
This bootstraps the graph with known ground truth.
"""
这个方法处理多种关系类型:child_of(亲子关系)、married_to(婚姻关系)、is_sibling_of(兄弟姐妹关系)、is_pet_of(宠物关系),以及 loves(兴趣爱好)。每种关系都带有适当的 valid_from 时间戳——亲子关系从出生日期开始,兴趣爱好从 2025-01-01 开始。
注释中提到数据来自 fact_checker.py ENTITY_FACTS,这意味着存在一个独立的事实验证模块,维护着一组已验证的基准事实。知识图谱的播种过程本质上是将这些基准事实从一种数据结构(Python 字典)转换为另一种数据结构(SQLite 三元组)。这种设计将"事实来源"和"事实存储"解耦——你可以更换知识图谱的实现而不影响事实验证逻辑,反之亦然。
stats():图谱概览
stats() 方法(knowledge_graph.py:315-334)提供了知识图谱的全局统计信息:
return {
"entities": entities,
"triples": triples,
"current_facts": current,
"expired_facts": expired,
"relationship_types": predicates,
}
current_facts 和 expired_facts 的区分特别有意义。如果一个知识图谱中 expired_facts 远大于 current_facts,说明这个图谱覆盖了很长的时间跨度,积累了大量历史。如果 current_facts 远大于 expired_facts,说明大部分事实都是新近录入且仍然有效的。这个比例本身就是一种元数据,告诉你知识图谱的"年龄"和"活跃度"。
实体 ID 标准化
一个看似微小但非常重要的设计细节是实体 ID 的生成方式(knowledge_graph.py:92-93):
def _entity_id(self, name: str) -> str:
return name.lower().replace(" ", "_").replace("'", "")
所有实体名称在存储前都被标准化:转为小写,空格替换为下划线,撇号被删除。这意味着 "Kai"、"kai"、"KAI" 都会被映射到同一个实体 ID "kai"。
这个设计解决了一个非常实际的问题:当从多个来源(对话、文档、代码注释)提取事实时,同一个实体几乎一定会以不同的大小写和格式出现。如果不做标准化,知识图谱中会出现 kai、Kai、KAI 三个实体,它们之间没有任何关系——但实际上它们是同一个人。
标准化函数故意设计得非常简单。它不尝试处理复杂的同义词问题(比如"张三"和"Zhang San"是同一个人),也不尝试做实体消歧(同名不同人)。它只处理最常见的变体情况。更复杂的实体解析留给上游的实体检测模块。
设计哲学总结
回顾整个 knowledge_graph.py 的设计,有几个贯穿始终的原则:
时间是一等公民。 每一条写入操作都接受时间参数,每一条查询操作都支持时间过滤。时间不是事后添加的标注,而是数据模型的核心维度。
宽容输入,精确输出。 写入时,允许缺少时间信息、缺少实体类型、缺少溯源信息。查询时,精确地根据时间窗口过滤,精确地区分当前事实和历史事实。系统不会因为数据不完美而拒绝工作,但也不会因为数据不完美而给出模糊的答案。
只增长,不收缩。 invalidate() 不删除数据,只标记结束时间。add_triple() 不覆盖已结束的记录,而是创建新记录。知识图谱是一部编年史,每一页都被保留。
本地优先,零依赖。 SQLite 作为存储引擎,不需要外部服务,不需要网络连接,不需要额外的进程管理。整个知识图谱就是你文件系统里的一个 .sqlite3 文件。
这些原则共同构成了 MemPalace 时态知识图谱的设计哲学。它不追求图数据库的全部能力,而是在个人 AI 记忆系统这个具体场景下,用最小的复杂度实现了最关键的时态功能。
下一章,我们将看到时态 KG 的一个重要应用:矛盾检测。当一条新的声明与知识图谱中的已有事实不一致时,系统如何发现并报告这种不一致?答案就藏在时间窗口的交叉比对之中。
第12章:矛盾检测
定位:第四部分"时间维度"的中间章。分析 MemPalace 如何利用时态知识图谱检测归属冲突、过期信息和不一致日期——从 README 中的三个具体示例出发,推断实现机制,并讨论误报与漏报的工程权衡。
AI 自信地犯错
大语言模型有一个众所周知的特点:它们犯错时毫不犹豫。它们不会说"我不确定"或者"让我查一下"——它们会以和正确回答完全相同的语气,流畅地给出一个错误答案。
当 AI 充当你的记忆系统时,这个特点变得格外危险。如果你的 AI 助手记住了"Soren 负责 auth migration",而实际上 Maya 才是负责人,那么接下来所有基于这条错误信息做出的决策都是建立在沙子上的。更糟糕的是,你可能永远不会发现这个错误——因为你信任你的记忆系统,正如你信任你自己的记忆。
MemPalace 的矛盾检测机制正是为了应对这个问题。它不是试图让 AI 不犯错(这在当前技术条件下不可能),而是在 AI 即将犯错时拉响警报。
三个具体的矛盾场景
MemPalace 的 README 展示了三种不同类型的矛盾检测(README.md:262-273):
Input: "Soren finished the auth migration"
Output: AUTH-MIGRATION: attribution conflict -- Maya was assigned, not Soren
Input: "Kai has been here 2 years"
Output: KAI: wrong_tenure -- records show 3 years (started 2023-04)
Input: "The sprint ends Friday"
Output: SPRINT: stale_date -- current sprint ends Thursday (updated 2 days ago)
这三个例子看似简单,实际上代表了三种完全不同的检测逻辑。让我们逐一分析。
场景一:归属冲突
Input: "Soren finished the auth migration"
Output: AUTH-MIGRATION: attribution conflict -- Maya was assigned, not Soren
这条声明包含一个隐含的归属断言:auth migration 是 Soren 的工作。要检测这个矛盾,系统需要:
- 识别出声明中涉及的实体:
Soren(人)和auth-migration(项目/任务)。 - 识别出声明中的关系类型:某种"完成"或"负责"的关系。
- 查询知识图谱:auth-migration 实际上被分配给了谁?
从第 11 章分析的 knowledge_graph.py 来看,这种查询对应的操作是以 auth-migration 为实体查询 incoming 方向的关系:
kg.query_entity("auth-migration", direction="incoming")
或者直接查询特定关系类型:
kg.query_relationship("assigned_to")
知识图谱中存在的三元组可能是:
Maya → assigned_to → auth-migration (valid_from: 2026-01-15, valid_to: NULL)
当新的声明试图建立 Soren → completed → auth-migration 这条关系时,系统发现 auth-migration 的 assigned_to 关系指向的是 Maya 而非 Soren。两个不同的人被关联到同一个任务的责任归属上——这就是归属冲突。
关键在于:这种检测不需要理解自然语言的全部语义。它只需要做三件事——提取实体、识别关系类型、与已知事实交叉比对。知识图谱提供了比对的基准,时态信息确保比对的是当前有效的事实。
场景二:任期错误
Input: "Kai has been here 2 years"
Output: KAI: wrong_tenure -- records show 3 years (started 2023-04)
这个矛盾涉及动态计算。声明中的"2 years"不是一个可以直接存储在知识图谱中的静态值——它需要从 Kai 的入职日期和当前日期推算出来。
知识图谱中存储的三元组可能是:
Kai → started_at → Company (valid_from: 2023-04, valid_to: NULL)
检测逻辑大致如下:
- 从声明中提取实体
Kai和数值声明2 years。 - 查询
Kai的started_at或类似的入职关系。 - 从
valid_from(2023-04)到当前日期计算实际任期。 - 如果计算结果(约 3 年)与声明中的数值(2 年)不一致,触发警报。
这种检测的核心能力来自时态 KG 的 valid_from 字段。如果知识图谱只存储"Kai 在公司工作"这个静态事实,它无法判断任期声明是否正确。正是因为它存储了"Kai 从 2023 年 4 月开始在公司工作",系统才有了计算任期的基础数据。
注意输出中的 (started 2023-04) —— 系统不仅指出了矛盾,还给出了判断依据。这让用户可以决定:是知识图谱中的日期有误,还是声明中的数字不对。矛盾检测不做最终裁决,它只是把不一致呈现给人类。
场景三:日期过期
Input: "The sprint ends Friday"
Output: SPRINT: stale_date -- current sprint ends Thursday (updated 2 days ago)
这个场景检测的是一种更微妙的矛盾:声明本身可能在几天前是正确的,但现在已经过期了。
知识图谱中可能存在两条关于 sprint 结束日期的三元组:
Sprint → ends_on → Friday (valid_from: 2026-03-20, valid_to: 2026-03-23)
Sprint → ends_on → Thursday (valid_from: 2026-03-23, valid_to: NULL)
第一条三元组已经被 invalidate() 标记为结束(因为 sprint 的结束日期在两天前被更新了),第二条三元组是当前有效的。
当声明引用"Friday"时,系统用 as_of 参数查询当前有效的 sprint 结束日期,发现当前记录显示的是 Thursday,而不是 Friday。(updated 2 days ago) 这个附加信息来自第一条三元组的 valid_to 日期——它告诉你信息是什么时候变过期的。
这就是 invalidate() 方法(knowledge_graph.py:169-182)在矛盾检测中的价值。它不是在删除错误信息,而是在记录信息的生命周期。旧的事实变成了历史,新的事实取而代之,而系统可以精确地告诉你这个转变发生在什么时候。
实现机制推断
从三个场景中可以归纳出矛盾检测的通用流程:
输入声明
|
v
实体提取 —— 从声明中识别涉及的人、项目、时间、数值
|
v
关系映射 —— 推断声明中隐含的关系类型
|
v
知识图谱查询 —— 用 query_entity() 或 query_relationship() 获取已知事实
|
v
交叉比对 —— 将声明中的断言与已知事实对比
|
v
矛盾报告 —— 如果发现不一致,生成包含矛盾类型和依据的报告
其中,第一步(实体提取)和第二步(关系映射)是自然语言处理任务。从 MemPalace 的整体架构来看,这部分工作大概率由 LLM 完成——要么是用户正在对话的那个 LLM(通过 MCP 工具调用),要么是 MemPalace 内部集成的一个轻量级语言处理模块。
第三步(知识图谱查询)直接使用 KnowledgeGraph 类的查询方法。从第 11 章的分析可知,query_entity() 支持 as_of 时间过滤和方向控制,query_relationship() 支持按关系类型查询——这两个接口足以覆盖上述三种矛盾类型的查询需求。
第四步(交叉比对)是核心判断逻辑。它需要根据矛盾类型执行不同的比对策略:
- 归属冲突:检查同一任务/项目是否被分配给了不同的人。比对条件是同一个 object 实体存在多条不同 subject 的
assigned_to类关系。 - 数值不一致:从时间戳计算动态值(任期、年龄等),与声明中的数值比对。
- 日期过期:查询当前有效的日期类事实,与声明中引用的日期比对。
置信度的角色
知识图谱中每条三元组都有一个 confidence 字段(knowledge_graph.py:72),默认值为 1.0。这个字段在矛盾检测中扮演着重要角色。
当两条事实发生矛盾时,置信度提供了一种优先级判断:如果知识图谱中的事实置信度为 1.0(完全确信),而新声明来自一段随意的对话(可能的置信度较低),那么系统倾向于信任已有事实。反之,如果已有事实的置信度本来就不高,那么矛盾可能意味着新的声明提供了更准确的信息。
置信度不是矛盾检测的决策依据——系统仍然会报告矛盾——但它为矛盾报告提供了上下文。"知识图谱中有一条置信度 0.6 的记录与你的声明矛盾"和"知识图谱中有一条置信度 1.0 的基准事实与你的声明矛盾",这两种情况的严重程度是不同的。
source_closet 的溯源价值
当矛盾被检测到时,source_closet 字段(knowledge_graph.py:74)可以提供溯源能力。更准确地说:如果写入知识图谱时调用方提供了这个可选字段,系统不仅可以告诉你"Maya 被分配到了 auth migration",还可以告诉你这条信息来自哪个 closet——也就是继续追溯到原始的对话记录或文档。当前源码并不会在默认流程里自动补全这条链路。
这种溯源能力是 MemPalace 宫殿结构与知识图谱协同工作的接口体现。知识图谱负责快速的结构化查询("谁被分配到了这个任务"),宫殿结构负责深度的上下文检索("当时的对话是怎么说的")。两者在 schema 里通过 source_closet 字段连接;至于每条事实是否真的带着这根"线",取决于写入时是否提供了它。
矛盾分类
从 README 的三个示例中可以提取出一个矛盾分类体系。注意输出中使用了不同的严重级别标记——归属冲突被标记为红色(高严重度),任期错误和日期过期被标记为黄色(中等严重度)。
这种分级背后的逻辑是:
高严重度(归属冲突):某个人被错误地归属为某件事的负责人。这种错误的后果可能是很严重的——你可能因此在会议上感谢了错误的人,或者把后续任务分配给了错误的人。
中等严重度(数值不一致):任期、年龄等数值声明与记录不符。这种错误通常是无心的近似(记成"2 年"而实际是"3 年"),后果相对有限,但仍然值得纠正。
中等严重度(日期过期):引用了已经更新过的时间信息。这种错误通常是因为说话的人不知道信息已经变了,而不是因为他们记错了。
一个更完整的矛盾分类可能包括:
| 矛盾类型 | 严重度 | 检测方法 |
|---|---|---|
| 归属冲突 | 高 | 同一任务的不同归属者比对 |
| 数值不一致 | 中 | 从时间戳动态计算后比对 |
| 日期过期 | 中 | 当前有效事实与声明比对 |
| 状态矛盾 | 高 | 已结束的事实被引用为进行中 |
| 关系矛盾 | 中 | 不兼容的关系类型同时存在 |
误报与漏报
任何检测系统都面临误报(false positive)和漏报(false negative)的权衡。矛盾检测也不例外。
误报场景
同名不同实体。 如果团队中有两个 "Jordan",一个是设计师,一个是后端工程师。当声明提到"Jordan 完成了 UI 设计"时,系统可能会因为另一个 Jordan 是后端工程师而错误地触发归属冲突。
从 _entity_id() 的实现(knowledge_graph.py:92-93)可以看到,实体 ID 是通过简单的字符串标准化生成的——"jordan" 就是 "jordan",没有消歧机制。这意味着同名实体会被合并为一个节点,从而可能导致虚假的矛盾。
解决方案可能包括在实体注册时使用全名或加上限定词("Jordan Chen" vs "Jordan Kim"),但这要求上游的实体提取足够精确。
语义理解偏差。 "Soren helped with the auth migration" 与 "Soren finished the auth migration" 表达的是不同的关系——"帮忙"不等于"负责"。如果系统把"帮忙"也理解为归属关系,就会产生误报。
这类误报取决于关系映射阶段的精度。如果关系映射过于宽泛(把所有人-任务的关联都当作归属关系),误报率会上升;如果过于严格(只有明确的"负责"、"完成"才算归属关系),漏报率会上升。
时间粒度不匹配。 知识图谱中的 valid_from 使用日期字符串("2026-01-15"),而声明中可能使用更模糊的时间表达("上个月"、"去年年底")。如果这两者之间的转换不够准确,可能导致误报。
漏报场景
知识图谱不完整。 如果某条事实从未被录入知识图谱,系统就无法检测与它相关的矛盾。比如,如果知识图谱中没有记录 Maya 被分配到 auth migration,那么"Soren 完成了 auth migration"就不会触发任何警报——因为系统不知道应该是谁负责的。
这是最根本的漏报来源。矛盾检测只能在已知事实的范围内工作。知识图谱的覆盖度直接决定了矛盾检测的召回率。
隐含矛盾。 有些矛盾不是直接的事实冲突,而是逻辑推断才能发现的。比如,"Kai 上周一直在度假"和"Kai 上周审查了 12 个 PR"——这两条声明在表面上没有共享任何实体关系,但逻辑上是矛盾的(度假时不太可能审查 12 个 PR)。这种推断性矛盾超出了简单的三元组比对的能力范围。
渐变矛盾。 有些事实不是突然变成错误的,而是逐渐偏离现实的。比如,"我们团队有 5 个人"在三个月前是对的,但这三个月里陆续有人加入和离开,现在实际上是 7 个人。如果没有人显式地 invalidate() 旧的团队规模信息并录入新的,知识图谱会继续认为"5 个人"是有效的。
工程策略
面对误报和漏报的权衡,MemPalace 的设计选择倾向于宁可误报,不要漏报。理由很直接:一个误报只会让用户花几秒钟确认"哦,这次没问题";一个漏报可能让一个错误的事实在系统中存活数月,影响所有后续的回答和决策。
从 README 中的输出格式可以看到,矛盾报告附带了完整的判断依据("Maya was assigned, not Soren";"records show 3 years (started 2023-04)";"current sprint ends Thursday (updated 2 days ago)")。这让用户可以快速评估一个矛盾报告是真实的矛盾还是误报,从而降低误报对用户体验的负面影响。
矛盾检测的闭环
矛盾检测不是一个终点,而是一个闭环的起点。当矛盾被检测到后,有三种可能的处理路径:
路径一:修正声明。 用户承认声明有误。"哦对,确实是 Maya 在做 auth migration,不是 Soren。" 知识图谱不需要变化。
路径二:更新知识图谱。 用户确认声明是正确的,知识图谱需要更新。比如,auth migration 的负责人确实从 Maya 变成了 Soren。这时需要调用 invalidate() 结束 Maya 的 assigned_to 关系,然后用 add_triple() 创建 Soren 的新关系。
路径三:标记为需要调查。 用户不确定哪个版本是对的。这种情况下,矛盾本身就是有价值的信息——它标记了知识图谱中的一个不确定区域,提醒用户下次遇到相关话题时需要核实。
三种路径都比"让错误信息悄悄溜过去"要好。矛盾检测的核心价值不在于它的准确率是 90% 还是 99%,而在于它把"AI 可能在犯错"这个事实从隐性变成了显性。
深层设计思考
矛盾检测与时态 KG 的耦合
三种矛盾类型中,有两种(数值不一致、日期过期)直接依赖时态 KG 的能力。如果知识图谱没有 valid_from 和 valid_to 字段,任期就无法从起始日期动态计算,过期的日期就无法与当前有效的日期区分。
归属冲突检测在理论上可以在静态 KG 上实现(只要存储了归属关系),但在实践中,时态 KG 使得检测更加精确——它可以区分"Maya 现在负责 auth migration"和"Maya 曾经负责 auth migration(但已经转交给别人了)"。
这意味着矛盾检测不是一个独立的功能模块,而是时态知识图谱的自然延伸。有了时间维度,矛盾检测几乎是"免费"获得的——你只需要把新的声明与现有事实做时间感知的比对就够了。
矛盾检测的规模边界
当前的实现假设知识图谱的规模是可管理的——几百个实体,几千条三元组。在这个规模下,全量扫描 query_relationship() 的结果是完全可行的。
但如果知识图谱增长到百万级三元组(比如一个大型组织的完整知识库),逐条比对的策略就需要演进。可能的方向包括:为矛盾检测建立专门的索引(比如按实体对索引的归属关系表)、引入增量检测(只对新增三元组做矛盾检查,而不是每次全量比对)、或者使用规则引擎定义矛盾模式而不是硬编码检测逻辑。
不过,对于 MemPalace 的目标场景——个人或小团队的 AI 记忆系统——当前的实现足够了。知识图谱的规模增长速度受限于用户的对话量和事实提取率,几年内不太可能达到需要优化检测策略的量级。
这又是一个工程权衡:为当前的规模设计,而不是为想象中的未来规模过度设计。SQLite 处理几千条三元组的查询用时在毫秒级,矛盾检测的额外开销可以忽略不计。等到真的需要优化的那一天,再优化不迟。
小结
矛盾检测是 MemPalace 时态知识图谱最具实用价值的应用之一。它把 AI 记忆系统从"什么都记住"提升到了"记住,并且在记错的时候告诉你"。
三种矛盾类型——归属冲突、数值不一致、日期过期——各自代表了不同的检测逻辑,但它们共享同一个基础设施:带时间窗口的知识图谱。valid_from 和 valid_to 不仅使得历史查询成为可能,还使得过期检测和动态计算成为可能。
下一章,我们将看到时态知识图谱的另一个应用:时间线叙事。当你需要了解一个项目或一个人的完整历史时,timeline() 方法如何把离散的三元组编织成一个可读的编年史。
第13章:时间线叙事
定位:第四部分"时间维度"的收束章。从
timeline()方法的实现出发,展示如何将离散的时态三元组转化为可读的编年史,并探讨其在新人 onboarding 等场景中的应用价值。
从三元组到故事
知识图谱擅长回答结构化的查询:"Kai 现在在做什么项目?""auth migration 是谁负责的?""这条事实什么时候失效的?" 但当你需要了解一个实体的完整历史时——比如一个新加入团队的工程师想要快速了解某个项目的来龙去脉——单独的三元组查询就不够了。你需要的不是一条一条独立的事实,而是一个按时间排列的叙事。
这就是 timeline() 方法的设计目的:把离散的三元组按时间排序,形成一个可读的编年史。
timeline() 的实现
timeline() 方法位于 knowledge_graph.py:274-311。它的接口非常简洁:
def timeline(self, entity_name: str = None):
"""Get all facts in chronological order, optionally filtered by entity."""
一个参数,一个选择:你可以查看特定实体的时间线,也可以查看整个知识图谱的时间线。
实体时间线
当提供 entity_name 参数时(knowledge_graph.py:277-289):
if entity_name:
eid = self._entity_id(entity_name)
rows = conn.execute(
"""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
WHERE (t.subject = ? OR t.object = ?)
ORDER BY t.valid_from ASC NULLS LAST
""",
(eid, eid),
).fetchall()
有三个设计要点值得注意。
双向匹配。 WHERE (t.subject = ? OR t.object = ?) —— 实体可能出现在三元组的任意一端。当查询 "Kai" 的时间线时,既会包含 Kai -> works_on -> Orion 这样 Kai 作为主语的记录,也会包含 Priya -> manages -> Kai 这样 Kai 作为宾语的记录。这保证了时间线是完整的——一个人的故事不仅包括他做了什么,还包括什么发生在他身上。
时间排序。 ORDER BY t.valid_from ASC —— 按生效时间升序排列,最早的事实排在最前面。这是编年体的天然顺序:从过去走向现在。
NULL 排最后。 NULLS LAST —— 没有明确生效时间的事实被放到时间线的末尾。这些是"不知道什么时候开始的"事实。把它们排在最后而不是最前面是一个合理的选择:在一条编年史中,有确切日期的事件比没有日期的事实更有参考价值,应该优先展示。
全局时间线
当不提供 entity_name 时(knowledge_graph.py:291-298):
else:
rows = conn.execute("""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""").fetchall()
全局时间线查询所有三元组,同样按时间排序,但增加了 LIMIT 100 的限制。这是一个务实的安全阀:如果知识图谱中有上万条三元组,一次性全部返回既浪费内存又让调用方无法处理。100 条是一个合理的默认值——足以展示知识图谱的概貌,又不会过载。
返回结构
两种查询共享同一个返回格式(knowledge_graph.py:300-311):
return [
{
"subject": r[10],
"predicate": r[2],
"object": r[11],
"valid_from": r[4],
"valid_to": r[5],
"current": r[5] is None,
}
for r in rows
]
每条记录是一个字典,包含六个字段。subject、predicate、object 构成事实本身,valid_from、valid_to 标记时间窗口,current 标记是否仍然有效。
注意,这里的 subject 和 object 使用的是 r[10] 和 r[11]——这是 SQL JOIN 结果中的 sub_name 和 obj_name,也就是实体的原始显示名称而不是标准化后的 ID。这对时间线叙事至关重要:用户看到的应该是 "Kai" 而不是 "kai",是 "auth-migration" 而不是 "auth-migration" 的内部 ID。
一个完整的示例
假设我们为 Driftwood 项目建立了以下知识图谱:
kg = KnowledgeGraph()
# 项目成立
kg.add_triple("Priya", "created", "Driftwood", valid_from="2024-09-01")
kg.add_triple("Priya", "manages", "Driftwood", valid_from="2024-09-01")
# 团队组建
kg.add_triple("Kai", "joined", "Driftwood", valid_from="2024-10-01")
kg.add_triple("Soren", "joined", "Driftwood", valid_from="2024-10-15")
kg.add_triple("Maya", "joined", "Driftwood", valid_from="2024-11-01")
# 技术决策
kg.add_triple("Driftwood", "uses", "PostgreSQL", valid_from="2024-10-10")
kg.add_triple("Kai", "recommended", "Clerk", valid_from="2026-01-01")
kg.add_triple("Driftwood", "uses", "Clerk", valid_from="2026-01-15")
# 任务分配
kg.add_triple("Maya", "assigned_to", "auth-migration", valid_from="2026-01-15")
kg.add_triple("Maya", "completed", "auth-migration", valid_from="2026-02-01")
# 人员变动
kg.add_triple("Leo", "joined", "Driftwood", valid_from="2026-03-01")
调用 kg.timeline("Driftwood"),返回结果如下:
[
{"subject": "Priya", "predicate": "created", "object": "Driftwood", "valid_from": "2024-09-01", "valid_to": None, "current": True},
{"subject": "Priya", "predicate": "manages", "object": "Driftwood", "valid_from": "2024-09-01", "valid_to": None, "current": True},
{"subject": "Kai", "predicate": "joined", "object": "Driftwood", "valid_from": "2024-10-01", "valid_to": None, "current": True},
{"subject": "Driftwood", "predicate": "uses", "object": "PostgreSQL", "valid_from": "2024-10-10", "valid_to": None, "current": True},
{"subject": "Soren", "predicate": "joined", "object": "Driftwood", "valid_from": "2024-10-15", "valid_to": None, "current": True},
{"subject": "Maya", "predicate": "joined", "object": "Driftwood", "valid_from": "2024-11-01", "valid_to": None, "current": True},
{"subject": "Kai", "predicate": "recommended", "object": "Clerk", "valid_from": "2026-01-01", "valid_to": None, "current": True},
{"subject": "Driftwood", "predicate": "uses", "object": "Clerk", "valid_from": "2026-01-15", "valid_to": None, "current": True},
{"subject": "Maya", "predicate": "assigned_to", "object": "auth-migration", "valid_from": "2026-01-15", "valid_to": None, "current": True},
{"subject": "Maya", "predicate": "completed", "object": "auth-migration", "valid_from": "2026-02-01", "valid_to": None, "current": True},
{"subject": "Leo", "predicate": "joined", "object": "Driftwood", "valid_from": "2026-03-01", "valid_to": None, "current": True},
]
从这组结果中,一个人类读者或者一个 LLM 可以重建出 Driftwood 项目的完整故事:
2024 年 9 月,Priya 创建了 Driftwood 项目并担任管理者。10 月,Kai 加入团队,团队选择了 PostgreSQL 作为数据库。随后 Soren 在 10 月中旬加入,Maya 在 11 月加入。
进入 2026 年,Kai 在 1 月推荐了 Clerk 作为认证方案,团队在 1 月 15 日正式采纳。Maya 同时被分配了 auth migration 任务,并在 2 月 1 日完成。3 月,新成员 Leo 加入了团队。
这就是"从三元组到故事"的过程。原始数据是一组离散的、结构化的事实记录;经过时间排序后,它们自然地排列成一条叙事线,因果关系浮现出来——Kai 推荐 Clerk 在前,团队采纳 Clerk 在后;Maya 被分配任务在前,完成任务在后。
排序、聚合与格式化
timeline() 方法本身只完成了第一步:排序。它把三元组按 valid_from 升序排列,返回一个按时间排列的列表。但从原始列表到可读叙事之间,还有两个步骤通常由调用方完成。
聚合
原始时间线中,每条记录都是独立的三元组。但在叙事中,某些三元组应该被聚合展示。比如,Kai 和 Soren 都在 2024 年 10 月加入 Driftwood,在叙事中可以合并为"10 月,Kai 和 Soren 先后加入团队"。
聚合的策略可以很简单:按 valid_from 的月份(或周、日)分组,把同一时间段内的同类事件合并。具体的分组粒度取决于时间线的跨度——如果项目历史跨越数年,按月分组比较合理;如果只有几周,按日分组更清晰。
格式化
时间线数据可以被格式化为多种形式:
纯文本编年史——就像上面那段重建的叙事,适合在对话中直接呈现给用户。
结构化时间轴——按时间段分组的项目符号列表,适合快速扫描:
2024-09 Priya 创建了 Driftwood 项目
2024-10 Kai 加入 | 选择 PostgreSQL | Soren 加入
2024-11 Maya 加入
2026-01 Kai 推荐 Clerk | 团队采纳 Clerk | Maya 开始 auth migration
2026-02 Maya 完成 auth migration
2026-03 Leo 加入
AAAK 压缩格式——利用 MemPalace 的 AAAK 方言进一步压缩,适合作为 AI 上下文的一部分:
TL:DRIFTWOOD|PRI.create(24-09)|KAI.join(24-10)|PG.adopt(24-10)|SOR.join(24-10)|MAY.join(24-11)|CLK.rec:KAI(26-01)|CLK.adopt(26-01)|MAY.auth-mig(26-01>26-02)|LEO.join(26-03)
timeline() 方法返回的是结构化数据而非格式化文本,这给了调用方最大的灵活性。MCP 服务器可以把它格式化为对话中的自然语言回复,CLI 可以把它格式化为终端输出的表格,AAAK 压缩器可以把它转换为超紧凑的时间线摘要。
应用场景:新人 Onboarding
时间线叙事最直接的应用场景是新人 onboarding。当一个新的团队成员加入时,他们面对的不是一个新建的、空白的项目——他们面对的是一个有历史的系统,背后有无数次讨论、决策、推翻和重来。
传统的 onboarding 依赖于两种信息来源:文档和口头传授。文档往往是过时的(上次更新是六个月前),口头传授则是碎片化的(取决于你问了谁、问了什么问题)。
时间线叙事提供了第三种途径:从知识图谱自动生成的、按时间排列的项目历史。
一个 onboarding 场景
Leo 是 Driftwood 团队的新成员,2026 年 3 月加入。他对项目一无所知。他的 AI 助手通过 MCP 调用 mempalace_kg_timeline:
> 告诉我 Driftwood 项目的历史
调用 mempalace_kg_timeline("Driftwood")
Driftwood 项目时间线:
2024 年 9 月 - Priya 创建了项目并担任管理者
2024 年 10 月 - Kai 加入(后端),团队选择了 PostgreSQL
2024 年 10 月中 - Soren 加入(前端)
2024 年 11 月 - Maya 加入(基础设施)
2026 年 1 月 - Kai 推荐 Clerk 替代 Auth0(理由:定价和开发者体验)
2026 年 1 月 15 日 - 团队采纳 Clerk,Maya 负责 auth migration
2026 年 2 月 1 日 - Maya 完成 auth migration
2026 年 3 月 - 你(Leo)加入团队
这段回复让 Leo 在几秒钟内了解了项目的完整脉络。他知道了谁创建了项目,团队是怎么组建的,做了哪些关键技术决策,以及为什么做了这些决策。
更重要的是,他可以继续追问细节。时间线是入口,不是终点:
> Kai 为什么推荐 Clerk 而不是 Auth0?
调用 mempalace_search("Clerk Auth0 decision")
来自 2026 年 1 月的对话记录:
"Kai 比较了 Clerk 和 Auth0 的定价模型,发现 Clerk 在我们预期的
用户规模下便宜 40%。此外,Clerk 的 SDK 文档更好,集成测试更
容易写。团队 1 月 15 日会议上一致同意采用 Clerk。"
时间线提供了概貌;如果知识图谱条目在写入时带上了 source_closet 链接,它就还能继续追溯到原始的对话记录或文档,宫殿结构的语义搜索则提供完整的上下文。三个系统协同工作,形成了从"全景"到"特写"的信息检索链条。
与传统 onboarding 的对比
| 维度 | 传统 onboarding | 时间线叙事 |
|---|---|---|
| 信息来源 | 文档 + 口头传授 | 知识图谱自动生成 |
| 时效性 | 依赖人工更新 | 随知识图谱实时更新 |
| 完整性 | 取决于文档维护者的勤奋程度 | 覆盖所有录入的事实 |
| 交互性 | 静态文档 | 可以追问细节 |
| 个性化 | 通用文档,不区分读者 | 可以针对特定角色或关注点过滤 |
时间线叙事不能完全替代传统的 onboarding。有些知识——团队文化、沟通风格、非正式的工作规范——不适合被编码为三元组。但对于"项目的技术决策历史"这个特定维度,时间线叙事提供了一种比文档和口头传授都更可靠的方式。
时间线的局限
timeline() 方法的设计是有意识地简约的,这种简约带来了一些局限。
没有因果关系。 时间线只是按时间排序的事实列表。它不能告诉你事实之间的因果关系——"Kai 推荐了 Clerk" 和 "团队采纳了 Clerk" 在时间上相邻,人类读者可以推断前者导致了后者,但时间线数据本身不编码这种关系。
因果关系需要更复杂的知识表示——比如事件之间的 caused_by 或 led_to 关系。这超出了当前时态三元组模型的范围。但从另一个角度看,让 LLM 从时间排列的事实中推断因果关系,正是 LLM 擅长的事情。时间线提供了素材,LLM 负责叙事。
没有重要度排序。 所有事实被平等对待。"Priya 创建了 Driftwood"和"Driftwood 使用 PostgreSQL"在时间线中占据同样的位置,但前者显然比后者对项目叙事更重要。
一种可能的改进是引入重要度标记(可以利用 confidence 字段或新增一个 importance 字段),让时间线可以按重要度过滤。但这又引入了"谁来判断重要度"的问题——与 MemPalace"不让 AI 决定什么重要"的核心理念存在张力。
全局时间线的 100 条限制。 LIMIT 100 是一个硬编码的安全阀。对于小规模知识图谱来说这足够了,但如果知识图谱增长到数千条三元组,100 条可能只覆盖了很早期的历史。一种改进是支持分页查询(提供 offset 和 limit 参数),或者支持按时间范围过滤(只查看最近 6 个月的时间线)。
这些局限都是有意识的设计取舍。MemPalace 的时间线功能定位是"够用"——提供足够的结构让 LLM 可以生成可读的叙事,但不试图自己成为一个完整的时间线分析工具。复杂的因果推理、重要度判断、跨时间段的趋势分析——这些更适合在 LLM 层面完成,时间线只需要提供干净的、按时间排序的结构化数据作为输入。
时间线与宫殿结构的关系
时间线叙事是 MemPalace 三大子系统协作的一个缩影。
知识图谱提供结构化的事实和时间信息。timeline() 方法从这里获取排序后的三元组列表。
宫殿结构提供上下文和原始记忆。更准确地说,当时间线中的事实带有 source_closet 或 source_file 这类溯源字段时,它就可以回指到宫殿中的相应位置,再进一步连接到原始逐字内容。当前 schema 支持这条链路,但是否存在,取决于写入知识图谱时是否提供了这些字段。
AAAK 方言提供压缩能力。当时间线需要作为 AI 上下文的一部分加载时,AAAK 可以将一段完整的项目编年史压缩到极少的 token 中。
这种协作是自然的、无缝的。三个子系统各自做好自己的事情——知识图谱管理事实,宫殿结构管理记忆,AAAK 管理压缩——然后通过简单的接口协同工作。没有哪个子系统依赖另一个子系统的内部实现细节。
这种低耦合、高协作的架构风格,正是 MemPalace 能在只有两个外部依赖的情况下实现丰富功能的原因之一。
小结
timeline() 方法用不到 40 行代码(knowledge_graph.py:274-311)实现了从离散三元组到编年史的转换。它的设计遵循了 MemPalace 一贯的哲学:简单的数据结构,清晰的查询接口,把复杂的呈现和推理工作留给 LLM。
时间排序是最基本的叙事结构。ORDER BY t.valid_from ASC 这一行 SQL 把一堆无序的事实变成了一条故事线。双向匹配(subject = ? OR object = ?)保证了故事的完整性。NULLS LAST 把缺少时间信息的事实放到最后,避免它们干扰编年体的主线。
这不是一个复杂的功能。但它不需要是。它只需要提供足够好的原材料,让 LLM 来完成"讲故事"的最后一公里。这种"基础设施做简单的事,智能层做复杂的事"的分工,是 MemPalace 架构设计中反复出现的模式。
至此,第四部分"时间维度"结束。我们看到了时态知识图谱如何给事实赋予生命周期(第 11 章),如何利用时间信息检测矛盾(第 12 章),以及如何将离散的时态事实编织成可读的叙事(第 13 章)。这三章共同展示了一个核心洞见:在 AI 记忆系统中,时间不是元数据,时间是数据本身。
第14章:L0-L3——四层记忆栈的分层设计
定位:本章拆解 MemPalace 四层记忆栈的设计理由与实现。我们将逐层分析每一层解决什么问题、占用多少 token、何时加载,以及这种分层为何是四层而非其他数目。源码分析基于
mempalace/layers.py。
一个关于"唤醒"的问题
当你的 AI 助手从零开始一个新会话时,它对你一无所知。它不知道你叫什么名字,不知道你在做什么项目,不知道你昨天做了一个关键的架构决策。每一次新会话都是一次彻底的失忆。
解决这个问题的朴素方法是:把所有历史对话塞进上下文窗口。但正如本书前面章节分析的那样,六个月的日常 AI 使用大约产生 1950 万 token——这远超任何模型的上下文窗口。即使将来上下文窗口扩大到一亿 token,这种暴力装载的方式也有根本性的成本问题:每一次对话都要为数百万 token 付费,而其中 99% 在当前对话中毫无用处。
另一个常见方法是让 LLM 在每次对话后提取"重要信息",存成摘要。但正如我们反复讨论的,这种方法在存储阶段就引入了不可逆的信息损失。
MemPalace 的答案是一个四层记忆栈:不是存更多,也不是存更少,而是在正确的时机加载正确的量。
为什么是"栈"而不是"数据库"
在讨论具体的层级之前,值得先理解为什么 MemPalace 选择了"栈"这个隐喻。
传统数据库是扁平的:所有数据住在同一个层面,通过查询语言按需提取。但人类的记忆不是这样工作的。你不需要回忆自己的名字——这个信息始终在意识表面;你不需要刻意去想今天早上做了什么——这些近期经历在"工作记忆"中随时可用;但如果有人问你三年前某次旅行的细节,你需要主动去"搜索"长期记忆。
这种分层不是偶然的。认知科学将人类记忆分为感觉记忆、短期(工作)记忆和长期记忆,每一层有不同的容量、持续时间和提取成本。MemPalace 的四层栈直接映射了这种认知结构——不是因为仿生是目的,而是因为这种分层恰好解决了 AI 上下文管理中的实际工程问题。
核心问题是:如何在有限的 token 预算中最大化信息效用?
答案是按频率和紧迫性分层。有些信息每次对话都需要("我是谁"),有些只在特定话题出现时需要("这个项目的最近讨论"),有些只在明确提问时才需要("去年三月关于 GraphQL 的讨论")。把它们全部放在同一层处理,要么太贵,要么太慢,要么两者兼有。
四层全景
在进入每一层的细节之前,先看完整的栈结构:
| 层级 | 内容 | 典型大小 | 加载时机 | 设计动机 |
|---|---|---|---|---|
| L0 | 身份——"我是谁" | ~50-100 token | 始终加载 | AI 需要知道自己的角色和基本人际关系 |
| L1 | 关键事实——最重要的记忆 | ~500-800 token(当前实现) | 始终加载 | 最小可用上下文:团队、项目、核心偏好 |
| L2 | 房间回忆——按需检索 | ~200-500 token/次 | 显式 recall() 时 | 当前话题的一批相关上下文 |
| L3 | 深度搜索——语义检索 | 无上限 | 明确提问时 | 全量语料的语义搜索 |
graph TB
subgraph "始终加载"
L0["L0 身份<br/>~50 token"]
L1["L1 关键事实<br/>~500-800 token(当前)"]
end
subgraph "按需加载"
L2["L2 房间回忆<br/>~200-500 token"]
L3["L3 深度搜索<br/>无限制"]
end
L0 --> L1
L1 -.->|上层显式调用 recall| L2
L2 -.->|显式语义查询| L3
在当前 v3.0.0 源码里,L0 + L1 构成的"唤醒成本"大约是 600-900 token。README 同时给出了一个更激进的目标口径:如果将 AAAK 真正接入唤醒路径,L0 + L1 可压到约 170 token。两者不能混为一谈。本章以下分析,凡是讨论 layers.py 的现状,都以 600-900 token 为准;凡是讨论更长期的压缩方向,才引用 README 中的 170 token 目标。
这意味着:即使不引入 AAAK,MemPalace 的默认唤醒也仍然是一个相对便宜的常驻上下文层;而 README 中的 170 token,则代表这个设计在压缩路径完全接通后的上限目标。
L0:身份层
Layer 0: Identity (~100 tokens) — Always loaded. "Who am I?"
L0 是整个栈中最简单的一层,也是最不可省略的一层。它回答一个根本性的问题:这个 AI 助手是谁?
在 layers.py 的实现中,Layer0 类从一个纯文本文件读取身份信息(layers.py:34-69):
class Layer0:
"""
~100 tokens. Always loaded.
Reads from ~/.mempalace/identity.txt — a plain-text file the user writes.
"""
def __init__(self, identity_path: str = None):
if identity_path is None:
identity_path = os.path.expanduser("~/.mempalace/identity.txt")
self.path = identity_path
self._text = None
def render(self) -> str:
if self._text is not None:
return self._text
if os.path.exists(self.path):
with open(self.path, "r") as f:
self._text = f.read().strip()
else:
self._text = (
"## L0 — IDENTITY\n"
"No identity configured. Create ~/.mempalace/identity.txt"
)
return self._text
几个设计选择值得注意。
纯文本,用户手写。 身份不是从对话中自动提取的,而是用户自己写的。这是一个深思熟虑的决策。身份是一种声明性知识——"我是 Atlas,Alice 的个人 AI 助手"——不需要从海量对话中挖掘。让用户自己定义身份,意味着身份永远是精确的、有意的、可控的。
文件系统,不是数据库。 L0 读取的是 ~/.mempalace/identity.txt——一个普通的文本文件,用任何编辑器都能修改。这消除了所有"如何更新身份"的复杂性。想改身份?改文件就行。
缓存读取。 render() 方法使用 _text 做了简单的缓存(layers.py:52-65)。文件只读取一次,之后直接返回缓存内容。这对 L0 来说足够了——身份在一次会话中不会变化。
优雅降级。 如果身份文件不存在,L0 不会报错,而是返回一段提示文字,引导用户创建文件(layers.py:61-63)。系统永远可以启动,无论配置是否完整。
Token 估算。 token_estimate() 方法用一个简单的启发式:字符数除以 4(layers.py:67-68)。这不是精确的 tokenizer 计算,而是一个够用的近似值。在 L0 的规模(通常几十到一百个 token),这个精度完全可以接受。
一个典型的 identity.txt 大概长这样:
I am Atlas, a personal AI assistant for Alice.
Traits: warm, direct, remembers everything.
People: Alice (creator), Bob (Alice's partner).
Project: A journaling app that helps people process emotions.
这大约 50 个 token。看起来微不足道,但它给了 AI 一个至关重要的锚点:它知道自己是"谁",它服务于"谁",它的行为风格应该是什么样的。没有这个锚点,每次对话都要从"你好,我是你的 AI 助手"开始重新建立关系。
L1:关键事实层
Layer 1: Essential Story (~500-800) — Always loaded. Top moments from the palace.
如果说 L0 是"我是谁",L1 就是"我知道什么最重要的事"。
L1 的设计目标是在最小的 token 预算内,装载对当前对话最可能有用的核心事实。它不需要包含所有记忆——那是 L3 的工作——而是提供一个"最小可用上下文",让 AI 在没有任何主动搜索的情况下就能表现出"记得你"的能力。
在 layers.py:76-168 中,Layer1 类的实现揭示了几个关键设计:
自动生成,不是手动维护。 与 L0 不同,L1 不需要用户手写。它从 ChromaDB 中的宫殿数据自动提取最重要的记忆片段(layers.py:91-168)。
重要性排序。 L1 使用一个评分机制来决定哪些记忆最值得加载。评分逻辑在 layers.py:116-128:
scored = []
for doc, meta in zip(docs, metas):
importance = 3
for key in ("importance", "emotional_weight", "weight"):
val = meta.get(key)
if val is not None:
try:
importance = float(val)
except (ValueError, TypeError):
pass
break
scored.append((importance, meta, doc))
scored.sort(key=lambda x: x[0], reverse=True)
top = scored[: self.MAX_DRAWERS]
代码尝试从多个元数据键中读取重要性评分——importance、emotional_weight、weight——体现了一种务实的兼容性策略:不同来源的数据可能使用不同的键名来标记重要性,L1 会依次尝试,找到第一个有效值就使用。默认值为 3(中等重要性),保证即使没有显式标记,记忆也能参与排序。
按房间分组。 排序后的 top N 记忆不是简单地列成一个列表,而是按房间(room)分组展示(layers.py:135-139):
by_room = defaultdict(list)
for imp, meta, doc in top:
room = meta.get("room", "general")
by_room[room].append((imp, meta, doc))
这个设计让 L1 的输出具有结构性——AI 看到的不是一堆散乱的事实,而是按主题组织的信息。这与记忆宫殿的核心理念一致:空间结构本身就是索引。
硬性 token 上限。 L1 有两个硬性约束:最多 15 条记忆(MAX_DRAWERS = 15),总字符不超过 3200(MAX_CHARS = 3200,约 800 token)。当接近上限时,生成过程会优雅地截断,并添加 "... (more in L3 search)" 提示 AI 可以通过深度搜索获取更多(layers.py:160-163)。
为什么当前是 ~500-800 token,而 README 又会写到 ~120 token? 这是"当前实现"与"压缩路线图"之间的区别。layers.py 当前的 Layer1.generate() 仍然输出按 room 分组的原文片段,因此源码里的预算是 500-800 token。README 提到的 ~120 token,则对应另一种尚未接入 wake_up() 主路径的设想:把同一批关键事实压成 AAAK,再作为 L1 输送给模型。
这两种口径背后的设计方法是一致的:先确定"唤醒后,AI 至少该知道什么",再反推需要多少上下文。不同之处只在于表达介质。当前介质是压缩过的原文片段,所以预算落在 500-800 token;路线图中的介质是 AAAK,因此目标可以进一步降到约 120 token 的 L1。
L2:按需检索层
Layer 2: On-Demand (~200-500 each) — Loaded when a topic/wing comes up.
L2 是"被动记忆"和"主动搜索"之间的中间地带。
从概念设计上看,L0 和 L1 始终在场,它们构成了 AI 的"常驻意识"。L3 是深度搜索,需要明确的查询。L2 的角色则位于两者之间:当上层已经知道当前对话聚焦在哪个 wing 或 room 时,它可以先用一次轻量的元数据过滤,把相关 drawer 成批拉回,而不必立刻触发全量语义搜索。
需要补一句当前实现的边界:v3.0.0 里的 L2 还不是一个会自动监听对话、自动在话题切换时注入上下文的运行时机制。 它目前只是一个显式的 retrieve() / recall() 接口,供上层编排层在合适的时候手动调用。
在 layers.py:176-233 中,Layer2 的实现相当直接:
class Layer2:
"""
~200-500 tokens per retrieval.
Loaded when a specific topic or wing comes up in conversation.
Queries ChromaDB with a wing/room filter.
"""
def retrieve(self, wing: str = None, room: str = None,
n_results: int = 10) -> str:
L2 的核心机制是过滤而非搜索。它不使用语义查询,而是通过元数据过滤(wing 和 room)来缩小范围(layers.py:195-205):
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
kwargs = {"include": ["documents", "metadatas"], "limit": n_results}
if where:
kwargs["where"] = where
results = col.get(**kwargs)
注意这里用的是 col.get() 而非 col.query()。get() 是 ChromaDB 的元数据过滤方法,不涉及向量相似度计算——它只是按条件返回匹配的文档。这意味着 L2 的检索是确定性的、零语义成本的。更准确地说,当上层已经把"我们来看看 Driftwood 项目"解析成 wing="driftwood" 并显式调用 recall(wing="driftwood") 时,L2 不需要理解自然语言本身,它只需要返回所有匹配该过滤条件的 drawer。
为什么是 200-500 token? 这个范围对应的是一次取回 5-10 个过滤结果。每条片段被截断到 300 字符以内(layers.py:226-228),加上元数据标签,总量控制在一到两段话的长度。需要说清楚的是:当前实现没有按 filed_at 或 date 排序,因此这里更准确的表述是"一批过滤命中的片段",而不是严格意义上的"近期历史"。这个量足以让 AI 快速补齐当前话题的局部上下文,但不会挤占对话本身的上下文空间。
L2 的存在解决了一个微妙但重要的编排问题:如果没有 L2,上层系统要么只能依赖 L1 的浅层常驻信息,要么每次都直接跳进 L3 的全量语义搜索。L2 提供了一个更便宜的中间层,让调用方可以在识别出当前 wing/room 后,先拉回一批相关 drawer,再决定是否需要更深的搜索。也正因为如此,"自然地切换话题"在当前版本里仍然是上层代理或界面可以实现的体验,而不是 layers.py 已经自动完成的行为。
L3:深度搜索层
Layer 3: Deep Search (unlimited) — Full ChromaDB semantic search.
L3 是唯一使用语义搜索的层级。
前两层都在做"预加载"——在对话开始前注入常驻信息。L2 则是一个轻量的显式召回接口;L3 不同,它是按需触发的全量搜索,用于回答需要在整个记忆库中检索的问题。
Layer3 的核心方法 search() 在 layers.py:251-303:
class Layer3:
"""
Unlimited depth. Semantic search against the full palace.
"""
def search(self, query: str, wing: str = None,
room: str = None, n_results: int = 5) -> str:
# ...
kwargs = {
"query_texts": [query],
"n_results": n_results,
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
results = col.query(**kwargs)
这里用的是 col.query()——ChromaDB 的语义搜索方法。它将查询文本转化为向量,在整个集合中按余弦相似度排序,返回最接近的结果。
L3 的输出格式设计也值得注意(layers.py:287-303):
lines = [f'## L3 — SEARCH RESULTS for "{query}"']
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(1 - dist, 3)
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
# ...
lines.append(f" [{i}] {wing_name}/{room_name} (sim={similarity})")
lines.append(f" {snippet}")
每条结果包含三类信息:位置(wing/room)、相似度分数和内容摘要。位置信息让 AI 知道这条记忆"在宫殿的哪里",相似度分数让 AI 判断结果的可信度,内容摘要提供实际信息。
与 searcher.py 的关系。 layers.py 中的 L3 实现与 searcher.py 中的搜索功能在逻辑上是重叠的。searcher.py 提供了两个函数:search()(打印格式化输出,searcher.py:15-84)和 search_memories()(返回结构化数据,searcher.py:87-142)。两者都使用相同的 ChromaDB query() 调用,区别在于输出格式——前者用于 CLI,后者用于 MCP 服务器等程序化调用。
L3 还提供了一个 search_raw() 方法(layers.py:305-352),返回原始字典列表而非格式化文本。这为上层应用(如 MCP 工具)提供了灵活的数据接口。
统一接口:MemoryStack
四个层级通过 MemoryStack 类统一暴露(layers.py:360-438):
class MemoryStack:
def __init__(self, palace_path=None, identity_path=None):
self.l0 = Layer0(self.identity_path)
self.l1 = Layer1(self.palace_path)
self.l2 = Layer2(self.palace_path)
self.l3 = Layer3(self.palace_path)
def wake_up(self, wing=None) -> str:
"""L0 (identity) + L1 (essential story). ~600-900 tokens."""
parts = []
parts.append(self.l0.render())
parts.append("")
if wing:
self.l1.wing = wing
parts.append(self.l1.generate())
return "\n".join(parts)
def recall(self, wing=None, room=None, n_results=10) -> str:
"""On-demand L2 retrieval."""
return self.l2.retrieve(wing=wing, room=room, n_results=n_results)
def search(self, query, wing=None, room=None, n_results=5) -> str:
"""Deep L3 semantic search."""
return self.l3.search(query, wing=wing, room=room, n_results=n_results)
三个方法,三种使用场景:
wake_up():每次会话开始时调用一次。注入系统提示词或第一条消息。recall():由上层在识别出特定 wing/room 时显式调用。它提供 L2 的轻量过滤召回,但当前不会自己监听对话。search():用户明确提问时调用。语义检索全量数据。
wake_up() 方法还支持 wing 参数(layers.py:380-399),允许按项目过滤 L1 的内容。如果你在做 Driftwood 项目,wake_up(wing="driftwood") 会只加载与 Driftwood 相关的关键事实,进一步减少 token 消耗,同时提高信息相关性。
status() 方法(layers.py:409-438)提供了栈的整体状态诊断,包括身份文件是否存在、token 估算和记忆总数。这对调试和运维很有用。
为什么是四层,不是两层或八层
这是一个值得认真回答的设计问题。
为什么不是两层(始终加载 + 搜索)? 因为"始终加载"和"搜索"之间存在一个重要的灰色地带。想象一下只有 L0+L1 和 L3 的系统:你的 AI 知道你的名字和当前项目(L1),如果被问到具体问题可以搜索(L3),但当上层已经明确知道"现在在聊 Driftwood"时,它仍然只能直接跳到全量语义搜索。L2 填补了这个空隙——它把"按 wing/room 轻量召回"单独抽出来,让上层编排层不必每次都走最重的路径。当前版本里,这个收益体现为一个显式 API,而不是自动话题监听。
为什么不是三层(去掉 L0,把身份合并进 L1)? 因为身份和事实在本质上不同。身份是声明性的、用户控制的、几乎永不变化的。L1 的关键事实是从数据中自动生成的、按重要性排序的、随新数据更新的。把它们混在一起,要么让用户的身份声明被自动生成的内容挤掉,要么让自动生成的逻辑需要小心翼翼地避开用户手写的部分。分开更干净。
为什么不是更多层? 因为每增加一个层级,就增加了一个"何时加载"的决策点。四层已经覆盖了所有关键的时间语义:始终(L0、L1)、按过滤召回(L2)、按查询搜索(L3)。你很难定义出第五种有意义的加载时机。如果试图把 L2 拆分成"近期话题"和"远期话题",或者把 L3 拆分成"浅度搜索"和"深度搜索",带来的复杂性很可能超过收益。
四层是一个最小完备集:少一层则功能缺失,多一层则过度设计。
Token 预算的经济学
最后,让我们算一笔账。
如果严格按当前 layers.py 计算,MemPalace 的默认唤醒成本是 ~600-900 token,而不是 170 token。README 中的 ~170 token 和 ~$0.70/年,代表的是将 AAAK 接入唤醒主路径后的目标经济性,不是今天 mempalace wake-up 命令的实测口径。
但这不改变这里真正重要的原则:最好的成本优化不是让每次调用更便宜,而是让大多数调用不发生。
无论是今天的 600-900 token,还是 README 设想中的 170 token,L0 + L1 的核心都不是"把所有历史塞进去",而是"只把最值得常驻的那一小部分选出来"。通过四层分层,绝大多数记忆永远不需要加载到任何单次对话中。它们安静地待在 ChromaDB 里,只有在被明确需要时才通过 L2 或 L3 出场。
这就是分层的价值:不是让你存更少的东西,而是让你在正确的时刻加载正确的量。六个月的完整记忆仍然在那里,一条都没有丢。当前实现里,AI 用 600-900 token 醒来;按 README 的压缩路线,它未来可以用更小的代价做到同样的事。
栈不是文件柜。它是一套关于"什么值得现在就记住"的分层决策系统。
第15章:混合检索——从 96.6% 到 100%
定位:本章分析 MemPalace 如何从纯向量检索的 96.6% R@5 跃迁到混合模式的 100%(500/500)。我们将拆解 3.4% 的失败案例类型、逐步改进的技术路径、Haiku 重排序的成本与原理,以及为什么不应将 100% 简单等同于"永远完美"。
96.6% 意味着什么
在 LongMemEval 基准测试的 500 个问题中,MemPalace 的纯 ChromaDB 模式——不调用任何外部 API,不使用任何 LLM,完全在本地运行——命中了 483 个。这是一个需要放在上下文中理解的数字。
LongMemEval 是一个标准化的 AI 记忆基准测试,包含六种问题类型:知识更新、多会话推理、时间推理、单会话用户问题、单会话偏好问题和单会话助手问题。R@5(Recall at 5)的含义是:在系统返回的前 5 个结果中,正确答案是否存在。96.6% 意味着在 500 个问题中,只有 17 个问题的正确答案不在前 5 个检索结果中。
做到这个成绩的系统,总共依赖了一个组件:ChromaDB 的默认嵌入模型(all-MiniLM-L6-v2)。没有任何后处理,没有任何重排序,没有任何"智能提取"。存储原始文本,嵌入,按余弦相似度排序,返回。
如果把这个基线落回产品代码,它对应的正是 searcher.py 里的默认搜索路径。search_memories() 的核心逻辑非常朴素:
kwargs = {
"query_texts": [query],
"n_results": n_results,
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
results = col.query(**kwargs)
(searcher.py:109-117)
也就是说,今天 mempalace search 和 MCP 里的 mempalace_search,默认仍然走这条 raw retrieval 路径:可选 wing/room 过滤,加上一轮 ChromaDB 语义查询,然后直接返回原文。后面要讨论的 hybrid、rerank、Palace mode,主要存在于 benchmark/实验脚本里,它们解释了系统如何从 96.6% 冲到 100%,但不应被误读成"当前 CLI 默认已经这样工作"。
这里还需要补上 BENCHMARKS.md 里的另一条口径:同一份文档在给出 full-500 的 100% 之外,也明确发布了 hybrid_v4 在 held-out 450 上的 98.4% R@5 / 99.8% R@10,并把它称为 "honest publishable number"。因此,本章中的 100% 应理解为完整 benchmark 上的 competitive story;若讨论这些修复在未见数据上的泛化,更应该同时把 98.4% 摆在旁边。
正如 BENCHMARKS.md 中写的:
Nobody published this result because nobody tried the simple thing and measured it properly.
这句话指向了一个更深层的发现:整个 AI 记忆领域在存储阶段过度工程化了。当 Mem0 用 LLM 提取"用户偏好 PostgreSQL"并丢弃原始对话,当 Mastra 用 GPT 观察对话并生成摘要,它们都在存储阶段引入了不可逆的信息损失。MemPalace 证明了一个反直觉的事实:保留原始文本、依靠好的嵌入模型来检索,已经是一个极其强大的基线。
但 96.6% 不是 100%。那 17 个失败的问题告诉了我们什么?
3.4%:失败案例的解剖
对失败案例的分析揭示了几个清晰的模式。这些模式不是随机的——它们指向了向量检索的系统性盲区。
类型一:嵌入模型低估特定名词
HYBRID_MODE.md 中记录了典型案例:
- "What degree did I graduate with?" 正确答案是 "Business Administration"。嵌入模型将 "Business Administration" 和 "Computer Science" 视为语义上同样接近 "what degree"——两者都是学位名称,在嵌入空间中距离很近。但只有一个文档同时包含 "degree" 和 "Business Administration" 这两个词。
- "What kitchen appliance did I buy?" 正确答案是 "stand mixer"。"厨房电器" 在嵌入空间中是一个宽泛的语义区域,很多文档都与之相关。但 "stand mixer" 作为一个具体名词,只出现在一个特定的文档中。
- "Where did I study abroad?" 正确答案是 "Melbourne"。城市名在被大量上下文词汇包围时,其嵌入信号会被稀释。
共同特征:正确答案取决于一个具体的名词或短语,而嵌入模型倾向于捕捉"语义相近性"而非"精确匹配"。当多个文档在语义上都与查询相关时,嵌入模型无法区分哪个包含了那个具体的答案词。
类型二:时间锚点被嵌入忽略
"What was the significant business milestone I mentioned four weeks ago?" 这类问题包含一个时间锚——"四周前"。嵌入模型完全不处理时间信息。它不知道"四周前"对应哪个日期,也无法根据文档的时间戳来调整排序。正确的文档在语义上确实与查询相关(它确实是关于"商业里程碑"的),但在 top-50 的语义结果中,它的排名不够靠前,因为时间信号被忽略了。
类型三:偏好的间接表达
"What database do I prefer?" 这类问题在嵌入空间中与很多涉及数据库的文档都相关。但用户表达偏好的方式往往是间接的——"I find Postgres more reliable in my experience" 或 "I usually go with Postgres for new projects"。嵌入模型将这些句子理解为"关于 Postgres 的陈述",而非"关于偏好的表达"。当 top-5 结果中有其他更"语义接近"的数据库讨论文档时,真正包含偏好的文档可能排在第 6 或第 7 位。
类型四:对助手回复的引用
"You suggested X, can you remind me..." 这类问题指的是 AI 助手说过的话,而非用户说的话。但标准的索引只存储用户发言。助手的回复不在搜索范围内,自然无法匹配。
从 96.6% 到 100%:五步跃迁
MemPalace 的改进路径是一系列针对具体失败模式的定向修复,不是猜测性的泛化优化。每一步都回应了上面分析的某个失败类型。BENCHMARKS.md 中记录了完整的演进轨迹。
graph LR
V0["Raw<br/>96.6%"] --> V1["v1<br/>97.8%"]
V1 --> V2["v2<br/>98.4%"]
V2 --> V3["v3+rerank<br/>99.4%"]
V3 --> V4["v4+rerank<br/>100%"]
style V4 fill:#2d5,color:#fff
第一步:混合评分 v1(96.6% -> 97.8%)
回应的问题: 类型一——特定名词被嵌入低估。
方法: 在嵌入相似度之上叠加关键词重叠评分。从查询中提取有意义的关键词(去掉停用词),计算每个候选文档中关键词的匹配比例,用这个比例来调整距离分数。
HYBRID_MODE.md 中记录了融合公式:
fused_dist = dist * (1.0 - 0.30 * overlap)
dist:ChromaDB 的余弦距离(越低越好)overlap:查询关键词在文档中出现的比例(0.0 到 1.0)0.30:提升权重——最多 30% 的距离缩减
一个具体的例子:文档 A 语义距离 0.45,关键词重叠为 0;文档 B 语义距离 0.52,但关键词完全匹配。融合后 A 的分数仍是 0.450,而 B 变成 0.364,从排名落后翻转到排名领先。
关键设计选择是候选池的扩大:从 top-10 扩大到 top-50。更大的候选池给了关键词重排序更多的工作空间——如果正确答案在语义排名第 45 位但关键词完全匹配,需要它在池子里才有机会被提升上来。
为什么 30% 而不是更高? HYBRID_MODE.md 中解释了这个权重的调优过程。在完整的 500 题测试中,0.30 和 0.40 的效果基本相同,高于 0.40 则开始出现过拟合的迹象(100 题子集上看起来更好,但在全部 500 题上并无改善)。30% 足以翻转边缘案例,但不会强到覆盖明显更好的语义结果。
停用词列表本身也经过了审慎的设计:
STOP_WORDS = {
"what", "when", "where", "who", "how", "which", "did", "do",
"was", "were", "have", "has", "had", "is", "are", "the", "a",
"an", "my", "me", "i", "you", "your", ...
}
只有 3 个字符以上且不在停用词表中的词才被视为关键词。这过滤掉了问句中的功能词,保留了有检索价值的内容词。
第二步:混合评分 v2(97.8% -> 98.4%)
回应的问题: 类型二——时间锚点被忽略。
方法: 对包含时间引用的问题("四周前"、"上个月"、"最近"),计算每个候选文档的日期与目标日期的距离,给时间上接近的文档一个额外的评分提升。
days_diff = abs((session_date - target_date).days)
temporal_boost = max(0.0, 0.40 * (1.0 - days_diff / window_days))
fused_dist = fused_dist * (1.0 - temporal_boost)
最大 40% 的时间提升——足以把时间正确的文档推到前面,但不会完全覆盖语义信号。HYBRID_MODE.md 中特别解释了为什么不用 100%:时间接近是一个强信号但不是决定性信号,它是"提示"而非"规则"。
此外,v2 还引入了两轮检索机制来处理类型四(助手引用问题):第一轮用仅包含用户发言的索引找到最可能的 5 个会话,第二轮对这 5 个会话重新建立包含助手发言的索引,再次查询。这种"先粗后精"的策略避免了全局索引助手发言带来的语义信号稀释。
整个 v2 的关键特征是:零 LLM 调用。所有改进都是基于字符串匹配和日期运算——完全在本地完成,不需要 API 密钥,不需要网络连接。
第三步:混合评分 v2 + Haiku 重排序(98.4% -> 98.8%)
这是系统第一次引入 LLM。
longmemeval_bench.py:2765-2860 中实现的 llm_rerank() 函数揭示了重排序的完整机制:
def llm_rerank(question, rankings, corpus, corpus_ids, api_key,
top_k=10, model="claude-haiku-4-5-20251001"):
工作流程极其简洁:取检索返回的 top-K 个候选文档,每个截取前 500 个字符,与问题一起发送给 Haiku,让它从中选出"最可能包含答案"的那一个。被选中的文档提升到第 1 名,其余保持原有顺序。
提示词的设计刻意保持简单(longmemeval_bench.py:2807-2814):
Question: {question}
Below are {N} conversation sessions from someone's memory.
Which single session is most likely to contain the answer?
Reply with ONLY a number between 1 and {N}. Nothing else.
Session 1: {text[:500]}
...
Most relevant session number:
为什么只选一个,而不是做完整重排序? HYBRID_MODE.md 中的解释很直接:要求完整重排序会增加提示复杂度和错误率。选一个最好的是"果断且可靠的"。剩余的排名保持混合评分的顺序——这个顺序本身已经很好了。
容错设计也值得注意。 如果 API 调用失败(超时、限流、没有密钥),函数会捕获异常并返回原始排名,不做任何修改(longmemeval_bench.py:2851-2858)。系统不会因为重排序步骤出错而崩溃。这是一个可选的增强,不是必要的依赖。
第四步:混合评分 v3 + Haiku 重排序(98.8% -> 99.4%)
回应的问题: 类型三——偏好的间接表达。
这一步引入了偏好提取——用 16 个正则表达式模式在索引阶段检测用户的偏好表达:
PREF_PATTERNS = [
r"i've been having (?:trouble|issues?|problems?) with X",
r"i prefer X",
r"i usually X",
r"i want to X",
r"i'm thinking (?:about|of) X",
# ...
]
当某个会话中检测到偏好表达时,系统生成一个合成文档——例如 "User has mentioned: battery life issues on phone; looking at phone upgrade options"——并将它添加到 ChromaDB 中,与原始会话使用相同的 corpus_id。这个合成文档直接桥接了查询词汇和会话内容之间的语义鸿沟。
同时,v3 将 Haiku 重排序的候选池从 top-10 扩大到 top-20。一些助手引用类型的失败案例中,正确的会话排在第 11-12 名——刚好在 Haiku 看得见的窗口之外。扩大到 20 个捕获了这些边缘案例,而增加的提示成本可以忽略不计。
99.4% 是一个值得标记的里程碑:在 500 个问题中只剩 3 个未命中。更重要的是,BENCHMARKS.md 中记录了一个独立验证——Palace 模式(一种完全不同的检索架构,基于 hall 分类和两轮导航)也恰好达到了 99.4%。两种独立架构收敛在同一个分数上,这强烈暗示 99.4% 接近这个问题的架构性上限。
第五步:混合评分 v4 + Haiku 重排序(99.4% -> 100%)
最后 3 个失败问题被逐一分析和修复。BENCHMARKS.md 中记录了每一个:
问题 1:引用短语。 某个问题包含一个用单引号括起来的精确短语。修复:检测引号内的短语,对包含该短语的会话给予 60% 的距离缩减。
问题 2:人名权重不足。 某个关于特定人物的时间推理问题。嵌入模型对专有名词给予的权重不够。修复:从查询中提取大写的专有名词,对提及该名字的会话给予 40% 的距离缩减。
问题 3:记忆/怀旧模式。 某个偏好问题涉及高中经历。修复:在偏好提取模式中增加 "I still remember X"、"I used to X"、"when I was in high school X" 等模式。
结果:500/500。所有 6 种问题类型全部 100%。
交叉验证:Haiku 与 Sonnet。 BENCHMARKS.md 报告了一个细节:使用 Haiku 和 Sonnet 作为重排序器都能达到 100% R@5,NDCG@10 分别为 0.976 和 0.975——统计上无差异。Haiku 便宜约 3 倍,因此被推荐为默认选择。
但同一份 BENCHMARKS.md 也紧接着给出了一个更克制的数字:hybrid_v4 在一个未参与调参的 held-out 450 split 上,无 rerank 条件下拿到 98.4% R@5 / 99.8% R@10。这组数字不能和 "100% + rerank" 直接并排比较,但它提供了一个关键的边界感:v4 的修复并非完全失效,它们在未见数据上仍然泛化;只是"dev set 500/500"并不是理解泛化能力的唯一数字。
向量距离 vs 语义理解
这五步改进的历程揭示了一个根本性的区分:向量距离不等于语义理解。
向量距离衡量的是两段文本在嵌入空间中的几何距离。当你问 "What database do I prefer?" 而系统中有三个关于数据库的会话时,嵌入模型会告诉你"这三个都跟数据库有关,距离差不多"。但回答这个问题需要的不是"找到关于数据库的文档",而是"找到用户表达了数据库偏好的那个文档"。这是一个语义推理任务,不是一个距离计算任务。
这就是为什么 Haiku 重排序如此有效。嵌入模型只能说"这些文档跟查询相关"。Haiku 能做的是读取查询和候选文档,然后推理哪个文档实际上回答了这个问题。前者是几何运算,后者是阅读理解。
但值得注意的是:96.6% 的问题不需要这种推理。对于绝大多数问题,向量距离就是语义理解的一个足够好的近似。只有 3.4% 的边缘案例需要真正的阅读理解能力来区分"语义相关"和"实际回答"。
这个比例很重要。它意味着:
- 向量检索本身是一个极强的基线,不应该被低估。
- LLM 重排序不是核心功能的替代,而是边缘案例的补丁。
- 系统在没有任何 LLM 的情况下已经是可用的、有竞争力的。
成本:$0.70/年的算术
让我们计算 Haiku 重排序的实际成本。
每次重排序调用发送的内容:1 个问题 + 10 个候选文档各 500 字符 = 大约 5000 字符 = ~1250 token 的输入。Haiku 的回复是一个数字——大约 2 token。按 Haiku 的定价,每次调用成本约 $0.001。
如果一个用户每天进行 5 次需要深度搜索的对话,每次搜索触发一次 Haiku 重排序:
5 次/天 * 365 天 * $0.001/次 = $1.83/年
但实际上不是每次搜索都需要重排序。大多数查询在纯向量检索阶段就能得到正确结果(96.6% 的情况)。如果只对低置信度的结果触发重排序,实际调用频率会更低。
这里还要把两笔账分开。README 中引用的 "$0.70/年" 更具体地指向唤醒成本;而这里讨论的是 rerank 的增量成本。两者相关,但不是同一个预算项。对读者来说,更重要的结论是:96.6% 是默认产品路径的零 API 成绩;100% 则来自 benchmark 中额外叠加的混合检索与重排序。
对比之下,MemPalace 的年度成本(纯本地 $0,加 Haiku 重排序 ~$1-2)与竞品(Mem0、Zep 等年费 $228-$2,988)之间的差异不是百分之几十——是三个数量级。完整的竞品对比数据详见第 23 章。
不要将 100% 当作"永远完美"
这一点必须说清楚,因为 MemPalace 的团队自己也反复强调了这个警告。
100% R@5 是在 LongMemEval 的完整 500 个问题上测得的。这 500 个问题覆盖了六种类型,由学术团队设计,是目前 AI 记忆系统最标准的评估基准。这个分数是可重复的、经过验证的、有完整的复现脚本的。
但 BENCHMARKS.md 自己也没有让读者停在这里。它同时公布了一个 held-out 450 split:hybrid_v4 在这组未参与调参的问题上拿到 98.4% R@5 / 99.8% R@10,并明确称之为 "honest publishable number"。这提醒我们,500/500 是一个真实的 benchmark 成绩,但理解泛化时,还要看更克制的 held-out 数字。
但它仍然是一个特定测试集上的特定指标。以下是几个需要注意的边界条件:
测试集规模。 500 个问题足以进行有统计意义的对比(置信区间足够窄),但不足以代表所有可能的记忆检索场景。真实世界的查询多样性远超 500 个问题。
问题类型分布。 LongMemEval 的六种问题类型是学术团队定义的分类。真实用户的查询可能包含这六种之外的类型——比如跨模态引用("上次你给我画的那个架构图")或元认知问题("我在这个问题上反复改变了几次主意")。
数据特征。 基准测试使用的是研究团队准备的对话数据。不同用户的对话风格、话题分布和表达习惯可能显著不同。
v4 的定向修复。 从 v3 到 v4 的三个修复(引用短语提取、人名提升、怀旧模式检测)是针对特定失败问题设计的。这些修复在测试集上完美工作,但在面对全新的失败模式时不一定适用。这是任何数据驱动优化的固有局限。
BENCHMARKS.md 中对此有一个诚实的表述:
The 96.6% is the product story: free, private, one dependency, no API key, runs entirely offline. The 100% is the competitive story: a perfect score on the standard benchmark for AI memory. Both are real. Both are reproducible. Neither is the whole picture alone.
96.6%、100% 和 98.4% 是同一个系统的三个侧面。96.6% 是产品默认路径的底线能力——不依赖任何外部服务,在任何环境下都能工作;100% 是完整 benchmark 上的竞争成绩——但需要额外的 rerank 路径;98.4% 则是 benchmark 文档自己公布的 held-out 泛化数字。三者一起看,才是比较完整的技术画像。
两条独立路径的收敛
在结束本章之前,值得再提一个验证 MemPalace 检索上限的有力证据。
在混合评分路径(hybrid v1 -> v2 -> v3 -> v4)之外,团队独立开发了 Palace 模式——一种完全不同的检索架构,基于 hall 分类和两轮导航。Palace 模式将每个会话分类到五个 hall 之一(偏好、事实、事件、助手建议、通用),查询时先在最可能的 hall 内做紧凑搜索(减少噪声),再在全量数据上做 hall 加权搜索(防止分类错误导致遗漏)。
这两条路径在 99.4% R@5 精确收敛。BENCHMARKS.md 将此称为"独立架构收敛"——不同的设计、不同的代码路径、相同的分数上限。当两种独立方法在同一个天花板碰头,这比任何单一实验都更有力地说明了天花板的真实性。
最终的 v4 突破 99.4% 达到 100%,靠的是三个极其定向的修复——本质上是把最后三个边缘案例"手动解开"了。这些修复有效,但它们的定向性本身就说明了一个事实:在这个问题上,"通用改进"已经到达了它的极限,剩下的只能靠"逐个击破"。
这不是贬义。恰恰相反,它说明了 MemPalace 的基础架构——原始文本 + 结构化存储 + 嵌入检索——已经足够强,以至于只有极少数的、高度特殊的案例需要额外处理。96.6% 是架构的力量。3.4% 到 0% 的旅程是精细工程的力量。两者缺一不可,但重点不同:前者可迁移,后者需适配。
如果你在设计自己的记忆系统,96.6% 的设计原则(存储原文、结构化组织、嵌入检索)是可以直接借鉴的。从 96.6% 到 100% 的定向优化则需要根据你自己的失败案例来定制。这是本章最核心的启示:不要从优化开始。从基线开始,然后让失败案例告诉你该优化什么。
第16章:格式归一化
定位:数据进入记忆宫殿的第一道关卡。五种聊天格式,五种不同的数据结构,但宫殿只接受一种。本章讲 normalize.py 如何用不到 250 行代码完成这个翻译工作——不用任何 ML,不调任何 API,纯粹的模式匹配和结构转换。
问题:每个平台都发明了自己的格式
如果你和 AI 对话,你的对话历史可能散落在五个不同的地方。
Claude Code 把会话存成 JSONL——每行一个 JSON 对象,type 字段区分 human 和 assistant。Claude.ai 的 Web 端导出的是标准 JSON 数组,每个元素有 role 和 content。ChatGPT 用一种树形结构——mapping 字段下挂着一棵节点树,每个节点有 parent 和 children,消息埋在 message.content.parts 里。Slack 导出的是消息列表,type 固定为 "message",用户身份藏在 user 字段里。还有一种最朴素的格式:纯文本,人类的话用 > 标记,AI 的回复直接跟在后面。
五种格式,五种数据结构,五种关于"什么是一轮对话"的理解。
如果你要对这些对话做任何下游处理——分块、检索、实体检测——你有两个选择。
选择 A:每种格式单独处理。 写五套分块逻辑,五套实体检测逻辑,五套检索逻辑。每加一种新格式,所有下游模块都要改一遍。这是 N x M 问题——N 种输入格式乘以 M 个处理步骤。
选择 B:先统一格式,再处理。 写五个格式转换器和一套下游逻辑。每加一种新格式,只需要多写一个转换器。这是 N + M 问题。
MemPalace 选了 B。normalize.py 就是那个 N 到 1 的翻译层。
统一输出:transcript 格式
不管输入是什么格式,normalize() 的输出永远是同一种文本格式:
> 用户说的话
AI 的回复
> 用户的下一个问题
AI 的下一个回复
规则很简单:
- 用户的话以
>开头(借用 Markdown 引用语法) - AI 的回复紧跟在用户的话后面,不加任何前缀
- 每轮问答之间用空行分隔
这就是 MemPalace 内部的"通用语"。后续的分块器(第 18 章)、实体检测器(第 17 章)、检索引擎——它们只需要认识这一种格式。
为什么选这种格式而不是 JSON?因为下游最终需要的是纯文本。向量嵌入需要文本,语义搜索需要文本,展示给用户看的也是文本。用 JSON 做中间格式意味着每个下游消费者都要解析 JSON 再提取文本,多了一层不必要的间接。直接用文本,省去这一步。
> 标记的设计也有讲究。它让"区分谁在说话"变成了一个 O(1) 操作——看行首有没有 >,有就是用户,没有就是 AI。不需要维护状态机,不需要解析 JSON,一个 startswith(">") 就够了。
检测分支:五种格式的识别逻辑
normalize() 函数(normalize.py:22)是整个模块的入口。它的检测逻辑分三层:
flowchart TD
A[读取文件内容] --> B{"包含 >= 3 行以 > 开头?"}
B -->|是| C[已经是 transcript 格式 — 直接返回]
B -->|否| D{"扩展名是 .json/.jsonl<br/>或内容以 { 或 [ 开头?"}
D -->|是| E[尝试 JSON 归一化]
D -->|否| F[纯文本 — 直接返回]
E --> G[尝试 Claude Code JSONL]
G -->|成功| H[返回 transcript]
G -->|失败| I[JSON.parse 整个内容]
I -->|失败| F
I -->|成功| J[依次尝试 Claude.ai / ChatGPT / Slack]
J -->|某个成功| H
J -->|全部失败| F
第一层判断在 normalize.py:37-39:
lines = content.split("\n")
if sum(1 for line in lines if line.strip().startswith(">")) >= 3:
return content
如果文件里已经有 3 行以上以 > 开头的内容,认为它已经是 transcript 格式,直接返回。阈值选 3 而不是 1 或 2,是为了避免 Markdown 文件里偶尔出现的引用块触发误判。
第二层判断在 normalize.py:43:
ext = Path(filepath).suffix.lower()
if ext in (".json", ".jsonl") or content.strip()[:1] in ("{", "["):
normalized = _try_normalize_json(content)
这里用了双重条件——既看扩展名,也看内容的第一个字符。扩展名能覆盖正常命名的文件,内容嗅探能覆盖扩展名不对(比如 .txt 实际存的是 JSON)的情况。
第三层是 fallback。如果既不是 transcript,也不是 JSON,就当纯文本原样返回。纯文本会在后续的分块阶段用段落分块策略处理(见第 18 章)。
五种格式的具体解析
格式一:Claude Code JSONL
Claude Code 的会话导出是 JSONL 格式——每行一个独立的 JSON 对象。解析函数是 _try_claude_code_jsonl()(normalize.py:71)。
输入示例:
{"type": "human", "message": {"content": "解释一下 Python 的 GIL"}}
{"type": "assistant", "message": {"content": "GIL 是全局解释器锁..."}}
{"type": "human", "message": {"content": "那多线程还有意义吗?"}}
{"type": "assistant", "message": {"content": "有意义,取决于你的场景..."}}
关键识别信号是 type 字段——"human" 或 "assistant"。解析逻辑逐行读取,跳过解析失败的行,跳过非字典的行,只提取 type 和 message.content。
最后的验证条件在 normalize.py:92:
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
至少需要 2 条消息才认为解析成功。这是所有格式解析器共享的最低门槛——一问一答才构成一次有意义的对话。
注意这个函数在 _try_normalize_json() 中的位置(normalize.py:54)——它排在所有 JSON 解析器的最前面,而且在 json.loads() 之前执行。原因是 JSONL 不是合法的 JSON,对整个内容调 json.loads() 会失败。所以必须先尝试逐行解析。
格式二:Claude.ai JSON
Claude.ai 网页端导出的是标准 JSON。解析函数是 _try_claude_ai_json()(normalize.py:97)。
输入示例:
[
{"role": "user", "content": "什么是记忆宫殿?"},
{"role": "assistant", "content": "记忆宫殿是一种古老的记忆术..."}
]
或者包裹在外层对象里:
{
"messages": [
{"role": "human", "content": "..."},
{"role": "ai", "content": "..."}
]
}
这个解析器的灵活性体现在两个地方。一是外层结构——既接受直接的数组,也接受包含 messages 或 chat_messages 键的对象(normalize.py:99-100)。二是角色名——"user" 和 "human" 都认为是用户,"assistant" 和 "ai" 都认为是 AI(normalize.py:109-112)。这种宽松的解析策略不是马虎,是实用主义——Claude 的 API 和 Web 端在不同版本里用过不同的字段名,与其猜"当前版本用的是哪个",不如全部支持。
格式三:ChatGPT conversations.json
ChatGPT 的导出格式是所有格式中最复杂的。解析函数是 _try_chatgpt_json()(normalize.py:118)。
ChatGPT 不用线性数组存储对话,而是用一棵树。为什么?因为 ChatGPT 支持"编辑之前的消息并重新生成"——用户可以回到对话中的任意一点,修改自己的提问,生成一个新的分支。这棵树就是用来表示这些分支的。
输入示例(简化版):
{
"mapping": {
"root-id": {
"parent": null,
"message": null,
"children": ["msg-1"]
},
"msg-1": {
"parent": "root-id",
"message": {
"author": {"role": "user"},
"content": {"parts": ["什么是向量数据库?"]}
},
"children": ["msg-2"]
},
"msg-2": {
"parent": "msg-1",
"message": {
"author": {"role": "assistant"},
"content": {"parts": ["向量数据库是一种专门用于..."]}
},
"children": []
}
}
}
识别信号是顶层有 mapping 键(normalize.py:120)。解析策略是找到根节点(parent 为 null 且没有 message 的节点),然后沿着每个节点的第一个 children 一路走下去,形成一条线性路径。
这里有一个设计决策:当一个节点有多个 children(即用户编辑过消息产生了分支),只取第一个分支(normalize.py:153)。这意味着会丢失分支历史。这是一个有意识的取舍——对于记忆存储来说,保留"最终的对话走向"比保留"所有可能的分支"更有价值。如果保留所有分支,后续检索时会产生大量近似重复的结果。
遍历过程还有一个 visited 集合(normalize.py:140)防止循环引用——虽然正常的 ChatGPT 导出不应该有环,但防御性编程总是值得的。
格式四:Slack JSON
Slack 的频道导出是消息数组。解析函数是 _try_slack_json()(normalize.py:159)。
输入示例:
[
{"type": "message", "user": "U123", "text": "部署之前要跑测试吗?"},
{"type": "message", "user": "U456", "text": "必须跑。CI 里有配置。"},
{"type": "message", "user": "U123", "text": "好的,我先跑一遍本地的。"}
]
Slack 和其他格式有一个本质区别:它不是"用户 vs AI"的对话,而是"人 vs 人"的对话。没有天然的"提问者"和"回答者"角色。
解析器的处理策略很巧妙(normalize.py:177-186):第一个出现的用户被标记为 "user",第二个出现的用户被标记为 "assistant"。如果有第三、第四个参与者,它们的角色取决于当时 last_role 的值——交替分配 user 和 assistant。
if not seen_users:
seen_users[user_id] = "user"
elif last_role == "user":
seen_users[user_id] = "assistant"
else:
seen_users[user_id] = "user"
这不是在声称"某人是 AI",而是在用 user/assistant 的交替结构来保证后续的问答对分块能正常工作。在 transcript 格式中,> 标记的是"提问方",没有标记的是"回答方"。对于 Slack DM 来说,谁是提问方谁是回答方其实是交替的——你问我一个问题,我回答,然后我问你一个问题,你回答。交替分配角色,恰好匹配了这种自然的对话节奏。
格式五:纯文本
纯文本没有专门的解析器。如果一个文件既不是 transcript 格式也不是 JSON 格式,normalize() 直接返回原始内容(normalize.py:48)。
这些文件会在分块阶段被 convo_miner.py 的 _chunk_by_paragraph() 处理(见第 18 章),按段落或按行组分块。
内容提取:统一处理多态内容
五种格式中,"消息内容"的表示方式各不相同。Claude Code 的 content 可能是字符串,可能是包含 {"type": "text", "text": "..."} 的数组;ChatGPT 的内容藏在 content.parts 里,是字符串数组;Claude.ai 的也可能是字符串或数组。
_extract_content() 函数(normalize.py:192)是处理这种多态性的统一入口:
def _extract_content(content) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
return " ".join(parts).strip()
if isinstance(content, dict):
return content.get("text", "").strip()
return ""
三种类型,三条分支,最后兜底返回空字符串。这个函数被所有格式解析器共享,避免了每个解析器各自处理内容多态的重复代码。
值得注意的是 list 分支中对数组元素的两种处理:如果元素是字符串就直接取,如果元素是字典且 type 为 "text" 就取 text 字段。这覆盖了 Claude API 的内容块格式([{"type": "text", "text": "..."}, {"type": "image", ...}]),同时自动跳过了图片等非文本内容块。
转录生成:从消息列表到 transcript
所有格式解析器最终都会调用 _messages_to_transcript()(normalize.py:209),把 [(role, text), ...] 列表转成 transcript 文本。
这个函数的核心逻辑是配对——找到一个 user 消息后,看下一条是不是 assistant,如果是就配成一对,如果不是(比如连续两条 user)就单独输出用户消息。
while i < len(messages):
role, text = messages[i]
if role == "user":
if _fix is not None:
text = _fix(text)
lines.append(f"> {text}")
if i + 1 < len(messages) and messages[i + 1][0] == "assistant":
lines.append(messages[i + 1][1])
i += 2
else:
i += 1
else:
lines.append(text)
i += 1
lines.append("")
这里还有一个细节:用户消息会经过拼写检查(spellcheck_user_text,通过可选导入引入)。为什么只检查用户的文本?因为 AI 的输出几乎不会有拼写错误,而用户在聊天框里打字经常会有 typo。归一化阶段修正这些拼写错误,可以提高后续向量检索的准确度——"waht is GIL" 和 "what is GIL" 在嵌入空间中可能会有不小的距离。
架构选择的深层逻辑
回到开头的 N + M vs N x M 问题。MemPalace 的归一化层不仅仅是"少写点代码"这么简单。它带来了三个更深层的好处。
第一,解耦了输入和处理。 当 2025 年底某个新的 AI 对话平台出现时(比如 Gemini 的导出格式),你只需要在 normalize.py 里加一个 _try_gemini_json() 函数。分块器不需要改,检索器不需要改,实体检测器不需要改。
第二,让测试变得可控。 下游模块只需要用 transcript 格式写测试用例。不需要准备五种格式的测试数据,不需要维护五套测试矩阵。格式转换的正确性由 normalize.py 的单元测试单独保证。
第三,尽量保持了数据的语义。 归一化过程主要做格式转换,不做摘要或裁剪(拼写检查除外)。原始的问答结构和对话顺序大体会被保留下来,但"说话人身份"并不是所有格式里都能完整保留:例如 Slack 的 3+ 人对话会被折叠成 user/assistant 交替结构,以便后续的 exchange chunking 工作。这一点很重要:归一化是近乎无损的,但分支历史和部分原始角色信息会被简化。
整个 normalize.py 文件只有 253 行,没有外部依赖(只用了 json、os、pathlib 这些标准库),没有调用任何网络 API。它在本地运行,速度几乎是瞬时的。这种极简性不是偶然的——它是"数据管道第一层应该尽可能简单可靠"这个工程原则的直接体现。如果归一化层本身就复杂到需要调试,那它就失去了存在的意义。
小结
格式归一化解决的是一个看起来不起眼但极其重要的问题:在混乱的现实世界数据和整洁的内部表示之间建一座桥。五种格式进来,一种格式出去。下游模块永远不需要关心"这个数据原来是从 ChatGPT 导出的还是从 Slack 导出的"这种问题。
关键设计点:
- 检测优先级:transcript 直通 > JSONL 逐行尝试 > JSON 整体解析 > 纯文本兜底
- 宽松解析:多种角色名都接受(
user/human、assistant/ai),外层结构灵活(数组或对象) - 统一输出:
> user turn+assistant response+ 空行,极简但信息完整 - 近乎无损转换:不做摘要,但会在分支和部分角色标记上做简化
- 零外部依赖:标准库足矣
下一章,我们将看到归一化后的文本如何被扫描以发现其中提到的人物和项目——不用机器学习,不用 NER 模型,只用正则表达式和一套评分算法。
第17章:无 ML 的实体发现
定位:在对话文本中找到人物和项目的名字——不用 spaCy,不用 transformers,不用任何训练好的模型。只用正则表达式、频率统计和一套信号评分系统。本章讲 MemPalace 为什么选择规则而非 NER,以及这套规则系统的具体工作方式。
为什么不用 NER
命名实体识别(Named Entity Recognition, NER)是自然语言处理中的经典任务。spaCy、Stanford NER、Hugging Face 上的各种 transformer 模型都能做。给它一段文本,它告诉你哪些词是人名、地名、组织名。
对于 MemPalace 来说,用 NER 意味着什么?
首先是依赖。spaCy 本身约 30MB,加上语言模型(en_core_web_sm 约 12MB,en_core_web_lg 约 560MB),安装体积从 MemPalace 的几百 KB 膨胀到几十上百 MB。这还不算 PyTorch——如果要用 transformer 模型,PyTorch 起步就是 2GB。MemPalace 的整个依赖列表只有 chromadb 和 pyyaml,引入 NER 会让依赖体积增长两个数量级。
其次是上下文。NER 模型是在新闻、维基百科、学术论文上训练的。它们非常擅长识别"Barack Obama"、"Google"、"New York"这类在训练数据中高频出现的实体。但 MemPalace 处理的是私人对话——你女儿叫 Riley,你的项目叫 MemPalace,你的同事叫 Arjun。这些名字不在任何训练集里。NER 模型对它们的识别率取决于上下文线索,而聊天对话中的上下文线索往往不如新闻报道丰富。
再次是精度需求。MemPalace 不需要在任意文本中找出所有实体。它需要的是在用户自己的对话历史中找出反复出现的人物和项目。这是一个受限得多的问题——候选实体的数量有限(一个人的日常对话中反复提到的名字通常不超过几十个),而且有强频率信号(真正重要的实体一定会反复出现)。
这不是说 NER 不好。NER 在它的适用场景中——处理大量未知来源的文本、需要识别任意实体类型、处理多语言混合内容——是不可替代的工具。但 MemPalace 的场景恰好不是这种场景。它处理的是用户自己的、有限的、重复模式明显的对话数据。在这个约束条件下,规则方法够用,而且带来了 NER 给不了的好处:零额外依赖、毫秒级运行、完全本地、完全透明(你可以精确地知道为什么某个词被识别为人名)。
公平地说,如果 MemPalace 未来需要处理中文对话(中文没有大写字母这个天然的专有名词信号),或者需要识别组织名、地名、事件名等更多实体类型,规则方法的局限性就会显现。那时候引入 NER 可能是正确的选择。但在当前的英文对话 + 人物/项目二分类的场景下,规则足够了。
两遍扫描架构
entity_detector.py 使用两遍扫描的架构(entity_detector.py:8-9):
第一遍:候选提取。 从文本中找出所有大写开头的词,统计频率,过滤掉停用词和低频词。
第二遍:信号评分和分类。 对每个候选词,用一组正则模式检测它是"像人"还是"像项目",给出分数,最终分类。
这个两遍设计有一个重要好处:第一遍的计算量是 O(n)——只做一次全文扫描和词频统计。第二遍的计算量是 O(k * n)——k 是候选词数量,对每个候选词做一次全文正则匹配。因为候选词通常只有几十个(第一遍的频率过滤极大地缩小了范围),第二遍的实际计算量是可控的。
第一遍:候选提取
extract_candidates() 函数(entity_detector.py:443)负责从文本中提取候选实体。
核心逻辑是两条正则:
# 单词专有名词:大写开头,1-19个小写字母
raw = re.findall(r"\b([A-Z][a-z]{1,19})\b", text)
# 多词专有名词:连续的大写开头词(如 "Memory Palace")
multi = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text)
第一条匹配单个大写开头的词,比如 "Riley"、"Claude"、"Python"。长度限制在 2-20 个字符——太短的(如 "I")不太可能是名字,太长的也不正常。
第二条匹配连续的大写开头词组,比如 "Memory Palace"、"Claude Code"。这能捕捉到多词的项目名或全名。
提取出来的候选词经过两层过滤:
-
停用词过滤。
STOPWORDS集合包含约 250 个常见英文词(entity_detector.py:92-396),覆盖了代词、介词、连词、常见动词、技术术语(return、import、class)、UI 动作词(click、scroll)、以及那些大写开头但几乎不可能是实体的词(Monday、World、Well)。这个列表之所以这么长,是因为英文中有大量词可以在句首出现时大写——"Step one is..."、"Click the button..."、"Well, actually..."——而这些都不是实体。 -
频率过滤。候选词必须出现至少 3 次(
entity_detector.py:463)。一个真正重要的人物或项目,在对话历史中一定会反复出现。偶尔提一次的名字大概率不重要,也更容易是误判。
第二遍:信号评分
对于每个通过第一遍筛选的候选词,score_entity() 函数(entity_detector.py:486)会用一组模式去检测它更像人还是更像项目。
人物检测模式
MemPalace 用四类信号来判断一个词是不是人名:
信号一:动词模式。 人会做某些特定的动作。PERSON_VERB_PATTERNS(entity_detector.py:27-48)定义了 20 个模式:
| 模式类型 | 示例 | 权重 |
|---|---|---|
| 言语动词 | {name} said, {name} asked, {name} told | x2 |
| 情感动词 | {name} laughed, {name} smiled, {name} cried | x2 |
| 认知动词 | {name} thinks, {name} knows, {name} decided | x2 |
| 意愿动词 | {name} wants, {name} loves, {name} hates | x2 |
| 称呼模式 | hey {name}, thanks {name}, hi {name} | x2 |
"Riley said" 基本上等于在说"Riley 是一个人"。项目不会 say,系统不会 laugh。这些动词是极强的人物信号。
信号二:代词近邻。 如果一个名字附近(前后 3 行内)出现了人称代词(she、he、they、her、him 等),这是一个人物信号。检测逻辑在 entity_detector.py:515-525:
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
pronoun_hits = 0
for idx in name_line_indices:
window_text = " ".join(lines[max(0, idx - 2) : idx + 3]).lower()
for pronoun_pattern in PRONOUN_PATTERNS:
if re.search(pronoun_pattern, window_text):
pronoun_hits += 1
break
窗口大小是 5 行(前 2 行 + 当前行 + 后 2 行)。在每个窗口中只计一次代词命中(break),避免一个窗口中出现多个代词导致分数虚高。每次命中权重 x2。
信号三:对话标记。 DIALOGUE_PATTERNS(entity_detector.py:64-69)识别对话文本中的说话人标注格式:
DIALOGUE_PATTERNS = [
r"^>\s*{name}[:\s]", # > Speaker: ...
r"^{name}:\s", # Speaker: ...
r"^\[{name}\]", # [Speaker]
r'"{name}\s+said', # "Riley said..."
]
对话标记的权重最高:每次命中 x3(entity_detector.py:503)。因为如果一个名字出现在对话标记的位置——"Riley:" 或 "[Riley]"——它几乎一定是人名。
信号四:直接称呼。 如果文本中出现 "hey Riley"、"thanks Riley"、"hi Riley",权重 x4(entity_detector.py:528-531)。这是所有人物信号中权重最高的,因为直接称呼某人的名字,几乎不可能是在说一个项目。
项目检测模式
PROJECT_VERB_PATTERNS(entity_detector.py:72-89)定义了另一组模式:
| 模式类型 | 示例 | 权重 |
|---|---|---|
| 构建动词 | building {name}, built {name} | x2 |
| 发布动词 | shipping {name}, launched {name}, deployed {name} | x2 |
| 架构描述 | the {name} architecture, the {name} pipeline | x2 |
| 版本标识 | {name} v2, {name}-core | x3 |
| 代码引用 | {name}.py, import {name}, pip install {name} | x3 |
版本标识和代码引用的权重更高(x3),因为它们是项目的铁证——人不会有 .py 后缀,人不会被 pip install。
分类决策
classify_entity() 函数(entity_detector.py:562)根据评分结果进行最终分类。分类逻辑不仅看分数,还看信号的多样性。
核心决策树:
-
无信号(总分为 0):标记为
uncertain,置信度最高 0.4。这些词只凭频率进入候选名单,没有任何上下文线索。 -
人物比例 >= 70%,且有两种以上不同信号类型,且人物分 >= 5:分类为
person。这里的"两种以上不同信号类型"是一个关键设计(entity_detector.py:587-601)。为什么需要这个额外条件?考虑这个场景:文本中反复出现 "Click said..."(描述某个 UI 框架的日志输出)。"Click" 会在动词模式上得到高分——"Click said" 匹配
{name} said。但它只在一种信号类型(言语动词)上得分。一个真正的人名通常会触发多种信号——既有 "Riley said"(言语动词),又有附近出现的 "she"(代词),还有 "hey Riley"(直接称呼)。要求两种以上不同信号类型,就过滤掉了那些只在某个特定句式中频繁出现但实际上不是人名的词。 -
人物比例 >= 70%,但不满足多样性条件:降级为
uncertain,置信度 0.4(entity_detector.py:605-609)。代码注释明确说明了原因:"Pronoun-only match — downgrade to uncertain"。 -
人物比例 <= 30%:分类为
project。 -
其他情况:分类为
uncertain,标记 "mixed signals — needs review"。
检测流程的完整串联
detect_entities() 函数(entity_detector.py:632)把以上所有步骤串联起来:
-
收集文件内容。每个文件只读前 5000 字节(
entity_detector.py:652),最多读 10 个文件。这不是因为懒——是因为实体检测不需要读完整个文件。如果一个名字在前 5KB 里都没出现,它大概率不是核心实体。 -
文件选择有讲究。
scan_for_detection()函数(entity_detector.py:813)优先选择散文文件(.txt、.md、.rst、.csv),只有散文文件不足 3 个时才 fallback 到代码文件。原因是代码文件中有太多大写开头的词——类名、函数名、常量——它们会产生大量误报。代码注释说得很清楚(entity_detector.py:398-399):"Code files have too many capitalized names (classes, functions) that aren't entities"。 -
合并所有文件的文本,调
extract_candidates()提取候选。 -
对每个候选调
score_entity()和classify_entity()。 -
按类型分组,按置信度排序,截取前 N 个(人物最多 15 个,项目最多 10 个,不确定最多 8 个)。
实体注册表:持久化和消歧
这里需要先把两条链路分开。当前 mempalace init 的主流程是:entity_detector.py 检测候选实体,confirm_entities() 做交互确认,然后把结果写到项目目录下的 entities.json,供后续 AAAK / Dialect.from_config 这类实体映射配置路径使用。entity_registry.py 则是相邻但更长期的组件:它服务于 onboarding、名称消歧、研究缓存和持续学习,默认落盘位置是 ~/.mempalace/entity_registry.json。
EntityRegistry 类(entity_registry.py:268)维护一个 JSON 文件(默认位于 ~/.mempalace/entity_registry.json),存储三类信息:
{
"version": 1,
"mode": "personal",
"people": {
"Riley": {
"source": "onboarding",
"contexts": ["personal"],
"aliases": [],
"relationship": "daughter",
"confidence": 1.0
}
},
"projects": ["MemPalace", "Acme"],
"ambiguous_flags": ["ever", "max"],
"wiki_cache": {}
}
在 EntityRegistry 这条链路内部,实体的来源(source)有三个优先级:
- onboarding:用户在初始化时明确告诉系统的。置信度 1.0,不可挑战。
- learned:系统从会话历史中推断出来的。置信度取决于检测算法的输出。
- wiki:通过 Wikipedia API 查询确认的。置信度取决于 Wikipedia 的描述内容。
歧义词处理
这是注册表中最有趣的部分。英文中有大量既是普通词汇又是人名的词——Ever、Grace、Will、May、Max、Rose、Ivy、Chase、Hunter、Lane......
COMMON_ENGLISH_WORDS 集合(entity_registry.py:31-89)列举了约 50 个这样的词。当这些词出现在注册表中时,系统会把它们加入 ambiguous_flags 列表。
之后每次查询这些词,_disambiguate() 方法(entity_registry.py:463)会检查上下文来判断是人名还是普通词汇:
人名上下文模式(entity_registry.py:92-113):
PERSON_CONTEXT_PATTERNS = [
r"\b{name}\s+said\b", # "Ever said..."
r"\bwith\s+{name}\b", # "...with Ever"
r"\bsaw\s+{name}\b", # "I saw Ever"
r"\b{name}(?:'s|s')\b", # "Ever's birthday"
r"\bhey\s+{name}\b", # "hey Ever"
r"^{name}[:\s]", # "Ever: let's go"
]
普通词汇上下文模式(entity_registry.py:116-127):
CONCEPT_CONTEXT_PATTERNS = [
r"\bhave\s+you\s+{name}\b", # "have you ever"
r"\bif\s+you\s+{name}\b", # "if you ever"
r"\b{name}\s+since\b", # "ever since"
r"\b{name}\s+again\b", # "ever again"
r"\bnot\s+{name}\b", # "not ever"
]
消歧逻辑简单直接:计算两组模式各匹配了多少次。如果人名模式得分高,判定为人名;如果普通词汇模式得分高,判定为概念。如果打平,返回 None——让调用方 fallback 到默认行为(已注册为人名的词,平局时仍当人名处理,因为用户已经声明过了)。
这套消歧机制意味着系统能正确处理这样的对话:
> Have you ever tried the new API? ← "ever" = 副词
> I went to the park with Ever yesterday. ← "Ever" = 人名
Wikipedia 查询
对于既不在注册表中、也不在停用词列表中的陌生大写词,EntityRegistry 模块提供了 Wikipedia 查询功能(entity_registry.py:179)。
查询使用 Wikipedia 的 REST API(免费,无需 API key),根据返回的摘要内容判断词的类型:
- 如果摘要包含 "given name"、"personal name"、"masculine name" 等短语(
entity_registry.py:135-161),判定为人名 - 如果摘要包含 "city in"、"municipality"、"capital of" 等短语,判定为地名
- 如果在 Wikipedia 上找不到(404),反而判定为人名(
entity_registry.py:249-256)——一个不在 Wikipedia 上的大写词,很可能是某个具体的人的名字或昵称
查询结果会被缓存在 wiki_cache 中,避免重复请求。需要补一句实现边界:我在当前 CLI init / mining 主链路里没有看到默认调用这条 Wikipedia 研究路径;它更像是注册表模块预留出来的辅助能力,而不是今天每次初始化都会自动触发的步骤。
从会话中持续学习
注册表的 learn_from_text() 方法(entity_registry.py:553)说明这套设计并不想停在一次性初始化。理论上,每次处理新的会话文本时,系统都可以调用这个方法,对文本运行候选提取和信号评分;如果发现新的高置信度人物候选(默认阈值 0.75),就自动加入注册表。
这形成了一个清晰的渐进式学习方向:初始的 onboarding 提供种子数据,之后的文本可以持续补充新发现的实体。但需要实话实说,当前公开仓库里我没有看到 learn_from_text() 被默认接进 mempalace init、mine 或会话摄入主循环;它现在更像是已经实现但尚未成为默认工作流的一步。门槛有意设得较高(0.75),因为自动添加的成本不仅是一个错误的条目——它会影响后续所有查询的结果。宁可漏掉一些,也不要误判。
交互式确认
检测结果最终需要人类确认。confirm_entities() 函数(entity_detector.py:717)提供了一个简洁的交互界面:
==========================================================
MemPalace — Entity Detection
==========================================================
Scanned your files. Here's what we found:
PEOPLE:
1. Riley [●●●●○] dialogue marker (5x), pronoun nearby (3x)
2. Arjun [●●●○○] 'Arjun ...' action (4x), addressed directly (2x)
PROJECTS:
1. MemPalace [●●●●○] code file reference (3x), versioned (2x)
UNCERTAIN (need your call):
1. Claude [●●○○○] mixed signals — needs review
置信度用实心/空心圆点可视化(entity_detector.py:712)——5 个圆点对应 0-1.0 的置信度范围。每个实体旁边还列出了触发的前两个信号,让用户能理解"为什么系统认为这是一个人名"。
用户可以接受全部检测结果、手动修正错误分类、或添加系统遗漏的实体。也可以传入 yes=True 跳过交互,自动接受所有非 uncertain 的结果。
规则方法的边界
规则方法在 MemPalace 的场景下工作得很好,但清楚它的边界同样重要:
| 维度 | 规则方法 | ML/NER |
|---|---|---|
| 依赖 | 零(标准库正则) | spaCy/transformers + 模型文件 |
| 运行速度 | 毫秒级 | 秒级(首次加载模型更慢) |
| 英文人名识别 | 依赖大写信号 + 上下文 | 基于统计模型,更鲁棒 |
| 中文人名识别 | 基本不可能(无大写信号) | 专门模型可以做 |
| 罕见名字 | 只要频率够高就能识别 | 取决于训练数据覆盖度 |
| 可解释性 | 完全透明(哪条规则触发了) | 黑箱或半透明 |
| 适用规模 | 个人对话(十到几百个文件) | 无限制 |
| 新实体类型扩展 | 需要手写新规则 | 微调或换模型 |
MemPalace 的选择不是"规则比 NER 好",而是"在这个特定场景下,规则的投入产出比更高"。这是一个工程判断,不是一个技术信仰。
小结
实体检测模块用两遍扫描 + 信号评分的架构,在不引入任何 ML 依赖的前提下,实现了对英文对话中人物和项目的自动识别。
关键设计点:
- 两遍扫描:先用频率过滤缩小候选范围,再用信号评分精确分类
- 四类人物信号:动词模式、代词近邻、对话标记、直接称呼,权重从 x2 到 x4
- 多样性要求:必须有两种以上不同信号类型才能确认为人物,防止单一模式的误判
- 持久化注册表:三级来源优先级(onboarding > learned > wiki),歧义词上下文消歧
- 渐进式学习:每次会话都可能发现新实体,但自动添加的门槛有意设高
下一章将讲分块——把归一化后的文本切成适合向量检索的片段。对话文本和项目文件需要完全不同的分块策略,因为它们的最小语义单元不同。
第18章:分块的学问
定位:向量数据库的检索质量,有一半取决于分块策略。切太大,搜索结果充满无关内容;切太小,语义被割裂。本章讲 MemPalace 的两种分块策略——项目文件用固定窗口,对话用问答对——以及为什么对话文本不能用固定窗口。
为什么分块是个问题
向量检索的工作原理是:把文本片段转成向量,存进数据库;查询时把问题也转成向量,找最近的几个。
如果不做分块,把整个文件作为一个向量存进去呢?问题有两个。一是嵌入模型有长度限制——大多数模型只能处理 512 或 8192 个 token,超过就截断。二是即使模型能处理长文本,长文本的嵌入向量会成为所有主题的"平均值"——一个同时讨论了数据库设计、部署策略和团队管理的文档,它的向量会落在这三个主题的中间位置,结果是搜任何一个主题都搜不太到它。
所以必须切。问题是怎么切。
MemPalace 对这个问题的回答是:项目文件和对话文件需要不同的分块策略,因为它们的最小语义单元不同。
项目文件分块:固定窗口 + 段落感知
miner.py 中的 chunk_text() 函数(miner.py:135)负责项目文件的分块。它的参数定义在文件开头(miner.py:56-58):
CHUNK_SIZE = 800 # chars per drawer
CHUNK_OVERLAP = 100 # overlap between chunks
MIN_CHUNK_SIZE = 50 # skip tiny chunks
800 字符大约是 150-200 个英文词,相当于一个中等段落。选 800 而不是更大的值(比如 2000),是因为项目文件的内容通常是紧凑的——一个 Python 函数、一段 README 说明、一个配置块。800 字符足以容纳一个完整的逻辑单元,同时又小到让检索结果足够精确。
100 字符的重叠是为了处理恰好被切断的句子。如果一个重要的句子横跨两个分块的边界,重叠确保了前一个分块的最后 100 个字符和后一个分块的开头是一样的。这意味着这个句子至少在一个分块中是完整的。
但 chunk_text() 不是机械地每 800 字符切一刀。它有段落感知逻辑(miner.py:153-161):
if end < len(content):
# 优先在双换行(段落边界)处切割
newline_pos = content.rfind("\n\n", start, end)
if newline_pos > start + CHUNK_SIZE // 2:
end = newline_pos
else:
# 退而求其次,在单换行处切割
newline_pos = content.rfind("\n", start, end)
if newline_pos > start + CHUNK_SIZE // 2:
end = newline_pos
它先尝试在双换行处(段落边界)切割。如果在 [start + 400, start + 800] 范围内找到了双换行,就在那里切。如果找不到双换行,就找单换行。如果连单换行都找不到(比如一段极长的无换行文本),才在 800 字符处硬切。
start + CHUNK_SIZE // 2 这个下界(即 400 字符)防止了一个问题:如果段落边界出现在分块的最开头(比如第 10 个字符处),在那里切割会产生一个极小的分块,浪费存储空间和检索资源。要求切割点至少在分块的后半段,保证了每个分块都有足够的内容量。
最后,太短的分块(小于 50 字符)会被跳过(miner.py:164)。空行、单行注释这些不值得单独作为一个检索单元。
对话分块:问答对是最小语义单元
现在来看对话文件的分块。convo_miner.py 中的 chunk_exchanges() 函数(convo_miner.py:52)走了一条完全不同的路。
为什么对话不能用固定窗口
假设你有这样一段对话:
> 我们的数据库选型应该考虑哪些因素?
考虑三个维度:一是查询模式,你们主要是 OLTP 还是 OLAP;
二是数据规模,预计未来一年的数据量;三是团队熟悉度。
> 那 PostgreSQL 和 MySQL 相比呢?
PostgreSQL 在复杂查询和 JSON 支持上更强,MySQL 在简单读写
和运维生态上更成熟。考虑到你们有 JSON 数据需求,我建议 PostgreSQL。
如果用 800 字符固定窗口切割,可能的切割结果是:
【分块 1】
> 我们的数据库选型应该考虑哪些因素?
考虑三个维度:一是查询模式,你们主要是 OLTP 还是 OLAP;
二是数据规模,预计未来一年的数据量;三是团队熟悉度。
> 那 PostgreSQL 和 MySQL 相比呢?
【分块 2】
PostgreSQL 在复杂查询和 JSON 支持上更强,MySQL 在简单读写
和运维生态上更成熟。考虑到你们有 JSON 数据需求,我建议 PostgreSQL。
问题出在分块 1 的最后一行:问题 "那 PostgreSQL 和 MySQL 相比呢?" 被归入了分块 1,但它的答案在分块 2 里。如果用户后来搜索 "PostgreSQL vs MySQL",分块 1 会匹配到问题但不包含答案,分块 2 包含答案但缺少问题的上下文。两个分块都不完整。
这就是为什么对话需要以问答对为单位来分块。一个问题和它的回答是不可分割的语义单元——问题定义了上下文,回答提供了信息。拆开它们,两边都失去了意义。
问答对分块的实现
_chunk_by_exchange() 函数(convo_miner.py:66)的逻辑是:
def _chunk_by_exchange(lines: list) -> list:
chunks = []
i = 0
while i < len(lines):
line = lines[i]
if line.strip().startswith(">"):
# 找到一个用户发言
user_turn = line.strip()
i += 1
# 收集紧跟其后的 AI 回复
ai_lines = []
while i < len(lines):
next_line = lines[i]
if next_line.strip().startswith(">") or next_line.strip().startswith("---"):
break
if next_line.strip():
ai_lines.append(next_line.strip())
i += 1
# 合并为一个分块
ai_response = " ".join(ai_lines[:8])
content = f"{user_turn}\n{ai_response}" if ai_response else user_turn
if len(content.strip()) > MIN_CHUNK_SIZE:
chunks.append({"content": content, "chunk_index": len(chunks)})
else:
i += 1
return chunks
几个细节值得注意:
分块边界由 > 标记驱动。 遇到一个 > 行,开始收集一个问答对。继续向下读,直到遇到下一个 > 行(下一个问题)或 --- 分隔符。中间的所有非空行都是 AI 的回复。
AI 回复被截断为前 8 行(convo_miner.py:86)。这是一个有意的限制——" ".join(ai_lines[:8])。为什么?因为 AI 的回复可能非常长(几十行甚至上百行的代码、详细的步骤说明),但对于向量检索来说,前几行通常就包含了核心答案。把整个长回复塞进一个分块,会稀释向量的语义焦点。
空行被跳过(convo_miner.py:81)。只收集 next_line.strip() 非空的行。这确保了分块内容紧凑,没有无意义的空白。
--- 分隔符作为硬边界(convo_miner.py:80)。如果对话中有 --- 分隔线(常见于 Markdown 格式的对话日志),即使后面的内容不以 > 开头,也会终止当前问答对的收集。这是因为 --- 通常表示话题切换或对话分段。
Fallback:段落分块
如果文本中没有足够的 > 标记(少于 3 个),chunk_exchanges() 会 fallback 到 _chunk_by_paragraph()(convo_miner.py:102):
def _chunk_by_paragraph(content: str) -> list:
chunks = []
paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
if len(paragraphs) <= 1 and content.count("\n") > 20:
lines = content.split("\n")
for i in range(0, len(lines), 25):
group = "\n".join(lines[i : i + 25]).strip()
if len(group) > MIN_CHUNK_SIZE:
chunks.append({"content": group, "chunk_index": len(chunks)})
return chunks
for para in paragraphs:
if len(para) > MIN_CHUNK_SIZE:
chunks.append({"content": para, "chunk_index": len(chunks)})
return chunks
这个 fallback 处理两种情况:
- 有段落分隔的文本(双换行分隔):每个段落作为一个分块。
- 没有段落分隔但很长的文本(超过 20 行但没有双换行):每 25 行作为一个分块。
25 行这个数字不是随便选的——它大致对应 800 字符(假设每行 30-35 个字符),和项目文件的分块大小保持一致。
两种策略的参数对比
| 参数 | 项目文件 (miner.py) | 对话文件 (convo_miner.py) |
|---|---|---|
| 分块单元 | 固定窗口(800 字符) | 问答对(大小不固定) |
| 重叠 | 100 字符 | 无(问答对之间无重叠) |
| 边界感知 | 段落边界(双换行 > 单换行) | > 标记 + --- 分隔符 |
| 最小分块 | 50 字符 | 30 字符 |
| AI 回复截断 | 不适用 | 前 8 行 |
| Fallback | 无(硬切) | 段落分块 / 25 行分组 |
对话分块不需要重叠,因为问答对之间是天然分离的——问题 A 的答案和问题 B 的答案之间不存在"横跨边界的句子"这个问题。每个问答对都是自包含的。
对话分块的最小阈值(30 字符)比项目文件(50 字符)更低,因为一个简短但有意义的问答对——比如 "> 用什么语言?\nPython。"——只有 20 多个字符,但它携带了有价值的信息。
房间路由:分块之后的分类
项目文件分块后,每个分块需要被路由到对应的"房间"。detect_room() 函数(miner.py:89)用三级优先策略:
- 文件路径匹配:如果文件在
docs/目录下,而且有一个房间叫 "docs",直接路由到那个房间 - 文件名匹配:如果文件名包含某个房间名
- 内容关键词评分:用房间的关键词列表对内容的前 2000 字符做关键词计数
对话文件的房间路由不同。detect_convo_room() 函数(convo_miner.py:194)用五个预定义的话题分类:
TOPIC_KEYWORDS = {
"technical": ["code", "python", "function", "bug", ...],
"architecture": ["architecture", "design", "pattern", ...],
"planning": ["plan", "roadmap", "milestone", ...],
"decisions": ["decided", "chose", "switched", ...],
"problems": ["problem", "issue", "broken", ...],
}
这五个分类不是随意选的——它们对应了开发者在对话中最常讨论的五类话题。如果没有任何关键词匹配,fallback 到 "general"。
归一化与分块的衔接
对话文件的处理流程是一个清晰的管道(convo_miner.py:302-317):
原始文件 → normalize() → chunk_exchanges() → 存入 ChromaDB
normalize() 确保进入分块器的内容格式统一(第 16 章),chunk_exchanges() 确保每个分块是一个完整的语义单元。每个分块作为一个"抽屉"(drawer)存入 ChromaDB,带上 wing、room、source_file 等元数据。
值得注意的是,对话矿工支持两种提取模式(convo_miner.py:259):"exchange"(默认的问答对分块)和 "general"(通用提取器,提取决策、偏好、里程碑等特定类型的记忆)。通用提取模式的分块结果自带 memory_type 字段,直接作为房间名使用,绕过了 detect_convo_room() 的话题分类。
小结
分块看起来是一个简单的"切文本"操作,但切在哪里、切多大、切的单元是什么,直接决定了下游检索的质量。
MemPalace 的两种分块策略反映了一个基本洞察:不同类型的文本有不同的最小语义单元。 项目文件的最小语义单元是段落——一段代码、一段说明、一个配置块。对话的最小语义单元是问答对——问题定义上下文,回答提供信息,拆开它们两边都失去意义。
关键设计点:
- 项目文件:800 字符窗口 + 100 字符重叠 + 段落边界感知
- 对话文件:以
>标记为分界的问答对分块,AI 回复截断为前 8 行 - 两种策略共享一个原则:尽量在自然边界处切割,避免割裂语义
- Fallback 策略:对话没有
>标记时降级为段落分块,保证任何输入都能处理
第19章:MCP 服务器——19 个工具的 API 设计
定位:本章剖析 MemPalace 如何通过 19 个 MCP 工具将记忆宫殿暴露给 AI,以及为什么
mempalace_status的响应结构不仅返回数据,还同时教会 AI 一种语言和一套行为协议。
一个工具就够了,为什么是 19 个?
设计 API 的经典张力是粒度问题。给得太粗——一个万能工具——AI 需要在调用参数中表达所有意图,prompt 变成了微型编程语言。给得太细——每个操作一个工具——AI 淹没在选择中,每次决策都增加 token 消耗。
MemPalace 选择了 19 个工具,不是因为恰好有 19 种操作,而是因为这 19 个工具映射到 5 个认知类别,每个类别对应 AI 在记忆交互中的一种角色。这不是功能清单,而是角色模型。
让我们从源码出发。打开 mcp_server.py,工具注册在第 441-688 行的 TOOLS 字典中。每个工具是一个键值对:名称映射到包含 description、input_schema、handler 三个字段的字典。注册方式极其朴素——没有装饰器,没有注册中心,就是一个 Python 字典:
TOOLS = {
"mempalace_status": {
"description": "Palace overview — ...",
"input_schema": {"type": "object", "properties": {}},
"handler": tool_status,
},
# ... 18 more tools
}
这种朴素不是偷懒。MCP 协议本身就是 JSON-RPC——客户端发送 tools/list,服务器返回工具列表;客户端发送 tools/call,服务器执行并返回结果。mcp_server.py:708-718 中的 handle_request 函数处理 tools/list 请求时,直接遍历 TOOLS 字典生成响应。整个协议交互不超过 30 行代码。朴素意味着透明,透明意味着任何开发者都能在五分钟内理解整个注册机制。
五组工具,五种角色
19 个工具按认知角色分为五组。先看全貌,再逐一拆解为什么这样分。
读取组(7 个)——让 AI 感知宫殿的结构和内容:status、list_wings、list_rooms、get_taxonomy、search、check_duplicate、get_aaak_spec。
写入组(2 个)——让 AI 往宫殿里存东西:add_drawer、delete_drawer。
知识图谱组(5 个)——让 AI 操作实体关系:kg_query、kg_add、kg_invalidate、kg_timeline、kg_stats。
导航组(3 个)——让 AI 在宫殿中行走和探索:traverse、find_tunnels、graph_stats。
日记组(2 个)——让 AI 保持跨会话的自我意识:diary_write、diary_read。
这个分组不是按技术实现分的。search 和 traverse 底层都查询 ChromaDB,但前者是"找内容",后者是"走路径"。kg_query 和 search 都是检索,但前者检索的是结构化关系,后者检索的是非结构化文本。分组标准是 AI 在调用这个工具时扮演的角色:它是在观察、在记录、在推理、在探索、还是在反思?
graph TB
MCP[MemPalace MCP Server]
MCP --> R["读取组 7个<br/>status, list_wings, list_rooms<br/>get_taxonomy, search<br/>check_duplicate, get_aaak_spec"]
MCP --> W["写入组 2个<br/>add_drawer, delete_drawer"]
MCP --> K["知识图谱组 5个<br/>kg_query, kg_add<br/>kg_invalidate, kg_timeline<br/>kg_stats"]
MCP --> N["导航组 3个<br/>traverse, find_tunnels<br/>graph_stats"]
MCP --> D["日记组 2个<br/>diary_write, diary_read"]
为什么写入组只有 2 个,而读取组有 7 个?因为记忆系统的核心不对称性:写入是简单的(把东西放进去),读取是复杂的(从不同角度把东西找出来)。一个房间只有一扇门可以进入,但有七扇窗户可以观察。list_wings 是鸟瞰,list_rooms 是局部放大,get_taxonomy 是完整地图,search 是语义定位,check_duplicate 是入库前的去重门禁,get_aaak_spec 是语言参考手册。每一扇窗户对应一种不同的认知需求。
mempalace_status:一个工具调用,三重载荷
在 19 个工具中,mempalace_status 的地位是特殊的。更准确地说,当 palace collection 已经存在时,它不仅返回数据——它还会教会 AI 两样东西:一种语言(AAAK)和一套行为协议(记忆协议)。
看 mcp_server.py:63-86 中 tool_status 的返回结构:
def tool_status():
col = _get_collection()
if not col:
return _no_palace()
count = col.count()
wings = {}
rooms = {}
# ... count logic ...
return {
"total_drawers": count,
"wings": wings,
"rooms": rooms,
"palace_path": _config.palace_path,
"protocol": PALACE_PROTOCOL,
"aaak_dialect": AAAK_SPEC,
}
前四个字段是常规状态数据——抽屉总数、wing 分布、room 分布、存储路径。后两个字段是关键:protocol 和 aaak_dialect。但源码里的条件分支也提醒我们一个边界:如果 collection 还不存在,tool_status() 会直接返回 _no_palace(),也就是错误、路径和初始化提示,而不会携带这两段教学文本。
第一重载荷:宫殿概览。 total_drawers、wings、rooms 告诉 AI "你的记忆有多大、分成几块、每块有什么"。这是空间感知——AI 看到这些数字后,知道在 wing_user 里搜索个人偏好,在 wing_code 里搜索技术决策。
第二重载荷:记忆协议。 PALACE_PROTOCOL 是一段纯文本指令,定义在 mcp_server.py:93-100。它规定了 AI 的五步行为规范:
1. ON WAKE-UP: Call mempalace_status to load palace overview + AAAK spec.
2. BEFORE RESPONDING about any person, project, or past event:
call mempalace_kg_query or mempalace_search FIRST. Never guess — verify.
3. IF UNSURE about a fact: say "let me check" and query the palace.
4. AFTER EACH SESSION: call mempalace_diary_write to record what happened.
5. WHEN FACTS CHANGE: call mempalace_kg_invalidate on the old fact,
mempalace_kg_add for the new one.
这不是建议,而是协议。它把 AI 从"有记忆可用的工具调用者"提升为"主动维护记忆的代理"。第 2 条尤其关键——"Never guess, verify"——它直接对抗 LLM 的核心弱点:幻觉。当 AI 被问到"Max 几岁了"时,协议要求它先查询知识图谱,而不是从训练数据中猜一个答案。
第三重载荷:AAAK 方言规范。 AAAK_SPEC 定义在 mcp_server.py:102-119,是一份完整的压缩语言规范。它教会 AI 三样东西:实体编码(ALC=Alice, JOR=Jordan)、情绪标记(*warm*=joy, *fierce*=determined)、和结构语法(管道分隔、星级评分、hall/wing/room 命名法)。
为什么把语言规范嵌入 status 响应,而不是单独放一个工具?因为 MCP 的调用时机。AI 在进入一个已经存在的宫殿时,最自然的动作就是调用 status——"看看这里有什么"。如果 AAAK 规范在另一个工具里,AI 需要两次调用才能完成初始化。把它塞进 status,在 palace 已就绪的常见路径里,一次调用就能完成三件事:了解宫殿结构、学会行为协议、掌握压缩语言。
这个设计的底层哲学是:API 不仅传递数据,还传递行为模式。 传统 API 假设调用者已经知道如何使用数据。但当调用者是一个没有持久记忆的 LLM 时,API 必须在每次会话中重新教育调用者。mempalace_status 的三重载荷正是为此设计的。
mempalace_search:语义检索的接口克制
mempalace_search 是使用频率最高的工具,但它的接口设计极其克制。看 mcp_server.py:587-600 中的 schema:
"mempalace_search": {
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"wing": {"type": "string"},
"room": {"type": "string"},
},
"required": ["query"],
},
"handler": tool_search,
}
四个参数,只有 query 是必填的。wing 和 room 是可选过滤器。没有排序选项、没有分页、没有嵌入模型选择、没有距离度量参数。
这种克制是刻意的。它的底层逻辑来自宫殿结构带来的检索增益:在 22,000+ 记忆上的测试中,不加过滤的全量搜索只有 60.9% 的 R@10,加上 wing 过滤跳到 73.1%,加上 wing + room 过滤跳到 94.8%。也就是说,过滤器是主要的精度杠杆,而非搜索算法本身。
所以接口的设计重心放在过滤器上——让 AI 很容易表达"在这个 wing 的这个 room 里找"——而把搜索算法的复杂度完全封装掉。AI 不需要知道底层用的是 ChromaDB 的余弦相似度还是欧氏距离,它只需要知道"给我一个词、一个可选的范围"。
handler 实现同样简洁。tool_search 在 mcp_server.py:173-180 中只有一行有效代码——直接调用 searcher.py 中的 search_memories 函数:
def tool_search(query, limit=5, wing=None, room=None):
return search_memories(
query, palace_path=_config.palace_path,
wing=wing, room=room, n_results=limit,
)
search_memories(searcher.py:87-142)返回一个结构化的字典,包含每条结果的原文、wing、room、来源文件和相似度分数。注意它返回的是原文——"the actual words, never summaries"——这是 MemPalace 的核心承诺。AI 拿到的是逐字记忆,不是某个摘要模型对记忆的再解读。
mempalace_add_drawer:写入即去重
写入工具只有两个,但 add_drawer 的实现比它的接口暗示的更复杂。看 mcp_server.py:250-287:
def tool_add_drawer(wing, room, content,
source_file=None, added_by="mcp"):
col = _get_collection(create=True)
# Duplicate check
dup = tool_check_duplicate(content, threshold=0.9)
if dup.get("is_duplicate"):
return {
"success": False,
"reason": "duplicate",
"matches": dup["matches"],
}
# ... generate ID, store, return success
关键行为:在存储之前,它自动调用 tool_check_duplicate 做语义去重。阈值是 0.9——如果宫殿里已经有一条记忆与新内容的余弦相似度超过 90%,写入被拒绝,并返回已有的匹配记录。
这个设计把去重逻辑从 AI 的责任中移除了。没有这个机制,AI 需要在每次写入前手动调用 check_duplicate,而 LLM 经常"忘记"做这种防御性操作。内建去重意味着即使 AI 试图重复存储相同内容——比如在不同会话中多次被告知同一件事——宫殿也不会膨胀。
drawer ID 的生成方式(mcp_server.py:267)也值得注意:它用内容前 100 字符加当前时间戳的 MD5 哈希前 16 位,拼上 wing 和 room 前缀。这意味着同一内容在不同时间存入会产生不同 ID——但在去重阈值为 0.9 的情况下,语义相同的内容已经被拦截了。ID 的命名方式(drawer_wing_room_hash)也让调试变得直观:看到一个 ID 就知道它属于哪个 wing 和 room。
mempalace_kg_query:结构化记忆的时间轴
知识图谱组的五个工具是 MemPalace 与纯向量检索系统的根本区别。其中 kg_query 是最常用的。看 mcp_server.py:309-312:
def tool_kg_query(entity, as_of=None, direction="both"):
results = _kg.query_entity(
entity, as_of=as_of, direction=direction)
return {"entity": entity, "as_of": as_of,
"facts": results, "count": len(results)}
三个参数——实体名、时间点、方向——对应三种查询模式:
kg_query("Max")—— Max 的所有关系,过去和现在。kg_query("Max", as_of="2026-01-15")—— 2026 年 1 月 15 日 Max 的关系快照。kg_query("Max", direction="incoming")—— 谁与 Max 有关系。
as_of 参数的底层实现在 knowledge_graph.py:199-203:它通过 SQL 的 valid_from <= ? AND (valid_to IS NULL OR valid_to >= ?) 条件,只返回在指定日期有效的事实。这意味着当 AI 被问到"Max 去年在做什么"时,它能看到去年有效的事实,而不是今天的事实。
时间维度与 kg_invalidate 配合使用。当一个事实不再为真——比如 Max 从游泳队退出了——AI 调用 kg_invalidate("Max", "does", "swimming", ended="2026-03-01")。这条事实不会被删除,而是被标记了结束日期。历史查询仍然能看到它,但当前查询不会返回它。
这种"软删除"设计反映了一个深层认知:记忆不是数据库,事实的终止和事实的存在一样重要。 删除一条记忆意味着假装它从未发生过。标记它的结束日期意味着承认时间的流逝。AI 需要后一种能力才能正确回答"Max 以前做什么"和"Max 现在做什么"之间的区别。
导航组:从"找东西"到"走路径"
导航组的三个工具——traverse、find_tunnels、graph_stats——与读取组的 search 有本质区别。search 是"我知道要找什么,帮我找到"。导航是"我不知道要找什么,带我走走看"。
mempalace_traverse(mcp_server.py:553-569)的 description 说得最清楚:"Like following a thread through the palace: start at 'chromadb-setup' in wing_code, discover it connects to wing_myproject (planning) and wing_user (feelings about it)."
它的实现委托给 palace_graph.py 的 traverse 函数。底层逻辑是从 ChromaDB 的元数据中构建一个房间级别的图——当同一个 room 名出现在不同 wing 中时,它们之间就形成了一条"隧道"。AI 从一个 room 出发,沿隧道走到另一个 wing 的同名 room,再看那个 wing 里还有什么 room 与起点有关。
find_tunnels 更直接——给定两个 wing(或者不给,看所有 wing 之间的桥梁),返回连接它们的 room。当 AI 需要理解"技术决策和团队动态之间有什么关联"时,它可以调用 find_tunnels(wing_a="wing_code", wing_b="wing_team"),得到的是一组共享的 room 名——这些 room 就是两个领域交叉的主题。
这三个导航工具的存在解释了为什么工具总数是 19 而不是 14 或 15。如果只有读取和写入,AI 只能做精确检索。加上知识图谱,AI 能做结构化推理。加上导航,AI 能做开放式探索。19 个工具覆盖了记忆交互的完整频谱:从存储到检索到推理到探索到反思。
协议层:JSON-RPC 的极简实现
最后看一眼 MCP 协议层本身。mcp_server.py:746-768 中的 main 函数是整个服务器的入口:
def main():
logger.info("MemPalace MCP Server starting...")
while True:
line = sys.stdin.readline()
if not line:
break
request = json.loads(line.strip())
response = handle_request(request)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
stdin 读入,json 解析,分发处理,stdout 写出。没有 HTTP,没有 WebSocket,没有框架。MCP 协议走的是 stdio 通道——进程间通信的最原始形式。
这个选择有两个后果。第一,启动成本几乎为零——不需要端口绑定、不需要 TLS 证书、不需要服务发现。claude mcp add mempalace -- python -m mempalace.mcp_server 一行命令完成注册。第二,安全模型极其简单——MCP 服务器是 Claude Code 的子进程,继承父进程的权限,不需要额外的认证机制。
handle_request(mcp_server.py:691-743)处理四种 method:initialize(握手)、notifications/initialized(确认)、tools/list(工具清单)、tools/call(工具调用)。工具调用时,它从 TOOLS 字典中查找 handler,用 **tool_args 解包参数,调用函数,把返回值序列化为 JSON。整个分发逻辑不到 50 行。
这种极简实现不是技术限制,而是设计哲学:协议层应该是透明的——所有的复杂性应该在工具的语义设计中,而非在传输机制中。 19 个工具的分组逻辑、status 的三重载荷、add_drawer 的内建去重、kg_query 的时间过滤——这些才是值得投入设计精力的地方。协议层做到能用就行。
设计的不可见之处
回到开头的问题:为什么是 19 个工具?
答案不在数字本身。19 个工具是以下约束的自然结果:
读取必须多于写入。 因为记忆的价值在于被检索,而检索有多种粒度——全局概览、wing 级列表、room 级列表、语义搜索、去重检查、语言规范查阅。每种粒度服务一种不同的认知时刻。
知识图谱必须独立于向量检索。 因为向量检索回答"什么内容与这个查询相似",而知识图谱回答"这个实体与谁有什么关系、在什么时间"。前者是模糊匹配,后者是精确推理。AI 需要两种能力。
导航必须独立于搜索。 因为搜索假设用户知道要找什么,而导航假设用户想探索未知。traverse 和 find_tunnels 让 AI 能够发现连接,而不仅仅是检索已知。
日记必须存在。 因为没有日记,AI 只是一个在别人的记忆里搜索的工具。有了日记,它是一个有自己观察历史的代理。这两者之间的差距不是功能差距,是角色差距。
19 是这些约束的最小完备集。不是 18,因为那意味着砍掉一种认知能力。不是 20,因为没有第 20 种不能被前 19 种覆盖的需求。
每个 API 最终都是一种世界观的编码。MemPalace 的 19 个工具编码的世界观是:AI 不仅需要存取记忆,还需要在结构化关系中推理,在空间拓扑中探索,在时间线上追溯,在私有日记中反思。这五种能力合在一起,才构成完整的记忆交互。
第20章:专家代理系统
定位:本章分析 MemPalace 如何用宫殿的空间结构——而非配置文件的膨胀——来承载无限数量的专家代理,以及为什么它把每个代理的记忆建模为
wing + diary。其中,AAAK 日记是 README 和工具描述中鼓励的写法;而当前 MCP 运行时真正落地的,是每个代理一个 wing、一个 diary room 的存储结构。
专家代理的配置困境
假设你有 50 个 AI 代理,每个负责一个专业领域。一个审查代码质量,一个关注架构决策,一个追踪运维事件,一个记录产品需求,一个监控安全漏洞。在传统的代理框架中,每个代理需要独立的配置——系统提示词、记忆存储、状态管理、权限范围。50 个代理意味着 50 份配置文件,或者一个越来越庞大的中心配置。
Letta(前身 MemGPT)选择了中心化路径:每个代理有独立的记忆块、独立的系统提示、独立的核心记忆和归档记忆。这些都存储在云端,通过 API 管理。这很干净,但成本线性增长——免费版 1 个代理,开发者版 $20/月 10 个代理,商业版 $200/月 100 个代理。代理数量与钱包深度直接挂钩。
MemPalace 选择了一条完全不同的路径:代理不住在配置文件里,代理住在宫殿里。
一个代理 = 一个 Wing + 一本日记
回到 mcp_server.py 中的日记工具。tool_diary_write(第 349-392 行)的前三行揭示了整个架构:
def tool_diary_write(agent_name, entry, topic="general"):
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
room = "diary"
col = _get_collection(create=True)
当一个名叫 "reviewer" 的代理写日记时,它的条目被存入 wing_reviewer/diary。当 "architect" 写日记时,存入 wing_architect/diary。代理的身份由它的 wing 名决定,代理的记忆由它的 diary room 承载。不需要额外的配置文件来"注册"一个代理——第一次调用 diary_write 时,wing 自动创建。
元数据结构(mcp_server.py:368-380)进一步强化了这种自然性:
col.add(
ids=[entry_id],
documents=[entry],
metadatas=[{
"wing": wing,
"room": room,
"hall": "hall_diary",
"topic": topic,
"type": "diary_entry",
"agent": agent_name,
"filed_at": now.isoformat(),
"date": now.strftime("%Y-%m-%d"),
}],
)
每条日记条目携带完整的空间坐标——wing(哪个代理)、room(diary)、hall(hall_diary)、topic(主题标签)——加上时间坐标(filed_at、date)和身份标识(agent)。这足以让日记条目进入宫殿的既有基础设施:search 可以搜到它们,list_rooms 可以统计它们,图构建逻辑也会把 diary 视为一个普通 room。需要更谨慎地说的是:当前代码并没有为 diary 提供专门的 topic 过滤或 agent 编排机制,它首先仍然只是宫殿里的一类普通 drawer。
代理不是宫殿之外的附加物。代理就是宫殿的居民。
AAAK 日记:压缩的自我意识
代理写入日记时,接口约定鼓励使用 AAAK。看 diary_write 工具的 description(mcp_server.py:649):
Write to your personal agent diary in AAAK format. Your observations,
thoughts, what you worked on, what matters. Write in AAAK for
compression — e.g. 'SESSION:2026-04-04|built.palace.graph+diary.tools
|ALC.req:agent.diaries.in.aaak|★★★★'
工具描述直接示范了 AAAK 日记的格式。一个典型的代理日记条目可以写成这样:
PR#42|auth.bypass.found|missing.middleware.check|pattern:3rd.time.this.quarter|★★★★
这一行压缩了以下信息:在 PR #42 的审查中发现了认证绕过漏洞,原因是缺少中间件检查,这已经是本季度第三次出现同类问题,重要性四星。如果用自然语言写,至少需要三行。用 AAAK 写,一行搞定。
压缩的价值在 diary_read 中兑现,但这里要分清楚"接口约定"和"运行时强制"。tool_diary_write 并不会校验输入是不是 AAAK,也不会自动把自然语言压缩成 AAAK;它只是把调用方传进来的字符串原样存入 ChromaDB。换句话说,AAAK 的收益是真实的,但前提是调用方自己遵守这个写法约定。
因此,这里更准确的说法是:如果一个代码审查代理持续用 AAAK 记录自己的观察,那么它读取最近 10 条日记时,确实可能用极小的 token 预算恢复近期工作模式;如果它写的是自然语言,diary_read 也照样能工作,只是上下文成本会上升。
AAAK 日记的管道分隔语法在这里体现出特别的优势。pattern:3rd.time.this.quarter 不仅记录了一个事实,还记录了一个趋势。当代理下次审查认证相关的 PR 时,如果它再次读取这些日记,它就能把这个模式重新带回当前会话。日记不是日志,日记是学习曲线的压缩编码;只是这层"压缩"在当前实现中靠调用方自觉完成,而不是由 MCP 服务器代劳。
50 个代理,一行配置
MemPalace 代理系统最反直觉的特性是:无论你有多少个代理,其最小落地单元都只是一个新的 wing_<agent>。README 中进一步展示了一套更完整的 agent 发现接口:
You have MemPalace agents. Run mempalace_list_agents to see them.
一行。不是"你有一个叫 reviewer 的代理,它关注代码质量,它的系统提示是..."。而是告诉 AI:你有代理,去宫殿里看看有哪些。
README 还给出了与之配套的目录约定:
~/.mempalace/agents/
├── reviewer.json # 代码质量、模式识别、bug 追踪
├── architect.json # 设计决策、权衡分析、架构演进
└── ops.json # 部署、事件响应、基础设施
这套叙述表达了一个很清晰的产品方向:代理描述文件放在本地目录里,顶层配置只保留一句提示,运行时再去发现它们。但如果严格对照当前 mcp_server.py,需要补一句实话:仓库里并没有实现 mempalace_list_agents,也没有加载 ~/.mempalace/agents/*.json 的代码。 当前真正落地的部分,是 diary_write/diary_read 所提供的最小存储结构;README 里的 agent 目录和发现机制,更接近上层工作流的提案。
所以,把"添加第 51 个代理"拆成两层看会更准确。对当前 MCP 层来说,这意味着:给一个新的 agent_name 调用 diary_write,系统自然就会开始往 wing_<agent>/diary 写条目,不需要 schema 迁移,不需要额外实例,不需要中心注册。对 README 设想中的完整 agent 体验来说,则还会再加上一个 JSON 描述文件和一个运行时发现步骤。
对比 Letta 的模型:每个代理有独立的 core memory(始终加载的关键事实)、recall memory(可搜索的历史)、archival memory(长期存储)。这些都通过 REST API 管理,存储在云端。添加一个代理意味着创建一个代理实例、配置其记忆块、设置其系统提示、管理其 API 密钥。50 个代理意味着 50 次这样的操作,加上持续的月费。
这也是 MemPalace 与 Letta 的真正分野:它先把代理问题降解成一个存储问题。一个代理至少是一个 wing 加一本 diary;剩下的 focus、persona、协作方式,可以继续往上叠,但底座不需要膨胀成 50 套独立的记忆实例。
代理间的隧道:共享记忆的自然涌现
宫殿架构带来的一个意外优势是代理间的知识连接。
当 reviewer 代理在 wing_reviewer/diary 中记录 auth.bypass.found|missing.middleware.check,而 architect 代理在 wing_architect/diary 中记录 auth.migration.decision|clerk>auth0|middleware.layer.critical——它们各自在自己的 wing 里,互不干扰。但 mempalace_search("middleware") 仍然可能同时返回两条记录。mempalace_find_tunnels("wing_reviewer", "wing_architect") 也会注意到它们共享了同一个 room 名 diary。这是一种很粗粒度的关联,但已经足以说明:代理记忆不必住在彼此隔绝的数据库里。
当前实现的边界也要一并看到:searcher.py 只支持 wing 和 room 过滤,不支持 topic=auth 这种 diary 专用筛选;traverse 的图遍历也是按 room 名聚合,而不是按 topic 聚合。也就是说,MemPalace 已经提供了"把多个代理记忆放进同一宫殿后可以被统一搜索"这一级能力,但还没有提供更细的 agent-topic 编排层。
日记的读取:时间排序的自我回顾
tool_diary_read(mcp_server.py:395-436)的实现揭示了日记系统的最后一个设计细节:
def tool_diary_read(agent_name, last_n=10):
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
col = _get_collection()
results = col.get(
where={"$and": [{"wing": wing}, {"room": "diary"}]},
include=["documents", "metadatas"],
)
# ... sort by timestamp, return latest N
它用 $and 条件精确定位代理的日记——wing 匹配代理名,room 固定为 "diary"。然后按时间戳倒序排列,返回最近的 N 条。
默认值 last_n=10 是一个经过考量的选择。太少(比如 3 条),代理丢失趋势感——看不到"这个问题反复出现"。太多(比如 50 条),最近回顾就会开始吞掉不必要的上下文预算。这里无需假装代码里有精确的 token 管理器;源码做的事情更朴素:只返回最近 N 条,由调用方决定后续如何消费。
返回结构中的 total 字段告诉代理它的完整日记有多长:
return {
"agent": agent_name,
"entries": entries,
"total": len(results["ids"]),
"showing": len(entries),
}
一个有 200 条历史日记但只展示 10 条的代理,知道自己有丰富的历史但当前只看到了最近的切片。如果需要更早的记忆,它可以通过 mempalace_search 在自己的 wing 中做语义搜索。日记是近期回顾的快速通道,搜索是深层回忆的备用路径。
深度三层:代理架构的意义栈
把代理的三层含义叠放起来看:
第一层:存储层。 代理就是一个 wing。Wing 是 ChromaDB 中的一个元数据标签,不是一个独立的数据库。添加一个代理意味着增加一个标签值,系统的复杂度不增加。这是 0 到 N 代理的线性扩展——但成本函数的斜率是零(不算存储本身)。
第二层:认知层。 如果调用方遵守工具描述里的约定,用 AAAK 来写 diary,那么代理的记忆就不仅记录事实,还记录模式(pattern:3rd.time)、重要性评估(星级)、情绪标记(*fierce*、*raw*)。当代理在新会话中读取这些日记时,它不是在回忆发生了什么——它是在重建对领域的理解。一个审查过 200 个 PR 的 reviewer 代理,读取 10 条最近的压缩日记后,对代码质量的感知会比一个新鲜的、没有历史的 AI 更尖锐。
第三层:生态层。 多个代理在同一个宫殿中各自积累专业知识,它们的记忆通过宫殿的搜索和导航基础设施被连接。reviewer 发现的 bug 模式可能与 architect 的设计决策相关;ops 记录的事件可能印证 reviewer 的代码质量担忧。这些关联不需要人工建立——它们通过共享的语义空间和命名空间自然涌现。
这三层合在一起,回答了一个更大的问题:AI 代理的记忆应该放在哪里?
放在 CLAUDE.md 里?那是配置膨胀——每多一个代理,配置文件就多一段。放在独立数据库里?那是基础设施膨胀——每多一个代理,就多一个存储实例要管理。放在云服务里?那是成本膨胀——每多一个代理,月费就多一份。
放在宫殿的一个 wing 里?那是一个标签。宫殿已经有搜索、有导航、有知识图谱、有压缩。代理只是这些已有能力的一个新消费者。它不增加基础设施,不增加配置,不增加月费。它只增加记忆——而记忆正是宫殿存在的意义。
第21章:本地模型集成
定位:本章阐释 MemPalace 如何在本地依赖和默认嵌入资产准备完成后长期离线运行——从 ChromaDB 到本地模型到 AAAK 压缩——以及为什么整个栈的设计从第一天起就把"无需持续联网"作为硬约束而非可选特性。
离线不是降级模式
大多数 AI 记忆系统把离线支持当作一种降级模式:云端是完整功能,本地运行时砍掉一些特性、降低一些性能、免去一些费用作为交换。Mem0 的核心是云端 API,自部署只是企业版的选项。Zep 的知识图谱运行在 Neo4j 之上,虽然可以本地搭建,但推荐配置是云端实例。
MemPalace 的设计方向完全相反:主路径是本地,云端增强只是旁路。 ChromaDB 是嵌入式向量数据库,数据存储在本地文件系统。知识图谱用 SQLite,同样是本地文件。AAAK 压缩是纯字符串操作,不依赖任何外部服务。MCP 服务器走 stdio 通道,不涉及网络。更准确地说,对已经完成本地依赖和默认嵌入资产准备的环境,你可以在一台断网的笔记本上完成存储、搜索、唤醒和知识图谱查询;只有 benchmark 中那条追求 100% 的 Haiku/Sonnet rerank 路径,才额外引入了云端模型。
这种设计不是技术洁癖。它来自对记忆数据本质的判断:个人记忆是最敏感的数据类型之一,它不应该需要用户信任任何第三方。 你的技术决策、团队动态、个人偏好、项目进展——这些信息的集合比任何单个文档都更敏感,因为它描绘的是一个完整的工作画像。把这个画像托管在别人的服务器上,需要一个很强的理由。而"方便"不是一个足够强的理由。
本章的主题不是"如何在本地安装和配置"——那是文档的工作。本章的主题是:当整个栈都在本地时,AI 和记忆之间的集成路径是什么样的?
路径一:wake-up 命令
看 cli.py:107-118 中的 cmd_wakeup 实现:
def cmd_wakeup(args):
"""Show L0 (identity) + L1 (essential story)
— the wake-up context."""
from .layers import MemoryStack
palace_path = (os.path.expanduser(args.palace)
if args.palace
else MempalaceConfig().palace_path)
stack = MemoryStack(palace_path=palace_path)
text = stack.wake_up(wing=args.wing)
tokens = len(text) // 4
print(f"Wake-up text (~{tokens} tokens):")
print("=" * 50)
print(text)
它做了一件简单的事:从宫殿中提取 L0(身份)和 L1(关键事实),输出到终端。用户把这段文本复制进本地模型的系统提示中,模型就拥有了宫殿的核心记忆。
命令行的使用方式:
mempalace wake-up > context.txt
# 把 context.txt 的内容粘贴到本地模型的系统提示
mempalace wake-up --wing driftwood > context.txt
# 项目特定的唤醒上下文
MemoryStack.wake_up()(layers.py:380-399)的内部逻辑分两步。第一步加载 L0:读取 ~/.mempalace/identity.txt——一个用户手写的纯文本文件,定义 AI 的身份。第二步生成 L1:从 ChromaDB 中拉取最重要的 15 条记忆(按重要性排序),按 room 分组,截断到 3200 字符上限,格式化为紧凑的文本块。
按当前源码口径,这段输出通常是 ~600-900 token,CLI 还会在打印前用 len(text) // 4 做一次粗略估算(cli.py:114-117)。README 里出现的 ~170 token,描述的是另一条更激进的目标路径:把 L1 改写成 AAAK 后再用于 wake-up。也就是说,"本地模型可用"和"唤醒只有 170 token"是两个不同命题;前者已经实现,后者还没有接到默认命令上。
为什么用 wake-up 这个名字而不是 "load-context" 或 "get-summary"?因为这个操作的语义不是"获取数据",而是"唤醒一个有记忆的代理"。当本地模型加载了这段 L0 + L1 文本后,它从一个对用户一无所知的通用模型,变成了一个知道用户是谁、在做什么项目、关心什么事情的专属助手。这是身份的注入,不是数据的传输。
路径二:Python API
wake-up 命令适合手动工作流——用户在终端和本地模型之间来回切换。但如果你在构建一个自动化的管线——比如一个本地运行的代理框架,或者一个自定义的聊天界面——你需要编程接口。
searcher.py:87-142 中的 search_memories 函数是这个接口的核心:
from mempalace.searcher import search_memories
results = search_memories(
"auth decisions",
palace_path="~/.mempalace/palace",
wing="driftwood",
)
# results = {
# "query": "auth decisions",
# "filters": {"wing": "driftwood", "room": None},
# "results": [
# {"text": "...", "wing": "...", "room": "...",
# "source_file": "...", "similarity": 0.87},
# ...
# ]
# }
它返回一个字典而非打印到终端。字典中的 results 列表包含每条匹配记忆的原文、空间坐标、来源文件和相似度分数。调用者拿到这个字典后,可以把记忆文本注入到发给本地模型的 prompt 中。
一个典型的集成模式:
from mempalace.searcher import search_memories
from mempalace.layers import MemoryStack
# 1. 加载唤醒上下文
stack = MemoryStack()
wakeup = stack.wake_up()
# 2. 按需搜索相关记忆
results = search_memories("auth migration timeline")
memories = "\n".join(r["text"] for r in results["results"])
# 3. 组装 prompt,发送给本地模型
prompt = f"""## 你的记忆
{wakeup}
## 相关记忆
{memories}
## 用户问题
为什么我们选了 Clerk 而不是 Auth0?
"""
# response = local_model.generate(prompt)
这段代码不依赖任何网络请求。MemoryStack 从本地 ChromaDB 读取数据,search_memories 在本地做向量检索,prompt 组装是纯字符串拼接,local_model.generate 调用的是本地运行的模型推理。整条链路从头到尾在本机完成。
注意 search_memories 和 MCP 服务器中的 tool_search 实际上调用的是同一个函数(mcp_server.py:173-180)。MCP 路径和 Python API 路径在底层收敛到同一个检索引擎。这意味着通过 MCP 使用 Claude 找到的记忆,和通过 Python API 注入本地模型的记忆,来自完全相同的数据源和检索逻辑。不存在"MCP 版的记忆更好"这种情况。
完整离线栈的组成
把所有组件放在一起看,一个在冷启动准备完成后可长期离线运行的 MemPalace 栈长什么样:
存储层:ChromaDB(嵌入式)+ SQLite。 ChromaDB 在本地文件系统中存储向量嵌入和文档,默认位置 ~/.mempalace/palace。从当前仓库源码本身能直接确认的是:MemPalace 并没有显式配置一个外部 embedding 服务,而是直接依赖 ChromaDB 的默认本地嵌入路径。对已经完成首次资产准备的环境,这条路径之后可以持续离线使用。知识图谱用 SQLite,存储在 ~/.mempalace/knowledge_graph.sqlite3。两个数据库加起来对磁盘的要求微乎其微——一个有 22,000 条记忆的宫殿,全部数据加索引大约 200-300MB。
压缩层:AAAK 方言。 纯规则驱动的文本压缩,不依赖任何模型。实体名替换为三字母代码,结构化为管道分隔格式,情绪标记为星号标记。它已经作为独立能力存在于仓库里,但当前默认 wake-up 仍然输出原文式的 L0 + L1,而不是 AAAK 版唤醒文本。换句话说,AAAK 已经是离线栈的一部分,但它还没有完全成为本地模型入口的默认表达层。
接口层:CLI + Python API。 mempalace wake-up 输出唤醒文本,mempalace search 输出搜索结果。两个命令的输出都是纯文本,可以通过管道、重定向、或者复制粘贴注入任何模型。Python API 提供编程式访问,返回结构化数据供自动化管线使用。
推理层:用户选择的本地模型。 MemPalace 不绑定任何特定模型。它的输出是文本——任何能读文本的模型都能消费。这不是一个技术中立的姿态,而是架构约束的自然结果:当你的输出格式是纯文本时,你的消费者可以是任何文本处理器,无论是 70B 参数的 Llama 还是 7B 的 Mistral,无论是本地推理还是 API 调用。
为什么这样设计:两个关键决策
回看这个离线栈,两个设计决策值得深入分析。
决策一:文本作为接口,而非工具调用
MCP 路径下,AI 通过工具调用访问记忆——结构化的输入参数,结构化的 JSON 返回。但本地模型路径下,接口退化为纯文本。wake-up 输出是文本,search 输出是文本,AAAK 是文本。
这看似是降级——从结构化 API 降级到字符串复制粘贴。但实际上,文本是最具通用性的接口格式。JSON API 要求消费者理解 schema。工具调用要求消费者实现 MCP 协议。而文本只要求消费者能读。
这个选择的更深层意义在于:它不要求本地模型有任何"特殊能力"。不需要 function calling 支持,不需要 tool use 训练,不需要 JSON mode。一个只经过基础文本生成训练的 7B 模型,只要能读普通文本,就能消费 wake-up 输出。当前这段文本大约是 600-900 token;如果未来切到 AAAK 版 wake-up,这个门槛还会更低。
决策二:AAAK 是纯文本协议,不是编码格式
AAAK 的设计中有一个容易被忽略的关键特性:它不需要解码器。
比较一下其他压缩方案。如果你用 gzip 压缩记忆文本,你得到极高的压缩率,但 LLM 无法直接读 gzip 二进制。如果你用自定义 token 编码——比如把 "Alice" 映射为一个特殊 token——你需要修改模型的词表,或者在推理前做一次解码。
AAAK 两者都不需要。ALC=Alice 是一个可读的映射。| 是一个可见的分隔符。★★★★ 是一个直觉上可以理解的评分。任何 LLM——无论其训练数据、词表、或推理框架——都可以直接阅读 AAAK 文本并正确理解其含义。
这是让整个本地栈成立的基础假设。如果 AAAK 需要一个解码步骤,那么在本地模型的推理管线中就需要插入一个预处理器。预处理器意味着额外的代码、额外的依赖、额外的故障点。纯文本的 AAAK 消除了这个层次——记忆从存储到消费是端到端的纯文本流,中间没有任何转换步骤。
两条路径的取舍
wake-up 路径和 Python API 路径不是替代关系,而是互补关系。它们服务不同的使用场景。
wake-up 路径适合交互式使用。 用户坐在终端前,开启一个新的对话,先运行 mempalace wake-up,把输出粘贴到模型的上下文中,然后开始对话。整个过程需要 10 秒左右,额外 token 消耗通常在 600-900 之间。适合日常的问答、头脑风暴、代码审查。它的优势是零集成成本——不需要写代码,不需要改配置,不需要搭管线。README 中更轻的 ~170 token 口径,则属于这一流程的下一阶段优化目标。
Python API 路径适合自动化管线。 开发者构建一个自定义的代理框架——也许是一个基于 LangChain 的工作流,也许是一个自定义的 CLI 工具,也许是一个 IDE 插件——通过 search_memories 在每次对话前自动检索相关记忆,注入到 prompt 中。额外 token 消耗取决于搜索结果的数量和长度,通常在 500-2000 token 之间。适合需要深度记忆整合的场景——项目回顾、决策追溯、知识库查询。
两条路径共享同一个宫殿。wake-up 中看到的记忆和 API 中检索到的记忆来自同一个 ChromaDB 实例。切换路径不需要数据迁移、不需要重新索引、不需要格式转换。宫殿是唯一的事实源——访问方式是可替换的。
离线的代价与回报
诚实地说,完全离线运行有代价。
嵌入质量的代价。 ChromaDB 默认的 all-MiniLM-L6-v2 是一个小型嵌入模型。它的语义理解能力不如 OpenAI 的 text-embedding-3-large 或 Cohere 的 embed-v3。在极端的语义匹配场景下——比如用"为什么我们放弃了旧的认证系统"搜索一条包含"Auth0 的定价在用户超过一万时变得不可持续"的记忆——小模型可能会漏掉而大模型不会。MemPalace 通过宫殿结构的过滤来弥补这个差距:当你告诉搜索"在 wing_driftwood 的 auth-migration room 里找"时,搜索空间缩小到几十条记忆,小模型在这个范围内的准确率与大模型相当。这也是为什么宫殿结构带来 34% 的检索增益——结构弥补了模型。
推理能力的代价。 本地模型的推理能力通常弱于云端大模型。一个 7B 参数的模型可能无法像 Claude 那样精确地理解 AAAK 日记中的模式标记、正确推断时间关系、或者在多条矛盾记忆之间做出判断。但 MemPalace 的设计哲学是:让存储层做存储的事,让推理层做推理的事。 如果记忆被正确检索并呈现给模型,即使模型的推理能力有限,它至少在正确的事实基础上推理。这比一个推理能力很强但基于幻觉的回答要好得多。
回报是确定的。 隐私保护——你的记忆永远不离开你的机器。零运行成本——除了电费,没有任何月费。无限可用性——不依赖网络连接、不受 API 限流影响、不因服务宕机而失忆。以及一种更深层的回报:自主权。你的记忆系统不受任何第三方的定价决策、隐私政策变更、或服务关停影响。它在你的硬盘上,用你选择的模型运行,输出你控制的文本。
这不是每个用户都需要的权衡。如果你的记忆内容不敏感,云端方案的便利性可能更有价值。但对于那些记忆内容涉及商业决策、团队人事、个人生活的用户来说——而这恰恰是记忆系统最有价值的使用场景——离线能力不是一个可选特性,而是一个前提条件。
MemPalace 的整个技术栈是围绕这个前提条件设计的。ChromaDB 而非 Pinecone,SQLite 而非 Neo4j,AAAK 而非 GPT 摘要,stdio 而非 HTTP。每一个技术选择都指向同一个方向:你的记忆应该完全属于你,无论你是否连接互联网。
第22章:Benchmark 方法论
定位:本章解释为什么选择这三个 benchmark、每个 benchmark 测试什么能力维度、各自的盲区在哪里,以及如何让任何人在五分钟内复现全部结果。验证一个系统的最诚实方式不是展示它的成绩单,而是公开考试卷本身。
为什么需要三个 benchmark
一个 benchmark 只能回答一个问题。一个得了 96.6% 的系统可能只是恰好擅长那一种题型。
这不是假设。LongMemEval 是 AI 记忆领域最标准的测试——500 个问题,跨越 53 个对话 session,覆盖六种题型。MemPalace 在上面拿到了 96.6% 的 R@5。这个分数足够成为标题,但它只回答了一个问题:给你一堆对话历史,你能不能找到答案藏在哪个 session 里?
它没有回答:你能不能跨越多个 session 把线索串起来?它没有回答:当数据规模从 53 个 session 膨胀到数千个 session 时,性能会不会崩溃?它也没有回答:面对不同类型的记忆——事实、偏好、变化、推理——你的表现是否均匀?
所以我们选了三个 benchmark。不是因为三个数字比一个好看,而是因为每个 benchmark 测试的认知能力完全不同。它们的交集覆盖了 AI 记忆系统的三个核心维度:精确检索、多跳推理、大规模泛化。它们的盲区——每个 benchmark 测不到的东西——同样重要,本章会逐一分析。
LongMemEval:大海捞针
它是什么
LongMemEval 是一个由学术界设计的标准化记忆评估数据集。500 个问题,每个问题对应一个"大海"——53 个对话 session 组成的历史记录——和一根"针"——问题的正确答案藏在其中一个或多个 session 里。
测试的核心能力是信息定位:给定一个自然语言问题,你的系统能不能在 53 个 session 中找到包含答案的那一个?不需要生成答案,不需要理解答案,只需要把正确的 session 排到前面。
六种题型
LongMemEval 的 500 个问题覆盖六种题型,每种测试不同的检索难度:
| 题型 | 数量 | 描述 | MemPalace 基线 |
|---|---|---|---|
| knowledge-update | 78 | 事实随时间变化——当前答案覆盖了旧答案 | 99.0% |
| multi-session | 133 | 答案分散在多个 session 中 | 98.5% |
| temporal-reasoning | 133 | 包含时间锚点——"上个月"、"两周前" | 96.2% |
| single-session-user | 70 | 答案在用户的某句话里 | 95.7% |
| single-session-preference | 30 | 用户间接表达的偏好 | 93.3% |
| single-session-assistant | 56 | 答案在 AI 助手的回复里 | 92.9% |
最强的两个类别——knowledge-update 和 multi-session——正是 MemPalace 的设计甜区。当事实发生更新时,原始文本保留了新旧两个版本,搜索模型能自然匹配到包含更新的 session。当答案分散在多个 session 中时,逐字存储意味着每个 session 都完整保留了自己的那部分信息,semantic search 能分别命中。
最弱的两个类别揭示了更深的问题。single-session-preference(93.3%)弱在偏好表达的间接性:用户说"我觉得 Postgres 在并发场景下更靠谱",问题问的是"用户偏好什么数据库"——词汇完全不重叠,embedding 模型看不出两者的关联。single-session-assistant(92.9%)弱在索引缺口:默认只索引了用户发言,问题却问的是"AI 建议了什么"——答案根本不在搜索池里。
这两个弱点后来都被修复了。偏好缺口通过 16 个正则表达式模式提取偏好表达来弥补。助手缺口通过两阶段检索——先用用户发言定位 session,再在目标 session 内搜索助手发言——来解决。修复后分数从 96.6% 推进到了 99.4%,再到完整 500 题上的 100%。但同一份 BENCHMARKS.md 也同时公布了 hybrid_v4 在 held-out 450 上的 98.4% R@5 / 99.8% R@10,并把它标成更诚实的泛化数字。
为什么选它
LongMemEval 是目前 AI 记忆领域引用最广的 benchmark。Supermemory、Mastra、Mem0、Hindsight——所有主要竞品都在这个 benchmark 上报告了成绩。这意味着分数之间有直接可比性。如果你在 LongMemEval 上的 R@5 是 96.6%,而 Mastra 是 94.87%,这两个数字用的是同一把尺子。
它的数据是公开的——托管在 HuggingFace 上,任何人都可以下载。它的评估指标是标准化的——Recall@K 和 NDCG@K 有明确的数学定义。这些属性使它成为可复现基准测试的理想选择。
更重要的是,MemPalace 的 benchmark 文档没有只公布一个最好看的数字。它把 96.6% raw、100% full-500 + rerank、以及 98.4% held-out hybrid_v4 三组数字一起放出来,分别对应产品基线、竞争成绩和更干净的泛化检查。对一本讨论方法论的书来说,这种"并列展示不同口径"本身就是值得强调的方法论成熟度。
盲区
LongMemEval 有三个显著的盲区。
盲区一:规模太小。 53 个 session 是一个很小的搜索空间。一个真实用户六个月的 AI 使用会产生数百个对话 session。在 53 个 session 里排名第一和在 500 个 session 里排名第一是完全不同的任务。LongMemEval 的 96.6% 能否在十倍规模下保持?这个问题它回答不了。
盲区二:不测推理。 LongMemEval 只测检索,不测理解。它的指标是"正确的 session 是否出现在 top-K 结果中",而不是"系统能否用检索到的内容正确回答问题"。一个把所有 53 个 session 全部返回的系统,理论上可以拿到 100% 的 Recall@53——但它什么也没"理解"。
盲区三:不测跨 session 推理。 multi-session 类型的问题虽然答案分散在多个 session 中,但评估标准是"任意一个相关 session 出现在 top-K 中"就算正确。它不测"把多个 session 的信息串联起来得出结论"的能力。
LoCoMo:多跳推理
它是什么
LoCoMo(Long Conversational Memory)来自 Snap Research,是一个专门为多跳推理设计的 benchmark。10 个长对话,每个对话包含 19-32 个 session、400-600 轮对话,共产生 1986 个 QA 对。
"多跳推理"是什么意思?考虑这个场景:
- Session 5:Caroline 提到她在研究海洋生物学
- Session 12:Caroline 说她找到了一个相关的研究员职位
- Session 19:问题——"Caroline 的职业发展方向是什么?"
要回答这个问题,系统需要把 session 5 和 session 12 的信息串联起来。单独检索到其中任何一个都不够——你需要两者才能拼出完整的图景。这就是"多跳"的含义:答案不在任何一个地方,而是分布在多个位置,需要跨越多个信息节点来推理。
五种题型
LoCoMo 的 1986 个问题分为五种类型:
| 题型 | 描述 | MemPalace 基线 (R@10) |
|---|---|---|
| single-hop | 答案在一个 session 里 | 59.0% |
| temporal | 涉及时间关系 | 69.2% |
| temporal-inference | 需要跨 session 做时间推理 | 46.0% |
| open-domain | 开放性问题 | 58.1% |
| adversarial | 故意混淆的问题——问 A 的事,但 B 说的话更多 | 61.9% |
最难的类别是 temporal-inference——需要在多个 session 之间建立时间因果关系。基线只有 46.0%。这意味着超过一半的跨时间推理问题,纯粹的语义检索找不到正确的 session。
adversarial 类别揭示了一个有趣的挑战:当两个人在同一个对话中出现时,embedding 模型分不清"谁说了什么"。如果问题问的是 Caroline 的研究方向,但 Melanie 在同一个 session 里说了更多的话,embedding 模型可能会把 Melanie 主导的 session 排得更高——即使 Caroline 的关键信息在另一个 session 里。
为什么选它
LoCoMo 填补了 LongMemEval 的核心盲区:跨 session 推理。LongMemEval 问的是"你能不能找到正确的 session",LoCoMo 问的是"你能不能理解 session 之间的关系"。
它还有一个重要的设计特点:每个对话的 session 数量(19-32 个)更接近真实用户的数据规模。虽然仍然不算大,但比 LongMemEval 的 53 个 session 共用的设计更贴近"每个项目独立积累对话历史"的真实场景。
盲区
盲区一:对话数量太少。 只有 10 个对话。这意味着单个对话的异常表现会严重影响总分。如果恰好有一个对话的主题分布特别不利于你的系统,总分可能下降 5-10 个百分点。
盲区二:对话都是虚构的。 LoCoMo 的对话是人工编写的模拟对话,不是真实用户的 AI 交互记录。虚构对话的语言模式、主题分布、信息密度都可能与真实对话有系统性差异。
盲区三:每个对话只有两个说话者。 真实场景中一个对话可能涉及多人——团队 standup、群组讨论、多方决策。LoCoMo 只有两人对话,没有测试多方信息交织的场景。
ConvoMem:大规模覆盖
它是什么
ConvoMem 来自 Salesforce Research,是目前规模最大的对话记忆 benchmark——75,336 个 QA 对,覆盖六种不同的记忆类型。它不测深度推理,测的是广度和类型覆盖。
六个类别
| 类别 | 描述 | MemPalace R@K |
|---|---|---|
| assistant_facts_evidence | AI 助手说过的事实 | 100% |
| user_evidence | 用户陈述的事实 | 98.0% |
| abstention_evidence | 系统应当拒绝回答的问题 | 91.0% |
| implicit_connection_evidence | 需要推理才能建立的隐含联系 | 89.3% |
| preference_evidence | 用户的偏好和习惯 | 86.0% |
| changing_evidence | 随时间变化的事实 | -- |
assistant_facts_evidence 拿到 100% 不意外——ConvoMem 的测试方式是检查检索结果是否包含证据消息,而 MemPalace 逐条存储每一条消息(包括助手的回复),搜索时自然能命中。
preference_evidence 是最弱的类别(86.0%),原因与 LongMemEval 的偏好类别相同:偏好往往用间接语言表达,embedding 模型难以在问题和表达之间建立关联。
为什么选它
ConvoMem 填补了另外两个 benchmark 都缺失的维度:类型覆盖。LongMemEval 主要测事实检索,LoCoMo 主要测推理能力,ConvoMem 把"记忆"分成了六种不同的类型,分别测试。这很重要,因为一个在事实检索上表现优异的系统,在偏好记忆或隐含推理上可能完全不同。
它的规模(75K+ QA 对)也提供了统计显著性:当你有七万五千个数据点时,一个类别 86% 和另一个类别 100% 之间的差异是真实的,不是噪声。
盲区
盲区一:每个 QA 对的上下文很短。 ConvoMem 的许多测试项只涉及几条消息的上下文,不像 LongMemEval 需要在 53 个 session 中搜索。这意味着它更多测试的是"短程匹配"而不是"长程检索"。
盲区二:六个类别的权重不均匀。 某些类别的样本量远大于其他类别。加权平均的 92.9% 可能掩盖了小类别上的弱点。
盲区三:不测真实的记忆保留。 ConvoMem 假设所有对话内容都已被正确存储,只测检索能力。它不测"在六个月的持续使用中,存储质量是否退化"这样的真实世界问题。
三个 benchmark 的互补性
把三个 benchmark 放在一起看,它们形成了一个三角测量:
| 维度 | LongMemEval | LoCoMo | ConvoMem |
|---|---|---|---|
| 核心能力 | 精确检索 | 多跳推理 | 类型覆盖 |
| 数据规模 | 500 问题 | 1,986 QA 对 | 75,336 QA 对 |
| Session 规模 | 53 个共用 | 19-32 个/对话 | 短上下文 |
| 推理深度 | 浅(定位) | 深(推理) | 中(分类) |
| 竞品对比 | 充分 | 有限 | 有限 |
| 数据来源 | 学术设计 | 人工模拟 | 学术设计 |
| 可复现性 | 公开数据集 | 公开数据集 | 公开数据集 |
LongMemEval 是标尺——所有人都在用,分数有直接可比性,是证明系统基本能力的入场券。
LoCoMo 是试金石——它测试 LongMemEval 测不到的推理能力,也是最容易暴露系统弱点的 benchmark。MemPalace 在 LoCoMo 上的基线只有 60.3%,这个分数不是标题材料,但它诚实地反映了纯语义检索在多跳推理任务上的局限。
ConvoMem 是广角镜——它不深入任何一种能力,但覆盖面最广,确保系统不是只在某一种题型上特化。
三者的交集覆盖了一个完整的评估空间:如果一个系统在 LongMemEval 上检索精准、在 LoCoMo 上推理能力达标、在 ConvoMem 上各类型表现均衡,那么你有合理的信心认为它在真实场景中也能工作。如果一个系统只在其中一个 benchmark 上得分高,你应当保持怀疑。
三者都测不到的东西
三角测量覆盖了很多维度,但有些关键能力完全不在测试范围内:
真实的时间跨度。 三个 benchmark 都是静态数据集。它们模拟的是"已有的对话历史",不是"持续六个月逐渐积累的记忆"。在真实使用中,记忆系统面对的数据是渐进式增长的——每天新增几个 session,索引持续膨胀,检索质量是否会随时间退化?这个问题无法用静态 benchmark 回答。
写入正确性。 三个 benchmark 都假设数据已经被正确地存储了。它们不测 mining 阶段——分割、去重、分类、元数据提取。如果 MemPalace 的 convo_miner 把两个 session 错误地合并了,或者把同一段对话归到了错误的 wing,benchmark 不会捕捉到这个错误。
端到端回答质量。 Recall@K 衡量的是"正确的 session 是否在 top-K 中",不是"系统能否用检索到的内容正确回答问题"。一个检索完美但回答生成失败的系统,在这三个 benchmark 上仍然会拿满分。完整的端到端评估需要引入 LLM 来生成答案并计算 F1 score——这需要 API key,也意味着测的不再只是记忆系统,还包括了 LLM 本身的能力。
多模态内容。 三个 benchmark 都是纯文本。真实对话中包含的代码片段、错误堆栈、截图描述、链接——这些内容的检索特性与自然语言不同,但不在任何 benchmark 的测试范围内。
Runner 代码结构:如何复现
所有 benchmark 的 runner 代码都在 benchmarks/ 目录下,每个 benchmark 一个 Python 文件。设计原则是:clone、install、run——三步完成复现,不需要修改任何配置。
目录结构
benchmarks/
longmemeval_bench.py -- LongMemEval runner,所有模式
locomo_bench.py -- LoCoMo runner
convomem_bench.py -- ConvoMem runner
membench_bench.py -- MemBench runner(额外)
BENCHMARKS.md -- 完整结果和方法论记录
HYBRID_MODE.md -- 混合检索模式的技术细节
README.md -- 快速复现指南
results_*.jsonl -- 每次运行的原始结果
longmemeval_bench.py 的核心流程
这是最重要的一个 runner,因为 LongMemEval 是竞品对比的主要战场。它的核心循环是这样的:
对于 500 个问题中的每一个:
-
加载海底:把该问题对应的 53 个 session 全部加载到一个 fresh 的 ChromaDB collection 中。使用
EphemeralClient——内存模式,没有磁盘 IO,没有 SQLite 句柄泄漏。每个问题之间清空并重建 collection。 -
执行检索:用问题文本查询 collection。根据
--mode参数选择检索策略——raw(纯语义)、hybrid(关键词增强)、hybrid_v2(加时间增强)、palace(宫殿结构导航)、diary(主题摘要增强)。 -
评估排名:把返回的 document 列表与 ground-truth 的正确 session ID 对比。计算 Recall@5、Recall@10、NDCG@10。
-
记录详情:每个问题的检索结果——包括返回的每一个 document、对应的距离分数、是否命中——全部写入 JSONL 文件。这意味着你不仅可以复现总分,还可以审查每一个单独的问题。
关键的设计决策是使用 chromadb.EphemeralClient() 的全局单例。早期版本使用 PersistentClient 加临时目录,到第 388 个问题左右会因为 SQLite 句柄积累而挂起。切换到内存模式解决了这个问题,同时带来了约 2 倍的速度提升——在 Apple Silicon 上完成全部 500 个问题大约需要 5 分钟。
locomo_bench.py 的核心流程
LoCoMo 的结构略有不同,因为它的数据组织方式是"10 个独立对话,每个对话有自己的 QA 对":
对于 10 个对话中的每一个:
- 加载对话:把该对话的所有 session(19-32 个)加载到 ChromaDB。
- 逐个提问:用该对话的 QA 对逐一查询。
- 评估:检查检索到的 session 是否包含 ground-truth 的证据对话。
- 按类型统计:五种题型分别计算 recall。
一个值得注意的技术细节:LoCoMo 的 ground-truth 标注在 dialog 级别(单轮对话),但 MemPalace 的索引粒度是 session 级别(一个 session 包含多轮对话)。runner 通过 --granularity 参数控制评估粒度。session 粒度的成绩(60.3%)高于 dialog 粒度(48.0%),因为 session 是一个更粗的容器——命中包含证据的 session 比命中包含证据的具体那一轮对话更容易。
convomem_bench.py 的核心流程
ConvoMem 的特殊之处在于它的数据分布在 HuggingFace 上的多个文件中,runner 需要先下载再测试:
- 发现文件:通过 HuggingFace API 列出每个类别的可用数据文件。
- 下载并缓存:首次运行时从 HuggingFace 下载,缓存到本地避免重复下载。
- 抽样:通过
--limit参数控制每个类别采样多少个测试项。默认 50。 - 测试:对每个测试项,加载对话历史到 ChromaDB,用问题查询,检查检索结果是否包含证据消息。
快速复现
# 安装
git clone https://github.com/aya-thekeeper/mempal.git
cd mempal && pip install chromadb pyyaml
# LongMemEval(约 5 分钟)
mkdir -p /tmp/longmemeval-data
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_cleaned.json
# LoCoMo(约 2 分钟)
git clone https://github.com/snap-research/locomo.git /tmp/locomo
python benchmarks/locomo_bench.py /tmp/locomo/data/locomo10.json --granularity session
# ConvoMem(约 2 分钟)
python benchmarks/convomem_bench.py --category all --limit 50
raw 基线路径不需要 API key,不需要 GPU;在 benchmark 数据和默认 embedding 资产准备完成后,可以离线复跑。需要单独补清楚的是:如果启用 diary 或 LLM rerank,则还需要网络和 API key。不需要任何复杂配置文件。
结果的可审计性
每次运行都会生成一个 JSONL 或 JSON 结果文件,包含:
- 每个问题的完整文本
- 每个检索到的 document 及其距离分数
- 每个问题是否命中的判定
- 按题型的分类统计
这意味着当有人质疑某个分数时,你可以打开结果文件,找到那个具体的问题,看到检索返回的每一个 document,逐一验证评估逻辑。这不是一个黑盒——每一层都是透明的。
指标的含义
Recall@K 和 NDCG@K 是信息检索领域的标准指标,但对非专业读者来说,它们的直觉含义需要解释。
Recall@K:在返回的前 K 个结果中,有多少比例的正确答案被找到了?R@5 = 96.6% 意味着:对于 500 个问题中的 483 个,正确的 session 出现在了前 5 个检索结果中。剩下 17 个问题,正确的 session 不在前 5 名。
NDCG@K(Normalized Discounted Cumulative Gain):不仅考虑正确答案是否在 top-K 中,还考虑它的排名位置。正确答案排在第 1 位比排在第 5 位得分更高。NDCG@10 = 0.889 意味着:正确答案不仅经常出现在前 10 名,而且倾向于出现在靠前的位置。
在实际使用中,R@5 是更重要的指标。因为当你的 AI 助手调用 mempalace_search 时,它通常只看前 5 个结果。如果正确答案在第 6 名,AI 看不到它——等同于没找到。
方法论的承诺
本章描述的三个 benchmark、runner 代码、数据源、评估指标,构成了一个完整的可复现评估框架。任何人——无论是想验证 MemPalace 的声明、想在自己的系统上运行同样的测试、还是想理解这些分数到底意味着什么——都可以从这里出发。
但分数本身只是故事的一半。下一章我们会把 MemPalace 的分数放在竞争格局中,和 Supermemory、Mastra、Mem0、Zep 等系统做正面对比。我们会展示赢在哪里、输在哪里,以及为什么有些"输"比表面看起来更有意义。
第23章:竞品对比的诚实分析
定位:本章把 MemPalace 放在竞争格局中,逐系统、逐维度地对比。赢的地方实事求是地展示,输的地方同样实事求是地分析原因。不使用营销语言,不贬低竞品,不隐藏弱项。
先看成绩单
以下是 LongMemEval R@5 的直接对比,所有数据来自各系统的公开报告或可复现的 benchmark 运行:
| 系统 | LongMemEval R@5 | API 依赖 | 成本 |
|---|---|---|---|
| MemPalace (hybrid v4 + rerank) | 100% | 可选(Haiku) | Free + ~$0.001/查询 |
| Supermemory ASMR | ~99% | Yes | 未公开 |
| MemPalace (raw) | 96.6% | 无 | Free |
| Mastra | 94.87% | Yes (GPT-5-mini) | API 成本 |
| Mem0 | ~85% | Yes | $19-249/月 |
| Zep | ~85% | Yes | $25/月起 |
这张表是真实的。但如果你只看这张表就得出"MemPalace 碾压一切"的结论,你漏掉了很多重要的上下文。
四个维度的对比
一维的分数排名是危险的。它隐藏了系统之间根本性的架构差异,把不同设计哲学的产品强行放在同一个标尺上。更诚实的对比需要至少四个维度。
维度一:准确率
LongMemEval 是最标准的比较战场,上面的表已经展示了。但只看 LongMemEval 远远不够。
ConvoMem(75K+ QA 对)的对比:
| 系统 | ConvoMem 得分 | 说明 |
|---|---|---|
| MemPalace | 92.9% | 逐字存储 + 语义搜索 |
| Gemini(长上下文) | 70-82% | 把全部历史放进上下文窗口 |
| Block extraction | 57-71% | LLM 处理后的块提取 |
| Mem0 (RAG) | 30-45% | LLM 提取的记忆 |
MemPalace 在 ConvoMem 上超过 Mem0 两倍以上。这不是一个边际优势——是两倍。原因值得深入分析:Mem0 用 LLM 来决定"什么值得记住",然后只保存提取出来的事实。当 LLM 提取了错误的东西,或者遗漏了关键细节时,那部分记忆就永久丢失了。MemPalace 的逐字存储不做任何过滤——不判断什么重要、什么不重要——所以不存在"错误提取"这个失败模式。
但现在来看 MemPalace 表现不好的地方。
LoCoMo(1986 个多跳 QA 对):60.3% 的坦诚分析。
MemPalace 在 LoCoMo 上的基线成绩是 60.3% R@10(session 粒度,无 rerank)。这个分数不好。它意味着在四成的多跳推理问题中,MemPalace 连正确的 session 都没有排到前十名。
为什么?
LoCoMo 测试的是一种 MemPalace 的基础架构不擅长的能力:跨 session 信息串联。考虑一个典型的 LoCoMo 问题:"Caroline 在哪个领域找到了工作?"答案需要把 session 5(她提到对海洋生物学感兴趣)和 session 12(她说拿到了一个研究岗的 offer)串联起来。但 MemPalace 的语义搜索是逐 session 独立评分的——它不知道 session 5 和 session 12 有因果关系。问题里的关键词"领域"和"工作"分别与两个不同的 session 有微弱的语义关联,但不足以把任何一个排到 top-10。
更具体地拆解各类别的表现:
| 类别 | R@10(基线) | 说明 |
|---|---|---|
| temporal | 69.2% | 最好——时间关系是最直接的检索信号 |
| adversarial | 61.9% | 说话者混淆严重 |
| single-hop | 59.0% | 即使单跳也只有六成——搜索空间不够精确 |
| open-domain | 58.1% | 开放性问题的词汇匹配更难 |
| temporal-inference | 46.0% | 最差——需要推理的时间问题几乎是随机水平 |
temporal-inference 的 46.0% 接近随机猜测的表现。这是 MemPalace 最诚实的弱点:当答案需要跨越多个时间节点进行推理时,纯粹的向量检索基本不工作。
不过需要指出的是,LoCoMo 的竞品对比数据有限。Mem0、Zep、Supermemory 都没有公开报告 LoCoMo 成绩。已知的参照点是 Memori 系统的 81.95%(R@10),MemPalace 的 hybrid v5 模式(88.9% R@10)超过了它。但基线的 60.3% 确实不占优。
还有一个结构性问题需要透明公开:LoCoMo 的每个对话只有 19-32 个 session,当使用 top-k=50 进行检索时,候选池已经包含了所有 session——此时 Sonnet rerank 本质上是在做阅读理解,不是检索。因此,使用 top-k=50 + Sonnet rerank 得到的 100% 分数有结构性保证,不应与 top-k=10 的诚实检索分数混为一谈。诚实的 LoCoMo 分数是 top-10 下的成绩。
维度二:成本
这是 MemPalace 的核心优势之一,也是最容易被量化的维度。
| 系统 | 月度成本 | 年度成本 | 成本构成 |
|---|---|---|---|
| MemPalace (raw) | $0 | $0 | 无 API 调用 |
| MemPalace (hybrid + rerank) | ~$0.30 | ~$3.60 | ~300 次查询 x $0.001/次 |
| Mastra | 变动 | 变动 | GPT-5-mini API 成本 |
| Mem0 | $19-249 | $228-2,988 | 订阅制 |
| Zep | $25+ | $300+ | 订阅制 |
| Letta (MemGPT) | $20-200 | $240-2,400 | 订阅制 |
MemPalace 的 raw 模式成本是零。字面意义上的零。没有 API 调用,没有云服务,没有订阅费。ChromaDB 运行在本地,当前基线使用的是 ChromaDB 默认的本地 embedding 路径。更谨慎地说,源码并没有把默认 embedding 资产直接 vendoring 进仓库,因此更准确的口径仍然是:初始资产准备完成后,日常 raw 查询成本为零。
即使加上可选的 Haiku rerank,每次查询的成本大约是 $0.001——一千次查询一美元。假设一个活跃用户每天做 10 次记忆搜索,一个月 300 次查询的成本是 $0.30。
这个成本差异不是百分比级别的。Mem0 的入门价($19/月)是 MemPalace raw 模式成本的无穷大倍——因为分母是零。即使与 MemPalace 的 hybrid 模式相比,Mem0 的年度最低成本($228)仍然是 MemPalace 的 63 倍。
但公平地说,Mem0 和 Zep 的定价包含了 MemPalace 不提供的东西:托管基础设施、管理界面、团队协作功能、SLA 保障。对于企业用户来说,$25/月的 Zep 可能比"免费但自己运维"的 MemPalace 实际上更便宜——因为运维时间本身有成本。
维度三:隐私
| 系统 | 数据位置 | API 通信 | 隐私模型 |
|---|---|---|---|
| MemPalace (raw) | 本地为主(初始资产就绪后) | 无 | 日常 raw 运行时数据不离开你的机器 |
| MemPalace (hybrid) | 主要本地 | 仅 rerank 时发送 session 片段 | 可选的最小数据外发 |
| Mem0 | 云端 | 全程 API | 厂商持有数据 |
| Zep | 云端 | 全程 API | SOC 2, HIPAA 合规 |
| Supermemory | 云端 | 全程 API | 厂商持有数据 |
| Mastra | 取决于部署 | GPT API | OpenAI 持有查询数据 |
MemPalace 的 raw 模式是市场上少数真正接近"零数据外发"的主流 AI 记忆系统。不是"我们加密了数据",不是"我们符合 SOC 2",而是在日常 raw 运行阶段没有必要把查询和记忆发送到第三方 API。ChromaDB 跑在本地,搜索在本地,知识图谱也在本地。更谨慎地说,书里其他章节已经说明默认嵌入资产存在一次准备过程;但在那之后,你的对话记录——包含技术决策、内部讨论、代码片段、甚至个人偏好——都可以持续停留在你的磁盘上。
hybrid 模式引入了一个隐私权衡:当启用 LLM rerank 时,top-K 候选 session 的前 500 个字符会被发送到 Anthropic 的 API 用于重排序。这意味着每次查询有少量对话内容会离开你的机器。但这是可选的、可控的:你可以选择不使用 rerank 而接受 96.6% 的成绩,或者使用 rerank 追求更高的准确率。
Zep 值得特别提及:它是这个领域中隐私合规做得最认真的商业产品。SOC 2 认证和 HIPAA 合规意味着它经过了第三方审计,数据处理流程有法律约束力。对于医疗、金融等受监管行业的用户,Zep 的合规性可能比 MemPalace 这种"本地为主、长期离线可用"的方案更实用——因为后者意味着相当一部分合规责任由用户自己承担。
维度四:API 依赖
| 系统 | 无 API 可用 | 需要哪些 API | 离线运行 |
|---|---|---|---|
| MemPalace (raw) | 完全可用 | 无 | 冷启动准备后可离线 |
| MemPalace (hybrid) | 96.6% 可用,100% 需 API | Anthropic(可选) | 部分离线 |
| Mastra | 不可用 | OpenAI (GPT-5-mini) | 不支持 |
| Mem0 | 不可用 | 自有 API + LLM API | 不支持 |
| Zep | 不可用 | 自有 API + Graph DB | 不支持 |
| Supermemory | 不可用 | 自有 LLM API | 不支持 |
这一维度的对比非常清晰:MemPalace 是唯一一个在完全没有 API key 的情况下仍然能提供竞争力成绩的系统。96.6% 的 R@5——零 API 调用——已经超过了 Mastra(94.87%,需要 GPT-5-mini)、Mem0(~85%,需要付费订阅)、Zep(~85%,需要付费订阅)。
这不是一个微不足道的属性。API 依赖意味着:
- 可用性风险:当 API 提供商宕机时,你的记忆系统完全不工作。2024-2025 年间,主要 LLM API 的累计宕机时间足以让这成为一个真实问题。
- 成本不可控:API 定价由提供商单方面决定。你的记忆系统的运行成本取决于一个你无法控制的变量。
- 地理限制:某些地区无法访问某些 API 提供商。一个依赖 OpenAI API 的记忆系统在某些网络环境下无法使用。
- 数据主权:API 调用意味着数据出境。对于某些组织和地区的合规要求,这是一个硬约束。
逐个竞品分析
Supermemory ASMR(~99% R@5)
Supermemory 是距离 MemPalace 最近的竞品。它的 ASMR(Agentic Search with Memory Retrieval)架构在 LongMemEval 上报告了约 99% 的成绩——但这是实验版本的数字,其生产版本约在 85%。
Supermemory 做对了什么: ASMR 使用 LLM 运行多轮搜索——第一轮检索结果不满意时,LLM 会重新构造查询再搜一次。这种 agentic 方法在语义模糊的查询上特别有效:当第一次搜索没命中时,LLM 能理解失败的原因并调整策略。
MemPalace 的对比优势: 没有 API 依赖。Supermemory 的每次搜索可能触发多次 LLM 调用,成本和延迟都更高。MemPalace 的 raw 模式零成本、亚秒延迟——这在高频搜索场景下差距明显。
公平的判断: 如果你不在乎成本和延迟,Supermemory 的 agentic 方法可能在某些复杂查询上比 MemPalace 更灵活。但如果你在乎隐私和离线能力,Supermemory 不是一个选项。
Mastra(94.87% R@5)
Mastra 使用 GPT-5-mini 作为"观察者"——LLM 在对话进行时实时提取观察结果,然后存储这些观察而不是原始对话。
Mastra 做对了什么: 提取阶段的 LLM 能理解对话的结构,把隐含的信息显性化。如果用户说"上次那个 Postgres 的问题让我头疼了一整天",Mastra 的 LLM 能提取出"用户在 Postgres 上遇到过困难"这个显性事实。
Mastra 的问题: 一旦提取完成,原始对话就被丢弃了。如果 GPT-5-mini 在提取时遗漏了某个细节——比如它把"头疼了一整天"理解为情感表达而没有记录为时间投入——那个信息就永久丢失了。MemPalace 保留原始文本,不存在这个失败模式。
分数差距的含义: MemPalace raw(96.6%)比 Mastra(94.87%)高 1.7 个百分点。这个差距在统计上是显著的——在 500 个问题上意味着 9 个问题的差异——但不是压倒性的。考虑到 Mastra 需要 API 成本而 MemPalace 不需要,这 1.7pp 的优势变得更有分量。
Mem0(~85% R@5)
Mem0 是这个领域知名度最高的产品之一——品牌认知度远超 MemPalace。它使用 LLM 提取"核心记忆"——把对话蒸馏成简短的事实片段。
Mem0 做对了什么: 它的用户体验很好。集成简单,管理界面直观,记忆的可视化做得比任何竞品都好。对于不想自己运维的团队,Mem0 的托管服务省去了所有基础设施的操心。
Mem0 的根本问题: 在 ConvoMem benchmark 上,Mem0 只拿到了 30-45%——不到 MemPalace 92.9% 的一半。原因是系统性的,不是偶然的:LLM 提取记忆的方式不可避免地丢失信息。当 LLM 把一个 45 分钟的架构讨论压缩成"用户偏好 Postgres"时,它丢失了为什么偏好 Postgres、在什么场景下、对比了哪些替代方案、权衡了哪些因素。后续当问题涉及这些被丢弃的上下文时,系统找不到答案。
公平的承认: Mem0 的 $19-249/月定价包含了 MemPalace 没有的商业支持、SLA、团队协作功能。对于一个需要"开箱即用、有人负责"的企业团队来说,Mem0 的总拥有成本可能比 MemPalace 更低——因为 MemPalace 的"免费"不包含运维人力成本。
Zep(~85% R@5)
Zep 使用图数据库(类似 Neo4j 的方案)来存储实体关系。它的 Graphiti 系统建立了时间感知的知识图谱——实体之间的关系有生效和失效时间。
Zep 做对了什么: 知识图谱方法在实体关系查询上有天然优势。"Kai 现在在做什么项目?"这种查询,图数据库可以直接遍历边来回答,不需要在文档集合中搜索。时间有效性的设计也很优雅——当事实发生变化时,旧的关系被标记为失效而不是删除。
MemPalace 的对比: MemPalace 的知识图谱(knowledge_graph.py)提供了类似的能力——时间有效性、实体查询、timeline——但底层用的是 SQLite 而不是 Neo4j。这意味着零额外依赖、零运维成本,但也意味着在大规模图遍历上可能比专业图数据库慢。
公平的判断: 在 LongMemEval 上的 ~85% 成绩可能不能完全代表 Zep 的能力。Zep 的设计目标不仅是检索——它的图谱能力、实体关系管理、企业合规性(SOC 2、HIPAA)都是 MemPalace 没有正式提供的。如果你的需求是"构建一个合规的企业级记忆系统",Zep 的 ~85% 检索成绩可能是一个可以接受的权衡。
Hindsight(91.4% R@5)
Hindsight 是一个较新的系统,由 Virginia Tech 验证,使用 Gemini-3 和时间感知的向量检索。
Hindsight 做对了什么: 它的时间感知方法与 MemPalace 的 hybrid v2 中的时间增强相似——在向量相似度的基础上加入时间接近度作为信号。这个方向是正确的,因为很多记忆查询本质上是时间锚定的。
分数定位: 91.4% 介于 Mem0/Zep(~85%)和 Mastra(94.87%)之间。它需要 LLM API(Gemini-3),但性能还没有达到不需要 API 的 MemPalace raw(96.6%)。
MemPalace 赢在哪里
从四维对比中可以清晰地提炼出 MemPalace 的竞争优势:
在准确率维度上,MemPalace 的 raw 模式(96.6%)在无 API 条件下已经超过了所有需要 API 的竞品中除 Supermemory ASMR 实验版之外的全部。hybrid v4 + rerank 的 100% 是目前已发布的 LongMemEval 最高分。
在成本维度上,MemPalace 是唯一的零成本选项。所有其他系统至少需要 API 调用成本或订阅费。
在隐私维度上,MemPalace 的 raw 模式是唯一真正的零数据外发方案。
在 API 依赖维度上,MemPalace 是唯一在无 API 条件下仍然具有竞争力的系统。
这些优势有一个共同的技术根源:MemPalace 选择了"保留一切、用结构来组织"而不是"用 AI 来提取和压缩"。这个设计决策的直接后果是不需要 LLM 参与索引过程,因此不需要 API、不需要成本、不需要数据外发。
MemPalace 输在哪里
同样诚实地,MemPalace 在以下场景中是弱于竞品的:
多跳推理。 LoCoMo 的 60.3% 基线成绩显示,当答案需要跨 session 串联时,纯语义检索不够用。使用 LLM 进行记忆提取的系统(Mem0、Mastra)可以在提取阶段就建立跨 session 的关联——"用户在 session 5 提到对海洋生物学的兴趣,在 session 12 找到了相关工作"可以被提取为一条连贯的记忆。MemPalace 把这两个 session 分别存储,搜索时只能独立评分。
hybrid v5 模式把 LoCoMo 成绩提升到了 88.9%(R@10),主要通过关键词增强和人名提取。Wings v3 的说话者归属设计把 adversarial 类别从 34.0% 推到了 92.8%。但 temporal-inference 类别——需要真正的时间推理——始终是最弱的环节。
企业功能。 MemPalace 没有管理控制台、没有团队协作、没有审计日志、没有 SLA。Zep 和 Mem0 作为商业产品,在这些维度上远远领先。对于需要"IT 部门能管理"的企业客户,MemPalace 目前不是一个可行的选项。
集成生态。 Mem0 和 Zep 有丰富的 SDK(Python、JavaScript、Go)、与主流框架的集成、详细的 API 文档。MemPalace 的集成方式主要是 MCP(Model Context Protocol)和 CLI——对于已经在用 Claude 的开发者来说很方便,但对于其他生态的用户来说门槛更高。
嘈杂数据的处理。 MemBench benchmark 中的 noisy 类别——故意在问题中混入干扰信息——MemPalace 只拿到了 43.4%。这暴露了逐字存储方法的一个结构性弱点:当噪声在 embedding 层面与信号不可区分时,检索质量会严重退化。使用 LLM 提取的系统可以在提取阶段过滤噪声,但 MemPalace 把一切都保留了——包括噪声。
100% 背后的诚实
MemPalace 在 LongMemEval 上拿到了 100%——500/500,所有六种题型全部满分。这是事实。但这个事实需要一些上下文。
从 96.6% 到 99.4% 的提升路径(hybrid v1 到 v3)是基于分类别的失败模式分析——每次改进都针对一类问题,而不是特定的某个问题。这些改进是可泛化的。
但从 99.4% 到 100% 的最后 3 个问题,是通过检查这三个特定问题的失败原因来修复的:
- 一个问题因为包含带引号的短语
'sexual compulsions'而需要精确短语匹配 - 一个问题因为涉及特定人名
Rachel而需要专名提权 - 一个问题因为涉及高中回忆而需要怀旧模式的偏好提取
这三个修复是针对测试集的调优(teaching to the test)。它们可能泛化到类似的查询模式,也可能只在这三个特定问题上有效。在严格的学术评审中,这是一个需要标注的方法论问题。
团队对此的处理方式是:建立了一个 50/450 的 dev/held-out 拆分。450 个从未用于调优的 held-out 问题上,hybrid v4 的成绩是 98.4% R@5、99.8% R@10。这才是诚实的可发布数字。
三个数字讲了三个不同的故事:
- 96.6% — 零 API、零调优、零人工干预的基线能力。最保守也最可靠的声明。
- 98.4% — held-out 集上的诚实成绩,包含了可泛化的改进但排除了测试集调优。
- 100% — 在完整测试集上的满分,包含了三个针对特定问题的修复。辉煌但需要加注释。
60.3% 意味着什么
如果 100% 是需要上下文的好消息,60.3% 就是需要分析的坏消息。
LoCoMo 的 60.3% R@10 基线意味着 MemPalace 在多跳推理任务上的表现只是"及格"。在五个类别中,temporal-inference 只有 46.0%——接近随机水平。
但"及格"不等于"失败"。这里有三层分析。
第一层:这个分数是无 API 条件下的。 所有使用 LLM 辅助的系统在 LoCoMo 上的表现都更好,因为多跳推理本质上需要理解——不只是检索。MemPalace 的 60.3% 是用一个纯检索系统去做推理任务的结果。在同一条件下(无 LLM),MemPalace 的 hybrid v5 已经达到 88.9%,超过了 Memori 的 81.95%。
第二层:优化空间已被验证。 Wings v3 的说话者归属设计把 adversarial 从 34.0% 提升到 92.8%——这证明了结构化改进可以大幅提升 LoCoMo 成绩。bge-large embedding 模型(替换默认的 all-MiniLM)把 single-hop 从 59.0% 提升了 10.6pp。Haiku rerank 把 bge-large 的成绩从 92.4% 进一步推到 96.3%。这些改进的方向是明确的。
第三层:LoCoMo 的结构限制。 每个对话只有 19-32 个 session,当 top-k=50 时所有 session 都在候选池中,使得 rerank 变成阅读理解。这意味着 LoCoMo 的 100% rerank 分数和 LongMemEval 的 100% rerank 分数不能用同一个标准来评价。前者有结构性保证,后者是真正的检索成就。
设计哲学的根本分歧
所有这些对比的背后,是两种截然不同的设计哲学。
路线 A:"让 AI 决定什么值得记住"。 这是 Mem0、Mastra、Supermemory 的路线。LLM 阅读对话,提取关键信息,丢弃其余部分。优点是存储紧凑、检索空间小。缺点是不可逆地丢失了原始上下文——一旦提取错了,没有回头路。
路线 B:"保留一切,用结构来组织"。 这是 MemPalace 的路线。不做任何信息过滤,逐字保存原始对话。用 palace 结构(wing、hall、room、closet、drawer)来组织,用 semantic search 来检索。优点是零信息损失、零 API 依赖。缺点是搜索空间更大、多跳推理更难。
LongMemEval 的结果表明:路线 B 的检索精度不低于路线 A,甚至更高。96.6% vs 85-95% 不是一个侥幸的结果——它反映了一个基本事实:当你保留了全部原始文本时,答案总在那里等你找到。而当你让 LLM 提取记忆时,答案可能已经被"提取"掉了。
LoCoMo 的结果表明:路线 B 在推理任务上确实弱于路线 A 的潜力。逐字存储保留了信息,但没有建立信息之间的关联。这是一个开放的工程问题——palace 结构中的 tunnel(跨 wing 连接)和知识图谱的时间有效性正在试图解决它。
最终,这不是一个"哪个路线更好"的问题,而是一个"你在优化什么"的问题。如果你的首要约束是隐私和成本,路线 B 是唯一的选择。如果你的首要约束是推理深度和企业合规,路线 A 的商业产品可能更适合。MemPalace 选择了路线 B,然后在这条路线上走到了当前已知的最远处。
不宣称的东西
最后,有些东西是 MemPalace 明确不宣称的:
不宣称"最好的 AI 记忆系统"。 这取决于你用什么标准来定义"最好"。在 LongMemEval 上,是的。在 LoCoMo 的基线上,不是。在企业功能上,远远不是。
不宣称竞品不好。 Mem0 在用户体验上做得比 MemPalace 好。Zep 在合规性上做得比 MemPalace 好。Supermemory 的 agentic 搜索在某些场景下比 MemPalace 更灵活。每个系统在自己的设计约束下都做出了合理的工程选择。
不宣称 100% 是无条件的。 100% 有上下文。98.4% 的 held-out 分数是更诚实的数字。96.6% 的无 API 基线是最保守的声明。三个数字都是真的,但它们回答不同的问题。
不宣称免费等于零成本。 MemPalace 的软件是免费的,但运行它需要你自己的机器、自己的时间、自己的运维能力。对于有 IT 团队的企业,$25/月的 Zep 可能比"免费但自己管"的 MemPalace 实际成本更低。
验证一个系统的最诚实方式,不是只展示你赢的地方,而是同时展示你输的地方,并解释为什么。这一章试图做到这一点。下一部分将从验证转向未来——MemPalace 的路线图、已知的待解决问题、以及这个项目的开放方向。
第24章:本地优先不是妥协
定位:MemPalace 选择本地优先架构不是因为预算有限,也不是因为技术能力不足以构建云服务。本地优先是一个经过深思熟虑的架构约束,它源于对记忆数据本质的认识、创始人的价值观根基,以及对开源作为基础设施的信念。
最私密的数据
你的密码被泄露了。这很糟糕,但你可以改密码。你的信用卡号被盗了。这也很糟糕,但你可以挂失补办。你的身份证号被暴露了。这非常糟糕,但至少你的身份证号不会告诉窃取者你是怎么思考的。
现在考虑一个不同的场景:你六个月的 AI 对话记录被泄露了。
这些对话里有什么?
有你在凌晨三点和 AI 讨论是否应该裁掉表现不佳的团队成员时的犹豫不决。有你分析竞品产品时暴露的商业判断逻辑。有你调试一个安全漏洞时暴露的系统架构细节。有你在技术选型中排除某个方案时说的"这个框架的社区太小了,维护者看起来快要放弃了"——这句话如果被公开,可能得罪一整个开源社区。有你和 AI 讨论薪资谈判策略时暴露的底线。有你在做架构决策时说的"我其实不太懂这个领域,但我不能让团队知道"。
这些不是数据。这些是你的思维过程。
传统的数据泄露影响的是你的身份和资产。AI 对话记录的泄露影响的是你的判断力、决策模式和认知弱点的暴露。密码可以改,信用卡可以补,但你无法"更换"你的思维方式。一旦有人知道你是怎么做决策的——你在什么条件下会妥协、你对什么问题缺乏信心、你在压力下倾向于哪种选择——这些信息就永久地可以被利用。
这就是为什么 AI 记忆数据与其他所有类型的个人数据本质不同。
考虑一个团队使用 MemPalace 的场景。团队中五个人每天与 AI 进行深度技术对话。六个月后,MemPalace 中积累的记忆不仅包含技术决策,还包含团队动态的完整画像:谁提出的方案经常被采纳,谁的意见经常被否决,谁和谁之间存在技术分歧,哪些决策是在妥协中做出的。这是一个组织的认知 X 光片。
把这样的数据交给第三方服务器托管,相当于把组织的认知 X 光片放在别人的保险箱里——即使对方承诺不会打开看。问题不在于对方是否可信,而在于这种信任关系本身就不应该存在。
一个不需要存在的信任问题
现有的 AI 记忆产品——Mem0、Zep、Letta——采用的是标准的 SaaS 模式:你的记忆数据上传到他们的服务器,他们提供存储、检索和管理。他们通过 SOC 2 合规、HIPAA 认证和加密传输来保证安全性。
这些安全措施是真实的,也是有价值的。但它们解决的是错误的问题。
SOC 2 认证告诉你这家公司有规范的安全流程。它不能保证不会发生数据泄露——历史上无数通过 SOC 2 审计的公司都经历过数据泄露。HIPAA 合规告诉你这家公司知道如何处理敏感健康数据。它不能保证你的数据在公司被收购、破产或被传唤时仍然安全。端到端加密告诉你数据在传输过程中是安全的。它不能保证数据在服务器端解密处理时不会被泄露——而服务器端必须解密才能进行语义搜索。
更根本的问题是:为什么这个信任关系需要存在?
MemPalace 的回答是:它不需要。
当所有数据都在你的机器上——ChromaDB 在你的硬盘上、SQLite 在你的文件系统里、AAAK 压缩后的文本在你的本地目录中——不存在需要信任的第三方。不需要 SOC 2,因为没有第三方服务器。不需要 HIPAA,因为数据从未离开你的设备。不需要加密传输,因为没有传输。
这不是一个技术优劣的比较。云端方案在技术上完全可行——它们的检索精度、存储效率和用户体验都可以做得很好。这是一个关于信任架构的选择:是通过合规认证来管理信任风险,还是通过消除信任需求来消除信任风险。
MemPalace 选择了后者。
价值观根基
这个选择不是偶然的。要理解它为什么是 MemPalace 的默认姿态而非一个可选配置,需要回到 Ben Sigman 的职业轨迹。
Ben 在创建 MemPalace 之前,花了相当长的时间在去中心化金融领域。Bitcoin Libre 做的事情是去中心化借贷——一个用户可以在不信任任何中心化机构的前提下进行借贷的市场。这不是一个技术实验,这是一个运行中的产品,建立在一个明确的价值主张上:金融交易不应该依赖对中间人的信任。
这个价值主张背后的推理链是这样的:当你把资金放在银行里,你信任银行不会倒闭、不会冻结你的账户、不会被政府强制要求交出你的资产记录。大多数时候这种信任是合理的。但"大多数时候合理"和"永远合理"之间的差距,就是去中心化金融存在的理由。去中心化借贷不是说银行不可信——而是说在一个可以不需要信任的系统中,为什么要引入信任这个变量?
把这个推理链平移到记忆系统领域:当你把 AI 记忆数据放在 SaaS 服务器上,你信任服务商不会泄露你的数据、不会在被收购后改变隐私政策、不会在破产后让你的记忆随服务器一起消失。大多数时候这种信任是合理的。但在一个可以不需要信任的系统中,为什么要引入信任这个变量?
对于一个在去中心化借贷市场花了数年时间的人来说,这个推理是自然的、几乎是自动的。本地优先不是一个技术偏好,不是一个关于延迟或带宽的工程权衡——它是一个关于信任架构的哲学立场。你的钱不应该依赖对中间人的信任。你的记忆更不应该。
这就是为什么 MemPalace 的本地优先不是一个功能特性("我们也支持本地部署!"),而是一个架构约束("数据永远不会离开你的机器")。功能特性可以被关闭;架构约束不可以。
不依赖任何服务的代价
本地优先是有代价的。承认这一点比假装它是免费午餐更诚实。
代价一:没有跨设备同步。 你的 MemPalace 在你的笔记本电脑上。如果你想在台式机上也访问同样的记忆,你需要自己解决同步问题——通过 Git、rsync、共享文件系统,或者任何你信任的文件同步方案。SaaS 产品天然解决了这个问题,因为数据在云端,任何设备都能访问。
代价二:没有协作功能。 五个人的团队想共享同一个 MemPalace,需要自己搭建共享基础设施。SaaS 产品天然支持多用户协作,因为数据在共享服务器上。
代价三:没有托管运维。 ChromaDB 崩了,你自己修。SQLite 文件损坏了,你自己恢复备份。SaaS 产品有运维团队 7x24 监控,你不需要操心。
代价四:没有增量改进的推送。 SaaS 产品可以在后台持续优化检索算法、压缩策略、索引结构,用户无感升级。本地应用的升级需要用户主动更新。
这些是真实的代价。MemPalace 不假装它们不存在。它的立场是:这些代价是值得承担的,因为替代方案的代价更高——替代方案的代价是引入一个你无法完全控制的第三方来托管你最私密的数据。
但更深一层来看,这些代价中的大部分是可工程化解决的。跨设备同步可以通过加密的 P2P 同步实现(比如 Syncthing),不需要中心化服务器。团队协作可以通过共享文件系统或 Git 仓库实现。数据备份可以用标准的文件备份工具。这些方案比 SaaS 的开箱即用体验粗糙一些,但它们保持了本地优先的核心约束:数据始终在你控制的设备上。
还有一个经常被忽视的事实:对于个人开发者和小团队——MemPalace 当前最主要的用户群体——跨设备同步和多人协作并不是核心需求。一个人在一台机器上使用 MemPalace,是最常见也最自然的使用模式。在这个模式下,本地优先的代价接近于零,而收益——数据完全在自己手里——是最大化的。
MIT 协议的意义
MemPalace 的开源不是一个营销策略。它是本地优先架构的逻辑必然延伸。
考虑一个假设:MemPalace 是本地优先的,但它是闭源的。你的数据在你的机器上,但处理数据的代码是一个黑盒。你无法审计它是否在后台向某个服务器发送了遥测数据。你无法确认 AAAK 压缩过程中是否有信息泄露。你无法验证 ChromaDB 的查询过程是否触发了网络请求。
这样的系统是本地优先的吗?从数据存储的角度看,是的。从信任的角度看,不是——因为你仍然需要信任一个你无法审计的代码库。
MIT 协议解决了这个问题。任何人可以阅读 MemPalace 的每一行代码,验证它确实不做任何网络调用(除非用户显式启用 Haiku 重排序),确认数据确实只存在于本地。代码可审计意味着"本地优先"这个承诺是可验证的,而不仅仅是一个需要信任的声明。
MIT 协议还解决了另一个更长远的问题:存续性。
SaaS 产品有一个固有的存续性风险:公司倒闭,服务关停,你的数据——即使公司承诺在关停前给你导出的时间——也会面临迁移成本和格式不兼容的问题。更关键的是,当一个 AI 记忆 SaaS 关停时,你丢失的不仅是数据,还有处理数据的逻辑——你的记忆如何被组织、如何被检索、压缩算法如何工作——这些知识随公司一起消失了。
MemPalace 不存在这个风险。代码在你手里,数据在你手里。即使 MemPalace 的 GitHub 仓库明天消失,你 fork 的副本仍然是一个完整的、可运行的系统。MIT 协议确保任何人都有权 fork、修改和分发。你的记忆的存续性不依赖于任何公司、任何团队、任何个人的持续运营。
这不是一个理论上的优势。在 AI 记忆市场的早期阶段——2025-2026 年——产品的生存率是不确定的。已经有 AI 记忆创业公司在这个阶段关停了。使用闭源 SaaS 记忆产品的用户每一次都面临"我的记忆跟着公司一起走了"的风险。使用 MemPalace 的用户永远不会面临这个风险。
社区 fork 的权利不仅是法律上的保障,更是一个技术生态的构建基础。当核心项目的方向不再符合某些用户的需求时,他们可以 fork 出自己的版本。这不是分裂——这是开源软件的正常进化方式。Linux 有无数发行版,每个发行版服务不同的用户群体。MemPalace 的 MIT 协议赋予了同样的可能性。
零依赖的极端约束
MemPalace 的技术栈值得注意的一点是它的依赖列表:
Python 3.9+
chromadb>=0.4.0
pyyaml>=6.0
日常 raw 使用不需要 API key。没有必须依赖的云端服务。对已经完成本地依赖和默认嵌入器准备的环境,日常运行不需要互联网连接。
这个极端的零依赖约束不是偶然的。每一个外部依赖都是一个潜在的信任点和故障点。需要 API key 意味着你的数据(至少是查询内容)会离开你的机器。需要云端服务意味着你的系统可用性依赖于别人的服务器。需要互联网连接意味着在飞机上、在网络受限的环境中、在离线开发时,你的记忆系统不可用。
MemPalace 选择了一个更激进的立场:在本地依赖和默认嵌入器已经就绪之后,拔掉网线,日常存储、搜索和 wake-up 仍然可以照常工作。
ChromaDB 是一个嵌入式向量数据库,运行在进程内,数据存储在本地文件系统上。它不需要单独的数据库服务器,不需要网络连接,不需要配置。SQLite——知识图谱的存储后端——更是嵌入式数据库的典范,连单独的进程都不需要。AAAK 压缩完全在本地完成,不依赖任何外部模型或服务。
需要补一句实现层面的限定:当前仓库依赖的是 chromadb>=0.4.0,并没有把默认嵌入模型资产直接 vendoring 进仓库。书里另一章已经说明,这个默认嵌入器在首次使用时会完成一次准备过程;因此,更准确的口径应当是"冷启动之后可长期离线运行",而不是把"第一次准备环境"和"之后的本地运行"混成一句话。
这个约束的工程含义是深远的。它意味着 MemPalace 的核心 raw 路径——存储、搜索和 wake-up——不能建立在外部 API 之上。需要联网的能力可以存在,但必须是外围、可选、可替换的。当前公开仓库里已经有这类例子:benchmark 的 Haiku/Sonnet rerank 属于可选增强,entity registry 里的 Wikipedia lookup 属于辅助研究路径。它们都不是日常 raw 记忆循环的必要前提。
MemPalace 的选择是:先用本地 embedding 路径达到 96.6% 的精度,而不是把基础能力建立在云端模型上。96.6% 在零 API 调用的条件下已经是有史以来的最高分。这个分数不是妥协的结果——它是在严格约束下取得的成就。
Haiku 重排序是一个有意思的设计点。它是 MemPalace 中最显眼的可选联网增强——使用 Claude Haiku 对本地检索结果进行重排序,可以把完整 benchmark 上的成绩从 96.6% 推到 100%。同一份 benchmark 文档也同时给出 hybrid_v4 在 held-out 450 上的 98.4%,提醒读者不要把 "500/500" 误读成唯一的泛化数字。但关键词仍然是"可选"。不启用它,系统完全正常工作。启用它,你获得的是锦上添花而非雪中送炭。这个设计精确地表达了 MemPalace 对网络依赖的态度:可以有,但不能必须有。
三个场景
让我们用三个具体场景来说明本地优先在实践中的含义。
场景一:安全审计。 一家金融科技公司的安全团队需要审计所有处理客户数据的系统。对于 SaaS 记忆产品,审计意味着审查第三方的安全认证、数据处理协议、子处理器列表和数据驻留政策。对于 MemPalace,审计重点会转成:阅读源代码,确认日常 raw 的存储、搜索和 wake-up 路径都在本地完成,并把像 benchmark rerank、Wikipedia lookup 这样的可选联网代码点单独标出来。一个下午的代码审查,往往就能把核心数据路径说清楚。
场景二:公司倒闭。 使用某 AI 记忆 SaaS 产品的团队收到通知:服务将在 60 天后关停。团队需要在 60 天内导出所有数据、找到替代方案、迁移并验证数据完整性。这是一个高压的、有时间限制的工程任务,而且通常发生在最不方便的时候。使用 MemPalace 的团队永远不会面临这个场景。数据就在本地,代码就在 GitHub(或你的 fork)上。没有什么需要"迁移"的。
场景三:离线环境。 一个开发者在长途飞行中需要回顾三个月前关于数据库分片策略的讨论。使用云端记忆产品,这不可能——没有网络就没有记忆。使用 MemPalace,mempalace search "sharding strategy" 在本地即时返回结果。你的记忆不依赖于你是否在线。
这三个场景不是极端案例。安全审计是受监管行业的日常操作。公司关停在创业生态中是统计必然。离线工作是移动办公时代的常态。本地优先在这些场景中不是一个"nice to have",而是一个决定性的优势。
不是反对云,是反对强制信任
需要明确的一点是:本章的论点不是"云服务不好"。云服务在很多场景下是正确的选择——当你需要多人实时协作、当你需要全球分布式访问、当你需要免运维的基础设施。
本章的论点是:对于 AI 记忆这个特定的数据类型,本地优先是更合理的默认值。
原因回到本章开头的论点:AI 记忆数据是最私密的数据类型之一。它包含的不是你的身份信息或财务信息——它包含的是你的思维过程。对于这样的数据,"数据在你手里"不是一个可选的安全加固措施,而应该是默认的架构姿态。
MemPalace 用一个简洁的技术栈实现了这个姿态:Python + ChromaDB + SQLite + AAAK。没有必须依赖的服务器,没有强制 API 路径,没有订阅,也没有你无法审计的代码。你的记忆在你的机器上,处理记忆的代码在你的 GitHub fork 里,压缩记忆的 AAAK 方言是公开的规范。
这不是一个技术限制。这是一个设计决策。一个从去中心化借贷市场的价值观中自然生长出来的设计决策。
深层:基础设施的哲学
在最深层,本地优先反映的是对"基础设施应该由谁控制"这个问题的一种回答。
互联网的早期——1990 年代到 2000 年代初——有一种自然的去中心化倾向。你的电子邮件可以运行在自己的服务器上。你的网站可以托管在自己的机器上。你的数据默认在你的硬盘里。这不是意识形态驱动的,这只是当时技术的自然状态。
2010 年代的云计算浪潮改变了这个默认值。基础设施从本地迁移到云端——先是计算,然后是存储,然后是数据库,最后是几乎所有东西。这个迁移有真实的工程好处:弹性扩展、免运维、全球可达。但它也改变了一个根本性的权力关系:你的数据不再在你手里。
对于大多数类型的数据——代码(GitHub)、文档(Google Docs)、通信(Slack)——这个权力关系的改变是可接受的。这些数据的敏感性有限,迁移成本可控,而云服务带来的便利性足以弥补控制权的让渡。
但 AI 记忆是一个不同的数据类型。它的敏感性极高(你的思维过程),它的迁移成本极大(记忆系统不仅是数据,还有组织结构和检索逻辑),它的依赖性极深(你的 AI 助手的效用直接取决于记忆的可用性)。对于这样的数据,让渡控制权的代价可能超过云服务带来的所有便利。
MemPalace 的本地优先架构,加上 MIT 开源协议,加上零外部依赖的技术栈,共同构成了一个完整的控制权保障体系:
- 数据在你的机器上(物理控制权)。
- 代码是开源的(审计权)。
- 许可证允许 fork 和修改(修改权和分发权)。
- 不依赖外部服务(运行权不受第三方约束)。
这四层保障不是独立的——它们彼此依赖,缺一不可。数据在本地但代码闭源,你无法审计。代码开源但需要 API key,你的运行权受约束。代码开源、数据本地、但许可证不允许 fork,你的长期存续性没有保障。
MemPalace 同时满足了这四个条件。这不是一组偶然的选择,而是一个从"用户应该完全控制自己的记忆基础设施"这个原则出发推导出来的完整架构。
本地优先不是妥协。本地优先是结论。
第25章:超越对话
定位:MemPalace 的当前验证集中在对话记忆上,但它的架构——Wing/Hall/Room/Closet/Drawer 的层级结构、AAAK 压缩方言、时态知识图谱——并不依赖于"对话"这个特定的数据类型。本章分析这个架构在其他领域的适配可能性,以及 AAAK 进入 Closet 层的技术路线。
一个比对话更大的结构
MemPalace 的 README 中有一句容易被忽略的话:
"It has been tested on conversations -- but it can be adapted for different types of datastores."
这不是一句随意的展望。它是对架构本质的陈述。
回顾 MemPalace 的核心结构:Wing 是一个领域边界,Room 是一个概念节点,Hall 是一个分类维度,Closet 是压缩摘要的概念层,Drawer 是原始内容。这五层结构中,没有任何一层在定义上依赖于"对话"这个数据形态。Wing 不关心它里面装的是对话记录还是代码文件——它只关心"这些东西属于同一个领域"。Room 不关心它代表的是一次讨论的主题还是一个代码模块——它只关心"这是一个独立的概念单元"。
但要补一句实现口径上的实话:在当前公开源码里,显式落地最完整的是 drawer 存储、wing/room 元数据、searcher 查询和外围的 graph/kg/diary 能力;Closet 更多还是 README 与 benchmark 叙事中的中间层概念,而不是产品运行时里的独立 collection。
这意味着 MemPalace 的空间结构是数据类型无关的。宫殿的检索效力来自结构本身——语义分区降低搜索空间、层级过滤提升命中精度——而不是来自被存储内容的特定格式。第四章分析过的那个 34% 检索精度提升,来自 Wing 和 Room 的结构化过滤,与被过滤内容是对话还是代码无关。
当然,"理论上可以"和"工程上可行"之间有距离。让我们具体分析几个方向。
代码库:Wing 是项目,Room 是模块
一个中型软件团队管理着五个微服务、两个前端应用和一个共享库。六个月后,没有人记得为什么 payment-service 的重试逻辑用的是指数退避而不是固定间隔,也没有人记得 shared-lib 中那个看起来多余的抽象层是为了解决什么具体问题。
代码注释和 commit message 理论上应该记录这些信息。实际上,大多数 commit message 是 "fix bug" 或 "refactor auth module",而代码注释要么不存在、要么过时。真正的设计推理散落在 AI 对话、Slack 讨论和已经关闭的 PR 评论中。
将 MemPalace 适配到代码库场景中,映射关系是自然的:
Wing = 项目(payment-service, user-frontend, shared-lib)
Room = 模块或关注点(retry-logic, auth-middleware, database-schema)
Hall = 知识类型(hall_facts: 设计决策, hall_events: 重构历史,
hall_discoveries: 性能发现, hall_advice: 最佳实践)
Closet = 模块的压缩摘要(设计意图、关键约束、已知限制)
Drawer = 原始内容(相关的对话记录、PR 描述、设计文档片段)
这个映射中最有价值的部分是 Tunnel——跨 Wing 的概念连接。当 payment-service 和 user-frontend 都有一个名为 auth-middleware 的 Room 时,Tunnel 自动将它们关联起来。这意味着当你搜索认证相关的设计决策时,你能同时看到后端和前端的视角——即使它们是在不同时间、不同对话中讨论的。
MemPalace 已有的三种挖掘模式中,projects 模式(mempalace mine <dir>)已经支持对代码和文档文件的摄入。更准确地说,当前实现里的 wing 来自 mempalace.yaml 或 --wing 覆盖,room 路由则优先看路径命中,其次看文件名,最后再用内容关键词做启发式判断。它已经足以支持"把项目文件摄入同一个宫殿并按 room 检索"这一级能力,但还不是一句"按目录结构映射到 Wing 和 Room"就能概括的机制。在此基础上,更深度的适配——比如根据代码的 import 关系自动生成 Room 之间的 Hall 连接,或者从 Git 历史中提取时间维度的变更信息——是可工程化实现的扩展。
文档库:Wing 是知识域,Room 是主题
企业级文档管理面临的核心问题不是存储——存储从来不是问题。问题是检索。当一个组织有数千页的产品文档、技术规范、会议纪要和研究报告时,"找到那份关于 GDPR 合规的数据保留策略的文档"变成了一个非平凡的检索任务。
现有的文档管理系统——Confluence、Notion、SharePoint——用文件夹层级和标签来组织文档。这些组织方式的局限在第四章中已经分析过:它们是管理员视角的分类,不是检索者视角的导航结构。
MemPalace 的宫殿结构提供了一个不同的组织方式:
Wing = 知识域(compliance, product-design, engineering-standards)
Room = 具体主题(gdpr-data-retention, oauth-implementation, api-versioning)
Hall = 文档类型(hall_facts: 规范和标准, hall_events: 会议决议,
hall_advice: 实施指南)
这个结构的关键优势在于:搜索时,你不需要知道文档的标题或标签——你只需要描述你要找的信息,系统可以通过 Wing 和 Room 的语义过滤缩小搜索空间。对文档场景的适配如果做深,"我们的数据保留策略对欧盟用户有什么特殊要求"这样的自然语言查询,就可以先被组织到类似 wing_compliance / gdpr-data-retention 的局部搜索空间,再在其中进行语义检索。需要和当前实现区分的是:公开源码里的 searcher.py 目前只支持显式的 wing / room 过滤,还没有把自然语言 query 自动分类到 hall_facts 或某个 room 的运行时链路。
邮件与通信:Wing 是联系人,Room 是项目
另一个自然的适配方向是邮件和通信记录。当前的 MemPalace 已经支持 Slack 导出的摄入。将这个能力扩展到邮件,映射关系是清晰的:
Wing = 联系人或团队(wing_client_acme, wing_vendor_stripe, wing_team_infra)
Room = 项目或话题(contract-renewal, api-integration, incident-2026-03)
Tunnel 在这个场景中尤其有价值。当客户 Acme 的合同续签讨论(wing_client_acme / contract-renewal)与内部基础设施团队的容量规划讨论(wing_team_infra / capacity-planning)涉及同一个主题时——比如"明年的 SLA 承诺需要增加多少计算资源"——Tunnel 自动建立连接。你在回顾客户谈判历史时,能自动发现内部团队的相关讨论,反之亦然。
笔记系统:Wing 是领域,Room 是概念
个人知识管理工具——Obsidian、Logseq、Roam Research——的核心理念是双向链接:笔记之间的关联和笔记本身一样重要。MemPalace 的 Tunnel 机制在本质上就是双向链接——同一个 Room 名称出现在不同 Wing 中时,自动创建连接。
Wing = 知识领域(distributed-systems, machine-learning, product-management)
Room = 概念(consensus-algorithms, gradient-descent, user-retention)
一个有趣的可能性是:MemPalace 的宫殿结构可以作为现有笔记工具的检索加速层。你继续在 Obsidian 中写笔记,但 MemPalace 在后台将笔记内容摄入宫殿结构,提供跨笔记的语义检索和自动关联发现。笔记工具擅长的是创作和浏览;MemPalace 擅长的是检索和关联。两者的结合可能比单独使用任何一个都更强。
AAAK 进入 Closet 层
以上所有扩展方向都可以在 MemPalace 的当前架构上实现——它们本质上是改变摄入管道和映射规则,核心的存储和检索机制不需要改变。但有一个更深层的技术演进方向,它将显著改变系统的性能特征:AAAK 方言进入 Closet 层。
要理解这个演进的含义,需要先把"概念层"和"当前运行时"区分开。
更准确地说,当前公开源码里还没有一个显式的 Closet 存储层。mempalace mine 目前直接把切分后的原文块写入 mempalace_drawers;元数据主要是 wing、room、source_file 等字段。searcher.py 的默认路径也是直接查询这个 drawers collection,再把原文返回给调用方。换句话说,当前运行时里真正落地的是 Drawer;Closet 更多是 README 和部分 benchmark 叙事里那种"用于快速导航的中间表示"。
因此,README 所说的 "add AAAK directly to the closets" 并不是在描述一个已经存在、只差换编码的现成子系统,而是在描述下一阶段:把这个中间层从概念/实验表达,推进为显式的压缩导航层。
MemPalace 的 README 中明确提到了这个演进方向:
"In our next update, we'll add AAAK directly to the closets, which will be a real game changer -- the amount of info in the closets will be much bigger, but it will take up far less space and far less reading time for your agent."
让我们从 dialect.py 的当前能力来分析这个方向的可行性。
Dialect 类的 compress() 方法接受纯文本输入,输出 AAAK 格式。它做了以下几件事:
第一,实体检测和编码。_detect_entities_in_text() 扫描文本中的已知实体(通过预配置的实体映射)和疑似实体(通过大写词启发式规则),将 "Kai recommended Clerk" 中的 "Kai" 编码为 "KAI"。
第二,主题提取。_extract_topics() 通过词频分析和启发式加权(大写词、含连字符/下划线的技术术语加分)提取关键主题词,将长段描述压缩为 auth_migration_clerk 这样的主题标签。
第三,关键语句提取。_extract_key_sentence() 对每个句子评分——包含决策词("decided"、"because"、"instead")的句子得分更高,较短的句子优先——提取出最具信息量的片段。
第四,情感和标志检测。_detect_emotions() 和 _detect_flags() 通过关键词匹配检测文本的情感倾向和重要性标记(DECISION、ORIGIN、TECHNICAL 等)。
一段 500 词的对话摘要,经过 compress() 处理后,可能被压缩为两到三行 AAAK 格式:
wing_kai|auth-migration|2026-01|session_042
0:KAI+PRI|auth_migration_clerk|"Chose Clerk over Auth0 pricing+dx"|determ+convict|DECISION+TECHNICAL
大约 30 个 token。原始摘要可能是 300 个 token。压缩比约 10 倍。
当这个压缩应用到未来的显式 Closet 层时,效果是双重的。
效果一:同样的存储空间可以容纳更多信息。 如果一个 Closet 之前能存储 10 条摘要(3000 token),AAAK 化后可以存储 100 条(同样 3000 token)。这意味着 AI 在读取一个 Closet 时能获得十倍于之前的上下文覆盖。
效果二:AI 的读取速度更快。 AAAK 被设计为 AI 可即时理解的格式——它在 mempalace_status 的响应中教会 AI AAAK 语法,AI 在之后的交互中直接解析 AAAK。读取 30 个 token 的 AAAK 摘要比读取 300 个 token 的英文摘要快得多,而信息量是等价的。在需要扫描大量 Closet 来定位信息的场景中,这个速度差异是决定性的。
从 dialect.py 的当前实现来看,这个演进在技术上是可行的。compress() 方法已经能够处理任意纯文本输入,不依赖于特定的数据结构。一旦摄入管道开始生成显式的 closet 文本,把这一步接上 dialect.compress() 进行 AAAK 编码,仍然属于增量工程变更,不需要重构核心架构。
需要注意的一个技术考量是:AAAK 压缩后的文本在语义嵌入空间中的行为可能与原始英文不同。ChromaDB 使用的嵌入模型(如 all-MiniLM-L6-v2)是在英文文本上训练的,AAAK 格式的文本——如 KAI+PRI|auth_migration_clerk——可能产生与英文等价描述不同的嵌入向量。这意味着 Closet 层 AAAK 化后,搜索查询(通常是英文自然语言)与 Closet 内容(AAAK 格式)之间的语义匹配可能需要调整。
一种可能的解决方案是双存储:Closet 同时保留 AAAK 版本(用于 AI 读取)和原始英文版本(用于嵌入检索)。这会增加一些存储开销,但保持了检索精度。另一种方案是在搜索时将查询也转换为 AAAK 格式,使查询和内容在同一个表示空间中匹配——但这需要验证嵌入模型在 AAAK 文本上的行为。
无论采用哪种方案,AAAK 进入 Closet 层的方向是明确的,可行性是有基础的。更准确的说法是:它不是从零发明一种全新的能力,而是把已有的 AAAK 编码能力应用到一个尚未在产品运行时中显式物化的中间层。
开源社区的探索空间
MemPalace 以 MIT 协议开源,这意味着上述所有扩展方向都不需要等待官方团队来实现。社区中任何有兴趣的开发者都可以 fork 项目,实现自己的摄入管道适配。
几个具体的探索空间值得指出:
摄入管道的多样化。 当前的 convo_miner.py 处理五种对话格式的标准化。同样的管道模式可以扩展到更多数据类型:Git commit 和 PR 评论的摄入、Obsidian vault 的摄入、浏览器书签和高亮标注的摄入。每种数据类型需要一个 normalizer(将原始格式转为标准结构),其余的宫殿逻辑可以复用。
Wing/Room 的自动发现。 当前的 mempalace init 更接近"本地扫描 + 交互式确认":先从目录、文件名和内容里检测候选 room,再让用户接受、编辑或补充。对于大型数据集,进一步的自动发现可能更实际——通过聚类分析自动识别数据中的领域边界(Wing)和概念节点(Room)。这在文档库和邮件库等数据量大的场景中尤为有价值。
知识图谱的跨源融合。 当不同类型的数据被摄入同一个宫殿后,知识图谱(knowledge_graph.py)提供了一个本地的三元组存储层,用来承载跨源实体关系。你在邮件中提到的客户名称、在代码注释中出现的同一名称、在会议纪要中讨论的同一客户,理论上都可以被抽成时态三元组并写入同一个图中。需要补清楚的是:当前公开源码已经实现的是 add_triple / query_entity 这一层能力,而不是一个会自动从多种数据源中抽取并融合事实的现成管道。
Specialist Agent 的领域扩展。 README 曾用 reviewer、architect、ops 举过软件开发场景的 specialist 例子,但当前公开源码真正实现的是更底层的通用机制:任意 agent_name 都可以拥有自己的 wing_<agent>/diary。正因为底座足够简单,同样的存储结构也可以扩展到其他领域:sales agent 追踪客户关系演变,research agent 追踪论文阅读和研究方向,legal agent 追踪合规要求的变化。
不做路线图承诺
本章有意识地使用了"可能"、"可以"、"方向"这样的措辞,而不是"将会"、"计划"、"预计"。原因很简单:MemPalace 是一个活跃发展中的开源项目,它的未来方向取决于社区的需求、贡献者的兴趣和实际的工程验证。画一条漂亮的产品路线图很容易,兑现它很难。
更诚实的做法是说:MemPalace 的架构——Wing/Hall/Room 的空间结构、AAAK 的压缩能力、时态知识图谱——在设计上是通用的。它们被验证的领域是对话记忆,验证结果是 96.6%(零 API)和 100%(Haiku 重排序)。它们能否在代码库、文档、邮件、笔记等领域达到同样的效果,需要实际的工程尝试和基准测试验证。
这也是开源的价值所在。一个闭源产品说"我们将支持代码库记忆",你只能等。一个开源项目说"架构支持代码库记忆",你可以自己验证。fork 代码、写一个代码摄入管道、跑一个基准测试——整个验证过程对任何人开放。
宫殿的边界
MemPalace 的核心洞见是:结构比算法更重要。 在检索这个问题上,一个好的空间组织结构带来的精度提升(34%),超过了大多数纯算法优化能达到的增益。
这个洞见不限于对话。它适用于任何需要在大量信息中快速定位特定知识的场景。代码库中的设计决策检索、文档库中的策略查找、邮件中的历史讨论回溯、笔记中的概念关联发现——所有这些场景都面临同一个核心问题:搜索空间太大,纯语义匹配的区分度不够。
MemPalace 的宫殿结构——通过引入领域边界(Wing)、分类维度(Hall)和概念节点(Room)——为这个问题提供了一个与数据类型无关的解决方案。它不依赖于更大的模型、更好的嵌入、更多的计算——它依赖于更好的组织。
这是一个简单但深刻的工程判断:与其让 AI 在 22,000 条无结构的记录中搜索,不如先给记录建一个宫殿,让 AI 知道该去哪个房间找。
对话只是 MemPalace 验证这个判断的第一个领域。不会是最后一个。
附录 A:E2E Trace — 从 mempalace init 到第一次搜索
本附录追踪一个完整的用户旅程:从
pip install mempalace到第一次搜索返回结果。每一步都标注源码位置,每一条数据流都可以在代码中验证。 涉及章节:第 5 章(Wing-Hall-Room 结构)、第 16-18 章(归一化-实体检测-分块管道)、第 14-15 章(记忆层与混合检索)。
场景
用户 Alex 有一个项目目录 ~/projects/my_app,里面包含前端代码(components/)、后端代码(api/)、文档(docs/)和若干 Claude Code 对话导出文件。他希望:
- 让 MemPalace 自动识别项目结构,生成房间分类
- 把项目文件和对话记录都摄入记忆宫殿
- 搜索"为什么我们切换到了 GraphQL",找到当时的决策对话
整个流程涉及三个命令:mempalace init、mempalace mine、mempalace search。
序列图
sequenceDiagram
participant U as 用户 (CLI)
participant CLI as cli.py
participant ED as entity_detector.py
participant RD as room_detector_local.py
participant CFG as config.py
participant M as miner.py
participant CM as convo_miner.py
participant S as searcher.py
participant DB as ChromaDB
Note over U,DB: 阶段 1:初始化
U->>CLI: mempalace init ~/projects/my_app
CLI->>ED: scan_for_detection(dir)
ED-->>CLI: 返回 prose 文件列表
CLI->>ED: detect_entities(files)
ED->>ED: extract_candidates() → 提取大写专有名词
ED->>ED: score_entity() → 人物/项目评分
ED->>ED: classify_entity() → 分类 + 置信度
ED-->>CLI: {people, projects, uncertain}
CLI->>ED: confirm_entities(detected)
ED-->>CLI: 确认后的实体列表
CLI->>CLI: 写入 entities.json
CLI->>RD: detect_rooms_local(dir)
RD->>RD: detect_rooms_from_folders()
RD->>RD: save_config() → 写入 mempalace.yaml
RD-->>CLI: 房间配置完成
CLI->>CFG: MempalaceConfig().init()
CFG-->>CLI: 写入 ~/.mempalace/config.json
Note over U,DB: 阶段 2:数据摄入
U->>CLI: mempalace mine ~/projects/my_app
CLI->>M: mine(project_dir, palace_path)
M->>M: load_config() → 读取 mempalace.yaml
M->>M: scan_project() → 收集可读文件
M->>DB: get_collection("mempalace_drawers")
loop 每个文件
M->>M: detect_room() → 路由到房间
M->>M: chunk_text() → 800字符分块
M->>DB: add_drawer() → 写入 ChromaDB
end
U->>CLI: mempalace mine ~/chats/ --mode convos
CLI->>CM: mine_convos(convo_dir, palace_path)
CM->>CM: scan_convos() → 收集对话文件
loop 每个对话文件
CM->>CM: normalize() → 格式归一化
CM->>CM: chunk_exchanges() → 按交换对分块
CM->>CM: detect_convo_room() → 话题分类
CM->>DB: collection.add() → 写入 ChromaDB
end
Note over U,DB: 阶段 3:搜索
U->>CLI: mempalace search "为什么切换到 GraphQL"
CLI->>S: search(query, palace_path)
S->>DB: PersistentClient(path)
S->>DB: col.query(query_texts, n_results)
DB-->>S: {documents, metadatas, distances}
S->>S: 计算 similarity = 1 - distance
S-->>U: 格式化输出结果
阶段 1:初始化(mempalace init)
初始化是整个系统中最关键的一步——它决定了数据的组织方式,而这个组织方式在写入时就已经固化。初始化做两件事:检测实体(谁和什么)、检测房间(如何分类)。
1.1 入口:CLI 解析
用户执行 mempalace init ~/projects/my_app,argparse 将 dir 参数传递给 cmd_init 函数(cli.py:37)。这个函数是整个初始化流程的编排器,它依次调用实体检测和房间检测两个子系统。
1.2 实体检测:Pass 1 — 扫描文件
cmd_init 首先调用 scan_for_detection(args.dir)(cli.py:45)。这个函数定义在 entity_detector.py:813,它的工作是收集适合做实体检测的文件。
关键设计决策:优先扫描散文文件。PROSE_EXTENSIONS(entity_detector.py:400-405)只包含 .txt、.md、.rst、.csv,因为代码文件中的大写标识符(类名、函数名)会产生大量假阳性。只有当散文文件不足 3 个时,才会回退到包含代码文件(entity_detector.py:834)。每次最多扫描 10 个文件(max_files 参数),每个文件只读前 5KB(entity_detector.py:652)——这不是偷懒,而是因为实体如果重要到需要记住,它一定会在文件开头反复出现。
1.3 实体检测:Pass 2 — 提取与评分
detect_entities(files)(entity_detector.py:632)执行三步管道:
提取候选词:extract_candidates()(entity_detector.py:443)用正则 r"\b([A-Z][a-z]{1,19})\b" 找出所有大写开头的单词,过滤掉停用词表(STOPWORDS,约 200 个常见英文词,entity_detector.py:92-396),只保留出现 3 次以上的词。同时用 r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b" 提取多词专有名词(如 "Memory Palace"、"Claude Code")。
信号评分:对每个候选词,score_entity()(entity_detector.py:486)用两组正则模式打分:
- 人物信号(
PERSON_VERB_PATTERNS,entity_detector.py:27-48):{name} said、{name} asked、hey {name}等动作模式。对话标记(DIALOGUE_PATTERNS)权重最高,每次匹配 +3 分。代词邻近性检测会检查名字前后 3 行内是否出现she/he/they等代词。 - 项目信号(
PROJECT_VERB_PATTERNS,entity_detector.py:72-89):building {name}、import {name}、{name}.py等技术模式。版本号标记和代码引用权重最高,每次匹配 +3 分。
分类:classify_entity()(entity_detector.py:562)根据人物/项目得分比例做出判断。一个重要的保护机制:即使人物得分占比超过 70%,如果只有一种信号类型(比如只有代词匹配),也会被降级为"不确定"(entity_detector.py:605-609)。这避免了"Click"这样的词因为频繁出现在 Click said 模式中而被误判为人名。
1.4 实体确认与保存
confirm_entities()(entity_detector.py:717)让用户交互式审核检测结果。如果传入 --yes 标志,则自动接受所有检测到的人物和项目,跳过不确定项(entity_detector.py:739-744)。确认后的实体保存为 entities.json(cli.py:54-56),供后续 miner 使用。
1.5 房间检测
实体检测完成后,cmd_init 调用 detect_rooms_local(project_dir)(cli.py:62)。这个函数定义在 room_detector_local.py:270,执行以下步骤:
文件夹扫描:detect_rooms_from_folders()(room_detector_local.py:97)遍历项目顶层目录和第二层目录,将文件夹名与 FOLDER_ROOM_MAP(room_detector_local.py:20-94)做匹配。这个映射表覆盖了 40+ 种常见文件夹命名:frontend/client/ui/components 都映射到 "frontend" 房间,backend/server/api/routes/models 都映射到 "backend" 房间。
对于我们的场景,components/ 会被匹配到 "frontend",api/ 会被匹配到 "backend",docs/ 会被匹配到 "documentation"。如果某个顶层文件夹不在映射表中但看起来像合法名字(长度 > 2,字母开头),它会被直接用作房间名(room_detector_local.py:128-130)。
回退策略:如果文件夹结构只产生了一个 "general" 房间,系统会调用 detect_rooms_from_files()(room_detector_local.py:168),通过文件名中的关键词来检测房间。
配置保存:save_config()(room_detector_local.py:255)将 wing 名(取自目录名)和房间列表写入项目目录下的 mempalace.yaml。对于 ~/projects/my_app,生成的配置大致为:
wing: my_app
rooms:
- name: frontend
description: Files from components/
- name: backend
description: Files from api/
- name: documentation
description: Files from docs/
- name: general
description: Files that don't fit other rooms
1.6 全局配置
最后,MempalaceConfig().init()(cli.py:63)在 ~/.mempalace/ 下创建全局配置文件 config.json(config.py:126-138),包含宫殿路径(默认 ~/.mempalace/palace)、集合名(mempalace_drawers)、话题翼列表和关键词映射。
初始化产出物清单
| 文件 | 位置 | 作用 |
|---|---|---|
entities.json | ~/projects/my_app/ | 确认的人物和项目列表 |
mempalace.yaml | ~/projects/my_app/ | Wing 名 + 房间定义 |
config.json | ~/.mempalace/ | 全局配置(宫殿路径等) |
阶段 2:数据摄入(mempalace mine)
2.1 项目文件摄入
执行 mempalace mine ~/projects/my_app,cmd_mine(cli.py:66)在默认 projects 模式下调用 mine()(miner.py:315)。
配置加载:load_config()(miner.py:66)读取项目目录下的 mempalace.yaml,获取 wing 名和房间列表。
文件扫描:scan_project()(miner.py:287)递归遍历项目目录,收集 READABLE_EXTENSIONS(miner.py:19-40)定义的 20 种文件类型(.py、.js、.ts、.md 等),跳过 SKIP_DIRS(miner.py:42-54)中的目录(.git、node_modules、__pycache__ 等)。
获取 ChromaDB 集合:get_collection()(miner.py:183)以 PersistentClient 模式连接 ChromaDB,创建或获取名为 mempalace_drawers 的集合。ChromaDB 使用默认的 all-MiniLM-L6-v2 模型自动生成嵌入向量——无需 API 密钥。
逐文件处理:process_file()(miner.py:233)对每个文件执行三步管道:
- 去重检查:
file_already_mined()(miner.py:192)查询 ChromaDB,如果该源文件已经被摄入过,直接跳过。 - 房间路由:
detect_room()(miner.py:89)按优先级匹配:文件夹路径 → 文件名 → 内容关键词 → 回退到 "general"。对于components/Header.tsx,第一优先级(路径包含 "components",匹配 "frontend" 房间)就会命中。 - 分块:
chunk_text()(miner.py:135)将文件内容切成 800 字符的块(CHUNK_SIZE,miner.py:56),相邻块之间有 100 字符重叠(CHUNK_OVERLAP,miner.py:57)。分割点优先选择段落边界(\n\n),其次是行边界(\n),确保不会在句子中间断开。最小块大小为 50 字符(MIN_CHUNK_SIZE,miner.py:58),更短的片段会被丢弃。
写入 ChromaDB:add_drawer()(miner.py:201)为每个块生成唯一 ID(drawer_{wing}_{room}_{md5_hash}),连同六项元数据一起写入:
{
"wing": wing, # 项目名
"room": room, # 房间名
"source_file": source, # 原始文件路径
"chunk_index": index, # 块在文件中的序号
"added_by": agent, # 摄入代理
"filed_at": timestamp, # ISO 8601 时间戳
}
这些元数据在写入时就已确定——搜索时的过滤能力完全取决于此时标注的完整度。
2.2 对话文件摄入
执行 mempalace mine ~/chats/ --mode convos,cmd_mine(cli.py:69)调用 mine_convos()(convo_miner.py:252)。对话摄入和项目摄入共享同一个 ChromaDB 集合,但使用不同的分块策略。
格式归一化:每个文件先经过 normalize()(convo_miner.py:302)处理,将 Claude Code、ChatGPT、Slack 等不同格式统一为 > 用户提问\n AI 回答 的规范格式。
交换对分块:chunk_exchanges()(convo_miner.py:52)检测文件中 > 标记的数量。如果 > 标记超过 3 个,判定为对话格式,调用 _chunk_by_exchange()(convo_miner.py:66)——一个用户提问(> 行)加上紧随的 AI 回答构成一个不可分割的块。如果不是对话格式,回退到 _chunk_by_paragraph()(convo_miner.py:102)按段落分块。
话题房间检测:detect_convo_room()(convo_miner.py:194)对内容的前 3000 字符用 TOPIC_KEYWORDS(convo_miner.py:127-191)做关键词打分。五个话题房间——technical、architecture、planning、decisions、problems——各自有 10-13 个关键词。得分最高的话题成为该对话的房间。对于包含 "switched"、"chose"、"alternative" 的 GraphQL 讨论,"decisions" 房间会拿到最高分。
写入时的额外元数据:对话块比项目块多两个元数据字段——"ingest_mode": "convos" 和 "extract_mode": "exchange"(convo_miner.py:368-369)。这使得搜索时可以区分项目知识和对话记忆。
阶段 3:搜索(mempalace search)
3.1 搜索入口
执行 mempalace search "为什么切换到 GraphQL",cmd_search(cli.py:94)调用 search()(searcher.py:15)。
3.2 连接宫殿
search() 首先用 PersistentClient 连接 ChromaDB 并获取 mempalace_drawers 集合(searcher.py:21-22)。如果宫殿不存在,立即报错并建议用户先执行 init 和 mine(searcher.py:24-26)。
3.3 构建过滤器
如果用户指定了 --wing 或 --room,search() 会构建 ChromaDB 的 where 过滤器(searcher.py:29-35)。当两者同时指定时,使用 $and 组合查询:
where = {"$and": [{"wing": wing}, {"room": room}]}
这是 MemPalace 混合搜索的核心:先用元数据做精确过滤(缩小搜索范围),再在过滤后的子集上做向量相似度搜索。对于"为什么切换到 GraphQL"这个查询,如果用户加上 --wing my_app --room decisions,搜索空间可能从上千个 drawer 缩小到几十个。
3.4 执行查询
col.query()(searcher.py:46)将查询文本向量化(ChromaDB 内部使用 all-MiniLM-L6-v2 模型),然后在集合中找到 n_results 个最近邻向量。返回三个并行数组:documents(原文)、metadatas(元数据)、distances(向量距离)。
3.5 结果格式化
searcher.py:68-83 将结果格式化输出。相似度通过 1 - distance 计算(searcher.py:69),因为 ChromaDB 默认使用余弦距离。输出包括:
[1] my_app / decisions
Source: graphql-migration.md
Match: 0.847
> Why did we switch from REST to GraphQL?
We discussed this on Tuesday. The main reasons were...
每条结果都标注了 wing(来自哪个项目)、room(属于哪个分类)、source(原始文件名)和 match(相似度分数),以及完整的原文——不是摘要,不是释义,而是用户当时写下的原话。
3.6 程序化搜索接口
除了 CLI 输出,search_memories()(searcher.py:87)提供了返回字典的程序化接口,供 MCP 服务器和其他调用方使用。返回格式:
{
"query": "为什么切换到 GraphQL",
"filters": {"wing": "my_app", "room": "decisions"},
"results": [
{
"text": "原始文本...",
"wing": "my_app",
"room": "decisions",
"source_file": "graphql-migration.md",
"similarity": 0.847,
}
],
}
这个追踪揭示了什么
回顾完整的数据流,三个设计原则浮现出来:
结构先于内容。init 阶段在任何数据摄入之前就确定了宫殿的骨架——wing 名、房间列表、实体映射。这不是技术偷懒,而是刻意选择:如果你不知道自己的世界里有谁和有什么,再多的向量嵌入也帮不了你。实体检测的两类信号模式(人物动词 vs 项目动词)和双信号保护机制(entity_detector.py:601),都是为了在数据进入宫殿之前就把分类做对。
元数据在写入时确定。每个 drawer 的 wing、room、source_file 都在 add_drawer() 时刻写死。搜索时的过滤能力完全来自写入时的标注质量。这意味着如果 detect_room() 的路由逻辑有误,错误会被永久保存——但也意味着搜索时不需要任何额外的分类计算,速度极快。miner.py:201-225 的六个元数据字段就是 MemPalace 搜索能力的上限。
相同的宫殿,不同的摄入策略。项目文件和对话文件最终都写入同一个 mempalace_drawers 集合(miner.py:188、convo_miner.py:217),但它们的分块逻辑完全不同:项目文件按 800 字符固定窗口切割(miner.py:56),对话文件按交换对的语义边界切割(convo_miner.py:66)。两种策略为同一种查询服务——当你搜索"为什么切换到 GraphQL"时,来自代码注释和对话记忆的结果会并排出现,由相似度分数统一排序。这就是第 5 章 Wing-Hall-Room 结构的实际效果:结构提供分类,向量提供关联,两者互不干扰。
附录 B:E2E Trace — MCP 工具调用的完整生命周期
本附录追踪一次真实的 AI 与 MemPalace 交互,从用户提问到最终回答,逐帧展示 MCP 协议下的数据流动。 涉及章节:第 19 章(MCP 服务器)、第 11 章(知识图谱)、第 9 章(AAAK)。
场景
用户打开 Claude Code,输入一个看似简单的问题:
"上个月我们为什么决定用 Clerk?"
这个问题暗含三层需求:时间过滤("上个月")、决策追溯("为什么")、实体识别("Clerk")。Claude 不能凭空猜测——它需要去宫殿里查。接下来的几百毫秒内,MCP 协议将驱动四个阶段的工具调用,每一次调用都有精确的输入输出边界。
序列图:完整生命周期
sequenceDiagram
participant U as 用户
participant C as Claude
participant MCP as MCP Server
participant S as Searcher
participant KG as KnowledgeGraph
participant DB as ChromaDB
participant SQL as SQLite
U->>C: "上个月我们为什么决定用 Clerk?"
Note over C: 阶段 1:会话启动
C->>MCP: tools/call: mempalace_status
MCP->>DB: _get_collection()
DB-->>MCP: collection
MCP->>DB: col.get(include=["metadatas"])
DB-->>MCP: all_meta
MCP-->>C: {total_drawers, wings, rooms, protocol, aaak_dialect}
Note over C: 阶段 2:语义搜索
C->>MCP: tools/call: mempalace_search<br/>{query: "决定用 Clerk 的原因", wing: "wing_myproject"}
MCP->>S: search_memories()
S->>DB: col.query(query_texts, where, n_results)
DB-->>S: {documents, metadatas, distances}
S-->>MCP: {query, filters, results: [...]}
MCP-->>C: 搜索结果(含原文 + 相似度)
Note over C: 阶段 3:知识图谱查询
C->>MCP: tools/call: mempalace_kg_query<br/>{entity: "Clerk", direction: "both"}
MCP->>KG: query_entity("Clerk")
KG->>SQL: SELECT ... WHERE subject=? OR object=?
SQL-->>KG: rows
KG-->>MCP: facts[]
MCP-->>C: {entity, facts, count}
Note over C: 阶段 4:写入新发现
C->>MCP: tools/call: mempalace_add_drawer<br/>{wing, room, content}
MCP->>MCP: tool_check_duplicate(content, 0.9)
MCP->>DB: col.query(query_texts, n_results=5)
DB-->>MCP: 无重复
MCP->>DB: col.add(ids, documents, metadatas)
DB-->>MCP: 写入成功
MCP-->>C: {success: true, drawer_id}
C-->>U: 综合回答 + 引用来源
阶段 1:会话启动(mempalace_status)
每次 Claude 与 MemPalace 交互,第一个调用永远是 mempalace_status。这不是可选操作——PALACE_PROTOCOL 的第一条规则就写着:
"ON WAKE-UP: Call mempalace_status to load palace overview + AAAK spec."
(mcp_server.py:94)
请求
MCP 客户端发送一个 JSON-RPC 请求:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "mempalace_status",
"arguments": {}
}
}
这个请求被 handle_request() 路由到 tools/call 分支(mcp_server.py:719),再通过 TOOLS 字典分派到 tool_status() 函数(mcp_server.py:63)。
执行路径
tool_status() 首先调用 _get_collection()(mcp_server.py:41-49)获取 ChromaDB 集合。如果集合不存在——比如用户从未运行过 mempalace init——函数返回 _no_palace()(mcp_server.py:52-57),给出明确的修复指引:
def _no_palace():
return {
"error": "No palace found",
"palace_path": _config.palace_path,
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
}
宫殿存在时,tool_status() 遍历所有元数据,统计 wing 和 room 分布(mcp_server.py:70-78),然后返回一个包含六个字段的字典。
三重载荷
tool_status() 的返回值不只是统计数据。它携带了三重载荷(mcp_server.py:79-86):
return {
"total_drawers": count, # 载荷 1:宫殿概览
"wings": wings,
"rooms": rooms,
"palace_path": _config.palace_path,
"protocol": PALACE_PROTOCOL, # 载荷 2:行为协议
"aaak_dialect": AAAK_SPEC, # 载荷 3:AAAK 规范
}
载荷 1:宫殿概览。 total_drawers、wings、rooms 告诉 AI 这个宫殿有多大、有哪些领域。AI 据此决定后续搜索应该限定在哪个 wing。
载荷 2:行为协议。 PALACE_PROTOCOL(mcp_server.py:93-100)是五条行为规则,其中最关键的是第二条和第三条:
"BEFORE RESPONDING about any person, project, or past event: call mempalace_kg_query or mempalace_search FIRST. Never guess — verify."
"IF UNSURE about a fact: say 'let me check' and query the palace. Wrong is worse than slow."
这两条规则将 AI 从"生成模式"切换到"查证模式"。没有这个协议,AI 会直接编造答案。
载荷 3:AAAK 规范。 AAAK_SPEC(mcp_server.py:102-119)教会 AI 如何读写 AAAK 压缩格式——实体代码(ALC=Alice)、情感标记(*warm*=joy)、结构分隔符(管道符分隔字段)。这意味着后续搜索返回的 AAAK 文本,AI 能直接理解。
这三重载荷的设计意图是:一次调用,AI 就获得了操作宫殿所需的全部上下文。不需要读配置文件,不需要额外的初始化步骤。
阶段 2:搜索(mempalace_search)
收到 status 后,Claude 解析了用户的问题,构造搜索请求。它知道"Clerk"大概率与项目决策相关,于是将搜索限定在 wing_myproject。
请求
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "mempalace_search",
"arguments": {
"query": "决定用 Clerk 的原因",
"wing": "wing_myproject",
"limit": 5
}
}
}
执行路径
tool_search()(mcp_server.py:173-180)是一个薄代理——它把参数直接透传给 search_memories():
def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None):
return search_memories(
query,
palace_path=_config.palace_path,
wing=wing,
room=room,
n_results=limit,
)
真正的搜索逻辑在 searcher.py:87-142 的 search_memories() 函数中。这个函数做三件事:
第一步:构建 where 过滤器(searcher.py:100-107)。当同时指定 wing 和 room 时,使用 ChromaDB 的 $and 复合条件;只指定一个时,直接传单条件。这个分支逻辑看似简单,但它决定了搜索是在整个宫殿中进行,还是限定在某个特定区域:
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
第二步:执行语义查询(searcher.py:109-118)。调用 col.query() 时传入 query_texts(向量化后的查询)、n_results(返回数量)、include(需要返回的字段),以及可选的 where 过滤器。ChromaDB 在内部将查询文本嵌入为向量,与所有 drawer 的向量做余弦距离计算,返回最近的 N 个结果。
第三步:组装返回值(searcher.py:126-142)。每条搜索结果包含五个字段:text(原文)、wing、room、source_file(来源文件名)、similarity(相似度得分,由 1 - distance 计算而来)。
hits.append({
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?")).name,
"similarity": round(1 - dist, 3),
})
返回结果示例
{
"query": "决定用 Clerk 的原因",
"filters": {"wing": "wing_myproject", "room": null},
"results": [
{
"text": "AUTH.DECISION:2026-03|chose.Clerk→Auth0.rejected|*pragmatic*...",
"wing": "wing_myproject",
"room": "decisions",
"source_file": "meeting-2026-03-12.md",
"similarity": 0.847
}
]
}
注意返回的 text 是 AAAK 格式。因为阶段 1 已经把 AAAK 规范加载到了 Claude 的上下文中,Claude 能直接展开 AUTH.DECISION:2026-03|chose.Clerk→Auth0.rejected 为自然语言。
阶段 3:知识图谱查询(mempalace_kg_query)
搜索返回了"选择 Clerk"的原始记录,但 Claude 想知道更多:Clerk 与项目其他组件的关系是什么?它是否替代了之前的方案?这类关系查询正是知识图谱的用武之地。
请求
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "mempalace_kg_query",
"arguments": {
"entity": "Clerk",
"direction": "both"
}
}
}
执行路径
tool_kg_query()(mcp_server.py:309-312)将请求转发给 KnowledgeGraph.query_entity():
def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"):
results = _kg.query_entity(entity, as_of=as_of, direction=direction)
return {"entity": entity, "as_of": as_of, "facts": results, "count": len(results)}
query_entity()(knowledge_graph.py:186-241)是知识图谱的核心查询方法。它接受三个参数:name(实体名)、as_of(时间点过滤)、direction(查询方向)。
实体 ID 规范化。 首先通过 _entity_id()(knowledge_graph.py:92-93)将实体名转为小写下划线格式:"Clerk" 变成 "clerk"。这保证了大小写不敏感的匹配。
双向查询。 当 direction="both" 时,函数执行两条 SQL 查询。第一条查 outgoing 关系(knowledge_graph.py:198-217)——"Clerk 指向什么":
SELECT t.*, e.name as obj_name
FROM triples t JOIN entities e ON t.object = e.id
WHERE t.subject = ?
第二条查 incoming 关系(knowledge_graph.py:219-238)——"什么指向 Clerk":
SELECT t.*, e.name as sub_name
FROM triples t JOIN entities e ON t.subject = e.id
WHERE t.object = ?
时间过滤。 如果传入了 as_of 参数,两条查询都会追加时间窗口条件(knowledge_graph.py:201-203):
AND (t.valid_from IS NULL OR t.valid_from <= ?)
AND (t.valid_to IS NULL OR t.valid_to >= ?)
这意味着只返回在 as_of 那个时间点仍然有效的事实。已经被 invalidate() 标记过期的事实(valid_to 不为 NULL)会被自动排除。
返回结果结构
每条 fact 包含完整的时间戳和有效性标记:
{
"entity": "Clerk",
"as_of": null,
"facts": [
{
"direction": "outgoing",
"subject": "Clerk",
"predicate": "replaces",
"object": "Auth0",
"valid_from": "2026-03-12",
"valid_to": null,
"confidence": 1.0,
"source_closet": "drawer_wing_myproject_decisions_a3f2...",
"current": true
},
{
"direction": "incoming",
"subject": "MyProject",
"predicate": "uses",
"object": "Clerk",
"valid_from": "2026-03-12",
"valid_to": null,
"confidence": 1.0,
"source_closet": null,
"current": true
}
],
"count": 2
}
current: true 表示这个事实至今有效(valid_to 为 NULL)。source_closet 如果存在,指向知识图谱事实的原始出处——ChromaDB 中某个 drawer 的 ID,形成知识图谱到向量存储的反向链接。
阶段 4:记忆写入(mempalace_add_drawer)
Claude 综合了搜索结果和知识图谱查询,给用户做了回答。对话中可能产生了新的信息——比如用户补充说"对,当时 Auth0 的定价涨了 40%,这也是一个原因"。Claude 决定把这条新发现存入宫殿。
请求
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "mempalace_add_drawer",
"arguments": {
"wing": "wing_myproject",
"room": "decisions",
"content": "AUTH.COST:2026-03|Auth0.price↑40%→triggered.Clerk.eval|★★★",
"added_by": "mcp"
}
}
}
执行路径
tool_add_drawer()(mcp_server.py:250-287)的第一件事不是写入,而是查重。
幂等性保护。 函数在第 259 行调用 tool_check_duplicate(content, threshold=0.9)。tool_check_duplicate()(mcp_server.py:183-215)对传入内容执行语义搜索,返回相似度大于等于 0.9 的已有 drawer。如果找到重复,写入直接中止:
dup = tool_check_duplicate(content, threshold=0.9)
if dup.get("is_duplicate"):
return {
"success": False,
"reason": "duplicate",
"matches": dup["matches"],
}
这个设计解决了一个实际问题:AI 在多轮对话中可能反复尝试写入相同内容,或者两个 agent 在同一个会话中独立发现了同一个事实。0.9 的阈值允许措辞上的微小差异,但阻止语义重复。
ID 生成。 通过内容前 100 字符加当前时间戳的 MD5 哈希生成唯一 ID(mcp_server.py:267):
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5(
(content[:100] + datetime.now().isoformat()).encode()
).hexdigest()[:16]}"
ID 格式 drawer_{wing}_{room}_{hash} 是自描述的——仅凭 ID 就能知道这个 drawer 属于哪个 wing 和 room。
写入 ChromaDB。 最终调用 col.add()(mcp_server.py:270-284),写入 document(原文)和 metadata(wing、room、source_file、chunk_index、added_by、filed_at)。ChromaDB 在写入时自动计算文档的嵌入向量,后续搜索就能匹配到这条新记录。
元数据设计
写入的元数据(mcp_server.py:276-283)值得注意:
{
"wing": wing,
"room": room,
"source_file": source_file or "",
"chunk_index": 0,
"added_by": added_by, # "mcp" 表示 AI 写入
"filed_at": datetime.now().isoformat(),
}
added_by 字段区分了记忆的来源:"mcp" 表示 AI 通过 MCP 写入,"mine" 表示由 mempalace mine 命令从文件中提取。filed_at 是写入时间而非事件发生时间——事件时间编码在 AAAK 内容本身中(如 AUTH.COST:2026-03)。
这个追踪揭示了什么
Status 的三重载荷是刻意设计
将行为协议和 AAAK 规范嵌入 tool_status() 的返回值,而非作为独立工具,是一个架构决策。它利用了 AI 的一个行为特性:AI 会阅读工具返回的全部内容。通过把规则"捎带"在状态查询中,确保了 AI 在做任何操作之前就已经知道应该遵循什么协议。如果将协议和规范拆成独立工具,AI 可能会忘记调用它们。
但这也意味着一个前提条件:宫殿必须已经初始化。当 _get_collection() 返回 None 时,tool_status() 返回的是 _no_palace()(mcp_server.py:64-65),不包含 protocol 和 aaak_dialect 字段。换句话说,没有宫殿的 AI 不会获得行为协议——这既是防御(避免协议作用在空宫殿上),也是提示(AI 看到 hint 字段后会引导用户初始化)。
搜索的元数据过滤是分层架构
searcher.py 中的 where 过滤器构建逻辑(searcher.py:100-107)实现了三级搜索粒度:全宫殿搜索(不传 wing/room)、wing 级搜索(只传 wing)、room 级搜索(同时传 wing 和 room)。这对应了记忆宫殿的空间隐喻——你可以在整个宫殿中找东西,也可以只在某个翼楼的某个房间里找。
语义搜索发生在 ChromaDB 的向量空间中,元数据过滤发生在 SQLite 索引中。两者的结合意味着即使用户的查询在语义上匹配了多个 wing 的内容,wing 过滤器也能确保只返回相关领域的结果。
写入的幂等性是对 AI 行为的补偿
AI 的一个已知问题是重复操作——在多轮对话中,它可能忘记自己已经写过某条记忆,于是再次写入。tool_add_drawer() 中的 tool_check_duplicate() 调用(mcp_server.py:259)正是对这个问题的工程补偿。0.9 的相似度阈值是一个经验值:足够高以允许措辞变体("Auth0 涨价了" vs "Auth0 的价格上升了"),又足够严以捕获实质重复。
注意查重和写入使用的是同一个 ChromaDB 集合,查重本身就是一次向量搜索。这意味着查重的成本与一次普通搜索相当——在典型的个人知识库规模(数千到数万条记录)下,这个开销可以忽略。
MCP 协议层的透明性
整个交互链路中,handle_request()(mcp_server.py:691-743)扮演的角色极其简单:解析 JSON-RPC、路由到对应的 handler、把返回值包装为 JSON-RPC 响应。它不做任何业务逻辑。所有工具的 handler 都是普通的 Python 函数,接受基本类型参数,返回字典。这意味着这些函数可以被直接调用(比如在测试中),不依赖 MCP 协议层。
MCP 协议层的透明性也体现在错误处理上:如果 handler 抛出异常,handle_request() 捕获它并返回 JSON-RPC 错误对象(mcp_server.py:735-737),包含错误码 -32000 和异常消息。AI 收到这个错误后,可以决定是重试、换一种查询方式,还是直接告诉用户出了问题。
这四个阶段——启动、搜索、图谱查询、写入——构成了 MemPalace MCP 交互的基本循环。每次对话可能不会走完所有阶段(有时搜索就够了,不需要图谱查询或写入),但阶段 1 的 status 调用是不可跳过的起点。它加载的三重载荷,决定了 AI 在这次会话中的一切行为边界。
附录 C:AAAK 方言完整参考
本附录整合了
mcp_server.py中的AAAK_SPEC常量和dialect.py中的完整编码表, 提供 AAAK 方言的可查阅参考。源码基线:当前仓库主线(pyproject.toml为3.0.0,部分运行时代码仍保留2.0.0版本标记)。
概述
AAAK 是一种面向 AI 智能体的压缩速记格式。它不是给人类读的——它是给 LLM 读的。 任何能读英文的模型(Claude、GPT、Gemini、Llama、Mistral)都能直接理解 AAAK, 无需解码器或微调。
格式结构
行类型
| 前缀 | 含义 | 格式 |
|---|---|---|
0: | 头部行 | FILE_NUM|PRIMARY_ENTITY|DATE|TITLE |
Z + 数字 | Zettel 条目 | ZID:ENTITIES|topic_keywords|"key_quote"|WEIGHT|EMOTIONS|FLAGS |
T: | 隧道(跨条目关联) | T:ZID<->ZID|label |
ARC: | 情感弧线 | ARC:emotion->emotion->emotion |
字段分隔
- 管道
|分隔同一行内的不同字段 - 箭头
→表示因果或转变关系 - 星级
★到★★★★★表示重要性(1-5 级)
实体编码
实体名取前三个字母的大写形式:
| 原名 | 编码 | 规则 |
|---|---|---|
| Alice | ALC | name[:3].upper() |
| Jordan | JOR | |
| Riley | RIL | |
| Max | MAX | |
| Ben | BEN | |
| Priya | PRI | |
| Kai | KAI | |
| Soren | SOR |
源码位置:dialect.py:367-379(encode_entity 方法)
情感编码表
AAAK 使用标准化的短编码表示情感状态。
核心情感编码
| 英文 | 编码 | 含义 |
|---|---|---|
| vulnerability | vul | 脆弱 |
| joy | joy | 喜悦 |
| fear | fear | 恐惧 |
| trust | trust | 信任 |
| grief | grief | 悲伤 |
| wonder | wonder | 惊奇 |
| rage | rage | 愤怒 |
| love | love | 爱 |
| hope | hope | 希望 |
| despair | despair | 绝望 |
| peace | peace | 平静 |
| humor | humor | 幽默 |
| tenderness | tender | 温柔 |
| raw_honesty | raw | 坦诚 |
| self_doubt | doubt | 自我怀疑 |
| relief | relief | 释然 |
| anxiety | anx | 焦虑 |
| exhaustion | exhaust | 疲惫 |
| conviction | convict | 确信 |
| quiet_passion | passion | 沉静的热情 |
| warmth | warmth | 温暖 |
| curiosity | curious | 好奇 |
| gratitude | grat | 感恩 |
| frustration | frust | 挫折感 |
| confusion | confuse | 困惑 |
| satisfaction | satis | 满足 |
| excitement | excite | 兴奋 |
| determination | determ | 决心 |
| surprise | surprise | 惊讶 |
源码位置:dialect.py:47-88(EMOTION_CODES 字典)
MCP 服务器中的简写标记
mcp_server.py 的 AAAK_SPEC 使用 *marker* 格式标注情感语境:
| 标记 | 含义 |
|---|---|
*warm* | 温暖/喜悦 |
*fierce* | 坚定/决心 |
*raw* | 脆弱/坦诚 |
*bloom* | 温柔/绽放 |
情感信号检测
dialect.py 通过关键词匹配自动检测文本中的情感:
| 关键词 | 映射编码 |
|---|---|
| decided | determ |
| prefer | convict |
| worried | anx |
| excited | excite |
| frustrated | frust |
| confused | confuse |
| love | love |
| hate | rage |
| hope | hope |
| fear | fear |
| happy | joy |
| sad | grief |
| surprised | surprise |
| grateful | grat |
| curious | curious |
| anxious | anx |
| relieved | relief |
| concern | anx |
源码位置:dialect.py:91-114(_EMOTION_SIGNALS 字典)
语义标志(Flags)
标志标记事实断言的类型,辅助检索和分类。
| 标志 | 含义 | 触发关键词 |
|---|---|---|
DECISION | 显式决策或选择 | decided, chose, switched, migrated, replaced, instead of, because |
ORIGIN | 起源时刻 | founded, created, started, born, launched, first time |
CORE | 核心信念或身份支柱 | core, fundamental, essential, principle, belief, always, never forget |
PIVOT | 情感转折点 | turning point, changed everything, realized, breakthrough, epiphany |
TECHNICAL | 技术架构或实现细节 | api, database, architecture, deploy, infrastructure, algorithm, framework, server, config |
SENSITIVE | 需要谨慎处理的内容 | (由人工标注) |
GENESIS | 直接导致了现存事物的产生 | (由上下文推断) |
源码位置:dialect.py:117-152(_FLAG_SIGNALS 字典)
宫殿结构标识
| 元素 | 格式 | 示例 |
|---|---|---|
| Wing | wing_ + 名称 | wing_user, wing_code, wing_myproject |
| Hall | hall_ + 类型 | hall_facts, hall_events, hall_discoveries, hall_preferences, hall_advice |
| Room | 连字符 slug | chromadb-setup, gpu-pricing, auth-migration |
完整示例
原始英文(~70 token)
Priya manages the Driftwood team: Kai (backend, 3 years), Soren (frontend),
Maya (infrastructure), and Leo (junior, started last month). They're building
a SaaS analytics platform. Current sprint: auth migration to Clerk.
Kai recommended Clerk over Auth0 based on pricing and DX.
AAAK 编码(~35 token)
TEAM: PRI(lead) | KAI(backend,3yr) SOR(frontend) MAY(infra) LEO(junior,new)
PROJ: DRIFTWOOD(saas.analytics) | SPRINT: auth.migration→clerk
DECISION: KAI.rec:clerk>auth0(pricing+dx) | ★★★★
事实断言验证
| # | 断言 | AAAK 中的对应 | 保留 |
|---|---|---|---|
| 1 | Priya 是团队领导 | PRI(lead) | Yes |
| 2 | Kai 做后端 | KAI(backend,3yr) | Yes |
| 3 | Kai 有 3 年经验 | KAI(backend,3yr) | Yes |
| 4 | Soren 做前端 | SOR(frontend) | Yes |
| 5 | Maya 做基础设施 | MAY(infra) | Yes |
| 6 | Leo 是初级工程师 | LEO(junior,new) | Yes |
| 7 | Leo 上个月入职 | LEO(junior,new) | Yes |
| 8 | 项目叫 Driftwood | DRIFTWOOD | Yes |
| 9 | 是 SaaS 分析平台 | saas.analytics | Yes |
| 10 | 当前 sprint 是 auth 迁移 | SPRINT: auth.migration→clerk | Yes |
| 11 | 迁移目标是 Clerk | →clerk | Yes |
| 12 | Kai 推荐 Clerk | KAI.rec:clerk | Yes |
| 13 | 理由是定价和开发体验 | pricing+dx | Yes |
13/13 事实断言全部保留。压缩比 ~2x(此示例较短且信息密集)。
MCP 服务器中的 AAAK_SPEC
以下是 mcp_server.py:102-119 中通过 mempalace_status 工具传递给 AI 的完整规范:
AAAK is a compressed memory dialect that MemPalace uses for efficient storage.
It is designed to be readable by both humans and LLMs without decoding.
FORMAT:
ENTITIES: 3-letter uppercase codes. ALC=Alice, JOR=Jordan, RIL=Riley, MAX=Max, BEN=Ben.
EMOTIONS: *action markers* before/during text. *warm*=joy, *fierce*=determined,
*raw*=vulnerable, *bloom*=tenderness.
STRUCTURE: Pipe-separated fields. FAM: family | PROJ: projects | ⚠: warnings/reminders.
DATES: ISO format (2026-03-31). COUNTS: Nx = N mentions (e.g., 570x).
IMPORTANCE: ★ to ★★★★★ (1-5 scale).
HALLS: hall_facts, hall_events, hall_discoveries, hall_preferences, hall_advice.
WINGS: wing_user, wing_agent, wing_team, wing_code, wing_myproject,
wing_hardware, wing_ue5, wing_ai_research.
ROOMS: Hyphenated slugs representing named ideas (e.g., chromadb-setup, gpu-pricing).
EXAMPLE:
FAM: ALC→♡JOR | 2D(kids): RIL(18,sports) MAX(11,chess+swimming) | BEN(contributor)
Read AAAK naturally — expand codes mentally, treat *markers* as emotional context.
When WRITING AAAK: use entity codes, mark emotions, keep structure tight.
按当前协议约定,AI 在显式调用 mempalace_status 且 palace 已存在时,会在返回结果中收到这段规范;这不是一个脱离工具调用的自动注入过程。
压缩流水线
dialect.py 的 compress() 方法执行五阶段处理:
graph TD
A[原始文本] --> B["1. 实体检测<br/>name[:3].upper()"]
B --> C["2. 主题提取<br/>去停用词 + 频率排序"]
C --> D["3. 关键语句筛选<br/>_extract_key_sentence()"]
D --> E["4. 情感/标志检测<br/>关键词 → 编码映射"]
E --> F["5. AAAK 组装<br/>管道分隔 + 头部行"]
对于当前 dialect.compress() 的 plain-text 路径,更准确的描述是:五个阶段整体都带有启发式筛选,而不是只有第 3 步有损。实体、topics、情感、flags 都会做检测和截断,key_sentence 只是最明显的一步。它更接近"高压缩索引生成"而不是严格无损编码。
README 和前文章节里讨论的"无损 AAAK"应理解为设计目标:如果要做到真正的逐断言保留,压缩器需要比当前这个 heuristic plain-text 管道更强的结构对齐能力。
源码位置:dialect.py:539-602(compress 方法)
AAAK 方言完整度评估
已实现的能力
| 能力 | 实现位置 | 完整度 |
|---|---|---|
| 实体编码 | encode_entity() :367-379 | 完整 — name[:3].upper(),支持预定义映射和自动编码 |
| 情感编码 | EMOTION_CODES :47-88 | 完整 — 28 种情感 → 短编码映射 |
| 情感检测 | _EMOTION_SIGNALS :91-114 | 基础 — 24 个关键词触发,简单 in 匹配,无上下文 |
| 标志检测 | _FLAG_SIGNALS :117-152 | 基础 — 7 类标志,36 个关键词,简单匹配 |
| 主题提取 | _extract_topics() :430-455 | 基础 — 词频 + 大写/驼峰加权,top-3 |
| 关键语句 | _extract_key_sentence() :457-508 | 基础 — 18 个决策词评分,短句加权,截断到 55 字符 |
| 实体检测 | _detect_entities_in_text() :510-537 | 基础 — 已知实体匹配 + 大写词 fallback,top-3 |
| 压缩组装 | compress() :539-602 | 完整 — 管道分隔输出格式 |
| 停用词 | _STOP_WORDS :155-289 | 完整 — ~135 个英文停用词 |
| 配置持久化 | from_config() / save_config() | 完整 |
| Zettel 格式 | encode_zettel() / compress_file() | 完整 — 兼容旧格式 |
| Layer1 生成 | generate_layer1() | 完整 — 批量压缩 + 聚合 |
| 压缩统计 | compression_stats() | 完整 — 原文/压缩 token 计数 |
缺失的关键能力
作为一种"语言",AAAK 缺少关键的语言学基础设施:
| 缺失 | 影响 | 严重度 |
|---|---|---|
| 无形式语法定义 | 没有 BNF/EBNF/PEG 规范,"语法"只存在于 compress() 的代码逻辑里 | 高 |
| 无解码器/解压器 | 只有编码方向,没有 decompress() 方法来验证可逆性 | 高 |
| 无往返测试 | 没有 assert decompress(compress(text)) ≈ text 的测试 | 高 |
| 无 token 级精度 | count_tokens() 用 len(text)//3 估算,不是真正的 tokenizer | 中 |
| 无多语言支持 | 停用词、信号词、实体检测全部硬编码英语 | 中 |
| 无版本化 | 编码格式没有版本标记,无法区分不同版本的 AAAK 输出 | 中 |
| 截断不可恢复 | key_sentence 截断到 55 字符(:506-507),topics 取 top-3,emotions 取 top-3 — 超出的全部丢弃 | 高 |
核心定性判断
AAAK 不是一种语言,而是一个压缩函数。
一种真正的语言需要三个要素:
- 语法(syntax)— 什么是合法的 AAAK 字符串。AAAK 部分具备(管道分隔、头部行格式),但没有形式定义。
- 语义(semantics)— 每个符号的含义定义。AAAK 具备(情感编码表有明确语义)。
- 往返性(roundtrip)— 编码→解码后信息不丢失。AAAK 完全不具备。
compress() 是一个单向函数——它把文本压缩成 AAAK 格式,但没有对应的 decompress() 来验证信息是否真的保留了。README 声称 "lossless" 的依据是"LLM 能读懂 AAAK"——这把验证责任推给了模型的推理能力,而不是格式本身的可逆性保证。
公平地说
- 设计直觉是对的 — "极度缩写的英语,让 LLM 当解码器"这个思路确实有效,因为 LLM 的语言理解能力可以填补省略的信息。
- 工程上够用 — 作为 Closet 层的索引(而非唯一存储),AAAK 不需要严格无损——Drawer 保留了原文。
- 跨模型可读性是真的 — 任何能读英文的模型确实能理解
KAI(backend,3yr),这个属性不依赖 AAAK 的形式完整性。 - 950 行代码做到了可用 — 对于一个 v3.0.0 的项目来说,这个实现足够支撑 benchmark 结果。
综合评分
| 维度 | 评分 | 说明 |
|---|---|---|
| 设计思路 | 8/10 | "LLM 即解码器"是原创且有效的洞见 |
| 实现完整度 | 5/10 | 编码器完整,但缺解码器和往返验证 |
| 形式语言完整度 | 3/10 | 无 BNF、无版本、无形式语义 |
| 工程实用性 | 7/10 | 作为索引层够用,Drawer 兜底 |
| "30x lossless" 声明 | 3/10 | 过度承诺——实际是有损索引生成 |
最诚实的定位:AAAK 是一种面向 AI 的速记索引格式,通过极度缩写的英语让任何 LLM 都能快速理解上下文摘要,同时依赖 Drawer 层保留完整原文作为安全网。它的核心价值不在于"无损压缩",而在于"跨模型可读的高效索引"。
附录 D:真实性与可信度评估
定位:本附录不是技术原理讲解,而是对 MemPalace 当前开源仓库的一次证据化评估。它回答的不是"这个想法好不好",而是"哪些能力在源码里已经成立,哪些还停留在 README / 叙事层,哪些目前无法仅凭本地仓库确认"。
评估边界
这份评估只基于两个对象:
- 当前书稿引用的本地源码快照
- 书中可被源码直接验证的技术表述
它不试图判断创始人的主观动机,也不评估任何闭源组件、私有数据、线下演示或社交媒体传播效果。换句话说,这不是道德审判,也不是投资建议;它只是一次工程可信度审计。
这个边界很重要。一个项目可能同时满足两件事:
- 它确实有真实、可运行的工程实现
- 它的叙事口径又明显超前于当前实现
MemPalace 正是这种情况。
三栏结论
| 类别 | 结论 | 可信度 |
|---|---|---|
| 核心本地存储/检索管道 | 真实存在,且能从源码直接追踪 | 高 |
| AAAK 作为"严格无损、普适、超高压缩"的当前实现 | 更像设计目标,不是当前 plain-text 压缩器的真实状态 | 低到中 |
~170 token wake-up、Hall/Closet/agent 自动化叙事 | README/路线图成分明显重于当前默认运行时 | 低 |
| Benchmark 管线与结果复现脚本 | 真实存在,但要区分 raw / hybrid / rerank,不等于默认产品路径 | 中 |
| "完全离线、安装后立刻断网也一切照常工作" | 核心路径基本本地优先,但冷启动和资产准备仍有边界 | 中 |
这张表的核心结论可以浓缩成一句话:
它不是一个没有代码的空壳项目,但它的宣传叙事长期跑在实现前面。
一:哪些东西是真的
1. 数据摄入和归一化是真的
normalize.py、miner.py、convo_miner.py 这条链路不是摆设。项目确实能把多种输入格式转成统一 transcript / drawer 形式,再写进本地向量库。它不是"只有 benchmark,没有产品代码"的仓库。
这意味着 MemPalace 至少有一个坚实的底盘:本地 ingest -> chunk -> store -> search 这条路径是成立的。
2. 检索和 MCP 接口是真的
searcher.py 提供了可运行的语义搜索;mcp_server.py 也确实暴露了一组读写工具。即使你完全不接受它关于"记忆宫殿"的宏大叙事,这个项目依然是一个真实存在的本地记忆存储 + 搜索 + MCP 封装系统。
3. 一部分记忆层和辅助能力是真的
layers.py、知识图谱、diary、duplicate check、taxonomy 这些组件都不是 PPT 里的名字。它们在仓库里有实际代码,有调用接口,也能被局部验证。
但"存在代码"不等于"叙事版本完全成立"。这就进入下一节。
二:哪些地方明显叙事超前
1. AAAK 的当前实现被讲得比源码更强
书和 README 容易让人以为 AAAK 已经是一个当前可用的、逐事实无损的压缩语言。但当前 dialect.compress() 的 plain-text 路径实际上带有大量 heuristic:
- 实体只保留前几个
- topics 是频率排序后的 top-k
- emotions / flags 会截断
key_sentence本身就是显式筛选
这更像一个高压缩索引生成器,不是严格的"零信息损失编码器"。因此,如果把 AAAK 当成设计方向,我认为可信;如果把它当成当前开源实现已经兑现的产品能力,我认为不可信。
2. ~170 token wake-up 不是当前默认路径
当前运行时代码的 wake-up 仍然是较长的多层文本拼接,而不是书里一度反复出现的 ~170 token AAAK 化唤醒。后者更接近 README 所描绘的目标状态,而不是今天默认 CLI 的真实输出。
这类差异的影响很实际:它直接改变用户对成本、延迟、local model 可用性的判断。
3. Hall / Closet / agent 体系被写得比实现更完整
在叙事里,MemPalace 常被描述成一个层次丰富、可自动路由、带 specialist agents 的认知建筑;但在当前开源实现中,真正稳定存在的主路径仍然更接近:
wingroom- drawers
- 若干附加元数据和辅助工具
Hall、Closet、自动路由、内建 reviewer/architect/ops agent 这些说法,很多更像设计语言、接口愿景或 README 世界观,而不是当前默认运行时里一步不差的现实。
三:哪些判断只能给"中等可信"
1. Benchmark 结果不能直接等于产品体验
仓库里确实有 benchmark 脚本,也确实有 raw / hybrid / rerank 这些不同路径。但问题在于,读者很容易把"benchmark 上 100%"理解成"默认产品已经 100%"。这并不准确。
更严谨的读法应该是:
- raw 检索是一条真实能力路径
- hybrid / rerank 是另一条更强但更复杂的实验或评测路径
- 评测上界不等于默认产品路径
所以 benchmark 不是假的,但它常被误读成了产品现状。
2. 本地优先基本可信,但绝对化表述要小心
MemPalace 的核心价值主张之一是 local-first。按当前仓库看,这个方向基本可信:主要存储、检索、归一化、分块路径都在本地完成,不依赖 SaaS API。
但如果把它表述成"安装完成立刻拔网线,任何场景都完全不受影响",这就过头了。默认嵌入资产、可选 benchmark rerank、Wikipedia lookup 一类边界仍然存在。因此更准确的说法是:
它是本地优先系统,不是对所有冷启动场景都已被严格证明的绝对离线系统。
四:总体判断
如果问题是:"它是不是纯骗局?"
我的答案是:不像。
因为纯骗局通常有三个特征:
- 缺少真实可运行代码
- 关键能力只存在于演示视频或营销语
- 一旦沿调用链往下追,就会发现核心环节是空的
MemPalace 不符合这一组特征。它有真实的数据管道、真实的本地存储、真实的搜索、真实的 MCP 接口,也有一部分确实有用的工程判断。
但如果问题是:"它有没有明显夸大?"
我的答案同样是:有,而且是系统性的。
这种夸大不主要表现为伪造代码,而主要表现为把下面三件事混写:
- 当前默认实现
- benchmark / 实验路径
- README 与设计愿景
一旦这三层被混在一起,读者就会高估项目成熟度。
因此,最公允的结论不是"骗局"或"神作",而是:
一个有真实工程基础、但长期存在叙事超前问题的项目。
五:如何阅读这个项目
如果你打算继续阅读本书或评估 MemPalace,最稳妥的方法是用下面这个顺序:
- 先信源码主路径:
normalize -> chunk -> store -> search -> MCP - 再看 benchmark,区分 raw、hybrid、rerank
- 最后再看 README/AAAK/Hall/Closet/agent 叙事,把它们默认理解成"方向"而不是"现状"
只要顺序反过来,你就很容易被项目的世界观吸引,然后在实现层面不断发现落差。
这个附录的意义,不是给项目"判死刑",而是给读者一把更稳的尺子:先确认什么已经成立,再讨论它应该走向哪里。