Browse Source

完成「LLM 调用追踪面板」开发

master
wanghanlin 1 week ago
parent
commit
2fe322aa7d
  1. 10
      CLAUDE.md
  2. 1
      frontend/components.d.ts
  3. 44
      frontend/src/api/llm-trace.ts
  4. 1
      frontend/src/router/index.ts
  5. 1
      frontend/src/stores/navigation.ts
  6. 420
      frontend/src/views/PromptTracePanel.vue
  7. 12
      frontend/src/views/RoleManager.vue
  8. 13
      frontend/src/views/SystemConfigManager.vue
  9. 117
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  10. 20
      src/main/java/com/wok/supportbot/app/ChatContext.java
  11. 23
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  12. 14
      src/main/java/com/wok/supportbot/app/ChatRequest.java
  13. 36
      src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
  14. 79
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  15. 10
      src/main/java/com/wok/supportbot/controller/AiController.java
  16. 156
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  17. 2
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  18. 12
      src/main/java/com/wok/supportbot/dao/LlmCallTraceMapper.java
  19. 139
      src/main/java/com/wok/supportbot/entity/LlmCallTrace.java
  20. 243
      src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
  21. 4
      src/main/java/com/wok/supportbot/service/RagHitLogService.java
  22. 127
      src/main/resources/init-database.sql
  23. 123
      src/test/java/com/wok/supportbot/Phase1ComponentTests.java
  24. 55
      src/test/java/com/wok/supportbot/SupportBotApplicationTests.java

10
CLAUDE.md

