Browse Source

修复 API Key 日志泄露、模型配置缓存、JWT 误报、IntentRouter JSON 解析异常、事务噪声及 MCP 超时重连

master
wanghanlin 2 days ago
parent
commit
f397fda3ca
  1. 14
      pom.xml
  2. 2
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  3. 41
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  4. 24
      src/main/java/com/wok/supportbot/config/ModelHealthService.java
  5. 28
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  6. 16
      src/main/java/com/wok/supportbot/rag/RerankerService.java
  7. 10
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  8. 61
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  9. 53
      src/main/java/com/wok/supportbot/service/IntentRouter.java
  10. 2
      src/main/resources/application.yml

14
pom.xml

@ -93,9 +93,23 @@
</dependency> </dependency>
<!-- Spring AI MCP Client(Model Context Protocol 客户端) --> <!-- Spring AI MCP Client(Model Context Protocol 客户端) -->
<!-- 排除旧版 MCP SDK 0.10.0,显式引入新版 0.18.3 修复 SSE 400 + 超时问题 -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId> <artifactId>spring-ai-starter-mcp-client</artifactId>
<exclusions>
<exclusion>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 显式引入新版 MCP SDK(覆盖 spring-ai-mcp 的旧版 0.10.0) -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.18.3</version>
</dependency> </dependency>
<!--Hutool 工具库--> <!--Hutool 工具库-->

