Skip to content

3.1 记忆:从"什么都不记"到"记住关键事实"

我遇到了什么问题

第 2 章末尾说到,LLM 是"无状态"的。我当时的解法是:保存 10 轮对话历史,每次调用时带上。

这个解法能用,但很快遇到了三个问题:

问题 1:对话历史 ≠ 记忆

用户说:"我叫张三,以后叫我小张就行。" LLM 回复:"好的,小张。"

五轮对话后,对话历史里"我叫张三"这条消息已经被截断了。LLM 又开始叫"用户"。

对话历史是"流水账"——它记录了对话的过程,但没有"记住"关键信息。我需要的是记忆,不是历史

问题 2:上下文从哪来?

用户说:"帮我看看这个项目的架构。"

LLM 需要知道"这个项目"是什么——目录结构、模块划分、技术栈、命名规范。这些信息不在对话历史里,也不在系统提示词里。

LLM 不知道它不知道什么。 你得主动把信息喂给它。

问题 3:上下文越长,效果越差

对话历史 10 轮,大概 4K Token。加上系统提示词 2K,加上用户输入 1K,一次调用 7K Token。

看起来不多,但:

  • Token 是要钱的,按次计费
  • 上下文越长,响应越慢
  • 上下文越长,LLM 越容易"走神"——它会忘记重点,开始胡说

这三个问题,本质上是一个问题:怎么让 Agent 在有限的上下文窗口里,既记住关键信息,又不被无关信息干扰?

这个问题,我花了三个阶段才想清楚。


阶段一:没有记忆——LLM 只是一个节点

记忆系统的三个阶段:从节点时代到门户Agent的演进

最开始,我的产品里只有两种 Agent 节点:LLM 节点和意图识别节点。

LLM 节点很简单——接收输入,调用大模型,返回输出。每次调用都是独立的,没有"会话"的概念。就像你跟一个人说话,说完他就忘了,下次见面得从头开始。

这个阶段不需要记忆。节点是一次性的工具,用完就扔。

阶段二:内存记忆——够用就好

后来我做了一些简单的 Agent,比如代码生成 Agent、浏览器操作 Agent、FFmpeg 视频处理 Agent。这些 Agent 有一个共同特点:对话轮数少,任务明确。

用户说"帮我生成一段排序代码",Agent 问一句"用什么语言",用户说"C#",Agent 输出代码。三到五轮结束。

对于这种场景,我做了最简单的内存记忆:

csharp
// Agent.Memory —— 最简实现
internal sealed class InMemoryStore : IMemoryStoreProvider
{
    private sealed class SessionData
    {
        public string SessionId { get; init; } = "";
        public List<ChatMessage> Messages { get; } = new();  // 对话历史
        public List<string> Facts { get; } = new();          // 记忆事实
    }
}

就两个列表:

  • Messages:对话流水账,按时间顺序存
  • Facts:关键事实,比如"用户偏好 C#"、"项目用 .NET 8"

为什么把这两个分开存?因为对话历史会被截断,但关键事实不能丢。

用户说"叫我小张",这句话会被提取到 Facts 里。就算对话历史被压缩到只剩最近 5 轮,"用户叫小张"这个事实还在。

用户退出软件,记忆清空,下次重新开始。

这个设计看起来很简陋,但对于桌面端软件来说完全够用。Agent 是工具,用户用完就走,不需要跨会话的"长期记忆"。就像你打开记事本,用完关掉,下次打开是一个新页面。

这个阶段,内存记忆解决了"会话内不丢信息"的问题,够用了。

阶段三:需要更聪明的记忆——门户 Agent 的出现

后来产品功能越来越多,我需要做一个统一的门户 Agent——用户所有功能的起点。

这个 Agent 不再是"用完就走"的工具。用户每天打开,跟它对话,让它帮忙处理各种任务。它应该越用越懂你

这时候,内存记忆的三个问题暴露了:

  1. 不持久:退出就清空,下次又得从头教
  2. 不分类:所有事实平铺在一个列表里,没有结构
  3. 不会压缩:对话越来越长,Token 消耗越来越大

我开始调研业界怎么做的。

Hermes 的做法让我眼前一亮。

Hermes 的记忆系统有一个核心概念:记忆提供者(MemoryProvider)。它不是一个简单的存储,而是一个有生命周期的管理器:

initialize()          → 启动时连接后端
system_prompt_block() → 静态文本注入系统提示词
prefetch(query)       → 每轮对话前,根据用户输入"召回"相关记忆
sync_turn(user, asst) → 每轮对话后,异步写入新记忆
on_session_end()      → 会话结束时,提取关键事实
on_pre_compress()     → 压缩前,抢救即将被丢弃的信息

这个设计的关键洞察是:记忆不是被动存储,是主动召回。

Hermes 不是把所有记忆都塞给 LLM,而是在每轮对话前,根据用户当前说的话,主动检索最相关的记忆。用户说"帮我看看数据库",系统就召回"项目用 SQLite"这条记忆,而不是把所有记忆都倒出来。

字节 OpenViking 的做法给了我另一个启发:三层结构。

三层记忆结构:L0一行摘要、L1结构化摘要、L2完整内容

OpenViking 把每条记忆分成三层:

层级内容Token 预算用途
L0一行摘要~100快速判断"这条记忆跟当前话题有没有关系"
L1结构化摘要~500提供关键信息,不需要展开细节
L2完整内容不限需要深入了解时才加载

这个设计非常聪明。就像你看一本书:先看目录(L0),觉得有用再翻到那一章看摘要(L1),确实需要才读全文(L2)。

我把这两个思路结合起来,做了 OpenVikingStore。


关键认知

记忆的演进不是一步到位的,而是被产品需求逼出来的

  • 节点时代 → 不需要记忆
  • 简单 Agent 时代 → 内存记忆够用
  • 门户 Agent 时代 → 需要主动召回 + 三层结构

记忆的复杂度,应该匹配产品的复杂度。 不要一上来就搞向量数据库、知识图谱——先问清楚:你的 Agent 需要"记住"什么?