跳到主要内容

记忆存储的工程实践

实战项目推荐

记忆存储最终会遇到粒度、检索、排序和更新问题。超级 AI 智能体把会话记忆和知识库检索结合起来,并提供 RRF、重排序等链路能力,适合看记忆如何进入工程系统。

项目详细介绍:什么是超级 AI 智能体?

一个看似简单的问题:记忆该切多细

上一篇讲了记忆系统的整体架构,你知道了要把信息存到向量库、关系库,也知道了"读→用→写"的闭环流程。但真到落地的时候,第一个让人卡住的问题往往不是"存到哪",而是"一条记忆到底该有多长"。

举个场景:你的Agent是一个智能客服,跟客户聊了半小时。这半小时的对话要沉淀成长期记忆,那你是把整段对话当作一条记忆存进去,还是每轮问答切一条,还是把里面的关键信息拆成十几个碎片分别存?

这就是记忆粒度问题,它直接决定了后续检索的质量。

粒度太粗的代价

假如你把整段30分钟的对话摘要成一大段文字(500~1000 token)当作一条记忆存进向量库。后续用户再来咨询的时候,你用"上次聊的退款政策"去检索,确实能命中这条记忆。但问题是,这条记忆里混着退款政策、物流时效、优惠券使用规则等好几个话题。全部注入context后,Agent需要从一大段里自己去找相关的那几句话,无关内容占了大半的token预算。

更麻烦的是相关性打分。向量相似度是对整条文本算的,如果这条记忆里"退款政策"只占了20%的篇幅,那整体的embedding向量里"退款"这个语义权重就很低,可能还不如另一条只聊了"退换货流程"的短记忆匹配分数高。

粒度太细的代价

反过来,如果你把对话拆成极细的碎片——每句话一条记忆,那检索倒是很精准了,"退款政策"能精确命中那句话。可新的问题来了:碎片缺乏上下文。

比如客户说"那就按你说的方案来吧",这句话单独存没有任何意义,你不知道"方案"指什么。它必须和前面的几轮对话放在一起才有信息量。碎片太细还有另一个问题:同一个话题的信息被打散成了十几条,检索时可能只命中其中两三条,拼起来之后信息不连贯。

工程上的平衡点

实践中比较好用的策略是话题级别的切分:以一个完整的话题讨论为单位,一条记忆覆盖一个议题的起承转合。

/**
* 话题级别的记忆切分 —— 智能健身教练场景
*/
public class TopicBasedMemorySplitter {

/**
* 把一次完整对话按话题边界切分成多条记忆
*/
public List<MemoryChunk> splitByTopic(List<Message> conversation) {
List<MemoryChunk> chunks = new ArrayList<>();
List<Message> currentTopic = new ArrayList<>();
String currentTopicLabel = "";

for (Message msg : conversation) {
String detectedTopic = detectTopic(msg);

// 话题发生了切换
if (!detectedTopic.equals(currentTopicLabel) && !currentTopic.isEmpty()) {
chunks.add(buildChunk(currentTopic, currentTopicLabel));
currentTopic = new ArrayList<>();
}
currentTopicLabel = detectedTopic;
currentTopic.add(msg);
}
// 最后一个话题
if (!currentTopic.isEmpty()) {
chunks.add(buildChunk(currentTopic, currentTopicLabel));
}
return chunks;
}

private MemoryChunk buildChunk(List<Message> messages, String topic) {
// 每条记忆包含:话题标签、原始内容的摘要、关键结论、时间戳
return MemoryChunk.builder()
.topic(topic)
.summary(summarize(messages))
.keyDecisions(extractDecisions(messages))
.timestamp(messages.get(0).getTimestamp())
.messageCount(messages.size())
.build();
}
}

这种做法的好处:每条记忆自带完整的上下文(因为是一个话题的完整讨论),embedding能准确反映这段内容的语义主题,检索时不会出现"碎片无意义"的问题。同时粒度又没粗到把多个无关话题混在一起,检索精度有保障。

粒度选择的经验法则

一条记忆控制在100~300 token左右效果最好。太短(少于50 token)缺乏上下文,太长(超过500 token)语义混杂。如果是结构化事实(用户偏好、配置参数),可以更短;如果是叙事性内容(讨论过程、决策记录),适当长一些。

