对话记忆

分页查询

传统分页查询的问题:

1.用户在查询的过程中想要查询第1条到第5条,现在是能正确查询到的,当用户想要查第6条到第10条的时候,若是现在又新增了5条数据,那么查到的又是刚刚查询过的第1条到第5条数据。这就造成了重复读。

2.同时传统分页查询中,我们想要10000页的十条数据,需要将前面的数据都遍历一遍,这是很损耗性能的,尤其是在高并发的环境下。

这种就可以引入游标分页查询

在这里插入图片描述

在这里插入图片描述

游标分页查询

为了解决传统分页存在的问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,每一次的请求从上一次请求的游标来开始加载数据。

一般我们选择数据记录的唯一标识符来作为游标。例如自增的id

在这里插入图片描述

当要操作下一页时,前端携带游标值发起查询,后端操作数据库从id小于当前游标值的数据开始查询,这样结果就不会受新增数据的影响。

在这里插入图片描述

标准的使用id作为游标,因为主键性能最优且不重复。但在我们这个项目中按时间排序是核心需求,完全可以用crtateTime作为游标,不必额外携带对话历史的id作为复合游标,简化了游标查询的逻辑。

实例sql语句如下:

SELECT * FROM chat_history 
WHERE appId = 123 AND createTime < '2025-07-29 10:30:00'
ORDER BY createTime DESC 
LIMIT 10;

而且还可以给appId和createTime增加复合索引,进一步提高检索效率。这样的执行过程就变成了:

1.直接定位到(appId=123,createTime<‘2025-07-29 10:30:00’)的索引位置

2.顺序读取10条记录

3.完成查询

这样就只有10次的查询成本。

对话历史后端开发:

核心功能实现:

先增加对话历史的枚举类:

@Getter
public enum ChatHistoryMessageTypeEnum {

    USER("用户", "user"),
    AI("AI", "ai");

    private final String text;

    private final String value;

    ChatHistoryMessageTypeEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static ChatHistoryMessageTypeEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (ChatHistoryMessageTypeEnum anEnum : ChatHistoryMessageTypeEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

1.新增对话历史

对话历史的保存需要在用户发送消息AI回复完成这两个时机进行。无论AI回复成功还是失败,都需要留下完整的对话记录,确保用户能够了解完整的交互历史

@Override
public boolean addChatMessage(Long appId, String message, String messageType, Long userId) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "消息内容不能为空");
    ThrowUtils.throwIf(StrUtil.isBlank(messageType), ErrorCode.PARAMS_ERROR, "消息类型不能为空");
    ThrowUtils.throwIf(userId == null || userId <= 0, ErrorCode.PARAMS_ERROR, "用户ID不能为空");
    // 验证消息类型是否有效
    ChatHistoryMessageTypeEnum messageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(messageType);
    ThrowUtils.throwIf(messageTypeEnum == null, ErrorCode.PARAMS_ERROR, "不支持的消息类型: " + messageType);
    ChatHistory chatHistory = ChatHistory.builder()
            .appId(appId)
            .message(message)
            .messageType(messageType)
            .userId(userId)
            .build();
    return this.save(chatHistory);
}

@Resource
private ChatHistoryService chatHistoryService;

@Override
public Flux<String> chatToGenCode(Long appId, String message, User loginUser) {
    // ... 前面省略
    // 4. 获取应用的代码生成类型
    String codeGenTypeStr = app.getCodeGenType();
    CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenTypeStr);
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型");
    }
    // 5. 通过校验后,添加用户消息到对话历史
    chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
    // 6. 调用 AI 生成代码(流式)
    Flux<String> contentFlux = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
    // 7. 收集AI响应内容并在完成后记录到对话历史
    StringBuilder aiResponseBuilder = new StringBuilder();
    return contentFlux
            .map(chunk -> {
                // 收集AI响应内容
                aiResponseBuilder.append(chunk);
                return chunk;
            })
            .doOnComplete(() -> {
                // 流式响应完成后,添加AI消息到对话历史
                String aiResponse = aiResponseBuilder.toString();
                if (StrUtil.isNotBlank(aiResponse)) {
                    chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                }
            })
            .doOnError(error -> {
                // 如果AI回复失败,也要记录错误消息
                String errorMessage = "AI回复失败: " + error.getMessage();
                chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
            });
}

2.关联删除

@Override
public boolean deleteByAppId(Long appId) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    QueryWrapper queryWrapper = QueryWrapper.create()
            .eq("appId", appId);
    return this.remove(queryWrapper);
}

/**
 * 删除应用时关联删除对话历史
 *
 * @param id 应用ID
 * @return 是否成功
 */
@Override
public boolean removeById(Serializable id) {
    if (id == null) {
        return false;
    }
    // 转换为 Long 类型
    Long appId = Long.valueOf(id.toString());
    if (appId <= 0) {
        return false;
    }
    // 先删除关联的对话历史
    try {
        chatHistoryService.deleteByAppId(appId);
    } catch (Exception e) {
        // 记录日志但不阻止应用删除
        log.error("删除应用关联对话历史失败: {}", e.getMessage());
    }
    // 删除应用
    return super.removeById(id);
}

3.游标查询

@EqualsAndHashCode(callSuper = true)
@Data
public class ChatHistoryQueryRequest extends PageRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 消息内容
     */
    private String message;

    /**
     * 消息类型(user/ai)
     */
    private String messageType;

    /**
     * 应用id
     */
    private Long appId;

    /**
     * 创建用户id
     */
    private Long userId;

    /**
     * 游标查询 - 最后一条记录的创建时间
     * 用于分页查询,获取早于此时间的记录
     */
    private LocalDateTime lastCreateTime;

    private static final long serialVersionUID = 1L;
}

