【从 0 到 1 动手造 Agent】(原理篇)03、让 Agent 记住你:记忆分层与自我演化

211 阅读12分钟

前两章我们拿到了两把钥匙:一个自由的循环,一张确定性的图。

但合上书,一个更扎心的问题浮上来——如果对话进行到第 100 轮,上下文窗口爆了,你的 Agent 还记得你是谁、说过什么吗?


一、一个厨房里最常见的尴尬

先接着上一章的厨房往下讲。

客人第一天来,说了一句:「我不吃香菜。」

大厨点点头,照做了。菜很好。

第二天客人又来了。大厨一脸茫然——他完全不记得这个人昨天说过什么。

这不是他态度不好,是物理上装不下。这位大厨的工作记忆只有一道菜那么长,做完就清空。

现实中的解法有两个,都很笨:

  • 在菜单上一直往上写——写到后来,菜单比桌子还长,大厨根本看不过来;
  • 把写满的菜单塞进抽屉——抽屉倒是能装,但大厨压根不知道抽屉里有啥。

这正是所有 Agent 面临的困境:

模型记不住东西。

不是「记性差」,是物理上装不下。一个 128k 的上下文窗口,塞进 100 轮对话的工具返回、中间思考、冗余日志,早就爆了。

工业界最常见的解法是「截断最老的」或者「把历史丢给向量库」——但这两招都很粗糙:

  • 截断 = 用户说过的话永远消失了;
  • 向量库 = 模型根本不知道你丢给它的那段话是谁说的、什么时候说的。

那么问题来了:在真实的世界里,「记不住就装不下」这个问题,操作系统是怎么解决的?

答案是——内存分层。

flowchart LR
    CPU[CPU] -->|只跟内存打交道| RAM[内存 RAM<br/>快,小,贵]
    RAM -.->|装不下就换出| DISK[磁盘 Disk<br/>慢,大,便宜]
    OS[操作系统] -.->|按需换页| RAM
    OS -.->|按需调取| DISK

    style CPU fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
    style RAM fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style DISK fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
    style OS fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A
  • 速度快的装不下(内存),容量大的够不着(磁盘);
  • CPU 只跟内存打交道,磁盘由操作系统按需调度;
  • 装不下的时候,操作系统会明确地告诉进程:「内存压力高,请你自己清理」。

MemGPT 这篇论文干的事,就是把这一整套计算机体系结构,原封不动地搬给了大模型。它现在的开源继任者叫 Letta。

这一章我们就把它最本质的两件事讲透:类操作系统的内存分层,以及自我修改内存。


二、把后厨改造成一个「操作系统」

现在把上面这套内存分层,原样搬进厨房。

假设我们要给这位失忆的大厨,配一整套记忆设施。最直觉的方案是分四层:

flowchart TB
    subgraph IN[在灶台边 - 模型每一轮都能直接看见]
        direction LR
        CM[便签条<br/>Core Memory]
        WC[手边的食材<br/>Working Context]
    end

    subgraph OUT[在隔壁房间 - 模型知道有,但得去取]
        direction LR
        RS[冰箱<br/>Recall Storage]
        AS[储藏室<br/>Archival Storage]
    end

    CM -->|抬眼就能看见| LLM[大厨]
    WC -->|伸手就够得着| LLM
    LLM -.->|按编号去取| RS
    LLM -.->|按意思去找| AS
    RS -.->|取回来摆上台面| WC
    AS -.->|取回来摆上台面| WC

    style IN fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style OUT fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
    style LLM fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A

四个区域,两句话说完:

存储厨房对应谁看得见怎么读写
核心记忆贴在灶台前的便签条大厨抬眼就能看见用工具改写:追加 / 替换
工作上下文灶台上正在用的食材大厨伸手就够得着追加;放不下了自动收走
回忆存储塞进冰箱的历史知道里面有,得去拿按编号精确取
档案存储储藏室的长期资料知道里面有,得去翻按「意思」语义检索

注意一个关键区别:前两块是「摆在眼前」的,后两块是「存在别处」的。