检索精度:语义搜索为什么比关键词好用

记忆存进去了,下一步就是"找得准"。这个环节直接决定了Agent拿到的背景信息质量。

关键词匹配的困境

传统做法是用关键词搜索——用户问到"健身计划",就在记忆库里搜包含"健身计划"这四个字的记录。看起来简单直接,但在Agent场景下几乎不可用。

为什么?因为自然语言的表达方式太灵活了。用户之前说的可能是"每周的训练安排"、"我的锻炼时间表"、"下一阶段的运动规划",这些表达跟"健身计划"一个关键词都对不上,但意思完全一样。关键词搜索会全部漏掉。

反过来,用户说"我上周的体检计划出来了",关键词搜索可能因为"计划"这个词而错误匹配了跟健身计划相关的记忆。

向量语义检索的原理

向量检索解决的就是这个问题。它的核心思路是:不比较文字本身,比较含义。

工作流程是这样的:

  1. 存记忆的时候,把每条记忆通过Embedding模型转成一个高维向量(比如1536维的浮点数数组)
  2. 检索的时候,把查询语句也转成同样维度的向量
  3. 计算查询向量和库里所有记忆向量之间的距离(通常用余弦相似度)
  4. 返回距离最近的前K条

因为Embedding模型是在海量语料上训练过的,它能理解"训练安排"和"健身计划"在语义空间里是邻居,所以即使文字完全不同,向量之间的距离也很近,就能被检索到。

/**
* 向量语义检索的完整流程 —— 健身教练Agent检索用户历史偏好
*/
@Service
public class SemanticMemoryRetriever {

private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;

/**
* 根据当前对话上下文,检索最相关的历史记忆
*/
public List<MemoryRecord> retrieveRelevant(String currentQuery, String userId, int topK) {
// 1. 构建过滤条件:只在这个用户的记忆里搜
FilterExpression filter = new FilterExpression(
"userId", FilterExpression.Operator.EQ, userId);

// 2. 执行语义检索
SearchRequest request = SearchRequest.builder()
.query(currentQuery)
.topK(topK)
.similarityThreshold(0.72) // 低于这个相似度的不要
.filterExpression(filter)
.build();

List<Document> results = vectorStore.similaritySearch(request);

// 3. 转换为业务对象
return results.stream()
.map(doc -> MemoryRecord.builder()
.content(doc.getText())
.topic(doc.getMetadata().get("topic").toString())
.timestamp(Instant.parse(doc.getMetadata().get("timestamp").toString()))
.relevanceScore(doc.getScore())
.build())
.toList();
}
}

混合检索:两种策略互补

纯向量检索也有短板。比如用户说"帮我查一下编号为FIT-2024-0087的训练方案",这种包含精确ID的查询,向量检索反而容易失准——Embedding模型对随机编号的编码不够精确,可能会返回编号相近但内容无关的记录。

这时候精确匹配(关键词或字段查询)反而更可靠。

生产环境中的最佳实践是混合检索:同时用向量检索和关键词检索,然后把两路结果合并排序。

/**
* 混合检索策略:向量 + 关键词,结果用RRF融合
*/
@Service
public class HybridMemorySearch {

private final VectorStore vectorStore;
private final JdbcTemplate jdbc;

public List<MemoryRecord> hybridSearch(String query, String userId, int topK) {
// 路径一:向量语义检索
List<MemoryRecord> semanticResults = semanticSearch(query, userId, topK * 2);

// 路径二:关键词全文检索(覆盖精确匹配场景)
List<MemoryRecord> keywordResults = keywordSearch(query, userId, topK * 2);

// 用RRF(Reciprocal Rank Fusion)做无参数融合
return rrfMerge(semanticResults, keywordResults, topK);
}

/**
* RRF融合:按排名倒数求和,不依赖分数归一化
* 公式:score = Σ 1/(k + rank_i),k通常取60
*/
private List<MemoryRecord> rrfMerge(
List<MemoryRecord> listA, List<MemoryRecord> listB, int topK) {
Map<String, Double> scores = new HashMap<>();
int k = 60;

for (int i = 0; i < listA.size(); i++) {
String id = listA.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < listB.size(); i++) {
String id = listB.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}

// 按融合分数降序取topK
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> findRecordById(entry.getKey()))
.toList();
}
}
RRF为什么好用