/**
 * 获取查询包装类
 *
 * @param chatHistoryQueryRequest
 * @return
 */
@Override
public QueryWrapper getQueryWrapper(ChatHistoryQueryRequest chatHistoryQueryRequest) {
    QueryWrapper queryWrapper = QueryWrapper.create();
    if (chatHistoryQueryRequest == null) {
        return queryWrapper;
    }
    Long id = chatHistoryQueryRequest.getId();
    String message = chatHistoryQueryRequest.getMessage();
    String messageType = chatHistoryQueryRequest.getMessageType();
    Long appId = chatHistoryQueryRequest.getAppId();
    Long userId = chatHistoryQueryRequest.getUserId();
    LocalDateTime lastCreateTime = chatHistoryQueryRequest.getLastCreateTime();
    String sortField = chatHistoryQueryRequest.getSortField();
    String sortOrder = chatHistoryQueryRequest.getSortOrder();
    // 拼接查询条件
    queryWrapper.eq("id", id)
            .like("message", message)
            .eq("messageType", messageType)
            .eq("appId", appId)
            .eq("userId", userId);
    // 游标查询逻辑 - 只使用 createTime 作为游标
    if (lastCreateTime != null) {
        queryWrapper.lt("createTime", lastCreateTime);
    }
    // 排序
    if (StrUtil.isNotBlank(sortField)) {
        queryWrapper.orderBy(sortField, "ascend".equals(sortOrder));
    } else {
        // 默认按创建时间降序排列
        queryWrapper.orderBy("createTime", false);
    }
    return queryWrapper;
}

@Override
public Page<ChatHistory> listAppChatHistoryByPage(Long appId, int pageSize,
                                                  LocalDateTime lastCreateTime,
                                                  User loginUser) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    ThrowUtils.throwIf(pageSize <= 0 || pageSize > 50, ErrorCode.PARAMS_ERROR, "页面大小必须在1-50之间");
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NOT_LOGIN_ERROR);
    // 验证权限:只有应用创建者和管理员可以查看
    App app = appService.getById(appId);
    ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
    boolean isAdmin = UserConstant.ADMIN_ROLE.equals(loginUser.getUserRole());
    boolean isCreator = app.getUserId().equals(loginUser.getId());
    ThrowUtils.throwIf(!isAdmin && !isCreator, ErrorCode.NO_AUTH_ERROR, "无权查看该应用的对话历史");
    // 构建查询条件
    ChatHistoryQueryRequest queryRequest = new ChatHistoryQueryRequest();
    queryRequest.setAppId(appId);
    queryRequest.setLastCreateTime(lastCreateTime);
    QueryWrapper queryWrapper = this.getQueryWrapper(queryRequest);
    // 查询数据
    return this.page(Page.of(1, pageSize), queryWrapper);
}

/**
 * 分页查询某个应用的对话历史(游标查询)
 *
 * @param appId          应用ID
 * @param pageSize       页面大小
 * @param lastCreateTime 最后一条记录的创建时间
 * @param request        请求
 * @return 对话历史分页
 */
@GetMapping("/app/{appId}")
public BaseResponse<Page<ChatHistory>> listAppChatHistory(@PathVariable Long appId,
                                                          @RequestParam(defaultValue = "10") int pageSize,
                                                          @RequestParam(required = false) LocalDateTime lastCreateTime,
                                                          HttpServletRequest request) {
    User loginUser = userService.getLoginUser(request);
    Page<ChatHistory> result = chatHistoryService.listAppChatHistoryByPage(appId, pageSize, lastCreateTime, loginUser);
    return ResultUtils.success(result);
}

4.管理员查询功能

/**
 * 管理员分页查询所有对话历史
 *
 * @param chatHistoryQueryRequest 查询请求
 * @return 对话历史分页
 */
@PostMapping("/admin/list/page/vo")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<ChatHistory>> listAllChatHistoryByPageForAdmin(@RequestBody ChatHistoryQueryRequest chatHistoryQueryRequest) {
    ThrowUtils.throwIf(chatHistoryQueryRequest == null, ErrorCode.PARAMS_ERROR);
    long pageNum = chatHistoryQueryRequest.getPageNum();
    long pageSize = chatHistoryQueryRequest.getPageSize();
    // 查询数据
    QueryWrapper queryWrapper = chatHistoryService.getQueryWrapper(chatHistoryQueryRequest);
    Page<ChatHistory> result = chatHistoryService.page(Page.of(pageNum, pageSize), queryWrapper);
    return ResultUtils.success(result);
}

@Resource
@Lazy
private AppService appService;

对话记忆:

保存到哪里?

LangChain4J提供了对话记忆能力,还能结合redis持久化对话记忆

1)为什么不直接用内存来存储会话记忆?

一个是重启之后会丢失记忆;其次每个应用都在内存中维护对话历史,很容易出现超出内存的情况(OOM)

2)为什么不用Mysql来存储会话记忆?

一方面是因为Redis作为内存数据库,在读写对话记忆时性能更高;另一方面时数据库中的对话历史表包含其他业务字段,不适合交给框架的对话记忆组件管理。

加载历史

由于redis的内存不是无限的,同时也会定时删除,那么在删除的时候,我们就需要在初始化绘画技艺的时候,加载最新的对话记录到Redis中就可以确保AI了解交互历史

对话隔离

每个人的对话应该是相互隔离互不干扰的

开发实现

1.引入依赖

先引入langchain4j的redis的依赖

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-community-redis-spring-boot-starter</artifactId>
    <version>1.0.0-beta3</version>
</dependency>

2.配置redis

添加redis的配置信息:

data:
  redis:
    host: localhost
    port: 6379
    Auth: root

按理来说要添加超时时间,但是这个版本的没有添加超时时间的ttl。

现在我们来创建一个redis的配置类:

/**
 * redis持久化对话配置
 */
@Configuration
@ConfigurationProperties(prefix = "spring.data.redis")
@Data
public class RedisChatMemoryStoreConfig {
        private String host;
        private int port;

        private String password;



       @Bean
    public RedisChatMemoryStore redisChatMemoryStore() {
     return RedisChatMemoryStore.builder()
              .host(host)
              .port(port)
              .password(password)
              .build();
       }

}

在启动类中排除embedding的自动装配,因为此时用不上,其自动装配会导致我们报错

@SpringBootApplication(exclude = {RedisEmbeddingStoreAutoConfiguration.class})

3.使用对话记忆

1)使用langchain4j提供的内置的对话记忆的实现

在aiService中加上@Memory注解

interface AiCodeGeneratorService  {
    HtmlCodeResult generateHtmlCode(@MemoryId int memoryId, @UserMessage String userMessage);
}

利用文档中提供的chatMemoryProvider来为每个memoryId构造其专属的对话记忆,同时需要给其设置上id来区分每一个对话记录

private final RedisChatMemoryStore redisChatMemoryStore;

@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            // 根据 id 构建独立的对话记忆
            .chatMemoryProvider(memoryId -> MessageWindowChatMemory
                    .builder()
                    .id(memoryId)
                    .chatMemoryStore(redisChatMemoryStore)
                    .maxMessages(20)
                    .build())
            .build();
}

现在在使用AI生成方法的时候,就需要多传上一个id参数了,例如:

generateHtmlCode(1, "生成博客网站");
generateHtmlCode(2, "生成电商网站");

2)采用AI Service隔离

我们可以在用户使用的时候来创建新的AIService服务,但是每次发消息都创建服务的话性能太低,所以我们设置如果是同一个appId的话我们就使用同一个AIService服务即可,使用caffeine进行内存存储来进行缓存优化。

1)修改工厂类,根据appId来获取AIservice服务。

@Configuration
public class AiCodeGeneratorServiceFactory {

@Resource
private ChatModel chatModel;

@Resource
private StreamingChatModel streamingChatModel;

@Resource
private RedisChatMemoryStore redisChatMemoryStore;

/**
 * 根据 appId 获取服务
 */
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
    // 根据 appId 构建独立的对话记忆
    MessageWindowChatMemory chatMemory = MessageWindowChatMemory
            .builder()
            .id(appId)
            .chatMemoryStore(redisChatMemoryStore)
            .maxMessages(20)
            .build();
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            .chatMemory(chatMemory)
            .build();
}

4.本地缓存优化

1)引入依赖

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

/**
 * AI 服务实例缓存
 * 缓存策略:
 * - 最大缓存 1000 个实例
 * - 写入后 30 分钟过期
 * - 访问后 10 分钟过期
 */
private final Cache<Long, AiCodeGeneratorService> serviceCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .expireAfterAccess(Duration.ofMinutes(10))
        .removalListener((key, value, cause) -> {
            log.debug("AI 服务实例被移除,appId: {}, 原因: {}", key, cause);
        })
        .build();

/**
 * 根据 appId 获取服务(带缓存)
 */
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
    return serviceCache.get(appId, this::createAiCodeGeneratorService);
}

/**
 * 创建新的 AI 服务实例
 */
private AiCodeGeneratorService createAiCodeGeneratorService(long appId) {
    log.info("为 appId: {} 创建新的 AI 服务实例", appId);
    // 根据 appId 构建独立的对话记忆
    MessageWindowChatMemory chatMemory = MessageWindowChatMemory
            .builder()
            .id(appId)
            .chatMemoryStore(redisChatMemoryStore)
            .maxMessages(20)
            .build();
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            .chatMemory(chatMemory)
            .build();
}

2)修改门面类

@Resource
private AiCodeGeneratorServiceFactory aiCodeGeneratorServiceFactory;

// 根据 appId 获取对应的 AI 服务实例
AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId);

5.对话历史加载

现在我们需要考虑一个问题,当我们的服务没有过期但是缓存过期了怎么办,那这样的话,不久获取不到对话了历史纪录了?所以我们需要在进行AIservice的调用中加上我们对话历史的加载。

代码如下所示:

首先我们构建查询条件,起始点为1而不是0,因为我们在数据库中的会话记忆到缓存的时候,设为0,那么就是把用户最新发的消息加载到了缓存中,但是用户这个消息一开始也会加载到缓存中就会重复。

    QueryWrapper queryWrapper = QueryWrapper.create()
                .eq(ChatHistory::getAppId, appId)
                .orderBy(ChatHistory::getCreateTime, false)
                .limit(1, maxCount);
        List<ChatHistory> historyList = this.list(queryWrapper);
        if (CollUtil.isEmpty(historyList)) {
            return 0;
        }

写法的区别

.eq(ChatHistory::getAppId, appId)
.orderBy(ChatHistory::getCreateTime, false)

 .eq("appId", appId)
.orderBy("createTime", false)

