隔离工具调用记录,解决 AI 偷懒不执行工具调用的问题

背景

项目:AI 代码生成平台

场景:当 Redis 里面的记录过期之后,从 db 里面获取到数据,AI 学习到之前的工具调用的对话记录导致偷懒,没有真正调用工具只输出修改成功

解决方案

我个人的解决方案是把 tool_executed 工具执行结果,单独设置一个 messageType 比如 tool ,这样在 AiService 在没有 Redis 数据的时候获取 db 记录排除一下类型为 tool 的记录。

优化后的思路:

后端新添加一个类型 FRONTED 这个类型的数据是专门前端显示并且包含工具调用的记录

同时记录一个类型为 AI 的,这个类型的数据不包含工具调用的记录,此类型不返回给前端

工具调用记录结果类似(罪魁祸首):

c
复制代码
[工具调用] 读取目录 根目录 [工具调用] 读取目录 src [工具调用] 读取文件 src/Home.vue [工具调用] 读取目录 src/pages [工具调用] 读取文件 src/pages/Home.vue [工具调用] 修改文件 src/pages/Home.vue 替换前: <h1 class="hero-title">展示创意,连接世界</h1> 替换后: <h1 class="hero-title">连接世界</h1>

代码修改

最终修改后的完整的代码:https://github.com/lieeew/yu-ai-code-mother/tree/dev/leikooo

下面是具体修改的细节

1、修改 ChatHistoryMessageTypeEnum

java
复制代码
/** * 对话历史消息枚举类型 */ @Getter public enum ChatHistoryMessageTypeEnum { USER("用户", "user"), // 方便前端显示, 包含工具调用的流程 FRONTEND("前端显示", "frontend"), // 不包含工具调用的流程 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; } }

2、定义了一个内部类

方便在 originFlux 里面的 map 解析 JSON 类型,拿到对应的 content 信息和 type 类型,方便分类收集信息

java
复制代码
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class AiResponse { private String content; /** * @see StreamMessageTypeEnum */ private String type; }

3、handleJsonMessageChunk 方法里面返回的是 JSON 字符串数据,方便调用方进行解析

java
复制代码
/** * JSON 消息流处理器 * 处理 VUE_PROJECT 类型的复杂流式响应,包含工具调用信息 */ @Slf4j @Component public class JsonMessageStreamHandler { @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, long appId, User loginUser) { // 收集数据用于生成后端记忆格式 StringBuilder aiHistoryStringBuilder = new StringBuilder(); StringBuilder gatherHistoryStringBuilder = new StringBuilder(); // 用于跟踪已经见过的工具ID,判断是否是第一次调用 Set<String> seenToolIds = new HashSet<>(); return originFlux .map(chunk -> { // 解析每个 JSON 消息块 AiResponse aiResponse = JSONUtil.toBean(handleJsonMessageChunk(chunk, seenToolIds), AiResponse.class); String content = aiResponse.content; // 收集完整信息方便前端进行显示 gatherHistoryStringBuilder.append(content); // 收集数据用于生成后端记忆格式 if (aiResponse.type.equals(StreamMessageTypeEnum.AI_RESPONSE.getValue())) { aiHistoryStringBuilder.append(content); } return content; }) // 过滤空字串 .filter(StrUtil::isNotEmpty) .doOnComplete(() -> { // ====== 不包含 tool 调用信息 ====== String aiResponse = aiHistoryStringBuilder.toString(); if (StringUtils.isNoneBlank(aiResponse)) { chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); } // ====== 前端显示包含 tool 调用信息 ====== String frontendHistory = gatherHistoryStringBuilder.toString(); if (StringUtils.isNoneBlank(frontendHistory)) { chatHistoryService.addChatMessage(appId, frontendHistory, ChatHistoryMessageTypeEnum.FRONTEND.getValue(), loginUser.getId()); } }) .doOnError(error -> { // 如果AI回复失败,也要记录错误消息 String errorMessage = "AI回复失败: " + error.getMessage(); chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); }); } /** * 解析并收集 TokenStream 数据 */ private String handleJsonMessageChunk(String chunk, 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(); return getAiMsgJson(data, typeEnum); } 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 getAiMsgJson(tool.generateToolRequestResponse(), typeEnum); } else { // 不是第一次调用这个工具,直接返回空 return getAiMsgJson("", typeEnum); } } case TOOL_EXECUTED -> { ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(chunk, ToolExecutedMessage.class); JSONObject jsonObject = JSONUtil.parseObj(toolExecutedMessage.getArguments()); // 根据工具名称获取工具实例 String toolName = toolExecutedMessage.getName(); BaseTool tool = toolManager.getTool(toolName); String result = tool.generateToolExecutedResult(jsonObject); // 输出前端和要持久化的内容 String output = String.format("\n\n%s\n\n", result); return getAiMsgJson(output, typeEnum); } default -> { log.error("不支持的消息类型: {}", typeEnum); return getAiMsgJson("", typeEnum); } } } private String getAiMsgJson(String content, StreamMessageTypeEnum typeEnum) { AiResponse aiResponse = AiResponse.builder().content(content).type(typeEnum.getValue()).build(); return JSONUtil.toJsonStr(aiResponse); } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class AiResponse { /** * 内容 */ private String content; /** * @see StreamMessageTypeEnum */ private String type; } }

4、修改 historyService 方法

  • FRONTED 类型包含 tool 调用的信息,前端显示这个类型的数据
  • USER 类型是用户发给前端的信息,前端 + AiService 使用这个类型的数据
  • AI 类型不包含 tool 调用的信息,AiService 加载会话使用这个类型的数据

ChatHistoryServiceImpl#listAppChatHistoryByPage

主要修改位置是查询的 queryWapper 属于 frontend、user 这两个类型

java
复制代码
@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); queryWrapper.in(ChatHistory::getMessageType, ChatHistoryMessageTypeEnum.FRONTEND.getValue(), ChatHistoryMessageTypeEnum.USER.getValue() ); // 查询数据 return this.page(Page.of(1, pageSize), queryWrapper); }

5、ChatHistoryServiceImpl#loadChatHistoryToMemory

修改加载到 AiService 的记录,包含的 messageType 属于 ai、user 这两个类型

java
复制代码
@Override public int loadChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount) { try { QueryWrapper queryWrapper = QueryWrapper.create() .eq(ChatHistory::getAppId, appId) .in(ChatHistory::getMessageType, ChatHistoryMessageTypeEnum.AI, ChatHistoryMessageTypeEnum.USER ) .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())); } 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; } }

