Browse Source
feat(pipeline): 新增 AI 执行链节点开关,后台可关闭首 token 前的耗时节点
feat(pipeline): 新增 AI 执行链节点开关,后台可关闭首 token 前的耗时节点
默认 MULTI_QUERY 策略下,一次对话在产出首个 token 前需串行执行意图路由 LLM 分类(1 次 LLM)、查询重写(1 次 LLM + 4 路 embedding)、FAQ 语义匹配(0~2 次 embedding),是「AI 回复慢」的主因。现提供可后台配置的开关按需关闭。 - 新增 PipelineToggleService:4 个开关落 system_config,用 volatile 不可变 快照读取(ApplicationReadyEvent 载入 + 写时整体重建),规避 SystemConfigService 无缓存直查在 hot path 上每请求 3~4 次的开销; 保存后即时生效,无需重启、无需清缓存 - 新增 PipelineToggleController:GET/PUT /pipeline-toggle(admin) - ChatPipeline.routeIntent 可跳过 LLM 意图分类;删除无调用点的死代码 shouldBypassRag(内部会重复触发一次意图 LLM 调用) - RagPipeline 可关闭查询重写、可将 MULTI_QUERY 降级为单路检索,两者复用 rewriteQuery 既有的「未知策略返回原文」降级分支,不新增代码路径 - FaqMatchEngine 可跳过第三级语义匹配,保留无网络调用的精确/关键词匹配 - PipelineFlow.vue 新增开关面板与极速/均衡/精准预设,被关闭的节点在流程图 上置灰虚线;菜单项限制为 admin - 种子数据默认全部 true,保持升级后行为与改动前完全一致; DatabaseInitConfig 与 init-database.sql 已同步master^2
14 changed files with 1147 additions and 44 deletions
-
395AGENTS.md
-
23CLAUDE.md
-
26frontend/src/api/pipeline-toggle.ts
-
2frontend/src/stores/navigation.ts
-
353frontend/src/views/PipelineFlow.vue
-
5frontend/src/views/SystemConfigManager.vue
-
33src/main/java/com/wok/supportbot/app/ChatPipeline.java
-
12src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
82src/main/java/com/wok/supportbot/controller/PipelineToggleController.java
-
17src/main/java/com/wok/supportbot/rag/RagPipeline.java
-
8src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
-
214src/main/java/com/wok/supportbot/service/PipelineToggleService.java
-
19src/main/resources/init-database.sql
-
2src/main/resources/static/sdk/test.html
@ -0,0 +1,395 @@ |
|||
# AGENTS.md |
|||
|
|||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. |
|||
|
|||
## 项目概述 |
|||
|
|||
AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支持 RAG 知识库检索、多轮对话、结构化数据提取、知识库全生命周期管理。 |
|||
|
|||
## 构建与运行 |
|||
|
|||
```bash |
|||
# 编译 |
|||
./mvnw compile |
|||
|
|||
# 运行(端口 9090) |
|||
./mvnw spring-boot:run |
|||
|
|||
# 运行测试 |
|||
./mvnw test |
|||
|
|||
# 运行单个测试类 |
|||
./mvnw test -Dtest=SupportBotApplicationTests |
|||
|
|||
# 运行单个测试方法 |
|||
./mvnw test -Dtest=SupportBotApplicationTests#testRag |
|||
``` |
|||
|
|||
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。 |
|||
|
|||
**测试说明**: 所有测试均为集成测试(`@SpringBootTest`),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:`SupportBotApplicationTests`(对话/RAG)、`PgVectorVectorStoreConfigTest`(向量存储)、`QueryTransformerTests`(查询重写策略)。无单元测试。 |
|||
|
|||
**访问地址**: 前端管理页面 `http://localhost:9090/index.html`,API 文档 `http://localhost:9090/doc.html`(Knife4j) |
|||
|
|||
## 核心架构决策 |
|||
|
|||
### 手动配置 PgVectorStore(未引入自动配置) |
|||
`PgVectorStoreConfig` 手动配置 PgVectorStore Bean(标记 `@Primary`),`SupportBotApplication` 是裸 `@SpringBootApplication`、无任何 exclude —— 因为项目依赖的是**非 starter** 的 `spring-ai-pgvector-store`(只有实现类,不含 `spring-ai-autoconfigure-vector-store-pgvector`),classpath 上本就没有 `PgVectorStoreAutoConfiguration`。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。 |
|||
|
|||
### Spring AI 集成模式 |
|||
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(chatModelFactory.getChatModel("CHAT"))` 构建,ChatModel 由 `ChatModelFactory` 按 DB 活跃配置动态创建 |
|||
- **ChatClient 构建**: 所有权在 `AssistantApp`(`getChatClient`),按 `appType` + `allowedMcpTools` 缓存不同实例 |
|||
- **Advisor 链**: `ContentSafetyAdvisor`(最外层,`HIGHEST_PRECEDENCE`)→ `MessageChatMemoryAdvisor`(记忆)→ `MyLoggerAdvisor`(日志) |
|||
- **SSE 流式**: 仅保留 `Flux<String>` 形态;废弃的 `Flux<ServerSentEvent>` 和 `SseEmitter` 已移除 |
|||
|
|||
### ChatMemory 持久化 |
|||
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。 |
|||
|
|||
### 统一对话管道(重构后) |
|||
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成: |
|||
|
|||
``` |
|||
用户请求 |
|||
→ 鉴权/角色解析(Controller) |
|||
→ ChatPipeline.buildRequest(ChatContext) |
|||
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG) ← 可关: pipeline_intent_llm_enabled |
|||
→ RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索) |
|||
· FAQ 三级匹配(第三级语义匹配) ← 可关: pipeline_faq_semantic_enabled |
|||
· 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled |
|||
· MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled |
|||
→ 组装 finalMessage + finalSystemPrompt + 资料块 |
|||
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream) |
|||
``` |
|||
|
|||
### AI 执行链节点开关(性能开关) |
|||
|
|||
管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。 |
|||
|
|||
| 配置键(`system_config`) | 默认 | 关闭后省下 | 代价 | |
|||
|---|---|---|---| |
|||
| `pipeline_intent_llm_enabled` | `true` | 1 次 LLM | 不再按意图跳过知识库检索(寒暄词快速路径保留,零开销) | |
|||
| `pipeline_rewrite_enabled` | `true` | 1 次 LLM | 忽略请求携带的重写策略,直接用原文检索 | |
|||
| `pipeline_multiquery_enabled` | `true` | 3 路 embedding | `MULTI_QUERY` 降级为单路原文检索(其他策略不受影响) | |
|||
| `pipeline_faq_semantic_enabled` | `true` | 0~2 次 embedding | FAQ 只剩精确匹配与关键词匹配,近义问法匹配不到 | |
|||
|
|||
- **实现**:`PipelineToggleService` 持有 `volatile` 不可变快照(`@EventListener(ApplicationReadyEvent)` 载入,写时整体重建,与 `ContentSafetyService` 的 DFA 树同一范式)。之所以不复用 `SystemConfigService.getValueByKey`,是因为它**无缓存、每次直查数据库**,而这些开关在每请求的 hot path 上要判定 3~4 次。 |
|||
- **生效**:后台保存后立刻重建快照,下一次对话即生效,无需重启、无需清任何缓存。代价是**直接改数据库不生效**(请走后台页面)。 |
|||
- **接口**:`GET/PUT /pipeline-toggle`(`PipelineToggleController`,admin)。PUT 为增量更新,未提交的 key 保持原值。 |
|||
- **前端**:`PipelineFlow.vue` 顶部开关面板 + 三个预设(极速/均衡/精准),被关闭的节点在 Mermaid 图上**置灰 + 虚线**,能力降级(如仅关 FAQ 语义匹配)的节点保持原色只加虚线。 |
|||
- **注意**:菜单项已加 `roles: ['admin']`;非管理员直接访问 URL 时开关面板隐藏、流程图仍可查看(接口 403 被静默降级处理)。 |
|||
|
|||
- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象 |
|||
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter |
|||
- **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除 |
|||
- **IntentRouter**: 已在 ChatPipeline 接入,`AiController.shouldBypassKnowledgeRetrieval` 已移除 |
|||
- **分类过滤**: 统一由 `CategoryFilter` 工具类处理(`parse`/`normalize`/`buildExpression`) |
|||
- **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除 |
|||
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 |
|||
|
|||
### 文档处理管道 |
|||
`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。 |
|||
|
|||
**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。 |
|||
|
|||
**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷): |
|||
|
|||
| 项 | 规则 | |
|||
|---|---| |
|||
| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 | |
|||
| 序列化 | 实体 `content` 标 `@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 | |
|||
| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** | |
|||
|
|||
**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService` 以 `cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。 |
|||
|
|||
**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。 |
|||
|
|||
|
|||
## 关键配置 |
|||
|
|||
- `application.yml` 含 DashScope API Key,已被 `.gitignore` 排除 |
|||
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`) |
|||
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法) |
|||
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解 |
|||
- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。 |
|||
- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。 |
|||
- 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。 |
|||
- 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。 |
|||
- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。 |
|||
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`) |
|||
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW |
|||
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true |
|||
- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。 |
|||
- **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。 |
|||
- **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。 |
|||
- `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项) |
|||
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验 |
|||
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重 |
|||
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准 |
|||
|
|||
### 模型配置管理 |
|||
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / EMBEDDING / RAG_REWRITE)独立管理。**所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml** |
|||
- **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证 |
|||
- **启动 seed**: 首次启动时使用硬编码默认值写入 DB(`qwen-turbo` / `text-embedding-v2`),用户可在前端修改。**DashScope 自动配置已禁用**(`spring.ai.dashscope.enabled=false`),api-key 可选——yml 与 DB 均未配置 api-key 不影响启动,实际调用 AI 功能前在前端「AI 大模型配置管理」页面配置即可 |
|||
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性 |
|||
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位 |
|||
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions` |
|||
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效 |
|||
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建 |
|||
- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集) |
|||
- **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`<datalist>` 支持搜索选择 + 自定义输入) |
|||
|
|||
### 依赖版本 |
|||
- Spring Boot: `3.5.8`(Spring AI Alibaba 1.1.2.2 官方基线) |
|||
- Spring AI BOM: `1.1.2`(properties 中的 `spring-ai.version`),统一管理所有 `org.springframework.ai` 依赖版本 |
|||
- `spring-ai-alibaba-starter-dashscope`: `1.1.2.2`。**必须显式写版本号** —— `spring-ai-alibaba-bom` 并不管理该模块(它只管理 agentscope / graph / studio 等 8 个模块),因此项目**不导入** SAA BOM |
|||
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)。**刻意使用非 starter 坐标**:项目手动构建 ChatModel/EmbeddingModel,用 starter 会额外引入 `-model-openai` / `-chat-client` / `-chat-memory` 三个自动配置,可能生成与手写工厂、`DatabaseChatMemory` 冲突的 Bean。`spring-ai-pgvector-store` 同理 |
|||
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用 |
|||
- **MCP SDK 必须锁 0.18.3**(`pom.xml` 中排除传递依赖 + 显式声明):Spring AI 1.1.2 的 `spring-ai-mcp` 仍锁 MCP SDK **0.17.0**,而 0.17.0 的 JsonMapper 包名是 `io.modelcontextprotocol.json.jackson`(无 `jackson2`),项目 `McpClientManager` 用的是 0.18.0 起才有的 `...json.jackson2.JacksonMcpJsonMapper`。`spring-ai-mcp` 只引用 `McpSyncClient/McpAsyncClient/McpClient/McpTransportContext/McpSchema/Assert`,这些类 0.18.3 均存在,故覆盖安全 |
|||
- `com.github.victools:jsonschema-generator`: 不再显式声明,由 `spring-ai-model:1.1.2` 传递引入(4.38.0,结构化输出 `BeanOutputConverter` 依赖它) |
|||
- `com.knuddels:jtokkit:1.1.0`: 显式声明,`OverlapTokenTextSplitter` 直接使用 |
|||
- `com.esotericsoftware:kryo` 与 `org.springframework.security:spring-security-oauth2-client` 均已移除(唯一使用者/唯一用途已消失;后者曾是 Spring AI 1.0.x `ToolCallingAutoConfiguration` 的 ClassNotFound workaround,1.1.2 已无该耦合) |
|||
|
|||
### EmbeddingModel 架构 |
|||
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型 |
|||
- **EmbeddingModelFactory**:按 DB 活跃配置动态创建/缓存 EmbeddingModel,**支持多种提供商**: |
|||
- DashScope(通义千问):`DashScopeEmbeddingModel` |
|||
- OpenAI 兼容提供商(DeepSeek / Kimi / 智谱 / OpenAI):通过 `OpenAiEmbeddingModel` + 自定义 baseUrl + `embeddingsPath` 创建 |
|||
- **豆包文本模型**(volcengine + 非 vision):通过 `OpenAiEmbeddingModel`,embeddingsPath=`/embeddings` |
|||
- **豆包多模态模型**(volcengine + `*vision*`):通过 `VolcengineMultimodalEmbeddingModel`,手写 `RestClient` 直调 `/embeddings/multimodal`,适配 `{type, text}` 格式 |
|||
- **各厂商 embeddingsPath 映射**: |
|||
| 提供商 | embeddingsPath | 实现类 | |
|||
|--------|---------------|--------| |
|||
| dashscope | — | DashScopeEmbeddingModel | |
|||
| volcengine (vision) | `/embeddings/multimodal` | VolcengineMultimodalEmbeddingModel | |
|||
| volcengine (text) | `/embeddings` | OpenAiEmbeddingModel | |
|||
| moonshot | `/embeddings` | OpenAiEmbeddingModel | |
|||
| zhipu | `/embeddings` | OpenAiEmbeddingModel | |
|||
| deepseek | `/v1/embeddings` | OpenAiEmbeddingModel | |
|||
| openai | `/v1/embeddings` | OpenAiEmbeddingModel | |
|||
- 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions` 一致 |
|||
- **DynamicEmbeddingModel**:代理类实现 `EmbeddingModel` 接口,每次调用委托给 Factory,使 VectorStore 无需重建即可热切换 |
|||
- **前端**:`ModelConfigManager.js` 对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商;EMBEDDING 类型弹窗增加「向量维度」输入框(写入 `extraConfig.dimensions`) |
|||
|
|||
## 前端架构 |
|||
|
|||
- **技术栈**: Vue 3.5 + TypeScript + Vite + TDesign Vue Next(`tdesign-vue-next`)+ Pinia + Vue Router(hash 模式) |
|||
- **源码目录**: `frontend/`(不再手写 `static/js`;`static` 现在是 `npm run build` 的产物,由 `frontend/cp-to-static.mjs` 拷贝进去) |
|||
- **入口**: `frontend/src/main.ts` → `App.vue`(登录页 / `MainLayout` 切换)→ `router/index.ts` |
|||
- **组件化**: 每个功能页面一个 `.vue` 文件(`frontend/src/views/`),TDesign 组件通过 `unplugin-vue-components` 自动按需引入(模板直接写 `<t-xxx>`) |
|||
- **状态管理**: Pinia(`frontend/src/stores/`),路由守卫在 `router/index.ts` 中做页面数据预加载 |
|||
- **API 封装**: `frontend/src/api/*.ts` 统一封装(axios 实例 + 拦截器见 `request.ts`),API 基址为空字符串(同源部署) |
|||
- **SSE 流式**: `frontend/src/utils/sse.ts` 中 `readSSEStream*()` 统一处理 SSE 接口 |
|||
- **开发运行**: `cd frontend && npm run dev`(Vite 代理到 9090);生产 `npm run build` 后拷贝进 `static` |
|||
- **UI 开发准则**: 页面 UI 开发统一遵循 `frontend/UI-DEV-GUIDE.md`(组件优先、复用 composables、Token 配色等铁律) |
|||
|
|||
## 开发规范与踩坑记录 |
|||
|
|||
### 后端:数据库变更必须同步到启动初始化 |
|||
**规则**: 任何涉及表结构(建表、增删改列、索引)、初始数据(种子数据、默认配置)的变更,**必须同步记录到以下两个文件**,否则新部署环境或重建数据库时会丢失变更: |
|||
|
|||
| 文件 | 作用 | 需要同步的内容 | |
|||
|------|------|----------------| |
|||
| `DatabaseInitConfig.java` | 应用启动自动执行(主力) | 建表 SQL(`CREATE TABLE IF NOT EXISTS`)、列迁移(`ALTER TABLE ADD COLUMN IF NOT EXISTS`)、种子数据(`ON CONFLICT ... DO UPDATE`) | |
|||
| `init-database.sql` | 手动备用脚本 | 与 `DatabaseInitConfig` 保持一致的完整建表 + 索引 + 种子数据 | |
|||
|
|||
**流程**: 改完 Entity / Mapper 后,先在 `DatabaseInitConfig.init()` 中添加对应的初始化逻辑(幂等检查 + safeInit 包裹),再同步更新 `init-database.sql`。 |
|||
|
|||
**反面案例**: `knowledge_document.content_hash` 列仅在 `DatabaseInitConfig` 中迁移,未同步到 `knowledge-base.sql`,导致该脚本变为过时版本。 |
|||
|
|||
### 后端:vector_store 物理删除(禁止在原生 SQL 中引用 is_delete) |
|||
|
|||
**规则**: `vector_store` 表由 Spring AI `PgVectorStore` 以 `initializeSchema(true)` 自动建表,默认只有 `id / content / metadata(json) / embedding` 四列;`DatabaseInitConfig` 仅额外补了 `content_tsvector`、`create_time` 两列。因此 **vector_store 只保证有六列**:`id / content / metadata / embedding / content_tsvector / create_time`,**没有 `is_delete`、没有 `update_time`**。 |
|||
|
|||
- vector_store 的向量删除走**物理删除**(`pgVectorVectorStore.delete(ids)`),不存在逻辑删除语义,原生 SQL 中**禁止**写 `AND is_delete = false`。 |
|||
- 凡是引用业务表(`knowledge_document` 等 MyBatis Plus 逻辑删除表)的 SQL 里常用的 `is_delete = false` 过滤,**不得照搬到 vector_store**。 |
|||
- 对 vector_store 写原生 SQL 前,先确认引用的列在上述六列白名单内。 |
|||
|
|||
**反面案例**: `HybridSearchService.keywordSearch()` 从 `knowledge_document` 表查询照搬了 `AND is_delete = false`,但 vector_store 无此列,PostgreSQL 报 `column "is_delete" does not exist`(SQLState 42703),被 Spring 统一翻译为 `BadSqlGrammarException`,前端显示「搜索失败: bad SQL grammar」,且仅关键词/混合模式触发(向量模式走 Spring AI similaritySearch 不执行该 SQL)。 |
|||
|
|||
### 后端:数据库初始化一律幂等自愈 |
|||
|
|||
**规则**: 新增列/索引/触发器的 init 方法(`DatabaseInitConfig` 内)**不要**「列已存在就 return」,应使用幂等 SQL 组合自愈:`ADD COLUMN IF NOT EXISTS` + 回填(`UPDATE ... WHERE xxx IS NULL`)+ `CREATE INDEX IF NOT EXISTS` + `CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER`。避免半途失败或对象被手动删除后留下静默缺口(如新文档 `content_tsvector` 恒为 NULL、检索静默返回空)。 |
|||
|
|||
### 后端:Long ID 序列化为字符串 |
|||
**规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。 |
|||
|
|||
| 场景 | 做法 | |
|||
|------|------| |
|||
| Entity 字段 | `@JsonSerialize(using = ToStringSerializer.class)` + `@TableId(type = IdType.ASSIGN_ID)` | |
|||
| 原生 JDBC SQL | `SELECT u.id::TEXT AS id`(CAST 为 TEXT),不能直接 `SELECT u.id` | |
|||
| Controller Map 返回 | `Map.of("id", user.getId().toString())` | |
|||
|
|||
**反面案例**: 用户管理 `listUsers` 使用原生 SQL 未 CAST,导致新建的用户(雪花 ID 约 19 位)在前端编辑时 ID 被截断,报"用户不存在"。 |
|||
|
|||
### 后端:敏感字段脱敏 |
|||
**规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。 |
|||
|
|||
### 后端:列表排序字段白名单(防 SQL 注入) |
|||
**规则**: 任何用户可控的列表排序参数,必须经 `com.wok.supportbot.common.SortUtils` 的白名单映射解析,**严禁**将前端传入的 `sortField` 直接拼接进 `ORDER BY`。 |
|||
|
|||
| 场景 | 做法 | |
|||
|------|------| |
|||
| MyBatis Plus `QueryWrapper` | `String col = SortUtils.resolveColumn(sortField, 白名单Map, "create_time");` 再 `orderByAsc/orderByDesc(col)` | |
|||
| `LambdaQueryWrapper` | 其 `orderByAsc/orderByDesc` 只接受 `SFunction`、不接受字符串列名,需改用 `QueryWrapper` 做排序 | |
|||
| JdbcTemplate 原生 SQL | 同样用 `resolveColumn` 得到安全列名后拼 `ORDER BY col ASC/DESC` | |
|||
| `DISTINCT ON` 查询 | 排序需将原查询包成子查询,在外层按白名单列排序(见 `MessageFeedbackService.listConversationsByFeedback`) | |
|||
|
|||
白名单用 `Map.of(colKey, 列名)` 定义;`resolveColumn` 内部先判空(避免 `Map.of().get(null)` 抛 NPE),未知字段回退默认列(通常 `create_time`);`sortOrder` 仅 `asc` 视为升序,其余一律降序。 |
|||
|
|||
**反面案例**: 早期 `DocumentService.listDocuments` 手工写白名单,因未判 `sortField == null` 直接 `Map.of().getOrDefault(sortField, ...)` 触发 NPE,已由 `SortUtils.resolveColumn` 统一规避。 |
|||
|
|||
### 后端:Java 文本块拼 SQL 的边界空格陷阱 |
|||
**规则**: 用 Java 文本块(`"""`)拼 SQL 时,**严禁**让闭合定界符 `"""` 与内容行同尾、或让变量紧跟开启定界符 `"""` 拼接,否则空格/换行会被静默吞掉导致 SQL 粘连。Java 文本块有两个隐蔽行为: |
|||
|
|||
1. **闭合定界符前的尾随空格被剥离**:`ORDER BY """` 中 `BY` 后的空格会丢,`"ORDER BY "` 变成 `"ORDER BY"`。 |
|||
2. **开启定界符后的前导换行被消费**:`+ """` 后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如 `DESC` + `LIMIT` 变成 `DESCLIMIT`)。 |
|||
|
|||
**做法**: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界: |
|||
```java |
|||
// ✅ 正确:显式空格 + 变量,不碰文本块边界 |
|||
""" ... ) t |
|||
""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?"; |
|||
// ❌ 错误:ORDER BY 尾随空格被剥离、orderBy 与 LIMIT 粘连 |
|||
""" ... ) t |
|||
ORDER BY """ + orderBy + """ |
|||
LIMIT ? OFFSET ? |
|||
"""; |
|||
``` |
|||
`""" + whereClause` 这类「闭合定界符独立一行 + whereClause 自带前导空格」的写法是安全的,因为不依赖尾随空格。 |
|||
|
|||
**反面案例**: `MessageFeedbackService.listConversationsByFeedback` 用 `ORDER BY """ + orderBy + """` 拼接,编译后 `ORDER BY ` 尾随空格被剥离、`DESC` 与 `LIMIT` 粘连成 `ORDER BYfeedback_time DESCLIMIT`,PostgreSQL 报 `syntax error`(Spring 翻译为 `BadSqlGrammarException`),前端显示「查询失败:bad SQL grammar」。 |
|||
|
|||
### 前端:弹窗实现统一模式 |
|||
项目中存在两种弹窗模式,**不可混用**: |
|||
|
|||
| 模式 | 实现方式 | 适用 CSS 类 | |
|||
|------|----------|------------| |
|||
| **A: CSS 类切换** | `:class="{ active: xxx.visible }"` | `.modal-overlay`(CSS 定义 `display:none` + `.active { display:flex }`) | |
|||
| **B: 内联样式 + v-if** | `v-if="xxx" style="...display:flex..."` | 无 `.modal-overlay`,用内联样式 | |
|||
|
|||
**禁止**: 将 `.modal-overlay` 与 `v-if` 搭配使用 — `v-if` 控制 DOM 存在性,但不添加 `.active` 类,导致弹窗渲染后被 CSS `display:none` 隐藏,按钮点击无反应。 |
|||
|
|||
### 前端:错误处理显示服务器信息 |
|||
**规则**: `catch` 块必须透传服务器错误信息,禁止吞掉错误只显示泛化提示: |
|||
```javascript |
|||
// ✅ 正确 |
|||
catch (e) { toast(e.message || '操作失败', 'error') } |
|||
// ❌ 错误 — 用户和开发者都无法排查 |
|||
catch (e) { toast('操作失败', 'error') } |
|||
``` |
|||
|
|||
### 前后端:AI 执行链示意图与代码保持同步 |
|||
|
|||
**规则**: 对 `app/`、`rag/`、`advisor/` 包中核心编排类的**结构性变更**(新增/删除管道阶段、调整调用链、引入新组件),**必须同步更新以下两处架构图**,否则示意图与实际代码会不一致: |
|||
|
|||
| 文件 | 内容 | 说明 | |
|||
|------|------|------| |
|||
| `frontend/src/views/PipelineFlow.vue` | Mermaid 流程图 DSL(`GRAPH_DEFINITION` 常量) | 管理后台「AI 执行链」页面的可视化图表 | |
|||
| `AGENTS.md` 中的 ASCII 管道图 | 文本流程描述("统一对话管道"章节) | 供 AI 和开发者快速了解架构 | |
|||
|
|||
**触发条件**(满足任一即需更新): |
|||
- `ChatPipeline.buildRequest()` 的决策分支(意图路由、FAQ、RAG、纯对话路径)发生变更 |
|||
- `RagPipeline.retrieve()` 的检索流程(查询重写策略、检索方式、资料拼装)发生变更 |
|||
- `AssistantApp` 的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor) |
|||
- 新增管道阶段组件(如 `IntentRouter`、`SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件 |
|||
- 组件间调用关系调整(如原来 A→B 改为 A→C→B) |
|||
|
|||
**图表元数据**: `PipelineFlow.vue` 中 DSL 首行有 `%%graph-meta` 注释标记最后更新时间,修改图表时必须更新该日期。 |
|||
|
|||
**反面案例**: commit `527d9e7` 创建的流程图展示了 RAG 子图中 `VECTOR → RRF | KEYWORD → RRF | HYBRID → RRF → Reranker` 的三模式检索流程,但实际 `RagPipeline.similaritySearch()` 仅做纯向量检索,`HybridSearchService`/`RrfFusion`/`RerankerService` 尚未接入主对话流程,导致图表与代码事实不符。 |
|||
|
|||
### 后端:CORS 双轨制(SDK 接口开放 / 管理接口白名单) |
|||
|
|||
**规则**: CORS 配置分两套,**不可整体收紧为单一白名单**(系统可用性第一): |
|||
|
|||
| 路径 | CORS 策略 | 原因 | |
|||
|------|----------|------| |
|||
| SDK 第三方接入接口:`/ai/**`、`/category/tree`、`/category/list`、`/feedback`、`/attachment/upload` | `allowedOriginPatterns("*")` 开放跨域 | 第三方域名动态未知,用 Bearer Token 鉴权,不依赖来源白名单 | |
|||
| 管理后台接口:其余 `/**` | `allowed-origins` 白名单 | 用 httpOnly refresh cookie,需白名单防跨域 CSRF | |
|||
|
|||
配置有两处,**必须同步修改**:`SecurityConfig.corsConfigurationSource()`(Security 链 CorsFilter)与 `CorsConfig.addCorsMappings()`(Spring MVC 层 CorsInterceptor)。两者均按注册顺序匹配,**先注册精确的 SDK 路径,再注册兜底的 `/**`**。 |
|||
|
|||
**反面案例**: commit `9338fcb` 为配合 refresh token 迁移 httpOnly Cookie,把 CORS 从 `allowedOriginPatterns("*")` 整体收紧为白名单,引发两个回归:(1) 同源静态资源 `<script crossorigin>`/`<link crossorigin>` 带 `Origin` 头、白名单缺线上域名 → 首页空白 + assets 403;(2) SDK 第三方跨域、第三方域名不在白名单 → 接口 403。教训:改 CORS 必须同时考虑「同源静态资源的 crossorigin 属性」和「SDK 第三方跨域」两个场景。 |
|||
|
|||
**部署提醒**: 更换前端域名/端口时,必须同步把新域名加入 `application-prod.yml` 的 `app.cors.allowed-origins`,否则同源静态资源会 403。 |
|||
|
|||
## API 路由约定 |
|||
|
|||
- AI 对话: `/ai/*`(`AiController`) |
|||
- 模型配置: `/model-config/*`(`AiModelConfigController`) |
|||
- 文档上传: `/upload/*`(`DocumentController`) |
|||
- 文档管理: `/document/*`(`DocumentController`) |
|||
- 批量操作: `/document/batch/*`(`DocumentController`,用 POST 避免 DELETE+RequestBody 路径冲突) |
|||
- 分类管理: `/category/*`(`DocumentController`) |
|||
- 消息反馈: `/feedback/*`(`MessageFeedbackController`) |
|||
- 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`) |
|||
- FAQ 管理: `/faq/*`(`FaqController`) |
|||
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取) |
|||
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色) |
|||
- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色) |
|||
- 执行链节点开关: `/pipeline-toggle`(`PipelineToggleController`,admin 角色) |
|||
|
|||
### Filter 优先级 |
|||
|
|||
| 优先级 | Filter | 路径 | 说明 | |
|||
|--------|--------|------|------| |
|||
| `HIGHEST + 1` | `SdkAuthFilter` | `/ai/**` | SDK JWT 鉴权 | |
|||
| `HIGHEST + 2` | `ApiKeyAuthFilter` | `/open-api/**` | API Key 鉴权 | |
|||
| SecurityFilterChain 内 | `JwtAuthFilter` | 管理接口 | 管理后台 JWT | |
|||
|
|||
### SDK 鉴权架构 |
|||
|
|||
采用两段式鉴权:客户端后端用 API Key 换取短期 JWT Token → SDK 携带 Token 请求 `/ai/**` → SdkAuthFilter 校验放行。SDK JWT 密钥独立于管理后台 JWT(`jwt.sdk-secret`)。 |
|||
|
|||
- API Key 支持绑定客服角色列表(`role_ids` JSONB 字段),Token 换取时优先返回绑定的角色 |
|||
- 未绑定角色的 API Key 返回所有启用角色(向后兼容) |
|||
- JWT Token 中 `sub` 为 apiKeyId,`rids` 为允许的角色 ID 列表 |
|||
- 第三方系统接入指南详见 `SDK-INTEGRATION.md` |
|||
|
|||
## P0 阶段新增功能 |
|||
|
|||
### 内容安全过滤(P0-004) |
|||
- **DFA 引擎**: `ContentSafetyService` 使用字典树匹配敏感词,`volatile` + copy-on-write 保证线程安全热加载 |
|||
- **ContentSafetyAdvisor**: 实现 `BaseAdvisor`,`getOrder()` 返回 `HIGHEST_PRECEDENCE`(Advisor 链最外层),before 阶段检查用户输入、after 阶段检查 AI 输出 |
|||
- **敏感词级别**: level=1 仅脱敏(MASK),level=2 拦截(BLOCK)返回安全提示 |
|||
- **审计日志**: `content_audit_log` 表记录所有违规事件,不删除 |
|||
- **前端**: `SensitiveWordManager.js` 在系统设置 Tab,支持 CRUD + 批量导入 + 审计日志查看 |
|||
|
|||
### 用户反馈系统(P0-002) |
|||
- **反馈实体**: `MessageFeedback`,按 `message_id` 唯一索引,重复提交覆盖(upsert 语义) |
|||
- **反馈类型**: THUMBS_UP(有帮助)/ THUMBS_DOWN(没帮助),点踩可选原因分类 + 自由文本 |
|||
- **ChatPanel**: 每条 AI 回复下方有 👍/👎 按钮,点击调用 `POST /feedback` |
|||
- **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存 |
|||
- **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息 |
|||
|
|||
### 意图识别 + FAQ 精准匹配(P0-003) |
|||
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter<IntentResult>` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG |
|||
- **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85) |
|||
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算 |
|||
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler) |
|||
- **前端**: `FaqManager.js` 在知识库文档管理 Tab,支持 CRUD + 批量 JSON 导入/导出 + 启用/禁用 |
|||
|
|||
### 混合检索 + 重排序引擎(P0-001) |
|||
- **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容 |
|||
- **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合 |
|||
- **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60 |
|||
- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置;HTTP 由 `RestClient` + `JdkClientHttpRequestFactory` 显式设置 connect/read 超时(各 3s,原 `RestTemplate` 无任何超时),超时/异常自动 fallback 到 RRF 原始排序 |
|||
- **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,向量元数据同步留后续 |
|||
- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立 |
|||
- `CompressionQueryRewriter`: 当前传入空历史列表 |
|||
- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 |
|||
- `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警** |
|||
@ -0,0 +1,26 @@ |
|||
import request from './request' |
|||
import type { ApiResponse } from '@/types/api' |
|||
|
|||
/** 链路节点开关(对应后端 PipelineToggleService.ToggleView) */ |
|||
export interface PipelineToggle { |
|||
/** 配置键(system_config.config_key) */ |
|||
key: string |
|||
/** 中文名称 */ |
|||
label: string |
|||
/** 功能说明 */ |
|||
description: string |
|||
/** 关闭后的收益提示 */ |
|||
impact: string |
|||
/** 当前是否启用 */ |
|||
enabled: boolean |
|||
} |
|||
|
|||
/** 查询全部 AI 执行链节点开关 */ |
|||
export function getPipelineToggles(): Promise<ApiResponse<PipelineToggle[]>> { |
|||
return request.get('/pipeline-toggle').then(r => r.data) |
|||
} |
|||
|
|||
/** 批量保存链路节点开关(保存后立即生效,无需重启) */ |
|||
export function updatePipelineToggles(values: Record<string, boolean>): Promise<ApiResponse<PipelineToggle[]>> { |
|||
return request.put('/pipeline-toggle', values).then(r => r.data) |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
package com.wok.supportbot.controller; |
|||
|
|||
import com.wok.supportbot.service.PipelineToggleService; |
|||
import com.wok.supportbot.service.PipelineToggleService.ToggleView; |
|||
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.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* AI 执行链节点开关接口。 |
|||
* <p> |
|||
* 供管理后台「AI 执行链」页面({@code /#/settings/pipeline-flow})读写对话链路上 |
|||
* 可选的性能节点开关(意图路由 / 查询重写 / 多路扩展 / FAQ 语义匹配)。 |
|||
* 开关落库到 {@code system_config} 表,保存后立即生效,无需重启。 |
|||
* <p> |
|||
* 管理接口,需要 admin 角色。路由落在 SecurityFilterChain 既有的 {@code /**} 管理白名单内, |
|||
* 无需额外的 CORS 配置。 |
|||
*/ |
|||
@Slf4j |
|||
@RestController |
|||
@RequestMapping("/pipeline-toggle") |
|||
public class PipelineToggleController { |
|||
|
|||
@Autowired |
|||
private PipelineToggleService pipelineToggleService; |
|||
|
|||
/** |
|||
* 查询全部链路节点开关(含中文名、说明与收益提示)。 |
|||
*/ |
|||
@GetMapping |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> list() { |
|||
try { |
|||
List<ToggleView> toggles = pipelineToggleService.listToggles(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", toggles |
|||
)); |
|||
} catch (Exception e) { |
|||
log.error("查询链路节点开关失败", e); |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量保存链路节点开关(保存后立即生效)。 |
|||
* |
|||
* @param body 请求体 {@code { "pipeline_intent_llm_enabled": false, ... }}, |
|||
* 未出现的键保持原值 |
|||
*/ |
|||
@PutMapping |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> update(@RequestBody Map<String, Boolean> body) { |
|||
try { |
|||
List<ToggleView> toggles = pipelineToggleService.saveAll(body); |
|||
log.info("链路节点开关已更新: {}", body); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "保存成功,已立即生效", |
|||
"data", toggles |
|||
)); |
|||
} catch (Exception e) { |
|||
log.error("保存链路节点开关失败", e); |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "保存失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,214 @@ |
|||
package com.wok.supportbot.service; |
|||
|
|||
import com.wok.supportbot.entity.SystemConfig; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.context.event.ApplicationReadyEvent; |
|||
import org.springframework.context.event.EventListener; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* AI 执行链节点开关服务(对话链路性能开关)。 |
|||
* <p> |
|||
* 一次对话在产出首个 token 之前会串行执行若干「旁路节点」——意图路由 LLM 分类、查询重写、 |
|||
* FAQ 语义匹配,每个节点都要多花一次跨网络调用,累加后直接体现在用户感知的「回复慢」上。 |
|||
* 本服务为这些节点提供可后台配置的开关,按需关闭以压缩首 token 延迟。 |
|||
* <p> |
|||
* 存储复用 {@code system_config} 表(key-value 模式),默认<b>全部开启</b>, |
|||
* 保证升级后行为与改动前完全一致,由管理员主动提速。 |
|||
* <p> |
|||
* <b>为什么需要内存快照</b>:{@link SystemConfigService#getValueByKey} 无缓存、每次直查数据库, |
|||
* 而这些开关位于每请求的 hot path(一次对话需判定 3~4 次)。因此本服务在应用就绪时一次性载入 |
|||
* 全部开关值,构建不可变快照({@code volatile} + 写时整体替换,与 {@code ContentSafetyService} |
|||
* 的 DFA 树同一范式),读取路径零锁零数据库访问。 |
|||
* <p> |
|||
* <b>生效语义</b>:管理后台保存后立即重建快照,下一次对话即生效,无需重启。 |
|||
* 代价是直接修改数据库不会生效——请走管理后台「AI 执行链」页面。 |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
public class PipelineToggleService { |
|||
|
|||
/** 意图路由(IntentRouter LLM 分类)开关 */ |
|||
public static final String KEY_INTENT_LLM = "pipeline_intent_llm_enabled"; |
|||
|
|||
/** 查询重写开关 */ |
|||
public static final String KEY_REWRITE = "pipeline_rewrite_enabled"; |
|||
|
|||
/** MULTI_QUERY 多路查询扩展开关 */ |
|||
public static final String KEY_MULTI_QUERY = "pipeline_multiquery_enabled"; |
|||
|
|||
/** FAQ 语义匹配(三级匹配的第三级)开关 */ |
|||
public static final String KEY_FAQ_SEMANTIC = "pipeline_faq_semantic_enabled"; |
|||
|
|||
/** |
|||
* 开关元数据定义。后端为单一事实来源,前端不硬编码名称与说明。 |
|||
* |
|||
* @param key 配置键({@code system_config.config_key}) |
|||
* @param label 中文名称(管理后台「AI 执行链」页面展示) |
|||
* @param description 功能说明,同时作为 {@code system_config.description} 落库 |
|||
* @param impact 关闭后的收益提示(管理后台展示,帮助管理员权衡) |
|||
* @param defaultValue 配置项缺失时的兜底默认值(true = 保持历史行为) |
|||
*/ |
|||
public record ToggleDefinition(String key, String label, String description, String impact, boolean defaultValue) { |
|||
} |
|||
|
|||
/** 全部可配置的链路节点开关(顺序即管理后台展示顺序,按耗时收益从高到低排列) */ |
|||
private static final List<ToggleDefinition> DEFINITIONS = List.of( |
|||
new ToggleDefinition(KEY_INTENT_LLM, "意图路由(LLM 分类)", |
|||
"是否使用 IntentRouter 做 LLM 意图分类(FAQ / RAG / CHITCHAT)", |
|||
"关闭后省 1 次 LLM 调用;寒暄词快速路径(本地列表匹配、零开销)仍保留," |
|||
+ "但不再能按意图跳过知识库检索", |
|||
true), |
|||
new ToggleDefinition(KEY_REWRITE, "查询重写", |
|||
"是否按请求携带的 rewriteStrategy 对用户问题做重写后再检索", |
|||
"关闭后省 1 次 LLM 调用(REWRITE / TRANSLATION / COMPRESSION 均不再执行)," |
|||
+ "直接用原始问题做向量检索", |
|||
true), |
|||
new ToggleDefinition(KEY_MULTI_QUERY, "多路查询扩展(MULTI_QUERY)", |
|||
"MULTI_QUERY 策略是否把 1 个问题扩展为多路查询并分别检索", |
|||
"关闭后 MULTI_QUERY 请求降级为单路原文检索,省 3 路 embedding;" |
|||
+ "其他重写策略不受影响。注意该策略是前端默认值,关闭收益最大", |
|||
true), |
|||
new ToggleDefinition(KEY_FAQ_SEMANTIC, "FAQ 语义匹配(第三级)", |
|||
"FAQ 三级匹配的第三级(向量余弦相似度)是否启用", |
|||
"关闭后省 0~2 次 embedding 调用,FAQ 仅保留精确匹配与关键词匹配;" |
|||
+ "近义问法(如「怎么退款」与「如何申请退货」)可能匹配不到", |
|||
true) |
|||
); |
|||
|
|||
@Autowired |
|||
private SystemConfigService systemConfigService; |
|||
|
|||
/** 开关快照:key → 是否启用。不可变 Map,写入时整体替换(volatile 保证可见性) */ |
|||
private volatile Map<String, Boolean> snapshot = Collections.emptyMap(); |
|||
|
|||
/** |
|||
* 应用就绪后载入开关快照。 |
|||
* <p> |
|||
* 选用 {@link ApplicationReadyEvent} 而非 {@code @PostConstruct}:建表由 |
|||
* {@code DatabaseInitConfig} 的 {@code @PostConstruct} 完成,两个 {@code @PostConstruct} |
|||
* 的先后顺序不受 Spring 控制,过早读取会因表不存在而失败。就绪前的调用走 |
|||
* {@link #defaultOf} 兜底(默认 true,与历史行为一致)。 |
|||
*/ |
|||
@EventListener(ApplicationReadyEvent.class) |
|||
public void loadOnReady() { |
|||
reload(); |
|||
} |
|||
|
|||
/** |
|||
* 重新载入开关快照并整体替换(写时重建,读取路径无锁)。 |
|||
* 读取失败时保留原快照并告警,不影响对话链路可用性。 |
|||
*/ |
|||
public void reload() { |
|||
try { |
|||
// 一次性取全表,避免逐 key 查库(4 次 → 1 次) |
|||
Map<String, String> rawValues = new HashMap<>(); |
|||
for (SystemConfig config : systemConfigService.listAll()) { |
|||
rawValues.put(config.getConfigKey(), config.getConfigValue()); |
|||
} |
|||
|
|||
Map<String, Boolean> loaded = new HashMap<>(DEFINITIONS.size()); |
|||
for (ToggleDefinition def : DEFINITIONS) { |
|||
String value = rawValues.get(def.key()); |
|||
loaded.put(def.key(), value == null ? def.defaultValue() : parseBoolean(value)); |
|||
} |
|||
this.snapshot = Collections.unmodifiableMap(loaded); |
|||
log.info("AI 执行链节点开关已加载: {}", this.snapshot); |
|||
} catch (Exception e) { |
|||
log.warn("AI 执行链节点开关加载失败,沿用兜底默认值(全部开启): {}", e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 判断指定开关是否启用;配置项缺失或应用尚未就绪时回退到定义中的默认值。 |
|||
* |
|||
* @param key 配置键 |
|||
*/ |
|||
public boolean isEnabled(String key) { |
|||
Boolean enabled = snapshot.get(key); |
|||
return enabled != null ? enabled : defaultOf(key); |
|||
} |
|||
|
|||
/** 意图路由开关:关闭后跳过 IntentRouter 的 LLM 分类(寒暄词快速路径保留) */ |
|||
public boolean isIntentLlmEnabled() { |
|||
return isEnabled(KEY_INTENT_LLM); |
|||
} |
|||
|
|||
/** 查询重写开关:关闭后忽略请求携带的策略,直接用原始问题检索 */ |
|||
public boolean isRewriteEnabled() { |
|||
return isEnabled(KEY_REWRITE); |
|||
} |
|||
|
|||
/** 多路扩展开关:关闭后 MULTI_QUERY 请求降级为单路原文检索 */ |
|||
public boolean isMultiQueryEnabled() { |
|||
return isEnabled(KEY_MULTI_QUERY); |
|||
} |
|||
|
|||
/** FAQ 语义匹配开关:关闭后 FAQ 仅保留精确匹配与关键词匹配 */ |
|||
public boolean isFaqSemanticEnabled() { |
|||
return isEnabled(KEY_FAQ_SEMANTIC); |
|||
} |
|||
|
|||
/** |
|||
* 开关清单(含中文名、说明与收益提示),供管理后台「AI 执行链」页面渲染。 |
|||
*/ |
|||
public List<ToggleView> listToggles() { |
|||
return DEFINITIONS.stream() |
|||
.map(def -> new ToggleView(def.key(), def.label(), def.description(), def.impact(), |
|||
isEnabled(def.key()))) |
|||
.toList(); |
|||
} |
|||
|
|||
/** |
|||
* 批量保存开关值并立即生效。 |
|||
* |
|||
* @param values key → 是否启用;未出现在 Map 中的开关保持原值 |
|||
* @return 保存后的完整开关清单 |
|||
*/ |
|||
public List<ToggleView> saveAll(Map<String, Boolean> values) { |
|||
for (ToggleDefinition def : DEFINITIONS) { |
|||
Boolean enabled = values.get(def.key()); |
|||
if (enabled == null) { |
|||
continue; |
|||
} |
|||
systemConfigService.saveOrUpdate(def.key(), String.valueOf(enabled), def.description()); |
|||
} |
|||
reload(); |
|||
return listToggles(); |
|||
} |
|||
|
|||
/** 开关视图对象(管理后台展示用) */ |
|||
public record ToggleView(String key, String label, String description, String impact, boolean enabled) { |
|||
} |
|||
|
|||
/** |
|||
* 全部开关定义。 |
|||
* <p> |
|||
* 供种子数据初始化({@code DatabaseInitConfig})等场景复用,保证配置键、中文说明 |
|||
* 在「服务定义」与「数据库种子」之间是单一事实来源,避免两处文案漂移。 |
|||
*/ |
|||
public static List<ToggleDefinition> definitions() { |
|||
return DEFINITIONS; |
|||
} |
|||
|
|||
/** 宽松布尔解析:兼容 true / 1(大小写不敏感),其余一律视为 false */ |
|||
private static boolean parseBoolean(String raw) { |
|||
String value = raw == null ? "" : raw.trim(); |
|||
return "true".equalsIgnoreCase(value) || "1".equals(value); |
|||
} |
|||
|
|||
/** 配置项缺失时的兜底默认值;未定义的 key 一律按开启处理(不阻塞链路) */ |
|||
private static boolean defaultOf(String key) { |
|||
return DEFINITIONS.stream() |
|||
.filter(def -> def.key().equals(key)) |
|||
.map(ToggleDefinition::defaultValue) |
|||
.findFirst() |
|||
.orElse(true); |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue