From 23f195326d2849f3bf54b7e969392e576dc5ff57 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Sat, 15 Aug 2026 17:47:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm-trace):=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=E5=A2=9E=E5=BC=BA=E4=B8=8E=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增追踪字段与落库链路: - FAQ 命中详情(faqId/faqQuestion/faqMatchType/faqScore) - 错误分类(errorType/errorMessage)与 Token 用量三档 - MCP 工具调用事件(toolCallsJson)、多轮历史(historyMessagesJson/historyTurns) - RAG 命中片段(ragHitsJson)、检索模式(searchMode)、账户/API Key - 数据库迁移新增 13 列 + 3 索引,同步 init-database.sql 前端增强: - 筛选新增 API Key / 错误类型下拉,列表新增 RAG 列与刷新按钮 - 详情 Tab 扩展 FAQ / 工具调用 / 对话上下文,Prompt 关键词搜索高亮 - 新增会话时间线抽屉、调用趋势图(Chart.js 双轴折线) 缺陷修复: - MCP 工具事件采集改用 ToolContext 显式收集器,修复 Reactor 流式跨线程丢 ThreadLocal 导致 toolCallsJson 为空 - 趋势统计 GROUP BY 别名改为 date_trunc 表达式,修复 PostgreSQL 语法错误 - 详情表格移除重复 row-key,新增 /error-types 与 /trend 接口 --- frontend/components.d.ts | 3 + frontend/src/api/api-key.ts | 4 + frontend/src/api/llm-trace.ts | 28 ++ .../src/components/PromptSearchHighlight.vue | 66 ++++ frontend/src/views/PromptTracePanel.vue | 297 +++++++++++++++++- .../com/wok/supportbot/app/AssistantApp.java | 291 +++++++++++++++-- .../com/wok/supportbot/app/ChatContext.java | 28 +- .../com/wok/supportbot/app/ChatPipeline.java | 17 +- .../com/wok/supportbot/app/ChatRequest.java | 15 +- .../supportbot/config/DatabaseInitConfig.java | 39 +++ .../supportbot/controller/AiController.java | 8 +- .../controller/LlmCallTraceController.java | 40 ++- .../controller/OpenApiController.java | 2 +- .../wok/supportbot/entity/LlmCallTrace.java | 53 ++++ .../wok/supportbot/mcp/McpToolCallback.java | 99 +++++- .../com/wok/supportbot/rag/RagContext.java | 7 +- .../com/wok/supportbot/rag/RagPipeline.java | 40 ++- .../service/LlmCallTraceService.java | 59 +++- src/main/resources/init-database.sql | 29 ++ 19 files changed, 1043 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/PromptSearchHighlight.vue diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 30b2be5..faf0a40 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -9,6 +9,7 @@ declare module 'vue' { export interface GlobalComponents { FormDialog: typeof import('./src/components/FormDialog.vue')['default'] MessageSources: typeof import('./src/components/MessageSources.vue')['default'] + PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] TButton: typeof import('tdesign-vue-next')['Button'] @@ -43,6 +44,8 @@ declare module 'vue' { TTabs: typeof import('tdesign-vue-next')['Tabs'] TTag: typeof import('tdesign-vue-next')['Tag'] TTextarea: typeof import('tdesign-vue-next')['Textarea'] + TTimeline: typeof import('tdesign-vue-next')['Timeline'] + TTimelineItem: typeof import('tdesign-vue-next')['TimelineItem'] TUpload: typeof import('tdesign-vue-next')['Upload'] } } diff --git a/frontend/src/api/api-key.ts b/frontend/src/api/api-key.ts index 54b8d8e..2af1346 100644 --- a/frontend/src/api/api-key.ts +++ b/frontend/src/api/api-key.ts @@ -4,6 +4,10 @@ import type { ApiResponse } from '@/types/api' export function listApiKeys(page = 1, size = 20): Promise { return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data) } +/** 拉取全部 API Key(供筛选下拉,最多 1000 条) */ +export function listAllApiKeys(): Promise { + return request.get(`/api-key/list?page=1&size=1000`).then(r => r.data) +} export function createApiKey(data: any): Promise { return request.post('/api-key', data).then(r => r.data) } export function revokeApiKey(id: string): Promise { return request.put(`/api-key/${id}/revoke`).then(r => r.data) } export function enableApiKey(id: string): Promise { return request.put(`/api-key/${id}/enable`).then(r => r.data) } diff --git a/frontend/src/api/llm-trace.ts b/frontend/src/api/llm-trace.ts index 0f3dcda..6b73175 100644 --- a/frontend/src/api/llm-trace.ts +++ b/frontend/src/api/llm-trace.ts @@ -11,6 +11,8 @@ export interface LlmTraceQuery { startTime?: string endTime?: string keyword?: string + apiKeyId?: string + errorType?: string } /** 分页查询调用记录 */ @@ -24,6 +26,8 @@ export function listLlmTraces(query: LlmTraceQuery = {}): Promise { if (query.startTime) params.set('startTime', query.startTime) if (query.endTime) params.set('endTime', query.endTime) if (query.keyword) params.set('keyword', query.keyword) + if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId) + if (query.errorType) params.set('errorType', query.errorType) return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data) } @@ -42,3 +46,27 @@ export function cleanLlmTraces(keepDays: number): Promise export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise { return request.get(`/llm-trace/stats?groupBy=${groupBy}`).then(r => r.data) } + +/** 可选错误类型列表 */ +export function getLlmTraceErrorTypes(): Promise { + return request.get('/llm-trace/error-types').then(r => r.data) +} + +/** 单会话调用记录(会话时间线,复用 list 接口按 conversationId 过滤) */ +export function getLlmTraceConversation(conversationId: string): Promise { + return request.get(`/llm-trace/list?conversationId=${encodeURIComponent(conversationId)}&size=100`).then(r => r.data) +} + +/** 时间维度趋势统计 */ +export interface LlmTraceTrendQuery { + groupBy?: 'HOUR' | 'DAY' + startTime?: string + endTime?: string +} +export function getLlmTraceTrend(query: LlmTraceTrendQuery = {}): Promise { + const params = new URLSearchParams() + params.set('groupBy', query.groupBy ?? 'HOUR') + if (query.startTime) params.set('startTime', query.startTime) + if (query.endTime) params.set('endTime', query.endTime) + return request.get(`/llm-trace/trend?${params.toString()}`).then(r => r.data) +} diff --git a/frontend/src/components/PromptSearchHighlight.vue b/frontend/src/components/PromptSearchHighlight.vue new file mode 100644 index 0000000..fd95a6f --- /dev/null +++ b/frontend/src/components/PromptSearchHighlight.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/views/PromptTracePanel.vue b/frontend/src/views/PromptTracePanel.vue index 3bde341..c9313a2 100644 --- a/frontend/src/views/PromptTracePanel.vue +++ b/frontend/src/views/PromptTracePanel.vue @@ -19,11 +19,15 @@ + +
+ 刷新 对比({{ selectedRowKeys.length }}/2) + 趋势 清理
@@ -42,6 +46,9 @@ + @@ -50,6 +57,7 @@ @@ -60,8 +68,11 @@
{{ intentLabel(detail.intent) }} {{ statusLabel(detail.status) }} + {{ detail.searchMode }} + {{ detail.errorType }} {{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms
+
{{ detail.errorMessage }}
@@ -70,7 +81,7 @@
RAG 资料
{{ detail.ragContext }}
最终完整 system prompt
-
{{ detail.systemPrompt || '(空)' }}
+
复制完整 Prompt 编辑角色提示词 @@ -78,10 +89,41 @@
{{ detail.userMessage }}
-
{{ detail.aiResponse }}(已截断)
+ +
+ Token:prompt {{ detail.promptTokens ?? '-' }} / completion {{ detail.completionTokens ?? '-' }} / 合计 {{ detail.totalTokens ?? '-' }} +
+
{{ detail.aiResponse }}(已截断)
+
+
检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条
+ +
注入的 RAG 上下文
{{ detail.ragContext || '(无 RAG 资料)' }}
-
检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条
+
+ + +
+
FAQ ID{{ detail.faqId ?? '-' }}
+
问题{{ detail.faqQuestion ?? '-' }}
+
匹配方式{{ detail.faqMatchType || '-' }}
+
相似度{{ formatScore(detail.faqScore) }}
+
+
+ + + + + + + + +
历史轮数:{{ detail.historyTurns }}
+ +
@@ -94,6 +136,13 @@
{{ c.roleName || '(无角色)' }} · {{ c.intent }} · {{ formatDate(c.createTime) }}
+
+ RAG {{ c.enableRag ? '开' : '关' }} + {{ c.searchMode }} + {{ c.errorType }} + FAQ:{{ c.faqMatchType }} + Token:{{ c.totalTokens }} +
系统提示词
{{ c.systemPrompt || '(空)' }}
用户消息
@@ -105,6 +154,44 @@ + + + +
会话 ID:{{ timelineConversationId }}
+ + + +
+ {{ intentLabel(row.intent) }} + {{ statusLabel(row.status) }} + {{ formatDate(row.createTime) }} · {{ row.latencyMs }}ms +
+
{{ row.userMessage }}
+
{{ row.aiResponse }}
+
+
+
+
+ + + +
+ + 近 24 小时 + 近 7 天 + + + 按小时 + 按天 + +
+ 刷新 +
+
+ +
+ + @@ -122,14 +209,17 @@ diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 1725d00..2351775 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -8,15 +8,20 @@ import com.wok.supportbot.config.SimpleCircuitBreaker; import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.entity.LlmCallTrace; import com.wok.supportbot.mcp.McpToolCallback; +import com.wok.supportbot.mcp.McpToolCallback.ToolCallEvent; import com.wok.supportbot.mcp.McpToolCallbackAdapter; import com.wok.supportbot.service.AiModelConfigService; import com.wok.supportbot.service.ContentSafetyService; import com.wok.supportbot.service.LlmCallTraceService; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.document.Document; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.vectorstore.VectorStore; @@ -33,6 +38,9 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID; @@ -126,6 +134,37 @@ public class AssistantApp { /** 截断时保留的尾部字符数 */ private static final int AI_RESPONSE_TAIL_CHARS = 500; + /** 埋点 JSON 序列化器(构建 ragHitsJson / toolCallsJson / historyMessagesJson) */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** 历史消息每轮内容截断长度(避免 trace 行过大) */ + private static final int HISTORY_MESSAGE_MAX_CHARS = 200; + + /** 历史消息记录上限(条数) */ + private static final int HISTORY_MESSAGE_MAX_COUNT = 10; + + /** 错误消息截断长度 */ + private static final int ERROR_MESSAGE_MAX_CHARS = 500; + + /** MCP 工具调用结果落库截断长度(避免返回数据过大撑爆 trace 行) */ + private static final int TOOL_CALL_RESULT_MAX_CHARS = 2000; + + /** MCP 工具调用入参落库截断长度 */ + private static final int TOOL_CALL_INPUT_MAX_CHARS = 500; + + /** + * 埋点附加元信息:承载错误分类/消息、token 用量与 MCP 工具调用事件, + * 避免 recordTrace 参数过多。 + */ + private record TraceMeta( + String errorType, + String errorMessage, + Integer promptTokens, + Integer completionTokens, + Integer totalTokens, + List mcpEvents + ) {} + /** * 初始化 ChatClient * @@ -220,17 +259,20 @@ public class AssistantApp { // 熔断:全局 AI 调用处于熔断状态,直接返回降级提示(不做 buildRequest,避免熔断期间仍走意图路由/检索) if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { log.warn("AI 调用熔断中,返回降级提示"); - recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS"); + recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS", + new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null)); return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of()); } ChatRequest req = chatPipeline.buildRequest(ctx); if (req.faqHit()) { String faqAnswer = req.faqAnswer().get(); - recordTrace(ctx, req, faqAnswer, 0, "FAQ"); + recordTrace(ctx, req, faqAnswer, 0, "FAQ", + new TraceMeta(null, null, null, null, null, null)); return new ChatResult(faqAnswer, List.of()); } - McpToolCallback.resetEvents(); - McpToolCallback.resetCallRounds(); + // 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题 + List events = new CopyOnWriteArrayList<>(); + AtomicInteger rounds = new AtomicInteger(0); try { ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) .prompt() @@ -239,17 +281,28 @@ public class AssistantApp { if (StringUtils.hasText(req.finalSystemPrompt())) { spec = spec.system(req.finalSystemPrompt()); } - String text = spec.call().chatResponse().getResult().getOutput().getText(); + spec = spec.toolContext(Map.of( + McpToolCallback.MCP_EVENTS_KEY, events, + McpToolCallback.MCP_ROUNDS_KEY, rounds)); + ChatResponse response = spec.call().chatResponse(); + String text = response.getResult().getOutput().getText(); + Usage usage = response.getMetadata() != null ? response.getMetadata().getUsage() : null; aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY); - recordTrace(ctx, req, text, elapsedMillis(startNanos), "COMPLETE"); + recordTrace(ctx, req, text, elapsedMillis(startNanos), "COMPLETE", + new TraceMeta(null, null, + usage != null ? usage.getPromptTokens() : null, + usage != null ? usage.getCompletionTokens() : null, + usage != null ? usage.getTotalTokens() : null, + events)); // 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成 - return new ChatResult(text, McpToolCallback.drainEvents(), List.of()); + return new ChatResult(text, events, List.of()); } catch (Exception e) { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); String fallback = "抱歉,AI 服务调用失败:" + e.getMessage(); - recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR"); + recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR", + new TraceMeta(classifyError(e), maskError(e.getMessage()), null, null, null, events)); return new ChatResult(fallback, List.of()); } } @@ -268,7 +321,8 @@ public class AssistantApp { // 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索) if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { log.warn("AI 调用熔断中(流式),返回降级提示"); - recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS"); + recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS", + new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null)); return Flux.just(CIRCUIT_OPEN_MESSAGE); } ChatRequest req = chatPipeline.buildRequest(ctx); @@ -276,11 +330,13 @@ public class AssistantApp { // FAQ 命中:整段答案原样输出,由 SSE 编码器处理内部换行, // 后端不做任何格式增删(不拆行、不加换行、不补空格)。 String faqAnswer = req.faqAnswer().get(); - recordTrace(ctx, req, faqAnswer, 0, "FAQ"); + recordTrace(ctx, req, faqAnswer, 0, "FAQ", + new TraceMeta(null, null, null, null, null, null)); return Flux.just(faqAnswer); } - McpToolCallback.resetEvents(); - McpToolCallback.resetCallRounds(); + // 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题 + List events = new CopyOnWriteArrayList<>(); + AtomicInteger rounds = new AtomicInteger(0); ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) .prompt() .user(req.finalMessage()) @@ -288,8 +344,25 @@ public class AssistantApp { if (StringUtils.hasText(req.finalSystemPrompt())) { spec = spec.system(req.finalSystemPrompt()); } - // 原始文本流;推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成 - Flux rawStream = spec.stream().content(); + spec = spec.toolContext(Map.of( + McpToolCallback.MCP_EVENTS_KEY, events, + McpToolCallback.MCP_ROUNDS_KEY, rounds)); + // 改为 chatResponse 流以采集 token 用量,再映射回纯文本流 + AtomicReference usageRef = new AtomicReference<>(); + AtomicReference errorTypeRef = new AtomicReference<>(); + AtomicReference errorMessageRef = new AtomicReference<>(); + Flux responseFlux = spec.stream().chatResponse(); + Flux rawStream = responseFlux + .doOnNext(r -> { + if (r != null && r.getMetadata() != null && r.getMetadata().getUsage() != null) { + usageRef.set(r.getMetadata().getUsage()); + } + }) + .map(r -> { + String out = r != null && r.getResult() != null && r.getResult().getOutput() != null + ? r.getResult().getOutput().getText() : ""; + return out != null ? out : ""; + }); // 聚合所有分片用于埋点(在 doFinally 时取完整回复文本) StringBuilder aggregated = new StringBuilder(); return preserveTrailingWhitespace(rawStream) @@ -297,16 +370,21 @@ public class AssistantApp { .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnError(e -> { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); + errorTypeRef.set(classifyError(e)); + errorMessageRef.set(maskError(e.getMessage())); log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); }) .doFinally(signalType -> { - // 确保 ThreadLocal 清理,防止线程池复用时数据残留 - McpToolCallback.resetEvents(); - McpToolCallback.resetCallRounds(); - // 流式埋点:按终止信号区分状态,断连/异常也落库 + // 流式埋点:按终止信号区分状态,断连/异常也落库(events 由 toolContext 显式收集,跨线程安全) String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE" : signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL"; - recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status); + Usage usage = usageRef.get(); + recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status, + new TraceMeta(errorTypeRef.get(), errorMessageRef.get(), + usage != null ? usage.getPromptTokens() : null, + usage != null ? usage.getCompletionTokens() : null, + usage != null ? usage.getTotalTokens() : null, + events)); }) .onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage())); } @@ -379,7 +457,8 @@ public class AssistantApp { * @param latencyMs 耗时(毫秒) * @param status 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */ - private void recordTrace(ChatContext ctx, ChatRequest req, String responseText, long latencyMs, String status) { + private void recordTrace(ChatContext ctx, ChatRequest req, String responseText, long latencyMs, + String status, TraceMeta meta) { try { // 走缓存的活跃配置(仅取模型元信息,不落 apiKey),避免每次对话在 Reactor 线程同步查库 AiModelConfig cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType()); @@ -394,10 +473,27 @@ public class AssistantApp { truncated = true; } + // FAQ 命中详情(faqMatchResult 来自编排决策,未命中为 null) + Long faqId = null; + String faqQuestion = null; + String faqMatchType = null; + Double faqScore = null; + if (req != null && req.faqMatchResult() != null && req.faqMatchResult().getFaq() != null) { + faqId = req.faqMatchResult().getFaq().getId(); + faqQuestion = req.faqMatchResult().getFaq().getQuestion(); + faqMatchType = req.faqMatchResult().getMatchType(); + faqScore = req.faqMatchResult().getScore(); + } + + // 历史消息(一次读取,同时用于 JSON 与条数) + List history = safeGetHistory(ctx.chatId()); + LlmCallTrace trace = LlmCallTrace.builder() .conversationId(ctx.chatId()) .roleId(ctx.roleId()) .roleName(ctx.roleName()) + .accountId(ctx.accountId()) + .apiKeyId(ctx.apiKeyId()) .intent(req != null ? req.intent() : null) .enableRag(ctx.enableRag()) .systemPrompt(req != null ? contentSafetyService.mask(req.finalSystemPrompt()) : null) @@ -407,15 +503,26 @@ public class AssistantApp { .aiResponse(aiResponse) .aiResponseTruncated(truncated) .ragContext(req != null ? contentSafetyService.mask(req.ragContextText()) : null) + .ragHitsJson(buildRagHitsJson(req)) .faqHit(req != null ? req.faqHit() : false) - // 检索模式:当前主管道 RagPipeline.similaritySearch 仅纯向量检索; - // HybridSearchService(VECTOR/KEYWORD/HYBRID)接入主管道后应改为透传真实值 - .searchMode((req != null && "RAG".equals(req.intent())) ? "VECTOR" : null) + .faqId(faqId) + .faqQuestion(faqQuestion) + .faqMatchType(faqMatchType) + .faqScore(faqScore) + .searchMode(req != null ? req.searchMode() : null) .hitCount(req != null ? req.hitCount() : null) + .toolCallsJson(buildToolCallsJson(meta != null ? meta.mcpEvents() : null)) + .historyMessagesJson(buildHistoryJson(history)) + .historyTurns(history != null ? history.size() : null) .modelName(cfg != null ? cfg.getModelName() : null) .provider(cfg != null ? cfg.getProvider() : null) .temperature(cfg != null ? cfg.getTemperature() : null) .maxTokens(cfg != null ? cfg.getMaxTokens() : null) + .promptTokens(meta != null ? meta.promptTokens() : null) + .completionTokens(meta != null ? meta.completionTokens() : null) + .totalTokens(meta != null ? meta.totalTokens() : null) + .errorType(meta != null ? meta.errorType() : null) + .errorMessage(meta != null ? contentSafetyService.mask(meta.errorMessage()) : null) .latencyMs((int) latencyMs) .status(status) .build(); @@ -425,6 +532,144 @@ public class AssistantApp { } } + /** + * 序列化 RAG 命中文档片段为 JSON(含 documentId/title/chunkIndex/sourceName/score/searchMode)。 + * 无命中返回 null。 + */ + private String buildRagHitsJson(ChatRequest req) { + if (req == null || req.hitDocuments() == null || req.hitDocuments().isEmpty()) { + return null; + } + try { + List> items = new ArrayList<>(); + for (Document doc : req.hitDocuments()) { + Map meta = doc.getMetadata(); + Map item = new LinkedHashMap<>(); + item.put("documentId", meta.get("documentId")); + item.put("title", meta.get("title")); + item.put("chunkIndex", meta.get("chunkIndex")); + item.put("sourceName", meta.get("sourceName")); + // 距离字段在不同检索实现下可能是 distance 或 score,二者取一 + Object score = meta.get("distance") != null ? meta.get("distance") : meta.get("score"); + item.put("score", score); + item.put("searchMode", req.searchMode()); + items.add(item); + } + return OBJECT_MAPPER.writeValueAsString(items); + } catch (Exception e) { + log.warn("序列化 RAG 命中片段失败: {}", e.getMessage()); + return null; + } + } + + /** + * 序列化 MCP 工具调用事件为 JSON(input/result 先脱敏再截断,避免返回数据过大撑爆 trace 行)。 + * 无事件返回 null。 + */ + private String buildToolCallsJson(List events) { + if (events == null || events.isEmpty()) { + return null; + } + try { + List> items = new ArrayList<>(); + for (ToolCallEvent e : events) { + Map item = new LinkedHashMap<>(); + item.put("tool", e.tool()); + item.put("input", truncateToolCallText(contentSafetyService.mask(e.input()), TOOL_CALL_INPUT_MAX_CHARS)); + item.put("result", truncateToolCallText(contentSafetyService.mask(e.result()), TOOL_CALL_RESULT_MAX_CHARS)); + item.put("latencyMs", e.latencyMs()); + item.put("error", e.error()); + items.add(item); + } + return OBJECT_MAPPER.writeValueAsString(items); + } catch (Exception ex) { + log.warn("序列化 MCP 工具调用事件失败: {}", ex.getMessage()); + return null; + } + } + + /** + * 对工具调用 input/result 做长度截断(保留头部 + 截断标记)。 + * + * @param text 脱敏后的文本 + * @param maxChars 最大保留字符数 + * @return 未超限返回原文本;超限返回前 maxChars 个字符 + 截断标记 + */ + private String truncateToolCallText(String text, int maxChars) { + if (text == null || text.length() <= maxChars) { + return text; + } + int total = text.length(); + return text.substring(0, maxChars) + "…(共 " + total + " 字符,已截断)"; + } + + /** 从会话记忆读取最近若干条历史消息(失败返回 null)。 */ + private List safeGetHistory(String chatId) { + try { + return chatMemory.get(chatId, HISTORY_MESSAGE_MAX_COUNT); + } catch (Exception e) { + return null; + } + } + + /** 将历史消息序列化为 JSON(每轮内容截断)。无历史返回 null。 */ + private String buildHistoryJson(List history) { + if (history == null || history.isEmpty()) { + return null; + } + try { + List> items = new ArrayList<>(); + for (Message m : history) { + String role = m.getMessageType() != null ? m.getMessageType().name() : "unknown"; + String content = m.getText() != null ? m.getText() : ""; + if (content.length() > HISTORY_MESSAGE_MAX_CHARS) { + content = content.substring(0, HISTORY_MESSAGE_MAX_CHARS) + "…"; + } + items.add(Map.of("role", role, "content", content)); + } + return OBJECT_MAPPER.writeValueAsString(items); + } catch (Exception e) { + return null; + } + } + + /** + * 按异常类型分类错误,供 trace.error_type 落库。 + */ + private String classifyError(Throwable t) { + if (t == null) { + return "UNKNOWN"; + } + String name = t.getClass().getSimpleName(); + String msg = t.getMessage() == null ? "" : t.getMessage().toLowerCase(); + if (name.contains("Mcp") || msg.contains("mcp") || msg.contains("tool")) { + return "MCP"; + } + if (name.contains("CircuitBreaker") || msg.contains("circuit")) { + return "CIRCUIT_BREAK"; + } + if (name.contains("Validation") || name.contains("IllegalArgument")) { + return "VALIDATION"; + } + if (name.contains("Ai") || name.contains("OpenAi") || name.contains("DashScope") + || name.contains("Http") || name.contains("Timeout") || msg.contains("timeout")) { + return "LLM_API"; + } + return "UNKNOWN"; + } + + /** 对异常消息做脱敏并截断,供 trace.error_message 落库。 */ + private String maskError(String message) { + if (message == null || message.isBlank()) { + return null; + } + String masked = contentSafetyService.mask(message); + if (masked != null && masked.length() > ERROR_MESSAGE_MAX_CHARS) { + return masked.substring(0, ERROR_MESSAGE_MAX_CHARS) + "…"; + } + return masked; + } + /** * 计算自 startNanos 起的耗时(毫秒)。 */ diff --git a/src/main/java/com/wok/supportbot/app/ChatContext.java b/src/main/java/com/wok/supportbot/app/ChatContext.java index b1ae394..a9a4102 100644 --- a/src/main/java/com/wok/supportbot/app/ChatContext.java +++ b/src/main/java/com/wok/supportbot/app/ChatContext.java @@ -22,6 +22,8 @@ import java.util.List; * @param streaming 是否流式输出 * @param roleId 客服角色 ID(可空,供调用追踪等使用) * @param roleName 客服角色名(可空,快照用途) + * @param accountId 账户 ID(可空,供调用追踪/个人信息删除权使用) + * @param apiKeyId API Key ID(可空,Open API 路径为实际鉴权 Key,供租户隔离与追踪) */ public record ChatContext( String message, @@ -34,7 +36,9 @@ public record ChatContext( boolean enableRag, boolean streaming, Long roleId, - String roleName + String roleName, + String accountId, + Long apiKeyId ) { /** 默认应用类型 */ @@ -53,30 +57,38 @@ public record ChatContext( * 便捷构造:仅指定核心字段,其余取默认值(非 RAG、非流式)。 */ public static ChatContext of(String message, String chatId) { - return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false, null, null); + return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false, null, null, null, null); } public ChatContext withSystemPrompt(String systemPrompt) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } public ChatContext withAllowedMcpTools(List allowedMcpTools) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } public ChatContext withCategoryIds(List categoryIds) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } public ChatContext withRewriteStrategy(String rewriteStrategy) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } public ChatContext withEnableRag(boolean enableRag) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } public ChatContext withStreaming(boolean streaming) { - return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName); + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); + } + + public ChatContext withAccountId(String accountId) { + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); + } + + public ChatContext withApiKeyId(Long apiKeyId) { + return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId); } } diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java index e080997..cfdca77 100644 --- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java +++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java @@ -2,6 +2,7 @@ package com.wok.supportbot.app; import com.wok.supportbot.rag.RagContext; import com.wok.supportbot.rag.RagPipeline; +import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import com.wok.supportbot.service.IntentRouter; import com.wok.supportbot.service.SystemConfigService; import com.wok.supportbot.service.RagHitLogService; @@ -78,7 +79,7 @@ public class ChatPipeline { // 普通对话:enableRag=false(含 Controller 层 isKbDenied 强制置 false 的情况) if (!ctx.enableRag()) { return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), - globalPrompt, null, null, "CHAT"); + globalPrompt, null, null, "CHAT", null, null, null); } // 意图路由:先用 IntentRouter 做细粒度分类 @@ -87,11 +88,12 @@ public class ChatPipeline { // FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示 if (intent != null && "FAQ".equals(intent.getIntent()) && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { - Optional faqAnswer = ragPipeline.tryFaqMatch(ctx.message()); - if (faqAnswer.isPresent()) { + Optional faqMatch = ragPipeline.tryFaqMatchResult(ctx.message()); + if (faqMatch.isPresent()) { log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId()); + Optional faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer()); return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer, - globalPrompt, null, null, "FAQ"); + globalPrompt, null, null, "FAQ", null, null, faqMatch.get()); } log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId()); } @@ -100,7 +102,7 @@ public class ChatPipeline { if (intent != null && "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) { return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), - globalPrompt, null, null, "CHITCHAT"); + globalPrompt, null, null, "CHITCHAT", null, null, null); } // RAG 检索(含 FAQ 优先匹配) @@ -123,13 +125,14 @@ public class ChatPipeline { } if (rag.faqHit()) { return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(), - globalPrompt, null, null, "FAQ"); + globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult()); } // RAG 生成:资料块注入 system,重写后查询作为 user 消息 String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText()); return new ChatRequest(ctx, rag.rewrittenQuery(), finalSystem, Optional.empty(), - globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG"); + globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG", + rag.searchMode(), rag.documents(), null); } /** diff --git a/src/main/java/com/wok/supportbot/app/ChatRequest.java b/src/main/java/com/wok/supportbot/app/ChatRequest.java index 626abeb..14b3610 100644 --- a/src/main/java/com/wok/supportbot/app/ChatRequest.java +++ b/src/main/java/com/wok/supportbot/app/ChatRequest.java @@ -1,5 +1,9 @@ package com.wok.supportbot.app; +import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; +import org.springframework.ai.document.Document; + +import java.util.List; import java.util.Optional; /** @@ -17,6 +21,9 @@ import java.util.Optional; *
  • {@link #ragContextText()}:RAG 资料块文本(未注入时为空/null)
  • *
  • {@link #hitCount()}:命中的知识库片段数(非 RAG 为 null)
  • *
  • {@link #intent()}:本次决策命中的意图:CHAT / CHITCHAT / FAQ / RAG
  • + *
  • {@link #searchMode()}:真实检索模式 VECTOR / KEYWORD / HYBRID(非 RAG 为 null)
  • + *
  • {@link #hitDocuments()}:命中的知识库片段(RAG 场景,非 RAG 为 null)
  • + *
  • {@link #faqMatchResult()}:FAQ 命中详情(含 matchType/score,未命中为 null)
  • * * * @param ctx 原始上下文 @@ -27,6 +34,9 @@ import java.util.Optional; * @param ragContextText RAG 资料块文本,可为 null * @param hitCount 命中文档数,可为 null * @param intent 意图:CHAT / CHITCHAT / FAQ / RAG + * @param searchMode 真实检索模式,可为 null + * @param hitDocuments 命中的知识库片段,可为 null + * @param faqMatchResult FAQ 命中详情,可为 null */ public record ChatRequest( ChatContext ctx, @@ -36,7 +46,10 @@ public record ChatRequest( String globalPrompt, String ragContextText, Integer hitCount, - String intent + String intent, + String searchMode, + List hitDocuments, + FaqMatchResult faqMatchResult ) { /** FAQ 是否命中 */ diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 516814a..8fcb705 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -190,6 +190,7 @@ public class DatabaseInitConfig { createLlmCallTraceTable(); } }); + safeInit("迁移 llm_call_trace 扩展列", this::addLlmCallTraceExtensionColumns); // P1-003: API 开放平台 safeInit("创建 API Key 表 api_key", () -> { @@ -1114,14 +1115,27 @@ public class DatabaseInitConfig { user_message TEXT, ai_response TEXT, ai_response_truncated BOOLEAN, + tool_calls_json TEXT, + history_messages_json TEXT, + history_turns INTEGER, rag_context TEXT, faq_hit BOOLEAN, + faq_id BIGINT, + faq_question TEXT, + faq_match_type VARCHAR(16), + faq_score DOUBLE PRECISION, search_mode VARCHAR(20), hit_count INTEGER, + rag_hits_json TEXT, model_name VARCHAR(128), provider VARCHAR(64), temperature DOUBLE PRECISION, max_tokens INTEGER, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + error_type VARCHAR(32), + error_message TEXT, latency_ms INTEGER, status VARCHAR(16), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL @@ -1131,6 +1145,31 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_created ON llm_call_trace (create_time DESC, id DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_role_created ON llm_call_trace (role_id, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_conv_created ON llm_call_trace (conversation_id, create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)"); + } + + /** + * 为已存在的 llm_call_trace 表补加扩展列(幂等,供旧部署环境迁移)。 + */ + private void addLlmCallTraceExtensionColumns() { + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS tool_calls_json TEXT"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_messages_json TEXT"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_turns INTEGER"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_id BIGINT"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_question TEXT"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_match_type VARCHAR(16)"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_score DOUBLE PRECISION"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS rag_hits_json TEXT"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS completion_tokens INTEGER"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS total_tokens INTEGER"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_type VARCHAR(32)"); + jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_message TEXT"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)"); } private void createDashboardSnapshotTable() { diff --git a/src/main/java/com/wok/supportbot/controller/AiController.java b/src/main/java/com/wok/supportbot/controller/AiController.java index 0dd13e2..d9ac504 100644 --- a/src/main/java/com/wok/supportbot/controller/AiController.java +++ b/src/main/java/com/wok/supportbot/controller/AiController.java @@ -118,7 +118,7 @@ public class AiController { try { List cats = resolveCategoryIds(scope, categoryId, categoryIds); ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats, - normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name()); + normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name(), context.accountId(), null); List docs = assistantApp.retrieveSources(ctx); List> out = new ArrayList<>(); for (Document doc : docs) { @@ -149,7 +149,7 @@ public class AiController { RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt), scope.hasRole() ? scope.allowedMcpTools() : null, null, null, false, false, - context.roleId(), scope.name()); + context.roleId(), scope.name(), context.accountId(), null); } /** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */ @@ -162,7 +162,7 @@ public class AiController { boolean enableRag = !isKbDenied(scope); List cats = resolveCategoryIds(scope, categoryId, categoryIds); return new ChatContext(message, chatId, "CHAT", sys, scope.hasRole() ? scope.allowedMcpTools() : null, cats, - normalizeStrategy(rewriteStrategy), enableRag, false, context.roleId(), scope.name()); + normalizeStrategy(rewriteStrategy), enableRag, false, context.roleId(), scope.name(), context.accountId(), null); } // ==================== SDK 会话管理接口(带账户归属校验) ==================== @@ -356,7 +356,7 @@ public class AiController { } ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(), ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true, - ctx.roleId(), ctx.roleName()); + ctx.roleId(), ctx.roleName(), ctx.accountId(), ctx.apiKeyId()); return assistantApp.chatStream(ctx); } diff --git a/src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java b/src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java index e41013a..1609f2c 100644 --- a/src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java +++ b/src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java @@ -54,10 +54,11 @@ public class LlmCallTraceController { @RequestParam(required = false) String intent, @RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime, - @RequestParam(required = false) String keyword) { + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String errorType) { try { Map result = llmCallTraceService.pageQuery( - page, size, roleId, conversationId, intent, startTime, endTime, keyword); + page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType); Map data = new LinkedHashMap<>(); data.put("success", true); data.put("data", result.get("records")); @@ -146,6 +147,41 @@ public class LlmCallTraceController { } } + /** + * 可选错误类型列表(供前端筛选下拉)。 + */ + @GetMapping("/error-types") + public ResponseEntity> errorTypes() { + List> types = List.of( + Map.of("label", "LLM 调用失败", "value", "LLM_API"), + Map.of("label", "MCP 工具异常", "value", "MCP"), + Map.of("label", "熔断降级", "value", "CIRCUIT_BREAK"), + Map.of("label", "参数校验", "value", "VALIDATION"), + Map.of("label", "未知", "value", "UNKNOWN") + ); + return ResponseEntity.ok(Map.of("success", true, "data", types)); + } + + /** + * 时间维度趋势统计(按小时或天聚合)。 + */ + @GetMapping("/trend") + public ResponseEntity> trend( + @RequestParam(defaultValue = "HOUR") String groupBy, + @RequestParam(required = false) String startTime, + @RequestParam(required = false) String endTime) { + try { + List> rows = llmCallTraceService.trend(groupBy, startTime, endTime); + return ResponseEntity.ok(Map.of("success", true, "data", rows)); + } catch (Exception e) { + log.error("查询 LLM 调用追踪趋势失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "统计失败:" + e.getMessage() + )); + } + } + /** * 获取当前登录用户名(用于审计日志,best-effort)。 */ diff --git a/src/main/java/com/wok/supportbot/controller/OpenApiController.java b/src/main/java/com/wok/supportbot/controller/OpenApiController.java index 554d0d9..1326e31 100644 --- a/src/main/java/com/wok/supportbot/controller/OpenApiController.java +++ b/src/main/java/com/wok/supportbot/controller/OpenApiController.java @@ -225,7 +225,7 @@ public class OpenApiController { return new ChatContext(message, chatId, "CHAT", systemPrompt, scope.hasRole() ? scope.allowedMcpTools() : null, - catIds, strategy, useRag, streaming, roleId, scope.name()); + catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId()); } /** diff --git a/src/main/java/com/wok/supportbot/entity/LlmCallTrace.java b/src/main/java/com/wok/supportbot/entity/LlmCallTrace.java index 660510e..2e137df 100644 --- a/src/main/java/com/wok/supportbot/entity/LlmCallTrace.java +++ b/src/main/java/com/wok/supportbot/entity/LlmCallTrace.java @@ -93,6 +93,18 @@ public class LlmCallTrace implements Serializable { @TableField("ai_response_truncated") private Boolean aiResponseTruncated; + /** MCP 工具调用事件 JSON 数组(可空) */ + @TableField("tool_calls_json") + private String toolCallsJson; + + /** 本次注入 LLM 的历史消息 JSON(可空) */ + @TableField("history_messages_json") + private String historyMessagesJson; + + /** 历史消息轮数(可空) */ + @TableField("history_turns") + private Integer historyTurns; + /** RAG 资料块(可空) */ @TableField("rag_context") private String ragContext; @@ -101,6 +113,23 @@ public class LlmCallTrace implements Serializable { @TableField("faq_hit") private Boolean faqHit; + /** FAQ 命中 ID(可空,未命中或非 FAQ 意图为 null) */ + @TableField("faq_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long faqId; + + /** FAQ 标准问题快照(可空) */ + @TableField("faq_question") + private String faqQuestion; + + /** FAQ 匹配类型:EXACT / KEYWORD / SEMANTIC(可空) */ + @TableField("faq_match_type") + private String faqMatchType; + + /** FAQ 匹配分数(0.0 ~ 1.0,可空) */ + @TableField("faq_score") + private Double faqScore; + /** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */ @TableField("search_mode") private String searchMode; @@ -109,6 +138,10 @@ public class LlmCallTrace implements Serializable { @TableField("hit_count") private Integer hitCount; + /** RAG 命中片段详情 JSON 数组(含 documentId/title/chunkIndex/score/searchMode,可空) */ + @TableField("rag_hits_json") + private String ragHitsJson; + /** 模型名称 */ @TableField("model_name") private String modelName; @@ -125,10 +158,30 @@ public class LlmCallTrace implements Serializable { @TableField("max_tokens") private Integer maxTokens; + /** 提示词 token 数(可空,非 LLM 调用路径无此值) */ + @TableField("prompt_tokens") + private Integer promptTokens; + + /** 生成 token 数(可空) */ + @TableField("completion_tokens") + private Integer completionTokens; + + /** 总 token 数(可空) */ + @TableField("total_tokens") + private Integer totalTokens; + /** 调用耗时(毫秒) */ @TableField("latency_ms") private Integer latencyMs; + /** 错误类型:LLM_API / MCP / CIRCUIT_BREAK / VALIDATION / UNKNOWN(可空,非失败调用为 null) */ + @TableField("error_type") + private String errorType; + + /** 错误原始消息(已脱敏,可空) */ + @TableField("error_message") + private String errorMessage; + /** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */ @TableField("status") private String status; diff --git a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java index 28dc3b6..daf68b8 100644 --- a/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java +++ b/src/main/java/com/wok/supportbot/mcp/McpToolCallback.java @@ -6,6 +6,7 @@ import com.wok.supportbot.config.McpClientManager; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.DefaultToolDefinition; @@ -14,6 +15,7 @@ import org.springframework.ai.tool.definition.ToolDefinition; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; /** * MCP Tool -> Spring AI ToolCallback 适配器 @@ -30,6 +32,11 @@ public class McpToolCallback implements ToolCallback { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * 日志输出内容的最大长度,避免 MCP 工具返回数据过大导致日志刷屏。 + */ + private static final int MAX_LOG_LENGTH = 1000; + // ==================== 工具调用事件收集 ==================== /** @@ -37,9 +44,20 @@ public class McpToolCallback implements ToolCallback { */ public record ToolCallEvent(String tool, String input, String result, long latencyMs, boolean error) {} + /** + * ToolContext 中携带事件收集器的 key(AssistantApp 通过 ChatClientRequestSpec.toolContext 注入)。 + * 用于解决 Reactor 流式场景下 ThreadLocal 跨线程丢失工具调用事件的问题。 + */ + public static final String MCP_EVENTS_KEY = "mcp_tool_events"; + + /** + * ToolContext 中携带调用轮次计数器的 key,避免跨线程时轮次限制失效。 + */ + public static final String MCP_ROUNDS_KEY = "mcp_tool_rounds"; + /** * 线程级事件收集器:在同一请求线程中收集所有工具调用事件 - * SSE 流式输出完成后,从这里取出事件发送给前端 + * 作为无 toolContext 场景(同步调用 / 兼容旧逻辑)的兜底。 */ private static final ThreadLocal> EVENTS = ThreadLocal.withInitial(ArrayList::new); @@ -166,17 +184,57 @@ public class McpToolCallback implements ToolCallback { */ @Override public String call(String toolInput) { + // 无 ToolContext 时回退到 ThreadLocal 收集器(同步调用 / 兼容旧逻辑) + return doCall(toolInput, EVENTS.get(), null); + } + + /** + * 带 ToolContext 的执行入口。 + *

    + * 优先从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器, + * 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题;读取不到时回退 ThreadLocal。 + */ + @Override + @SuppressWarnings("unchecked") + public String call(String toolInput, ToolContext toolContext) { + List collector = EVENTS.get(); + AtomicInteger rounds = null; + Map ctx = toolContext != null ? toolContext.getContext() : null; + if (ctx != null) { + Object ev = ctx.get(MCP_EVENTS_KEY); + if (ev instanceof List list) { + collector = (List) list; + } + Object rd = ctx.get(MCP_ROUNDS_KEY); + if (rd instanceof AtomicInteger ai) { + rounds = ai; + } + } + return doCall(toolInput, collector, rounds); + } + + /** + * 工具调用主逻辑。 + * + * @param toolInput JSON 格式的工具输入参数 + * @param collector 事件收集器(非 null,事件追加到此列表) + * @param rounds 调用轮次计数器(可 null,为 null 时回退 ThreadLocal 计数) + * @return 工具执行结果(JSON 字符串形式) + */ + private String doCall(String toolInput, List collector, AtomicInteger rounds) { // 检查调用轮次是否超限 - int currentRound = CALL_ROUNDS.get(); + int currentRound = rounds != null ? rounds.getAndIncrement() : CALL_ROUNDS.get(); if (currentRound >= maxCallRounds) { log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}", originalToolName, currentRound, maxCallRounds); return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}"; } - CALL_ROUNDS.set(currentRound + 1); + if (rounds == null) { + CALL_ROUNDS.set(currentRound + 1); + } log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}", - mcpServerConfigId, originalToolName, toolInput, currentRound + 1, maxCallRounds); + mcpServerConfigId, originalToolName, truncate(toolInput), currentRound + 1, maxCallRounds); long startTime = System.currentTimeMillis(); try { @@ -194,22 +252,22 @@ public class McpToolCallback implements ToolCallback { McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments); McpSchema.CallToolResult result = client.callTool(request); - // 调试日志:输出 MCP 调用原始返回结果 + // 调试日志:输出 MCP 调用原始返回结果(截断,避免返回数据过大刷屏) log.info("🔧 [DEBUG] MCP callTool 原始结果 - serverId={}, tool={}, rawJson={}", - mcpServerConfigId, originalToolName, ModelOptionsUtils.toJsonString(result)); + mcpServerConfigId, originalToolName, truncate(ModelOptionsUtils.toJsonString(result))); long latency = System.currentTimeMillis() - startTime; log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}", originalToolName, latency, result.isError()); - // 收集工具调用事件(供 SSE 流式输出使用) + // 收集工具调用事件(供提示词追踪 / SSE 流式输出使用) String resultStr = result.content() != null ? String.valueOf(result.content()) : ""; boolean isError = result.isError() != null && result.isError(); - EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, resultStr, latency, isError)); + collector.add(new ToolCallEvent(originalToolName, toolInput, resultStr, latency, isError)); // 检查是否为错误结果 if (result.isError() != null && result.isError()) { - log.error("MCP 工具返回错误: tool={}, content={}", originalToolName, result.content()); + log.error("MCP 工具返回错误: tool={}, content={}", originalToolName, truncate(String.valueOf(result.content()))); return "{\"error\": \"工具执行返回错误: " + escapeJson(String.valueOf(result.content())) + "\"}"; } @@ -217,7 +275,7 @@ public class McpToolCallback implements ToolCallback { // 将 MCP Content 列表序列化为 JSON 字符串返回给 AI 模型 // 与官方 SyncMcpToolCallback 保持一致,使用 ModelOptionsUtils 序列化 String resultJson = ModelOptionsUtils.toJsonString(result.content()); - log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, resultJson); + log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, truncate(resultJson)); return resultJson; } catch (Exception e) { @@ -235,16 +293,16 @@ public class McpToolCallback implements ToolCallback { McpSchema.CallToolRequest retryRequest = new McpSchema.CallToolRequest(originalToolName, retryArgs); McpSchema.CallToolResult retryResult = reconnected.callTool(retryRequest); - // 调试日志:输出 MCP 重试调用原始返回结果 + // 调试日志:输出 MCP 重试调用原始返回结果(截断) log.info("🔧 [DEBUG] MCP callTool 重试原始结果 - serverId={}, tool={}, rawJson={}", - mcpServerConfigId, originalToolName, ModelOptionsUtils.toJsonString(retryResult)); + mcpServerConfigId, originalToolName, truncate(ModelOptionsUtils.toJsonString(retryResult))); 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)); + collector.add(new ToolCallEvent(originalToolName, toolInput, retryResultStr, retryLatency, retryIsError)); if (retryResult.isError() != null && retryResult.isError()) { return "{\"error\": \"工具执行返回错误: " + escapeJson(String.valueOf(retryResult.content())) + "\"}"; @@ -257,11 +315,24 @@ public class McpToolCallback implements ToolCallback { } // 记录失败事件 - EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true)); + collector.add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true)); return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; } } + /** + * 截断日志内容,避免 MCP 工具返回数据过大导致日志刷屏。 + * + * @param text 原始文本 + * @return 未超限时返回原文本;超限时返回前 MAX_LOG_LENGTH 个字符 + 截断标记 + */ + private String truncate(String text) { + if (text == null || text.length() <= MAX_LOG_LENGTH) { + return text; + } + return text.substring(0, MAX_LOG_LENGTH) + "…(共 " + text.length() + " 字符,已截断)"; + } + /** * 转义字符串中的特殊字符,避免破坏 JSON 格式 */ diff --git a/src/main/java/com/wok/supportbot/rag/RagContext.java b/src/main/java/com/wok/supportbot/rag/RagContext.java index b57deda..5a982a2 100644 --- a/src/main/java/com/wok/supportbot/rag/RagContext.java +++ b/src/main/java/com/wok/supportbot/rag/RagContext.java @@ -1,5 +1,6 @@ package com.wok.supportbot.rag; +import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import org.springframework.ai.document.Document; import java.util.List; @@ -19,12 +20,16 @@ import java.util.Optional; * @param documents 检索命中的知识库片段(含 metadata),可为空 * @param contextText 拼接后的资料文本(用 {@code "\n\n---\n\n"} 分隔);无资料时为空串 * @param rewrittenQuery 传给模型的用户消息:MULTI_QUERY 为原始 message,其余策略为重写后的查询 + * @param searchMode 真实检索模式(VECTOR / KEYWORD / HYBRID),当前主管道固定为 VECTOR + * @param faqMatchResult FAQ 命中详情(含 matchType/score);未命中为 null */ public record RagContext( Optional faqAnswer, List documents, String contextText, - String rewrittenQuery + String rewrittenQuery, + String searchMode, + FaqMatchResult faqMatchResult ) { /** FAQ 是否命中 */ diff --git a/src/main/java/com/wok/supportbot/rag/RagPipeline.java b/src/main/java/com/wok/supportbot/rag/RagPipeline.java index 9af53e9..8767b4a 100644 --- a/src/main/java/com/wok/supportbot/rag/RagPipeline.java +++ b/src/main/java/com/wok/supportbot/rag/RagPipeline.java @@ -8,6 +8,7 @@ import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.service.FaqMatchEngine; +import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import com.wok.supportbot.service.RagHitLogService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -108,10 +109,12 @@ public class RagPipeline { */ public RagContext retrieve(ChatContext ctx) { // 1. FAQ 优先匹配:命中则直接返回标准答案,跳过检索与生成 - Optional faqAnswer = tryFaqMatch(ctx.message()); - if (faqAnswer.isPresent()) { - log.info("FAQ 命中,跳过知识库检索: chatId={}", ctx.chatId()); - return new RagContext(faqAnswer, Collections.emptyList(), "", ctx.message()); + Optional faqMatch = tryFaqMatchResult(ctx.message()); + if (faqMatch.isPresent()) { + log.info("FAQ 命中,跳过知识库检索: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType()); + String answer = faqMatch.get().getFaq().getAnswer(); + return new RagContext(Optional.ofNullable(answer), Collections.emptyList(), "", ctx.message(), + currentSearchMode(), faqMatch.get()); } // 2. 统一检索 + 组装结果 @@ -150,7 +153,7 @@ public class RagPipeline { logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery); String contextText = joinContext(docs); - return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery); + return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery, currentSearchMode(), null); } /** @@ -172,19 +175,36 @@ public class RagPipeline { // ==================== FAQ 匹配 ==================== /** - * 尝试 FAQ 三级匹配(精确→关键词→语义),命中返回标准答案。 - * 异常时降级为未命中,供 ChatPipeline 等调用方直接获取 FAQ 匹配结果。 + * 尝试 FAQ 三级匹配(精确→关键词→语义),命中返回完整匹配结果(含 matchType/score)。 + * 异常时降级为未命中。 */ - public Optional tryFaqMatch(String message) { + public Optional tryFaqMatchResult(String message) { try { - return faqMatchEngine.match(message) - .map(result -> result.getFaq().getAnswer()); + return faqMatchEngine.match(message); } catch (Exception e) { log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage()); return Optional.empty(); } } + /** + * 尝试 FAQ 三级匹配,命中返回标准答案(仅答案文本)。 + * 异常时降级为未命中,供仅需答案的调用方使用。 + */ + public Optional tryFaqMatch(String message) { + return tryFaqMatchResult(message).map(result -> result.getFaq().getAnswer()); + } + + /** + * 当前主管道的真实检索模式。 + *

    + * 目前仅单路向量检索(VECTOR);HybridSearchService 接入主管道后, + * 此方法应改为根据上下文透传 VECTOR / KEYWORD / HYBRID。 + */ + private String currentSearchMode() { + return "VECTOR"; + } + // ==================== 查询重写 ==================== /** diff --git a/src/main/java/com/wok/supportbot/service/LlmCallTraceService.java b/src/main/java/com/wok/supportbot/service/LlmCallTraceService.java index a37666d..eefc4aa 100644 --- a/src/main/java/com/wok/supportbot/service/LlmCallTraceService.java +++ b/src/main/java/com/wok/supportbot/service/LlmCallTraceService.java @@ -40,7 +40,8 @@ public class LlmCallTraceService { /** 列表查询需要排除的大 TEXT 字段 */ private static final Set BIG_TEXT_FIELDS = - Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response"); + Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response", + "error_message", "tool_calls_json", "rag_hits_json", "history_messages_json"); /** 默认保留天数 */ private static final int DEFAULT_RETENTION_DAYS = 30; @@ -64,19 +65,20 @@ public class LlmCallTraceService { * 分页查询(列表不含大 TEXT 字段,user_message 截断为摘要)。 */ public Map pageQuery(int page, int size, Long roleId, String conversationId, - String intent, String startTime, String endTime, String keyword) { + String intent, String startTime, String endTime, String keyword, + String errorType) { if (page < 1) page = 1; if (page > 10000) page = 10000; if (size < 1 || size > 100) size = 20; // 总数(只加 WHERE 条件) Long total = llmCallTraceMapper.selectCount( - buildWhere(new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword)); + buildWhere(new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType)); if (total == null) total = 0L; // 列表(排除大字段 + 排序 + 分页) QueryWrapper listWrapper = buildWhere( - new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword); + new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType); listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn())); listWrapper.orderByDesc("create_time").orderByDesc("id"); listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size)); @@ -130,6 +132,49 @@ public class LlmCallTraceService { return result; } + /** + * 时间维度趋势:按小时或天聚合调用量 / 平均耗时 / 错误率 / FAQ 命中率 / token 用量。 + * + * @param groupBy HOUR 或 DAY(其余值按 HOUR 处理) + * @param startTime 可选开始时间(含),格式 yyyy-MM-dd HH:mm:ss + * @param endTime 可选结束时间(含),格式 yyyy-MM-dd HH:mm:ss + * @return 按时间桶升序排列的统计列表 + */ + public List> trend(String groupBy, String startTime, String endTime) { + boolean byDay = "DAY".equalsIgnoreCase(groupBy); + String bucket = byDay ? "day" : "hour"; + String timeFormat = byDay ? "'YYYY-MM-DD'" : "'YYYY-MM-DD HH24'"; + + StringBuilder sql = new StringBuilder(); + sql.append("SELECT to_char(date_trunc('").append(bucket).append("', create_time), ") + .append(timeFormat).append(") AS time_bucket, ") + .append("COUNT(*) AS call_count, ") + .append("ROUND(AVG(latency_ms)) AS avg_latency_ms, ") + .append("ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'ERROR') / NULLIF(COUNT(*), 0), 2) AS error_rate, ") + .append("ROUND(100.0 * COUNT(*) FILTER (WHERE faq_hit = true) / NULLIF(COUNT(*), 0), 2) AS faq_hit_rate, ") + .append("COALESCE(SUM(total_tokens), 0) AS total_tokens ") + .append("FROM llm_call_trace WHERE 1=1 "); + List params = new ArrayList<>(); + if (startTime != null && !startTime.isBlank()) { + sql.append("AND create_time >= ? "); + params.add(startTime.trim()); + } + if (endTime != null && !endTime.isBlank()) { + sql.append("AND create_time <= ? "); + params.add(endTime.trim()); + } + // PostgreSQL 不允许 GROUP BY 引用 SELECT 输出列别名,故此处重复 date_trunc 表达式; + // ORDER BY 则可引用别名 time_bucket + sql.append("GROUP BY date_trunc('").append(bucket).append("', create_time) ORDER BY time_bucket"); + + List> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + List> result = new ArrayList<>(rows.size()); + for (Map row : rows) { + result.add(snakeToCamel(row)); + } + return result; + } + /** * 分批删除 N 天前的记录,避免单次大表 DELETE 锁表。 * @@ -190,7 +235,8 @@ public class LlmCallTraceService { * 组装 WHERE 条件(供 count 与 list 复用)。 */ private QueryWrapper buildWhere(QueryWrapper wrapper, Long roleId, - String conversationId, String intent, String startTime, String endTime, String keyword) { + String conversationId, String intent, String startTime, String endTime, String keyword, + String errorType) { if (roleId != null) { wrapper.eq("role_id", roleId); } @@ -200,6 +246,9 @@ public class LlmCallTraceService { if (intent != null && !intent.isBlank()) { wrapper.eq("intent", intent.trim().toUpperCase()); } + if (errorType != null && !errorType.isBlank()) { + wrapper.eq("error_type", errorType.trim().toUpperCase()); + } if (startTime != null && !startTime.isBlank()) { wrapper.ge("create_time", startTime.trim()); } diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index 5e4f921..cba2b9f 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -622,14 +622,27 @@ CREATE TABLE IF NOT EXISTS llm_call_trace ( user_message TEXT, ai_response TEXT, ai_response_truncated BOOLEAN, + tool_calls_json TEXT, + history_messages_json TEXT, + history_turns INTEGER, rag_context TEXT, faq_hit BOOLEAN, + faq_id BIGINT, + faq_question TEXT, + faq_match_type VARCHAR(16), + faq_score DOUBLE PRECISION, search_mode VARCHAR(20), hit_count INTEGER, + rag_hits_json TEXT, model_name VARCHAR(128), provider VARCHAR(64), temperature DOUBLE PRECISION, max_tokens INTEGER, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + error_type VARCHAR(32), + error_message TEXT, latency_ms INTEGER, status VARCHAR(16), create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -638,6 +651,9 @@ CREATE TABLE IF NOT EXISTS llm_call_trace ( CREATE INDEX IF NOT EXISTS idx_llm_trace_created ON llm_call_trace (create_time DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_llm_trace_role_created ON llm_call_trace (role_id, create_time DESC); CREATE INDEX IF NOT EXISTS idx_llm_trace_conv_created ON llm_call_trace (conversation_id, create_time DESC); +CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC); +CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC); +CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC); COMMENT ON TABLE llm_call_trace IS 'LLM 调用追踪表(记录每次 LLM 调用的 system prompt/回复/模型参数/耗时,用于提示词优化调试,append-only)'; COMMENT ON COLUMN llm_call_trace.id IS '主键(雪花算法生成)'; @@ -654,14 +670,27 @@ COMMENT ON COLUMN llm_call_trace.role_prompt IS '角色提示词快照( COMMENT ON COLUMN llm_call_trace.user_message IS '用户原始消息(脱敏后)'; COMMENT ON COLUMN llm_call_trace.ai_response IS 'AI 回复(截断,保留头部+尾部)'; COMMENT ON COLUMN llm_call_trace.ai_response_truncated IS 'AI 回复是否被截断'; +COMMENT ON COLUMN llm_call_trace.tool_calls_json IS 'MCP 工具调用事件 JSON 数组(可空)'; +COMMENT ON COLUMN llm_call_trace.history_messages_json IS '本次注入 LLM 的历史消息 JSON(可空)'; +COMMENT ON COLUMN llm_call_trace.history_turns IS '历史消息条数(可空)'; COMMENT ON COLUMN llm_call_trace.rag_context IS 'RAG 资料块(可空)'; COMMENT ON COLUMN llm_call_trace.faq_hit IS '是否 FAQ 命中'; +COMMENT ON COLUMN llm_call_trace.faq_id IS 'FAQ 命中 ID(可空)'; +COMMENT ON COLUMN llm_call_trace.faq_question IS 'FAQ 标准问题快照(可空)'; +COMMENT ON COLUMN llm_call_trace.faq_match_type IS 'FAQ 匹配类型:EXACT / KEYWORD / SEMANTIC(可空)'; +COMMENT ON COLUMN llm_call_trace.faq_score IS 'FAQ 匹配分数(0.0~1.0,可空)'; COMMENT ON COLUMN llm_call_trace.search_mode IS '检索模式:VECTOR / KEYWORD / HYBRID(可空)'; COMMENT ON COLUMN llm_call_trace.hit_count IS '命中文档数(可空)'; +COMMENT ON COLUMN llm_call_trace.rag_hits_json IS 'RAG 命中片段详情 JSON 数组(可空)'; COMMENT ON COLUMN llm_call_trace.model_name IS '模型名称'; COMMENT ON COLUMN llm_call_trace.provider IS '提供商'; COMMENT ON COLUMN llm_call_trace.temperature IS '温度参数'; COMMENT ON COLUMN llm_call_trace.max_tokens IS '最大 Token'; +COMMENT ON COLUMN llm_call_trace.prompt_tokens IS '提示词 token 数(可空)'; +COMMENT ON COLUMN llm_call_trace.completion_tokens IS '生成 token 数(可空)'; +COMMENT ON COLUMN llm_call_trace.total_tokens IS '总 token 数(可空)'; +COMMENT ON COLUMN llm_call_trace.error_type IS '错误类型:LLM_API / MCP / CIRCUIT_BREAK / VALIDATION / UNKNOWN(可空)'; +COMMENT ON COLUMN llm_call_trace.error_message IS '错误原始消息(已脱敏,可空)'; COMMENT ON COLUMN llm_call_trace.latency_ms IS '调用耗时(毫秒)'; COMMENT ON COLUMN llm_call_trace.status IS '状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS'; COMMENT ON COLUMN llm_call_trace.create_time IS '创建时间';