Browse Source

feat(llm-trace): 提示词追踪增强与缺陷修复

新增追踪字段与落库链路:
- 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 接口
master
wanghanlin 1 week ago
parent
commit
23f195326d
  1. 3
      frontend/components.d.ts
  2. 4
      frontend/src/api/api-key.ts
  3. 28
      frontend/src/api/llm-trace.ts
  4. 66
      frontend/src/components/PromptSearchHighlight.vue
  5. 297
      frontend/src/views/PromptTracePanel.vue
  6. 291
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  7. 28
      src/main/java/com/wok/supportbot/app/ChatContext.java
  8. 17
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  9. 15
      src/main/java/com/wok/supportbot/app/ChatRequest.java
  10. 39
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  11. 8
      src/main/java/com/wok/supportbot/controller/AiController.java
  12. 40
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  13. 2
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  14. 53
      src/main/java/com/wok/supportbot/entity/LlmCallTrace.java
  15. 99
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  16. 7
      src/main/java/com/wok/supportbot/rag/RagContext.java
  17. 40
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  18. 59
      src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
  19. 29
      src/main/resources/init-database.sql

3
frontend/components.d.ts

@ -9,6 +9,7 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
FormDialog: typeof import('./src/components/FormDialog.vue')['default'] FormDialog: typeof import('./src/components/FormDialog.vue')['default']
MessageSources: typeof import('./src/components/MessageSources.vue')['default'] MessageSources: typeof import('./src/components/MessageSources.vue')['default']
PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
TButton: typeof import('tdesign-vue-next')['Button'] TButton: typeof import('tdesign-vue-next')['Button']
@ -43,6 +44,8 @@ declare module 'vue' {
TTabs: typeof import('tdesign-vue-next')['Tabs'] TTabs: typeof import('tdesign-vue-next')['Tabs']
TTag: typeof import('tdesign-vue-next')['Tag'] TTag: typeof import('tdesign-vue-next')['Tag']
TTextarea: typeof import('tdesign-vue-next')['Textarea'] 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'] TUpload: typeof import('tdesign-vue-next')['Upload']
} }
} }

4
frontend/src/api/api-key.ts

@ -4,6 +4,10 @@ import type { ApiResponse } from '@/types/api'
export function listApiKeys(page = 1, size = 20): Promise<ApiResponse> { export function listApiKeys(page = 1, size = 20): Promise<ApiResponse> {
return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data) return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data)
} }
/** 拉取全部 API Key(供筛选下拉,最多 1000 条) */
export function listAllApiKeys(): Promise<ApiResponse> {
return request.get(`/api-key/list?page=1&size=1000`).then(r => r.data)
}
export function createApiKey(data: any): Promise<ApiResponse> { return request.post('/api-key', data).then(r => r.data) } export function createApiKey(data: any): Promise<ApiResponse> { return request.post('/api-key', data).then(r => r.data) }
export function revokeApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/revoke`).then(r => r.data) } export function revokeApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/revoke`).then(r => r.data) }
export function enableApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/enable`).then(r => r.data) } export function enableApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/enable`).then(r => r.data) }

28
frontend/src/api/llm-trace.ts

@ -11,6 +11,8 @@ export interface LlmTraceQuery {
startTime?: string startTime?: string
endTime?: string endTime?: string
keyword?: string keyword?: string
apiKeyId?: string
errorType?: string
} }
/** 分页查询调用记录 */ /** 分页查询调用记录 */
@ -24,6 +26,8 @@ export function listLlmTraces(query: LlmTraceQuery = {}): Promise<ApiResponse> {
if (query.startTime) params.set('startTime', query.startTime) if (query.startTime) params.set('startTime', query.startTime)
if (query.endTime) params.set('endTime', query.endTime) if (query.endTime) params.set('endTime', query.endTime)
if (query.keyword) params.set('keyword', query.keyword) 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) return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data)
} }
@ -42,3 +46,27 @@ export function cleanLlmTraces(keepDays: number): Promise<CleanLlmTracesResult>
export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise<ApiResponse> { export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise<ApiResponse> {
return request.get(`/llm-trace/stats?groupBy=${groupBy}`).then(r => r.data) return request.get(`/llm-trace/stats?groupBy=${groupBy}`).then(r => r.data)
} }
/** 可选错误类型列表 */
export function getLlmTraceErrorTypes(): Promise<ApiResponse> {
return request.get('/llm-trace/error-types').then(r => r.data)
}
/** 单会话调用记录(会话时间线,复用 list 接口按 conversationId 过滤) */
export function getLlmTraceConversation(conversationId: string): Promise<ApiResponse> {
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<ApiResponse> {
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)
}

66
frontend/src/components/PromptSearchHighlight.vue

@ -0,0 +1,66 @@
<template>
<div class="prompt-highlight">
<div class="ph-toolbar">
<t-input v-model="keyword" placeholder="在提示词中搜索高亮" clearable size="small" style="width: 240px;">
<template #prefix-icon><SearchIcon /></template>
</t-input>
<span v-if="keyword" class="ph-count">{{ matchCount }} 处匹配</span>
</div>
<div class="ph-body" v-html="highlighted"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { SearchIcon } from 'tdesign-icons-vue-next'
const props = defineProps<{ text: string }>()
const keyword = ref('')
/** 匹配数量(基于原始文本,忽略大小写) */
const matchCount = computed(() => {
if (!keyword.value.trim() || !props.text) return 0
const regex = new RegExp(escapeRegex(keyword.value.trim()), 'gi')
return (props.text.match(regex) || []).length
})
/** 高亮后的 HTML(原始文本转义 + 关键词包裹 mark,防止 XSS) */
const highlighted = computed(() => {
const escaped = escapeHtml(props.text || '')
if (!keyword.value.trim()) return escaped
const kw = escapeHtml(keyword.value.trim())
const regex = new RegExp(`(${escapeRegex(kw)})`, 'gi')
return escaped.replace(regex, '<mark class="ph-mark">$1</mark>')
})
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
</script>
<style scoped>
.prompt-highlight { display: flex; flex-direction: column; gap: 8px; }
.ph-toolbar { display: flex; align-items: center; gap: 10px; }
.ph-count { font-size: 12px; color: var(--td-text-color-secondary); }
.ph-body {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
background: var(--td-bg-color-secondarycontainer, #f7f7f7);
padding: 12px;
border-radius: 6px;
max-height: 480px;
overflow: auto;
}
:deep(.ph-mark) { background: var(--td-warning-color-1, #fff3e0); color: var(--td-text-color-primary); padding: 0 1px; }
</style>

297
frontend/src/views/PromptTracePanel.vue

@ -19,11 +19,15 @@
<t-select v-model="filterRoleId" :options="roleOptions" placeholder="全部角色" clearable size="small" style="width:150px;" @change="onFilterChange" /> <t-select v-model="filterRoleId" :options="roleOptions" placeholder="全部角色" clearable size="small" style="width:150px;" @change="onFilterChange" />
<t-input v-model="filterConversationId" placeholder="会话 ID" clearable size="small" style="width:180px;" @enter="onSearch" /> <t-input v-model="filterConversationId" placeholder="会话 ID" clearable size="small" style="width:180px;" @enter="onSearch" />
<t-select v-model="filterIntent" :options="intentOptions" placeholder="全部意图" clearable size="small" style="width:120px;" @change="onFilterChange" /> <t-select v-model="filterIntent" :options="intentOptions" placeholder="全部意图" clearable size="small" style="width:120px;" @change="onFilterChange" />
<t-select v-model="filterApiKeyId" :options="apiKeyOptions" placeholder="全部 API Key" clearable size="small" style="width:160px;" @change="onFilterChange" />
<t-select v-model="filterErrorType" :options="errorTypeOptions" placeholder="全部错误类型" clearable size="small" style="width:140px;" @change="onFilterChange" />
<t-date-picker v-model="filterStart" placeholder="开始日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> <t-date-picker v-model="filterStart" placeholder="开始日期" clearable size="small" style="width:140px;" @change="onFilterChange" />
<t-date-picker v-model="filterEnd" placeholder="结束日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> <t-date-picker v-model="filterEnd" placeholder="结束日期" clearable size="small" style="width:140px;" @change="onFilterChange" />
<t-input v-model="filterKeyword" placeholder="搜索用户消息 / AI 回复" clearable size="small" style="width:200px;" @enter="onSearch" /> <t-input v-model="filterKeyword" placeholder="搜索用户消息 / AI 回复" clearable size="small" style="width:200px;" @enter="onSearch" />
<div style="flex:1;" /> <div style="flex:1;" />
<t-button size="small" variant="outline" @click="refreshAll">刷新</t-button>
<t-button size="small" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比{{ selectedRowKeys.length }}/2</t-button> <t-button size="small" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比{{ selectedRowKeys.length }}/2</t-button>
<t-button size="small" variant="outline" @click="openTrend">趋势</t-button>
<t-button size="small" variant="outline" @click="openClean">清理</t-button> <t-button size="small" variant="outline" @click="openClean">清理</t-button>
</div> </div>
@ -42,6 +46,9 @@
<template #faqHit="{ row }"> <template #faqHit="{ row }">
<t-tag size="small" variant="light" :theme="row.faqHit ? 'success' : 'default'">{{ row.faqHit ? '命中' : '未命中' }}</t-tag> <t-tag size="small" variant="light" :theme="row.faqHit ? 'success' : 'default'">{{ row.faqHit ? '命中' : '未命中' }}</t-tag>
</template> </template>
<template #enableRag="{ row }">
<t-tag size="small" variant="light" :theme="row.enableRag ? 'primary' : 'default'">{{ row.enableRag ? '是' : '否' }}</t-tag>
</template>
<template #status="{ row }"> <template #status="{ row }">
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag> <t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag>
</template> </template>
@ -50,6 +57,7 @@
</template> </template>
<template #op="{ row }"> <template #op="{ row }">
<t-button size="small" variant="text" @click="openDetail(row)">详情</t-button> <t-button size="small" variant="text" @click="openDetail(row)">详情</t-button>
<t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button>
</template> </template>
</t-table> </t-table>
@ -60,8 +68,11 @@
<div class="detail-meta"> <div class="detail-meta">
<t-tag size="small" variant="light" :theme="intentTheme(detail.intent)">{{ intentLabel(detail.intent) }}</t-tag> <t-tag size="small" variant="light" :theme="intentTheme(detail.intent)">{{ intentLabel(detail.intent) }}</t-tag>
<t-tag size="small" variant="light" :theme="statusTheme(detail.status)">{{ statusLabel(detail.status) }}</t-tag> <t-tag size="small" variant="light" :theme="statusTheme(detail.status)">{{ statusLabel(detail.status) }}</t-tag>
<t-tag v-if="detail.searchMode" size="small" variant="outline">{{ detail.searchMode }}</t-tag>
<t-tag v-if="detail.errorType" size="small" variant="light" theme="danger">{{ detail.errorType }}</t-tag>
<span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span> <span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span>
</div> </div>
<div v-if="detail.errorMessage" class="error-box">{{ detail.errorMessage }}</div>
<t-tabs v-model="detailTab"> <t-tabs v-model="detailTab">
<t-tab-panel value="system" label="系统提示词"> <t-tab-panel value="system" label="系统提示词">
<div class="prompt-segments"> <div class="prompt-segments">
@ -70,7 +81,7 @@
<div class="seg" v-if="detail.ragContext"><span class="seg-tag">RAG 资料</span><pre class="seg-text">{{ detail.ragContext }}</pre></div> <div class="seg" v-if="detail.ragContext"><span class="seg-tag">RAG 资料</span><pre class="seg-text">{{ detail.ragContext }}</pre></div>
</div> </div>
<div class="block-title">最终完整 system prompt</div> <div class="block-title">最终完整 system prompt</div>
<pre class="full-prompt">{{ detail.systemPrompt || '(空)' }}</pre>
<PromptSearchHighlight :text="detail.systemPrompt || '(空)'" />
<div class="detail-actions"> <div class="detail-actions">
<t-button size="small" @click="copyPrompt">复制完整 Prompt</t-button> <t-button size="small" @click="copyPrompt">复制完整 Prompt</t-button>
<t-button size="small" variant="outline" :disabled="!detail.roleId" @click="jumpEditRole">编辑角色提示词</t-button> <t-button size="small" variant="outline" :disabled="!detail.roleId" @click="jumpEditRole">编辑角色提示词</t-button>
@ -78,10 +89,41 @@
</div> </div>
</t-tab-panel> </t-tab-panel>
<t-tab-panel value="user" label="用户消息"><pre class="full-prompt">{{ detail.userMessage }}</pre></t-tab-panel> <t-tab-panel value="user" label="用户消息"><pre class="full-prompt">{{ detail.userMessage }}</pre></t-tab-panel>
<t-tab-panel value="ai" label="AI 回复"><pre class="full-prompt">{{ detail.aiResponse }}<span v-if="detail.aiResponseTruncated" class="muted">已截断</span></pre></t-tab-panel>
<t-tab-panel value="ai" label="AI 回复">
<div v-if="detail.totalTokens || detail.promptTokens || detail.completionTokens" class="muted" style="margin-bottom:8px;">
Tokenprompt {{ detail.promptTokens ?? '-' }} / completion {{ detail.completionTokens ?? '-' }} / 合计 {{ detail.totalTokens ?? '-' }}
</div>
<pre class="full-prompt">{{ detail.aiResponse }}<span v-if="detail.aiResponseTruncated" class="muted">已截断</span></pre>
</t-tab-panel>
<t-tab-panel value="rag" label="RAG 资料"> <t-tab-panel value="rag" label="RAG 资料">
<div class="muted" style="margin-bottom:8px;">检索模式{{ detail.searchMode || '-' }} · 命中{{ detail.hitCount ?? '-' }} </div>
<t-table v-if="detailRagHits.length" :data="detailRagHits" :columns="ragHitsColumns" size="small" :pagination="false" style="margin-bottom:12px;" />
<div class="block-title">注入的 RAG 上下文</div>
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre> <pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre>
<div class="muted" style="margin-top:8px;">检索模式{{ detail.searchMode || '-' }} · 命中{{ detail.hitCount ?? '-' }} </div>
</t-tab-panel>
<t-tab-panel value="faq" label="FAQ">
<t-empty v-if="!detail.faqQuestion && !detail.faqId" description="本次调用未命中 FAQ" />
<div v-else class="faq-box">
<div class="faq-row"><span class="faq-label">FAQ ID</span><span>{{ detail.faqId ?? '-' }}</span></div>
<div class="faq-row"><span class="faq-label">问题</span><span>{{ detail.faqQuestion ?? '-' }}</span></div>
<div class="faq-row"><span class="faq-label">匹配方式</span><span>{{ detail.faqMatchType || '-' }}</span></div>
<div class="faq-row"><span class="faq-label">相似度</span><span>{{ formatScore(detail.faqScore) }}</span></div>
</div>
</t-tab-panel>
<t-tab-panel value="tools" label="工具调用">
<t-table v-if="detailToolCalls.length" :data="detailToolCalls" :columns="toolCallsColumns" size="small" :pagination="false">
<template #latencyMs="{ row }"><span>{{ row.latencyMs ?? '-' }}ms</span></template>
<template #error="{ row }">
<t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag>
<span v-else class="muted">-</span>
</template>
</t-table>
<t-empty v-else description="本次调用未触发 MCP 工具" />
</t-tab-panel>
<t-tab-panel value="context" label="对话上下文">
<div v-if="detail.historyTurns" class="muted" style="margin-bottom:8px;">历史轮数{{ detail.historyTurns }}</div>
<t-table v-if="detailHistory.length" :data="detailHistory" :columns="historyColumns" size="small" :pagination="false" />
<t-empty v-else description="无多轮历史记录" />
</t-tab-panel> </t-tab-panel>
</t-tabs> </t-tabs>
</div> </div>
@ -94,6 +136,13 @@
<div v-if="compareA && compareB" class="compare-grid"> <div v-if="compareA && compareB" class="compare-grid">
<div v-for="(c, idx) in [compareA, compareB]" :key="idx" class="compare-col"> <div v-for="(c, idx) in [compareA, compareB]" :key="idx" class="compare-col">
<div class="compare-head">{{ c.roleName || '(无角色)' }} · {{ c.intent }} · {{ formatDate(c.createTime) }}</div> <div class="compare-head">{{ c.roleName || '(无角色)' }} · {{ c.intent }} · {{ formatDate(c.createTime) }}</div>
<div class="compare-meta">
<t-tag size="small" variant="light" :theme="c.enableRag ? 'primary' : 'default'">RAG {{ c.enableRag ? '开' : '关' }}</t-tag>
<t-tag v-if="c.searchMode" size="small" variant="outline">{{ c.searchMode }}</t-tag>
<t-tag v-if="c.errorType" size="small" variant="light" theme="danger">{{ c.errorType }}</t-tag>
<span v-if="c.faqMatchType" class="muted">FAQ{{ c.faqMatchType }}</span>
<span v-if="c.totalTokens" class="muted">Token{{ c.totalTokens }}</span>
</div>
<div class="block-title">系统提示词</div> <div class="block-title">系统提示词</div>
<pre class="full-prompt small">{{ c.systemPrompt || '(空)' }}</pre> <pre class="full-prompt small">{{ c.systemPrompt || '(空)' }}</pre>
<div class="block-title">用户消息</div> <div class="block-title">用户消息</div>
@ -105,6 +154,44 @@
<t-empty v-else description="请先在列表中勾选 2 条记录" /> <t-empty v-else description="请先在列表中勾选 2 条记录" />
</t-drawer> </t-drawer>
<!-- 会话时间线抽屉 -->
<t-drawer v-model:visible="timelineVisible" size="medium" header="会话时间线" :footer="false">
<t-loading :loading="timelineLoading" show-overlay>
<div class="muted" style="margin-bottom:12px;">会话 ID{{ timelineConversationId }}</div>
<t-empty v-if="!timelineLoading && !timelineRows.length" description="该会话无调用记录" />
<t-timeline v-else>
<t-timeline-item v-for="row in timelineRows" :key="row.id">
<div class="tl-head">
<t-tag size="small" variant="light" :theme="intentTheme(row.intent)">{{ intentLabel(row.intent) }}</t-tag>
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag>
<span class="muted">{{ formatDate(row.createTime) }} · {{ row.latencyMs }}ms</span>
</div>
<div class="tl-user">{{ row.userMessage }}</div>
<div class="tl-ai">{{ row.aiResponse }}</div>
</t-timeline-item>
</t-timeline>
</t-loading>
</t-drawer>
<!-- 趋势图抽屉 -->
<t-drawer v-model:visible="trendVisible" size="large" header="调用趋势" :footer="false" @opened="loadTrend" @closed="destroyTrendChart">
<div class="trend-toolbar">
<t-radio-group v-model="trendRange" variant="default-filled" size="small" @change="loadTrend">
<t-radio-button value="24h"> 24 小时</t-radio-button>
<t-radio-button value="7d"> 7 </t-radio-button>
</t-radio-group>
<t-radio-group v-model="trendGroupBy" variant="default-filled" size="small" @change="loadTrend">
<t-radio-button value="HOUR">按小时</t-radio-button>
<t-radio-button value="DAY">按天</t-radio-button>
</t-radio-group>
<div style="flex:1;" />
<t-button size="small" variant="outline" :loading="trendLoading" @click="loadTrend">刷新</t-button>
</div>
<div class="trend-chart-wrap">
<canvas ref="trendChartRef"></canvas>
</div>
</t-drawer>
<!-- 清理弹窗 --> <!-- 清理弹窗 -->
<t-dialog v-model:visible="cleanVisible" header="清理调用记录" width="420px" :footer="false"> <t-dialog v-model:visible="cleanVisible" header="清理调用记录" width="420px" :footer="false">
<t-form label-align="top"> <t-form label-align="top">
@ -122,14 +209,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats } from '@/api/llm-trace'
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats, getLlmTraceErrorTypes, getLlmTraceConversation, getLlmTraceTrend } from '@/api/llm-trace'
import { getAllRoles } from '@/api/role' import { getAllRoles } from '@/api/role'
import { listAllApiKeys } from '@/api/api-key'
import PromptSearchHighlight from '@/components/PromptSearchHighlight.vue'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
import { useDebounce } from '@/composables/useDebounce' import { useDebounce } from '@/composables/useDebounce'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { palette } from '@/utils/palette'
const router = useRouter() const router = useRouter()
const { confirm } = useConfirm() const { confirm } = useConfirm()
@ -157,7 +247,11 @@ const filterIntent = ref('')
const filterStart = ref('') const filterStart = ref('')
const filterEnd = ref('') const filterEnd = ref('')
const filterKeyword = ref('') const filterKeyword = ref('')
const filterApiKeyId = ref('')
const filterErrorType = ref('')
const roleOptions = ref<{ label: string; value: string }[]>([]) const roleOptions = ref<{ label: string; value: string }[]>([])
const apiKeyOptions = ref<{ label: string; value: string }[]>([])
const errorTypeOptions = ref<{ label: string; value: string }[]>([])
// ===== ===== // ===== =====
const traces = ref<any[]>([]) const traces = ref<any[]>([])
@ -172,12 +266,13 @@ const columns = [
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) }, { colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120 }, { colKey: 'roleName', title: '角色', width: 120 },
{ colKey: 'intent', title: '意图', width: 100 }, { colKey: 'intent', title: '意图', width: 100 },
{ colKey: 'enableRag', title: 'RAG', width: 70 },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true }, { colKey: 'modelName', title: '模型', width: 140, ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 }, { colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'faqHit', title: 'FAQ', width: 80 }, { colKey: 'faqHit', title: 'FAQ', width: 80 },
{ colKey: 'status', title: '状态', width: 100 }, { colKey: 'status', title: '状态', width: 100 },
{ colKey: 'userMessage', title: '用户消息', ellipsis: true }, { colKey: 'userMessage', title: '用户消息', ellipsis: true },
{ colKey: 'op', title: '操作', width: 80, fixed: 'right' },
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' },
] ]
// ===== ===== // ===== =====
@ -197,6 +292,38 @@ const detailLoading = ref(false)
const detail = ref<any>(null) const detail = ref<any>(null)
const detailTab = ref('system') const detailTab = ref('system')
const detailRagHits = computed(() => parseJsonArray(detail.value?.ragHitsJson))
const detailToolCalls = computed(() => parseJsonArray(detail.value?.toolCallsJson))
const detailHistory = computed(() => parseJsonArray(detail.value?.historyMessagesJson))
const ragHitsColumns = [
{ colKey: 'title', title: '标题', ellipsis: true },
{ colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true },
{ colKey: 'chunkIndex', title: '分块', width: 70 },
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score) },
{ colKey: 'searchMode', title: '检索模式', width: 100 },
]
const toolCallsColumns = [
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true },
{ colKey: 'input', title: '输入', ellipsis: true },
{ colKey: 'result', title: '结果', ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'error', title: '错误', width: 70 },
]
const historyColumns = [
{ colKey: 'role', title: '角色', width: 100 },
{ colKey: 'content', title: '内容', ellipsis: true },
]
function formatScore(v: any): string {
if (v === null || v === undefined || v === '') return '-'
const n = Number(v)
if (Number.isFinite(n)) return n.toFixed(4)
return String(v)
}
// ===== ===== // ===== =====
const compareVisible = ref(false) const compareVisible = ref(false)
const compareA = ref<any>(null) const compareA = ref<any>(null)
@ -207,8 +334,25 @@ const cleanVisible = ref(false)
const cleanKeepDays = ref(30) const cleanKeepDays = ref(30)
const cleaning = ref(false) const cleaning = ref(false)
// ===== 线 =====
const timelineVisible = ref(false)
const timelineLoading = ref(false)
const timelineConversationId = ref('')
const timelineRows = ref<any[]>([])
// ===== =====
const trendVisible = ref(false)
const trendGroupBy = ref<'HOUR' | 'DAY'>('HOUR')
const trendRange = ref('24h')
const trendLoading = ref(false)
const trendRows = ref<any[]>([])
const trendChartRef = ref<HTMLCanvasElement | null>(null)
let trendChart: any = null
onMounted(async () => { onMounted(async () => {
await loadRoles() await loadRoles()
loadApiKeys()
loadErrorTypes()
loadList() loadList()
loadStats() loadStats()
}) })
@ -222,6 +366,24 @@ async function loadRoles() {
} catch (e: any) { console.warn('角色下拉加载失败:' + e.message) } } catch (e: any) { console.warn('角色下拉加载失败:' + e.message) }
} }
async function loadApiKeys() {
try {
const r = await listAllApiKeys()
if (r.success) {
apiKeyOptions.value = (r.data || []).map((k: any) => ({ label: k.name || ('API Key ' + k.id), value: String(k.id) }))
}
} catch (e: any) { console.warn('API Key 下拉加载失败:' + e.message) }
}
async function loadErrorTypes() {
try {
const r = await getLlmTraceErrorTypes()
if (r.success) {
errorTypeOptions.value = (r.data || []).map((t: any) => ({ label: t.label, value: t.value }))
}
} catch (e: any) { console.warn('错误类型下拉加载失败:' + e.message) }
}
// ===== ===== // ===== =====
const statSummary = computed(() => { const statSummary = computed(() => {
let count = 0, latencySum = 0, faqHits = 0 let count = 0, latencySum = 0, faqHits = 0
@ -270,6 +432,8 @@ async function loadList() {
startTime: filterStart.value ? filterStart.value + ' 00:00:00' : undefined, startTime: filterStart.value ? filterStart.value + ' 00:00:00' : undefined,
endTime: filterEnd.value ? filterEnd.value + ' 23:59:59' : undefined, endTime: filterEnd.value ? filterEnd.value + ' 23:59:59' : undefined,
keyword: filterKeyword.value || undefined, keyword: filterKeyword.value || undefined,
apiKeyId: filterApiKeyId.value || undefined,
errorType: filterErrorType.value || undefined,
}) })
if (r.success) { if (r.success) {
traces.value = r.data?.records || r.data || [] traces.value = r.data?.records || r.data || []
@ -297,6 +461,11 @@ function onFilterChange() {
loadList() loadList()
} }
function refreshAll() {
loadList()
loadStats()
}
const onSearch = debounce(() => { page.value = 1; selectedRowKeys.value = []; loadList() }) const onSearch = debounce(() => { page.value = 1; selectedRowKeys.value = []; loadList() })
function onSelectChange(keys: (string | number)[], options?: { currentRowKey?: string | number }) { function onSelectChange(keys: (string | number)[], options?: { currentRowKey?: string | number }) {
@ -394,6 +563,112 @@ async function doClean() {
cleaning.value = false cleaning.value = false
} }
} }
// ===== 线 =====
async function openTimeline(row: any) {
if (!row.conversationId) { toast('该记录无会话 ID', 'warning'); return }
timelineVisible.value = true
timelineConversationId.value = row.conversationId
timelineLoading.value = true
timelineRows.value = []
try {
const r = await getLlmTraceConversation(row.conversationId)
if (r.success) {
timelineRows.value = r.data || []
} else {
toast(r.message || '加载会话时间线失败', 'error')
}
} catch (e: any) {
toast('加载会话时间线失败:' + e.message, 'error')
} finally {
timelineLoading.value = false
}
}
// ===== =====
function openTrend() {
trendVisible.value = true
}
function trendTimeRange(): { startTime?: string; endTime?: string } {
const end = new Date()
if (trendRange.value === '24h') {
const start = new Date(end.getTime() - 24 * 3600 * 1000)
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) }
}
if (trendRange.value === '7d') {
const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000)
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) }
}
return {}
}
function fmtDateTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
async function loadTrend() {
trendLoading.value = true
try {
const range = trendTimeRange()
const r = await getLlmTraceTrend({ groupBy: trendGroupBy.value, ...range })
if (r.success) {
trendRows.value = r.data || []
await nextTick()
renderTrendChart()
} else {
toast(r.message || '加载趋势失败', 'error')
}
} catch (e: any) {
toast('加载趋势失败:' + e.message, 'error')
} finally {
trendLoading.value = false
}
}
async function renderTrendChart() {
const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js')
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler)
const labels = trendRows.value.map((s: any) => s.timeBucket || '')
if (trendChart) { trendChart.destroy(); trendChart = null }
if (!trendChartRef.value) return
trendChart = new Chart(trendChartRef.value, {
type: 'line',
data: { labels, datasets: [
{ label: '调用量', data: trendRows.value.map((s: any) => s.callCount ?? 0), borderColor: palette.blue, backgroundColor: `rgba(${palette.blueRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 3, yAxisID: 'y' },
{ label: '平均耗时(ms)', data: trendRows.value.map((s: any) => s.avgLatencyMs ?? 0), borderColor: palette.purple, backgroundColor: `rgba(${palette.purpleRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' },
{ label: '错误率(%)', data: trendRows.value.map((s: any) => s.errorRate ?? 0), borderColor: palette.red, backgroundColor: `rgba(${palette.redRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' },
{ label: 'FAQ 命中率(%)', data: trendRows.value.map((s: any) => s.faqHitRate ?? 0), borderColor: palette.green, backgroundColor: `rgba(${palette.greenRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' },
{ label: '总 Token', data: trendRows.value.map((s: any) => s.totalTokens ?? 0), borderColor: palette.orange, backgroundColor: `rgba(${palette.orangeRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' },
] },
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } },
scales: {
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: '调用量' } },
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: '耗时/率/Token' } },
x: { grid: { display: false } },
},
},
})
}
function destroyTrendChart() { if (trendChart) { trendChart.destroy(); trendChart = null } }
onBeforeUnmount(() => destroyTrendChart())
// ===== JSON =====
function parseJsonArray(text: string | null | undefined): any[] {
if (!text) return []
try {
const arr = JSON.parse(text)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
</script> </script>
<style scoped> <style scoped>
@ -417,4 +692,14 @@ async function doClean() {
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.compare-col { min-width: 0; } .compare-col { min-width: 0; }
.compare-head { font-weight: 600; margin-bottom: 8px; } .compare-head { font-weight: 600; margin-bottom: 8px; }
.compare-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.error-box { background: var(--td-error-color-1, #fdecec); color: var(--td-error-color, #d54941); padding: 8px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; word-break: break-word; white-space: pre-wrap; }
.faq-box { border: 1px solid var(--color-border, #e7e7e7); border-radius: 6px; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.faq-row { display: flex; gap: 16px; font-size: 13px; }
.faq-label { flex-shrink: 0; width: 80px; color: var(--color-text-secondary); }
.tl-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.tl-user { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 4px; white-space: pre-wrap; word-break: break-word; }
.tl-ai { font-size: 13px; background: var(--color-bg-secondary, #f7f7f7); padding: 8px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-word; }
.trend-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.trend-chart-wrap { position: relative; height: 420px; }
</style> </style>

291
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.AiModelConfig;
import com.wok.supportbot.entity.LlmCallTrace; import com.wok.supportbot.entity.LlmCallTrace;
import com.wok.supportbot.mcp.McpToolCallback; import com.wok.supportbot.mcp.McpToolCallback;
import com.wok.supportbot.mcp.McpToolCallback.ToolCallEvent;
import com.wok.supportbot.mcp.McpToolCallbackAdapter; import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import com.wok.supportbot.service.AiModelConfigService; import com.wok.supportbot.service.AiModelConfigService;
import com.wok.supportbot.service.ContentSafetyService; import com.wok.supportbot.service.ContentSafetyService;
import com.wok.supportbot.service.LlmCallTraceService; import com.wok.supportbot.service.LlmCallTraceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; 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.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
@ -33,6 +38,9 @@ import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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; 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; 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<ToolCallEvent> mcpEvents
) {}
/** /**
* 初始化 ChatClient * 初始化 ChatClient
* *
@ -220,17 +259,20 @@ public class AssistantApp {
// 熔断全局 AI 调用处于熔断状态直接返回降级提示不做 buildRequest避免熔断期间仍走意图路由/检索 // 熔断全局 AI 调用处于熔断状态直接返回降级提示不做 buildRequest避免熔断期间仍走意图路由/检索
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示"); 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()); return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of());
} }
ChatRequest req = chatPipeline.buildRequest(ctx); ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) { if (req.faqHit()) {
String faqAnswer = req.faqAnswer().get(); 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()); return new ChatResult(faqAnswer, List.of());
} }
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 显式事件收集器 + 轮次计数器通过 toolContext 传给 McpToolCallback规避 Reactor 跨线程丢 ThreadLocal 的问题
List<ToolCallEvent> events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
try { try {
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt() .prompt()
@ -239,17 +281,28 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(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); 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 异步按需生成 // 推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, McpToolCallback.drainEvents(), List.of());
return new ChatResult(text, events, List.of());
} catch (Exception e) { } catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
String fallback = "抱歉,AI 服务调用失败:" + 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()); return new ChatResult(fallback, List.of());
} }
} }
@ -268,7 +321,8 @@ public class AssistantApp {
// 熔断全局 AI 调用处于熔断状态不做 buildRequest避免熔断期间仍走意图路由/检索 // 熔断全局 AI 调用处于熔断状态不做 buildRequest避免熔断期间仍走意图路由/检索
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示"); 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); return Flux.just(CIRCUIT_OPEN_MESSAGE);
} }
ChatRequest req = chatPipeline.buildRequest(ctx); ChatRequest req = chatPipeline.buildRequest(ctx);
@ -276,11 +330,13 @@ public class AssistantApp {
// FAQ 命中整段答案原样输出 SSE 编码器处理内部换行 // FAQ 命中整段答案原样输出 SSE 编码器处理内部换行
// 后端不做任何格式增删不拆行不加换行不补空格 // 后端不做任何格式增删不拆行不加换行不补空格
String faqAnswer = req.faqAnswer().get(); 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); return Flux.just(faqAnswer);
} }
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 显式事件收集器 + 轮次计数器通过 toolContext 传给 McpToolCallback规避 Reactor 跨线程丢 ThreadLocal 的问题
List<ToolCallEvent> events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt() .prompt()
.user(req.finalMessage()) .user(req.finalMessage())
@ -288,8 +344,25 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());
} }
// 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
Flux<String> rawStream = spec.stream().content();
spec = spec.toolContext(Map.of(
McpToolCallback.MCP_EVENTS_KEY, events,
McpToolCallback.MCP_ROUNDS_KEY, rounds));
// 改为 chatResponse 流以采集 token 用量再映射回纯文本流
AtomicReference<Usage> usageRef = new AtomicReference<>();
AtomicReference<String> errorTypeRef = new AtomicReference<>();
AtomicReference<String> errorMessageRef = new AtomicReference<>();
Flux<ChatResponse> responseFlux = spec.stream().chatResponse();
Flux<String> 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 时取完整回复文本 // 聚合所有分片用于埋点 doFinally 时取完整回复文本
StringBuilder aggregated = new StringBuilder(); StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream) return preserveTrailingWhitespace(rawStream)
@ -297,16 +370,21 @@ public class AssistantApp {
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> { .doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
errorTypeRef.set(classifyError(e));
errorMessageRef.set(maskError(e.getMessage()));
log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
}) })
.doFinally(signalType -> { .doFinally(signalType -> {
// 确保 ThreadLocal 清理防止线程池复用时数据残留
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 流式埋点按终止信号区分状态断连/异常也落库
// 流式埋点按终止信号区分状态断连/异常也落库events toolContext 显式收集跨线程安全
String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE" String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL"; : 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())); .onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
} }
@ -379,7 +457,8 @@ public class AssistantApp {
* @param latencyMs 耗时毫秒 * @param latencyMs 耗时毫秒
* @param status 状态COMPLETE / ERROR / CANCEL / FAQ / BYPASS * @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 { try {
// 走缓存的活跃配置仅取模型元信息不落 apiKey避免每次对话在 Reactor 线程同步查库 // 走缓存的活跃配置仅取模型元信息不落 apiKey避免每次对话在 Reactor 线程同步查库
AiModelConfig cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType()); AiModelConfig cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType());
@ -394,10 +473,27 @@ public class AssistantApp {
truncated = true; 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<Message> history = safeGetHistory(ctx.chatId());
LlmCallTrace trace = LlmCallTrace.builder() LlmCallTrace trace = LlmCallTrace.builder()
.conversationId(ctx.chatId()) .conversationId(ctx.chatId())
.roleId(ctx.roleId()) .roleId(ctx.roleId())
.roleName(ctx.roleName()) .roleName(ctx.roleName())
.accountId(ctx.accountId())
.apiKeyId(ctx.apiKeyId())
.intent(req != null ? req.intent() : null) .intent(req != null ? req.intent() : null)
.enableRag(ctx.enableRag()) .enableRag(ctx.enableRag())
.systemPrompt(req != null ? contentSafetyService.mask(req.finalSystemPrompt()) : null) .systemPrompt(req != null ? contentSafetyService.mask(req.finalSystemPrompt()) : null)
@ -407,15 +503,26 @@ public class AssistantApp {
.aiResponse(aiResponse) .aiResponse(aiResponse)
.aiResponseTruncated(truncated) .aiResponseTruncated(truncated)
.ragContext(req != null ? contentSafetyService.mask(req.ragContextText()) : null) .ragContext(req != null ? contentSafetyService.mask(req.ragContextText()) : null)
.ragHitsJson(buildRagHitsJson(req))
.faqHit(req != null ? req.faqHit() : false) .faqHit(req != null ? req.faqHit() : false)
// 检索模式当前主管道 RagPipeline.similaritySearch 仅纯向量检索
// HybridSearchServiceVECTOR/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) .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) .modelName(cfg != null ? cfg.getModelName() : null)
.provider(cfg != null ? cfg.getProvider() : null) .provider(cfg != null ? cfg.getProvider() : null)
.temperature(cfg != null ? cfg.getTemperature() : null) .temperature(cfg != null ? cfg.getTemperature() : null)
.maxTokens(cfg != null ? cfg.getMaxTokens() : 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) .latencyMs((int) latencyMs)
.status(status) .status(status)
.build(); .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<Map<String, Object>> items = new ArrayList<>();
for (Document doc : req.hitDocuments()) {
Map<String, Object> meta = doc.getMetadata();
Map<String, Object> 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 工具调用事件为 JSONinput/result 先脱敏再截断避免返回数据过大撑爆 trace
* 无事件返回 null
*/
private String buildToolCallsJson(List<ToolCallEvent> events) {
if (events == null || events.isEmpty()) {
return null;
}
try {
List<Map<String, Object>> items = new ArrayList<>();
for (ToolCallEvent e : events) {
Map<String, Object> 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<Message> safeGetHistory(String chatId) {
try {
return chatMemory.get(chatId, HISTORY_MESSAGE_MAX_COUNT);
} catch (Exception e) {
return null;
}
}
/** 将历史消息序列化为 JSON(每轮内容截断)。无历史返回 null。 */
private String buildHistoryJson(List<Message> history) {
if (history == null || history.isEmpty()) {
return null;
}
try {
List<Map<String, String>> 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 起的耗时毫秒 * 计算自 startNanos 起的耗时毫秒
*/ */

28
src/main/java/com/wok/supportbot/app/ChatContext.java

@ -22,6 +22,8 @@ import java.util.List;
* @param streaming 是否流式输出 * @param streaming 是否流式输出
* @param roleId 客服角色 ID可空供调用追踪等使用 * @param roleId 客服角色 ID可空供调用追踪等使用
* @param roleName 客服角色名可空快照用途 * @param roleName 客服角色名可空快照用途
* @param accountId 账户 ID可空供调用追踪/个人信息删除权使用
* @param apiKeyId API Key ID可空Open API 路径为实际鉴权 Key供租户隔离与追踪
*/ */
public record ChatContext( public record ChatContext(
String message, String message,
@ -34,7 +36,9 @@ public record ChatContext(
boolean enableRag, boolean enableRag,
boolean streaming, boolean streaming,
Long roleId, Long roleId,
String roleName
String roleName,
String accountId,
Long apiKeyId
) { ) {
/** 默认应用类型 */ /** 默认应用类型 */
@ -53,30 +57,38 @@ public record ChatContext(
* 便捷构造仅指定核心字段其余取默认值 RAG非流式 * 便捷构造仅指定核心字段其余取默认值 RAG非流式
*/ */
public static ChatContext of(String message, String chatId) { 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) { 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<String> allowedMcpTools) { public ChatContext withAllowedMcpTools(List<String> 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<Long> categoryIds) { public ChatContext withCategoryIds(List<Long> 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) { 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) { 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) { 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);
} }
} }

17
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.RagContext;
import com.wok.supportbot.rag.RagPipeline; import com.wok.supportbot.rag.RagPipeline;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.IntentRouter; import com.wok.supportbot.service.IntentRouter;
import com.wok.supportbot.service.SystemConfigService; import com.wok.supportbot.service.SystemConfigService;
import com.wok.supportbot.service.RagHitLogService; import com.wok.supportbot.service.RagHitLogService;
@ -78,7 +79,7 @@ public class ChatPipeline {
// 普通对话enableRag=false Controller isKbDenied 强制置 false 的情况 // 普通对话enableRag=false Controller isKbDenied 强制置 false 的情况
if (!ctx.enableRag()) { if (!ctx.enableRag()) {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHAT");
globalPrompt, null, null, "CHAT", null, null, null);
} }
// 意图路由先用 IntentRouter 做细粒度分类 // 意图路由先用 IntentRouter 做细粒度分类
@ -87,11 +88,12 @@ public class ChatPipeline {
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示 // FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
if (intent != null && "FAQ".equals(intent.getIntent()) if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
Optional<String> faqAnswer = ragPipeline.tryFaqMatch(ctx.message());
if (faqAnswer.isPresent()) {
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message());
if (faqMatch.isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId()); log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
Optional<String> faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer, 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()); log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
} }
@ -100,7 +102,7 @@ public class ChatPipeline {
if (intent != null && "CHITCHAT".equals(intent.getIntent()) if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) { && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHITCHAT");
globalPrompt, null, null, "CHITCHAT", null, null, null);
} }
// RAG 检索 FAQ 优先匹配 // RAG 检索 FAQ 优先匹配
@ -123,13 +125,14 @@ public class ChatPipeline {
} }
if (rag.faqHit()) { if (rag.faqHit()) {
return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(), return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(),
globalPrompt, null, null, "FAQ");
globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult());
} }
// RAG 生成资料块注入 system重写后查询作为 user 消息 // RAG 生成资料块注入 system重写后查询作为 user 消息
String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText()); String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText());
return new ChatRequest(ctx, rag.rewrittenQuery(), finalSystem, Optional.empty(), 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);
} }
/** /**

15
src/main/java/com/wok/supportbot/app/ChatRequest.java

@ -1,5 +1,9 @@
package com.wok.supportbot.app; 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; import java.util.Optional;
/** /**
@ -17,6 +21,9 @@ import java.util.Optional;
* <li>{@link #ragContextText()}RAG 资料块文本未注入时为空/null</li> * <li>{@link #ragContextText()}RAG 资料块文本未注入时为空/null</li>
* <li>{@link #hitCount()}命中的知识库片段数 RAG null</li> * <li>{@link #hitCount()}命中的知识库片段数 RAG null</li>
* <li>{@link #intent()}本次决策命中的意图CHAT / CHITCHAT / FAQ / RAG</li> * <li>{@link #intent()}本次决策命中的意图CHAT / CHITCHAT / FAQ / RAG</li>
* <li>{@link #searchMode()}真实检索模式 VECTOR / KEYWORD / HYBRID RAG null</li>
* <li>{@link #hitDocuments()}命中的知识库片段RAG 场景 RAG null</li>
* <li>{@link #faqMatchResult()}FAQ 命中详情 matchType/score未命中为 null</li>
* </ul> * </ul>
* *
* @param ctx 原始上下文 * @param ctx 原始上下文
@ -27,6 +34,9 @@ import java.util.Optional;
* @param ragContextText RAG 资料块文本可为 null * @param ragContextText RAG 资料块文本可为 null
* @param hitCount 命中文档数可为 null * @param hitCount 命中文档数可为 null
* @param intent 意图CHAT / CHITCHAT / FAQ / RAG * @param intent 意图CHAT / CHITCHAT / FAQ / RAG
* @param searchMode 真实检索模式可为 null
* @param hitDocuments 命中的知识库片段可为 null
* @param faqMatchResult FAQ 命中详情可为 null
*/ */
public record ChatRequest( public record ChatRequest(
ChatContext ctx, ChatContext ctx,
@ -36,7 +46,10 @@ public record ChatRequest(
String globalPrompt, String globalPrompt,
String ragContextText, String ragContextText,
Integer hitCount, Integer hitCount,
String intent
String intent,
String searchMode,
List<Document> hitDocuments,
FaqMatchResult faqMatchResult
) { ) {
/** FAQ 是否命中 */ /** FAQ 是否命中 */

39
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -190,6 +190,7 @@ public class DatabaseInitConfig {
createLlmCallTraceTable(); createLlmCallTraceTable();
} }
}); });
safeInit("迁移 llm_call_trace 扩展列", this::addLlmCallTraceExtensionColumns);
// P1-003: API 开放平台 // P1-003: API 开放平台
safeInit("创建 API Key 表 api_key", () -> { safeInit("创建 API Key 表 api_key", () -> {
@ -1114,14 +1115,27 @@ public class DatabaseInitConfig {
user_message TEXT, user_message TEXT,
ai_response TEXT, ai_response TEXT,
ai_response_truncated BOOLEAN, ai_response_truncated BOOLEAN,
tool_calls_json TEXT,
history_messages_json TEXT,
history_turns INTEGER,
rag_context TEXT, rag_context TEXT,
faq_hit BOOLEAN, faq_hit BOOLEAN,
faq_id BIGINT,
faq_question TEXT,
faq_match_type VARCHAR(16),
faq_score DOUBLE PRECISION,
search_mode VARCHAR(20), search_mode VARCHAR(20),
hit_count INTEGER, hit_count INTEGER,
rag_hits_json TEXT,
model_name VARCHAR(128), model_name VARCHAR(128),
provider VARCHAR(64), provider VARCHAR(64),
temperature DOUBLE PRECISION, temperature DOUBLE PRECISION,
max_tokens INTEGER, max_tokens INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
error_type VARCHAR(32),
error_message TEXT,
latency_ms INTEGER, latency_ms INTEGER,
status VARCHAR(16), status VARCHAR(16),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL 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_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_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_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() { private void createDashboardSnapshotTable() {

8
src/main/java/com/wok/supportbot/controller/AiController.java

@ -118,7 +118,7 @@ public class AiController {
try { try {
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds); List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats, 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<Document> docs = assistantApp.retrieveSources(ctx); List<Document> docs = assistantApp.retrieveSources(ctx);
List<Map<String, Object>> out = new ArrayList<>(); List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) { for (Document doc : docs) {
@ -149,7 +149,7 @@ public class AiController {
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt), return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt),
scope.hasRole() ? scope.allowedMcpTools() : null, null, null, false, false, 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)。 */ /** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */
@ -162,7 +162,7 @@ public class AiController {
boolean enableRag = !isKbDenied(scope); boolean enableRag = !isKbDenied(scope);
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds); List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
return new ChatContext(message, chatId, "CHAT", sys, scope.hasRole() ? scope.allowedMcpTools() : null, cats, 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 会话管理接口带账户归属校验 ==================== // ==================== SDK 会话管理接口带账户归属校验 ====================
@ -356,7 +356,7 @@ public class AiController {
} }
ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(), ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(),
ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true, 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); return assistantApp.chatStream(ctx);
} }

40
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 intent,
@RequestParam(required = false) String startTime, @RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime, @RequestParam(required = false) String endTime,
@RequestParam(required = false) String keyword) {
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String errorType) {
try { try {
Map<String, Object> result = llmCallTraceService.pageQuery( Map<String, Object> result = llmCallTraceService.pageQuery(
page, size, roleId, conversationId, intent, startTime, endTime, keyword);
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType);
Map<String, Object> data = new LinkedHashMap<>(); Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));
@ -146,6 +147,41 @@ public class LlmCallTraceController {
} }
} }
/**
* 可选错误类型列表供前端筛选下拉
*/
@GetMapping("/error-types")
public ResponseEntity<Map<String, Object>> errorTypes() {
List<Map<String, String>> 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<Map<String, Object>> trend(
@RequestParam(defaultValue = "HOUR") String groupBy,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
try {
List<Map<String, Object>> 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 * 获取当前登录用户名用于审计日志best-effort
*/ */

2
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -225,7 +225,7 @@ public class OpenApiController {
return new ChatContext(message, chatId, "CHAT", systemPrompt, return new ChatContext(message, chatId, "CHAT", systemPrompt,
scope.hasRole() ? scope.allowedMcpTools() : null, scope.hasRole() ? scope.allowedMcpTools() : null,
catIds, strategy, useRag, streaming, roleId, scope.name());
catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId());
} }
/** /**

53
src/main/java/com/wok/supportbot/entity/LlmCallTrace.java

@ -93,6 +93,18 @@ public class LlmCallTrace implements Serializable {
@TableField("ai_response_truncated") @TableField("ai_response_truncated")
private Boolean aiResponseTruncated; 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 资料块(可空) */ /** RAG 资料块(可空) */
@TableField("rag_context") @TableField("rag_context")
private String ragContext; private String ragContext;
@ -101,6 +113,23 @@ public class LlmCallTrace implements Serializable {
@TableField("faq_hit") @TableField("faq_hit")
private Boolean faqHit; 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(可空) */ /** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */
@TableField("search_mode") @TableField("search_mode")
private String searchMode; private String searchMode;
@ -109,6 +138,10 @@ public class LlmCallTrace implements Serializable {
@TableField("hit_count") @TableField("hit_count")
private Integer hitCount; private Integer hitCount;
/** RAG 命中片段详情 JSON 数组(含 documentId/title/chunkIndex/score/searchMode,可空) */
@TableField("rag_hits_json")
private String ragHitsJson;
/** 模型名称 */ /** 模型名称 */
@TableField("model_name") @TableField("model_name")
private String modelName; private String modelName;
@ -125,10 +158,30 @@ public class LlmCallTrace implements Serializable {
@TableField("max_tokens") @TableField("max_tokens")
private Integer maxTokens; 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") @TableField("latency_ms")
private Integer latencyMs; 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 */ /** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */
@TableField("status") @TableField("status")
private String status; private String status;

99
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.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* MCP Tool -> Spring AI ToolCallback 适配器 * MCP Tool -> Spring AI ToolCallback 适配器
@ -30,6 +32,11 @@ public class McpToolCallback implements ToolCallback {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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) {} public record ToolCallEvent(String tool, String input, String result, long latencyMs, boolean error) {}
/**
* ToolContext 中携带事件收集器的 keyAssistantApp 通过 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<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new); private static final ThreadLocal<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new);
@ -166,17 +184,57 @@ public class McpToolCallback implements ToolCallback {
*/ */
@Override @Override
public String call(String toolInput) { public String call(String toolInput) {
// ToolContext 时回退到 ThreadLocal 收集器同步调用 / 兼容旧逻辑
return doCall(toolInput, EVENTS.get(), null);
}
/**
* ToolContext 的执行入口
* <p>
* 优先从 ToolContext 读取 AssistantApp 注入的事件收集器与轮次计数器
* 解决 Reactor 流式场景下 ThreadLocal 跨线程丢失的问题读取不到时回退 ThreadLocal
*/
@Override
@SuppressWarnings("unchecked")
public String call(String toolInput, ToolContext toolContext) {
List<ToolCallEvent> collector = EVENTS.get();
AtomicInteger rounds = null;
Map<String, Object> ctx = toolContext != null ? toolContext.getContext() : null;
if (ctx != null) {
Object ev = ctx.get(MCP_EVENTS_KEY);
if (ev instanceof List<?> list) {
collector = (List<ToolCallEvent>) 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<ToolCallEvent> collector, AtomicInteger rounds) {
// 检查调用轮次是否超限 // 检查调用轮次是否超限
int currentRound = CALL_ROUNDS.get();
int currentRound = rounds != null ? rounds.getAndIncrement() : CALL_ROUNDS.get();
if (currentRound >= maxCallRounds) { if (currentRound >= maxCallRounds) {
log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}", log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}",
originalToolName, currentRound, maxCallRounds); originalToolName, currentRound, maxCallRounds);
return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}"; return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}";
} }
CALL_ROUNDS.set(currentRound + 1);
if (rounds == null) {
CALL_ROUNDS.set(currentRound + 1);
}
log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}", log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}",
mcpServerConfigId, originalToolName, toolInput, currentRound + 1, maxCallRounds);
mcpServerConfigId, originalToolName, truncate(toolInput), currentRound + 1, maxCallRounds);
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
try { try {
@ -194,22 +252,22 @@ public class McpToolCallback implements ToolCallback {
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments); McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments);
McpSchema.CallToolResult result = client.callTool(request); McpSchema.CallToolResult result = client.callTool(request);
// 调试日志输出 MCP 调用原始返回结果
// 调试日志输出 MCP 调用原始返回结果截断避免返回数据过大刷屏
log.info("🔧 [DEBUG] MCP callTool 原始结果 - serverId={}, tool={}, rawJson={}", log.info("🔧 [DEBUG] MCP callTool 原始结果 - serverId={}, tool={}, rawJson={}",
mcpServerConfigId, originalToolName, ModelOptionsUtils.toJsonString(result));
mcpServerConfigId, originalToolName, truncate(ModelOptionsUtils.toJsonString(result)));
long latency = System.currentTimeMillis() - startTime; long latency = System.currentTimeMillis() - startTime;
log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}", log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}",
originalToolName, latency, result.isError()); originalToolName, latency, result.isError());
// 收集工具调用事件 SSE 流式输出使用
// 收集工具调用事件提示词追踪 / SSE 流式输出使用
String resultStr = result.content() != null ? String.valueOf(result.content()) : ""; String resultStr = result.content() != null ? String.valueOf(result.content()) : "";
boolean isError = result.isError() != null && result.isError(); 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()) { 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\": \"工具执行返回错误: " + return "{\"error\": \"工具执行返回错误: " +
escapeJson(String.valueOf(result.content())) + "\"}"; escapeJson(String.valueOf(result.content())) + "\"}";
} }
@ -217,7 +275,7 @@ public class McpToolCallback implements ToolCallback {
// MCP Content 列表序列化为 JSON 字符串返回给 AI 模型 // MCP Content 列表序列化为 JSON 字符串返回给 AI 模型
// 与官方 SyncMcpToolCallback 保持一致使用 ModelOptionsUtils 序列化 // 与官方 SyncMcpToolCallback 保持一致使用 ModelOptionsUtils 序列化
String resultJson = ModelOptionsUtils.toJsonString(result.content()); String resultJson = ModelOptionsUtils.toJsonString(result.content());
log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, resultJson);
log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, truncate(resultJson));
return resultJson; return resultJson;
} catch (Exception e) { } catch (Exception e) {
@ -235,16 +293,16 @@ public class McpToolCallback implements ToolCallback {
McpSchema.CallToolRequest retryRequest = new McpSchema.CallToolRequest(originalToolName, retryArgs); McpSchema.CallToolRequest retryRequest = new McpSchema.CallToolRequest(originalToolName, retryArgs);
McpSchema.CallToolResult retryResult = reconnected.callTool(retryRequest); McpSchema.CallToolResult retryResult = reconnected.callTool(retryRequest);
// 调试日志输出 MCP 重试调用原始返回结果
// 调试日志输出 MCP 重试调用原始返回结果截断
log.info("🔧 [DEBUG] MCP callTool 重试原始结果 - serverId={}, tool={}, rawJson={}", log.info("🔧 [DEBUG] MCP callTool 重试原始结果 - serverId={}, tool={}, rawJson={}",
mcpServerConfigId, originalToolName, ModelOptionsUtils.toJsonString(retryResult));
mcpServerConfigId, originalToolName, truncate(ModelOptionsUtils.toJsonString(retryResult)));
long retryLatency = System.currentTimeMillis() - startTime; long retryLatency = System.currentTimeMillis() - startTime;
log.info("MCP 工具重试成功: tool={}, latency={}ms", originalToolName, retryLatency); log.info("MCP 工具重试成功: tool={}, latency={}ms", originalToolName, retryLatency);
String retryResultStr = retryResult.content() != null ? String.valueOf(retryResult.content()) : ""; String retryResultStr = retryResult.content() != null ? String.valueOf(retryResult.content()) : "";
boolean retryIsError = retryResult.isError() != null && retryResult.isError(); 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()) { if (retryResult.isError() != null && retryResult.isError()) {
return "{\"error\": \"工具执行返回错误: " + escapeJson(String.valueOf(retryResult.content())) + "\"}"; 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()) + "\"}"; 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 格式 * 转义字符串中的特殊字符避免破坏 JSON 格式
*/ */

7
src/main/java/com/wok/supportbot/rag/RagContext.java

@ -1,5 +1,6 @@
package com.wok.supportbot.rag; package com.wok.supportbot.rag;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import java.util.List; import java.util.List;
@ -19,12 +20,16 @@ import java.util.Optional;
* @param documents 检索命中的知识库片段 metadata可为空 * @param documents 检索命中的知识库片段 metadata可为空
* @param contextText 拼接后的资料文本 {@code "\n\n---\n\n"} 分隔无资料时为空串 * @param contextText 拼接后的资料文本 {@code "\n\n---\n\n"} 分隔无资料时为空串
* @param rewrittenQuery 传给模型的用户消息MULTI_QUERY 为原始 message其余策略为重写后的查询 * @param rewrittenQuery 传给模型的用户消息MULTI_QUERY 为原始 message其余策略为重写后的查询
* @param searchMode 真实检索模式VECTOR / KEYWORD / HYBRID当前主管道固定为 VECTOR
* @param faqMatchResult FAQ 命中详情 matchType/score未命中为 null
*/ */
public record RagContext( public record RagContext(
Optional<String> faqAnswer, Optional<String> faqAnswer,
List<Document> documents, List<Document> documents,
String contextText, String contextText,
String rewrittenQuery
String rewrittenQuery,
String searchMode,
FaqMatchResult faqMatchResult
) { ) {
/** FAQ 是否命中 */ /** FAQ 是否命中 */

40
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.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.RagHitLogService; import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -108,10 +109,12 @@ public class RagPipeline {
*/ */
public RagContext retrieve(ChatContext ctx) { public RagContext retrieve(ChatContext ctx) {
// 1. FAQ 优先匹配命中则直接返回标准答案跳过检索与生成 // 1. FAQ 优先匹配命中则直接返回标准答案跳过检索与生成
Optional<String> faqAnswer = tryFaqMatch(ctx.message());
if (faqAnswer.isPresent()) {
log.info("FAQ 命中,跳过知识库检索: chatId={}", ctx.chatId());
return new RagContext(faqAnswer, Collections.emptyList(), "", ctx.message());
Optional<FaqMatchResult> 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. 统一检索 + 组装结果 // 2. 统一检索 + 组装结果
@ -150,7 +153,7 @@ public class RagPipeline {
logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery); logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery);
String contextText = joinContext(docs); 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 匹配 ====================
/** /**
* 尝试 FAQ 三级匹配精确关键词语义命中返回标准答案
* 异常时降级为未命中 ChatPipeline 等调用方直接获取 FAQ 匹配结果
* 尝试 FAQ 三级匹配精确关键词语义命中返回完整匹配结果 matchType/score
* 异常时降级为未命中
*/ */
public Optional<String> tryFaqMatch(String message) {
public Optional<FaqMatchResult> tryFaqMatchResult(String message) {
try { try {
return faqMatchEngine.match(message)
.map(result -> result.getFaq().getAnswer());
return faqMatchEngine.match(message);
} catch (Exception e) { } catch (Exception e) {
log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage()); log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage());
return Optional.empty(); return Optional.empty();
} }
} }
/**
* 尝试 FAQ 三级匹配命中返回标准答案仅答案文本
* 异常时降级为未命中供仅需答案的调用方使用
*/
public Optional<String> tryFaqMatch(String message) {
return tryFaqMatchResult(message).map(result -> result.getFaq().getAnswer());
}
/**
* 当前主管道的真实检索模式
* <p>
* 目前仅单路向量检索VECTORHybridSearchService 接入主管道后
* 此方法应改为根据上下文透传 VECTOR / KEYWORD / HYBRID
*/
private String currentSearchMode() {
return "VECTOR";
}
// ==================== 查询重写 ==================== // ==================== 查询重写 ====================
/** /**

59
src/main/java/com/wok/supportbot/service/LlmCallTraceService.java

@ -40,7 +40,8 @@ public class LlmCallTraceService {
/** 列表查询需要排除的大 TEXT 字段 */ /** 列表查询需要排除的大 TEXT 字段 */
private static final Set<String> BIG_TEXT_FIELDS = private static final Set<String> 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; private static final int DEFAULT_RETENTION_DAYS = 30;
@ -64,19 +65,20 @@ public class LlmCallTraceService {
* 分页查询列表不含大 TEXT 字段user_message 截断为摘要 * 分页查询列表不含大 TEXT 字段user_message 截断为摘要
*/ */
public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId, public Map<String, Object> 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 < 1) page = 1;
if (page > 10000) page = 10000; if (page > 10000) page = 10000;
if (size < 1 || size > 100) size = 20; if (size < 1 || size > 100) size = 20;
// 总数只加 WHERE 条件 // 总数只加 WHERE 条件
Long total = llmCallTraceMapper.selectCount( 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; if (total == null) total = 0L;
// 列表排除大字段 + 排序 + 分页 // 列表排除大字段 + 排序 + 分页
QueryWrapper<LlmCallTrace> listWrapper = buildWhere( QueryWrapper<LlmCallTrace> 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.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn()));
listWrapper.orderByDesc("create_time").orderByDesc("id"); listWrapper.orderByDesc("create_time").orderByDesc("id");
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size)); listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size));
@ -130,6 +132,49 @@ public class LlmCallTraceService {
return result; 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<Map<String, Object>> 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<Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
List<Map<String, Object>> result = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
result.add(snakeToCamel(row));
}
return result;
}
/** /**
* 分批删除 N 天前的记录避免单次大表 DELETE 锁表 * 分批删除 N 天前的记录避免单次大表 DELETE 锁表
* *
@ -190,7 +235,8 @@ public class LlmCallTraceService {
* 组装 WHERE 条件 count list 复用 * 组装 WHERE 条件 count list 复用
*/ */
private QueryWrapper<LlmCallTrace> buildWhere(QueryWrapper<LlmCallTrace> wrapper, Long roleId, private QueryWrapper<LlmCallTrace> buildWhere(QueryWrapper<LlmCallTrace> 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) { if (roleId != null) {
wrapper.eq("role_id", roleId); wrapper.eq("role_id", roleId);
} }
@ -200,6 +246,9 @@ public class LlmCallTraceService {
if (intent != null && !intent.isBlank()) { if (intent != null && !intent.isBlank()) {
wrapper.eq("intent", intent.trim().toUpperCase()); wrapper.eq("intent", intent.trim().toUpperCase());
} }
if (errorType != null && !errorType.isBlank()) {
wrapper.eq("error_type", errorType.trim().toUpperCase());
}
if (startTime != null && !startTime.isBlank()) { if (startTime != null && !startTime.isBlank()) {
wrapper.ge("create_time", startTime.trim()); wrapper.ge("create_time", startTime.trim());
} }

29
src/main/resources/init-database.sql

@ -622,14 +622,27 @@ CREATE TABLE IF NOT EXISTS llm_call_trace (
user_message TEXT, user_message TEXT,
ai_response TEXT, ai_response TEXT,
ai_response_truncated BOOLEAN, ai_response_truncated BOOLEAN,
tool_calls_json TEXT,
history_messages_json TEXT,
history_turns INTEGER,
rag_context TEXT, rag_context TEXT,
faq_hit BOOLEAN, faq_hit BOOLEAN,
faq_id BIGINT,
faq_question TEXT,
faq_match_type VARCHAR(16),
faq_score DOUBLE PRECISION,
search_mode VARCHAR(20), search_mode VARCHAR(20),
hit_count INTEGER, hit_count INTEGER,
rag_hits_json TEXT,
model_name VARCHAR(128), model_name VARCHAR(128),
provider VARCHAR(64), provider VARCHAR(64),
temperature DOUBLE PRECISION, temperature DOUBLE PRECISION,
max_tokens INTEGER, max_tokens INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
error_type VARCHAR(32),
error_message TEXT,
latency_ms INTEGER, latency_ms INTEGER,
status VARCHAR(16), status VARCHAR(16),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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_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_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_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 TABLE llm_call_trace IS 'LLM 调用追踪表(记录每次 LLM 调用的 system prompt/回复/模型参数/耗时,用于提示词优化调试,append-only)';
COMMENT ON COLUMN llm_call_trace.id IS '主键(雪花算法生成)'; 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.user_message IS '用户原始消息(脱敏后)';
COMMENT ON COLUMN llm_call_trace.ai_response IS 'AI 回复(截断,保留头部+尾部)'; 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.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.rag_context IS 'RAG 资料块(可空)';
COMMENT ON COLUMN llm_call_trace.faq_hit IS '是否 FAQ 命中'; 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.search_mode IS '检索模式:VECTOR / KEYWORD / HYBRID(可空)';
COMMENT ON COLUMN llm_call_trace.hit_count IS '命中文档数(可空)'; 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.model_name IS '模型名称';
COMMENT ON COLUMN llm_call_trace.provider IS '提供商'; COMMENT ON COLUMN llm_call_trace.provider IS '提供商';
COMMENT ON COLUMN llm_call_trace.temperature 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.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.latency_ms IS '调用耗时(毫秒)';
COMMENT ON COLUMN llm_call_trace.status IS '状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS'; COMMENT ON COLUMN llm_call_trace.status IS '状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS';
COMMENT ON COLUMN llm_call_trace.create_time IS '创建时间'; COMMENT ON COLUMN llm_call_trace.create_time IS '创建时间';

Loading…
Cancel
Save