Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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]

代码尝试从多个元数据键中读取重要性评分——importanceemotional_weightweight——体现了一种务实的兼容性策略:不同来源的数据可能使用不同的键名来标记重要性,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_atdate 排序,因此这里更准确的表述是"一批过滤命中的片段",而不是严格意义上的"近期历史"。这个量足以让 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 的压缩路线,它未来可以用更小的代价做到同样的事。

栈不是文件柜。它是一套关于"什么值得现在就记住"的分层决策系统。