详细说明:
  1. 编译期检查 vs. 运行期检查
    • 写法 A:使用 ChatHistory::getAppId 是 Java 8 的方法引用。编译器会检查 ChatHistory 类中是否存在 getAppId 方法。如果字段名写错(比如写成了 ChatHistory::getAppId2),代码在编译阶段就会报错
    • 写法 B:使用字符串 "appId"。编译器不会检查字符串内容是否对应数据库字段。如果写错(比如写成了 "app_id""appId2"),只有在运行时执行 SQL 时才会抛出异常(如 SQLSyntaxErrorException)。
  2. 重构支持
    • 写法 A:如果你在 IDE 中将 ChatHistory 类的 appId 字段重命名为 applicationId,IDE 会自动更新所有引用该字段的地方,包括查询条件。
    • 写法 B:重构字段名后,字符串 "appId" 不会自动更新,需要手动修改,容易遗漏。
  3. 可读性
    • 写法 A:语义更清晰,直接看到是引用了哪个实体的哪个属性。
    • 写法 B:需要开发者记住数据库字段名,容易出错。
  4. 性能
    • 写法 A:首次运行时需要解析 Lambda 表达式,性能略低(但差异极小,通常可忽略)。
    • 写法 B:直接拼接字符串,性能略高。

3. 实际 SQL 对比

假设 appId = 1001maxCount = 10,生成的 SQL 大致如下:

sql

复制 插入 新文件

SELECT * 
FROM chat_history 
WHERE app_id = 1001 
ORDER BY create_time DESC 
LIMIT 0, 10;

4. 推荐写法

推荐使用写法 A(Lambda 表达式),原因如下:

  1. 类型安全:避免因字段名拼写错误导致的运行时异常。
  2. 重构友好:IDE 重构字段名时自动更新查询条件。
  3. 代码可维护性高:减少硬编码字符串,降低维护成本。

只有在以下特殊场景下才考虑写法 B:

  • 动态字段名(字段名是变量传入的)。
  • 极端性能优化场景(但通常差异可忽略)。

反转列表

我们这里还提到了反转列表,我们在数据库查询的的时候,查询条件是降序查询。这意味着最新的消息在最前面,最旧的消息在最后面。为什么用倒序,因为我们需要的就是最新的数据,最新的数据在下面。

为什么内存中需要使用正序?

我们刚刚为了性能返回的是 新->旧 这样的数据,但是再对话记忆窗口中处理上下文,先需要知道以前干了什么后面干了什么,才可以正确的进行逻辑输出。

注意清空缓存!

因为不清空缓存的话,要是服务过期缓存没过期的话,历史对话记录就会重复!!

@Override
public int loadChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount) {
    try {
        // 直接构造查询条件,起始点为 1 而不是 0,用于排除最新的用户消息
        QueryWrapper queryWrapper = QueryWrapper.create()
                .eq(ChatHistory::getAppId, appId)
                .orderBy(ChatHistory::getCreateTime, false)
                .limit(1, maxCount);
        List<ChatHistory> historyList = this.list(queryWrapper);
        if (CollUtil.isEmpty(historyList)) {
            return 0;
        }
        // 反转列表,确保按时间正序(老的在前,新的在后)
        historyList = historyList.reversed();
        // 按时间顺序添加到记忆中
        int loadedCount = 0;
        // 先清理历史缓存,防止重复加载
        chatMemory.clear();
        for (ChatHistory history : historyList) {
            if (ChatHistoryMessageTypeEnum.USER.getValue().equals(history.getMessageType())) {
                chatMemory.add(UserMessage.from(history.getMessage()));
                loadedCount++;
            } else if (ChatHistoryMessageTypeEnum.AI.getValue().equals(history.getMessageType())) {
                chatMemory.add(AiMessage.from(history.getMessage()));
                loadedCount++;
            }
        }
        log.info("成功为 appId: {} 加载了 {} 条历史对话", appId, loadedCount);
        return loadedCount;
    } catch (Exception e) {
        log.error("加载历史对话失败,appId: {}, error: {}", appId, e.getMessage(), e);
        // 加载失败不影响系统运行,只是没有历史上下文
        return 0;
    }
}

这时候再就修改工厂类中的创建AI服务的方法,在创建完记忆对象的时候我们加载对话到记忆中中

 private AiCodeGeneratorService   createAiCodeGeneratorService(Long appId) {
// 记录日志,表示正在为特定appId创建AiCodeGeneratorService
         log.info("为appId:{} 创建AiCodeGeneratorService",appId);
// 创建聊天记忆对象,使用Redis作为存储后端
            MessageWindowChatMemory chatMemory =    MessageWindowChatMemory.builder()
                 .id(appId)  // 设置记忆ID为appId
                 .chatMemoryStore(redisChatMemoryStore)  // 使用Redis作为聊天记忆存储
                 .maxMessages(20)  // 设置最大消息数量为20
                 .build();

//加载对话历史到记忆中
chatHistoryService.localChatHistoryToMemory(appId,chatMemory,20);
// 构建并返回AiCodeGeneratorService实例
         return AiServices.builder( AiCodeGeneratorService.class)
                 .chatLanguageModel(chatLanguageModel)  // 设置聊天语言模型
                 .streamingChatLanguageModel(streamingChatLanguageModel)  // 设置流式聊天语言模型
                 .chatMemory(chatMemory)  // 设置聊天记忆
                 .build();
    }

6.Redis分布式Session

登录的时候每次都要重新登录,很麻烦,所以我们把session会话保存到redis中,设置过期时间,这样的话就不用每次重新登陆了。

1)引入依赖

<!-- Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2)修改配置

我们首先在session配置中设置session存储的位置以及它的过期时间

