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 的上下文中。
这个管道有三个关键环节:
- 提取:从对话中识别有价值的信息(事实提取)
- 压缩:在信息丢失前抢救关键内容(智能压缩)
- 召回:在正确的时机注入正确的信息(主动检索)