大厨能直接读到的,永远是前两块。后两块必须先「取回来」,摆到前两块里,他才能看见。

这就是整个记忆体系的核心机制。接下来两节,讲清楚两个最关键的零件。


三、零件一:便签条 —— 让记忆「一直摆在眼前」

3.1 大厨要记住「客人不吃香菜」,最笨也最有效的办法是什么?

把这句话写在一张便签条上,贴在灶台正前方。

大厨每做一道菜,抬头就能看见。他不需要「回忆」,因为这句话从来没离开过他的视野。

这就是核心记忆(Core Memory)的全部设计。

它由若干「记忆块」组成,每一块是一张便签:

结构体 记忆块:
    标签    : 文本        # 便签的名字,比如 "客人" / "我自己"
    内容    : 文本        # 便签上写的东西
    字数上限 : 数字        # ★ 硬边界——便签就这么大,写满了就是写满了
    只读    : 真假        # 只读的块,大厨改不了

那个 字数上限 字段,就是「内存」二字的物理含义。它不是一个建议,是一道硬墙。

3.2 便签上的字,是谁写的?

这是 MemGPT 最反直觉、也最精彩的地方。

记忆的写入者,不是用户,是模型自己。

传统的做法是:你的信息写死在 System Prompt 里,想改就得改代码、重新部署。

MemGPT 把「写记忆」变成了大厨的一个工具。大厨读完岗位守则,发现「哦,这个客人的偏好我还没记」,就主动调用工具,把这句话物理写进便签条。

函数 追加记忆(记忆库, 标签, 新内容):
    当前内容 = 记忆库.取块(标签).内容
    新内容   = 当前内容 + 换行 + 新内容
    记忆库.更新块(标签, 新内容)
    返回 新内容

看清楚——这里没有任何魔法,就是字符串拼接加写回对象。 十几行代码。

但它的意义是巨大的:不用改一行部署代码,大厨自己在运行时决定「该记什么、该忘什么、该改什么」。

这就叫自我修改内存。

3.3 轴心:便签是数据,岗位守则是它的投影

这里有一个必须点破的设计细节。

便签上的内容,是怎么变成大厨能看见的东西的?答案是每一轮重新「编译」一次:

函数 编译成守则(记忆库):
    结果 = "<核心记忆>\n"
    对于 每一块 在 记忆库 里:
        结果 += "  <" + 块.标签 + " 已用=" + 块.已用字数 + " 上限=" + 块.上限 + ">\n"
        结果 += "    " + 块.内容 + "\n"
        结果 += "  </" + 块.标签 + ">\n"
    结果 += "</核心记忆>"
    返回 结果

编译出来的东西长这样:

<核心记忆>
  <我自己 已用=16 上限=2000>
    你是忠实记录客人偏好的助理。
  </我自己>
  <客人 已用=30 上限=2000>
    客人不吃香菜
  </客人>
</核心记忆>

▍这里是整个「自我演化」的轴心。

记忆是数据,岗位守则是它的渲染产物。大厨改的不是「守则字符串」,而是背后的记忆对象;下一轮做菜前重新编译一遍,守则就自动带上了新记忆。

你不需要任何「把修改写回提示词」的代码——因为提示词从来不是源头,它只是投影。

而且注意那个 已用 / 上限——大厨能看见自己还剩多少空间。 这是后面「压力告警」的基础。


四、零件二:冰箱 —— 让记忆「该收就收」

便签条就这么大,写满了怎么办?

4.1 换页:不是删掉,是收进冰箱

工作上下文(灶台上手边的食材)也一样会满。满了之后,最老的东西不是被删掉,而是被收进冰箱。

函数 放入工作区(消息):
    循环 只要 当前占用 + 消息.大小 > 预算:
        最老的 = 工作区.弹出最早的一条()
        冰箱.存入(最老的)                    # ★ 不是删除,是换页
        打印("已换页到冰箱:" + 最老的.内容)
    工作区.追加(消息)

「换出去」和「删掉」是两件完全不同的事。 换出去的东西还在冰箱里,需要的时候按编号能精确取回来。