​ store-type: redis

​ timeout: 2592000

然后我们在server服务中设置session的cookie的的过期时间

session:
cookie:
max-age: 2592000

#session配置
  session:
    store-type: redis
    #session 30 天过期
    timeout: 2592000
server:
  port: 8080
  servlet:
    context-path: /api
    #cookie 30天过期
    session:
      cookie:
        max-age: 2592000

对话记忆的功能扩展

1.记录用户与其对话的轮数

在APP表中引入DialogueRounds的属性来记录对话轮次。

就是在用户进行对话的时候我们去修改数据库中的APP的DialogueRounds,使其每次对话的时候进行加一。

代码部分如下所示:

 //在调用ai前先调用用户的消息到数据库中
        chatHistoryService.addChatMessage(appId,message, ChatHistoryMessageTypeEnum.USER.getValue(),loginUser.getId());
       //增加对话轮次
        QueryWrapper queryWrapper = QueryWrapper.create()
                .eq(App::getId,appId);
        this.update(App.builder()
                        .DialogueRounds(app.getDialogueRounds()+1)
                        .build(),queryWrapper );
        //6.调用代码生成器生成代码(流式)
        Flux<String>  contentFlux =    aiCodeGeneratorFacade.generatorAndSaveCodeStream(message,type,appId);

2.对话记忆导出为MarkDown文件。

思路

1.先将根据appId来查询对话历史

2.利用MarkdownUtils来将对话历史转换为markdown的形式

3.最后我们通过定义的文件路径,将文件保存在对应位置中。

实现逻辑:

    public boolean exportHistoryMarkdown(Long appId, User loginuser) {

        ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
        ThrowUtils.throwIf(loginuser == null, ErrorCode.NOT_LOGIN_ERROR,"未登录");
        // 验证权限:只有应用创建者和管理员可以导出
        if(!UserConstant.ADMIN_ROLE.equals(loginuser.getUserRole())&&!appService.getById(appId).getUserId().equals(loginuser.getId())){
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR,"无权导出该应用的对话历史");
        }
        // 查询对话历史
      QueryWrapper queryWrapper =   QueryWrapper.create()
                .eq(ChatHistory::getAppId,appId);
      List<ChatHistory> historyList = this.list(queryWrapper);
      //将数据库格式转为markdown格式
        String content = MarkdownUtils.markdown(historyList);
        String export_Markdown_Dir = AppConstant.CODE_EXPORT_MARDOWN_DIR + File.separator +loginuser.getId() +"_"+appId;
        FileUtil.writeString(content,export_Markdown_Dir + File.separator + "history.md", "UTF-8");
        return true;
    }

MarkdownUtils:

这里我们发现我们查询的顺序是正确的,那么为什么还需要这里的按时间排序呢?

这是因为我们数据库的查询不一定是正确的顺序,这样我们来防止脏数据,其次可能这个用法还会被其他的组件复用,用来导出其他的集合信息。

TODO:

这里我原本想将其变为一个复用的方法,但是感觉这种自定义的格式还是不要改动了,就是专门呢来为对话历史服务的。

public class MarkdownUtils {
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * 将聊天历史列表转换为Markdown格式
     * @param historyList 聊天历史列表
     * @return Markdown格式的字符串
     */
    public static String markdown(List<ChatHistory> historyList) {
        if (historyList == null || historyList.isEmpty()) {
            return "# 对话历史\n\n暂无对话记录";
        }

        // 按时间排序
        List<ChatHistory> sortedList = historyList.stream()
                .sorted((h1, h2) -> h1.getCreateTime().compareTo(h2.getCreateTime()))
                .collect(Collectors.toList());

        StringBuilder markdown = new StringBuilder();
        markdown.append("# 对话历史\n\n");

        // 添加基本信息
        if (!sortedList.isEmpty()) {
            ChatHistory first = sortedList.get(0);
            markdown.append("**应用ID**: ").append(first.getAppId()).append("\n\n");
            markdown.append("**用户ID**: ").append(first.getUserId()).append("\n\n");
            markdown.append("**导出时间**: ").append(LocalDateTime.now().format(DATE_FORMATTER)).append("\n\n");
            markdown.append("---\n\n");
        }

        // 添加对话内容
        for (int i = 0; i < sortedList.size(); i++) {
            ChatHistory history = sortedList.get(i);
            markdown.append("## 消息 ").append(i + 1).append("\n\n");
            markdown.append("**时间**: ").append(history.getCreateTime().format(DATE_FORMATTER)).append("\n\n");
            markdown.append("**类型**: ").append(getMessageTypeDisplay(history.getMessageType())).append("\n\n");
            markdown.append("**内容**:\n\n").append(history.getMessage()).append("\n\n");

            if (i < sortedList.size() - 1) {
                markdown.append("---\n\n");
            }
        }

        return markdown.toString();
    }

    /**
     * 获取消息类型的显示文本
     * @param messageType 消息类型
     * @return 显示文本
     */
    private static String getMessageTypeDisplay(String messageType) {
        if ("user".equalsIgnoreCase(messageType)) {
            return "用户";
        } else if ("ai".equalsIgnoreCase(messageType)) {
            return "AI助手";
        } else {
            return messageType;
        }
    }


}

3.增加Ai智能总结用户的对话历史记录功能,减少tokens的使用。

思路:

1.我们可以设置一个定时任务,给用户定期进行总结。

2.在定时任务中,我们为每一个用户的常用的应用(appid)开创一个aiService服务,这个服务的功能就是来进行对用户历史记录进行总结并将其保存到缓存中供用户使用。

3.若用户在与AI进行对话的时候我们就去查询这个总结,要是没有查到,我们在去查询对话的历史记录。

以下是代码实现:

定时任务:

@Component
@Slf4j
public class ChatHistorySummaryTask {

    @Resource
    private AppService appService;

    @Resource
    private AiCodeGeneratorServiceFactory aiCodeGeneratorServiceFactory;

    @Resource
    private UserService userService;

    // 每天凌晨2点执行
    @Scheduled(cron = "0 1/2 * * * ?" )
    public void summarizeActiveAppsHistory() {
        log.info("开始执行对话历史总结任务");

        // 获取所有用户
        List<User> users = userService.list();

        for (User user : users) {
            // 获取每个用户对话最频繁的前5个应用
            List<App> activeApps = appService.getMostActiveApps(user.getId(), 5);

            for (App app : activeApps) {
                try {
                    // 总结对话历史
                    aiCodeGeneratorServiceFactory.createAicodeGeneratorServiceForHistory(app.getId());
                    log.info("成功总结应用 {} 的对话历史", app.getId());
                } catch (Exception e) {
                    log.error("总结应用 {} 的对话历史失败", app.getId(), e);
                }
            }
        }

        log.info("对话历史总结任务执行完成");
    }
}

来创建AI历史服务:

1)在用户正常的对话中:

在这里我们正常的去尝试获取缓存,如果获取到了,就不去加载我们的对话历史到记忆中了。如果没有获取到我们再去加载。

将总结保存在它自己本身的记忆中,然后再使用的时候 去读取这里面的记忆:

一开始我打算作为系统消息传输的,但是最后发现系统消息好像只有一条(不重复,这里我之前以为是记忆的问题,于是还写了一个redis的缓存来存,后来发现不是就删了),所以就用用户消息了,又因为他是在记忆中的,只是保存到缓存的,不会保存到历史记录中,所以用用户消息也可以。(因为我们历史记录的保存,是保存用户的输入,而这个是记忆。ai的回复也只有结合总结和用户消息去回复的,只有单独一个回复)

在这里插入图片描述

  private AiCodeGeneratorService   createAiCodeGeneratorService(Long appId) {
    // 记录日志,表示正在为特定appId创建AiCodeGeneratorService
             log.info("为appId:{} 创建AiCodeGeneratorService",appId);
    // 创建聊天记忆对象,使用Redis作为存储后端
                MessageWindowChatMemory chatMemory =    MessageWindowChatMemory.builder()
                     .id(appId)  // 设置记忆ID为appId
                     .chatMemoryStore(redisChatMemoryStore)  // 使用Redis作为聊天记忆存储
                     .maxMessages(20)  // 设置最大消息数量为20
                     .build();
         //尝试获取对应应用的记忆总结
//         String redisKey = "app:summary:" + appId;
//         String summary = redisTemplate.opsForValue().get(redisKey);

        List<ChatMessage> chatMessageList =  redisChatMemoryStore.getMessages(appId.toString()+"summary_memory");
//         String redisKey = "app:summary:" + appId;
//         String summary = redisTemplate.opsForValue().get(redisKey);
//         if(StrUtil.isNotBlank(summary)){
//             chatMemory.add(SystemMessage.from(summary));
//         }else {
//             //加载对话历史到记忆中
//             chatHistoryService.localChatHistoryToMemory(appId, chatMemory, 20);
//         }
//             // 构建并返回AiCodeGeneratorService实例
//             return AiServices.builder(AiCodeGeneratorService.class)
//                     .chatLanguageModel(chatLanguageModel)  // 设置聊天语言模型
//                     .streamingChatLanguageModel(streamingChatLanguageModel)  // 设置流式聊天语言模型
//                     .chatMemory(chatMemory)  // 设置聊天记忆
//                     .build();


         if(CollUtil.isNotEmpty(chatMessageList)){

             StringBuilder summaryBuilder = new StringBuilder();
             for (ChatMessage message : chatMessageList) {
                 summaryBuilder.append(message.toString()).append("\n");
             }

             // 将组合后的内容作为系统消息添加到聊天记忆中
             chatMemory.add(UserMessage.from("以下是用户与该应用之前对话的内容:" + summaryBuilder));
             log.info("appId:{} 已添加{}条聊天记忆", appId, chatMessageList.size());
         }else {
             //加载对话历史到记忆中
             chatHistoryService.localChatHistoryToMemory(appId, chatMemory, 20);
         }
         // 构建并返回AiCodeGeneratorService实例
         return AiServices.builder(AiCodeGeneratorService.class)
                 .chatLanguageModel(chatLanguageModel)  // 设置聊天语言模型
                 .streamingChatLanguageModel(streamingChatLanguageModel)  // 设置流式聊天语言模型
                 .chatMemory(chatMemory)  // 设置聊天记忆
                 .build();

        }

