Skip to content

3.2 记忆管理:三层记忆与事实提取

上一节讲到,我从 Hermes 学到了"主动召回",从 OpenViking 学到了"三层结构",把它们结合做出了 OpenVikingStore。

这一节讲具体的记忆管理:记忆怎么产生、怎么分类、怎么压缩、怎么检索。

短期记忆:对话历史

对话历史就是"短期记忆"——它只存在于当前会话,用来维持对话的连贯性。

csharp
// 短期记忆:Messages 列表
public Task AddMessageAsync(string sessionId, ChatMessage message)
{
    var session = _sessions.GetOrAdd(sessionId, _ => new SessionData { SessionId = sessionId });
    lock (session.Messages)
    {
        session.Messages.Add(message);
    }
}

短期记忆的特点是:完整、按时间顺序、会过期。

就像人的短期记忆——你记得今天早上吃了什么,但不会记得上周二的早餐。对话历史也是,最近几轮记得清楚,太早的就要被压缩掉。

长期记忆:事实 + 分层上下文

长期记忆分两种形式:

事实(Facts)

事实是从对话中提炼出来的关键信息,一行一条:

  • "用户偏好:叫他小张"
  • "项目技术栈:.NET 8 + SQLite"
  • "上次决策:选择了本地部署方案"

事实是扁平的,没有结构,直接塞进系统提示词。适合"用户是谁、项目用什么"这种简单信息。

分层上下文(ContextEntry)

当信息比较复杂,一行说不清楚时,就需要三层结构:

csharp
public sealed class ContextEntry
{
    public string Uri { get; set; }           // ov://memory/{sessionId}/facts/xxx
    public string? L0 { get; set; }           // 一行摘要(~100 tokens)
    public string? L1 { get; set; }           // 核心概览(~2000 tokens)
    public string? L2 { get; set; }           // 完整内容(不限长度)
    public Dictionary<string, string> Metadata { get; set; }  // 分类标签
}

比如用户说"我们项目的数据库用的是 PostgreSQL,连接池最大 100,读写分离,主库写从库读"。

这条信息放进 Facts 太长了。放进 ContextEntry:

  • L0:"数据库:PostgreSQL,读写分离"
  • L1:包含连接池配置、主从规则的 Markdown 摘要
  • L2:完整的配置细节和决策原因

需要快速判断相关性时看 L0,需要给出建议时看 L1,需要写代码时看 L2。

什么是"事实"?

这里需要解释一下"事实"这个概念。

事实不是对话原文的复制粘贴,而是从对话中提取的有价值的信息。比如:

  • 你的喜好("用户偏好 C#")
  • 你的偏好("叫他小张")
  • 代码的编码规范("项目用 .NET 8,接口命名用 I 前缀")
  • 项目决策("选择了本地部署方案,因为客户有数据安全要求")

Hermes 为什么会"越用越懂你"?因为它从每一次对话中提取所有关于你的喜好、偏好、项目信息的事实,然后去重、归档。下次对话时,这些事实会被主动召回到上下文中。

Agent 不是"记住了你说过什么",而是"理解了你是谁"。

事实提取:Agent 怎么“学会”了解你

事实提取三步流程:提取候选→去重决策→归档存储

事实不是自动产生的。你需要一个提取机制。

在 OpenVikingStore 里,我做了一个 MemoryExtractor——记忆提取引擎。它的核心思路来自 Hermes 和 OpenViking:

用 LLM 从对话中提取结构化记忆。

提取过程分三步:

第一步:提取候选

把对话历史喂给 LLM,让它识别"哪些信息值得长期记住":

分析以下对话,提取值得长期保存的记忆。

## 提取规则
- 穷举式提取:每条独立事实分别提取
- 保留细节:专有名词、参数名、数值、版本号
- 高召回:不确定是否值得提取时,先提取

## 记忆分类
| 问题 | 分类 |
|------|------|
| 用户是谁? | profile |
| 用户偏好什么? | preferences |
| 这是什么? | entities |
| 发生了什么? | events |
| 如何解决? | cases |
| 流程是什么? | patterns |

LLM 会返回一组候选记忆,每条包含分类、摘要、概览、详细内容。

第二步:去重决策

提取出来的候选,可能跟已有记忆重复。比如用户上次说过"我用 PostgreSQL",这次又说了一遍。

去重也是让 LLM 判断:

