--- title: "08-对话历史模块" created: 2025-12-02 tags: - 项目 aliases: - 对话历史模块 --- # 对话历史模块 ## 本节重点 本节我们将为 AI 零‍代码应用生成平台添加对话历史管理功能,让用户⁡能够查看和管理历史对话记录。这一节的核心在于‎实现完整的对话记忆体系,让 AI 能够基于历‎史上下文进行网站的迭代优化,用户可以通过多轮对话来完善和优化生成的网站应用。 本节主要内容: - 对话历史游标方案设计 - 对话历史的保存和查询(后端 + 前端) - 对话记忆持久化 - Redis 分布式 Session ## 一、需求分析 在前面的章节中,我们实现了 AI 生成应用的核心功能,但存在一个明显的问题:**每次对话都是独立的,AI 无法记住之前的交互内容。**这导致用户无法基于已生成的网站进行迭代改进,极大地限制了平台的实用性。 举个例子:用户首先让‍ AI 生成一个博客网站,然后希望在此基⁡础上添加评论功能,最后再优化一下页面‎样式。在没有对话记忆的情况下,每次都需要重新‎描述完整需求,AI 也会重新生成整个网站,而不是在原有基础上进行改进。 因此,我们需要实现以下需求: 1)对话历史的‍持久化存储:用户发送消息时,⁡需要保存用户消息;AI 成功‎回复后,需要保存 AI 消息‎。即使 AI 回复失败,也要记录错误信息,确保对话的完整性。 2)应用级‍别的数据隔离:每个应⁡用的对话历史都是独‎立的。删除应用时,需要‎关联删除该应用的所有对话历史,避免数据冗余。 3)对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 **向前加载** 更多历史记录。(仅应用创建者和管理员可见) 详细来说,进入应用页面时,前‍端根据应用 id 先加载一次对话历史消息,关联查询最新⁡ 10 条消息。如果存在历史对话,直接展示;如果没有历‎史记录,才自动发送初始化提示词。这样就解决了之前浏览别‎人的应用时意外触发对话的问题 4)管理对‍话历史:管理员可以⁡查看所有应用的对话‎历史,按照时间降序‎排序,便于内容监管。 ## 二、方案设计 ### 分页查询 对于对话历史‍(聊天记录)的分页查询⁡,不建议使用传统分页‎查询。         ‎ 为什么呢? ### 传统分页查询的问题 在传统分页中,数据通常是 **基于页码或偏移量** 进行加载的。如果数据在分页过程发生了变化,比如插入新数据、删除老数据,用户看到的分页数据可能会出现不一致,导致用户错过或重复某些数据。 举个例子,假设用户会持‍续收到新的消息。如果按照传统分页基于偏移量⁡加载,第一页已经加载了第 1 - 5 行的‎数据,本来要查询的第二页数据是第 6 - ‎10 行(对应的 SQL 语句为 limit 5, 5),数据库记录如下: ![[image-4b377b77.png]] 结果在查询‍第二页前,突然用户又⁡收到了 5 条新消息‎,数据库记录就变成了下‎面这样。原本的第一页,变成了当前的第二页! ![[image-3aac99f8.png]] 这样就导致‍查询出的第二页数据⁡,正好是之前已经查‎询出的第一页的数据‎,造成了消息重复加载。 此外,传统的 offset 分页方式在处理大量对话数据时存在严重的性能问题。假设一个热门应用积累了几万条对话记录后,如果用户想查看较早的历史消息,执行 `LIMIT 10000, 10` 这样的查询会非常缓慢。 这是因为数据库需要‍先扫描和跳过前面的 10000 条⁡记录,才能返回用户真正需要的 10 条‎数据。随着 offset 值的‎增大,查询性能会线性下降,在高并发场景下很容易成为系统瓶颈。 ### 游标查询 为了解决这些问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,**每次请求从上一次请求的游标开始加载数据。** 一般我们会选择数据记录的‍唯一标识符(主键)、时间戳、或者具有排序能力的字⁡段作为游标。比如即时通讯系统中的每个消息,通常都‎有一个唯一自增的 id,就可以作为游标。每次查询‎完当前页面的数据后,可以将最后一条消息记录的 id 作为游标值传递给前端(客户端)。 ![[image-57909e6b.png]] 当要加载下一‍页时,前端携带游标值发起⁡查询,后端操作数据库从 ‎id 小于当前游标值的数‎据开始查询,这样查询结果就不会受到新增数据的影响。 ![[image-812a3bf4.png]] 标准实践建议优先使用 id ‍作为游标,因为主键性能最优且不重复。但针对我们的场景,按⁡时间排序是核心需求,而且同一个 appId 下时间重复的‎可能性极低,所以直接使用对话历史的创建时间 create‎Time 作为游标是完全可行的。不需要额外带上对话历史的 id 作为复合游标,简化了游标查询的逻辑。 示例 SQL 语句如下: ```sql SELECT * FROM chat_history WHERE appId = 123 AND createTime < '2025-07-29 10:30:00' ORDER BY createTime DESC LIMIT 10; ``` 而且还可以‍给 appId 和⁡ createTi‎me 增加复合索引‎,进一步提高检索效率。这样执行过程就变成了: 1. 直接定位到 (appId=123, createTime<‘2025-07-29 10:30:00’) 的索引位置 2. 顺序读取 10 条记录 3. 完成查询 几乎只有 10 次读取成本! 再举个明显的‍对比例子,假设一个热门⁡聊天室有 10 万条对‎话记录,用户要查看 1‎ 个月前的消息(大约在第 3000 页): | 查询方式 | SQL语句 | 执行时间 | 资源消耗 | | --- | --- | --- | --- | | Offset 分页 | LIMIT 30000, 10 | 800ms | 需要扫描 30000 条记录 | | 游标查询 ‍ | createTi⁡me < ‘2025‎-07-29’ | ‎12ms | 直接定位,仅读取 10 条 | 💡 鱼皮之前写过一篇 [关于游标的文章](https://mp.weixin.qq.com/s/Z1Fnfju37DCeJufiu6DUiw),感兴趣的同学可以阅读。 ### 库表设计 ### 1、核心设计 根据我们的方案,可以设计出对话历史表的结构。为了避免与 AI 库的 ChatMessage 冲突,表名采用 `chat_history`: ```sql -- 对话历史表 create table chat_history ( id bigint auto_increment comment 'id' primary key, message text not null comment '消息', messageType varchar(32) not null comment 'user/ai', appId bigint not null comment '应用id', userId bigint not null comment '创建用户id', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除', INDEX idx_appId (appId), -- 提升基于应用的查询性能 INDEX idx_createTime (createTime), -- 提升基于时间的查询性能 INDEX idx_appId_createTime (appId, createTime) -- 游标查询核心索引 ) comment '对话历史' collate = utf8mb4_unicode_ci; ``` 这个设计中最关键的是复合索引 `idx_appId_createTime`,能够大幅提高游标查询的效率。 ### 2、扩展设计 1)可以按需添加 `parentId` 字段,将 AI 消息和对应的用户提示词进行关联,便于生成失败时的重试、或者用户手动重新生成。 ```text parentId bigint null comment '父消息id(用于上下文关联)', ``` 2)如果需要保存每个版本的代码文件,还可以添加 `fileList` 字段,结构为 JSON 数组格式,这样每条消息就对应一个代码版本。 不过代码文件很大时,存到数据库里不是一个合适的选择。 ## 三、对话历史后端开发 ### 基础代码生成 首先使用 MyBatis Flex 生成器生成基础代码: ![[image-49e928ac.png]] 将生成的文件移动到对应的包下。 然后修改 ‍ChatHisto⁡ry 类,将主键 ‎ID 的生成策略改‎为雪花算法: ```java /** * id */ @Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId) private Long id; ``` ### 业务代码生成 - Vibe Coding 之前我们已经‍写了清晰的需求描述,并⁡且项目中已有多个模块的‎代码,可以给 AI‎ 参考。因此这里直接使用 AI 来生成业务代码。 在 Cursor 中打开 **后端项目根目录**,执行以下提示词: ```text 请参考项目中已有的 User 和 APP 模块的文件和代码风格,帮我根据下列需求,生成完整的 ChatHistory 模块的后端代码。 ## 需要的功能如下 1)对话历史的持久化存储:用户发送消息时,需要保存用户消息;AI 成功回复后,需要保存 AI 消息。即使 AI 回复失败,也要记录错误信息,确保对话的完整性。 2)应用级别的数据隔离:每个应用的对话历史都是独立的。删除应用时,需要关联删除该应用的所有对话历史,避免数据冗余。 3)对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 向前加载 更多历史记录。(仅应用创建者和管理员可见) 详细来说,进入应用页面时,前端根据应用 id 先加载一次对话历史消息,关联查询最新 10 条消息。如果存在历史对话,直接展示;如果没有历史记录,才自动发送初始化提示词。这样就解决了之前浏览别人的应用时意外触发对话的问题。 4)管理对话历史:管理员可以查看所有应用的对话历史,按照时间降序排序,便于内容监管。 ## 实现提示 1)需要为 messageType 创建一个枚举类 ``` 注意,上述‍提示词中,我特地给⁡ AI 了一个引导‎,让它为 mess‎ageType 创建一个枚举类。 生成过程如图: ![[image-4f57e1f4.png]] AI 生成了完整的枚举类,代码如下: ```java @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 回复成功还是失败,都要留下完整的对话记录,确保用户能够了解完整的交互历史。 可以在 `ChatHistoryServiceImpl` 中提供统一的保存接口: ```java @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); } ``` 然后在 `AppServiceImpl` 的 `chatToGenCode` 方法中集成对话历史保存逻辑。这里利用了 Flux 响应式编程的特性,可以在流式响应的过程中收集完整的 AI 回复: ```java @Resource private ChatHistoryService chatHistoryService; @Override public Flux 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 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、关联删除 当应用被删除时,需要同步清理对话历史数据。 在 `ChatHistoryServiceImpl` 中提供根据 appId 删除的方法: ```java @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); } ``` 然后在 `AppServiceImpl` 中重写 `removeById` 方法,实现关联删除: ```java /** * 删除应用时关联删除对话历史 * * @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、游标查询 游标查询是本节的技术重点,开发时一定要仔细。 1)先在 `model.dto.chathistory` 包下新建包含游标字段的请求对象: ```java @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; } ``` 2)在 `ChatHistoryServiceImpl` 开发查询包装类的构造方法: ```java /** * 获取查询包装类 * * @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; } ``` 3)在 `ChatHistoryServiceImpl` 编写核心的游标查询服务方法: ```java @Override public Page 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); } ``` 4)最后在 `ChatHistoryController` 开发游标查询接口: ```java /** * 分页查询某个应用的对话历史(游标查询) * * @param appId 应用ID * @param pageSize 页面大小 * @param lastCreateTime 最后一条记录的创建时间 * @param request 请求 * @return 对话历史分页 */ @GetMapping("/app/{appId}") public BaseResponse> listAppChatHistory(@PathVariable Long appId, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) LocalDateTime lastCreateTime, HttpServletRequest request) { User loginUser = userService.getLoginUser(request); Page result = chatHistoryService.listAppChatHistoryByPage(appId, pageSize, lastCreateTime, loginUser); return ResultUtils.success(result); } ``` ### 4、管理员查询功能 管理员可以‍分页查询所有应用的⁡对话历史消息列表,‎按照时间降序排序。 这个功能比较简单,直接开发分页查询接口就好: ```java /** * 管理员分页查询所有对话历史 * * @param chatHistoryQueryRequest 查询请求 * @return 对话历史分页 */ @PostMapping("/admin/list/page/vo") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse> 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 result = chatHistoryService.page(Page.of(pageNum, pageSize), queryWrapper); return ResultUtils.success(result); } ``` --- 接下来就可以测试了,如果在开发过程中遇到循环依赖问题导致项目无法启动,可以使用 `@Lazy` 注解解决。比如在 `ChatHistoryServiceImpl` 引入 AppService 时添加注解: ```java @Resource @Lazy private AppService appService; ``` ## 四、对话历史前端开发 后端开发完成后,记得先执行 `openapi` 命令,根据接口生成前端请求和数据模型代码。 ### 功能实现 - Vibe Coding 这次的前端开发量并不大,核心就 2 点: 1. 修改应用对话页面,加载对话历史 2. 新增对话管理页面 可以直接交给 AI 来生成: ```text 你是一位专业的前端开发,帮我根据下列信息,参考项目已有的代码风格,生成符合要求的完整代码。 ## 需求 1)修改应用对话页面。 - 进入应用对话页面时,前端调用游标查询对话历史接口,根据应用 id 加载一次最近 10 条对话历史消息,按照消息创建时间的升序展示在对话区域(区分 AI 和用户消息)。 - 如果消息数量超过 10 条(10 条为一页),可以点消息上方的加载更多,利用游标加载下一页历史消息。(我想前端需要维护已加载的消息列表) - 修改自动发送初始消息的逻辑。移除之前页面 url 的 view 参数,如果是自己的 app,并且没有对话历史,才自动将 initPrompt 作为第一条消息触发对话。 - 修改网站展示的逻辑。进入页面时,如果 app 有至少 2 条对话记录,也展示对应的网站。 2)新增对话管理页面。完全参考应用管理页面实现 ## 后端接口 已经在 @api 目录下生成了后端请求代码和数据类型信息。 ``` AI 生成的代码如下: 1)路由文件新增对话管理页: ```java { path: '/admin/chatManage', name: '对话管理', component: ChatManagePage, }, ``` 2)修改应用对话生成页面,完整代码如下: 3)新增对话管理页面,完整代码如下: ```java ``` 其中,游标查询和消息列表维护是前端的技术重点,代码如下: ```typescript // 核心状态定义 const messages = ref([]) // 消息列表 const loadingHistory = ref(false) // 加载状态 const hasMoreHistory = ref(false) // 是否还有更多历史 const lastCreateTime = ref() // 游标:最后一条消息的创建时间 // 加载对话历史的核心逻辑 const loadChatHistory = async (isLoadMore = false) => { if (loadingHistory.value) return loadingHistory.value = true try { // 构建请求参数 const params = { pageSize: 10, // 关键:使用最后一条消息时间作为游标 lastCreateTime: isLoadMore ? lastCreateTime.value : undefined } const res = await api.getChatHistory(params) const chatHistories = res.data.records || [] if (chatHistories.length > 0) { // 将后端数据转换为前端消息格式 const historyMessages = chatHistories .map(chat => ({ type: chat.messageType === 'user' ? 'user' : 'ai', content: chat.message || '', createTime: chat.createTime, })) .reverse() // 关键:反转让老消息在前 if (isLoadMore) { // 加载更多:添加到列表开头 messages.value.unshift(...historyMessages) } else { // 初始加载:直接设置 messages.value = historyMessages } // 关键:更新游标为最老消息的时间 lastCreateTime.value = chatHistories[chatHistories.length - 1]?.createTime // 判断是否还有更多数据 hasMoreHistory.value = chatHistories.length === 10 } else { hasMoreHistory.value = false } } finally { loadingHistory.value = false } } ``` 这段代码的重点是: 1. 状态管理:通过 `lastCreateTime` 维护游标状态,确保分页的连续性 2. 消息排序:后端返回的是时间降序,前端需要反转后再添加到列表 3. 增量加载:新消息添加到数组开头,保持时间顺序的正确性 4. 边界处理:通过返回记录数判断是否还有更多数据 ### 常见问题 - 消息顺序相反 在开发过程中可能会遇到消息顺序相反的问题,比如下图: ![[image-74a076bf.png]] 可以让 AI 修复问题,轻轻松松~ ```text 目前加载后展示的消息顺序反了,应该像聊天记录一样上方展示老的,下方展示新的 ``` 通过调整数据处理逻辑,确保消息按照正确的时间顺序展示: ![[image-95c508e0.png]] ## 五、对话记忆 ### 需求分析 很多时候 ‍AI 生成的网站没⁡办法一次性满足用户‎的需求,因此需要提‎供网站修改功能。 但是目前我们‍的 AI 对话会断片儿⁡,无法记住之前的对话内‎容,每次修改实际上都是‎重新生成完整的网站,而不是在原有基础上进行修改。 ![[image-77789b0a.png]]![[image-52f16f75.png]] 这种情况下‍,用户不能进行迭代⁡式的网站开发,每次‎都是开盲盒,极大地‎限制了平台的实用性。 ### 方案设计 要解决这个问题,就要给 AI 增加对话记忆能力。 ### 保存到哪儿? LangC‍hain4j 不仅⁡提供了对话记忆能力‎,而且还能结合 R‎edis 持久化对话记忆,非常爽~ 这里可能有同学会好奇 2 个问题: 1)为什么不直接用内存来存储会话记忆? 首先是重启‍后会丢失记忆;其次⁡如果每个应用都在内‎存中维护对话历史,‎很容易出现 OOM 问题。 2)为什么不用 MySQL 来存储会话记忆? 一方面是因为 R‍edis 作为内存数据库,在读写⁡对话记忆时性能更高;另一方面是数‎据库中的对话历史表包含其他业务字‎段,不适合直接交给 LangChain4j 的对话记忆组件管理。 ### 加载历史 要注意,**Redis 的内存也不是无限的!**一般情况下要给存入 Redis 的每个 Key 都设置合理的过期时间,不能不过期。所以这就可能导致 Redis 的会话记忆被删除的情况。 怎么解决呢? 方案很简单,之‍前我们已经在数据库中保存了用⁡户和 AI 的消息,只需要在‎初始化会话记忆时,加载最新的‎对话记录到 Redis 中,就能确保 AI 了解交互历史。 流程:AI‍ 对话 => 从数⁡据库中加载对话历史‎到 Redis =‎> Redis 为 AI 提供对话记忆 ### 对话隔离 此外,每个‍应用的对话记忆应该是⁡相互隔离的,Lang‎Chain4j 也提‎供了对话记忆隔离的能力,下面开发实现中会讲解。 ### 开发实现 ### 1、引入依赖 参考 [LangChain4j 官方文档](https://docs.langchain4j.dev/integrations/embedding-stores/redis),引入必要的依赖: ```xml dev.langchain4j langchain4j-community-redis-spring-boot-starter 1.1.0-beta7 ``` 这个依赖会‍引入 Redis ⁡的 Jedis 客‎户端,以及与 La‎ngChain4j 的整合组件。 ### 2、配置 Redis 1)在配置文件中添加 Redis 连接信息: ```yaml spring: # redis data: redis: host: localhost port: 6379 password: ttl: 3600 ``` 注意,这里的 `ttl` 不是连接 Redis 的超时时间(timeout),而是过期时间(单位:秒),我这里设置 1 小时。前面也提到,如果消息数比较多,不设置的话 Redis 内存很容易占满。 ![[image-4e80a8d0.png]] 2)在 `config` 下新建 Redis 对话记忆存储配置类,初始化 RedisChatMemoryStore 的 Bean: ```java @Configuration @ConfigurationProperties(prefix = "spring.data.redis") @Data public class RedisChatMemoryStoreConfig { private String host; private int port; private String password; private long ttl; @Bean public RedisChatMemoryStore redisChatMemoryStore() { return RedisChatMemoryStore.builder() .host(host) .port(port) .password(password) .ttl(ttl) .build(); } } ``` 注意,如果‍你的 Redis ⁡密码不为空,上述配‎置中还要添加 us‎er 用户名配置: ```java RedisChatMemoryStore.builder() .user("你的用户名") ``` 如果你的密码为空,那么就不用添加了,根据情况选择就好。 3)在启动‍类中排除 embe⁡dding 的自动‎装配,因为本项目用‎不到: ```java @SpringBootApplication(exclude = {RedisEmbeddingStoreAutoConfiguration.class}) ``` 否则会报错: ![[image-22666610.png]] ### 3、使用对话记忆 不同 ap‍pId 的对话记忆⁡是独立隔离的,利用‎ LangChai‎n4j 可以有 2 种实现方案。 ### 方案 1 - 内置隔离机制 参考 [官方文档](https://docs.langchain4j.dev/tutorials/ai-services#chat-memory),可以给 AI 服务方法增加 `memoryId` 注解和参数,然后通过 `chatMemoryProvider` 为每个 appId 分配对话记忆。 由于我们目‍前使用的是同一个 ⁡AiService‎ 实例,先采用这种‎方式,好像修改成本更低。 修改 AiService 方法: ```typescript interface AiCodeGeneratorService { HtmlCodeResult generateHtmlCode(@MemoryId int memoryId, @UserMessage String userMessage); } ``` 在工厂类中创建 AI Service 时,我们必须通过 `chatMemoryProvider` 为每个 memoryId 来构造专属的 MessageWindowChatMemory。注意,必须为 MessageWindowChatMemory 设置 id,因为使用的是同一个 Redis 存储实例,否则 Redis 中的存储 key 都是 default,无法区分不同的对话。 ```java 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 参数: ```java generateHtmlCode(1, "生成博客网站"); generateHtmlCode(2, "生成电商网站"); ``` 编写单元测试,测试 2 场不同的对话: ```java @Test void testChatMemory() { HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(1, "做个程序员鱼皮的工具网站,总代码量不超过 20 行"); Assertions.assertNotNull(result); result = aiCodeGeneratorService.generateHtmlCode(1, "不要生成网站,告诉我你刚刚做了什么?"); Assertions.assertNotNull(result); result = aiCodeGeneratorService.generateHtmlCode(2, "做个程序员鱼皮的工具网站,总代码量不超过 20 行"); Assertions.assertNotNull(result); result = aiCodeGeneratorService.generateHtmlCode(2, "不要生成网站,告诉我你刚刚做了什么?"); Assertions.assertNotNull(result); } ``` 执行单元测‍试,在 Redis⁡ 中能看到历史对话‎记忆,每个 id ‎对应了一个 key: ![[image-b5e40091.png]] 如果不传入 id,默认为 default: ![[image-9b963fb8.png]] Redis 中存储的内容结构: ```typescript [ { "text": "你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。\\n\\n你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。\\n\\n约束:\\n1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。\\n2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。\\n3. 独立文件: 必须将所有的 CSS 代码都内联在 `` 标签的 `\\\\n\\\\n\\\\n