2)创建服务

   public AiCodeGeneratorService createAicodeGeneratorServiceForHistory(Long appId) {
        log.info("为appId:{} 创建AiCodeGeneratorService", appId);

        // 创建聊天记忆
        MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder()
                .id(appId.toString()+"summary_memory")
                .chatMemoryStore(redisChatMemoryStore)
                .maxMessages(20)
                .build();




            // 如果没有总结,则生成总结
            log.info("appId:{} 没有对话总结,正在生成...", appId);

            // 获取最近的对话历史
            List<ChatHistory> historyList = chatHistoryService.summarizeChatHistory(appId, 40);

            // 构建对话历史文本
            StringBuilder historyText = new StringBuilder();
            for (ChatHistory history : historyList) {
                String role = "user".equals(history.getMessageType()) ? "用户" : "AI";
                historyText.append(role).append(": ").append(history.getMessage()).append("\n");
            }

            // 定义总结提示词
            String summaryPrompt ="""
        你是一个专业的对话总结助手,负责分析并总结用户与AI之间的对话历史。
        
        请用JSON格式总结对话内容,确保输出是有效的JSON对象。
        
        JSON格式要求:
        {
            "topic": "对话主题(不超过10个字)",
            "keyPoints": [
                "关键讨论点1",
                "关键讨论点2",
                "关键讨论点3"
            ],
            "userNeeds": "用户的核心需求和问题",
            "aiSolutions": "AI提供的主要解决方案或建议",
            "pendingIssues": "待解决的问题或后续行动项"
        }
        
        请基于以上要求,总结以下对话内容:
        
        """ + historyText;

            // 调用AI生成总结
          String  summary = chatLanguageModel.chat(summaryPrompt);

            // 将总结保存到Redis,设置7天过期时间
//            redisTemplate.opsForValue().set(redisKey, summary, Duration.ofDays(7));
            log.info("appId:{} 对话总结已生成并保存", appId);


        // 将总结作为系统消息添加到记忆中
        chatMemory.add(SystemMessage.from("以下是用户与该应用之前对话的总结:\n\n" + summary));

        // 创建并返回AI服务
        return AiServices.builder(AiCodeGeneratorService.class)
                .chatLanguageModel(chatLanguageModel)
                .streamingChatLanguageModel(streamingChatLanguageModel)
                .chatMemory(chatMemory)
                .build();
    }


获取最近的对话记录:

@Override
    public List<ChatHistory> summarizeChatHistory(Long appId, int maxMessages) {
        ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
        ThrowUtils.throwIf(maxMessages <= 0, ErrorCode.PARAMS_ERROR, "最大消息数必须大于0");
        // 获取最近的对话记录
        QueryWrapper queryWrapper = QueryWrapper.create()
                .eq(ChatHistory::getAppId, appId)
                .orderBy(ChatHistory::getCreateTime, false)
                .limit(maxMessages);

        List<ChatHistory> historyList = this.list(queryWrapper);

        if (CollUtil.isEmpty(historyList)) {
            return Collections.emptyList();
        }

        return  historyList;
    }

3)设置记忆删除

首先我这个版本的RedisChatMemoryStore是没有设置过期时间的,所以我们需要根据RedisChatMemoryStore来自定义一个可以去自动过期的一个缓存。

这是对其的一个修改:

我们在这里添加了tll过期时间,在updateMessages方法中设置了我们的过期时间

SetParams params = SetParams.setParams().ex(ttl.getSeconds());
String res = this.client.set(toMemoryIdString(memoryId), json, params);


/**
 * 给记忆存储添加过期时间
 */
public class TtlRedisChatMemoryStore implements ChatMemoryStore {
    private final JedisPooled client;
    private final Duration ttl;
    public TtlRedisChatMemoryStore(String host, Integer port, String user, String password,Duration ttl) {
        String finalHost = ValidationUtils.ensureNotBlank(host, "host");
        int finalPort = (Integer)ValidationUtils.ensureNotNull(port, "port");
        this.ttl = ValidationUtils.ensureNotNull(ttl,"ttl");
        if (user != null) {
            String finalUser = ValidationUtils.ensureNotBlank(user, "user");
            String finalPassword = ValidationUtils.ensureNotBlank(password, "password");
            this.client = new JedisPooled(finalHost, finalPort, finalUser, finalPassword);
        } else {
            this.client = new JedisPooled(finalHost, finalPort);
        }

    }
    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        String json = this.client.get(toMemoryIdString(memoryId));
        return (List)(json == null ? new ArrayList() : ChatMessageDeserializer.messagesFromJson(json));
    }
    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        String json = ChatMessageSerializer.messagesToJson((List)ValidationUtils.ensureNotEmpty(messages, "messages"));
        SetParams params = SetParams.setParams().ex(ttl.getSeconds());
        String res = this.client.set(toMemoryIdString(memoryId), json, params);
        if (!"OK".equals(res)) {
            throw new RedisChatMemoryStoreException("Set memory error, msg=" + res);
        }
    }
    @Override
    public void deleteMessages(Object memoryId) {
        this.client.del(toMemoryIdString(memoryId));
    }

    private static String toMemoryIdString(Object memoryId) {
        boolean isNullOrEmpty = memoryId == null || memoryId.toString().trim().isEmpty();
        if (isNullOrEmpty) {
            throw new IllegalArgumentException("memoryId cannot be null or empty");
        } else {
            return memoryId.toString();
        }
    }

    public static TtlRedisChatMemoryStore.Builder builder() {
        return new TtlRedisChatMemoryStore.Builder();
    }

    public static class Builder {
        private String host;
        private Integer port;
        private String user;
        private String password;
        private Duration ttl;

        public Builder() {
        }

        public TtlRedisChatMemoryStore.Builder host(String host) {
            this.host = host;
            return this;
        }

        public TtlRedisChatMemoryStore.Builder port(Integer port) {
            this.port = port;
            return this;
        }

        public TtlRedisChatMemoryStore.Builder user(String user) {
            this.user = user;
            return this;
        }

        public TtlRedisChatMemoryStore.Builder password(String password) {
            this.password = password;
            return this;
        }
        public TtlRedisChatMemoryStore.Builder ttl(Duration ttl) {
            this.ttl = ttl;
            return this;
        }

        public TtlRedisChatMemoryStore build() {
            return new TtlRedisChatMemoryStore(this.host, this.port, this.user, this.password, this.ttl);
        }
    }
}

然后修改配置类:

@Component
@ConfigurationProperties(prefix = "spring.data.redis")
@Data
public class TtlRedisChatMemoryStoreConfig {
    private String host;
    private int port;
    private String password;
    private Duration ttl;

    @Bean
    public TtlRedisChatMemoryStore ttlredisChatMemoryStore() {
        return TtlRedisChatMemoryStore.builder()
                .host(host)
                .port(port)
                .password(password)
                .ttl(ttl)
                .build();
    }
}

这样就可以实现我们的过期了。

那如果用户在有效时间内无限制的进行对话,那样我们的缓存不是无限的增加了?,用户也不一定只会对一个方面进行不断地询问

这样的话我就加上了一个手动的删除,因为前面我们增加了一个属性来记录用户对话的轮次。要是到了指定的轮次,那么就删除缓存

    private Flux<String> processCodeStream(Flux<String> codeStream,CodeGenTypeEnum codeGenType,Long appId) {

        AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId);

        StringBuilder codeBuilder = new StringBuilder();
        App app = appService.getById(appId);


        return codeStream
                .doOnNext(chunk->codeBuilder.append(chunk))
                .doOnComplete(()->{
                    try{
                        //流式返回完成后,保存代码
                        String completeCode =  codeBuilder.toString();
                        //解析代码为对象
                        Object parserResult = CodeParserExecutor.executeParser(completeCode,codeGenType);
                        //使用执行器保存代码到文件
                        File saveDir = CodeFileSaverExecutor.executeSaver(parserResult,codeGenType,appId);
                        log.info("文件路径为"+saveDir.getAbsolutePath());
                    }catch (Exception e){
                        log.error("流式生成HTML代码失败",e);
                    }
                if(app.getDialogueRounds()>=5) {
                    try {
                        chatMemoryStore.deleteMessages(appId.toString());
                        chatMemoryStore.deleteMessages(appId.toString()+"summary_memory");
                    } catch (Exception e) {
                        log.error("清除聊天记忆失败", e);
                    }
                }
                });

但是这样就又有一个问题了,就是到了指定的轮次后要是用户还在使用但是缓存被清除了那怎么办?难道再从数据库中读取吗?

我这里想着这种情况的话应该是用户进行了很多次的对话,我们修改删除的方法,每次就删除缓存内的前十条,这样用户在进行大量对话的时候,就不用再等待从用户历史对话中提取数据了。

我们可以看到删除的实现类:

在这里插入图片描述

对其进行修改:

 @Override
    public void deleteMessages(Object memoryId) {
        this.deleteEarliestMessages(memoryId, DELETE_BATCH_SIZE);
    }


    /*
     *
     * 删除最早的前N条消息
     * @param memoryId 记忆ID
     * @param count 要删除的消息数量
     */
    public void deleteEarliestMessages(Object memoryId, int count) {
        String key = toMemoryIdString(memoryId);

        // 获取当前列表长度
        long currentSize = client.llen(key);

        // 如果列表不为空且要删除的数量小于当前大小
        if (currentSize > 0 && count < currentSize) {
            // 保留从count开始的所有消息,删除前count条
            client.ltrim(key, count, -1);
            // 更新过期时间
            client.expire(key, (int) ttl.getSeconds());
        } else {
            // 如果要删除的数量大于等于当前大小,直接删除整个key
            client.del(key);
        }
    }

到此为止这个功能就开发完了,但是还是存在很多问题,至少基本上勉勉强强可以实现了。。。

4.打算在在另一个项目中进行编写。利用websocket进行多人开发。

ages(appId.toString());
chatMemoryStore.deleteMessages(appId.toString()+“summary_memory”);
} catch (Exception e) {
log.error(“清除聊天记忆失败”, e);
}
}
});




但是这样就又有一个问题了,就是到了指定的轮次后要是用户还在使用但是缓存被清除了那怎么办?难道再从数据库中读取吗?

我这里想着这种情况的话应该是用户进行了很多次的对话,我们修改删除的方法,每次就删除缓存内的前十条,这样用户在进行大量对话的时候,就不用再等待从用户历史对话中提取数据了。

我们可以看到删除的实现类:

[外链图片转存中...(img-cvx4LEAY-1772717472320)]



对其进行修改:

```java
 @Override
    public void deleteMessages(Object memoryId) {
        this.deleteEarliestMessages(memoryId, DELETE_BATCH_SIZE);
    }


    /*
     *
     * 删除最早的前N条消息
     * @param memoryId 记忆ID
     * @param count 要删除的消息数量
     */
    public void deleteEarliestMessages(Object memoryId, int count) {
        String key = toMemoryIdString(memoryId);

        // 获取当前列表长度
        long currentSize = client.llen(key);

        // 如果列表不为空且要删除的数量小于当前大小
        if (currentSize > 0 && count < currentSize) {
            // 保留从count开始的所有消息,删除前count条
            client.ltrim(key, count, -1);
            // 更新过期时间
            client.expire(key, (int) ttl.getSeconds());
        } else {
            // 如果要删除的数量大于等于当前大小,直接删除整个key
            client.del(key);
        }
    }

到此为止这个功能就开发完了,但是还是存在很多问题,至少基本上勉勉强强可以实现了。。。

4.打算在在另一个项目中进行编写。利用websocket进行多人开发。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