2 Commits

Author SHA1 Message Date
wanghanlin 23f195326d feat(llm-trace): 提示词追踪增强与缺陷修复 1 week ago
wanghanlin 2fe322aa7d 完成「LLM 调用追踪面板」开发 1 week ago
  1. 10
      CLAUDE.md
  2. 4
      frontend/components.d.ts
  3. 4
      frontend/src/api/api-key.ts
  4. 72
      frontend/src/api/llm-trace.ts
  5. 66
      frontend/src/components/PromptSearchHighlight.vue
  6. 1
      frontend/src/router/index.ts
  7. 1
      frontend/src/stores/navigation.ts
  8. 705
      frontend/src/views/PromptTracePanel.vue
  9. 12
      frontend/src/views/RoleManager.vue
  10. 13
      frontend/src/views/SystemConfigManager.vue
  11. 384
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  12. 32
      src/main/java/com/wok/supportbot/app/ChatContext.java
  13. 30
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  14. 27
      src/main/java/com/wok/supportbot/app/ChatRequest.java
  15. 36
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  16. 118
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  17. 10
      src/main/java/com/wok/supportbot/controller/AiController.java
  18. 192
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  19. 2
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  20. 12
      src/main/java/com/wok/supportbot/dao/LlmCallTraceMapper.java
  21. 192
      src/main/java/com/wok/supportbot/entity/LlmCallTrace.java
  22. 97
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  23. 7
      src/main/java/com/wok/supportbot/rag/RagContext.java
  24. 40
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  25. 292
      src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
  26. 4
      src/main/java/com/wok/supportbot/service/RagHitLogService.java
  27. 156
      src/main/resources/init-database.sql
  28. 123
      src/test/java/com/wok/supportbot/Phase1ComponentTests.java
  29. 55
      src/test/java/com/wok/supportbot/SupportBotApplicationTests.java

10
CLAUDE.md

