Agent记忆系统设计:让AI真正记住用户、记住历史
Agent记忆系统设计:让AI真正记住用户、记住历史
和AI助手相处了三个月,它还不知道我的名字
这是小张和我说的一件真实的事,听完我沉默了好几秒。
他从去年9月开始用一款AI编程助手,每天都用,用得很勤快。有一天他突发奇想,问了一句:"你还记得我第一次找你帮忙是什么问题吗?"
AI助手回答:"对不起,我没有记忆跨会话信息的能力。每次对话结束后,我就忘记了之前的所有内容。"
小张愣了一下,然后把这个问题转给了我。
"老张,我用了三个月,它不知道我叫什么,不知道我在做什么项目,不知道我喜欢用什么框架。每次开新对话,我都要重新介绍自己:我是做Java的,我在用Spring Boot 3.x,我的项目是一个电商平台……重复了三个月,真的很累。"
他说完之后,我问了他一个问题:"你能接受你的同事每天都忘记你是谁吗?"
他说:"当然不行。"
"那为什么能接受AI每天忘记你?"
沉默。
这就是AI助手体验的最大漏洞——没有记忆系统。有了工具调用,AI能做事;有了记忆,AI才算认识你。
这篇文章,就来聊聊怎么给你的Java Agent装上一个真正能用的记忆系统。
先说结论(TL;DR)
- 记忆系统分四层:感知缓冲(秒级)→ 短期记忆(会话内)→ 长期记忆(跨会话)→ 程序记忆(技能)
- 短期记忆:滑动窗口管理对话上下文,超长时压缩摘要
- 长期记忆:提取关键信息 → 向量化 → 存入向量数据库 → 对话时检索注入
- 用户偏好记忆:结构化存储,直接注入系统提示
- 遗忘机制:基于访问频率和时间衰减,避免记忆无限膨胀
- 隐私保护:用户记忆数据加密存储,支持用户查看和删除
记忆系统分层:从秒级到永久
人类记忆系统的类比
四层记忆的Java实现对应关系
| 记忆层 | 生命周期 | 存储方式 | Java实现 |
|---|---|---|---|
| 感知缓冲 | 当前请求 | 方法参数 | UserMessage |
| 短期记忆 | 当前会话 | 内存/Redis | List<Message> + 滑动窗口 |
| 长期情景 | 跨会话 | 向量数据库 | VectorStore |
| 用户语义 | 永久 | 关系数据库 | UserProfile |
| 程序记忆 | 永久 | 关系数据库 | UserPreferences |
依赖与配置
<!-- pom.xml -->
<dependencies>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring AI Vector Store - PGVector -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis(短期记忆存储) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Session Redis(会话管理) -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- AES加密(记忆隐私保护) -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.77</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies># application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.7
embedding:
options:
model: text-embedding-3-small
datasource:
url: jdbc:postgresql://localhost:5432/memory_agent
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: update
show-sql: false
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD:}
ai:
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536 # text-embedding-3-small的维度
memory:
short-term:
max-messages: 20 # 短期记忆最大消息数
summary-threshold: 15 # 超过此数量触发摘要压缩
ttl-seconds: 3600 # 会话超时(1小时)
long-term:
top-k: 5 # 每次检索最相关的K条记忆
min-similarity: 0.75 # 最低相似度阈值
max-memories-per-user: 1000 # 每用户最大记忆条数
forgetting:
enabled: true
decay-days: 90 # 90天未访问的记忆开始衰减
min-importance: 0.3 # 低于此重要度的记忆会被清理
privacy:
encryption-enabled: true
allow-user-deletion: true短期记忆:对话上下文窗口管理
// ShortTermMemory.java
package com.laozhang.memory.shortterm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.*;
@Slf4j
@Component
@RequiredArgsConstructor
public class ShortTermMemory {
private final RedisTemplate<String, Object> redisTemplate;
private final ConversationSummarizer summarizer;
@Value("${memory.short-term.max-messages:20}")
private int maxMessages;
@Value("${memory.short-term.summary-threshold:15}")
private int summaryThreshold;
@Value("${memory.short-term.ttl-seconds:3600}")
private int ttlSeconds;
private static final String KEY_PREFIX = "stm:session:";
/**
* 获取当前会话的对话历史
*/
@SuppressWarnings("unchecked")
public List<ConversationTurn> getHistory(String sessionId) {
String key = KEY_PREFIX + sessionId;
List<Object> raw = redisTemplate.opsForList().range(key, 0, -1);
if (raw == null) return new ArrayList<>();
return raw.stream()
.map(obj -> (ConversationTurn) obj)
.toList();
}
/**
* 添加新的对话轮次
*/
public void addTurn(String sessionId, String userMessage, String assistantResponse) {
String key = KEY_PREFIX + sessionId;
ConversationTurn turn = ConversationTurn.builder()
.userMessage(userMessage)
.assistantResponse(assistantResponse)
.timestamp(System.currentTimeMillis())
.build();
redisTemplate.opsForList().rightPush(key, turn);
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
// 检查是否需要压缩
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > summaryThreshold) {
compressHistory(sessionId, key, size.intValue());
}
log.debug("短期记忆更新: sessionId={}, 当前条数={}", sessionId, size);
}
/**
* 将对话历史转换为Spring AI的Message列表
*/
public List<Message> toMessages(String sessionId) {
List<ConversationTurn> history = getHistory(sessionId);
List<Message> messages = new ArrayList<>();
for (ConversationTurn turn : history) {
if (turn.isSummary()) {
// 摘要消息用特殊标记注入
messages.add(new UserMessage("[历史摘要] " + turn.getUserMessage()));
} else {
messages.add(new UserMessage(turn.getUserMessage()));
messages.add(new AssistantMessage(turn.getAssistantResponse()));
}
}
return messages;
}
/**
* 压缩历史:将旧的对话压缩为摘要,保留最近几轮
*/
private void compressHistory(String sessionId, String key, int currentSize) {
log.info("触发历史压缩: sessionId={}, 当前条数={}", sessionId, currentSize);
// 获取需要压缩的旧消息(保留最近5轮)
int keepLast = 5;
List<Object> allMessages = redisTemplate.opsForList().range(key, 0, -1);
if (allMessages == null || allMessages.size() <= keepLast) return;
List<ConversationTurn> toCompress = allMessages.subList(0, allMessages.size() - keepLast)
.stream()
.map(o -> (ConversationTurn) o)
.toList();
// 生成摘要
String summary = summarizer.summarize(toCompress);
// 重建历史:摘要 + 最近5轮
redisTemplate.delete(key);
// 插入摘要
ConversationTurn summaryTurn = ConversationTurn.builder()
.userMessage(summary)
.assistantResponse("")
.summary(true)
.timestamp(System.currentTimeMillis())
.build();
redisTemplate.opsForList().rightPush(key, summaryTurn);
// 插入最近5轮
List<Object> recentMessages = allMessages.subList(allMessages.size() - keepLast, allMessages.size());
for (Object msg : recentMessages) {
redisTemplate.opsForList().rightPush(key, msg);
}
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
log.info("历史压缩完成: {} 轮对话 → 1条摘要 + {} 轮最近对话", toCompress.size(), keepLast);
}
/**
* 清除会话历史(用户主动结束会话时)
*/
public void clear(String sessionId) {
redisTemplate.delete(KEY_PREFIX + sessionId);
log.debug("短期记忆已清除: sessionId={}", sessionId);
}
}// ConversationSummarizer.java - 对话摘要生成器
@Component
@RequiredArgsConstructor
public class ConversationSummarizer {
private final ChatClient chatClient;
public String summarize(List<ConversationTurn> turns) {
StringBuilder history = new StringBuilder();
for (ConversationTurn turn : turns) {
history.append("用户: ").append(turn.getUserMessage()).append("\n");
if (!turn.isSummary()) {
history.append("AI: ").append(turn.getAssistantResponse()).append("\n");
}
history.append("---\n");
}
return chatClient.prompt()
.system("""
请将以下对话历史压缩成一段简洁的摘要(不超过300字)。
摘要要保留:
1. 用户提到的关键信息(姓名、项目、技术栈等)
2. 重要的问题和解决方案
3. 用户明确表示的偏好和要求
不需要保留对话的具体措辞,只保留要点。
""")
.user("请摘要以下对话:\n" + history)
.call()
.content();
}
}// ConversationTurn.java
@Data
@Builder
public class ConversationTurn implements Serializable {
private String userMessage;
private String assistantResponse;
private long timestamp;
private boolean summary; // 是否是压缩摘要
}长期记忆实现:提取→向量化→存储→检索
整体流程图
记忆提取器
// MemoryExtractor.java
package com.laozhang.memory.longterm;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class MemoryExtractor {
private final ChatClient chatClient;
private final LongTermMemoryStore memoryStore;
private final ObjectMapper objectMapper;
/**
* 异步从对话中提取关键记忆(不阻塞主流程)
*/
@Async("memoryExtractorPool")
public void extractAndStore(String userId, String userMessage, String assistantResponse) {
log.debug("开始提取记忆: userId={}", userId);
try {
String extractionPrompt = String.format("""
请从以下对话中提取值得长期记忆的信息。
用户消息:%s
AI回复:%s
提取规则:
1. 只提取对用户有长期价值的信息(身份信息、技术偏好、项目背景等)
2. 忽略一次性的、情境性的信息
3. 每条记忆要简洁、独立、可复用
返回JSON格式:
{
"memories": [
{
"content": "记忆内容",
"type": "IDENTITY|PREFERENCE|PROJECT|SKILL|CONSTRAINT",
"importance": 0.0-1.0,
"keywords": ["关键词1", "关键词2"]
}
]
}
如果没有值得记忆的信息,返回:{"memories": []}
""", userMessage, assistantResponse);
String response = chatClient.prompt()
.user(extractionPrompt)
.call()
.content();
// 解析提取的记忆
String json = extractJson(response);
Map<String, Object> result = objectMapper.readValue(json, Map.class);
List<Map<String, Object>> memories = (List<Map<String, Object>>) result.get("memories");
if (memories != null && !memories.isEmpty()) {
for (Map<String, Object> memory : memories) {
MemoryEntry entry = MemoryEntry.builder()
.userId(userId)
.content((String) memory.get("content"))
.type(MemoryEntry.MemoryType.valueOf((String) memory.get("type")))
.importance(((Number) memory.get("importance")).doubleValue())
.keywords((List<String>) memory.get("keywords"))
.build();
memoryStore.store(entry);
log.debug("存储新记忆: {} - {}", entry.getType(), entry.getContent());
}
log.info("提取并存储了 {} 条新记忆: userId={}", memories.size(), userId);
}
} catch (Exception e) {
log.error("记忆提取失败: userId={}", userId, e);
// 记忆提取失败不影响主流程
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
return "{\"memories\":[]}";
}
}记忆存储:向量化存储
// LongTermMemoryStore.java
package com.laozhang.memory.longterm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
@Slf4j
@Component
@RequiredArgsConstructor
public class LongTermMemoryStore {
private final VectorStore vectorStore;
@Value("${memory.long-term.top-k:5}")
private int topK;
@Value("${memory.long-term.min-similarity:0.75}")
private double minSimilarity;
/**
* 存储记忆到向量数据库
*/
public void store(MemoryEntry entry) {
// 构建存储文档
Document doc = new Document(
entry.getContent(),
Map.of(
"userId", entry.getUserId(),
"type", entry.getType().name(),
"importance", entry.getImportance(),
"createdAt", System.currentTimeMillis(),
"accessCount", 0,
"lastAccessedAt", System.currentTimeMillis()
)
);
vectorStore.add(List.of(doc));
log.debug("记忆已向量化存储: {}", entry.getContent());
}
/**
* 检索与当前对话相关的记忆
*/
public List<MemoryEntry> retrieve(String userId, String query) {
// 构建带用户过滤的搜索请求
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(minSimilarity)
.filterExpression("userId == '" + userId + "'")
.build();
List<Document> results = vectorStore.similaritySearch(request);
return results.stream()
.map(doc -> MemoryEntry.builder()
.userId(userId)
.content(doc.getText())
.type(MemoryEntry.MemoryType.valueOf(
(String) doc.getMetadata().getOrDefault("type", "SEMANTIC")))
.importance(((Number) doc.getMetadata().getOrDefault("importance", 0.5)).doubleValue())
.build())
.toList();
}
/**
* 格式化记忆用于注入系统提示
*/
public String formatForInjection(List<MemoryEntry> memories) {
if (memories.isEmpty()) return "";
StringBuilder sb = new StringBuilder("## 关于这位用户,我记住了:\n");
for (MemoryEntry memory : memories) {
sb.append("- [").append(memory.getType().getLabel()).append("] ");
sb.append(memory.getContent()).append("\n");
}
sb.append("\n");
return sb.toString();
}
}记忆条目模型
// MemoryEntry.java
package com.laozhang.memory.longterm;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class MemoryEntry {
public enum MemoryType {
IDENTITY("身份信息"), // 姓名、职位、公司
PREFERENCE("偏好设置"), // 喜欢的框架、代码风格
PROJECT("项目信息"), // 正在做的项目
SKILL("技能水平"), // 技术能力评估
CONSTRAINT("约束条件"), // 不能用某技术、特殊限制
GOAL("学习目标"), // 想要达成的目标
HISTORY("历史事件"); // 重要的历史事件
private final String label;
MemoryType(String label) { this.label = label; }
public String getLabel() { return label; }
}
private String userId;
private String content;
private MemoryType type;
private double importance; // 0.0-1.0
private List<String> keywords;
private long createdAt;
private long lastAccessedAt;
private int accessCount;
}用户偏好记忆:记住用户习惯
// UserPreferenceMemory.java
package com.laozhang.memory.preference;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "user_preferences")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserPreference {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(nullable = false)
private String userId;
// 技术偏好
private String preferredLanguage; // Java, Kotlin
private String preferredFramework; // Spring Boot版本
private String preferredDatabase; // MySQL, PostgreSQL
private String codeStyle; // Google Style, Alibaba规范
// 回复偏好
private String responseLanguage; // zh-CN, en-US
private String responseVerbosity; // BRIEF, NORMAL, DETAILED
private Boolean includeCodeComments; // 代码是否要注释
private Boolean preferChineseVarNames; // 是否用中文变量名
// 项目上下文
private String currentProject; // 当前项目名称
private String projectDescription; // 项目描述
private String techStack; // 技术栈JSON
// 学习偏好
private String expertiseLevel; // BEGINNER, INTERMEDIATE, ADVANCED
private String learningStyle; // 喜欢示例代码、喜欢原理解析
@Column(updatable = false)
private long createdAt;
private long updatedAt;
}// UserPreferenceService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class UserPreferenceService {
private final UserPreferenceRepository preferenceRepository;
private final ChatClient chatClient;
/**
* 获取用户偏好,格式化为系统提示
*/
public String getPreferencePrompt(String userId) {
Optional<UserPreference> pref = preferenceRepository.findByUserId(userId);
if (pref.isEmpty()) return "";
UserPreference p = pref.get();
StringBuilder prompt = new StringBuilder("## 用户偏好设置:\n");
if (p.getPreferredFramework() != null)
prompt.append("- 技术框架:").append(p.getPreferredFramework()).append("\n");
if (p.getCodeStyle() != null)
prompt.append("- 代码规范:").append(p.getCodeStyle()).append("\n");
if (p.getResponseVerbosity() != null)
prompt.append("- 回复详细程度:").append(p.getResponseVerbosity()).append("\n");
if (p.getExpertiseLevel() != null)
prompt.append("- 技术水平:").append(p.getExpertiseLevel()).append("\n");
if (p.getCurrentProject() != null)
prompt.append("- 当前项目:").append(p.getCurrentProject()).append("\n");
if (Boolean.TRUE.equals(p.getIncludeCodeComments()))
prompt.append("- 偏好:代码示例要包含详细注释\n");
return prompt.toString();
}
/**
* 从对话中自动更新用户偏好
*/
@Async
public void updateFromConversation(String userId, String userMessage) {
// 检测用户是否提及偏好
if (!containsPreferenceSignal(userMessage)) return;
String extractionPrompt = String.format("""
从以下用户消息中提取用户偏好设置,以JSON格式返回。
如果消息中没有明确的偏好信息,返回 {}。
用户消息:%s
可提取的偏好字段(只返回消息中明确提到的):
preferredFramework(Spring Boot版本/框架)、codeStyle(代码规范)、
preferredDatabase(数据库)、responseVerbosity(回复详细程度)、
currentProject(当前项目)
示例输出:{"preferredFramework": "Spring Boot 3.2", "currentProject": "电商平台"}
""", userMessage);
try {
String response = chatClient.prompt()
.user(extractionPrompt)
.call()
.content();
String json = extractJson(response);
if (!json.equals("{}")) {
updatePreferences(userId, json);
}
} catch (Exception e) {
log.error("偏好提取失败", e);
}
}
private boolean containsPreferenceSignal(String message) {
String lower = message.toLowerCase();
return lower.contains("我在用") || lower.contains("我喜欢") ||
lower.contains("我的项目") || lower.contains("我们团队") ||
lower.contains("spring boot") || lower.contains("框架");
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return (start >= 0 && end > start) ? text.substring(start, end + 1) : "{}";
}
private void updatePreferences(String userId, String jsonUpdates) {
// 实际实现:解析JSON并更新对应字段
log.info("更新用户偏好: userId={}, updates={}", userId, jsonUpdates);
}
}任务记忆:跨会话的任务进度保存
// TaskMemory.java - 跨会话任务追踪
package com.laozhang.memory.task;
import jakarta.persistence.*;
import lombok.*;
import java.util.List;
@Entity
@Table(name = "task_memories")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TaskMemory {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
private String userId;
private String taskTitle;
private String taskDescription;
@Enumerated(EnumType.STRING)
private TaskStatus status;
@Column(columnDefinition = "TEXT")
private String progressNotes; // 已完成的步骤笔记
@Column(columnDefinition = "TEXT")
private String nextSteps; // 下次继续时的待办事项
private long createdAt;
private long updatedAt;
private long lastAccessedAt;
public enum TaskStatus {
IN_PROGRESS, PAUSED, COMPLETED, ABANDONED
}
}// TaskMemoryService.java
@Service
@RequiredArgsConstructor
public class TaskMemoryService {
private final TaskMemoryRepository taskRepository;
/**
* 检测用户消息是否在继续一个之前的任务
*/
public Optional<TaskMemory> findRelevantTask(String userId, String userMessage) {
List<TaskMemory> activeTasks = taskRepository
.findByUserIdAndStatus(userId, TaskMemory.TaskStatus.IN_PROGRESS);
// 简单关键词匹配(实际可用向量相似度)
return activeTasks.stream()
.filter(task -> userMessage.contains(task.getTaskTitle()) ||
isRelated(userMessage, task.getTaskDescription()))
.findFirst();
}
/**
* 格式化任务记忆注入系统提示
*/
public String formatTaskContext(TaskMemory task) {
return String.format("""
## 未完成的任务(上次对话进度):
- **任务**:%s
- **已完成**:%s
- **下一步**:%s
""",
task.getTaskTitle(),
task.getProgressNotes() != null ? task.getProgressNotes() : "初始阶段",
task.getNextSteps() != null ? task.getNextSteps() : "继续上次讨论"
);
}
private boolean isRelated(String message, String taskDescription) {
if (taskDescription == null) return false;
// 简单实现:检查关键词重叠
String[] taskWords = taskDescription.split("\\s+");
for (String word : taskWords) {
if (word.length() > 2 && message.contains(word)) return true;
}
return false;
}
}记忆的遗忘机制:旧记忆的衰减与清理
// MemoryForgettingService.java
package com.laozhang.memory.forgetting;
import com.laozhang.memory.preference.UserPreferenceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class MemoryForgettingService {
private final MemoryMetaRepository memoryMetaRepository;
@Value("${memory.forgetting.decay-days:90}")
private int decayDays;
@Value("${memory.forgetting.min-importance:0.3}")
private double minImportance;
/**
* 每天凌晨2点执行记忆衰减
*/
@Scheduled(cron = "0 0 2 * * ?")
public void runForgettingCycle() {
log.info("开始记忆衰减清理...");
long cutoffTime = System.currentTimeMillis() - (long) decayDays * 24 * 3600 * 1000;
// 查找长时间未访问且重要度低的记忆
int deletedCount = memoryMetaRepository.deleteByLastAccessedAtBeforeAndImportanceLessThan(
cutoffTime, minImportance
);
log.info("记忆衰减完成:删除了 {} 条过期低重要度记忆", deletedCount);
}
/**
* 计算记忆的当前有效重要度(随时间衰减)
*/
public double calculateEffectiveImportance(double baseImportance, long createdAt, long lastAccessedAt) {
long daysSinceCreation = (System.currentTimeMillis() - createdAt) / (24 * 3600 * 1000);
long daysSinceAccess = (System.currentTimeMillis() - lastAccessedAt) / (24 * 3600 * 1000);
// 时间衰减因子(指数衰减)
double ageFactor = Math.exp(-0.01 * daysSinceCreation);
// 访问频率奖励(经常被访问的记忆不衰减)
double accessBonus = daysSinceAccess < 7 ? 1.2 : (daysSinceAccess < 30 ? 1.0 : 0.8);
return Math.min(1.0, baseImportance * ageFactor * accessBonus);
}
}记忆检索:何时、如何把记忆注入对话
// MemoryInjector.java - 核心:决定何时注入什么记忆
package com.laozhang.memory;
import com.laozhang.memory.longterm.LongTermMemoryStore;
import com.laozhang.memory.longterm.MemoryEntry;
import com.laozhang.memory.preference.UserPreferenceService;
import com.laozhang.memory.task.TaskMemory;
import com.laozhang.memory.task.TaskMemoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Slf4j
@Component
@RequiredArgsConstructor
public class MemoryInjector {
private final LongTermMemoryStore longTermStore;
private final UserPreferenceService preferenceService;
private final TaskMemoryService taskMemoryService;
/**
* 为当前对话构建完整的记忆上下文系统提示
*/
public String buildMemoryContext(String userId, String currentQuery) {
StringBuilder context = new StringBuilder();
// 1. 注入用户偏好(每次必须注入)
String preferenceContext = preferenceService.getPreferencePrompt(userId);
if (!preferenceContext.isEmpty()) {
context.append(preferenceContext).append("\n");
}
// 2. 检索相关的长期记忆(语义相关)
List<MemoryEntry> relevantMemories = longTermStore.retrieve(userId, currentQuery);
if (!relevantMemories.isEmpty()) {
context.append(longTermStore.formatForInjection(relevantMemories));
}
// 3. 检查是否有未完成的相关任务
Optional<TaskMemory> relatedTask = taskMemoryService.findRelevantTask(userId, currentQuery);
relatedTask.ifPresent(task ->
context.append(taskMemoryService.formatTaskContext(task))
);
if (!context.isEmpty()) {
log.debug("记忆注入: userId={}, 偏好={}, 长期记忆={}条, 任务={}",
userId,
!preferenceContext.isEmpty(),
relevantMemories.size(),
relatedTask.isPresent());
}
return context.toString();
}
}实战:个性化学习助手的完整记忆系统
完整Agent实现
// PersonalizedLearningAgent.java
package com.laozhang.memory.agent;
import com.laozhang.memory.MemoryInjector;
import com.laozhang.memory.extractor.MemoryExtractor;
import com.laozhang.memory.preference.UserPreferenceService;
import com.laozhang.memory.shortterm.ShortTermMemory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PersonalizedLearningAgent {
private final ChatClient chatClient;
private final ShortTermMemory shortTermMemory;
private final MemoryInjector memoryInjector;
private final MemoryExtractor memoryExtractor;
private final UserPreferenceService preferenceService;
/**
* 处理用户问题(带完整记忆系统)
*/
public String chat(String userId, String sessionId, String userMessage) {
log.info("处理用户消息: userId={}, session={}", userId, sessionId);
// 1. 构建记忆上下文(长期记忆 + 用户偏好)
String memoryContext = memoryInjector.buildMemoryContext(userId, userMessage);
// 2. 构建系统提示
String systemPrompt = buildSystemPrompt(memoryContext);
// 3. 获取短期记忆(当前会话历史)
List<Message> conversationHistory = shortTermMemory.toMessages(sessionId);
// 4. 组装完整消息列表
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.addAll(conversationHistory);
messages.add(new UserMessage(userMessage));
// 5. 调用LLM
String response = chatClient.prompt()
.messages(messages)
.call()
.content();
// 6. 更新短期记忆
shortTermMemory.addTurn(sessionId, userMessage, response);
// 7. 异步提取长期记忆(不阻塞响应)
memoryExtractor.extractAndStore(userId, userMessage, response);
// 8. 异步更新用户偏好
preferenceService.updateFromConversation(userId, userMessage);
return response;
}
private String buildSystemPrompt(String memoryContext) {
String basePrompt = """
你是一个个性化Java学习助手。
你的目标是根据用户的技术水平、项目背景和学习历史,提供最适合的帮助。
行为准则:
1. 如果你记得用户的技术水平,调整回答的深度
2. 如果你知道用户的当前项目,在回答中结合项目背景
3. 如果用户之前学过相关内容,可以引用历史("上次你学习了X,现在Y的原理类似")
4. 主动记住用户提到的新信息(框架偏好、项目变化等)
""";
if (!memoryContext.isEmpty()) {
return basePrompt + "\n---\n" + memoryContext;
}
return basePrompt;
}
}API接口
// LearningAgentController.java
@RestController
@RequestMapping("/api/learning")
@RequiredArgsConstructor
public class LearningAgentController {
private final PersonalizedLearningAgent agent;
@PostMapping("/chat")
public ResponseEntity<ChatResponse> chat(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-Session-Id") String sessionId,
@RequestBody ChatRequest request) {
String response = agent.chat(userId, sessionId, request.message());
return ResponseEntity.ok(new ChatResponse(response));
}
record ChatRequest(String message) {}
record ChatResponse(String message) {}
}隐私保护:用户记忆数据的安全存储
// MemoryEncryptionService.java
package com.laozhang.memory.privacy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
@Slf4j
@Service
public class MemoryEncryptionService {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
@Value("${memory.privacy.encryption-key:${random.value}}")
private String encryptionKey;
/**
* 加密记忆内容
*/
public String encrypt(String plaintext) {
try {
byte[] keyBytes = encryptionKey.substring(0, 32).getBytes(StandardCharsets.UTF_8);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// IV + 密文,一起Base64编码
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
log.error("记忆加密失败", e);
throw new RuntimeException("记忆加密失败", e);
}
}
/**
* 解密记忆内容
*/
public String decrypt(String ciphertext) {
try {
byte[] keyBytes = encryptionKey.substring(0, 32).getBytes(StandardCharsets.UTF_8);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec);
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("记忆解密失败", e);
throw new RuntimeException("记忆解密失败", e);
}
}
}// MemoryPrivacyController.java - GDPR合规:用户查看和删除记忆
@RestController
@RequestMapping("/api/memory/privacy")
@RequiredArgsConstructor
public class MemoryPrivacyController {
private final LongTermMemoryStore memoryStore;
private final UserPreferenceRepository preferenceRepository;
private final ShortTermMemory shortTermMemory;
/**
* 查看我的所有记忆
*/
@GetMapping("/my-memories")
public ResponseEntity<UserMemoryView> getMyMemories(
@RequestHeader("X-User-Id") String userId) {
List<MemoryEntry> longTermMemories = memoryStore.retrieveAll(userId);
Optional<UserPreference> preference = preferenceRepository.findByUserId(userId);
return ResponseEntity.ok(UserMemoryView.builder()
.longTermMemories(longTermMemories)
.preferences(preference.orElse(null))
.memoryCount(longTermMemories.size())
.build());
}
/**
* 删除特定记忆
*/
@DeleteMapping("/memory/{memoryId}")
public ResponseEntity<Void> deleteMemory(
@RequestHeader("X-User-Id") String userId,
@PathVariable String memoryId) {
memoryStore.delete(userId, memoryId);
return ResponseEntity.noContent().build();
}
/**
* 删除所有记忆(GDPR"被遗忘权")
*/
@DeleteMapping("/forget-me")
public ResponseEntity<Void> forgetMe(
@RequestHeader("X-User-Id") String userId) {
memoryStore.deleteAll(userId);
preferenceRepository.deleteByUserId(userId);
log.info("用户 {} 的所有记忆已删除(被遗忘权)", userId);
return ResponseEntity.noContent().build();
}
}生产注意事项
1. 向量数据库选型
| 数据库 | 适合场景 | Spring AI支持 | 特点 |
|---|---|---|---|
| PGVector | 中小规模,已有PG | 原生支持 | 零额外运维成本 |
| Qdrant | 大规模,纯向量场景 | 原生支持 | 高性能,内存效率高 |
| Redis Vector | 超低延迟需求 | 原生支持 | 毫秒级检索 |
| Weaviate | 多模态记忆 | 原生支持 | 支持图像记忆 |
推荐:先用PGVector(利用现有PostgreSQL),规模超过100万条向量时再迁移到Qdrant。
2. 记忆提取的成本控制
每次对话异步提取记忆会增加LLM调用次数。控制策略:
- 只对长度超过50字的消息进行提取(短消息一般没有值得记的信息)
- 每个用户每天最多提取50条记忆(超出后批量合并相似记忆)
- 用gpt-4o-mini做提取(比gpt-4o便宜10倍,提取任务不需要最强的模型)
3. 记忆注入的Token预算
每次注入记忆会占用Token:
- 用户偏好:约100-200 tokens
- 长期记忆(5条):约500-1000 tokens
- 任务记忆:约200-400 tokens
- 总计约1000-1600 tokens的额外开销
对于Token紧张的场景,可以按重要度排序,只注入Top 3的记忆。
常见问题解答
Q1:记忆系统会不会记住错误的信息,越来越离谱?
A:这是真实存在的风险,有三个缓解措施:
- 提取时只记录用户明确陈述的事实,不记录AI的推断
- 给每条记忆打置信度标签,低置信度的记忆在注入时标注不确定性
- 提供用户纠错接口——用户说"我现在不用那个框架了",对应的记忆要能被更新
Q2:如何判断哪些信息值得长期记忆?
A:给LLM一个判断框架:
- 用户的身份信息(姓名、公司、职位)→ 重要度 0.9
- 技术偏好(框架、数据库)→ 重要度 0.8
- 当前项目信息 → 重要度 0.7
- 一次性问题的具体内容 → 不记录(重要度 < 0.3)
Q3:短期记忆和长期记忆的边界在哪里?
A:推荐的实践:
- 会话结束时(用户关闭对话),短期记忆不自动转为长期
- 只有明确提取到有价值信息时,才写入长期记忆
- 长期记忆是信息蒸馏后的精华,不是对话历史的存档
Q4:记忆系统对延迟有多大影响?
A:主要影响在检索阶段:
- 向量检索(Top-5):PGVector约50-200ms,Qdrant约10-50ms
- 用户偏好查询(数据库):约5-20ms
- 总增加延迟:约100-300ms
可以通过预加载(用户发消息前就开始检索)来隐藏这个延迟。
Q5:多个设备、多个会话的记忆如何同步?
A:长期记忆是用户级别的(以userId为key),天然跨设备共享。 短期记忆是会话级别的(以sessionId为key),不同设备的对话是独立的短期记忆。 如果用户在手机上开始了一个话题,换到PC上继续,长期记忆会帮助衔接,但短期历史不共享。
Q6:记忆系统的GDPR合规怎么做?
A:最低限度要实现:
- 告知用户:系统会记住对话中的个人信息(隐私政策)
- 查看权:用户能看到系统记住了什么(
/api/memory/privacy/my-memories) - 删除权:用户能删除所有记忆(
/api/memory/privacy/forget-me) - 加密存储:记忆内容不能明文存储(本文已实现AES-GCM加密)
总结
记忆系统是AI助手从"工具"升级为"伙伴"的关键一步。
可操作行动清单:
给AI一个好记忆,用户才会真正爱上它。
