* LangChain4j中的会话记忆ChatMemory
在构建 AI 志愿填报顾问时一个很自然的期望是它能记住我们之前聊过什么而不是每次都像第一次见面一样。大模型本身是无状态的每次调用都是独立的要实现“记忆”唯一的方法就是把聊天历史连同新问题一起发给模型。LangChain4j 用一套简洁的抽象帮我们搞定了这件事从最基本的会话记忆到多用户隔离再到持久化存储让记忆能力变得健壮且透明。一、会话记忆的原理先搞清楚 LangChain4j 是怎么让大模型“记住”的用户发送消息“西北大学是211吗”给后端。后端将这条消息存入一个会话记忆存储对象然后把存储对象里的所有历史消息一起发给大模型。大模型根据完整的上下文生成回答比如“是的西北大学是211高校”。后端收到回答后再把这条回答也存进记忆存储对象然后才返回给用户。用户接着问“那它是985吗”这条消息再次存入记忆对象此时记忆对象里已有三轮对话全部发给大模型它就能根据之前的上下文推断出用户还在问西北大学。整个过程 LangChain4j 自动完成我们几乎不用写额外的代码。二、基础实现MessageWindowChatMemoryLangChain4j 提供了ChatMemory接口来抽象记忆存储核心方法就三个add(ChatMessage)添加一条消息messages()获取所有消息clear()清空记忆同时提供了MessageWindowChatMemory实现类它用一个固定大小的滑动窗口来保留最近的消息防止上下文过长。2.1 定义会话记忆 Bean注册一个MessageWindowChatMemory的Bean设置最大保留条数建议20条Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); }为什么限制数量一是大模型有上下文窗口限制二是按 token 计费发得越多越贵。窗口大小是一个实用的折中。2.2 把记忆挂载到 AiService 上在AiService注解中通过chatMemory属性关联刚定义的 BeanAiService( wiringMode AiServiceWiringMode.EXPLICIT, chatModel openAiChatModel, streamingChatModel openAiStreamingChatModel, chatMemory chatMemory ) public interface ConsultantService { SystemMessage(fromResource system.txt) FluxString chat(String message); }启动后测试多轮对话就能看到上下文关联效果。但一个问题马上暴露出来所有用户共用同一个 ChatMemory张三问的学校会被李四的对话干扰这显然不行。补充如果使用AiService的默认 wiring 模式AUTO且容器中只有一个ChatMemoryBean部分版本可能会自动注入本文的版本就可以。但题中指定了wiringMode EXPLICIT并且需要严谨控制因此必须显式配置 2.2 才能生效。建议始终显式关联避免歧义。三、会话记忆隔离一个用户一个记忆隔离的核心思路是给每个会话一个唯一标识memoryIdLangChain4j 会为每个memoryId维护独立的ChatMemory实例。3.1 原理用户请求时携带memoryId比如session-123。LangChain4j 在一个内部容器中查找id为session-123的ChatMemory。如果没找到就通过ChatMemoryProvider创建一个新的ChatMemory并关联该 ID如果找到了直接复用。3.2 定义 ChatMemoryProvider不再需要单一的chatMemoryBean而是提供一个ChatMemoryProvider它负责按需创建新的ChatMemory对象并设置 ID。Bean public ChatMemoryProvider chatMemoryProvider() { return memoryId - MessageWindowChatMemory.builder() .id(memoryId) // 关键绑定唯一ID .maxMessages(20) .build(); }3.3 接口增加 MemoryId 参数在ConsultantService的chat方法上新增一个MemoryId参数同时因为现在有多个参数需用UserMessage明确哪个是用户输入public interface ConsultantService { SystemMessage(fromResource system.txt) FluxString streamChat(MemoryId String memoryId, UserMessage String message); }3.4 Controller 配合前端在调用/chat接口时带上memoryId一般由前端生成并维持Controller 直接透传RequestMapping(value /streamChat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chat(RequestParam(memoryId) String memoryId, RequestParam(message) String message) { return consultantService.streamChat(memoryId, message); }现在不同memoryId的对话完全隔离互不干扰。四、持久化重启也不失忆目前我们用MessageWindowChatMemory默认内置的SingleSlotChatMemoryStore它的底层是一个内存ArrayList服务重启记忆全丢。要解决这个问题需要把会话记录存到外部存储如 Redis。4.1 原理MessageWindowChatMemory的内部其实委托给一个ChatMemoryStore接口做实际的增删查public interface ChatMemoryStore { ListChatMessage getMessages(Object memoryId); void updateMessages(Object memoryId, ListChatMessage messages); void deleteMessages(Object memoryId); }我们只需要提供一个实现了该接口的 Redis 版本然后注入给MessageWindowChatMemory。4.2 准备 Redis 环境安装并启动 Redis本地用 Docker 一行命令docker run --name redis -d -p 6379:6379 redis引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置连接spring: data: redis: host: localhost port: 63794.3配置Redis配置类package com.langchan4jSpringBoot.config; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.cache.RedisCacheWriter; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.*; import java.time.Duration; /** * Redis配置类 * 负责配置RedisTemplate序列化方式、缓存管理器以及自定义key前缀策略 */ Configuration EnableCaching public class RedisConfig { // 从配置文件中读取Redis key的统一前缀 Value(${spring.data.redis.key-prefix}) private String keyPrefix; /** * 配置RedisTemplate * - key使用带前缀的String序列化 * - value使用Jackson JSON序列化 */ Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key采用带统一前缀的key序列化器 template.setKeySerializer(prefixRedisSerializer()); // hash的key也采用带统一前缀的key序列化器 template.setHashKeySerializer(prefixRedisSerializer()); // value序列化方式采用jackson template.setValueSerializer(valueSerializer()); // hash的value序列化方式采用jackson template.setHashValueSerializer(valueSerializer()); template.afterPropertiesSet(); return template; } /** * 配置Spring Cache缓存管理器 * - 使用非锁定的RedisCacheWriter * - 自定义缓存key前缀拼接规则 * - value采用Jackson JSON序列化 * - 默认永不过期 */ Bean public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { RedisCacheWriter redisCacheWriter RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory); RedisCacheConfiguration redisCacheConfiguration RedisCacheConfiguration.defaultCacheConfig() .computePrefixWith(name - { if (name.endsWith(:)) { return keyPrefix.concat(:).concat(name); } return keyPrefix.concat(:).concat(name).concat(:); }) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer())); redisCacheConfiguration.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())); //过期时间设置为 Duration#ZERO永远不过期 redisCacheConfiguration.entryTtl(Duration.ZERO); return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration); } /** * 创建带统一前缀的key序列化器 */ private RedisSerializerString prefixRedisSerializer() { return new PrefixRedisSerializer(keyPrefix :); } /** * 自定义前缀序列化器 * 在序列化时自动为key添加前缀实现多应用共用Redis时的命名空间隔离 */ static class PrefixRedisSerializer implements RedisSerializerString { /** * 委托给默认的String序列化器处理实际的字节转换 */ private final RedisSerializerString delegate RedisSerializer.string(); /** * key前缀如 app: */ private final String prefix; public PrefixRedisSerializer(String prefix) { this.prefix prefix; } Override public byte[] serialize(String s) throws SerializationException { return delegate.serialize(prefix s); } Override public String deserialize(byte[] bytes) throws SerializationException { return delegate.deserialize(bytes); } } /** * 使用Jackson序列化器 * * return */ private RedisSerializerObject valueSerializer() { ObjectMapper objectMapper new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return new GenericJackson2JsonRedisSerializer(objectMapper); } }4.4实现 RedisChatMemoryStorepackage com.langchan4jSpringBoot.repository; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageDeserializer; import dev.langchain4j.data.message.ChatMessageSerializer; import dev.langchain4j.store.memory.chat.ChatMemoryStore; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; import java.util.List; import java.util.concurrent.TimeUnit; Repository public class RedisChatMemoryStore implements ChatMemoryStore { /** * Redis key前缀完整key格式为 chat:memory:{conversationId} */ private static final String KEY_PREFIX chat:memory:; /** * 会话记录过期天数 */ private static final int TTL_DAYS 3; Autowired private StringRedisTemplate stringRedisTemplate; Override public ListChatMessage getMessages(Object memoryId) { String json stringRedisTemplate.opsForValue().get(KEY_PREFIX memoryId); return ChatMessageDeserializer.messagesFromJson(json); } Override public void updateMessages(Object memoryId, ListChatMessage list) { String json ChatMessageSerializer.messagesToJson(list); stringRedisTemplate.opsForValue().set(KEY_PREFIX memoryId, json, TTL_DAYS, TimeUnit.DAYS); } Override public void deleteMessages(Object memoryId) { stringRedisTemplate.delete(KEY_PREFIX memoryId); } }序列化/反序列化直接使用 LangChain4j 自带的ChatMessageSerializer/ChatMessageDeserializer非常方便。4.5 将 Redis Store 注入 ChatMemoryProviderAutowired private ChatMemoryStore redisChatMemoryStore; Bean public ChatMemoryProvider chatMemoryProvider() { return memoryId - MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(20) .chatMemoryStore(redisChatMemoryStore) // 使用 Redis 持久化 .build(); }现在即使服务重启只要 Redis 还在对话记忆就不会丢失。还可以通过设置过期时间如Duration.ofDays(1)实现自动清理。五、总结从无记忆到带隔离的持久化记忆我们层层递进地解决了会话记忆的核心问题需求实现方式关键配置基础记忆MessageWindowChatMemorychatMemory chatMemory多用户隔离ChatMemoryProviderMemoryIdchatMemoryProvider chatMemoryProvider持久化存储自定义ChatMemoryStoreRedis.chatMemoryStore(redisChatMemoryStore)LangChain4j 把记忆管理彻底从业务代码中抽离我们只需要在接口层面做声明就能获得完整的多轮对话能力。接下来再结合 RAG 知识库我们的 AI 志愿填报顾问就能既“记得住”又“懂得新”真正成为一个靠谱的助手。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2634784.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!