鱼皮的工具网站

\\\\n
\\\\n

这是一个简单的工具网站,专为程序员鱼皮设计。

\\\\n
\\\\n\\\\n\\",\\n\\"description\\": \\"这是一个非常简洁的工具网站,专为程序员鱼皮设计。网站包含一个标题和一个简单的工具描述区域,使用了基本的HTML和CSS来实现响应式布局。整个代码量控制在20行以内,符合要求。\\"\\n}", "toolExecutionRequests": [], "type": "AI" }, { "contents": [ { "text": "不要生成网站,告诉我你刚刚做了什么?\\nYou must answer strictly in the following JSON format: {\\n\\"htmlCode\\": (HTML代码; type: string),\\n\\"description\\": (生成代码的描述; type: string)\\n}", "type": "TEXT" } ], "type": "USER" }, { "text": "{\\n\\"htmlCode\\": \\"\\",\\n\\"description\\": \\"我刚刚创建了一个非常简洁的工具网站HTML代码,专为程序员鱼皮设计。这个网站包含一个标题和一个简单的工具描述区域,使用了基本的HTML和CSS来实现响应式布局。整个代码量控制在20行以内,符合用户的要求。\\"\\n}", "toolExecutionRequests": [], "type": "AI" } ] ``` ### 方案 2 - AI Service 隔离 之前所有应用共用‍同一个 AI Service 实⁡例,如果想隔离会话记忆,可以给每‎个应用分配一个专属的 AI Se‎rvice,每个 AI Service 绑定独立的对话记忆。 修改 AI‍ Service ⁡工厂类,提供根据 ‎appId 获取 ‎AI Service 服务的方法: ```java @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(); } } ``` 为了保证跟‍之前的代码兼容,仍⁡然默认提供一个 A‎I Service‎ 的 Bean: ```java /** * 默认提供一个 Bean */ @Bean public AiCodeGeneratorService aiCodeGeneratorService() { return getAiCodeGeneratorService(0L); } ``` ### 4、本地缓存优化 基于方案 2,我们可以利用 [Caffeine 本地缓存](https://github.com/ben-manes/caffeine) 进一步优化性能。 每次构造完 appId‍ 对应的 AI 服务实例后,利用 Caff⁡eine 缓存来存储,之后相同 appId‎ 就能直接获取到 AI 服务实例,避免重‎复构造。注意,本地缓存占用的是内存,所以必须设置合理的过期策略防止内存泄漏。 先引入 Caffeine 依赖: ```xml com.github.ben-manes.caffeine caffeine ``` 优化 Ai‍CodeGener⁡atorServi‎ceFactory‎,增加缓存逻辑: ```java /** * AI 服务实例缓存 * 缓存策略: * - 最大缓存 1000 个实例 * - 写入后 30 分钟过期 * - 访问后 10 分钟过期 */ private final Cache 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(); } ``` 最后修改 `AiCodeGeneratorFacade`,所有方法使用的 AI Service 改为通过工厂根据 appId 获取 AI Service: ```java @Resource private AiCodeGeneratorServiceFactory aiCodeGeneratorServiceFactory; // 根据 appId 获取对应的 AI 服务实例 AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId); ``` 使用这种方案,‍不需要改动 AI Serv⁡ice 本身的代码,更符合开闭‎原则。        ‎ ### 5、历史对话加载 根据方案,‍对话记忆初始化时,⁡需要从数据库中加载‎对话历史到记忆中。 在 `ChatHistoryService` 中开发加载方法: ```java @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 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; } } ``` 注意上述代码中的几个重要细节: 1. 查询起始点设置为 1 而不是 0,这是为了排除最新的用户消息。因为在对话流程中,用户消息被添加到数据库后,AI 服务也会自动将用户消息添加到记忆中,如果不排除会导致消息重复。 2. 注意反转从数据库中查到的消息列表,确保加载到记忆中的消息是按时间正序的。 3. 加载前先清理 Redis 中的历史对话记忆,防止重复加载。 然后就可以‍在初始化 AI Se⁡rvice 的对话记‎忆时调用了,这相当于‎是懒加载,对话时才会加载记忆,节约内存。 ```java private AiCodeGeneratorService createAiCodeGeneratorService(long appId) { log.info("为 appId: {} 创建新的 AI 服务实例", appId); // 根据 appId 构建独立的对话记忆 MessageWindowChatMemory chatMemory = MessageWindowChatMemory .builder() .id(appId) .chatMemoryStore(redisChatMemoryStore) .maxMessages(20) .build(); // 从数据库加载历史对话到记忆中 chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20); return AiServices.builder(AiCodeGeneratorService.class) .chatModel(chatModel) .streamingChatModel(streamingChatModel) .chatMemory(chatMemory) .build(); } ``` 数据加载肯‍定是耗时操作,所以⁡我们引入本地缓存的‎含金量又提高了~ ### 6、测试验证 直接通过前端测试验证: 1)重新创‍建应用,进行多轮对⁡话,查看 Redi‎s 中的记忆是否正‎确保存: ![[image-e9d0bdd4.png]] 2)查看历‍史应用,进行对话,⁡查看 Redis ‎中的对话记忆是否正‎确加载: ![[image-53a38d74.png]] 测试发现,对话记忆中的消‍息数是正确的,美中不足的是系统消息不在最开头,⁡但这个影响不是很大。如果要解决这个问题,可以将‎系统消息作为每个应用的第一条消息保存到对话历史‎中,这样加载时直接一起加载;但是返回给前端时要过滤掉,因为系统消息不应该对用户可见。 总之,效果‍符合预期,AI 现⁡在能够基于历史对话‎进行网站的迭代‎优化了。 ![[image-9a07ed23.png]] ## 六、Redis 分布式 Session 既然已经整合了 ‍Redis,我们可以顺便优化一下⁡用户登录态的管理。之前每次重启服‎务器都需要重新登录,现在可以使用‎ Redis 管理 Session 登录态,实现分布式会话管理。 操作方式也很简单,1 分钟就能完成。 1)先在 Maven 中引入 `spring-session-data-redis` 库: ```xml org.springframework.session spring-session-data-redis ``` 2)修改 `application.yml` 配置文件,更改 Session 的存储方式和过期时间: ```yaml spring: # session 配置 session: store-type: redis # session 30 天过期 timeout: 2592000 server: port: 8123 servlet: context-path: /api # cookie 30 天过期 session: cookie: max-age: 2592000 ``` 这就搞定了,‍现在用户的登录状态会保存⁡在 Redis 中。重启‎服务器后,不需要重新登陆‎,并且在 Redis 中可以看到登录相关的 key: ![[image-4b2040bf.png]] ## 七、扩展思路 通过本期开‍发,平台已经具备了⁡完整的对话记忆能力‎,但还有很多可以优‎化和扩展的方向。 ### 1、记录应用对话总轮次 统计每个应‍用的对话轮数,这个⁡数据可以用于分析用‎户使用习惯,也可以‎作为应用复杂度的参考指标。 ### 2、对话历史导出功能 支持导出对‍话记录为 Mark⁡down 文件,方‎便用户保存和分享开‎发过程。 ### 3、智能记忆管理(较难) 利用 AI‍ 分析对话次数较多⁡的应用,智能总结过‎去的对话历史,节省‎ Token 的同时优化记忆效果。 ### 4、多人协作对话(较难) 允许多个用户共同参与一个应用的对话,实现团队协作开发。在 [编程导航的智能协同云图库项目](https://www.codefather.cn/course/1864210260732116994) 中给大家讲解过怎么实现多人协同操作。 --- **项目分区导航**:⬅️ [[07-AI_零代码应用生成平台源码|07-AI_零代码应用生成平台源码]] | 08-对话历史模块 | ➡️ [[09-工程项目生成|09-工程项目生成]]