RRF(Reciprocal Rank Fusion)的最大优点是不需要对两路检索的分数做归一化。向量检索返回的是余弦相似度(0~1),关键词检索返回的是BM25分数(无上限),两种分数量纲完全不同,直接比较没意义。RRF只看排名不看分数,天然解决了这个问题。它在冷启动阶段(没有标注数据调参)特别实用。

记忆框架选型:自己造还是站在巨人肩上

记忆系统从零搭建的工作量不小——Embedding管理、向量存储、去重逻辑、冲突消解、生命周期管理,每一块都有工程细节。如果你的项目不需要深度定制,可以考虑用社区里已经比较成熟的记忆框架。

Mem0:轻量级个性化记忆

Mem0的定位很明确:帮你管每个用户的个性化记忆。你只需要调它的API存和查,底层的embedding、去重、冲突处理它全包了。

核心特点:

  • 按user_id做记忆隔离,天然支持多租户
  • 存记忆时自动做去重——如果新记忆和已有的语义重复度高,会合并更新
  • 同时支持向量存储和图存储(知识图谱),可以按需选择
  • 提供cloud版本和自部署版本

适用场景:你的Agent需要"记住每个用户的偏好和习惯",而你不想自己维护一套记忆管理逻辑。比如个性化推荐助手、私人秘书类Agent。

局限:记忆管理策略比较固定,如果你需要高度定制化的记忆生命周期(比如按业务规则决定记忆过期时间),Mem0的灵活度可能不够。

Letta:操作系统级的记忆分层

Letta(前身是MemGPT)走了一条更"硬核"的路线,它的设计灵感来自操作系统的内存层级。

它把Agent的记忆分成三层:

层级对应OS概念容量访问速度内容
Core Memory主存很小(几百token)即时始终在context里的关键信息
Recall Memory缓存中等快速最近的对话历史
Archival Memory磁盘无限需主动检索长期归档知识

最有意思的设计:Agent自己管理这三层之间的数据流动。它通过Tool调用来决定什么时候把Core Memory里过期的信息"换出"到Archival Memory,什么时候从Archival Memory里"换入"需要的历史知识。

/**
* 模拟Letta的三层记忆管理思路(简化版)
* 场景:智能健身教练的记忆管理
*/
public class TieredMemoryManager {

// Core Memory:始终带在context里的核心信息
private Map<String, String> coreMemory = new LinkedHashMap<>();

// Recall Memory:最近N轮对话
private Deque<Message> recallMemory = new ArrayDeque<>();

// Archival Memory:向量库中的长期记忆
private final VectorStore archivalMemory;

/**
* Agent主动决定"换出"操作:把Core里不再紧急的信息归档
*/
public void evictFromCore(String key, String reason) {
String value = coreMemory.remove(key);
if (value != null) {
// 存到Archival,带上归档原因和时间戳
Document doc = new Document(value, Map.of(
"originalKey", key,
"evictReason", reason,
"archivedAt", Instant.now().toString()
));
archivalMemory.add(List.of(doc));
}
}

/**
* Agent主动决定"换入"操作:从Archival里检索需要的知识
*/
public String loadFromArchival(String query) {
List<Document> results = archivalMemory.similaritySearch(
SearchRequest.builder().query(query).topK(3).build());
return results.stream()
.map(Document::getText)
.collect(Collectors.joining("\n"));
}
}

适用场景:需要Agent具备"自主记忆管理"能力的复杂场景,比如长期运行的研究助手、项目管理Agent。

局限:对模型的工具调用能力要求很高。如果模型判断失误(该换出的没换出,不该换入的换入了),记忆管理就会出问题。

Zep(Graphiti):时间感知的记忆管理

Zep的独特卖点是时间维度。很多记忆框架只关心"内容相关性"——查询和记忆的语义是否接近。但在实际业务中,时间同样关键。

