diff --git a/pom.xml b/pom.xml index 3e6f72b..bf117f8 100644 --- a/pom.xml +++ b/pom.xml @@ -93,9 +93,23 @@ + org.springframework.ai spring-ai-starter-mcp-client + + + io.modelcontextprotocol.sdk + mcp + + + + + + + io.modelcontextprotocol.sdk + mcp + 0.18.3 diff --git a/src/main/java/com/wok/supportbot/SupportBotApplication.java b/src/main/java/com/wok/supportbot/SupportBotApplication.java index aab707c..78caa3b 100644 --- a/src/main/java/com/wok/supportbot/SupportBotApplication.java +++ b/src/main/java/com/wok/supportbot/SupportBotApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; /** * 主启动类 @@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling @EnableAsync +@EnableTransactionManagement public class SupportBotApplication { public static void main(String[] args) { diff --git a/src/main/java/com/wok/supportbot/config/McpClientManager.java b/src/main/java/com/wok/supportbot/config/McpClientManager.java index 7041d8a..2ad255b 100644 --- a/src/main/java/com/wok/supportbot/config/McpClientManager.java +++ b/src/main/java/com/wok/supportbot/config/McpClientManager.java @@ -6,6 +6,8 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -91,6 +93,12 @@ public class McpClientManager { @Value("${mcp.client.init-timeout:15s}") private String initTimeoutStr; + /** + * 共享的 JSON 映射器实例(Jackson 实现),供 StdioClientTransport 等组件使用 + * 新版 SDK(0.12+)要求显式传入 McpJsonMapper + */ + private final McpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper()); + /** * 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接 * 确保第一次对话请求时 clientCache 已就绪,MCP 工具可以被注册到 ChatClient @@ -422,7 +430,7 @@ public class McpClientManager { paramsBuilder.env(envStr); } - StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build()); + StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build(), mcpJsonMapper); client = buildSyncClient(transport); log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command); @@ -577,6 +585,37 @@ public class McpClientManager { } } + /** + * 尝试重新建立指定配置的 MCP 客户端连接(用于调用失败时的自动恢复)。 + * 先关闭并移除旧客户端,再通过懒加载重新创建。 + * + * @param configId 配置ID + * @return 重建后的 MCP Client 实例,失败返回 null + */ + public McpSyncClient reconnect(Long configId) { + String key = configId.toString(); + // 先移除旧客户端(同时关闭底层连接) + McpSyncClient old = clientCache.remove(key); + if (old != null) { + try { + old.close(); + } catch (Exception e) { + log.debug("关闭旧 MCP 客户端时出错: configId={}, error={}", configId, e.getMessage()); + } + log.info("MCP 客户端已移除,准备重建: configId={}", configId); + } + // 从不可用集合中移除,允许重新尝试 + unavailableConfigs.remove(key); + // 通过懒加载重建客户端 + McpSyncClient newClient = getClient(configId); + if (newClient != null) { + log.info("MCP 客户端重建成功: configId={}", configId); + } else { + log.warn("MCP 客户端重建失败: configId={}", configId); + } + return newClient; + } + /** * 测试指定配置的 MCP Server 连接 * 创建临时客户端 -> initialize -> listTools -> close,不影响正常缓存 diff --git a/src/main/java/com/wok/supportbot/config/ModelHealthService.java b/src/main/java/com/wok/supportbot/config/ModelHealthService.java index 42c65b3..e6e7f10 100644 --- a/src/main/java/com/wok/supportbot/config/ModelHealthService.java +++ b/src/main/java/com/wok/supportbot/config/ModelHealthService.java @@ -50,6 +50,12 @@ public class ModelHealthService { /** 单配置探活超时时间(秒) */ private static final int PROBE_TIMEOUT_SECONDS = 5; + /** 空配置跳过阈值:连续 N 次返回 0 行后跳过该 appType */ + private static final int EMPTY_SKIP_THRESHOLD = 3; + + /** 记录各 appType 连续空查询次数,达到阈值后跳过直到下次配置变更 */ + private final ConcurrentHashMap emptyCountMap = new ConcurrentHashMap<>(); + /** * 应用销毁时关闭线程池,防止资源泄漏 */ @@ -87,7 +93,23 @@ public class ModelHealthService { for (String appType : appTypes) { try { + // 跳过连续多次空查询的 appType(如未配置的 RERANK),减少无效 DB 查询 + int emptyCount = emptyCountMap.getOrDefault(appType, 0); + if (emptyCount >= EMPTY_SKIP_THRESHOLD) { + log.debug("跳过空配置 appType [{}] 的探活(已连续 {} 次返回空)", appType, emptyCount); + continue; + } + List configs = configService.getAllActiveConfigs(appType); + if (configs.isEmpty()) { + // 本次查询为空,递增计数 + int newCount = emptyCountMap.merge(appType, 1, Integer::sum); + log.debug("appType [{}] 无活跃配置(连续 {} 次)", appType, newCount); + continue; + } + // 有配置则重置计数 + emptyCountMap.remove(appType); + for (AiModelConfig config : configs) { Future> future = probeExecutor.submit(() -> probeConfig(config)); pendingTasks.add(Map.entry(config.getId(), future)); @@ -183,6 +205,8 @@ public class ModelHealthService { try { AiModelConfig config = configService.getConfigWithFullKey(configId); if (config != null) { + // 有新配置激活,重置该 appType 的空查询计数 + emptyCountMap.remove(config.getAppType()); Map result = probeConfig(config); updateHealthStatus(configId, result); } diff --git a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java index 2351c40..ddff710 100644 --- a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java +++ b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java @@ -219,6 +219,34 @@ public class McpToolCallback implements ToolCallback { } catch (Exception e) { long latency = System.currentTimeMillis() - startTime; log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage()); + + // 如果是超时异常(可能因 SSE 会话过期导致),尝试重连后重试一次 + if (e instanceof java.util.concurrent.TimeoutException + || e.getMessage() != null && e.getMessage().contains("TimeoutException")) { + log.info("MCP 工具调用超时,尝试重连后重试: tool={}, serverId={}", originalToolName, mcpServerConfigId); + McpSyncClient reconnected = mcpClientManager.reconnect(mcpServerConfigId); + if (reconnected != null) { + try { + Map retryArgs = OBJECT_MAPPER.readValue(toolInput, new TypeReference<>() {}); + McpSchema.CallToolRequest retryRequest = new McpSchema.CallToolRequest(originalToolName, retryArgs); + McpSchema.CallToolResult retryResult = reconnected.callTool(retryRequest); + long retryLatency = System.currentTimeMillis() - startTime; + log.info("MCP 工具重试成功: tool={}, latency={}ms", originalToolName, retryLatency); + + String retryResultStr = retryResult.content() != null ? String.valueOf(retryResult.content()) : ""; + boolean retryIsError = retryResult.isError() != null && retryResult.isError(); + EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, retryResultStr, retryLatency, retryIsError)); + + if (retryResult.isError() != null && retryResult.isError()) { + return "{\"error\": \"工具执行返回错误: " + escapeJson(String.valueOf(retryResult.content())) + "\"}"; + } + return ModelOptionsUtils.toJsonString(retryResult.content()); + } catch (Exception retryEx) { + log.error("MCP 工具重试仍然失败: tool={}, error={}", originalToolName, retryEx.getMessage()); + } + } + } + // 记录失败事件 EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true)); return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; diff --git a/src/main/java/com/wok/supportbot/rag/RerankerService.java b/src/main/java/com/wok/supportbot/rag/RerankerService.java index 9f12e69..bf47e5d 100644 --- a/src/main/java/com/wok/supportbot/rag/RerankerService.java +++ b/src/main/java/com/wok/supportbot/rag/RerankerService.java @@ -11,6 +11,7 @@ import org.springframework.web.client.RestTemplate; import java.time.Duration; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** @@ -28,6 +29,11 @@ public class RerankerService { /** HTTP 超时时间(秒) */ private static final int TIMEOUT_SECONDS = 3; + /** RERANK 活跃配置本地缓存,避免每次 Rerank 调用都查询 ai_model_config 表 */ + private volatile AiModelConfig cachedRerankConfig; + private volatile long lastCacheTime = 0; + private static final long CACHE_TTL_MS = 60_000; // 缓存 1 分钟,兼顾时效性与性能 + /** * 对候选文档执行重排序 * - 有 RERANK 配置 → 调用对应提供商的 Rerank API @@ -63,14 +69,20 @@ public class RerankerService { } /** - * 获取 RERANK 类型的活跃配置 + * 获取 RERANK 类型的活跃配置(带本地缓存,TTL 1 分钟) */ private AiModelConfig getActiveRerankConfig() { + long now = System.currentTimeMillis(); + if (cachedRerankConfig != null && (now - lastCacheTime) < CACHE_TTL_MS) { + return cachedRerankConfig; + } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(AiModelConfig::getAppType, "RERANK") .eq(AiModelConfig::getIsActive, true) .last("LIMIT 1"); - return aiModelConfigMapper.selectOne(wrapper); + cachedRerankConfig = aiModelConfigMapper.selectOne(wrapper); + lastCacheTime = now; + return cachedRerankConfig; } /** diff --git a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java index dc3b84b..f573702 100644 --- a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java +++ b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java @@ -62,6 +62,16 @@ public class JwtAuthFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + /** + * 跳过 SDK 路径 — 这些路径由 SdkAuthFilter 使用独立的 jwt.sdk-secret 验签, + * 不应由本 Filter 用管理后台密钥重复验证,避免产生误报 WARN 日志。 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + return path.startsWith("/ai/"); + } + /** * 从 Authorization 头提取 Bearer Token */ diff --git a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java index 7ea5bf0..dbd901b 100644 --- a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java +++ b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; /** * AI 模型配置管理服务 @@ -31,6 +32,9 @@ public class AiModelConfigService { /** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */ + private final ConcurrentHashMap activeConfigCache = new ConcurrentHashMap<>(); + // ==================== 分页列表 ==================== /** @@ -150,11 +154,32 @@ public class AiModelConfigService { /** * 获取指定应用类型的活跃配置(含完整 API Key,仅供内部调用使用) + * 使用本地缓存减少对 ai_model_config 表的重复查询。 * * @param appType 应用类型 * @return 活跃配置(含完整 API Key) */ public AiModelConfig getActiveConfigWithFullKey(String appType) { + // 先查缓存,命中则直接返回(防御性拷贝,避免缓存被外部修改污染) + AiModelConfig cached = activeConfigCache.get(appType); + if (cached != null) { + return AiModelConfig.builder() + .id(cached.getId()) + .name(cached.getName()) + .appType(cached.getAppType()) + .provider(cached.getProvider()) + .apiKey(cached.getApiKey()) + .modelName(cached.getModelName()) + .temperature(cached.getTemperature()) + .maxTokens(cached.getMaxTokens()) + .baseUrl(cached.getBaseUrl()) + .extraConfig(cached.getExtraConfig() != null ? new LinkedHashMap<>(cached.getExtraConfig()) : null) + .isActive(cached.getIsActive()) + .priority(cached.getPriority()) + .description(cached.getDescription()) + .build(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(AiModelConfig::getAppType, appType) .eq(AiModelConfig::getIsActive, true) @@ -164,9 +189,31 @@ public class AiModelConfigService { if (config != null && config.getApiKey() != null) { config.setApiKey(config.getApiKey().trim()); } + + // 写入缓存(只缓存非 null 结果,避免缓存"查无此配置"导致后续永远不查 DB) + if (config != null) { + activeConfigCache.put(appType, config); + } + return config; } + /** + * 清除指定 appType 的活跃配置缓存(配置变更时调用) + */ + public void evictActiveConfigCache(String appType) { + activeConfigCache.remove(appType); + log.debug("清除活跃配置缓存: appType={}", appType); + } + + /** + * 清除所有活跃配置缓存 + */ + public void evictAllActiveConfigCache() { + activeConfigCache.clear(); + log.debug("清除所有活跃配置缓存"); + } + // ==================== 新建配置 ==================== /** @@ -191,6 +238,8 @@ public class AiModelConfigService { aiModelConfigMapper.insert(config); // JSONB 字段 MyBatis Plus typeHandler 可能不触发,用 JdbcTemplate 显式写入保证可靠 persistExtraConfig(config.getId(), config.getExtraConfig()); + // 清除该 appType 的活跃配置缓存 + evictActiveConfigCache(config.getAppType()); log.info("新建 AI 模型配置: name={}, appType={}, modelName={}", config.getName(), config.getAppType(), config.getModelName()); return config; @@ -245,6 +294,9 @@ public class AiModelConfigService { persistExtraConfig(id, config.getExtraConfig()); } log.info("更新 AI 模型配置: id={}", id); + // 清除该 appType 的活跃配置缓存(更新后 appType 可能与原来不同) + String evictAppType = config.getAppType() != null ? config.getAppType() : existing.getAppType(); + evictActiveConfigCache(evictAppType); AiModelConfig updated = aiModelConfigMapper.selectById(id); // 返回前脱敏,避免明文 api_key 暴露给前端(与列表/详情接口保持一致) if (updated != null) { @@ -284,6 +336,8 @@ public class AiModelConfigService { log.info("激活 AI 模型配置: id={}, appType={}, modelName={}, priority={}", id, config.getAppType(), config.getModelName(), newPriority); + // 清除该 appType 的活跃配置缓存 + evictActiveConfigCache(config.getAppType()); } /** @@ -491,10 +545,15 @@ public class AiModelConfigService { * 停用指定配置(从 Fallback 链中移除) */ public void deactivateConfig(Long id) { + AiModelConfig config = aiModelConfigMapper.selectById(id); LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(AiModelConfig::getId, id) .set(AiModelConfig::getIsActive, false); aiModelConfigMapper.update(null, updateWrapper); + // 清除该 appType 的活跃配置缓存 + if (config != null) { + evictActiveConfigCache(config.getAppType()); + } log.info("停用 AI 模型配置: id={}", id); } @@ -601,6 +660,8 @@ public class AiModelConfigService { result.put("errors", errors); log.info("导入配置完成: added={}, overwritten={}, skipped={}, errors={}", added, overwritten, skipped, errors.size()); + // 导入可能改变任意 appType 的活跃配置,清除全部缓存 + evictAllActiveConfigCache(); return result; } } diff --git a/src/main/java/com/wok/supportbot/service/IntentRouter.java b/src/main/java/com/wok/supportbot/service/IntentRouter.java index 74dbb0c..3c17a0d 100644 --- a/src/main/java/com/wok/supportbot/service/IntentRouter.java +++ b/src/main/java/com/wok/supportbot/service/IntentRouter.java @@ -1,6 +1,7 @@ package com.wok.supportbot.service; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import com.wok.supportbot.config.ChatModelFactory; import lombok.AllArgsConstructor; import lombok.Data; @@ -25,8 +26,6 @@ public class IntentRouter { @Autowired private ChatModelFactory chatModelFactory; - private final ObjectMapper objectMapper = new ObjectMapper(); - /** 意图分类 Prompt 模板 */ private static final String INTENT_PROMPT_TEMPLATE = """ 你是一个意图分类器。根据用户问题,判断其属于以下哪个意图: @@ -83,29 +82,59 @@ public class IntentRouter { // ==================== 解析方法 ==================== + /** 从 LLM 原始响应中提取 intent 字段值 */ + private static final Pattern INTENT_PATTERN = Pattern.compile("\"intent\"\\s*:\\s*\"(FAQ|RAG|CHITCHAT)\""); + /** 从 LLM 原始响应中提取 confidence 字段值 */ + private static final Pattern CONFIDENCE_PATTERN = Pattern.compile("\"confidence\"\\s*:\\s*(0?\\.\\d+|1\\.0|[01])"); + /** - * 解析 LLM 返回的 JSON 意图分类结果 + * 解析 LLM 返回的意图分类结果。 + * 不依赖 JSON 结构完整性,使用正则直接从原始文本提取 intent 和 confidence 字段值, + * 可正确处理 LLM 输出的各种畸变:重复拼接、缺少逗号、额外文本、BOM/零宽字符等。 * 如果解析失败,默认返回 RAG(降级到现有流程) */ private IntentResult parseIntentResponse(String response) { try { - // 清理可能的 markdown 代码块包裹 - String cleaned = response.trim(); + // 1. 预处理:去除 BOM、零宽字符等不可见干扰字符 + String cleaned = response.trim() + .replace("", "") + .replace("​", "") + .replace("‌", "") + .replace("‍", "") + .replace(" ", " ") + .replace("‎", "") + .replace("‏", "") + .replace("⁠", ""); + + // 2. 去除 markdown 代码块包裹 if (cleaned.startsWith("```")) { cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", ""); } - IntentResult result = objectMapper.readValue(cleaned, IntentResult.class); + // 3. 正则提取 intent(取第一个匹配,应对 LLM 重复拼接 JSON 的情况) + Matcher intentMatcher = INTENT_PATTERN.matcher(cleaned); + String intent = intentMatcher.find() ? intentMatcher.group(1) : null; + + // 4. 正则提取 confidence(取第一个匹配) + Matcher confidenceMatcher = CONFIDENCE_PATTERN.matcher(cleaned); + double confidence = confidenceMatcher.find() ? Double.parseDouble(confidenceMatcher.group(1)) : 0.5; + + if (intent == null) { + log.warn("未能从 LLM 响应中提取到 intent 字段,降级为 RAG"); + return new IntentResult("RAG", 0.0); + } - // 校验意图类型有效性 - if (result.getIntent() == null || !isValidIntent(result.getIntent())) { - log.warn("无效的意图类型: {}, 降级为 RAG", result.getIntent()); + if (!isValidIntent(intent)) { + log.warn("无效的意图类型: {}, 降级为 RAG", intent); return new IntentResult("RAG", 0.5); } - return result; + return new IntentResult(intent, confidence); } catch (Exception e) { - log.warn("意图分类 JSON 解析失败,降级为 RAG: response={}", response, e); + String truncated = response != null && response.length() > 200 + ? response.substring(0, 200) + "..." + : response; + log.warn("意图分类解析异常,降级为 RAG: rawResponse={}", truncated, e); return new IntentResult("RAG", 0.0); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 210280a..34c26aa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,7 +37,7 @@ mybatis-plus: type-aliases-package: com.wok.supportbot.entity mapper-locations: classpath*:mapper/**/*.xml configuration: - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl map-underscore-to-camel-case: true global-config: db-config: