AI 零代码应用生成平台(优化对话记忆模块)
问题描述
在完成项目第 9 期的开发,进行测试时发现了一个问题,进入某个 app 选中元素进行修改操作,一开始 AI 会正常调用工具进行修改,在修改完成后刷新页面会发现页面修改成功。这时进入到另一个 app 进行修改操作,也修改成功了,然后再回到上一个 app 进行修改操作时,发现 AI 返回的输出结果和之前的修改操作是一样的,但是页面并未修改,到后台查看源码也是没有修改。在后台打断点发现 AI 并没用调用工具,而是自己生成了修改操作的结果信息直接返回了!

一下给我惊呆了,也气笑了,这大模型居然这么聪明,还会跳过工具调用直接生成结果信息糊弄我,也是让它摸上鱼了。
尝试解决
在询问 CodeGeeX 后决定修改系统提示词,强制大模型在用户进行修改操作时调用工具,禁止跳过工具调用直接返回工具调用的结果信息:
测试之后发现还是没有用,于是又在用户提示词也强制大模型必须调用工具进行修改,这时才真正调用工具修改成功了:
在控制台发现工具调用成功的信息:

问题根源
询问群里的朋友们有没有遇到同样的问题,终于大佬给我解惑了,原来是对话记忆缓存的问题:
在进入第一个应用进行修改时,对话记忆里没有修改操作的信息,选中元素让大模型修改会正常调用工具进行操作,同理,进入第二个应用进行修改也能够修改成功。然后重新进入第一个应用,此时如果第一个应用的对话记忆缓存已经过期失效,那些工具调用相关的对话记忆也就消失了,因为存入 MySQL 库中的对话历史是进行过处理的,去除了那些影响用户交互体验的内容,但是这些信息其实又是比较重要的!重新创建该应用的 AI Service 时会将 MySQL 库中的对话历史加载到 redis 缓存,由于没有了工具调用的记忆,再次让 AI 进行修改操作时,它就跳过了工具调用的操作,模仿之前的格式直接返回工具调用的结果信息,这就是它这么“聪明”的原因!
随后我在后台数据库将一个 AI 会“偷懒”的 app 的修改操作相关对话历史全都逻辑删除,重启项目选择元素进行修改操作,AI 果然就恢复了之前的能力,但是首次修改操作会修改大量其他内容,第二次开始就只会修改指定元素的内容了,而且在修改多次之后它好像又开始“偷懒”...
解决方案
方案一
首先是群里大佬给的方案:修改加载对话记忆的部分,过滤掉 AI 输出的修改信息 思考了一下,对于这个方案有一些问题:
- 过滤 AI 输出的修改信息,是对其输出的修改信息进行处理,省略那些修改操作相关的字眼,将剩余内容加载进入缓存;还是将其整条过滤掉,只加载其他对话历史进入缓存。
- 用户要求 AI 修改内容的提示词是否也要去掉。如果要去掉,同时 AI 输出的修改信息也去掉了,那由于对话记忆的缺失,会不会导致 AI 回复的准确性下降;如果不去掉,AI 回复用户提示词输出的修改信息却去掉了,AI 会不会重复进行之前的修改操作(之前好像有过中断 AI 的响应后,再次与其对话时 AI 还会处理上一条提示词)
考虑了一下省略其中修改操作相关的字眼,于是让 CodeGeeX 帮助生成一个提示词的过滤工具,代码如下:
▼java复制代码/** * 提示词过滤器工具类 */ public class PromptFilterUtil { // 预定义的关键词和模式 private static final String[] CORE_REQUIREMENT_KEYWORDS = { "创建", "生成", "设计", "实现", "开发", "构建", "网站", "应用", "系统", "平台", "页面", "项目" }; private static final String[] MODIFY_PATTERNS = { "改为", "修改成", "换成", "更新为", "改成", "修改.*?内容", "更改.*?文本", "更新.*?信息", "将.*?改为", "把.*?改成", "把.*?换成", "调整.*?为", "变更.*?成" }; private static final String[] ELEMENT_KEYWORDS = { "页面路径", "标签", "选择器", "当前内容", "元素", "组件", "节点", "位置", "路径", "class", "id", "nth-child", "router-link" }; private static final String[] TOOL_CALL_PATTERNS = { "\\[工具调用\\].*?\\n", "\\[.*?\\]", "工具名称:.*?\\n", "调用参数:.*?\\n" }; private static final String[] EXECUTION_PATTERNS = { "替换前:\\s*'''[\\s\\S]*?'''", "替换后:\\s*'''[\\s\\S]*?'''", "执行结果:.*?\\n", "返回结果:.*?\\n", "操作成功:.*?\\n", "文件修改成功:.*?\\n" }; private static final String[] CORE_RESPONSE_KEYWORDS = { "应用创建成功", "网站生成完成", "项目已构建", "代码生成完成", "页面创建成功", "功能实现完成" }; // 预编译正则表达式 private static final Pattern[] COMPILED_MODIFY_PATTERNS = Arrays.stream(MODIFY_PATTERNS) .map(pattern -> Pattern.compile(".*" + pattern + ".*")) .toArray(Pattern[]::new); private static final Pattern[] COMPILED_TOOL_CALL_PATTERNS = Arrays.stream(TOOL_CALL_PATTERNS) .map(Pattern::compile) .toArray(Pattern[]::new); private static final Pattern[] COMPILED_EXECUTION_PATTERNS = Arrays.stream(EXECUTION_PATTERNS) .map(Pattern::compile) .toArray(Pattern[]::new); // 使用Set提高查找效率 private static final Set<String> CORE_REQUIREMENT_SET = new HashSet<>(Arrays.asList(CORE_REQUIREMENT_KEYWORDS)); private static final Set<String> ELEMENT_SET = new HashSet<>(Arrays.asList(ELEMENT_KEYWORDS)); private static final Set<String> CORE_RESPONSE_SET = new HashSet<>(Arrays.asList(CORE_RESPONSE_KEYWORDS)); /** * 过滤用户提示词 */ public static String filterUserPrompt(String message) { if (StrUtil.isBlank(message)) { return message; } // 检查是否包含核心需求 boolean hasCoreRequirement = CORE_REQUIREMENT_SET.stream() .anyMatch(message::contains); if (hasCoreRequirement) { // 检查是否是修改请求 boolean isModifyRequest = Arrays.stream(COMPILED_MODIFY_PATTERNS) .parallel() .anyMatch(pattern -> pattern.matcher(message).matches()); if (isModifyRequest) { // 检查是否包含元素信息 boolean hasElementInfo = ELEMENT_SET.stream() .parallel() .anyMatch(message::contains); return hasElementInfo ? "" : message; } return message; } // 处理不包含核心需求的消息 return handleNonCoreRequirementMessage(message); } /** * 过滤AI提示词 */ public static String filterAIPrompt(String message) { if (StrUtil.isBlank(message)) { return message; } // 检查是否包含核心响应 boolean hasCoreResponse = CORE_RESPONSE_SET.stream() .anyMatch(message::contains); if (hasCoreResponse) { return message; } // 过滤工具调用信息 String filteredMessage = message; for (Pattern pattern : COMPILED_TOOL_CALL_PATTERNS) { filteredMessage = pattern.matcher(filteredMessage).replaceAll(""); } // 过滤执行结果信息 for (Pattern pattern : COMPILED_EXECUTION_PATTERNS) { filteredMessage = pattern.matcher(filteredMessage).replaceAll(""); } // 清理格式 filteredMessage = filteredMessage.replaceAll("\\n{3,}", "\n\n"); filteredMessage = filteredMessage.trim(); // 如果过滤后消息为空或过短,则返回空 if (StrUtil.isBlank(filteredMessage) || filteredMessage.length() < 10) { return ""; } return filteredMessage; } /** * 处理不包含核心需求的消息 */ private static String handleNonCoreRequirementMessage(String message) { // 检查是否是修改请求 boolean isModifyRequest = Arrays.stream(COMPILED_MODIFY_PATTERNS) .parallel() .anyMatch(pattern -> pattern.matcher(message).matches()); // 如果是修改请求且消息较短,过滤掉 if (isModifyRequest && message.length() < 30) { return ""; } // 检查是否包含技术性元素信息 boolean hasElementInfo = ELEMENT_SET.stream() .parallel() .anyMatch(message::contains); return hasElementInfo ? "" : message; } }
简单测试一下:
- 用户提示词过滤效果
原内容
过滤后

- AI 提示词过滤效果
原内容
过滤后
效果好像并不好,用户提示词没有任何过滤,AI 提示词确实过滤了工具调用相关的内容,但剩余内容如果保留下来加载进缓存应该还是会被大模型模仿从而跳过工具调用操作。
若是 AI 与用户的所有修改相关记录全都过滤就会出现上方逻辑删除对话历史的测试遇到的问题,所以考虑放弃这个方案。
方案二
此时跳转思路,当第一次访问某个 app 进行修改操作时 AI 会正常调用工具进行操作,只要对话记忆没有过期,那些工具调用的相关信息还在,那么它就不会“偷懒”跳过工具调用的操作,如果将 AI 返回的所有信息都存入 MySQL 库,在新创建 AI Service 初始化缓存时将这些对话记忆加载进去那不就解决问题了!
重新梳理一下加载缓存和对话历史入库的流程:
- 缓存加载
对话记忆缓存的加载有两个渠道:
- 新建 AI Service 实例的时候将数据库中的对话历史载入缓存。
- 用户与 AI 服务对话时,实时的对话记录由框架管理自动载入缓存。
AI Service 的最大存活时间是 30 分钟,而对话记忆缓存的存活时间是 1 小时,相对来说这个时间还算长,需要注意的是,对话记忆存在 redis 中进行了持久化处理,就算重启项目也不会丢失,但是 AI Service 实例是存在 caffeine 本地缓存中,重启项目后本地缓存是会清空的,所以需要重新创建 AI Service 服务实例,而此时就会重新加载对话记忆缓存,所以感觉测试的时候好像也没过那么长时间怎么 AI 那么快就开始“偷懒”。
- 对话历史入库
保存对话历史记录的地方有 5 个:
原生 HTML 模式和多文件模式使用的模型没有注册工具,且它们的修改操作都是全量修改,AI 返回的数据好像几乎都入库了,不涉及本文提到的问题,所以仍然使用老的逻辑。
需要注意的地方:
- 用户提示词入库
2. Vue 工程模式下的 AI 提示词入库

用户提示词入库相对简单,不需要任何处理直接入库就行,主要关注 Vue 工程模式下 AI 提示词入库前的处理:

可以看到,这一块只是将 AI_RESPONSE 的 data 数据和格式化之后的工具调用结果信息入库了,工具调用的请求信息也没有入库。
存入对话历史表的数据主要是为了给前端用户展示,所以不能对当前库表的字段和其后端相关逻辑进行改动,那保存与 AI 对话的完整信息就有两个实现方案:
- 为已有的 chat_history 表新增一个 original_message 字段
- 新建一张单独存储完整对话信息的 chat_history_original 表
先考虑第一种方案,新增 original_message 字段那对话信息的原始数据就要和处理后的展示数据一起入库。在 handleJsonMessageChunk() 方法内的三种消息类型下打上断点发现:1. AI_RESPONSE 类型的消息会将一句话拆分成多个 chunk 分段返回。2. TOOL_REQUEST 的 arguments 数据也是分多次返回。3. TOOL_EXECUTED 类型返回的 chunk 内数据则是完整的。
- AI_RESPONSE

- TOOL_REQUEST

- TOOL_EXECUTED
且 TOOL_EXECUTED 内的 id, name, arguments 数据都是从 TOOL_REQUEST 中获取的:


这样的话那就简单了,再另外创建一个 StringBuilder ,对于 AI_RESPONSE 类型消息的处理与旧逻辑相同,在 TOOL_EXECUTED 下对这两种工具调用信息进行处理即可,这样就可以将对话的完整信息收集下来,再和格式化后的展示数据一起入库,然后新增一个用于 Vue 工程模式加载对话历史到缓存的方法(以 original_message 为源数据),最后在创建 AI Service 时加载缓存的地方判断类型分别加载就解决问题了。
这时我对比了一下 由框架管理生成的对话记忆 和 从数据库加载到缓存的对话记忆 ,发现了新的问题:左侧由框架管理生成,右侧为数据库加载。

- 框架管理生成的对话记忆中,工具调用请求的信息是存放在 AI 类型消息下的“toolExecutionRequests”键值里
- 消息类型不只有 AI、USER ,还有 TOOL_EXECUTION_RESULT,这是一种单独的消息类型
采用 chat_history 表新增 original_message 字段的方案,几种类型的消息都存放在一起然后打包加载到缓存,那也就是加载了一个 AI 类型消息的 text,这种效果应该不太好。 如果是新建 chat_history_original 表,将user、AI_RESPONSE、TOOL_REQUEST 和 TOOL_EXECUTED 类型的数据分别进行存储,在加载缓存的时候就可以实现比较好的效果了。
最终方案
开发
新建 chat_history_original 表:
▼sql复制代码-- 完整对话历史表(用于加载对话记忆,包含工具调用信息) create table chat_history_original ( id bigint auto_increment comment 'id' primary key, message text not null comment '消息', messageType varchar(32) not null comment 'user/ai/toolExecutionRequest/toolExecutionResult', 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;
这张表和 chat_history 表一模一样,那为什么不把这些消息就存在已有的 chat_history 表呢?因为在加载缓存的时候是根据 appId 查询,不太好分辨展示数据和用于加载对话记忆的数据。当然,如果新增一个字段将每一条记录分别标记为 display/cache 也可以解决问题,但还是想将数据进行隔离,所以决定新建一张表单独存储。
执行 MyBatisCodeGenerator 生成相关基类,Controller不需要,将其他基类移动到相关包下,ChatHistoryOriginal 的 ID 生成策略改为雪花算法 ID。
ChatHistoryOriginalService 添加相关方法:
▼java复制代码/** * 原始对话历史 服务层。 * 为 vue 工程模式恢复对话记忆(包含工具调用信息) * * @author agx */ public interface ChatHistoryOriginalService extends IService<ChatHistoryOriginal> { /** * 添加对话历史 * @param appId * @param message * @param messageType * @param userId * @return */ boolean addOriginalChatMessage(Long appId, String message, String messageType, Long userId); /** * 批量添加对话历史 * @param chatHistoryOriginalList * @return */ boolean addOriginalChatMessageBatch(List<ChatHistoryOriginal> chatHistoryOriginalList); /** * 根据 appId 关联删除对话历史记录 * @param appId * @return */ boolean deleteByAppId(Long appId); /** * 将 APP 的对话历史加载到缓存中 * @param appId * @param chatMemory * @param maxCount * @return */ int loadOriginalChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount); }
ChatHistoryOriginalServiceImpl 实现:1. 新增了对话历史批量对库的方法;2. 优化 loadOriginalChatHistoryToMemory 方法,将查询历史记录和将历史记录加载进缓存的部分抽取为单独的方法,分别进行优化。
▼java复制代码/** * 原始对话历史 服务层实现。 * 为 vue 工程模式恢复对话记忆(包含工具调用信息) * * @author agx */ @Service @Slf4j public class ChatHistoryOriginalServiceImpl extends ServiceImpl<ChatHistoryOriginalMapper, ChatHistoryOriginal> implements ChatHistoryOriginalService{ @Override public boolean addOriginalChatMessage(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.SYSTEM_ERROR, "不支持的消息类型: " + messageType); // 对话消息入库 ChatHistoryOriginal chatHistoryOriginal = ChatHistoryOriginal.builder() .appId(appId) .message(message) .messageType(messageType) .userId(userId) .build(); return this.save(chatHistoryOriginal); } @Override public boolean addOriginalChatMessageBatch(List<ChatHistoryOriginal> chatHistoryOriginalList) { // 参数校验 ThrowUtils.throwIf(chatHistoryOriginalList == null || chatHistoryOriginalList.isEmpty(), ErrorCode.PARAMS_ERROR, "消息列表不能为空"); // 验证消息类型是否有效,无效类型的对话记录不进行入库 List<ChatHistoryOriginal> validMessages = chatHistoryOriginalList.stream() .filter(chatHistory -> { ChatHistoryMessageTypeEnum messageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(chatHistory.getMessageType()); if (messageTypeEnum == null) { log.error("不支持的消息类型: {}", chatHistory.getMessageType()); return false; // 过滤掉无效消息 } return true; // 保留有效消息 }) .collect(Collectors.toList()); // 如果没有有效消息,直接返回 if (validMessages.isEmpty()) { return false; } // 批量入库 return this.saveBatch(validMessages); } @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); } @Override public int loadOriginalChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount) { try{ // 1. 查询历史记录,考虑边缘记录类型 List<ChatHistoryOriginal> originalHistoryList = queryHistoryWithEdgeCheck(appId, maxCount); if (CollUtil.isEmpty(originalHistoryList)) { return 0; } // 2. 反转列表,确保时间正序(老的在前,新的在后) originalHistoryList = originalHistoryList.reversed(); // 3. 先清理当前 app 的历史缓存,防止重复加载 chatMemory.clear(); // 4. 遍历原始历史记录,根据类型将消息添加到记忆中 int loadedCount = loadMessagesToMemory(originalHistoryList, chatMemory); log.info("成功为 appId: {} 加载 {} 条历史对话", appId, loadedCount); return loadedCount; } catch (Exception e) { log.error("加载历史对话失败,appId: {},error: {}", appId, e.getMessage(), e); // 加载失败不影响系统运行,只是没有历史上下文 return 0; } } /** * 查询历史记录,考虑边缘记录类型 * 工具调用信息必须是成对并且有序的: tool_request -> tool_result,否则就会报错! * 错误信息:dev.langchain4j.exception.HttpException: {"error":{"message":"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'","type":"invalid_request_error","param":null,"code":"invalid_request_error"}} * 1. 边缘检查的意义在于当查询到的第 maxCount + 1 那条数据是 tool_result 时就丢失了一条 tool_request,导致报错 * 2. 这里改为了按 id 倒序查询,时间戳排序可能因为相近值而不稳定,当 tool_request 和 tool_result 的顺序加载错了会导致报错(MyBatis-flex的雪花算法生成的ID是严格递增的) * * @param appId 应用ID * @param maxCount 最大记录数 * @return 历史记录列表 */ private List<ChatHistoryOriginal> queryHistoryWithEdgeCheck(Long appId, int maxCount) { // 1. 首先检查总记录数 QueryWrapper countQueryWrapper = QueryWrapper.create() .eq(ChatHistoryOriginal::getAppId, appId); long totalCount = this.count(countQueryWrapper); // 2. 如果总记录数小于等于1,直接返回空列表(因为我们要跳过第1条记录) if (totalCount <= 1) { log.debug("总记录数 ({}) 小于等于1,没有足够的历史记录可加载", totalCount); return Collections.emptyList(); } // 3. 计算实际可查询的最大记录数(减去要跳过的第1条记录) long availableCount = totalCount - 1; // 4. 如果总记录数小于等于 maxCount+1,则不需要检查边缘记录 if (totalCount <= maxCount + 1) { log.debug("总记录数 ({}) 小于等于 maxCount+1 ({}), 不需要检查边缘记录", totalCount, maxCount + 1); // 直接查询所有可用记录(跳过最新的用户消息) QueryWrapper queryWrapper = QueryWrapper.create() .eq(ChatHistoryOriginal::getAppId, appId) .orderBy(ChatHistoryOriginal::getId, false) // 使用ID倒序,确保顺序性 .limit(1, availableCount); // 查询从第2条开始的所有可用记录 return this.list(queryWrapper); } // 5. 如果总记录数大于 maxCount+1,则需要检查边缘记录 // 查询第 maxCount+1 条记录(边缘记录) QueryWrapper edgeQueryWrapper = QueryWrapper.create() .eq(ChatHistoryOriginal::getAppId, appId) .orderBy(ChatHistoryOriginal::getId, false) .limit(maxCount, 1); // 查询第 maxCount+1 条记录 ChatHistoryOriginal edgeRecord = this.getOne(edgeQueryWrapper); // 6. 如果边缘记录是 TOOL_EXECUTION_RESULT 类型,则需要额外查询其前一条 TOOL_EXECUTION_REQUEST 记录 boolean needExtraRequest = false; if (edgeRecord != null) { String edgeMessageType = edgeRecord.getMessageType(); ChatHistoryMessageTypeEnum edgeMessageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(edgeMessageType); needExtraRequest = (edgeMessageTypeEnum == ChatHistoryMessageTypeEnum.TOOL_EXECUTION_RESULT); } // 7. 计算实际需要查询的记录数 long actualLimit = Math.min(needExtraRequest ? maxCount + 1 : maxCount, availableCount); // 8. 查询历史记录 QueryWrapper queryWrapper = QueryWrapper.create() .eq(ChatHistoryOriginal::getAppId, appId) .orderBy(ChatHistoryOriginal::getId, false) .limit(1, actualLimit); // 查询从第2条开始的 actualLimit 条记录 List<ChatHistoryOriginal> originalHistoryList = this.list(queryWrapper); if (CollUtil.isEmpty(originalHistoryList)) { return Collections.emptyList(); } // 9. 检查是否需要调整 maxCount if (needExtraRequest && originalHistoryList.size() <= maxCount) { // 如果需要额外的 TOOL_EXECUTION_REQUEST 但没有获取到足够的记录 log.warn("边缘记录是 TOOL_EXECUTION_RESULT 类型,但未获取到足够的记录包含 TOOL_EXECUTION_REQUEST,将 maxCount 减 1"); maxCount = Math.max(0, maxCount - 1); // 确保 maxCount 不小于 0 // 如果 maxCount 变为 0,则直接返回空列表 if (maxCount == 0) { log.info("调整后 maxCount 为 0,不加载任何历史记录"); return Collections.emptyList(); } // 重新查询,使用调整后的 maxCount actualLimit = Math.min(maxCount, availableCount); queryWrapper = QueryWrapper.create() .eq(ChatHistoryOriginal::getAppId, appId) .orderBy(ChatHistoryOriginal::getId, false) .limit(1, actualLimit); // 查询从第2条开始的 actualLimit 条记录 originalHistoryList = this.list(queryWrapper); if (CollUtil.isEmpty(originalHistoryList)) { return Collections.emptyList(); } } return originalHistoryList; } /** * 将历史记录加载到内存中 * * @param originalHistoryList 历史记录列表 * @param chatMemory 聊天记忆 * @return 加载的记录数 */ private int loadMessagesToMemory(List<ChatHistoryOriginal> originalHistoryList, MessageWindowChatMemory chatMemory) { int loadedCount = 0; // 遍历原始历史记录,根据类型将消息添加到记忆中 for(ChatHistoryOriginal history : originalHistoryList) { // 这里需要根据消息类型进行转换,支持 AI, user, toolExecutionRequest, toolExecutionResult 4种类型 String messageType = history.getMessageType(); ChatHistoryMessageTypeEnum messageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(messageType); switch (messageTypeEnum) { case USER -> { chatMemory.add(UserMessage.from(history.getMessage())); loadedCount++; } case AI -> { chatMemory.add(AiMessage.from(history.getMessage())); loadedCount++; } case TOOL_EXECUTION_REQUEST -> { ToolRequestMessage toolRequestMessage = JSONUtil.toBean(history.getMessage(), ToolRequestMessage.class); ToolExecutionRequest toolExecutionRequest = ToolExecutionRequest.builder() .id(toolRequestMessage.getId()) .name(toolRequestMessage.getName()) .arguments(toolRequestMessage.getArguments()) .build(); // 有些工具调用请求带有文本,有些没有 if (toolRequestMessage.getText().isEmpty()) { chatMemory.add(AiMessage.from(List.of(toolExecutionRequest))); } else { chatMemory.add(AiMessage.from(toolRequestMessage.getText(), List.of(toolExecutionRequest))); } loadedCount++; } case TOOL_EXECUTION_RESULT -> { ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(history.getMessage(), ToolExecutedMessage.class); String id = toolExecutedMessage.getId(); String toolName = toolExecutedMessage.getName(); String toolExecutionResult = toolExecutedMessage.getResult(); chatMemory.add(ToolExecutionResultMessage.from(id, toolName, toolExecutionResult)); loadedCount++; } case null -> log.error("未知消息类型: {}", messageType); } } return loadedCount; } }
ChatHistoryMessageTypeEnum 增加 工具调用请求 和 工具调用结果 类型:
▼java复制代码@Getter public enum ChatHistoryMessageTypeEnum { USER("用户", "user"), AI("AI", "ai"), TOOL_EXECUTION_REQUEST("工具调用请求", "toolExecutionRequest"), TOOL_EXECUTION_RESULT("工具调用结果", "toolExecutionResult"); 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; } }
ToolRequestMessage 增加 text 属性,用于存储工具调用的文本信息:
▼java复制代码/** * 工具调用消息 */ @Data @EqualsAndHashCode(callSuper = true) @NoArgsConstructor public class ToolRequestMessage extends StreamMessage { private String id; private String name; private String arguments; private String text; public ToolRequestMessage(ToolExecutionRequest toolExecutionRequest) { super(StreamMessageTypeEnum.TOOL_REQUEST.getValue()); this.id = toolExecutionRequest.id(); this.name = toolExecutionRequest.name(); this.arguments = toolExecutionRequest.arguments(); } }
AppServiceImpl 的 chatToGenCode 方法,将用户提示词也存入 original 表中,同时删除 app 时也要尝试同步删除 original 表中对话历史:
▼java复制代码@Resource private ChatHistoryOriginalService chatHistoryOriginalService; public Flux<String> chatToGenCode(Long appId, String message, User loginUser) { // ... 省略 ... // 5. 通过校验后将用户消息添加到对话历史 chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId()); chatHistoryOriginalService.addOriginalChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId()); // ... 省略 ... } public boolean removeById(Serializable id) { // ... 省略 ... // 先删除关联的对话历史 try{ chatHistoryService.deleteByAppId(appId); chatHistoryOriginalService.deleteByAppId(appId); } // ... 省略 ... }
优化 StreamHandlerExecutor 的 doExecute 方法,这里需要将 ChatHistoryOriginalService 也作为参数传入:
▼java复制代码/** * 创建流处理器并处理聊天历史记录 * @param originFlux * @param chatHistoryService * @param chatHistoryOriginalService * @param appId * @param loginUser * @param codeGenType * @return */ public Flux<String> doExecute(Flux<String> originFlux, ChatHistoryService chatHistoryService, ChatHistoryOriginalService chatHistoryOriginalService, long appId, User loginUser, CodeGenTypeEnum codeGenType) { return switch (codeGenType) { case VUE_PROJECT -> // 使用注入的组件实例 jsonMessageStreamHandler.handle(originFlux, chatHistoryService, chatHistoryOriginalService, appId, loginUser); case HTML, MULTI_FILE -> // 简单文本处理器不需要依赖注入 new SimpleTextStreamHandler().handle(originFlux, chatHistoryService, appId, loginUser); }; }
优化 JsonMessageStreamHandler,新增 processToolExecutionMessage 方法,用于在 TOOL_EXECUTED 中对工具调用信息做入库前的预处理:
▼java复制代码/** * JSON 消息流处理器 * 处理 VUE_PROJECT 类型的复杂流式响应,包含工具调用信息 */ @Slf4j @Component public class JsonMessageStreamHandler { @Resource private VueProjectBuilder vueProjectBuilder; @Resource private ToolManager toolManager; /** * 处理 TokenStream(VUE_PROJECT) * 解析 JSON 消息并重组为完整的响应格式 * * @param originFlux 原始流 * @param chatHistoryService 聊天历史服务 * @param appId 应用ID * @param loginUser 登录用户 * @return 处理后的流 */ public Flux<String> handle(Flux<String> originFlux, ChatHistoryService chatHistoryService, ChatHistoryOriginalService chatHistoryOriginalService, long appId, User loginUser) { // 收集数据用于前端展示 StringBuilder chatHistoryStringBuilder = new StringBuilder(); // 收集用于恢复对话记忆的数据 StringBuilder aiResponseStringBuilder = new StringBuilder(); // 每个 Flux 流可能包含多条工具调用和 AI_RESPONSE 响应信息,统一收集之后批量入库 List<ChatHistoryOriginal> originalChatHistoryList = new ArrayList<>(); // 用于跟踪已经见过的工具ID,判断是否是第一次调用 Set<String> seenToolIds = new HashSet<>(); return originFlux .map(chunk -> { // 解析每个 JSON 消息块 return handleJsonMessageChunk(chunk, chatHistoryStringBuilder, aiResponseStringBuilder, originalChatHistoryList, seenToolIds); }) .filter(StrUtil::isNotEmpty) // 过滤空字串 .doOnComplete(() -> { // 工具调用信息入库 if (!originalChatHistoryList.isEmpty()) { // 完善 ChatHistoryOriginal 信息 originalChatHistoryList.forEach(chatHistory -> { chatHistory.setAppId(appId); chatHistory.setUserId(loginUser.getId()); }); // 批量入库 chatHistoryOriginalService.addOriginalChatMessageBatch(originalChatHistoryList); } // Ai response 入库(两种情况:1. 没有进行工具调用。2. 工具调用结束之后 AI 一般还会有一句返回) String aiResponseStr = aiResponseStringBuilder.toString(); chatHistoryOriginalService.addOriginalChatMessage(appId, aiResponseStr, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); // 流式响应完成后,添加 AI 消息到对话历史 String chatHistoryStr = chatHistoryStringBuilder.toString(); chatHistoryService.addChatMessage(appId, chatHistoryStr, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); // 异步构建 Vue 项目 String projectPath = AppConstant.CODE_OUTPUT_ROOT_DIR + "/vue_project_" + appId; vueProjectBuilder.buildProjectAsync(projectPath); }) .doOnError(error -> { // 如果AI回复失败,也要记录错误消息 String errorMessage = "AI回复失败: " + error.getMessage(); chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); chatHistoryOriginalService.addOriginalChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); }); } /** * 解析并收集 TokenStream 数据 */ private String handleJsonMessageChunk(String chunk, StringBuilder chatHistoryStringBuilder, StringBuilder aiResponseStringBuilder, List<ChatHistoryOriginal> originalChatHistoryList, Set<String> seenToolIds) { // 解析 JSON StreamMessage streamMessage = JSONUtil.toBean(chunk, StreamMessage.class); StreamMessageTypeEnum typeEnum = StreamMessageTypeEnum.getEnumByValue(streamMessage.getType()); switch (typeEnum) { case AI_RESPONSE -> { AiResponseMessage aiMessage = JSONUtil.toBean(chunk, AiResponseMessage.class); String data = aiMessage.getData(); // 直接拼接响应 chatHistoryStringBuilder.append(data); // 对于 AI 响应内容,与展示数据处理逻辑相同 aiResponseStringBuilder.append(data); return data; } case TOOL_REQUEST -> { ToolRequestMessage toolRequestMessage = JSONUtil.toBean(chunk, ToolRequestMessage.class); String toolId = toolRequestMessage.getId(); String toolName = toolRequestMessage.getName(); // 检查是否是第一次看到这个工具 ID if (toolId != null && !seenToolIds.contains(toolId)) { // 第一次调用这个工具,记录 ID 并完整返回工具信息 seenToolIds.add(toolId); // 获取工具实例 BaseTool tool = toolManager.getTool(toolName); // 返回格式化的工具调用信息 return tool.generateToolRequestResponse(); } else { // 不是第一次调用这个工具,直接返回空 return ""; } } case TOOL_EXECUTED -> { // 处理工具调用信息 processToolExecutionMessage(aiResponseStringBuilder, chunk, originalChatHistoryList); // 格式化处理 ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(chunk, ToolExecutedMessage.class); String toolName = toolExecutedMessage.getName(); JSONObject arguments = JSONUtil.parseObj(toolExecutedMessage.getArguments()); // 根据工具名称获取工具实例并生成格式化输出信息 BaseTool tool = toolManager.getTool(toolName); String result = tool.generateToolExecutedResult(arguments); // 输出前端和要持久化的内容 String output = String.format("\n\n%s\n\n", result); chatHistoryStringBuilder.append(output); return output; } default -> { log.error("不支持的消息类型: {}", typeEnum); return ""; } } } /** * 解析处理工具调用相关信息 * @param aiResponseStringBuilder * @param chunk * @param originalChatHistoryList */ private void processToolExecutionMessage(StringBuilder aiResponseStringBuilder, String chunk, List<ChatHistoryOriginal> originalChatHistoryList) { // 解析 chunk ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(chunk, ToolExecutedMessage.class); // 构造工具调用请求对象(工具调用结果的数据就是从调用请求里拿的,所以直接在这里处理调用请求信息) String aiResponseStr = aiResponseStringBuilder.toString(); ToolRequestMessage toolRequestMessage = new ToolRequestMessage(); toolRequestMessage.setId(toolExecutedMessage.getId()); toolRequestMessage.setName(toolExecutedMessage.getName()); toolRequestMessage.setArguments(toolExecutedMessage.getArguments()); toolRequestMessage.setText(aiResponseStr); // 转换成 JSON String toolRequestJsonStr = JSONUtil.toJsonStr(toolRequestMessage); // 构造 ChatHistory 存入列表 ChatHistoryOriginal toolRequestHistory = ChatHistoryOriginal.builder() .message(toolRequestJsonStr) .messageType(ChatHistoryMessageTypeEnum.TOOL_EXECUTION_REQUEST.getValue()) .build(); originalChatHistoryList.add(toolRequestHistory); ChatHistoryOriginal toolResultHistory = ChatHistoryOriginal.builder() .message(chunk) .messageType(ChatHistoryMessageTypeEnum.TOOL_EXECUTION_RESULT.getValue()) .build(); originalChatHistoryList.add(toolResultHistory); // AI 响应内容暂时结束,置空 aiResponseStringBuilder aiResponseStringBuilder.setLength(0); } }
优化 AiCodeGeneratorServiceFactory 下的 createAiCodeGeneratorService 方法,在创建 AI Service 时根据代码生成类型分别加载对话记忆:
▼java复制代码/** * 创建新的 AI 服务实例 * @param appId * @return */ private AiCodeGeneratorService createAiCodeGeneratorService(Long appId, CodeGenTypeEnum codeGenType) { log.info("为 appId: {} 创建新的 AI 服务实例", appId); AiCodeGeneratorService aiCodeGeneratorService; // 根据 appId 构建独立的对话记忆 MessageWindowChatMemory chatMemory = MessageWindowChatMemory .builder() .id(appId) .chatMemoryStore(redisChatMemoryStore) .maxMessages(60) // 一次工具调用也算一次记忆,maxMessages得设置得大一点,不然模型会失忆一直循环调用工具 .build(); // 根据代码生成类型选择不同的模型配置 switch (codeGenType) { case VUE_PROJECT -> { // 从数据库加载历史对话到缓存中,由于多了工具调用相关信息,加载的最大数量稍微多一些 chatHistoryOriginalService.loadOriginalChatHistoryToMemory(appId, chatMemory, 50); // Vue 项目生成使用推理模型 aiCodeGeneratorService = AiServices.builder(AiCodeGeneratorService.class) .streamingChatModel(reasoningStreamingChatModel) .chatMemoryProvider(memoryId -> chatMemory) .tools(toolManager.getAllTools()) .hallucinatedToolNameStrategy(toolExecutionRequest -> ToolExecutionResultMessage.from( toolExecutionRequest, "Error: there is no tool called " + toolExecutionRequest.name() )) .build(); } case HTML, MULTI_FILE -> { // 从数据库加载历史对话到缓存中 chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20); // HTML 和多文件生成模式使用默认模型 aiCodeGeneratorService = AiServices.builder(AiCodeGeneratorService.class) .chatModel(chatModel) .streamingChatModel(openAiStreamingChatModel) .chatMemory(chatMemory) .build(); } default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType.getValue()); }; return aiCodeGeneratorService; }
至此就开发完成了。
测试
创建一个 Vue 工程模式的 app,生成完成之后进行多轮修改操作,没有问题;本地创建一个 .json 文件保存 redis 中框架管理生成的对话记忆,重启项目进入该 app 再次修改。

修改成功! 查看控制台对话记忆的加载信息:
可以看到加载了 51 条对话历史,说明查询对话历史的边缘检查也生效了。
redis 中对话记忆前后对比:
数据格式也是一样的,最后查看 MySQL 中的数据:
符合预期,测试完成。
记录了一下这几天解决这个问题的整体思路,终于优化完成给大家提供了一个效果还不错的解决方案,希望能够帮助到各位同样受这个问题困扰的鱼友。