2
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.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/** /**
* 主启动类 * 主启动类
@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync @EnableAsync
@EnableTransactionManagement
public class SupportBotApplication { public class SupportBotApplication {
public static void main(String[] args) { public static void main(String[] args) {

41
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.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@ -91,6 +93,12 @@ public class McpClientManager {
@Value("${mcp.client.init-timeout:15s}") @Value("${mcp.client.init-timeout:15s}")
private String initTimeoutStr; private String initTimeoutStr;
/**
* 共享的 JSON 映射器实例Jackson 实现 StdioClientTransport 等组件使用
* 新版 SDK0.12+要求显式传入 McpJsonMapper
*/
private final McpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper());
/** /**
* 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接 * 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接
* 确保第一次对话请求时 clientCache 已就绪MCP 工具可以被注册到 ChatClient * 确保第一次对话请求时 clientCache 已就绪MCP 工具可以被注册到 ChatClient
@ -422,7 +430,7 @@ public class McpClientManager {
paramsBuilder.env(envStr); paramsBuilder.env(envStr);
} }
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build());
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build(), mcpJsonMapper);
client = buildSyncClient(transport); client = buildSyncClient(transport);
log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command); 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 连接 * 测试指定配置的 MCP Server 连接
* 创建临时客户端 -> initialize -> listTools -> close不影响正常缓存 * 创建临时客户端 -> initialize -> listTools -> close不影响正常缓存

24
src/main/java/com/wok/supportbot/config/ModelHealthService.java

@ -50,6 +50,12 @@ public class ModelHealthService {
/** 单配置探活超时时间(秒) */ /** 单配置探活超时时间(秒) */
private static final int PROBE_TIMEOUT_SECONDS = 5; private static final int PROBE_TIMEOUT_SECONDS = 5;
/** 空配置跳过阈值:连续 N 次返回 0 行后跳过该 appType */
private static final int EMPTY_SKIP_THRESHOLD = 3;
/** 记录各 appType 连续空查询次数,达到阈值后跳过直到下次配置变更 */
private final ConcurrentHashMap<String, Integer> emptyCountMap = new ConcurrentHashMap<>();
/** /**
* 应用销毁时关闭线程池防止资源泄漏 * 应用销毁时关闭线程池防止资源泄漏
*/ */
@ -87,7 +93,23 @@ public class ModelHealthService {
for (String appType : appTypes) { for (String appType : appTypes) {
try { try {
// 跳过连续多次空查询的 appType如未配置的 RERANK减少无效 DB 查询
int emptyCount = emptyCountMap.getOrDefault(appType, 0);
if (emptyCount >= EMPTY_SKIP_THRESHOLD) {
log.debug("跳过空配置 appType [{}] 的探活(已连续 {} 次返回空)", appType, emptyCount);
continue;
}
List<AiModelConfig> configs = configService.getAllActiveConfigs(appType); List<AiModelConfig> 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) { for (AiModelConfig config : configs) {
Future<Map<String, Object>> future = probeExecutor.submit(() -> probeConfig(config)); Future<Map<String, Object>> future = probeExecutor.submit(() -> probeConfig(config));
pendingTasks.add(Map.entry(config.getId(), future)); pendingTasks.add(Map.entry(config.getId(), future));
@ -183,6 +205,8 @@ public class ModelHealthService {
try { try {
AiModelConfig config = configService.getConfigWithFullKey(configId); AiModelConfig config = configService.getConfigWithFullKey(configId);
if (config != null) { if (config != null) {
// 有新配置激活重置该 appType 的空查询计数
emptyCountMap.remove(config.getAppType());
Map<String, Object> result = probeConfig(config); Map<String, Object> result = probeConfig(config);
updateHealthStatus(configId, result); updateHealthStatus(configId, result);
} }

28
src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

@ -219,6 +219,34 @@ public class McpToolCallback implements ToolCallback {
} catch (Exception e) { } catch (Exception e) {
long latency = System.currentTimeMillis() - startTime; long latency = System.currentTimeMillis() - startTime;
log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage()); 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<String, Object> 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)); EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true));
return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}";

16
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.time.Duration;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -28,6 +29,11 @@ public class RerankerService {
/** HTTP 超时时间(秒) */ /** HTTP 超时时间(秒) */
private static final int TIMEOUT_SECONDS = 3; 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 * - RERANK 配置 调用对应提供商的 Rerank API
@ -63,14 +69,20 @@ public class RerankerService {
} }
/** /**
* 获取 RERANK 类型的活跃配置
* 获取 RERANK 类型的活跃配置带本地缓存TTL 1 分钟
*/ */
private AiModelConfig getActiveRerankConfig() { private AiModelConfig getActiveRerankConfig() {
long now = System.currentTimeMillis();
if (cachedRerankConfig != null && (now - lastCacheTime) < CACHE_TTL_MS) {
return cachedRerankConfig;
}
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, "RERANK") wrapper.eq(AiModelConfig::getAppType, "RERANK")
.eq(AiModelConfig::getIsActive, true) .eq(AiModelConfig::getIsActive, true)
.last("LIMIT 1"); .last("LIMIT 1");
return aiModelConfigMapper.selectOne(wrapper);
cachedRerankConfig = aiModelConfigMapper.selectOne(wrapper);
lastCacheTime = now;
return cachedRerankConfig;
} }
/** /**

10
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -62,6 +62,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); 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 * Authorization 头提取 Bearer Token
*/ */

61
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 com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* AI 模型配置管理服务 * AI 模型配置管理服务
@ -31,6 +32,9 @@ public class AiModelConfigService {
/** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */ /** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */
private final ConcurrentHashMap<String, AiModelConfig> activeConfigCache = new ConcurrentHashMap<>();
// ==================== 分页列表 ==================== // ==================== 分页列表 ====================
/** /**
@ -150,11 +154,32 @@ public class AiModelConfigService {
/** /**
* 获取指定应用类型的活跃配置含完整 API Key仅供内部调用使用 * 获取指定应用类型的活跃配置含完整 API Key仅供内部调用使用
* 使用本地缓存减少对 ai_model_config 表的重复查询
* *
* @param appType 应用类型 * @param appType 应用类型
* @return 活跃配置含完整 API Key * @return 活跃配置含完整 API Key
*/ */
public AiModelConfig getActiveConfigWithFullKey(String appType) { 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<AiModelConfig> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, appType) wrapper.eq(AiModelConfig::getAppType, appType)
.eq(AiModelConfig::getIsActive, true) .eq(AiModelConfig::getIsActive, true)
@ -164,9 +189,31 @@ public class AiModelConfigService {
if (config != null && config.getApiKey() != null) { if (config != null && config.getApiKey() != null) {
config.setApiKey(config.getApiKey().trim()); config.setApiKey(config.getApiKey().trim());
} }
// 写入缓存只缓存非 null 结果避免缓存"查无此配置"导致后续永远不查 DB
if (config != null) {
activeConfigCache.put(appType, config);
}
return 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); aiModelConfigMapper.insert(config);
// JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠 // JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠
persistExtraConfig(config.getId(), config.getExtraConfig()); persistExtraConfig(config.getId(), config.getExtraConfig());
// 清除该 appType 的活跃配置缓存
evictActiveConfigCache(config.getAppType());
log.info("新建 AI 模型配置: name={}, appType={}, modelName={}", log.info("新建 AI 模型配置: name={}, appType={}, modelName={}",
config.getName(), config.getAppType(), config.getModelName()); config.getName(), config.getAppType(), config.getModelName());
return config; return config;
@ -245,6 +294,9 @@ public class AiModelConfigService {
persistExtraConfig(id, config.getExtraConfig()); persistExtraConfig(id, config.getExtraConfig());
} }
log.info("更新 AI 模型配置: id={}", id); log.info("更新 AI 模型配置: id={}", id);
// 清除该 appType 的活跃配置缓存更新后 appType 可能与原来不同
String evictAppType = config.getAppType() != null ? config.getAppType() : existing.getAppType();
evictActiveConfigCache(evictAppType);
AiModelConfig updated = aiModelConfigMapper.selectById(id); AiModelConfig updated = aiModelConfigMapper.selectById(id);
// 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致 // 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致
if (updated != null) { if (updated != null) {
@ -284,6 +336,8 @@ public class AiModelConfigService {
log.info("激活 AI 模型配置: id={}, appType={}, modelName={}, priority={}", log.info("激活 AI 模型配置: id={}, appType={}, modelName={}, priority={}",
id, config.getAppType(), config.getModelName(), newPriority); id, config.getAppType(), config.getModelName(), newPriority);
// 清除该 appType 的活跃配置缓存
evictActiveConfigCache(config.getAppType());
} }
/** /**
@ -491,10 +545,15 @@ public class AiModelConfigService {
* 停用指定配置 Fallback 链中移除 * 停用指定配置 Fallback 链中移除
*/ */
public void deactivateConfig(Long id) { public void deactivateConfig(Long id) {
AiModelConfig config = aiModelConfigMapper.selectById(id);
LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(AiModelConfig::getId, id) updateWrapper.eq(AiModelConfig::getId, id)
.set(AiModelConfig::getIsActive, false); .set(AiModelConfig::getIsActive, false);
aiModelConfigMapper.update(null, updateWrapper); aiModelConfigMapper.update(null, updateWrapper);
// 清除该 appType 的活跃配置缓存
if (config != null) {
evictActiveConfigCache(config.getAppType());
}
log.info("停用 AI 模型配置: id={}", id); log.info("停用 AI 模型配置: id={}", id);
} }
@ -601,6 +660,8 @@ public class AiModelConfigService {
result.put("errors", errors); result.put("errors", errors);
log.info("导入配置完成: added={}, overwritten={}, skipped={}, errors={}", log.info("导入配置完成: added={}, overwritten={}, skipped={}, errors={}",
added, overwritten, skipped, errors.size()); added, overwritten, skipped, errors.size());
// 导入可能改变任意 appType 的活跃配置清除全部缓存
evictAllActiveConfigCache();
return result; return result;
} }
} }

53
src/main/java/com/wok/supportbot/service/IntentRouter.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service; 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 com.wok.supportbot.config.ChatModelFactory;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@ -25,8 +26,6 @@ public class IntentRouter {
@Autowired @Autowired
private ChatModelFactory chatModelFactory; private ChatModelFactory chatModelFactory;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 意图分类 Prompt 模板 */ /** 意图分类 Prompt 模板 */
private static final String INTENT_PROMPT_TEMPLATE = """ 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降级到现有流程 * 如果解析失败默认返回 RAG降级到现有流程
*/ */
private IntentResult parseIntentResponse(String response) { private IntentResult parseIntentResponse(String response) {
try { 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("```")) { if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", ""); 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 new IntentResult("RAG", 0.5);
} }
return result;
return new IntentResult(intent, confidence);
} catch (Exception e) { } 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); return new IntentResult("RAG", 0.0);
} }
} }

2
src/main/resources/application.yml

@ -37,7 +37,7 @@ mybatis-plus:
type-aliases-package: com.wok.supportbot.entity type-aliases-package: com.wok.supportbot.entity
mapper-locations: classpath*:mapper/**/*.xml mapper-locations: classpath*:mapper/**/*.xml
configuration: configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
global-config: global-config:
db-config: db-config:

Loading…
Cancel
Save