这就是「回忆存储」(Recall Storage)的角色——它是历史消息的仓库,大厨知道里面有东西,需要时按编号去拿。

4.2 压力告警:让大厨知道自己快满了

换页还伴随一个特别关键的动作。

当便签条或者灶台接近上限时,系统会往岗位守则里注入一条显式的告警:

系统告警:记忆压力过高,请用工具压缩或清理旧记忆。

为什么必须有这条告警?

因为大厨对「灶台快满了」这件事毫无知觉——他只能看见台面上的东西,看不见台面的边界。

系统必须像操作系统的信号一样,主动把压力通知给他,他才会想起来去调用工具压缩记忆。

这是「让模型管理自己的内存」的前提:先让压力对模型可见。

于是就有了完整的闭环:

sequenceDiagram
    participant OS as 记忆系统
    participant M as 大厨(模型)
    participant MEM as 便签条

    OS->>OS: 检查:便签已用 52 / 上限 60
    OS->>M: 在守则末尾注入「记忆压力过高」
    Note over M: 大厨读到告警
    M->>OS: 调用工具:把这几条旧记忆压缩成一行
    OS->>MEM: 物理改写便签内容
    MEM-->>OS: 52 字 → 35 字
    Note over OS: 压力解除,告警消失
    OS->>M: 下一轮守则里不再有告警

操作系统负责盯压力、发信号;大厨负责动手清理。 分工明确。


五、把三块拼起来:一次完整对话

现在把便签条、灶台、冰箱拼在一起,跑一遍「客人第二次上门」的完整流程:

sequenceDiagram
    participant U as 客人
    participant C as 控制器
    participant M as 大厨(模型)
    participant MEM as 便签条(核心记忆)
    participant R as 冰箱(回忆存储)

    Note over U,R: ── 第 1 次上门 ──
    U->>C: &#34;我不吃香菜&#34;
    C->>M: 守则(含空白便签)+ 历史
    M->>MEM: 调用工具:追加记忆「客人不吃香菜」
    Note over MEM: 便签被物理改写<br/>已用 0 → 6 字
    M-->>U: &#34;好的,记下了&#34;
    Note over C: 第一轮结束,重新编译守则<br/>便签内容已经进了提示词

    Note over U,R: ── 中间隔了 8 轮闲聊 ──
    C->>R: 灶台放不下,老消息陆续收进冰箱
    Note over MEM: 便签一直贴在灶台前<br/>从头到尾没被换出去过

    Note over U,R: ── 第 10 次上门 ──
    U->>C: &#34;你还记得我不吃什么吗?&#34;
    C->>M: 守则(便签上写着「客人不吃香菜」)
    M-->>U: &#34;记得,您不吃香菜。&#34;

    Note over U,M: 全程没有任何外部数据库查询<br/>答案就在每一轮都编译进守则的便签上

「记性好」的真相,其实不是记性好,是「没被换出去过」。

大厨能答上来,唯一的原因是那句话每一轮都物理地待在提示词里——它不是被「回忆」起来的,它是一直摆在眼前的。

这就是「常驻上下文」的全部秘密。


六、完整的控制器长什么样

把上面所有零件组装起来,一个完整的记忆型 Agent 控制器,主干只有这么长:

函数 跑一轮(用户输入):

    轮次 += 1
    放入工作区(用户消息)                      # ① 客人说话 → 进灶台

    循环 最多 内层步数 次:

        守则 = 拼装守则(
                  模板     = 基础岗位守则,
                  核心记忆 = 编译成守则(记忆库),      # ② 便签重新渲染
                  告警     = 检查内存压力())          # ③ 快满了就注入告警

        动作 = 大厨.作答(守则, 工作区快照)          # ④ 问大厨

        如果 动作 是 记忆工具:
            执行记忆工具(动作)                      #   物理改写便签
            把结果写回工作区
            继续循环                                # ★ 心跳:让大厨再想一步

        如果 动作 是 对客人说话:
            把回复写回工作区
            返回 回复                               # 本轮结束

    报错("超过上限仍未对客人说话")

这里有三个地方值得停下来看一眼。

6.1 心跳:记忆工具执行完,本轮为什么不结束?

看那个 继续循环。大厨写完便签之后,系统不结束这一轮,而是把控制权交回给他,让他再想一步。

为什么?因为便签刚被改写,大厨还没「看见」新版本。让他再转一圈,重新编译一遍守则,他就能看见自己刚写下的东西,再决定要不要跟客人说话。

这就是「心跳」(heartbeat)机制——一个工具执行完,模型有权决定「我要不要再想一步」。

6.2 两类工具,两种命运

flowchart LR
    A[大厨决定<br/>调用工具] --> B{是什么工具?}
    B -->|记忆工具| C[改写便签<br/>-> 继续循环 - 心跳]
    B -->|说话工具| D[回复客人<br/>-> 本轮结束]
    C --> A

    style B fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style C fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
    style D fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
  • 记忆工具(追加 / 替换):改完继续转,因为「记东西」不是目的,「用记下来的东西」才是;
  • 说话工具:一说话,这一轮就结束了,因为客人已经拿到答复了。

6.3 便签是数据,不是字符串

再看一眼那句 编译成守则(记忆库)。它是每一轮重新调用一次的。

这意味着:大厨改的不是提示词,而是背后的数据结构;提示词只是它每次的渲染结果。

这个设计带来一个非常舒服的性质——你想改大厨的行为,不用改代码,改便签就行;而改便签这件事,大厨自己就能干。


七、小结

7.1 三章叠起来看

到这里,三章合起来,一条清晰的进化线浮出来了:

维度01 · 循环02 · 确定性图03 · 记忆分层
核心抽象一个 while 循环 + 交接一张图 + 状态 + 合并规则内存分层 + 自我编辑工具
记忆自由字典可落盘的快照便签 / 灶台 / 冰箱 / 储藏室
谁控制循环模型图结构 + 条件边模型(心跳)+ 规则
上下文爆了怎么办无解,硬塞检查点 / 压缩换页 + 告警,让模型自己清
这一章递来的钥匙循环确定性自我演化

一句话串起来:

第一章证明了「循环」是 Agent 的最小骨架;第二章证明了「控制流」可以是显式的图;第三章则把目光从「流程」移向了「状态」——Agent 最稀缺的资源,是上下文窗口里那几 KB 内存,而它必须学会自己管理它。

7.2 真实 Letta 还做了什么

和上篇一样,列一下工业级 Letta 有、而这一章没展开的东西——如果真要在生产里用,缺的正是这几块:

还没做的设施真实 Letta 里是什么解决什么问题
精确 token 计数按真实 token 数算窗口字符近似在长英文上会偏差
档案存储的语义检索记忆进向量库,按意思召回长期记忆不能靠字符串匹配
数据库持久化消息、记忆块、Agent 状态全落库进程一挂,记忆全丢
工具描述自动生成从函数注释自动推导出参数 schema手写参数表容易写错
心跳规则的体系化终止型 / 子调用型 / 退出前必需型等规则只靠模型自觉,循环可能失控
提示词缓存优化记忆没变就不重建守则每轮重建会打爆缓存、烧钱

7.3 下一章要解决的问题

现在四块存储里,有一块还只是个空壳。

储藏室(档案存储) —— 我们说的是「按意思去找」。可什么叫「按意思」?储藏室里有十万份食材,大厨说「给我来点提鲜的」,怎么在毫秒之间把最对味的那几样挑出来?

这就是向量库。它会是我们第二幕(组件篇)的开篇主角。

但在那之前,还有一个更硬的问题没解决:这一章的大厨,便签写得再好、冰箱收得再整齐,他依然伸不出手——他改不了一个真实文件,跑不了一条真实命令。

下一章,我们给他装上一双手。

核心结论:记忆不是「存起来」,而是「每一次都摆在眼前」。Agent 的自我演化,本质上是把「记忆」从只读的数据,变成了模型可以自己改写的工具。