LangChain4j 记忆管理:从 NPE 到 Redis ------ 以 Corner 项目为例
Corner 是一个情绪驱动的地点推荐系统。用户选择心情 → AI 理解需求 → 调用工具链搜索 → 返回"治愈角落"。在这个过程中,记忆(Memory)是连接用户历史行为与个性化推荐的桥梁。
本文将基于 Corner 项目源码,拆解 LangChain4j 的记忆管理机制,以及我们在落地过程中踩过的坑和解决方案。
一、为什么需要"记忆"?
在 Corner 中,AI 与用户的交互不是无状态的。一个典型的对话场景:
用户:我想找个安静的地方
AI :推荐了「静谧咖啡馆」「湖边书屋」「山顶观景台」
用户:第一个太远了,有更近的吗?
AI :(需要记住刚才推荐了什么,才能给出替代方案)
如果 AI 每次都从零开始,就无法实现上下文连贯的体验。这就是 ChatMemory(会话记忆) 存在的意义。
但除了对话上下文,还有另一层记忆------用户长期偏好:
| 记忆类型 | 存储位置 | 生命周期 | 用途 |
|---|---|---|---|
| 会话记忆 (ChatMemory) | Redis | 1 天(TTL) | 对话上下文,让 AI "记得"刚才说了什么 |
| 用户偏好记忆 (UserPlaceMemory) | MySQL | 永久 | 去过/收藏/不喜欢的地点,影响推荐权重 |
这两层记忆在 Corner 中的分工如下:
#mermaid-svg-1YY5ZKyJNiQxCwHK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1YY5ZKyJNiQxCwHK .error-icon{fill:#552222;}#mermaid-svg-1YY5ZKyJNiQxCwHK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1YY5ZKyJNiQxCwHK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .marker.cross{stroke:#333333;}#mermaid-svg-1YY5ZKyJNiQxCwHK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1YY5ZKyJNiQxCwHK p{margin:0;}#mermaid-svg-1YY5ZKyJNiQxCwHK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster-label text{fill:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster-label span{color:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster-label span p{background-color:transparent;}#mermaid-svg-1YY5ZKyJNiQxCwHK .label text,#mermaid-svg-1YY5ZKyJNiQxCwHK span{fill:#333;color:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .node rect,#mermaid-svg-1YY5ZKyJNiQxCwHK .node circle,#mermaid-svg-1YY5ZKyJNiQxCwHK .node ellipse,#mermaid-svg-1YY5ZKyJNiQxCwHK .node polygon,#mermaid-svg-1YY5ZKyJNiQxCwHK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .rough-node .label text,#mermaid-svg-1YY5ZKyJNiQxCwHK .node .label text,#mermaid-svg-1YY5ZKyJNiQxCwHK .image-shape .label,#mermaid-svg-1YY5ZKyJNiQxCwHK .icon-shape .label{text-anchor:middle;}#mermaid-svg-1YY5ZKyJNiQxCwHK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .rough-node .label,#mermaid-svg-1YY5ZKyJNiQxCwHK .node .label,#mermaid-svg-1YY5ZKyJNiQxCwHK .image-shape .label,#mermaid-svg-1YY5ZKyJNiQxCwHK .icon-shape .label{text-align:center;}#mermaid-svg-1YY5ZKyJNiQxCwHK .node.clickable{cursor:pointer;}#mermaid-svg-1YY5ZKyJNiQxCwHK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .arrowheadPath{fill:#333333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1YY5ZKyJNiQxCwHK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1YY5ZKyJNiQxCwHK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1YY5ZKyJNiQxCwHK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster text{fill:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK .cluster span{color:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1YY5ZKyJNiQxCwHK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1YY5ZKyJNiQxCwHK rect.text{fill:none;stroke-width:0;}#mermaid-svg-1YY5ZKyJNiQxCwHK .icon-shape,#mermaid-svg-1YY5ZKyJNiQxCwHK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1YY5ZKyJNiQxCwHK .icon-shape p,#mermaid-svg-1YY5ZKyJNiQxCwHK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1YY5ZKyJNiQxCwHK .icon-shape .label rect,#mermaid-svg-1YY5ZKyJNiQxCwHK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1YY5ZKyJNiQxCwHK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1YY5ZKyJNiQxCwHK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1YY5ZKyJNiQxCwHK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户请求
'我想找个安静的地方,一个人待着'
会话记忆 (Redis)
'上次推荐了3个'
偏好记忆 (MySQL)
收藏: A地
去过: B地
不喜欢: C地
实时输入
mood=安静
lat=22.54
lng=113.95
AI 综合决策
调用工具链推荐
二、LangChain4j 的记忆抽象
LangChain4j 将记忆能力抽象为两个核心接口:
2.1 ChatMemoryStore ------ 存储层
这是最底层的接口,只负责消息的存取删,不关心业务逻辑:
java
// LangChain4j 源码抽象
public interface ChatMemoryStore {
List<ChatMessage> getMessages(Object memoryId); // 获取消息
void updateMessages(Object memoryId, List<ChatMessage> messages); // 更新消息
void deleteMessages(Object memoryId); // 删除消息
}
LangChain4j 内置了
InMemoryChatMemoryStore(基于 ConcurrentHashMap),但重启即丢失,不适合生产环境。
2.2 ChatMemoryProvider ------ 工厂层
这是一个函数式接口,根据 memoryId 创建对应的 ChatMemory 实例:
java
@FunctionalInterface
public interface ChatMemoryProvider {
ChatMemory provide(Object memoryId);
}
常见的 ChatMemory 实现包括:
- MessageWindowChatMemory:保留最近 N 条消息(滑动窗口)
- TokenWindowChatMemory:保留最近 N 个 Token
- ChatWithCompressorMemory:自动压缩旧消息节省 Token
三、Corner 的 Redis 记忆实现
在项目中综合选择了 MessageWindowChatMemory + Redis 的组合方案。以下是核心代码。
3.1 实现 ChatMemoryStore 接口
java
@Component
public class RedisChatMemoryRepository implements ChatMemoryStore {
private final StringRedisTemplate redisTemplate;
public RedisChatMemoryRepository(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String key = CHAT_MEMORY_KEY_PREFIX + memoryId;
// 从Redis获取信息
String json = redisTemplate.opsForValue().get(key);
if (json == null || json.isEmpty()) {
return List.of();
}
// 反序列化并返回
return ChatMessageDeserializer.messagesFromJson(json);
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> list) {
String key = CHAT_MEMORY_KEY_PREFIX + memoryId;
// 序列化信息
String json = ChatMessageSerializer.messagesToJson(list);
// 存入缓存中,时间限制为1天
redisTemplate.opsForValue().set(key, json, Duration.ofDays(1));
}
@Override
public void deleteMessages(Object memoryId) {
String key = CHAT_MEMORY_KEY_PREFIX + memoryId;
redisTemplate.delete(key);
}
}
关键设计决策:
| 决策点 | 选择 | 原因 |
|---|---|---|
| 序列化方式 | LangChain4j 内置的 ChatMessageSerializer |
兼容所有消息类型(System/User/AI/ToolMessage) |
| 存储结构 | Redis String(单 Key 存 JSON) | 简单可靠,一次读写即可获取完整对话历史 |
| TTL 策略 | 1 天 | 平衡用户体验(回来还能继续聊)与存储成本 |
| Key 设计 | chat_memory_key:{userId} |
用户隔离,每个用户独立记忆空间 |
3.2 配置 ChatMemoryProvider Bean
java
@Configuration
public class CommonConfig {
@Autowired
private RedisChatMemoryRepository redisChatMemoryRepository;
/**
* 配置 ChatMemoryProvider Bean,用于 LangChain4j 的会话记忆
*/
@Bean
public ChatMemoryProvider redisChatMemoryProvider() {
return memoryId -> MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(30) // 保留最近 30 条消息
.chatMemoryStore(redisChatMemoryRepository) // 使用 Redis 存储
.build();
}
}
这里有几个值得注意的参数:
maxMessages(30):保留最近 30 条消息(约 15 轮对话)。这个数字是根据实际场景调优的------太少会导致上下文丢失,太多会增加 LLM 调用的 Token 成本。id(memoryId):每个用户独立的记忆实例,互不干扰。
3.3 在 AI Service 中使用记忆
java
@AiService(
chatModel = "openAiChatModel",
tools = "recommendAITools",
chatMemoryProvider = "redisChatMemoryProvider" // ← 注入我们的 Redis 记忆提供者
)
public interface RecommendAIService {
@SystemMessage("你是一个温暖、贴心的情绪地点推荐助手。")
public Flux<String> chat(@UserMessage String message, @MemoryId String memoryId);
// ... 其他方法
}
@MemoryId 注解的作用 :告诉 LangChain4j 用哪个 ID 去查找对应的 ChatMemory。在我们的项目中,memoryId 就是 chat_memory_key:{userId}。
3.4 调用时传入 memoryId
java
@Service
public class ChatServiceImpl implements ChatService {
@Autowired
private RecommendAIService recommendAIService;
@Override
public Flux<String> chat(Long userId, RecommendRequest request) {
String message = request.getUserInput();
// 追加位置信息到消息中
if (request.getUserLat() != null && request.getUserLng() != null) {
message += String.format("\n[当前位置: 纬度%.6f, 经度%.6f]",
request.getUserLat(), request.getUserLng());
}
// 关键:用 userId 构造 memoryId,确保每个用户的记忆隔离
String memoryId = CHAT_MEMORY_KEY_PREFIX + userId;
return recommendAIService.chat(message, memoryId);
}
}
四、踩过的坑:NPE 是怎么来的?
坑 1:Redis 未启动导致 Bean 创建失败
现象:应用启动时报错
bash
org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'commonConfig':
Field redisChatMemoryRepository in com.example.corner.config.CommonConfig
required a bean of type 'StringRedisTemplate' that could not be found.
原因链路:
Redis 未启动
↓
Spring Boot 自动配置的 RedisConnectionFactory 创建失败
↓
StringRedisTemplate 无法创建
↓
RedisChatMemoryRepository 注入失败
↓
CommonConfig.redisChatMemoryProvider() 抛出 NPE
↓
整个应用无法启动
解决方案 :在 application.yaml 中显式声明 Redis 配置,并在 README 中强调 Redis 为必需依赖:
yaml
spring:
data:
redis:
host: localhost
port: 6379
同时添加健康检查逻辑------如果 Redis 连接失败,应用应快速失败(Fail Fast),而不是在运行时才报 NPE。
坑 2:memoryId 类型不匹配导致的空指针
现象 :调用 chat() 接口后返回正常,但第二次调用时 AI 像失忆了一样,完全不记得上次的对话。
排查过程:
java
// ❌ 错误写法:memoryId 类型不一致
String memoryId = userId.toString(); // "123"
// Redis 中的 key 是 chat_memory_key:123
// ✅ 正确写法:保持一致
String memoryId = CHAT_MEMORY_KEY_PREFIX + userId; // "chat_memory_key:123"
根因分析 :LangChain4j 的
ChatMemoryProvider.provide(memoryId)会用传入的memoryId去 Redis 查找。如果在updateMessages时用了带前缀的 key,但getMessages时用了不带前缀的 key,就会出现写入成功但读取为空的情况,表现就是 AI "失忆"。
坑 3:序列化兼容性问题
现象:升级 LangChain4j 版本后,之前存储的对话记录全部反序列化失败。
原因 :LangChain4j 的 ChatMessageSerializer 使用的是内部 JSON 格式,不同版本之间可能存在字段变化(比如新增了 toolExecutionId 字段)。
解决方案:
- 在 Redis Key 中加入版本号前缀:
chat_memory_key:v2:{userId} - 升级时做数据迁移或直接清空旧缓存(TTL 自然过期)
- 在反序列化时 catch 异常,降级为空列表而非抛出 NPE:
java
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String key = CHAT_MEMORY_KEY_PREFIX + memoryId;
String json = redisTemplate.opsForValue().get(key);
if (json == null || json.isEmpty()) {
return List.of();
}
try {
return ChatMessageDeserializer.messagesFromJson(json);
} catch (Exception e) {
log.warn("反序列化聊天记录失败, memoryId={}, error={}", memoryId, e.getMessage());
return List.of(); // 降级处理,避免 NPE 向上传播
}
}
坑 4:并发写冲突
现象:用户快速发送两条消息时,第二条消息可能覆盖第一条的回复记录。
原因 :我们的存储方案是整体替换 (set 整个 JSON 列表),而非追加操作。在高并发场景下会出现 lost update 问题。
java
// 当前实现:非原子操作
List<ChatMessage> messages = store.getMessages(id); // 读
messages.add(newMessage); // 改
store.updateMessages(id, messages); // 写 ← 此时可能已被其他线程修改
当前应对:对于 Corner 这种 C 端场景,同一用户的并发概率较低,暂时可接受。如果要彻底解决,可以考虑:
- Redis
WATCH/MULTI/EXEC事务 - Redis 分布式锁(Redisson)
- 或改用 Redis List 结构做 LPUSH 追加
五、两层记忆如何协同工作?
前面提到,Corner 除了会话记忆(Redis),还有用户偏好记忆(MySQL)。它们是如何配合的?
5.1 长期记忆的数据模型
sql
-- user_place_memory 表结构
CREATE TABLE user_place_memory (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
place_id BIGINT NOT NULL,
interaction_type ENUM('VISITED', 'BOOKMARKED', 'DISLIKED'),
rating TINYINT, -- 1-5 分评分
feedback VARCHAR(500), -- 文字反馈
visited_at DATE,
created_at DATETIME,
updated_at DATETIME
);
5.2 在推荐时的使用方式
在 RecommendAITools.java(file:///d:/04_Study_Materials/Personal/Back-end_Development/project/Corner/backend/src/main/java/com/example/corner/tools/RecommendAITools.java) 的 getSuitablePlaceBymoodAndsave 方法中,长期记忆被用于加权排序:
java
// 1. 查询用户所有记忆,排除 DISLIKED
List<UserPlaceMemory> allMemories = userPlaceMemoryRepository.findByUserId(userId);
List<UserPlaceMemory> validMemories = allMemories.stream()
.filter(m -> !"DISLIKED".equals(m.getInteractionType()))
.toList();
// 2. 计算匹配分数(评分40% + 互动类型30% + 标签匹配30%)
double score = calculateMatchScore(memory, place, mood);
// 3. BOOKMARKED 的地点优先展示
scoredPlaces.sort((a, b) -> Double.compare(b.score, a.score));
记忆的优先级规则:
markdown
用户收藏(BOOKMARKED) → 权重 × 1.0 → 最优先推荐
用户去过(VISITED) → 权重 × 0.7 → 其次
新发现的地点 → 权重 × 0.5 → 补充
用户不喜欢(DISLIKED) → 直接排除 → 永不推荐
5.3 两层记忆的对比总结
| 维度 | 会话记忆 (Redis ChatMemory) | 偏好记忆 (MySQL UserPlaceMemory) |
|---|---|---|
| 存储介质 | Redis | MySQL |
| 生命周期 | 1 天 TTL | 永久 |
| 内容 | 对话消息历史 | 地点交互记录 |
| 管理者 | LangChain4j 框架自动管理 | 业务代码手动 CRUD |
| 用途 | 让 AI "记得"当前对话上下文 | 影响推荐结果的排序权重 |
| 容量 | 30 条消息(滑动窗口) | 无限制 |
六、Redis 配置要点
application.yaml 关键配置
yaml
spring:
data:
redis:
host: localhost
port: 6379
# 注意:没有设置 password(开发环境)
# 生产环境建议:
# password: ${REDIS_PASSWORD}
# lettuce:
# pool:
# max-active: 8
# max-idle: 8
# min-idle: 0
Redis 在 Corner 中的三重角色
你可能注意到了,Redis 在这个项目中承担了三个职责:
┌─────────────────────────────────────────────────┐
│ Redis :6379 │
├─────────────┬──────────────┬────────────────────┤
│ 会话记忆 │ 向量存储 │ 缓存 │
│ ChatMemory │ EmbeddingStore│ Recommend Cache │
│ │ │ │
│ chat_memory_ │ corner_vectors│ recommend_cache: │
│ key:{uid} │ │ {hash} │
│ │ │ │
│ TTL=1天 │ 启动时加载 │ 可配 TTL │
│ 序列化JSON │ HNSW 索引 │ 推荐结果缓存 │
└─────────────┴──────────────┴────────────────────┘
这种设计的好处是基础设施统一------不需要额外引入 Milvus 等专用向量数据库,降低了运维复杂度。当然,当数据量增长到百万级时,可能需要考虑将向量存储独立出来。
七、总结与 Checklist
如果你也想在自己的项目中集成 LangChain4j + Redis 记忆,可以参考以下清单:
- 实现
ChatMemoryStore接口,选择合适的存储后端 - 注册
ChatMemoryProviderBean,指定maxMessages数量 - 在
@AiService注解中通过chatMemoryProvider引入 - 确保
@MemoryId参数在每次调用时正确传递 - 统一
memoryId的生成规则(前缀 + 标识符) - 对序列化/反序列化做异常捕获,防止 NPE
- 设置合理的 TTL,平衡体验与成本
- Redis 作为必需依赖,启动时 Fail Fast
- 区分会话记忆(短期)与业务记忆(长期)的边界
完整代码见 Corner 仓库 [Corner: 基于情绪感知的个性化微出行决策助手](https://github.com/bingege-0729/Corner)
参考文献
- Langchain4j Chat Memory Guide
- Spring Boot:Bean 与依赖注入
你按照本文改完后,还遇到过其他记忆相关的坑吗?评论区告诉我,我会整理进后续的踩坑合集。
如果本文对你有帮助,点赞 + 收藏 支持