第2104篇:AI Agent的长期记忆系统——超越对话窗口的用户理解
2026/4/30大约 9 分钟
第2104篇:AI Agent的长期记忆系统——超越对话窗口的用户理解
适读人群:构建个性化AI助手的工程师 | 阅读时长:约19分钟 | 核心价值:掌握长期记忆的分层存储架构,实现跨会话的用户画像积累和个性化能力
对话窗口关闭,所有记忆消失。这是现有LLM应用最大的局限之一。
对于一个真正有用的AI助手,用户不应该每次都从头介绍自己的背景。上次聊到的问题、用户表达过的偏好、已经解决的bug——这些都应该被记住,下次直接用上。
但长期记忆不是简单地把所有对话历史存起来。存储容易,检索和利用才是难点。这篇文章设计一套实用的长期记忆系统。
记忆的分类
/**
* 长期记忆的四种类型
*
* 类比人类记忆模型:
*
* 1. 情节记忆(Episodic Memory)
* 具体发生的事件,有时间戳
* 例:2024年1月15日用户遇到了OOM问题,我们一起排查是HeapDump泄漏
*
* 2. 语义记忆(Semantic Memory)
* 关于用户的稳定知识和事实
* 例:用户是Java后端工程师,擅长Spring Boot,5年经验
*
* 3. 程序记忆(Procedural Memory)
* 用户的偏好和工作方式
* 例:用户偏好先看代码再看解释,不喜欢太长的回复
*
* 4. 工作记忆(Working Memory)
* 当前会话的临时状态(超过会话后可以消亡)
* 例:正在分析的代码文件名,当前对话的上下文
*
* 系统需要管理前三种的长期持久化
*/记忆存储架构
/**
* 记忆条目数据模型
*/
@Data
@Builder
@Entity
@Table(name = "memory_entries")
public class MemoryEntry {
@Id
private String memoryId;
private String userId;
@Enumerated(EnumType.STRING)
private MemoryType type;
@Column(columnDefinition = "TEXT")
private String content; // 记忆内容(自然语言)
private String category; // 分类标签(tech_preference/problem_solved/user_profile等)
@Column(name = "embedding_vector", columnDefinition = "vector(1024)")
private float[] embeddingVector; // 向量化,用于相关性检索
private double importance; // 重要性分数(0-1),影响遗忘策略
private int accessCount; // 被访问次数(越常用越不会被遗忘)
private LocalDateTime lastAccessedAt;
private LocalDateTime createdAt;
private LocalDateTime expiresAt; // 可选的过期时间
// 来源信息(这条记忆从哪次对话来)
private String sourceSessionId;
private String sourceConversationId;
public enum MemoryType {
EPISODIC, // 情节记忆:发生了什么事
SEMANTIC, // 语义记忆:用户是什么人
PROCEDURAL // 程序记忆:用户的偏好
}
}记忆提取服务
/**
* 从对话中提取记忆
*
* 不是把对话全存下来,而是提取有价值的记忆片段
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MemoryExtractionService {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
/**
* 对话结束后,从对话中提取记忆
*
* 什么值得记忆?
* - 用户表达的偏好和喜好
* - 用户的背景信息
* - 重要的问题和解决方案
* - 用户的情绪和反馈
*
* 什么不值得记忆?
* - 简单的寒暄
* - 临时性的问题(今天天气如何)
* - 已经解决的一次性技术问题(除非很典型)
*/
public List<MemoryEntry> extractFromConversation(
String userId, String sessionId, List<ChatMessage> conversation) {
if (conversation.size() < 4) {
// 太短的对话通常没有值得提取的记忆
return List.of();
}
String conversationText = formatConversation(conversation);
String prompt = """
请从以下对话中提取值得长期记忆的信息。
对话内容:
%s
提取规则:
1. 只提取对理解用户长期有价值的信息
2. 临时性内容不要提取
3. 每条记忆要完整、可独立理解
请返回JSON:
{
"memories": [
{
"type": "SEMANTIC/EPISODIC/PROCEDURAL",
"category": "分类标签",
"content": "记忆内容(完整的陈述句)",
"importance": 0.0-1.0
}
]
}
type说明:
- SEMANTIC:关于用户的事实(职业、技能、背景)
- EPISODIC:发生的具体事件(解决了XX问题,遇到了XX错误)
- PROCEDURAL:用户偏好(喜欢什么风格、工作习惯)
如果对话没有值得记忆的内容,返回 {"memories": []}
只返回JSON。
""".formatted(conversationText);
try {
String response = llm.generate(prompt);
return parseMemories(response, userId, sessionId);
} catch (Exception e) {
log.error("记忆提取失败: userId={}", userId, e);
return List.of();
}
}
private List<MemoryEntry> parseMemories(String response, String userId, String sessionId) {
try {
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
List<MemoryEntry> memories = new ArrayList<>();
for (JsonNode m : root.path("memories")) {
String content = m.path("content").asText("").trim();
if (content.isEmpty()) continue;
// 向量化记忆内容
float[] embedding;
try {
embedding = embeddingModel.embed(content).content().vector();
} catch (Exception e) {
embedding = new float[0];
}
MemoryEntry entry = MemoryEntry.builder()
.memoryId("mem-" + System.currentTimeMillis() + "-" +
(int)(Math.random() * 1000))
.userId(userId)
.type(parseMemoryType(m.path("type").asText()))
.category(m.path("category").asText("general"))
.content(content)
.embeddingVector(embedding)
.importance(m.path("importance").asDouble(0.5))
.accessCount(0)
.createdAt(LocalDateTime.now())
.lastAccessedAt(LocalDateTime.now())
.sourceSessionId(sessionId)
.build();
memories.add(entry);
}
log.debug("提取记忆: userId={}, count={}", userId, memories.size());
return memories;
} catch (Exception e) {
log.warn("记忆解析失败: {}", e.getMessage());
return List.of();
}
}
private MemoryEntry.MemoryType parseMemoryType(String type) {
try {
return MemoryEntry.MemoryType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return MemoryEntry.MemoryType.SEMANTIC;
}
}
private String formatConversation(List<ChatMessage> messages) {
return messages.stream()
.map(m -> (m instanceof UserMessage ? "用户" : "助手") + ":" + m.text())
.collect(Collectors.joining("\n"));
}
private String extractJson(String s) {
int start = s.indexOf('{');
int end = s.lastIndexOf('}');
return (start >= 0 && end > start) ? s.substring(start, end + 1) : s;
}
}记忆检索服务
/**
* 记忆检索:在新的对话中找到相关记忆
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MemoryRetrievalService {
private final MemoryEntryRepository memoryRepo;
private final EmbeddingModel embeddingModel;
/**
* 检索和当前问题相关的记忆
*/
public List<MemoryEntry> retrieveRelevant(
String userId, String currentQuery, int topK) {
// 1. 向量检索(语义相关)
float[] queryVector = embeddingModel.embed(currentQuery).content().vector();
List<MemoryEntry> semanticMatches = memoryRepo.findByVectorSimilarity(
userId, queryVector, topK * 2, 0.65f);
// 2. 总是带上用户的基础语义记忆(用户画像)
List<MemoryEntry> profileMemories = memoryRepo.findByUserIdAndType(
userId, MemoryEntry.MemoryType.SEMANTIC, 5);
// 3. 合并去重,按重要性和相关性排序
Map<String, MemoryEntry> merged = new LinkedHashMap<>();
// 先加语义检索结果(按相关性排序)
semanticMatches.forEach(m -> merged.put(m.getMemoryId(), m));
// 加用户基础画像(如果不在已有结果里)
profileMemories.stream()
.filter(m -> !merged.containsKey(m.getMemoryId()))
.forEach(m -> merged.put(m.getMemoryId(), m));
List<MemoryEntry> result = new ArrayList<>(merged.values()).subList(
0, Math.min(topK, merged.size()));
// 更新访问计数(用于遗忘机制)
result.forEach(m -> {
m.setAccessCount(m.getAccessCount() + 1);
m.setLastAccessedAt(LocalDateTime.now());
});
memoryRepo.saveAll(result);
return result;
}
/**
* 构建记忆上下文(注入到System Prompt)
*/
public String buildMemoryContext(String userId, String currentQuery) {
List<MemoryEntry> relevantMemories = retrieveRelevant(userId, currentQuery, 8);
if (relevantMemories.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("=== 关于当前用户的已知信息 ===\n");
// 按类型分组展示
Map<MemoryEntry.MemoryType, List<MemoryEntry>> byType = relevantMemories.stream()
.collect(Collectors.groupingBy(MemoryEntry::getType));
if (byType.containsKey(MemoryEntry.MemoryType.SEMANTIC)) {
sb.append("\n用户信息:\n");
byType.get(MemoryEntry.MemoryType.SEMANTIC)
.forEach(m -> sb.append("- ").append(m.getContent()).append("\n"));
}
if (byType.containsKey(MemoryEntry.MemoryType.PROCEDURAL)) {
sb.append("\n用户偏好:\n");
byType.get(MemoryEntry.MemoryType.PROCEDURAL)
.forEach(m -> sb.append("- ").append(m.getContent()).append("\n"));
}
if (byType.containsKey(MemoryEntry.MemoryType.EPISODIC)) {
sb.append("\n相关历史事件:\n");
byType.get(MemoryEntry.MemoryType.EPISODIC)
.forEach(m -> sb.append("- ").append(m.getContent()).append("\n"));
}
sb.append("\n请在回答时参考以上信息,但不要刻意提及"我知道你...",自然地用上即可。");
return sb.toString();
}
}记忆更新和遗忘机制
/**
* 记忆维护服务
*
* 定期更新和清理过时的记忆,防止记忆库无限膨胀
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MemoryMaintenanceService {
private final MemoryEntryRepository memoryRepo;
private final ChatLanguageModel llm;
/**
* 合并重复或冲突的记忆
*
* 问题:随着时间推移,可能积累很多相似的记忆
* 例:
* - "用户是Java工程师"(2024年1月)
* - "用户主要用Java开发"(2024年3月)
* - "用户是Java后端工程师,负责微服务架构"(2024年6月)
*
* 三条都是有效的,但应该合并成一条更完整的
*/
@Scheduled(cron = "0 0 3 * * 0") // 每周日凌晨3点
public void consolidateMemories() {
List<String> userIds = memoryRepo.findDistinctUserIds();
for (String userId : userIds) {
try {
consolidateForUser(userId);
} catch (Exception e) {
log.error("用户记忆整合失败: userId={}", userId, e);
}
}
}
private void consolidateForUser(String userId) {
// 找到同一用户下的语义记忆,按类别分组
List<MemoryEntry> semanticMemories = memoryRepo.findByUserIdAndType(
userId, MemoryEntry.MemoryType.SEMANTIC, 100);
if (semanticMemories.size() < 3) return;
// 让LLM判断是否有重复/冲突的记忆,并合并
String memoriesText = semanticMemories.stream()
.map(m -> "- " + m.getContent())
.collect(Collectors.joining("\n"));
String prompt = """
以下是关于同一用户的记忆条目,请检查是否有重复或需要合并的条目。
%s
请返回JSON:
{
"toDelete": ["需要删除的重复条目原文"],
"toAdd": ["新的合并条目"]
}
只处理明确重复的内容,不确定的保留。返回JSON。
""".formatted(memoriesText);
try {
String response = llm.generate(prompt);
applyConsolidation(userId, semanticMemories, response);
} catch (Exception e) {
log.warn("记忆整合LLM调用失败: userId={}", userId, e);
}
}
/**
* 遗忘机制:删除不重要且长期未访问的记忆
*
* 遗忘公式:
* forgetting_score = (days_since_access / 90) * (1 - importance) * (1 / (access_count + 1))
* 当 forgetting_score > threshold 时,标记为待删除
*/
@Scheduled(cron = "0 30 2 * * *") // 每天凌晨2:30
public void applyForgettingCurve() {
LocalDateTime threshold90Days = LocalDateTime.now().minusDays(90);
// 找到超过90天未访问且低重要性的记忆
List<MemoryEntry> candidates = memoryRepo
.findForgettingCandidates(threshold90Days, 0.4);
int deleted = 0;
for (MemoryEntry memory : candidates) {
long daysSinceAccess = java.time.temporal.ChronoUnit.DAYS.between(
memory.getLastAccessedAt(), LocalDateTime.now());
double forgettingScore =
(daysSinceAccess / 90.0) *
(1 - memory.getImportance()) *
(1.0 / (memory.getAccessCount() + 1));
if (forgettingScore > 0.8) {
memoryRepo.delete(memory);
deleted++;
}
}
if (deleted > 0) {
log.info("遗忘机制清理记忆: count={}", deleted);
}
}
private void applyConsolidation(
String userId, List<MemoryEntry> existing, String llmResponse) {
// 解析LLM建议并应用
// 简化实现
}
}与对话系统集成
/**
* 带长期记忆的对话服务
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MemoryAwareConversationService {
private final ChatLanguageModel llm;
private final MemoryRetrievalService memoryService;
private final MemoryExtractionService extractionService;
private final MemoryEntryRepository memoryRepo;
// 短期对话历史(当前会话内)
private final Map<String, List<ChatMessage>> sessionHistories = new ConcurrentHashMap<>();
/**
* 带记忆的对话
*/
public String chat(String userId, String sessionId, String userMessage) {
// 1. 获取或创建会话历史
List<ChatMessage> history = sessionHistories.computeIfAbsent(
sessionId, k -> new ArrayList<>());
// 2. 检索相关长期记忆
String memoryContext = memoryService.buildMemoryContext(userId, userMessage);
// 3. 构建System Prompt(融入记忆)
String systemPrompt = buildSystemPrompt(memoryContext);
// 4. 构建消息列表
List<ChatMessage> messages = new ArrayList<>();
messages.add(SystemMessage.from(systemPrompt));
messages.addAll(getRecentHistory(history, 6)); // 最近3轮
messages.add(UserMessage.from(userMessage));
// 5. LLM生成回复
String response = llm.generate(messages).content().text();
// 6. 更新对话历史
history.add(UserMessage.from(userMessage));
history.add(AiMessage.from(response));
// 7. 如果对话达到一定长度,触发记忆提取(异步)
if (history.size() >= 10) {
List<ChatMessage> recentForExtraction =
history.subList(Math.max(0, history.size() - 10), history.size());
final String finalUserId = userId;
final String finalSessionId = sessionId;
final List<ChatMessage> snapshot = List.copyOf(recentForExtraction);
CompletableFuture.runAsync(() -> {
List<MemoryEntry> newMemories = extractionService.extractFromConversation(
finalUserId, finalSessionId, snapshot);
if (!newMemories.isEmpty()) {
memoryRepo.saveAll(newMemories);
log.info("提取新记忆: userId={}, count={}", finalUserId, newMemories.size());
}
});
}
return response;
}
private String buildSystemPrompt(String memoryContext) {
String basePrompt = """
你是一个智能助手,擅长回答各类问题并提供帮助。
%s
""";
return basePrompt.formatted(
memoryContext.isEmpty() ? "" : memoryContext
).trim();
}
private List<ChatMessage> getRecentHistory(List<ChatMessage> history, int maxMessages) {
if (history.size() <= maxMessages) return history;
return history.subList(history.size() - maxMessages, history.size());
}
}实践建议
记忆质量比记忆数量更重要
刚开始做长期记忆时,我们的策略是"尽量多记"。结果发现记忆库很快积累了大量低质量条目(比如"用户今天问了一个关于Java的问题"),这类条目既占存储,又干扰检索相关记忆的精度。后来改成严格过滤,只提取真正有长期价值的信息,记忆库小了很多,但效果反而更好。
用户需要能控制自己的记忆
隐私是长期记忆系统的核心问题。用户应该能查看AI记住了什么关于自己,也应该能删除不想被记住的内容。没有透明度和控制权的记忆系统,用户会感觉"被监视",这会破坏信任。
循序渐进地引入记忆
不要第一次见到用户就开始分析和存储记忆,用户会觉得突兀。更好的做法是在几次对话后,当系统确实有值得记住的信息时,才开始构建记忆。自然一点,就像人类朋友一样,不是第一次见面就把你的所有信息记在小本本上。