@ -214,6 +214,7 @@ catch (e) { toast('操作失败', 'error') }
- FAQ 管理: `/faq/*`(`FaqController`)
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取)
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色)
- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色)
### Filter 优先级
@ -263,6 +264,15 @@ catch (e) { toast('操作失败', 'error') }
- **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护
- **前端**: `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
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续

1
frontend/components.d.ts

@ -21,6 +21,7 @@ declare module 'vue' {
TDatePicker: typeof import('tdesign-vue-next')['DatePicker']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer']
TEmpty: typeof import('tdesign-vue-next')['Empty']
TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem']

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

@ -0,0 +1,44 @@
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
}
/** 分页查询调用记录 */
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)
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)
}

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/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.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' },
]

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: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] },
{ id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] },
{ id: 'prompt-trace', label: '提示词追踪', icon: '🔬', path: '/settings/prompt-trace', roles: ['admin'] },
],
},
]

420
frontend/src/views/PromptTracePanel.vue

@ -0,0 +1,420 @@
<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-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" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比{{ selectedRowKeys.length }}/2</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 #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>
</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>
<span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span>
</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>
<pre class="full-prompt">{{ detail.systemPrompt || '(空)' }}</pre>
<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 回复"><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 资料">
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre>
<div class="muted" style="margin-top:8px;">检索模式{{ detail.searchMode || '-' }} · 命中{{ detail.hitCount ?? '-' }} </div>
</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="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-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 } from 'vue'
import { useRouter } from 'vue-router'
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats } from '@/api/llm-trace'
import { getAllRoles } from '@/api/role'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useDebounce } from '@/composables/useDebounce'
import { useConfirm } from '@/composables/useConfirm'
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 roleOptions = 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: '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: 80, 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 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)
onMounted(async () => {
await loadRoles()
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) }
}
// ===== =====
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,
})
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()
}
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
}
}
</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; }
</style>

12
frontend/src/views/RoleManager.vue

@ -184,6 +184,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useCategoryStore } from '@/stores/category'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories, updateRoleMcpTools } from '@/api/role'
import { listAvailableMcpTools } from '@/api/mcp-server'
@ -192,6 +193,7 @@ import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const categoryStore = useCategoryStore()
const route = useRoute()
// ---- ----
const roles = ref<any[]>([])
@ -334,10 +336,16 @@ function toggleTool(name: string, val: boolean): void {
}
// ---- ----
onMounted(() => {
reload()
onMounted(async () => {
await reload()
categoryStore.loadCategories()
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 退 ----

13
frontend/src/views/SystemConfigManager.vue

@ -174,6 +174,7 @@
<script setup lang="ts">
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 DOMPurify from 'dompurify'
import { listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '@/api/system-config'
@ -181,6 +182,7 @@ import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const route = useRoute()
// ==================== ====================
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 退
watch(selectedConfig, (cfg) => {

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

@ -5,8 +5,13 @@ import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
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.McpToolCallbackAdapter;
import com.wok.supportbot.service.AiModelConfigService;
import com.wok.supportbot.service.ContentSafetyService;
import com.wok.supportbot.service.LlmCallTraceService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
@ -21,6 +26,7 @@ import org.springframework.util.StringUtils;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import reactor.core.publisher.SignalType;
import java.util.ArrayList;
import java.util.Collections;
@ -67,6 +73,15 @@ public class AssistantApp {
@Resource
private ChatPipeline chatPipeline;
@Resource
private AiModelConfigService aiModelConfigService;
@Resource
private LlmCallTraceService llmCallTraceService;
@Resource
private ContentSafetyService contentSafetyService;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools;
@ -102,6 +117,15 @@ public class AssistantApp {
/** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */
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;
/**
* 初始化 ChatClient
*
@ -192,14 +216,18 @@ public class AssistantApp {
* @return 回答文本 + MCP 事件
*/
public ChatResult chatWithEvents(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态直接返回降级提示
long startNanos = System.nanoTime();
// 熔断全局 AI 调用处于熔断状态直接返回降级提示不做 buildRequest避免熔断期间仍走意图路由/检索
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS");
return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of());
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
return new ChatResult(req.faqAnswer().get(), List.of());
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ");
return new ChatResult(faqAnswer, List.of());
}
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
@ -213,13 +241,16 @@ public class AssistantApp {
}
String text = spec.call().chatResponse().getResult().getOutput().getText();
aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY);
recordTrace(ctx, req, text, elapsedMillis(startNanos), "COMPLETE");
// 推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, McpToolCallback.drainEvents(), List.of());
} catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
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");
return new ChatResult(fallback, List.of());
}
}
@ -233,16 +264,20 @@ public class AssistantApp {
* @return 纯文本流式回答每个元素为一段自然语言文本
*/
public Flux<String> chatStream(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态
long startNanos = System.nanoTime();
// 熔断全局 AI 调用处于熔断状态不做 buildRequest避免熔断期间仍走意图路由/检索
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS");
return Flux.just(CIRCUIT_OPEN_MESSAGE);
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
// FAQ 命中整段答案原样输出 SSE 编码器处理内部换行
// 后端不做任何格式增删不拆行不加换行不补空格
return Flux.just(req.faqAnswer().get());
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ");
return Flux.just(faqAnswer);
}
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
@ -255,7 +290,10 @@ public class AssistantApp {
}
// 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
Flux<String> rawStream = spec.stream().content();
// 聚合所有分片用于埋点 doFinally 时取完整回复文本
StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@ -265,6 +303,10 @@ public class AssistantApp {
// 确保 ThreadLocal 清理防止线程池复用时数据残留
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// 流式埋点按终止信号区分状态断连/异常也落库
String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL";
recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status);
})
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
}
@ -325,6 +367,71 @@ public class AssistantApp {
}, 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) {
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;
}
LlmCallTrace trace = LlmCallTrace.builder()
.conversationId(ctx.chatId())
.roleId(ctx.roleId())
.roleName(ctx.roleName())
.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)
.faqHit(req != null ? req.faqHit() : false)
// 检索模式当前主管道 RagPipeline.similaritySearch 仅纯向量检索
// HybridSearchServiceVECTOR/KEYWORD/HYBRID接入主管道后应改为透传真实值
.searchMode((req != null && "RAG".equals(req.intent())) ? "VECTOR" : null)
.hitCount(req != null ? req.hitCount() : 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)
.latencyMs((int) latencyMs)
.status(status)
.build();
llmCallTraceService.recordAsync(trace);
} catch (Exception e) {
log.warn("构造 LLM 调用追踪失败(不影响主流程): chatId={}, error={}", ctx.chatId(), e.getMessage());
}
}
/**
* 计算自 startNanos 起的耗时毫秒
*/
private long elapsedMillis(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
/**
* 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources}
*

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

@ -20,6 +20,8 @@ import java.util.List;
* @param rewriteStrategy RAG 查询重写策略REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY可为 null
* @param enableRag 是否启用 RAG 检索false=普通对话
* @param streaming 是否流式输出
* @param roleId 客服角色 ID可空供调用追踪等使用
* @param roleName 客服角色名可空快照用途
*/
public record ChatContext(
String message,
@ -30,7 +32,9 @@ public record ChatContext(
List<Long> categoryIds,
String rewriteStrategy,
boolean enableRag,
boolean streaming
boolean streaming,
Long roleId,
String roleName
) {
/** 默认应用类型 */
@ -49,30 +53,30 @@ public record ChatContext(
* 便捷构造仅指定核心字段其余取默认值 RAG非流式
*/
public static ChatContext of(String message, String chatId) {
return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false);
return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false, null, null);
}
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);
}
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);
}
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);
}
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);
}
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);
}
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);
}
}

23
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -72,11 +72,13 @@ public class ChatPipeline {
* @return 执行决策
*/
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 的情况
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");
}
// 意图路由先用 IntentRouter 做细粒度分类
@ -88,7 +90,8 @@ public class ChatPipeline {
Optional<String> faqAnswer = ragPipeline.tryFaqMatch(ctx.message());
if (faqAnswer.isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer);
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
globalPrompt, null, null, "FAQ");
}
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
}
@ -96,7 +99,8 @@ public class ChatPipeline {
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& 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");
}
// RAG 检索 FAQ 优先匹配
@ -118,12 +122,14 @@ public class ChatPipeline {
ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode);
}
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");
}
// RAG 生成资料块注入 system重写后查询作为 user 消息
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");
}
/**
@ -193,11 +199,10 @@ public class ChatPipeline {
* 注意推荐问题suggest-message-list已不再由主回复生成
* 改由 {@link SuggestionGenerator} AI 回复结束后按需异步生成
*/
private String effectiveSystem(String rolePrompt) {
private String effectiveSystem(String rolePrompt, String globalPrompt) {
StringBuilder sb = new StringBuilder();
// 全局系统提示词 DB system_config 表动态读取
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
// 全局系统提示词 buildRequest 读取后传入避免重复查询
if (StringUtils.hasText(globalPrompt)) {
sb.append(globalPrompt);
}

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

@ -13,18 +13,30 @@ import java.util.Optional;
* <li>{@link #faqAnswer()}FAQ 命中时直接返回标准答案跳过 ChatClient 调用</li>
* <li>{@link #finalMessage()}传给模型的用户消息RAG 场景为重写后的查询否则为原始 message</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>
* </ul>
*
* @param ctx 原始上下文
* @param finalMessage 传给模型的用户消息
* @param finalSystemPrompt 传给模型的系统提示词可为 null/
* @param faqAnswer FAQ 命中答案未命中为 {@link Optional#empty()}
* @param globalPrompt 全局提示词快照可为 null
* @param ragContextText RAG 资料块文本可为 null
* @param hitCount 命中文档数可为 null
* @param intent 意图CHAT / CHITCHAT / FAQ / RAG
*/
public record ChatRequest(
ChatContext ctx,
String finalMessage,
String finalSystemPrompt,
Optional<String> faqAnswer
Optional<String> faqAnswer,
String globalPrompt,
String ragContextText,
Integer hitCount,
String intent
) {
/** 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;
}
}

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

@ -185,6 +185,11 @@ public class DatabaseInitConfig {
createDashboardSnapshotTable();
}
});
safeInit("创建 LLM 调用追踪表 llm_call_trace", () -> {
if (!checkTableExists("llm_call_trace")) {
createLlmCallTraceTable();
}
});
// P1-003: API 开放平台
safeInit("创建 API Key 表 api_key", () -> {
@ -260,7 +265,8 @@ public class DatabaseInitConfig {
"rag_hit_log", "dashboard_snapshot",
"api_key", "webhook_config",
"mcp_server_config",
"system_config"
"system_config",
"llm_call_trace"
};
java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -1091,6 +1097,42 @@ public class DatabaseInitConfig {
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,
rag_context TEXT,
faq_hit BOOLEAN,
search_mode VARCHAR(20),
hit_count INTEGER,
model_name VARCHAR(128),
provider VARCHAR(64),
temperature DOUBLE PRECISION,
max_tokens INTEGER,
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)");
}
private void createDashboardSnapshotTable() {
String sql = """
CREATE TABLE IF NOT EXISTS dashboard_snapshot (
@ -1255,6 +1297,13 @@ public class DatabaseInitConfig {
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "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 +1554,34 @@ public class DatabaseInitConfig {
// ===== api_key.role_ids =====
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("数据库表注释已应用");
} catch (Exception e) {
log.warn("应用数据库表注释时出错", e);

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

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

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

@ -0,0 +1,156 @@
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) {
try {
Map<String, Object> result = llmCallTraceService.pageQuery(
page, size, roleId, conversationId, intent, startTime, endTime, keyword);
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()
));
}
}
/**
* 获取当前登录用户名用于审计日志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,
scope.hasRole() ? scope.allowedMcpTools() : null,
catIds, strategy, useRag, streaming);
catIds, strategy, useRag, streaming, roleId, scope.name());
}
/**

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> {
}

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

@ -0,0 +1,139 @@
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;
/** RAG 资料块(可空) */
@TableField("rag_context")
private String ragContext;
/** 是否 FAQ 命中 */
@TableField("faq_hit")
private Boolean faqHit;
/** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */
@TableField("search_mode")
private String searchMode;
/** 命中文档数(可空) */
@TableField("hit_count")
private Integer hitCount;
/** 模型名称 */
@TableField("model_name")
private String modelName;
/** 提供商 */
@TableField("provider")
private String provider;
/** 温度参数 */
@TableField("temperature")
private Double temperature;
/** 最大 Token */
@TableField("max_tokens")
private Integer maxTokens;
/** 调用耗时(毫秒) */
@TableField("latency_ms")
private Integer latencyMs;
/** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */
@TableField("status")
private String status;
/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
}

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

@ -0,0 +1,243 @@
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");
/** 默认保留天数 */
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) {
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));
if (total == null) total = 0L;
// 列表排除大字段 + 排序 + 分页
QueryWrapper<LlmCallTrace> listWrapper = buildWhere(
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword);
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;
}
/**
* 分批删除 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) {
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 (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 searchMode 检索模式
*/
@Async
@Async("traceExecutor")
public void recordHit(String conversationId, String userQuery, Long documentId,
String documentTitle, String score, String searchMode) {
try {
@ -54,7 +54,7 @@ public class RagHitLogService {
* @param userQuery 用户查询文本
* @param searchMode 检索模式
*/
@Async
@Async("traceExecutor")
public void recordMiss(String conversationId, String userQuery, String searchMode) {
try {
RagHitLog hitLog = RagHitLog.builder()

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

@ -604,7 +604,132 @@ DROP INDEX IF EXISTS idx_faq_tenant_id;
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 数组,空数组=不限制,返回所有启用角色)';
-- ============================================================
-- 表 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,
rag_context TEXT,
faq_hit BOOLEAN,
search_mode VARCHAR(20),
hit_count INTEGER,
model_name VARCHAR(128),
provider VARCHAR(64),
temperature DOUBLE PRECISION,
max_tokens INTEGER,
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);
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.rag_context IS 'RAG 资料块(可空)';
COMMENT ON COLUMN llm_call_trace.faq_hit IS '是否 FAQ 命中';
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.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.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