LangChain4j 集成 Spring Boot:会话记忆 NPE 的根源与 ChatMemoryProvider 正确配置

LangChain4j 记忆管理:从 NPE 到 Redis ------ 以 Corner 项目为例

Corner 是一个情绪驱动的地点推荐系统。用户选择心情 → AI 理解需求 → 调用工具链搜索 → 返回"治愈角落"。在这个过程中,记忆(Memory)是连接用户历史行为与个性化推荐的桥梁

项目地址:Corner: 基于情绪感知的个性化微出行决策助手

本文将基于 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 字段)。

解决方案

  1. 在 Redis Key 中加入版本号前缀:chat_memory_key:v2:{userId}
  2. 升级时做数据迁移或直接清空旧缓存(TTL 自然过期)
  3. 在反序列化时 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 接口,选择合适的存储后端
  • 注册 ChatMemoryProvider Bean,指定 maxMessages 数量
  • @AiService 注解中通过 chatMemoryProvider 引入
  • 确保 @MemoryId 参数在每次调用时正确传递
  • 统一 memoryId 的生成规则(前缀 + 标识符)
  • 对序列化/反序列化做异常捕获,防止 NPE
  • 设置合理的 TTL,平衡体验与成本
  • Redis 作为必需依赖,启动时 Fail Fast
  • 区分会话记忆(短期)与业务记忆(长期)的边界

完整代码见 Corner 仓库 [Corner: 基于情绪感知的个性化微出行决策助手](https://github.com/bingege-0729/Corner)


参考文献

你按照本文改完后,还遇到过其他记忆相关的坑吗?评论区告诉我,我会整理进后续的踩坑合集。
如果本文对你有帮助,点赞 + 收藏 支持

相关推荐
JAVA9652 小时前
JAVA面试-并发篇 05-并发包AQS队列实现原理是什么
java·开发语言·面试
JAVA面经实录9172 小时前
RocketMQ全套学习知识手册
java·kafka·rabbitmq·rocketmq
混凝土拌意大利面2 小时前
TG-BOOT springboot 功能集散开发框架(AI 协作友好)
人工智能·spring boot·后端
phltxy2 小时前
Spring AI 从提示词到多模态
java·人工智能·spring
Halo_tjn2 小时前
反射与设计模式1
java·开发语言·算法
神仙别闹2 小时前
基于Python + SQL server 实现(GUI)原神圣遗物管理与角色数值模拟系统
java·数据库·python
是有头发的程序猿3 小时前
电商自动化实战:淘宝/天猫item_get商品详情API全量采集教程(Python源码)
java·python·自动化
古韵3 小时前
告别手写分页逻辑:usePagination 从 50 行到 3 行
java·前端
小村儿3 小时前
连载12- Cluade code 的MCP 到底还用不用
前端·后端·ai编程