候选记忆:用户偏好 PostgreSQL 数据库
已有相似记忆:项目数据库使用 PostgreSQL

决策:
- skip:重复信息,跳过
- create:有效新记忆,独立存储
- merge:同主题,合并(补充细节、纠正错误)

第三步:归档

去重通过后,候选记忆被归档为 ContextEntry,同时追加到 Facts 列表。

这就是为什么 Hermes 会"越用越懂你"——它不是简单地存对话,而是每次对话后都在主动提取你的偏好、习惯、项目信息,然后去重、归档。下次对话时,这些记忆会被主动召回,注入到上下文中。

压缩:对话历史不能无限增长

对话历史越来越长,Token 消耗越来越大。需要压缩。

简单截断

最简单的压缩:保留最近 N 轮,删除更早的。

csharp
// Agent.Memory —— 简单截断
public static int Compact(List<ChatMessage> messages, int keepRecent = 10)
{
    if (messages.Count <= keepRecent) return 0;
    var removedCount = messages.Count - keepRecent;
    messages.RemoveRange(0, removedCount);
    return removedCount;
}

简单粗暴,但有个问题:被删除的对话里,可能有重要信息。

智能压缩:压缩前先"抢救"

OpenViking 的 CompactionEngine 做了智能压缩:

对话超过 50 轮?

先让 LLM 为即将被删除的对话生成一份结构化摘要:
  - 关键决策和结果
  - 重要事实和上下文
  - 待办事项和开放问题

摘要归档为 ContextEntry(L0/L1/L2)

然后截断消息,保留最近 10 轮

这样,即使对话历史被截断,重要信息已经以摘要的形式保存下来了。下次需要时,可以通过检索找回来。

降级策略: 如果 LLM 不可用(比如没有注入 IChatClient),就降级为简单截断。不会报错,不会崩溃,只是丢失一些细节。

压缩的类比

这个过程跟人脑很像:

  • 短期记忆(Messages):你记得今天发生的事,但记不清上周的
  • 记忆压缩(Compact):睡觉时大脑会整理白天的记忆,重要的形成长期记忆,不重要的被丢弃
  • 长期记忆(ContextEntry):被压缩摘要保存下来的信息,虽然细节丢了,但关键点还在

检索:怎么找到需要的记忆

记忆存了一堆,怎么在需要的时候找到?

加权相关度评分

OpenVikingStore 的做法是加权相关度评分

csharp
private static int CalculateRelevanceScore(string query, ContextEntry entry)
{
    var score = 0;
    // L0 匹配(权重最高:10 分)
    if (entry.L0?.Contains(query) == true) score += 10;
    // L1 匹配(5 分)
    if (entry.L1?.Contains(query) == true) score += 5;
    // L2 匹配(3 分)
    if (entry.L2?.Contains(query) == true) score += 3;
    // 元数据匹配(2 分)
    // URI 匹配(1 分)
    return score;
}

用户说"数据库",系统在所有记忆里搜索:

  • 有一条记忆 L0 是"数据库:PostgreSQL,读写分离" → L0 命中,10 分
  • 有一条记忆 L2 里提到了"数据库连接池" → L2 命中,3 分

按分数排序,取 Top-K,把最相关的记忆注入到上下文中。

为什么不用向量数据库?

这个方案不需要向量数据库,不需要 Embedding,纯文本匹配。

对于编码场景够用了——因为用户说的话通常跟记忆里的关键词高度重合。用户不会用隐晦的方式说"数据库",他会直接说"数据库"。

但如果记忆量级到了万级以上,或者需要语义级别的匹配(比如"数据存储"应该匹配到"PostgreSQL"),那就需要 Embedding + 向量检索了。这是未来可以升级的方向。


关键认知

记忆管道:提取→去重→归档→压缩→检索→注入的完整流转链路

记忆管理的核心不是“怎么存”,是怎么流转

记忆 = 提取 → 去重 → 归档 → 压缩 → 检索 → 注入

这是一个管道,不是一个仓库。信息从对话中流出来,经过加工,变成可检索的知识,再在需要的时候流回去,注入到 LLM 的上下文中。

这个管道有三个关键环节:

  1. 提取:从对话中识别有价值的信息(事实提取)
  2. 压缩:在信息丢失前抢救关键内容(智能压缩)
  3. 召回:在正确的时机注入正确的信息(主动检索)