举个例子:用户三个月前说"我的目标体重是70kg",上周说"最近调整了目标,想先到75kg再慢慢减"。如果你的记忆系统没有时间感知,检索"目标体重"可能会同时返回这两条,Agent就不知道该以哪个为准了。

Zep通过时序知识图谱来解决这个问题:每条记忆都有"生效时间"和"失效标记",新事实覆盖旧事实时,旧的不是被删除,而是被标记为"已被取代"。这样既保留了历史演变的脉络,又保证检索时优先返回最新有效的信息。

适用场景:用户画像频繁变化的长周期服务(比如个人成长教练、长期客户关系管理)。

选型决策树

记忆框架选型
记忆框架选型

生产环境的记忆运维

把记忆系统搭起来不难,难的是长期跑下来不出问题。线上环境会遇到一些开发阶段不容易暴露的挑战。

记忆漂移:越用越偏的隐患

Agent运行几个月后,你可能会发现它的表现开始"漂移"——检索回来的记忆经常不太相关,或者给出的建议开始偏离用户实际需求。

根源在于记忆污染。系统运行久了,长期记忆里难免会积累一些质量不高的内容:表述模糊的摘要、基于误解产生的错误结论、已经过时但没被清理的旧信息。这些"坏记忆"一旦被检索到,就会误导Agent的判断,而Agent基于错误判断产生的新记忆又会进一步污染记忆库,形成恶性循环。

应对方案是定期做记忆健康度审计

/**
* 记忆健康度检查 —— 定期清理低质量记忆
*/
@Scheduled(cron = "0 0 3 * * SUN") // 每周日凌晨3点执行
public void memoryHealthAudit() {
List<MemoryRecord> allRecords = memoryStore.findAll();

for (MemoryRecord record : allRecords) {
double healthScore = calculateHealth(record);

if (healthScore < 0.3) {
// 低质量记忆:直接归档(不删除,以防误判)
memoryStore.archive(record.getId());
log.info("归档低质量记忆: {}, 健康分: {}", record.getId(), healthScore);
} else if (healthScore < 0.5) {
// 中等质量:标记为待审核
memoryStore.markForReview(record.getId());
}
}
}

private double calculateHealth(MemoryRecord record) {
double score = 1.0;

// 因素1:被检索命中但从未被使用的次数(说明检索相关但内容无价值)
score -= record.getRetrievedButUnusedCount() * 0.1;

// 因素2:最后被有效使用的时间距今
long daysSinceLastUse = ChronoUnit.DAYS.between(record.getLastUsedAt(), Instant.now());
if (daysSinceLastUse > 90) score -= 0.3;

// 因素3:是否存在更新版本(被后续记忆取代)
if (record.isSuperseded()) score -= 0.4;

return Math.max(0, score);
}

成本控制:向量检索不便宜

如果你的Agent用户量大、调用频繁,向量检索的成本会很可观。每次检索都要做一次Embedding调用(把查询语句转成向量),再做一次向量相似度计算。

几个实用的降本手段:

  • 查询缓存:相同或相似的查询,短时间内直接返回缓存结果
  • 记忆预加载:session开始时批量加载用户画像和高频记忆,后续轮次不重复检索
  • 分级存储:高频访问的记忆用内存向量库(如Redis Vector),冷数据用磁盘向量库(如Milvus)

隐私合规:该忘的得忘掉

GDPR、个人信息保护法等法规要求:用户有权要求删除自己的数据。你的记忆系统必须支持"精确遗忘"——能按用户ID把这个人的所有记忆彻底清除,包括向量库里的embedding、关系库里的画像字段、以及可能散落在摘要里的间接引用。

这对系统设计的要求是:所有记忆必须绑定owner标识,且有完整的数据血缘追踪。

小结

工程问题核心结论
粒度选择话题级切分(100~300 token/条)兼顾语义完整和检索精度
检索策略混合检索(向量+关键词+RRF融合)是生产标配
框架选型Mem0适合快速落地,Letta适合复杂自主管理,Zep擅长时间维度
记忆漂移定期健康度审计 + 低质量记忆归档
成本控制查询缓存 + 预加载 + 分级存储
隐私合规所有记忆绑定owner,支持精确遗忘
🎁优惠