6、SimpleTextStreamHandler 存储的格式也需要进行修改
需要保存两份,虽然内容一眼但是效果不一样

1)fronted 类型: 前端显示

2)ai 类型: 加载 Service 时的历史纪录

java
复制代码
@Slf4j public class SimpleTextStreamHandler { /** * 处理传统流(HTML, MULTI_FILE) * 直接收集完整的文本响应 * * @param originFlux 原始流 * @param chatHistoryService 聊天历史服务 * @param appId 应用ID * @param loginUser 登录用户 * @return 处理后的流 */ public Flux<String> handle(Flux<String> originFlux, ChatHistoryService chatHistoryService, long appId, User loginUser) { StringBuilder aiResponseBuilder = new StringBuilder(); return originFlux .map(chunk -> { // 收集AI响应内容 aiResponseBuilder.append(chunk); return chunk; }) .doOnComplete(() -> { // 流式响应完成后,添加AI消息到对话历史 String aiResponse = aiResponseBuilder.toString(); chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.FRONTEND.getValue(), loginUser.getId()); }) .doOnError(error -> { // 如果AI回复失败,也要记录错误消息 String errorMessage = "AI回复失败: " + error.getMessage(); chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId()); chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.FRONTEND.getValue(), loginUser.getId()); }); } }

最终测试

最终测试正常调用工具,并且前端显示也是正常

总结

通过把 tool 工具调用的记录隔离出来,避免 AI 学习导致不实际上调用工具,最终解决这个 BUG,并且前端不需要进行任何修改

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
leikooo
作者分享
ARTS 0809: 反转链表、Shopify 如何用 MySQL 解决超卖与 AI 时代程序员的价值
5
译文:《SwiftUI 七年:平庸的故事》 SwiftUI 在 2019 年高调发布,本应成为苹果全平台成熟、可量产的 UI 未来。七年过去,到了 2026 年,它仍像一场永不结束的 beta:布局难预期、性能不稳、数据流混乱,还几乎没有可靠的向后兼容,开发者被迫写一堆 shim 和 workaround。 作者用苹果官方教程(甚至是有问题的)以及与 UIKit 的对比说明:SwiftUI 用“看起来方便”换掉了精确的工程控制。更深一层,他认为这反映了苹果从 Cocoa、Aqua、Auto Layout 那种不妥协的工艺,转向“够用就行”的企业文化。 SwiftUI 为何存在 苹果并非单纯想提供更好工具,而是不得不应对竞争:React、React Native、Flutter 让“一套代码多端跑”变得诱人;Mac 上原生应用又日渐被网页和 Electron 吃掉。SwiftUI 要同时拴住原生生态、并降低移植到 Mac 的成本。卖点是:响应式数据流、声明式布局、跨平台复用。 数据流 “单一数据源”听起来很美,实际却是 @State、@Binding、ObservedObject,再到 Observation / @Observable 的不断换代。你很难确定视图会更新几次、为何更新;它该忽略的变化会反应,该关心的变化又可能忽略 - - 像个黑盒。 布局系统 基于尺寸协商的布局在 Keynote 里很合理,做浮动视图、自定义侧边栏时却极度不稳定。官方教程里一个很普通的侧边栏,多年仍有问题。布局脆弱到最后往往只能上 GeometryReader - - 一旦用了,声明式优势就没了,还要手算坐标,而且下一版布局规则一变,数学还得重写。 API 稳定与功能对等 代码里满是 if #available。滚动收起键盘要到 iOS 16;工具栏定制很晚才来;网络图片 AsyncImage 要到 iOS 15,缓存相关 API 到 2026 年 7 月仍在 beta。旧 API 常被换掉(如 NavigationView → NavigationStack),开发者要维护多套实现,等于替苹果做 QA。对比 Android 的 Jetpack Compose 可作为依赖打包回退到旧设备,SwiftUI 做不到“写最新 API、稳定回退”。 性能 在真实对比里,即便做了后台解码等优化,SwiftUI 图片网格滚动仍明显不如 UIKit。若展示一堆 JPEG 都得靠顶级芯片撑,架构本身就有问题。 跨平台神话 苹果说的是“学一次、到处用”,不是“写一次、到处跑”。iOS 上学到的布局很少直接适用 Mac;同一套 view 跨平台实现也不一致。结果常变成:学一次、再学一次、某处能用、处处要调。 哲学转向 最大的问题是“够用就行”:覆盖 90% 用例就算成功,用 velocity 掩盖质量下降。作者列举系统与一线应用中的各种瑕疵,认为这不是偶然,而是苹果主动降低质量门槛 - - 所以即使过了七年,他仍不信任 SwiftUI。 结论 对构建稳定、高性能、可维护系统真正重要的部分,SwiftUI 几乎都有问题。它不是“极差”,而是平庸 - - 用假便利换真精度,要么你花时间给框架打补丁,要么把半成品发出去。作者更宁愿继续用“遗留”的 UIKit / AppKit。 最后小总结: 最让人不能接受的不是“SwiftUI 还有 bug”,而是它把“看起来很快”当成了工程上的完成态。声明式、预览、跨平台,每一项都在秀高级感,可真正写进业务后,你面对的是难预测的重绘、脆弱的布局、层层 #available,以及把兼容和排错外包给业务方的现实。七年够长了,若还靠“框架还年轻”解释,那更像是对标准的侮辱。技术选型从来不只是语法偏好,而是你选的是可预期性、可维护成本,以及对用户体验的态度。平庸的“成功”往往比明显失败更危险 ,你说它能上线、能 demo、能交差,但是却在细节里一点点磨损信任。工具可以换代,但对质量的要求不该跟着一起降级。
4
ARTS 0802: 合并有序链表、AI 时代的技术断层与 TCP 200ms 延迟之谜
5
Spring 团队开发者布道师 Josh Long,从 2011 年起每周二坚持写 This Week in Spring https://spring.io/authors/joshlong,大概 15 年半从未间断,到现在大概写了 800 期以上😱 大佬在采访里他说,写博客不是额外负担,而是逼自己整理每周所学的「强制机制」——反正本来就会刷社区动态,写出来既方便自己,也帮到别人。更重要的是 Spring 一直在变:微服务、AI……永远有新东西可聊,停一周就容易掉队。一旦养成习惯,坚持往往比重新开始更容易。 这种级别的大佬都还在用周更逼自己不掉队,我更没理由再拖了。还有之前左耳朵耗子大佬说的 ARTS 打卡,我老实说只撑了两周,真的需要捡起来了,加油✊
10
试了下 Grok CLI:curl -fsSL https://x.ai/cli/install.sh | bash 虽然功能不如 Claude Code 全,但能免费用 Grok 4.5 啊😍。一行 prompt 大概 3 分钟就生成出来了而且没有报错:" Three.js UMD 构建。正在实现完整的太阳系模拟(含自定义轨道控制,兼容本地打)"。 大伙可以访问试试:https://solar-system-seven-mocha.vercel.app/
4
下载 APP