第2003篇:AI Agent的记忆管理——短期上下文与长期知识库的融合架构
大约 7 分钟
第2003篇:AI Agent的记忆管理——短期上下文与长期知识库的融合架构
适读人群:构建对话式AI Agent的工程师 | 阅读时长:约20分钟 | 核心价值:理解Agent记忆的分层设计,解决"AI总是忘事"的工程难题
我们团队的AI客服上线大概三个月后,客户开始抱怨:
"我上次已经说过我的问题了,为什么又要重新解释一遍?"
"我告诉过你们我在上海,为什么还在问我城市?"
根本原因很简单:我们的Agent没有持久记忆。每次对话都是全新开始,上一次会话里发生的一切,Agent完全不知道。
这个问题在技术上听起来好解决——不就是把历史记录塞进Prompt吗?但实际做的时候,会遇到一堆工程问题。这篇文章就是我们从踩坑到建立完整记忆架构的过程。
记忆的四个层次
在设计之前,我先梳理了AI Agent需要的记忆类型:
对一个AI客服来说:
- 工作记忆:当前这轮对话在聊什么
- 情节记忆:用户过去的服务历史(上次退货的订单、之前投诉过的问题)
- 语义记忆:用户的基本信息和偏好(所在城市、偏好的联系方式、VIP等级)
工作记忆:对话上下文的管理
最基础的工作记忆,就是维护当前会话的消息历史:
@Component
@Slf4j
public class ConversationMemory {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// 单个会话的最大消息数,超出时做滑动窗口截断
private static final int MAX_MESSAGES = 20;
// 会话过期时间(用户30分钟不活跃则过期)
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
private String sessionKey(String sessionId) {
return "conv:session:" + sessionId;
}
/**
* 向会话添加消息
*/
public void addMessage(String sessionId, ChatMessage message) {
String key = sessionKey(sessionId);
try {
String serialized = objectMapper.writeValueAsString(message);
redisTemplate.opsForList().rightPush(key, serialized);
redisTemplate.expire(key, SESSION_TTL);
// 维护滑动窗口:超出最大消息数时,删除最旧的消息
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_MESSAGES) {
redisTemplate.opsForList().leftPop(key);
}
} catch (JsonProcessingException e) {
log.error("序列化消息失败", e);
}
}
/**
* 获取当前会话的完整上下文
*/
public List<ChatMessage> getMessages(String sessionId) {
String key = sessionKey(sessionId);
List<String> serializedMessages = redisTemplate.opsForList().range(key, 0, -1);
if (serializedMessages == null) return Collections.emptyList();
return serializedMessages.stream()
.map(s -> {
try {
return objectMapper.readValue(s, ChatMessage.class);
} catch (JsonProcessingException e) {
log.error("反序列化消息失败", e);
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* 清除会话(用户主动结束对话时)
*/
public void clearSession(String sessionId) {
redisTemplate.delete(sessionKey(sessionId));
}
/**
* 获取会话摘要(Token优化:当上下文太长时,压缩历史部分)
*/
public String getCompressedContext(String sessionId, ChatClient summaryClient) {
List<ChatMessage> messages = getMessages(sessionId);
if (messages.size() <= 6) {
// 消息少,不需要压缩
return null;
}
// 只保留最近6条完整消息,把更早的消息压缩成摘要
List<ChatMessage> earlyMessages = messages.subList(0, messages.size() - 6);
List<ChatMessage> recentMessages = messages.subList(messages.size() - 6, messages.size());
// 用LLM压缩早期消息
String summaryPrompt = buildSummaryPrompt(earlyMessages);
String summary = summaryClient.prompt()
.user(summaryPrompt)
.call()
.content();
return "【对话历史摘要】\n" + summary + "\n\n【最近对话】(以下为完整内容)";
}
private String buildSummaryPrompt(List<ChatMessage> messages) {
StringBuilder sb = new StringBuilder("请将以下对话内容压缩成简短摘要,保留关键信息:\n\n");
for (ChatMessage msg : messages) {
sb.append(msg.getRole()).append(": ").append(msg.getContent()).append("\n");
}
sb.append("\n请用2-3句话总结对话的关键内容和已解决/未解决的问题。");
return sb.toString();
}
}情节记忆:历史会话的结构化存储
会话结束后,不能直接丢弃。我们需要把有价值的事件存进情节记忆库:
@Entity
@Table(name = "agent_episode_memory")
@Data
public class EpisodeMemory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private String sessionId;
private LocalDateTime happenedAt;
@Enumerated(EnumType.STRING)
private EpisodeType type; // SERVICE_REQUEST, COMPLAINT, PURCHASE, etc.
@Column(columnDefinition = "TEXT")
private String summary; // 事件摘要(LLM生成)
@Column(columnDefinition = "TEXT")
private String keyEntities; // 关键实体JSON(涉及的订单号、产品等)
@Column(columnDefinition = "vector(1536)")
private float[] embedding; // 摘要的向量,用于语义检索
private String outcome; // 结果(resolved/unresolved/escalated)
}
@Service
@Slf4j
@RequiredArgsConstructor
public class EpisodeMemoryService {
private final EpisodeMemoryRepository repository;
private final EmbeddingModel embeddingModel;
private final ChatClient summaryClient;
/**
* 会话结束时,提取并保存情节记忆
*/
@Async
public void saveEpisode(String userId, String sessionId, List<ChatMessage> messages) {
if (messages.size() < 2) return; // 太短的对话没有意义
try {
// 1. 用LLM提取结构化事件信息
EpisodeSummary summary = extractEpisodeSummary(messages);
// 2. 生成摘要的Embedding
float[] embedding = embeddingModel.embed(summary.getSummary());
// 3. 保存到数据库
EpisodeMemory episode = new EpisodeMemory();
episode.setUserId(userId);
episode.setSessionId(sessionId);
episode.setHappenedAt(LocalDateTime.now());
episode.setType(summary.getType());
episode.setSummary(summary.getSummary());
episode.setKeyEntities(summary.getKeyEntitiesJson());
episode.setEmbedding(embedding);
episode.setOutcome(summary.getOutcome());
repository.save(episode);
log.debug("情节记忆已保存: userId={}, type={}", userId, summary.getType());
} catch (Exception e) {
log.error("保存情节记忆失败: sessionId={}", sessionId, e);
}
}
private EpisodeSummary extractEpisodeSummary(List<ChatMessage> messages) {
String conversation = messages.stream()
.map(m -> m.getRole() + ": " + m.getContent())
.collect(Collectors.joining("\n"));
String extractPrompt = """
请分析以下客服对话,提取结构化信息,用JSON格式返回:
%s
请返回JSON格式:
{
"type": "SERVICE_REQUEST/COMPLAINT/PURCHASE/INQUIRY",
"summary": "简短摘要(50字以内)",
"key_entities": {"order_ids": [], "products": [], "issues": []},
"outcome": "resolved/unresolved/escalated",
"important_notes": "需要下次记住的重要信息(可选)"
}
""".formatted(conversation);
String response = summaryClient.prompt().user(extractPrompt).call().content();
// 解析JSON...(省略解析代码)
return parseEpisodeSummary(response);
}
/**
* 检索用户相关的历史记忆
* 用语义搜索找到最相关的历史事件
*/
public List<EpisodeMemory> recallRelevantEpisodes(
String userId, String currentQuery, int topK) {
float[] queryEmbedding = embeddingModel.embed(currentQuery);
// 使用pgvector做语义搜索,只在该用户的记忆范围内检索
return repository.findTopKByUserIdOrderBySimilarity(
userId, queryEmbedding, topK
);
}
}语义记忆:用户特征的持久化
用户偏好和基本信息需要单独存储,而且要可以被更新:
@Entity
@Table(name = "agent_user_profile")
@Data
public class UserProfile {
@Id
private String userId;
// 基础信息
private String preferredName; // 用户希望被怎么称呼
private String city;
private String preferredContactMethod; // email/phone/wechat
// 服务偏好
private Boolean preferSelfService; // 是否倾向自助解决
private Boolean preferDetailedExplanation; // 是否需要详细解释
// 用户特征(由AI分析会话后自动更新)
@Column(columnDefinition = "TEXT")
private String aiInferredTraits; // JSON: 对话中推断出的特征
private LocalDateTime lastUpdated;
}
@Service
@RequiredArgsConstructor
public class UserProfileService {
private final UserProfileRepository repository;
private final ChatClient analysisClient;
/**
* 在会话结束后,更新用户画像
*/
@Async
public void updateProfile(String userId, List<ChatMessage> messages,
EpisodeMemory episode) {
UserProfile profile = repository.findById(userId)
.orElse(new UserProfile(userId));
// 用LLM从对话中提取新的用户特征
String traits = extractNewTraits(messages, profile);
if (traits != null) {
profile.setAiInferredTraits(mergeTraits(profile.getAiInferredTraits(), traits));
profile.setLastUpdated(LocalDateTime.now());
repository.save(profile);
}
}
/**
* 把用户画像格式化成可注入Prompt的文本
*/
public String formatForPrompt(String userId) {
return repository.findById(userId)
.map(profile -> {
StringBuilder sb = new StringBuilder("【用户信息】\n");
if (profile.getPreferredName() != null) {
sb.append("称呼: ").append(profile.getPreferredName()).append("\n");
}
if (profile.getCity() != null) {
sb.append("城市: ").append(profile.getCity()).append("\n");
}
if (profile.getAiInferredTraits() != null) {
sb.append("用户偏好: ").append(profile.getAiInferredTraits()).append("\n");
}
return sb.toString();
})
.orElse("");
}
}记忆的融合注入
最后,在构建Agent的Prompt时,把三层记忆融合进去:
@Service
@RequiredArgsConstructor
public class MemoryAwareAgent {
private final ConversationMemory workingMemory;
private final EpisodeMemoryService episodeMemory;
private final UserProfileService userProfile;
private final ReActAgent coreAgent;
public AgentResult run(String userId, String sessionId,
String userMessage, List<AgentTool> tools) {
// 1. 获取工作记忆(当前会话历史)
List<ChatMessage> history = workingMemory.getMessages(sessionId);
// 2. 检索相关情节记忆(历史会话中与当前问题相关的事件)
List<EpisodeMemory> relevantEpisodes = episodeMemory.recallRelevantEpisodes(
userId, userMessage, 3
);
// 3. 获取用户画像
String profileContext = userProfile.formatForPrompt(userId);
// 4. 把记忆注入System Prompt
String memoryContext = buildMemoryContext(profileContext, relevantEpisodes);
// 5. 运行Agent(把记忆上下文注入)
AgentResult result = coreAgent.runWithContext(
userMessage, tools, history, memoryContext
);
// 6. 把本次消息加入工作记忆
workingMemory.addMessage(sessionId, new ChatMessage("user", userMessage));
if (result.getFinalAnswer() != null) {
workingMemory.addMessage(sessionId,
new ChatMessage("assistant", result.getFinalAnswer()));
}
return result;
}
private String buildMemoryContext(String profile, List<EpisodeMemory> episodes) {
StringBuilder sb = new StringBuilder();
if (!profile.isBlank()) {
sb.append(profile).append("\n");
}
if (!episodes.isEmpty()) {
sb.append("【历史服务记录】\n");
for (EpisodeMemory ep : episodes) {
sb.append("- [").append(ep.getHappenedAt().toLocalDate()).append("] ")
.append(ep.getSummary()).append(" (结果: ").append(ep.getOutcome()).append(")\n");
}
}
return sb.toString();
}
}这套记忆架构上线后,我们的客服满意度调查里"需要重复解释"这条从排名第三的投诉,掉出了前十。记忆不只是技术问题,它是AI是否"把用户当人"的体现。