@ -214,6 +214,7 @@ catch (e) { toast('操作失败', 'error') }
- FAQ 管理: `/faq/*`(`FaqController`) - FAQ 管理: `/faq/*`(`FaqController`)
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取) - SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取)
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色) - API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色)
- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色)
### Filter 优先级 ### Filter 优先级
@ -263,6 +264,15 @@ catch (e) { toast('操作失败', 'error') }
- **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护 - **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护
- **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式 - **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
## LLM 调用追踪(提示词调试)
- **llm_call_trace 表**:append-only,无逻辑删除。记录每次 LLM 调用的完整现场——最终 `system_prompt` + `global_prompt`/`role_prompt`/`rag_context` 分段快照、用户消息、AI 回复(截断)、模型参数(model_name/provider/temperature/max_tokens)、耗时、意图(CHAT/CHITCHAT/FAQ/RAG)、状态(COMPLETE/ERROR/CANCEL/FAQ/BYPASS)。
- **埋点位置**:`AssistantApp.chatWithEvents`/`chatStream` 顶部埋点,覆盖 FAQ 命中、熔断降级、流式断连/异常等路径;流式用 `doOnNext` 聚合分片 + `doFinally` 按终止信号落库(绝不在方法返回处计时)。
- **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。
- **查询面板**:前端「系统设置 → 提示词追踪」(`PromptTracePanel.vue`,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link `?roleId=`、`?key=ai_system_prompt`)。
- **安全声明**:`system_prompt`/`user_message`/`ai_response` 属敏感数据(提示词为商业秘密),当前**明文存储**、仅 admin 可查,落库前做敏感词脱敏(`ContentSafetyService.mask`)+ AI 回复截断;保留期限由 `system_config.llm_trace_retention_days` 控制(默认 30 天,`@Scheduled` 每日 2 点自动清理)。字段级加密留二期。
- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(意图识别/查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。
## 已知 TODO ## 已知 TODO
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续 - `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续

4
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']
@ -21,6 +22,7 @@ declare module 'vue' {
TDatePicker: typeof import('tdesign-vue-next')['DatePicker'] TDatePicker: typeof import('tdesign-vue-next')['DatePicker']
TDialog: typeof import('tdesign-vue-next')['Dialog'] TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider'] TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer']
TEmpty: typeof import('tdesign-vue-next')['Empty'] TEmpty: typeof import('tdesign-vue-next')['Empty']
TForm: typeof import('tdesign-vue-next')['Form'] TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem'] TFormItem: typeof import('tdesign-vue-next')['FormItem']
@ -42,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) }

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

@ -0,0 +1,72 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
/** LLM 调用追踪列表查询参数 */
export interface LlmTraceQuery {
page?: number
size?: number
roleId?: string
conversationId?: string
intent?: string
startTime?: string
endTime?: string
keyword?: string
apiKeyId?: string
errorType?: string
}
/** 分页查询调用记录 */
export function listLlmTraces(query: LlmTraceQuery = {}): Promise<ApiResponse> {
const params = new URLSearchParams()
params.set('page', String(query.page ?? 1))
params.set('size', String(query.size ?? 20))
if (query.roleId) params.set('roleId', query.roleId)
if (query.conversationId) params.set('conversationId', query.conversationId)
if (query.intent) params.set('intent', query.intent)
if (query.startTime) params.set('startTime', query.startTime)
if (query.endTime) params.set('endTime', query.endTime)
if (query.keyword) params.set('keyword', query.keyword)
if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId)
if (query.errorType) params.set('errorType', query.errorType)
return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data)
}
/** 单条详情(含完整 system prompt) */
export function getLlmTrace(id: string): Promise<ApiResponse> {
return request.get(`/llm-trace/${id}`).then(r => r.data)
}
/** 清理 N 天前的记录(后端返回 {success, message, deleted},无 data 字段) */
export interface CleanLlmTracesResult { success: boolean; message?: string; deleted?: number }
export function cleanLlmTraces(keepDays: number): Promise<CleanLlmTracesResult> {
return request.post('/llm-trace/clean', { keepDays }).then(r => r.data)
}
/** 聚合统计(按角色或模型分组) */
export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise<ApiResponse> {
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>

1
frontend/src/router/index.ts

@ -36,6 +36,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/settings/pipeline-flow', name: 'PipelineFlow', component: () => import('@/views/PipelineFlow.vue') }, { path: '/settings/pipeline-flow', name: 'PipelineFlow', component: () => import('@/views/PipelineFlow.vue') },
{ path: '/settings/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.vue') }, { path: '/settings/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.vue') },
{ path: '/settings/log-viewer', name: 'LogViewer', component: () => import('@/views/LogViewer.vue') }, { path: '/settings/log-viewer', name: 'LogViewer', component: () => import('@/views/LogViewer.vue') },
{ path: '/settings/prompt-trace', name: 'PromptTrace', component: () => import('@/views/PromptTracePanel.vue') },
// ==================== 兜底 ==================== // ==================== 兜底 ====================
{ path: '/:pathMatch(.*)*', redirect: '/chat' }, { path: '/:pathMatch(.*)*', redirect: '/chat' },
] ]

1
frontend/src/stores/navigation.ts

@ -33,6 +33,7 @@ export const MENU_ITEMS = [
{ id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' }, { id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' },
{ id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] }, { id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] },
{ id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] }, { id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] },
{ id: 'prompt-trace', label: '提示词追踪', icon: '🔬', path: '/settings/prompt-trace', roles: ['admin'] },
], ],
}, },
] ]

705
frontend/src/views/PromptTracePanel.vue

@ -0,0 +1,705 @@
<template>
<t-card title="提示词追踪" :bordered="false">
<p class="desc-text">记录每次 LLM 调用的完整现场system prompt / 模型参数 / 耗时 / 意图用于提示词优化调试记录自动保留 N 天后清理</p>
<!-- 聚合统计 -->
<div class="stats-bar">
<t-radio-group v-model="groupBy" variant="default-filled" size="small" @change="loadStats">
<t-radio-button value="role">按角色</t-radio-button>
<t-radio-button value="model">按模型</t-radio-button>
</t-radio-group>
<div class="stat-card"><span class="stat-num">{{ statSummary.count }}</span><span class="stat-label">总调用</span></div>
<div class="stat-card"><span class="stat-num">{{ statSummary.avgLatency }}</span><span class="stat-label">平均耗时(ms)</span></div>
<div class="stat-card"><span class="stat-num">{{ statSummary.faqRate }}</span><span class="stat-label">FAQ 命中率</span></div>
</div>
<t-table :data="stats" :columns="statsColumns" row-key="key" size="small" :loading="statsLoading" :pagination="false" style="margin-bottom:16px;" />
<!-- 筛选栏 -->
<div class="toolbar">
<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-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="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" />
<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" variant="outline" @click="openTrend">趋势</t-button>
<t-button size="small" variant="outline" @click="openClean">清理</t-button>
</div>
<!-- 调用列表 -->
<t-table :data="traces" :columns="columns" row-key="id" :loading="loading"
:selected-row-keys="selectedRowKeys"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange" @select-change="onSelectChange">
<template #roleName="{ row }">
<span v-if="row.roleName">{{ row.roleName }}</span>
<span v-else class="muted">(无角色)</span>
</template>
<template #intent="{ row }">
<t-tag size="small" variant="light" :theme="intentTheme(row.intent)">{{ intentLabel(row.intent) }}</t-tag>
</template>
<template #faqHit="{ row }">
<t-tag size="small" variant="light" :theme="row.faqHit ? 'success' : 'default'">{{ row.faqHit ? '命中' : '未命中' }}</t-tag>
</template>
<template #enableRag="{ row }">
<t-tag size="small" variant="light" :theme="row.enableRag ? 'primary' : 'default'">{{ row.enableRag ? '是' : '否' }}</t-tag>
</template>
<template #status="{ row }">
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag>
</template>
<template #latencyMs="{ row }">
<span>{{ row.latencyMs }}ms</span>
</template>
<template #op="{ row }">
<t-button size="small" variant="text" @click="openDetail(row)">详情</t-button>
<t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button>
</template>
</t-table>
<!-- 详情抽屉 -->
<t-drawer v-model:visible="detailVisible" size="large" header="调用详情" :footer="false">
<t-loading :loading="detailLoading" show-overlay>
<div v-if="detail">
<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="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>
</div>
<div v-if="detail.errorMessage" class="error-box">{{ detail.errorMessage }}</div>
<t-tabs v-model="detailTab">
<t-tab-panel value="system" label="系统提示词">
<div class="prompt-segments">
<div class="seg" v-if="detail.globalPrompt"><span class="seg-tag">全局</span><pre class="seg-text">{{ detail.globalPrompt }}</pre></div>
<div class="seg" v-if="detail.rolePrompt"><span class="seg-tag">角色</span><pre class="seg-text">{{ detail.rolePrompt }}</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 class="block-title">最终完整 system prompt</div>
<PromptSearchHighlight :text="detail.systemPrompt || '(空)'" />
<div class="detail-actions">
<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" @click="jumpEditGlobal">编辑全局提示词</t-button>
</div>
</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 回复">
<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 资料">
<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>
</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-tabs>
</div>
<t-empty v-else description="记录不存在或已被清理" />
</t-loading>
</t-drawer>
<!-- 对比抽屉 -->
<t-drawer v-model:visible="compareVisible" size="large" header="并排对比" :footer="false">
<div v-if="compareA && compareB" class="compare-grid">
<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-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>
<pre class="full-prompt small">{{ c.systemPrompt || '(空)' }}</pre>
<div class="block-title">用户消息</div>
<pre class="full-prompt small">{{ c.userMessage }}</pre>
<div class="block-title">AI 回复</div>
<pre class="full-prompt small">{{ c.aiResponse }}</pre>
</div>
</div>
<t-empty v-else description="请先在列表中勾选 2 条记录" />
</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-form label-align="top">
<t-form-item label="保留最近 N 天(删除 N 天前的记录)">
<t-input-number v-model="cleanKeepDays" :min="1" :max="365" style="width:100%;" />
</t-form-item>
</t-form>
<p class="muted" style="font-size:12px;">最小保留 1 清理动作不可恢复请谨慎操作</p>
<div class="dialog-footer">
<t-button variant="outline" @click="cleanVisible = false">取消</t-button>
<t-button theme="danger" :loading="cleaning" @click="doClean">确认清理</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats, getLlmTraceErrorTypes, getLlmTraceConversation, getLlmTraceTrend } from '@/api/llm-trace'
import { getAllRoles } from '@/api/role'
import { listAllApiKeys } from '@/api/api-key'
import PromptSearchHighlight from '@/components/PromptSearchHighlight.vue'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useDebounce } from '@/composables/useDebounce'
import { useConfirm } from '@/composables/useConfirm'
import { palette } from '@/utils/palette'
const router = useRouter()
const { confirm } = useConfirm()
const { debounce } = useDebounce()
// ===== / =====
const INTENT_LABEL: Record<string, string> = { CHAT: '普通对话', CHITCHAT: '闲聊', FAQ: 'FAQ', RAG: 'RAG' }
const STATUS_LABEL: Record<string, string> = { COMPLETE: '完成', ERROR: '失败', CANCEL: '断连', FAQ: 'FAQ', BYPASS: '熔断降级' }
function intentLabel(v: string) { return INTENT_LABEL[v] || v || '-' }
function intentTheme(v: string) { return ({ CHITCHAT: 'warning', FAQ: 'success', RAG: 'primary', CHAT: 'default' } as Record<string, string>)[v] || 'default' }
function statusLabel(v: string) { return STATUS_LABEL[v] || v || '-' }
function statusTheme(v: string) { return ({ COMPLETE: 'success', ERROR: 'danger', CANCEL: 'warning', FAQ: 'success', BYPASS: 'warning' } as Record<string, string>)[v] || 'default' }
const intentOptions = [
{ label: '普通对话', value: 'CHAT' },
{ label: '闲聊', value: 'CHITCHAT' },
{ label: 'FAQ', value: 'FAQ' },
{ label: 'RAG', value: 'RAG' },
]
// ===== =====
const filterRoleId = ref('')
const filterConversationId = ref('')
const filterIntent = ref('')
const filterStart = ref('')
const filterEnd = ref('')
const filterKeyword = ref('')
const filterApiKeyId = ref('')
const filterErrorType = ref('')
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 loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const selectedRowKeys = ref<string[]>([])
const columns = [
{ colKey: 'row-select', type: 'multiple', width: 40 },
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120 },
{ colKey: 'intent', title: '意图', width: 100 },
{ colKey: 'enableRag', title: 'RAG', width: 70 },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'faqHit', title: 'FAQ', width: 80 },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'userMessage', title: '用户消息', ellipsis: true },
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' },
]
// ===== =====
const groupBy = ref<'role' | 'model'>('role')
const stats = ref<any[]>([])
const statsLoading = ref(false)
const statsColumns = [
{ colKey: 'key', title: '分组', ellipsis: true },
{ colKey: 'count', title: '调用数', width: 100 },
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120 },
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%` },
]
// ===== =====
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<any>(null)
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 compareA = ref<any>(null)
const compareB = ref<any>(null)
// ===== =====
const cleanVisible = ref(false)
const cleanKeepDays = ref(30)
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 () => {
await loadRoles()
loadApiKeys()
loadErrorTypes()
loadList()
loadStats()
})
async function loadRoles() {
try {
const r = await getAllRoles()
if (r.success) {
roleOptions.value = (r.data || []).map((ro: any) => ({ label: ro.name, value: String(ro.id) }))
}
} 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(() => {
let count = 0, latencySum = 0, faqHits = 0
for (const s of stats.value) {
count += s.count || 0
latencySum += (s.count || 0) * (s.avgLatencyMs || 0)
faqHits += Math.round((s.count || 0) * (s.faqHitRate || 0))
}
const avgLatency = count > 0 ? Math.round(latencySum / count) : '-'
const faqRate = count > 0 ? ((faqHits / count) * 100).toFixed(1) + '%' : '-'
return { count, avgLatency, faqRate }
})
async function loadStats() {
statsLoading.value = true
try {
const r = await getLlmTraceStats(groupBy.value)
if (r.success) {
const rows = (r.data || []).map((s: any) => {
const key = groupBy.value === 'model'
? `${s.modelName || '-'}|${s.provider || '-'}`
: `${s.roleId ?? s.roleName ?? '(无角色)'}`
const faqRate = s.count > 0 ? ((s.faqHitCount || 0) / s.count) : 0
return { ...s, key, faqHitRate: faqRate }
})
stats.value = rows
} else {
toast(r.message || '加载统计失败', 'error')
}
} catch (e: any) {
toast('加载统计失败:' + e.message, 'error')
} finally {
statsLoading.value = false
}
}
// ===== =====
async function loadList() {
loading.value = true
try {
const r = await listLlmTraces({
page: page.value, size: pageSize.value,
roleId: filterRoleId.value || undefined,
conversationId: filterConversationId.value || undefined,
intent: filterIntent.value || undefined,
startTime: filterStart.value ? filterStart.value + ' 00:00:00' : undefined,
endTime: filterEnd.value ? filterEnd.value + ' 23:59:59' : undefined,
keyword: filterKeyword.value || undefined,
apiKeyId: filterApiKeyId.value || undefined,
errorType: filterErrorType.value || undefined,
})
if (r.success) {
traces.value = r.data?.records || r.data || []
total.value = r.data?.total || r.total || 0
} else {
toast(r.message || '加载失败', 'error')
}
} catch (e: any) {
toast('加载失败:' + e.message, 'error')
} finally {
loading.value = false
}
}
function onPageChange(info: { current: number; pageSize: number }) {
page.value = info.current
pageSize.value = info.pageSize
selectedRowKeys.value = []
loadList()
}
function onFilterChange() {
page.value = 1
selectedRowKeys.value = []
loadList()
}
function refreshAll() {
loadList()
loadStats()
}
const onSearch = debounce(() => { page.value = 1; selectedRowKeys.value = []; loadList() })
function onSelectChange(keys: (string | number)[], options?: { currentRowKey?: string | number }) {
const normalized = keys.map(String)
if (normalized.length > 2) {
toast('最多选择 2 条进行对比', 'warning')
// + 3
const current = String(options?.currentRowKey ?? normalized[normalized.length - 1])
const others = normalized.filter(k => k !== current)
selectedRowKeys.value = [current, ...others].slice(0, 2)
} else {
selectedRowKeys.value = normalized
}
}
// ===== =====
async function openDetail(row: any) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
detailTab.value = 'system'
try {
const r = await getLlmTrace(row.id)
if (r.success) {
detail.value = r.data
} else {
detail.value = null
}
} catch (e: any) {
toast('加载详情失败:' + e.message, 'error')
} finally {
detailLoading.value = false
}
}
async function copyPrompt() {
if (!detail.value?.systemPrompt) return
try {
await navigator.clipboard.writeText(detail.value.systemPrompt)
toast('已复制完整 Prompt', 'success')
} catch {
toast('复制失败', 'error')
}
}
function jumpEditRole() {
if (detail.value?.roleId) {
router.push({ path: '/settings/role', query: { roleId: String(detail.value.roleId) } })
}
}
function jumpEditGlobal() {
router.push({ path: '/settings/system-config', query: { key: 'ai_system_prompt' } })
}
// ===== =====
async function openCompare() {
if (selectedRowKeys.value.length !== 2) return
const ids = selectedRowKeys.value
try {
const [a, b] = await Promise.all([getLlmTrace(ids[0]), getLlmTrace(ids[1])])
if (!a.success || !b.success) {
toast(a.message || b.message || '加载对比数据失败', 'error')
return
}
compareA.value = a.data
compareB.value = b.data
compareVisible.value = true
} catch (e: any) {
toast('加载对比数据失败:' + e.message, 'error')
}
}
// ===== =====
function openClean() {
cleanKeepDays.value = 30
cleanVisible.value = true
}
async function doClean() {
if (!(await confirm('确认清理 ' + cleanKeepDays.value + ' 天前的调用记录?'))) return
cleaning.value = true
try {
const r = await cleanLlmTraces(cleanKeepDays.value)
if (r.success) {
toast('清理完成,删除 ' + (r.deleted ?? 0) + ' 条', 'success')
cleanVisible.value = false
loadList()
loadStats()
} else {
toast(r.message || '清理失败', 'error')
}
} catch (e: any) {
toast('清理失败:' + e.message, 'error')
} finally {
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>
<style scoped>
.desc-text { color: var(--color-text-secondary); font-size: 13px; margin: 0 0 16px; }
.toolbar { display: flex; align-items: center; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.stats-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; flex-wrap: wrap; }
.stat-card { display: flex; flex-direction: column; align-items: center; min-width: 80px; padding: 4px 12px; border-radius: 6px; background: var(--color-bg-secondary, #f3f3f3); }
.stat-num { font-size: 18px; font-weight: 600; color: var(--color-text-primary); }
.stat-label { font-size: 12px; color: var(--color-text-secondary); }
.muted { color: var(--color-text-secondary); }
.detail-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.block-title { font-size: 13px; font-weight: 600; margin: 12px 0 6px; }
.prompt-segments { display: flex; flex-direction: column; gap: 8px; }
.seg { border: 1px solid var(--color-border, #e7e7e7); border-radius: 6px; padding: 8px; }
.seg-tag { display: inline-block; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; }
.seg-text, .full-prompt { white-space: pre-wrap; word-break: break-word; font-family: inherit; margin: 0; font-size: 13px; line-height: 1.6; }
.full-prompt { background: var(--color-bg-secondary, #f7f7f7); padding: 12px; border-radius: 6px; max-height: 480px; overflow: auto; }
.full-prompt.small { max-height: 300px; font-size: 12px; }
.detail-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.compare-col { min-width: 0; }
.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>

12
frontend/src/views/RoleManager.vue

@ -184,6 +184,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories, updateRoleMcpTools } from '@/api/role' import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories, updateRoleMcpTools } from '@/api/role'
import { listAvailableMcpTools } from '@/api/mcp-server' import { listAvailableMcpTools } from '@/api/mcp-server'
@ -192,6 +193,7 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
const route = useRoute()
// ---- ---- // ---- ----
const roles = ref<any[]>([]) const roles = ref<any[]>([])
@ -334,10 +336,16 @@ function toggleTool(name: string, val: boolean): void {
} }
// ---- ---- // ---- ----
onMounted(() => {
reload()
onMounted(async () => {
await reload()
categoryStore.loadCategories() categoryStore.loadCategories()
loadMcpTools() loadMcpTools()
// deep-link URL /settings/role?roleId=xxx
const roleId = route.query.roleId as string | undefined
if (roleId) {
const target = roles.value.find((r: any) => r.id === roleId)
if (target) await selectRole(target)
}
}) })
// ---- selectedRole 退 ---- // ---- selectedRole 退 ----

13
frontend/src/views/SystemConfigManager.vue

@ -174,6 +174,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SettingIcon, AddIcon, RefreshIcon, SearchIcon, DeleteIcon, CheckIcon } from 'tdesign-icons-vue-next' import { SettingIcon, AddIcon, RefreshIcon, SearchIcon, DeleteIcon, CheckIcon } from 'tdesign-icons-vue-next'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '@/api/system-config' import { listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '@/api/system-config'
@ -181,6 +182,7 @@ import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const route = useRoute()
// ==================== ==================== // ==================== ====================
type ConfigType = 'html' | 'text' | 'boolean' type ConfigType = 'html' | 'text' | 'boolean'
@ -291,7 +293,16 @@ function displayName(item: any): string {
} }
// ==================== ==================== // ==================== ====================
onMounted(() => load())
onMounted(async () => {
await load()
// deep-link URL /settings/system-config?key=ai_system_prompt
const key = route.query.key as string | undefined
if (key) {
const target = configs.value.find((c: any) => c.configKey === key)
if (target) await selectConfig(target)
else toast('未找到配置项 ' + key + ',请先新建', 'warning')
}
})
// selectedConfig 退 // selectedConfig 退
watch(selectedConfig, (cfg) => { watch(selectedConfig, (cfg) => {

384
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -5,13 +5,23 @@ import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory; import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory; import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.SimpleCircuitBreaker; import com.wok.supportbot.config.SimpleCircuitBreaker;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.entity.LlmCallTrace;
import com.wok.supportbot.mcp.McpToolCallback; import com.wok.supportbot.mcp.McpToolCallback;
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.ContentSafetyService;
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;
@ -21,12 +31,16 @@ import org.springframework.util.StringUtils;
import reactor.core.Disposable; import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink; import reactor.core.publisher.FluxSink;
import reactor.core.publisher.SignalType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; 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;
@ -67,6 +81,15 @@ public class AssistantApp {
@Resource @Resource
private ChatPipeline chatPipeline; private ChatPipeline chatPipeline;
@Resource
private AiModelConfigService aiModelConfigService;
@Resource
private LlmCallTraceService llmCallTraceService;
@Resource
private ContentSafetyService contentSafetyService;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */ /** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}") @Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools; private boolean enableMcpTools;
@ -102,6 +125,46 @@ public class AssistantApp {
/** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */ /** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */
private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256; private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256;
/** AI 回复落库的最大字符数,超过则保留头部 + 尾部 */
private static final int MAX_AI_RESPONSE_CHARS = 2000;
/** 截断时保留的头部字符数 */
private static final int AI_RESPONSE_HEAD_CHARS = 1500;
/** 截断时保留的尾部字符数 */
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
* *
@ -192,17 +255,24 @@ public class AssistantApp {
* @return 回答文本 + MCP 事件 * @return 回答文本 + MCP 事件
*/ */
public ChatResult chatWithEvents(ChatContext ctx) { public ChatResult chatWithEvents(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态直接返回降级提示
long startNanos = System.nanoTime();
// 熔断全局 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",
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()) {
return new ChatResult(req.faqAnswer().get(), List.of());
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return new ChatResult(faqAnswer, List.of());
} }
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 显式事件收集器 + 轮次计数器通过 toolContext 传给 McpToolCallback规避 Reactor 跨线程丢 ThreadLocal 的问题
List<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()
@ -211,15 +281,29 @@ 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",
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());
return new ChatResult("抱歉,AI 服务调用失败:" + e.getMessage(), List.of());
String fallback = "抱歉,AI 服务调用失败:" + e.getMessage();
recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR",
new TraceMeta(classifyError(e), maskError(e.getMessage()), null, null, null, events));
return new ChatResult(fallback, List.of());
} }
} }
@ -233,19 +317,26 @@ public class AssistantApp {
* @return 纯文本流式回答每个元素为一段自然语言文本 * @return 纯文本流式回答每个元素为一段自然语言文本
*/ */
public Flux<String> chatStream(ChatContext ctx) { public Flux<String> chatStream(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态
long startNanos = System.nanoTime();
// 熔断全局 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",
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);
if (req.faqHit()) { if (req.faqHit()) {
// FAQ 命中整段答案原样输出 SSE 编码器处理内部换行 // FAQ 命中整段答案原样输出 SSE 编码器处理内部换行
// 后端不做任何格式增删不拆行不加换行不补空格 // 后端不做任何格式增删不拆行不加换行不补空格
return Flux.just(req.faqAnswer().get());
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return Flux.just(faqAnswer);
} }
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 显式事件收集器 + 轮次计数器通过 toolContext 传给 McpToolCallback规避 Reactor 跨线程丢 ThreadLocal 的问题
List<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())
@ -253,18 +344,47 @@ 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 时取完整回复文本
StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream) return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.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"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL";
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()));
} }
@ -325,6 +445,238 @@ public class AssistantApp {
}, FluxSink.OverflowStrategy.BUFFER); }, FluxSink.OverflowStrategy.BUFFER);
} }
/**
* 组装并异步写入一条 LLM 调用追踪记录
* <p>
* 所有字段均在调用现场非异步线程组装为实体避免把 ChatRequest/ChatContext
* 全量对象塞进异步队列落库前对用户消息与 AI 回复做敏感词脱敏与长度截断
*
* @param ctx 对话上下文
* @param req 编排决策熔断早退时为 null
* @param responseText AI 回复文本
* @param latencyMs 耗时毫秒
* @param status 状态COMPLETE / ERROR / CANCEL / FAQ / BYPASS
*/
private void recordTrace(ChatContext ctx, ChatRequest req, String responseText, long latencyMs,
String status, TraceMeta meta) {
try {
// 走缓存的活跃配置仅取模型元信息不落 apiKey避免每次对话在 Reactor 线程同步查库
AiModelConfig cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType());
// AI 回复脱敏 + 截断保留头部 + 尾部避免丢失末尾的推荐问题块
String maskedResponse = contentSafetyService.mask(responseText);
String aiResponse = maskedResponse;
boolean truncated = false;
if (maskedResponse != null && maskedResponse.length() > MAX_AI_RESPONSE_CHARS) {
aiResponse = maskedResponse.substring(0, AI_RESPONSE_HEAD_CHARS)
+ "\n\n…(内容过长已截断)…\n\n"
+ maskedResponse.substring(maskedResponse.length() - AI_RESPONSE_TAIL_CHARS);
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()
.conversationId(ctx.chatId())
.roleId(ctx.roleId())
.roleName(ctx.roleName())
.accountId(ctx.accountId())
.apiKeyId(ctx.apiKeyId())
.intent(req != null ? req.intent() : null)
.enableRag(ctx.enableRag())
.systemPrompt(req != null ? contentSafetyService.mask(req.finalSystemPrompt()) : null)
.globalPrompt(req != null ? contentSafetyService.mask(req.globalPrompt()) : null)
.rolePrompt(contentSafetyService.mask(ctx.systemPrompt()))
.userMessage(contentSafetyService.mask(ctx.message()))
.aiResponse(aiResponse)
.aiResponseTruncated(truncated)
.ragContext(req != null ? contentSafetyService.mask(req.ragContextText()) : null)
.ragHitsJson(buildRagHitsJson(req))
.faqHit(req != null ? req.faqHit() : false)
.faqId(faqId)
.faqQuestion(faqQuestion)
.faqMatchType(faqMatchType)
.faqScore(faqScore)
.searchMode(req != null ? req.searchMode() : null)
.hitCount(req != null ? req.hitCount() : null)
.toolCallsJson(buildToolCallsJson(meta != null ? meta.mcpEvents() : null))
.historyMessagesJson(buildHistoryJson(history))
.historyTurns(history != null ? history.size() : null)
.modelName(cfg != null ? cfg.getModelName() : null)
.provider(cfg != null ? cfg.getProvider() : null)
.temperature(cfg != null ? cfg.getTemperature() : null)
.maxTokens(cfg != null ? cfg.getMaxTokens() : null)
.promptTokens(meta != null ? meta.promptTokens() : null)
.completionTokens(meta != null ? meta.completionTokens() : null)
.totalTokens(meta != null ? meta.totalTokens() : null)
.errorType(meta != null ? meta.errorType() : null)
.errorMessage(meta != null ? contentSafetyService.mask(meta.errorMessage()) : null)
.latencyMs((int) latencyMs)
.status(status)
.build();
llmCallTraceService.recordAsync(trace);
} catch (Exception e) {
log.warn("构造 LLM 调用追踪失败(不影响主流程): chatId={}, error={}", ctx.chatId(), e.getMessage());
}
}
/**
* 序列化 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 起的耗时毫秒
*/
private long elapsedMillis(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
/** /**
* 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources} * 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources}
* *

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

@ -20,6 +20,10 @@ import java.util.List;
* @param rewriteStrategy RAG 查询重写策略REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY可为 null * @param rewriteStrategy RAG 查询重写策略REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY可为 null
* @param enableRag 是否启用 RAG 检索false=普通对话 * @param enableRag 是否启用 RAG 检索false=普通对话
* @param streaming 是否流式输出 * @param streaming 是否流式输出
* @param roleId 客服角色 ID可空供调用追踪等使用
* @param roleName 客服角色名可空快照用途
* @param accountId 账户 ID可空供调用追踪/个人信息删除权使用
* @param apiKeyId API Key ID可空Open API 路径为实际鉴权 Key供租户隔离与追踪
*/ */
public record ChatContext( public record ChatContext(
String message, String message,
@ -30,7 +34,11 @@ public record ChatContext(
List<Long> categoryIds, List<Long> categoryIds,
String rewriteStrategy, String rewriteStrategy,
boolean enableRag, boolean enableRag,
boolean streaming
boolean streaming,
Long roleId,
String roleName,
String accountId,
Long apiKeyId
) { ) {
/** 默认应用类型 */ /** 默认应用类型 */
@ -49,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);
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);
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);
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);
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);
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);
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);
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);
} }
} }

30
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;
@ -72,11 +73,13 @@ public class ChatPipeline {
* @return 执行决策 * @return 执行决策
*/ */
public ChatRequest buildRequest(ChatContext ctx) { public ChatRequest buildRequest(ChatContext ctx) {
String baseSystem = effectiveSystem(ctx.systemPrompt());
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
String baseSystem = effectiveSystem(ctx.systemPrompt(), globalPrompt);
// 普通对话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", null, null, null);
} }
// 意图路由先用 IntentRouter 做细粒度分类 // 意图路由先用 IntentRouter 做细粒度分类
@ -85,10 +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());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer);
Optional<String> faqAnswer = Optional.ofNullable(faqMatch.get().getFaq().getAnswer());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
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());
} }
@ -96,7 +101,8 @@ public class ChatPipeline {
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索 // 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
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", null, null, null);
} }
// RAG 检索 FAQ 优先匹配 // RAG 检索 FAQ 优先匹配
@ -118,12 +124,15 @@ public class ChatPipeline {
ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode); ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode);
} }
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", 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",
rag.searchMode(), rag.documents(), null);
} }
/** /**
@ -193,11 +202,10 @@ public class ChatPipeline {
* 注意推荐问题suggest-message-list已不再由主回复生成 * 注意推荐问题suggest-message-list已不再由主回复生成
* 改由 {@link SuggestionGenerator} AI 回复结束后按需异步生成 * 改由 {@link SuggestionGenerator} AI 回复结束后按需异步生成
*/ */
private String effectiveSystem(String rolePrompt) {
private String effectiveSystem(String rolePrompt, String globalPrompt) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
// 全局系统提示词 DB system_config 表动态读取
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
// 全局系统提示词 buildRequest 读取后传入避免重复查询
if (StringUtils.hasText(globalPrompt)) { if (StringUtils.hasText(globalPrompt)) {
sb.append(globalPrompt); sb.append(globalPrompt);
} }

27
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;
/** /**
@ -13,18 +17,39 @@ import java.util.Optional;
* <li>{@link #faqAnswer()}FAQ 命中时直接返回标准答案跳过 ChatClient 调用</li> * <li>{@link #faqAnswer()}FAQ 命中时直接返回标准答案跳过 ChatClient 调用</li>
* <li>{@link #finalMessage()}传给模型的用户消息RAG 场景为重写后的查询否则为原始 message</li> * <li>{@link #finalMessage()}传给模型的用户消息RAG 场景为重写后的查询否则为原始 message</li>
* <li>{@link #finalSystemPrompt()}传给模型的系统提示词角色人设 + RAG 资料块可为空</li> * <li>{@link #finalSystemPrompt()}传给模型的系统提示词角色人设 + RAG 资料块可为空</li>
* <li>{@link #globalPrompt()}全局提示词快照来自 system_config.ai_system_prompt可为空</li>
* <li>{@link #ragContextText()}RAG 资料块文本未注入时为空/null</li>
* <li>{@link #hitCount()}命中的知识库片段数 RAG null</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 原始上下文
* @param finalMessage 传给模型的用户消息 * @param finalMessage 传给模型的用户消息
* @param finalSystemPrompt 传给模型的系统提示词可为 null/ * @param finalSystemPrompt 传给模型的系统提示词可为 null/
* @param faqAnswer FAQ 命中答案未命中为 {@link Optional#empty()} * @param faqAnswer FAQ 命中答案未命中为 {@link Optional#empty()}
* @param globalPrompt 全局提示词快照可为 null
* @param ragContextText RAG 资料块文本可为 null
* @param hitCount 命中文档数可为 null
* @param intent 意图CHAT / CHITCHAT / FAQ / RAG
* @param searchMode 真实检索模式可为 null
* @param hitDocuments 命中的知识库片段可为 null
* @param faqMatchResult FAQ 命中详情可为 null
*/ */
public record ChatRequest( public record ChatRequest(
ChatContext ctx, ChatContext ctx,
String finalMessage, String finalMessage,
String finalSystemPrompt, String finalSystemPrompt,
Optional<String> faqAnswer
Optional<String> faqAnswer,
String globalPrompt,
String ragContextText,
Integer hitCount,
String intent,
String searchMode,
List<Document> hitDocuments,
FaqMatchResult faqMatchResult
) { ) {
/** FAQ 是否命中 */ /** FAQ 是否命中 */

36
src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java

@ -0,0 +1,36 @@
package com.wok.supportbot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步执行器配置
* <p>
* 为日志/追踪类异步写入提供有界线程池避免 Spring 默认 {@code SimpleAsyncTaskExecutor}
* 每任务新建一线程导致的高并发线程爆炸与无背压问题
*/
@Configuration
public class AsyncExecutorConfig {
/**
* LLM 追踪等日志类异步写入线程池
* 有界队列 + CallerRunsPolicy队列满时由调用线程执行保证不丢数据背压优先于丢弃
*/
@Bean("traceExecutor")
public ThreadPoolTaskExecutor traceExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("llm-trace-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 优雅停机时等待队列中的追踪/日志任务完成避免丢记录
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
executor.initialize();
return executor;
}
}

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

@ -185,6 +185,12 @@ public class DatabaseInitConfig {
createDashboardSnapshotTable(); createDashboardSnapshotTable();
} }
}); });
safeInit("创建 LLM 调用追踪表 llm_call_trace", () -> {
if (!checkTableExists("llm_call_trace")) {
createLlmCallTraceTable();
}
});
safeInit("迁移 llm_call_trace 扩展列", this::addLlmCallTraceExtensionColumns);
// P1-003: API 开放平台 // P1-003: API 开放平台
safeInit("创建 API Key 表 api_key", () -> { safeInit("创建 API Key 表 api_key", () -> {
@ -260,7 +266,8 @@ public class DatabaseInitConfig {
"rag_hit_log", "dashboard_snapshot", "rag_hit_log", "dashboard_snapshot",
"api_key", "webhook_config", "api_key", "webhook_config",
"mcp_server_config", "mcp_server_config",
"system_config"
"system_config",
"llm_call_trace"
}; };
java.util.List<String> missingTables = new java.util.ArrayList<>(); java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -1091,6 +1098,80 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id)");
} }
private void createLlmCallTraceTable() {
String sql = """
CREATE TABLE IF NOT EXISTS llm_call_trace (
id BIGINT PRIMARY KEY,
conversation_id VARCHAR(64),
role_id BIGINT,
role_name VARCHAR(100),
account_id VARCHAR(64),
api_key_id BIGINT,
intent VARCHAR(16),
enable_rag BOOLEAN,
system_prompt TEXT,
global_prompt TEXT,
role_prompt TEXT,
user_message TEXT,
ai_response TEXT,
ai_response_truncated BOOLEAN,
tool_calls_json TEXT,
history_messages_json TEXT,
history_turns INTEGER,
rag_context TEXT,
faq_hit BOOLEAN,
faq_id BIGINT,
faq_question TEXT,
faq_match_type VARCHAR(16),
faq_score DOUBLE PRECISION,
search_mode VARCHAR(20),
hit_count INTEGER,
rag_hits_json TEXT,
model_name VARCHAR(128),
provider VARCHAR(64),
temperature DOUBLE PRECISION,
max_tokens INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
error_type VARCHAR(32),
error_message TEXT,
latency_ms INTEGER,
status VARCHAR(16),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_created ON llm_call_trace (create_time DESC, id DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_role_created ON llm_call_trace (role_id, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_conv_created ON llm_call_trace (conversation_id, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)");
}
/**
* 为已存在的 llm_call_trace 表补加扩展列幂等供旧部署环境迁移
*/
private void addLlmCallTraceExtensionColumns() {
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS tool_calls_json TEXT");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_messages_json TEXT");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_turns INTEGER");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_id BIGINT");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_question TEXT");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_match_type VARCHAR(16)");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_score DOUBLE PRECISION");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS rag_hits_json TEXT");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS completion_tokens INTEGER");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS total_tokens INTEGER");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_type VARCHAR(32)");
jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_message TEXT");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)");
}
private void createDashboardSnapshotTable() { private void createDashboardSnapshotTable() {
String sql = """ String sql = """
CREATE TABLE IF NOT EXISTS dashboard_snapshot ( CREATE TABLE IF NOT EXISTS dashboard_snapshot (
@ -1255,6 +1336,13 @@ public class DatabaseInitConfig {
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING ON CONFLICT (config_key) DO NOTHING
""", "suggestion_prompt", defaultSuggestionPrompt, "AI 推荐问题 Prompt 模板仅在 suggestion_enabled=true 时生效"); """, "suggestion_prompt", defaultSuggestionPrompt, "AI 推荐问题 Prompt 模板仅在 suggestion_enabled=true 时生效");
// LLM 调用追踪记录保留天数自动清理默认 30
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "llm_trace_retention_days", "30", "LLM 调用追踪记录保留天数自动清理默认 30 ");
} }
/** /**
@ -1505,6 +1593,34 @@ public class DatabaseInitConfig {
// ===== api_key.role_ids ===== // ===== api_key.role_ids =====
executeComment("COLUMN api_key.role_ids", "绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)"); executeComment("COLUMN api_key.role_ids", "绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)");
// ===== llm_call_trace =====
executeComment("TABLE llm_call_trace", "LLM 调用追踪表(记录每次 LLM 调用的 system prompt/回复/模型参数/耗时,用于提示词优化调试,append-only)");
executeComment("COLUMN llm_call_trace.id", "主键(雪花算法生成)");
executeComment("COLUMN llm_call_trace.conversation_id", "会话 ID");
executeComment("COLUMN llm_call_trace.role_id", "客服角色 ID(可空)");
executeComment("COLUMN llm_call_trace.role_name", "角色名称快照(角色改名后仍可追溯)");
executeComment("COLUMN llm_call_trace.account_id", "账户 ID(可空,预留租户/个人信息删除权)");
executeComment("COLUMN llm_call_trace.api_key_id", "API Key ID(可空,预留租户隔离)");
executeComment("COLUMN llm_call_trace.intent", "意图:CHAT / CHITCHAT / FAQ / RAG");
executeComment("COLUMN llm_call_trace.enable_rag", "是否启用 RAG 增强");
executeComment("COLUMN llm_call_trace.system_prompt", "最终注入 LLM 的完整 system prompt");
executeComment("COLUMN llm_call_trace.global_prompt", "全局提示词快照(可空)");
executeComment("COLUMN llm_call_trace.role_prompt", "角色提示词快照(可空)");
executeComment("COLUMN llm_call_trace.user_message", "用户原始消息(脱敏后)");
executeComment("COLUMN llm_call_trace.ai_response", "AI 回复(截断,保留头部+尾部)");
executeComment("COLUMN llm_call_trace.ai_response_truncated", "AI 回复是否被截断");
executeComment("COLUMN llm_call_trace.rag_context", "RAG 资料块(可空)");
executeComment("COLUMN llm_call_trace.faq_hit", "是否 FAQ 命中");
executeComment("COLUMN llm_call_trace.search_mode", "检索模式:VECTOR / KEYWORD / HYBRID(可空)");
executeComment("COLUMN llm_call_trace.hit_count", "命中文档数(可空)");
executeComment("COLUMN llm_call_trace.model_name", "模型名称");
executeComment("COLUMN llm_call_trace.provider", "提供商");
executeComment("COLUMN llm_call_trace.temperature", "温度参数");
executeComment("COLUMN llm_call_trace.max_tokens", "最大 Token");
executeComment("COLUMN llm_call_trace.latency_ms", "调用耗时(毫秒)");
executeComment("COLUMN llm_call_trace.status", "状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS");
executeComment("COLUMN llm_call_trace.create_time", "创建时间");
log.info("数据库表注释已应用"); log.info("数据库表注释已应用");
} catch (Exception e) { } catch (Exception e) {
log.warn("应用数据库表注释时出错", e); log.warn("应用数据库表注释时出错", e);

10
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);
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) {
@ -148,7 +148,8 @@ public class AiController {
bindConversation(chatId, context); bindConversation(chatId, context);
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.accountId(), null);
} }
/** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */ /** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */
@ -161,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);
normalizeStrategy(rewriteStrategy), enableRag, false, context.roleId(), scope.name(), context.accountId(), null);
} }
// ==================== SDK 会话管理接口带账户归属校验 ==================== // ==================== SDK 会话管理接口带账户归属校验 ====================
@ -354,7 +355,8 @@ public class AiController {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
} }
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.accountId(), ctx.apiKeyId());
return assistantApp.chatStream(ctx); return assistantApp.chatStream(ctx);
} }

192
src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java

@ -0,0 +1,192 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.LlmCallTrace;
import com.wok.supportbot.service.LlmCallTraceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* LLM 调用追踪接口 admin 可访问
* <p>
* 提供调用记录的分页查询详情聚合统计与清理
* 端点仅挂管理路径/llm-trace绝不进入 /ai/** /open-api/**SDK 开放路径
* 防止 conversation_id 跨租户越权
*/
@Slf4j
@RestController
@RequestMapping("/llm-trace")
@PreAuthorize("hasRole('admin')")
public class LlmCallTraceController {
@Autowired
private LlmCallTraceService llmCallTraceService;
/** 清理的最小保留天数(防止误清全部) */
private static final int MIN_KEEP_DAYS = 1;
/** 清理的最大保留天数(防止超过 PostgreSQL make_interval 合理范围) */
private static final int MAX_KEEP_DAYS = 3650;
/**
* 分页查询调用记录列表不含大 TEXT 字段
*/
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String conversationId,
@RequestParam(required = false) String intent,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String errorType) {
try {
Map<String, Object> result = llmCallTraceService.pageQuery(
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType);
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
data.put("total", result.get("total"));
data.put("page", result.get("page"));
data.put("size", result.get("size"));
data.put("pages", result.get("pages"));
return ResponseEntity.ok(data);
} catch (Exception e) {
log.error("查询 LLM 调用追踪失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 单条详情含完整 system prompt
*/
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> detail(@PathVariable Long id) {
try {
LlmCallTrace trace = llmCallTraceService.getDetail(id);
if (trace == null) {
return ResponseEntity.status(404).body(Map.of(
"success", false,
"message", "调用记录不存在"
));
}
return ResponseEntity.ok(Map.of("success", true, "data", trace));
} catch (Exception e) {
log.error("查询 LLM 调用追踪详情失败: id={}", id, e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 清理 N 天前的记录POST避免 DELETE + RequestBody 路径冲突
* 保留天数设下限防止误清全部
*/
@PostMapping("/clean")
public ResponseEntity<Map<String, Object>> clean(@RequestBody Map<String, Object> body) {
try {
Object raw = body.get("keepDays");
int keepDays = raw instanceof Number n ? n.intValue() : Integer.parseInt(String.valueOf(raw));
if (keepDays < MIN_KEEP_DAYS || keepDays > MAX_KEEP_DAYS) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "保留天数必须在 " + MIN_KEEP_DAYS + " ~ " + MAX_KEEP_DAYS + " 天之间"
));
}
int deleted = llmCallTraceService.cleanBefore(keepDays);
log.info("管理员清理 LLM 调用追踪:操作人={}, 保留 {} 天, 删除 {} 条", currentUser(), keepDays, deleted);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "清理完成",
"deleted", deleted
));
} catch (Exception e) {
log.error("清理 LLM 调用追踪失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "清理失败:" + e.getMessage()
));
}
}
/**
* 聚合统计按角色或模型分组
*/
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> stats(@RequestParam(defaultValue = "role") String groupBy) {
try {
List<Map<String, Object>> rows = llmCallTraceService.stats(groupBy);
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()
));
}
}
/**
* 可选错误类型列表供前端筛选下拉
*/
@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
*/
private String currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "unknown";
}
}

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);
catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId());
} }
/** /**

12
src/main/java/com/wok/supportbot/dao/LlmCallTraceMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.LlmCallTrace;
import org.apache.ibatis.annotations.Mapper;
/**
* LLM 调用追踪 Mapper
*/
@Mapper
public interface LlmCallTraceMapper extends BaseMapper<LlmCallTrace> {
}

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

@ -0,0 +1,192 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* LLM 调用追踪实体
* <p>
* 记录每次 LLM 对话的完整"案发现场"最终 system prompt分段来源用户消息AI 回复
* 角色模型参数耗时意图等供管理员反推优化提示词
* append-only无逻辑删除
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("llm_call_trace")
public class LlmCallTrace implements Serializable {
@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/** 主键ID(雪花算法) */
@TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/** 会话ID */
@TableField("conversation_id")
private String conversationId;
/** 客服角色ID(可空,无角色时为 null) */
@TableField("role_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long roleId;
/** 角色名称快照(角色改名后仍可追溯) */
@TableField("role_name")
private String roleName;
/** 账户ID(可空,预留租户/个人信息删除权) */
@TableField("account_id")
private String accountId;
/** API Key ID(可空,预留租户隔离) */
@TableField("api_key_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long apiKeyId;
/** 意图:CHAT / CHITCHAT / FAQ / RAG */
@TableField("intent")
private String intent;
/** 是否启用 RAG 增强 */
@TableField("enable_rag")
private Boolean enableRag;
/** 最终注入 LLM 的完整 system prompt */
@TableField("system_prompt")
private String systemPrompt;
/** 全局提示词快照(可空) */
@TableField("global_prompt")
private String globalPrompt;
/** 角色提示词快照(可空) */
@TableField("role_prompt")
private String rolePrompt;
/** 用户原始消息(脱敏后) */
@TableField("user_message")
private String userMessage;
/** AI 回复(截断,保留头部+尾部) */
@TableField("ai_response")
private String aiResponse;
/** AI 回复是否被截断 */
@TableField("ai_response_truncated")
private Boolean aiResponseTruncated;
/** MCP 工具调用事件 JSON 数组(可空) */
@TableField("tool_calls_json")
private String toolCallsJson;
/** 本次注入 LLM 的历史消息 JSON(可空) */
@TableField("history_messages_json")
private String historyMessagesJson;
/** 历史消息轮数(可空) */
@TableField("history_turns")
private Integer historyTurns;
/** RAG 资料块(可空) */
@TableField("rag_context")
private String ragContext;
/** 是否 FAQ 命中 */
@TableField("faq_hit")
private Boolean faqHit;
/** FAQ 命中 ID(可空,未命中或非 FAQ 意图为 null) */
@TableField("faq_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long faqId;
/** FAQ 标准问题快照(可空) */
@TableField("faq_question")
private String faqQuestion;
/** FAQ 匹配类型:EXACT / KEYWORD / SEMANTIC(可空) */
@TableField("faq_match_type")
private String faqMatchType;
/** FAQ 匹配分数(0.0 ~ 1.0,可空) */
@TableField("faq_score")
private Double faqScore;
/** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */
@TableField("search_mode")
private String searchMode;
/** 命中文档数(可空) */
@TableField("hit_count")
private Integer hitCount;
/** RAG 命中片段详情 JSON 数组(含 documentId/title/chunkIndex/score/searchMode,可空) */
@TableField("rag_hits_json")
private String ragHitsJson;
/** 模型名称 */
@TableField("model_name")
private String modelName;
/** 提供商 */
@TableField("provider")
private String provider;
/** 温度参数 */
@TableField("temperature")
private Double temperature;
/** 最大 Token */
@TableField("max_tokens")
private Integer maxTokens;
/** 提示词 token 数(可空,非 LLM 调用路径无此值) */
@TableField("prompt_tokens")
private Integer promptTokens;
/** 生成 token 数(可空) */
@TableField("completion_tokens")
private Integer completionTokens;
/** 总 token 数(可空) */
@TableField("total_tokens")
private Integer totalTokens;
/** 调用耗时(毫秒) */
@TableField("latency_ms")
private Integer latencyMs;
/** 错误类型:LLM_API / MCP / CIRCUIT_BREAK / VALIDATION / UNKNOWN(可空,非失败调用为 null) */
@TableField("error_type")
private String errorType;
/** 错误原始消息(已脱敏,可空) */
@TableField("error_message")
private String errorMessage;
/** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */
@TableField("status")
private String status;
/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
}

97
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 + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}";
} }
if (rounds == null) {
CALL_ROUNDS.set(currentRound + 1); 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";
}
// ==================== 查询重写 ==================== // ==================== 查询重写 ====================
/** /**

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

@ -0,0 +1,292 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.LlmCallTraceMapper;
import com.wok.supportbot.entity.LlmCallTrace;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* LLM 调用追踪服务
* <p>
* 异步写入每次 LLM 调用的完整现场并提供分页查询详情聚合统计与自动清理
* 列表查询不 select TEXT 字段system_prompt 避免分页 payload 膨胀
*/
@Service
@Slf4j
public class LlmCallTraceService {
@Autowired
private LlmCallTraceMapper llmCallTraceMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private SystemConfigService systemConfigService;
/** 列表查询时 user_message 的摘要长度 */
private static final int SUMMARY_LENGTH = 120;
/** 列表查询需要排除的大 TEXT 字段 */
private static final Set<String> BIG_TEXT_FIELDS =
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;
/**
* 异步记录一次 LLM 调用追踪不阻塞对话主流程
*
* @param trace 追踪实体
*/
@Async("traceExecutor")
public void recordAsync(LlmCallTrace trace) {
try {
llmCallTraceMapper.insert(trace);
log.debug("记录 LLM 调用追踪: id={}, status={}, latencyMs={}", trace.getId(), trace.getStatus(), trace.getLatencyMs());
} catch (Exception e) {
log.error("记录 LLM 调用追踪失败: status={}, chatId={}, error={}", trace.getStatus(), trace.getConversationId(), e.getMessage());
}
}
/**
* 分页查询列表不含大 TEXT 字段user_message 截断为摘要
*/
public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId,
String intent, String startTime, String endTime, String keyword,
String errorType) {
if (page < 1) page = 1;
if (page > 10000) page = 10000;
if (size < 1 || size > 100) size = 20;
// 总数只加 WHERE 条件
Long total = llmCallTraceMapper.selectCount(
buildWhere(new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType));
if (total == null) total = 0L;
// 列表排除大字段 + 排序 + 分页
QueryWrapper<LlmCallTrace> listWrapper = buildWhere(
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType);
listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn()));
listWrapper.orderByDesc("create_time").orderByDesc("id");
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size));
List<LlmCallTrace> records = llmCallTraceMapper.selectList(listWrapper);
// user_message 截断为摘要避免列表 payload 过大
for (LlmCallTrace r : records) {
if (r.getUserMessage() != null && r.getUserMessage().length() > SUMMARY_LENGTH) {
r.setUserMessage(r.getUserMessage().substring(0, SUMMARY_LENGTH) + "…");
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("records", records);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
return result;
}
/**
* 单条详情含全部字段system_prompt 全文
*/
public LlmCallTrace getDetail(Long id) {
return llmCallTraceMapper.selectById(id);
}
/**
* 聚合统计按角色role或按模型model分组
*/
public List<Map<String, Object>> stats(String groupBy) {
String sql;
if ("model".equalsIgnoreCase(groupBy)) {
sql = "SELECT model_name, provider, COUNT(*) AS count, "
+ "ROUND(AVG(latency_ms)) AS avg_latency_ms, "
+ "COUNT(*) FILTER (WHERE faq_hit = true) AS faq_hit_count "
+ "FROM llm_call_trace WHERE model_name IS NOT NULL "
+ "GROUP BY model_name, provider ORDER BY count DESC";
} else {
sql = "SELECT role_id::text AS role_id, role_name, COUNT(*) AS count, "
+ "ROUND(AVG(latency_ms)) AS avg_latency_ms, "
+ "COUNT(*) FILTER (WHERE faq_hit = true) AS faq_hit_count "
+ "FROM llm_call_trace GROUP BY role_id, role_name ORDER BY count DESC";
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
List<Map<String, Object>> result = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
result.add(snakeToCamel(row));
}
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 锁表
*
* @param keepDays 保留天数
* @return 删除条数
*/
public int cleanBefore(int keepDays) {
int batchSize = 5000;
int totalDeleted = 0;
while (true) {
int deleted = jdbcTemplate.update(
"DELETE FROM llm_call_trace WHERE id IN ("
+ "SELECT id FROM llm_call_trace "
+ "WHERE create_time < NOW() - make_interval(days => ?) LIMIT ?)",
keepDays, batchSize);
if (deleted <= 0) {
break;
}
totalDeleted += deleted;
if (deleted < batchSize) {
break;
}
}
return totalDeleted;
}
/**
* 每日凌晨 2 点自动清理过期追踪记录保留天数从 system_config 读取
*/
@Scheduled(cron = "0 0 2 * * ?")
public void scheduledClean() {
int keepDays = retentionDays();
try {
int deleted = cleanBefore(keepDays);
if (deleted > 0) {
log.info("LLM 调用追踪自动清理完成:删除 {} 条(保留 {} 天)", deleted, keepDays);
}
} catch (Exception e) {
log.error("LLM 调用追踪自动清理失败: {}", e.getMessage());
}
}
/**
* 读取保留天数配置非法值回退默认 30
*/
private int retentionDays() {
String value = systemConfigService.getValueByKey("llm_trace_retention_days");
if (value != null && value.matches("\\d+")) {
int days = Integer.parseInt(value);
if (days >= 1) {
return days;
}
}
return DEFAULT_RETENTION_DAYS;
}
/**
* 组装 WHERE 条件 count list 复用
*/
private QueryWrapper<LlmCallTrace> buildWhere(QueryWrapper<LlmCallTrace> wrapper, Long roleId,
String conversationId, String intent, String startTime, String endTime, String keyword,
String errorType) {
if (roleId != null) {
wrapper.eq("role_id", roleId);
}
if (conversationId != null && !conversationId.isBlank()) {
wrapper.eq("conversation_id", conversationId.trim());
}
if (intent != null && !intent.isBlank()) {
wrapper.eq("intent", intent.trim().toUpperCase());
}
if (errorType != null && !errorType.isBlank()) {
wrapper.eq("error_type", errorType.trim().toUpperCase());
}
if (startTime != null && !startTime.isBlank()) {
wrapper.ge("create_time", startTime.trim());
}
if (endTime != null && !endTime.isBlank()) {
wrapper.le("create_time", endTime.trim());
}
if (keyword != null && !keyword.isBlank()) {
String raw = keyword.trim();
final String kw = raw.length() > 128 ? raw.substring(0, 128) : raw;
wrapper.and(w -> w.like("user_message", kw).or().like("ai_response", kw));
}
return wrapper;
}
/**
* snake_case camelCase JdbcTemplate 查询结果转驼峰
*/
private Map<String, Object> snakeToCamel(Map<String, Object> row) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : row.entrySet()) {
out.put(toCamel(e.getKey()), e.getValue());
}
return out;
}
private String toCamel(String snake) {
StringBuilder sb = new StringBuilder();
boolean upper = false;
for (char c : snake.toCharArray()) {
if (c == '_') {
upper = true;
} else if (upper) {
sb.append(Character.toUpperCase(c));
upper = false;
} else {
sb.append(c);
}
}
return sb.toString();
}
}

4
src/main/java/com/wok/supportbot/service/RagHitLogService.java

@ -28,7 +28,7 @@ public class RagHitLogService {
* @param score 匹配得分 * @param score 匹配得分
* @param searchMode 检索模式 * @param searchMode 检索模式
*/ */
@Async
@Async("traceExecutor")
public void recordHit(String conversationId, String userQuery, Long documentId, public void recordHit(String conversationId, String userQuery, Long documentId,
String documentTitle, String score, String searchMode) { String documentTitle, String score, String searchMode) {
try { try {
@ -54,7 +54,7 @@ public class RagHitLogService {
* @param userQuery 用户查询文本 * @param userQuery 用户查询文本
* @param searchMode 检索模式 * @param searchMode 检索模式
*/ */
@Async
@Async("traceExecutor")
public void recordMiss(String conversationId, String userQuery, String searchMode) { public void recordMiss(String conversationId, String userQuery, String searchMode) {
try { try {
RagHitLog hitLog = RagHitLog.builder() RagHitLog hitLog = RagHitLog.builder()

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

@ -604,7 +604,161 @@ DROP INDEX IF EXISTS idx_faq_tenant_id;
ALTER TABLE api_key ADD COLUMN IF NOT EXISTS role_ids JSONB DEFAULT '[]' NOT NULL; ALTER TABLE api_key ADD COLUMN IF NOT EXISTS role_ids JSONB DEFAULT '[]' NOT NULL;
COMMENT ON COLUMN api_key.role_ids IS '绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)'; COMMENT ON COLUMN api_key.role_ids IS '绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)';
-- ============================================================
-- 表 15: llm_call_trace — LLM 调用追踪表(提示词优化调试)
-- ============================================================
CREATE TABLE IF NOT EXISTS llm_call_trace (
id BIGINT PRIMARY KEY,
conversation_id VARCHAR(64),
role_id BIGINT,
role_name VARCHAR(100),
account_id VARCHAR(64),
api_key_id BIGINT,
intent VARCHAR(16),
enable_rag BOOLEAN,
system_prompt TEXT,
global_prompt TEXT,
role_prompt TEXT,
user_message TEXT,
ai_response TEXT,
ai_response_truncated BOOLEAN,
tool_calls_json TEXT,
history_messages_json TEXT,
history_turns INTEGER,
rag_context TEXT,
faq_hit BOOLEAN,
faq_id BIGINT,
faq_question TEXT,
faq_match_type VARCHAR(16),
faq_score DOUBLE PRECISION,
search_mode VARCHAR(20),
hit_count INTEGER,
rag_hits_json TEXT,
model_name VARCHAR(128),
provider VARCHAR(64),
temperature DOUBLE PRECISION,
max_tokens INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
error_type VARCHAR(32),
error_message TEXT,
latency_ms INTEGER,
status VARCHAR(16),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_llm_trace_created ON llm_call_trace (create_time DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_role_created ON llm_call_trace (role_id, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_conv_created ON llm_call_trace (conversation_id, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC);
COMMENT ON TABLE llm_call_trace IS 'LLM 调用追踪表(记录每次 LLM 调用的 system prompt/回复/模型参数/耗时,用于提示词优化调试,append-only)';
COMMENT ON COLUMN llm_call_trace.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN llm_call_trace.conversation_id IS '会话 ID';
COMMENT ON COLUMN llm_call_trace.role_id IS '客服角色 ID(可空)';
COMMENT ON COLUMN llm_call_trace.role_name IS '角色名称快照(角色改名后仍可追溯)';
COMMENT ON COLUMN llm_call_trace.account_id IS '账户 ID(可空,预留租户/个人信息删除权)';
COMMENT ON COLUMN llm_call_trace.api_key_id IS 'API Key ID(可空,预留租户隔离)';
COMMENT ON COLUMN llm_call_trace.intent IS '意图:CHAT / CHITCHAT / FAQ / RAG';
COMMENT ON COLUMN llm_call_trace.enable_rag IS '是否启用 RAG 增强';
COMMENT ON COLUMN llm_call_trace.system_prompt IS '最终注入 LLM 的完整 system prompt';
COMMENT ON COLUMN llm_call_trace.global_prompt IS '全局提示词快照(可空)';
COMMENT ON COLUMN llm_call_trace.role_prompt IS '角色提示词快照(可空)';
COMMENT ON COLUMN llm_call_trace.user_message IS '用户原始消息(脱敏后)';
COMMENT ON COLUMN llm_call_trace.ai_response IS 'AI 回复(截断,保留头部+尾部)';
COMMENT ON COLUMN llm_call_trace.ai_response_truncated IS 'AI 回复是否被截断';
COMMENT ON COLUMN llm_call_trace.tool_calls_json IS 'MCP 工具调用事件 JSON 数组(可空)';
COMMENT ON COLUMN llm_call_trace.history_messages_json IS '本次注入 LLM 的历史消息 JSON(可空)';
COMMENT ON COLUMN llm_call_trace.history_turns IS '历史消息条数(可空)';
COMMENT ON COLUMN llm_call_trace.rag_context IS 'RAG 资料块(可空)';
COMMENT ON COLUMN llm_call_trace.faq_hit IS '是否 FAQ 命中';
COMMENT ON COLUMN llm_call_trace.faq_id IS 'FAQ 命中 ID(可空)';
COMMENT ON COLUMN llm_call_trace.faq_question IS 'FAQ 标准问题快照(可空)';
COMMENT ON COLUMN llm_call_trace.faq_match_type IS 'FAQ 匹配类型:EXACT / KEYWORD / SEMANTIC(可空)';
COMMENT ON COLUMN llm_call_trace.faq_score IS 'FAQ 匹配分数(0.0~1.0,可空)';
COMMENT ON COLUMN llm_call_trace.search_mode IS '检索模式:VECTOR / KEYWORD / HYBRID(可空)';
COMMENT ON COLUMN llm_call_trace.hit_count IS '命中文档数(可空)';
COMMENT ON COLUMN llm_call_trace.rag_hits_json IS 'RAG 命中片段详情 JSON 数组(可空)';
COMMENT ON COLUMN llm_call_trace.model_name IS '模型名称';
COMMENT ON COLUMN llm_call_trace.provider IS '提供商';
COMMENT ON COLUMN llm_call_trace.temperature IS '温度参数';
COMMENT ON COLUMN llm_call_trace.max_tokens IS '最大 Token';
COMMENT ON COLUMN llm_call_trace.prompt_tokens IS '提示词 token 数(可空)';
COMMENT ON COLUMN llm_call_trace.completion_tokens IS '生成 token 数(可空)';
COMMENT ON COLUMN llm_call_trace.total_tokens IS '总 token 数(可空)';
COMMENT ON COLUMN llm_call_trace.error_type IS '错误类型:LLM_API / MCP / CIRCUIT_BREAK / VALIDATION / UNKNOWN(可空)';
COMMENT ON COLUMN llm_call_trace.error_message IS '错误原始消息(已脱敏,可空)';
COMMENT ON COLUMN llm_call_trace.latency_ms IS '调用耗时(毫秒)';
COMMENT ON COLUMN llm_call_trace.status IS '状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS';
COMMENT ON COLUMN llm_call_trace.create_time IS '创建时间';
-- ============================================================
-- 表 16: system_config — 系统配置表(key-value 模式)
-- ============================================================
CREATE TABLE IF NOT EXISTS system_config (
id BIGINT PRIMARY KEY,
config_key VARCHAR(128) NOT NULL UNIQUE,
config_value TEXT NOT NULL DEFAULT '',
description VARCHAR(500),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
-- 序列:供种子数据/手动 INSERT 生成 ID 兜底
CREATE SEQUENCE IF NOT EXISTS system_config_id_seq START 1000 NO CYCLE;
ALTER TABLE system_config ALTER COLUMN id SET DEFAULT nextval('system_config_id_seq');
COMMENT ON TABLE system_config IS '系统配置表(key-value 模式,支持灵活扩展配置项)';
COMMENT ON COLUMN system_config.id IS '主键';
COMMENT ON COLUMN system_config.config_key IS '配置键(唯一)';
COMMENT ON COLUMN system_config.config_value IS '配置值';
COMMENT ON COLUMN system_config.description IS '配置说明';
COMMENT ON COLUMN system_config.create_time IS '创建时间';
COMMENT ON COLUMN system_config.update_time IS '更新时间';
COMMENT ON COLUMN system_config.is_delete IS '逻辑删除';
-- system_config 种子数据(与 DatabaseInitConfig.syncDefaultSystemConfigs 保持一致,幂等)
INSERT INTO system_config (config_key, config_value, description)
VALUES ('disclaimer',
'<p class="csk-disclaimer__title">特别声明 Claim of Confidential</p><p>✓ 本文件内容为 ④ 内部公开 请勿外传:禁止未授权的内部、第三方人员使用与访问</p><p>✓ 非专项必要的业务与项目负责人,收到此内容请立即删除</p><p>✓ 严禁在未经过主管领导审批,发送给无关业务团队与人员</p>',
'SDK 聊天窗口底部的保密声明')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('ai_system_prompt', '', 'AI 对话全局系统提示词(为空则不注入;修改后即时生效,无需重启)')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('suggestion_enabled', 'false', 'AI 推荐问题功能开关(true/false)')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('suggestion_prompt',
'【推荐问题生成规则】
3
___SUGGESTIONS___
["推荐问题1", "推荐问题2", "推荐问题3"]
1.
2.
3. JSON
4. ___SUGGESTIONS___ ',
'AI 推荐问题 Prompt 模板(仅在 suggestion_enabled=true 时生效)')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================================ -- ============================================================
-- 完成! -- 完成!
-- 共创建 14 张表及对应索引。
-- 注:本脚本与 DatabaseInitConfig 为双文件同步源;部分表(如 rag_hit_log、
-- dashboard_snapshot、sys_user 等)历史上仅随 DatabaseInitConfig 演进,
-- 属既有技术债,以 DatabaseInitConfig 为准。
-- ============================================================ -- ============================================================

123
src/test/java/com/wok/supportbot/Phase1ComponentTests.java

@ -1,123 +0,0 @@
package com.wok.supportbot;
import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.rag.CategoryFilter;
import com.wok.supportbot.rag.RagContext;
import com.wok.supportbot.rag.RagPipeline;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Collections;
import java.util.List;
/**
* 阶段一公共组件验证测试
* 需要运行中的 PostgreSQLRagPipeline 检索依赖向量库
*/
@SpringBootTest
class Phase1ComponentTests {
@Resource
private CategoryFilter categoryFilter;
@Resource
private RagPipeline ragPipeline;
// ==================== 1.1 CategoryFilter ====================
@Test @DisplayName("CF-01 parse(String) 正常逗号分隔")
void parseStringNormal() {
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse("1,2,3"));
}
@Test @DisplayName("CF-02 parse(String) 含空格")
void parseStringWithSpaces() {
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse(" 1 , 2 , 3 "));
}
@Test @DisplayName("CF-03 parse(String) null/空白")
void parseStringNullOrBlank() {
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse((String) null));
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse(""));
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse(" "));
}
@Test @DisplayName("CF-04/05 parse(Object) List")
void parseObjectList() {
// 数字元素
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse((Object) List.of(1, 2, 3)));
// 字符串元素
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse((Object) List.of("1", "2", "3")));
}
@Test @DisplayName("CF-06/07 parse(Object) 字符串 / null")
void parseObjectStringOrNull() {
Assertions.assertEquals(List.of(4L, 5L, 6L), categoryFilter.parse((Object) "4,5,6"));
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse((Object) null));
}
@Test @DisplayName("CF-08/09 normalize")
void normalize() {
Assertions.assertEquals(List.of("1", "2", "3"), categoryFilter.normalize(List.of(1L, 2L, 3L)));
Assertions.assertEquals(Collections.emptyList(), categoryFilter.normalize(null));
Assertions.assertEquals(Collections.emptyList(), categoryFilter.normalize(Collections.emptyList()));
}
@Test @DisplayName("CF-10/11 buildExpression")
void buildExpression() {
Assertions.assertNotNull(categoryFilter.buildExpression(List.of(1L, 2L)));
Assertions.assertNull(categoryFilter.buildExpression(Collections.emptyList()));
}
// ==================== 1.2 RagPipeline ====================
@Test @DisplayName("RP-08 未指定策略 → 原样查询")
void ragDefaultStrategy() {
ChatContext ctx = new ChatContext("退换货流程是什么", "test-rp-01", "CHAT",
null, null, null, null, true, false);
RagContext result = ragPipeline.retrieve(ctx);
Assertions.assertFalse(result.faqHit(), "不应触发 FAQ(除非该问题恰好命中 FAQ 库)");
Assertions.assertNotNull(result.documents(), "documents 不应为 null");
Assertions.assertNotNull(result.rewrittenQuery(), "rewrittenQuery 不应为 null");
}
@Test @DisplayName("RP-11/12 分类过滤")
void ragCategoryFilter() {
// 有分类过滤
ChatContext ctx1 = new ChatContext("平台使用说明", "test-rp-11", "CHAT",
null, null, List.of(1L), null, true, false);
RagContext r1 = ragPipeline.retrieve(ctx1);
Assertions.assertNotNull(r1.documents());
System.out.println(">>> 分类过滤(1) 命中数: " + r1.documents().size());
// 无分类过滤
ChatContext ctx2 = new ChatContext("平台使用说明", "test-rp-12", "CHAT",
null, null, null, null, true, false);
RagContext r2 = ragPipeline.retrieve(ctx2);
Assertions.assertNotNull(r2.documents());
System.out.println(">>> 无分类过滤 命中数: " + r2.documents().size());
}
@Test @DisplayName("RP-14 retrieveDocuments 跳过 FAQ")
void ragRetrieveDocuments() {
ChatContext ctx = new ChatContext("平台使用说明", "test-rp-14", "CHAT",
null, null, null, null, true, false);
var docs = ragPipeline.retrieveDocuments(ctx);
Assertions.assertNotNull(docs);
System.out.println(">>> retrieveDocuments 命中数: " + docs.size());
}
@Test @DisplayName("RP-04 REWRITE 策略")
void ragRewriteStrategy() {
ChatContext ctx = new ChatContext("订单退款流程", "test-rp-04", "CHAT",
null, null, null, "REWRITE", true, false);
RagContext result = ragPipeline.retrieve(ctx);
Assertions.assertNotNull(result.rewrittenQuery());
System.out.println(">>> REWRITE 重写前: 订单退款流程");
System.out.println(">>> REWRITE 重写后: " + result.rewrittenQuery());
Assertions.assertNotNull(result.documents());
}
}

55
src/test/java/com/wok/supportbot/SupportBotApplicationTests.java

@ -1,55 +0,0 @@
package com.wok.supportbot;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.UUID;
@SpringBootTest
class SupportBotApplicationTests {
@Resource
private AssistantApp assistantApp;
@Test
void testChat() {
String chatId = UUID.randomUUID().toString();
// 第一轮商品咨询
String message = "你好,我想买一台适合学生用的笔记本电脑,有推荐吗?";
String answer = assistantApp.chat(ChatContext.of(message, chatId));
Assertions.assertNotNull(answer);
// 第二轮物流问题
message = "我上周买的那台电脑现在还没到,能查一下物流吗?";
answer = assistantApp.chat(ChatContext.of(message, chatId));
Assertions.assertNotNull(answer);
// 第三轮售后问题
message = "电脑到了,但有点问题。你刚刚说的售后流程能再说一遍吗?";
answer = assistantApp.chat(ChatContext.of(message, chatId));
Assertions.assertNotNull(answer);
}
@Test
void doChatWithRag() {
String chatId = "1069b88d-eb85-47ac-bd2e-c393d118a5aa";
String message = "我之前询问了你什么问题?";
String answer = assistantApp.chat(new ChatContext(message, chatId, "CHAT", null, null,
null, null, true, false));
Assertions.assertNotNull(answer);
}
@Test
void doChatWithRagEnhance() {
String chatId = "1069b88d-eb85-47ac-bd2e-c393d118a5aa";
String message = "我之前询问了你什么问题?";
String answer = assistantApp.chat(new ChatContext(message, chatId, "CHAT", null, null,
null, null, true, false));
Assertions.assertNotNull(answer);
}
}
Loading…
Cancel
Save