From 062ef0cf57d66fe9e7d8eb68a991083f1d90b71f Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Mon, 14 Sep 2026 16:51:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(pipeline):=20=E6=96=B0=E5=A2=9E=20AI=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=93=BE=E8=8A=82=E7=82=B9=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E5=8F=B0=E5=8F=AF=E5=85=B3=E9=97=AD=E9=A6=96?= =?UTF-8?q?=20token=20=E5=89=8D=E7=9A=84=E8=80=97=E6=97=B6=E8=8A=82?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认 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 已同步 --- AGENTS.md | 395 ++++++++++++++++++ CLAUDE.md | 23 +- frontend/src/api/pipeline-toggle.ts | 26 ++ frontend/src/stores/navigation.ts | 2 +- frontend/src/views/PipelineFlow.vue | 353 +++++++++++++++- frontend/src/views/SystemConfigManager.vue | 5 + .../com/wok/supportbot/app/ChatPipeline.java | 33 +- .../supportbot/config/DatabaseInitConfig.java | 12 + .../controller/PipelineToggleController.java | 82 ++++ .../com/wok/supportbot/rag/RagPipeline.java | 17 +- .../supportbot/service/FaqMatchEngine.java | 8 + .../service/PipelineToggleService.java | 214 ++++++++++ src/main/resources/init-database.sql | 19 + src/main/resources/static/sdk/test.html | 2 +- 14 files changed, 1147 insertions(+), 44 deletions(-) create mode 100644 AGENTS.md create mode 100644 frontend/src/api/pipeline-toggle.ts create mode 100644 src/main/java/com/wok/supportbot/controller/PipelineToggleController.java create mode 100644 src/main/java/com/wok/supportbot/service/PipelineToggleService.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70f072f --- /dev/null +++ b/AGENTS.md @@ -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` 形态;废弃的 `Flux` 和 `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 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`` 支持搜索选择 + 自定义输入) + +### 依赖版本 +- 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`,启动时检查 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` 自动按需引入(模板直接写 ``) +- **状态管理**: 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) 同源静态资源 ` @@ -293,10 +532,77 @@ onMounted(() => { gap: 16px; } +/* ===== 链路节点开关 ===== */ +.toggle-panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-bg-subtle); + padding: 16px; +} +.toggle-panel__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} +.toggle-panel__title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} +.toggle-panel__head .desc-text { + margin: 4px 0 0; +} +.toggle-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 12px; + margin-top: 14px; +} +.toggle-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-page); + transition: border-color 0.2s; +} +.toggle-item--off { + border-color: var(--color-warning-border); + background: var(--color-warning-bg); +} +.toggle-item__body { + flex: 1; + min-width: 0; +} +.toggle-item__label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} +.toggle-item__desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); +} +.toggle-item__impact { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-tertiary); +} + .pipeline-legend { display: flex; flex-wrap: wrap; gap: 10px 20px; + margin-top: 16px; margin-bottom: 8px; } .legend-item { @@ -313,6 +619,7 @@ onMounted(() => { flex: none; } .legend-diamond { transform: rotate(45deg); border-radius: 2px; } +.legend-dashed { border-style: dashed !important; } .pipeline-diagram { width: 100%; diff --git a/frontend/src/views/SystemConfigManager.vue b/frontend/src/views/SystemConfigManager.vue index ba796e4..d321529 100644 --- a/frontend/src/views/SystemConfigManager.vue +++ b/frontend/src/views/SystemConfigManager.vue @@ -198,6 +198,11 @@ const CONFIG_PRESETS: Record = { ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(为空则不注入)' }, suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' }, suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' }, + // AI 执行链节点开关(主入口在「AI 执行链」页面,此处登记以保证以开关而非文本框呈现) + pipeline_intent_llm_enabled: { type: 'boolean', label: '意图路由 LLM 分类', desc: '关闭后省 1 次 LLM 调用(寒暄词快速路径保留)' }, + pipeline_rewrite_enabled: { type: 'boolean', label: '查询重写', desc: '关闭后忽略重写策略,直接用原始问题检索' }, + pipeline_multiquery_enabled: { type: 'boolean', label: '多路查询扩展', desc: '关闭后 MULTI_QUERY 降级为单路检索,省 3 路 embedding' }, + pipeline_faq_semantic_enabled: { type: 'boolean', label: 'FAQ 语义匹配', desc: '关闭后 FAQ 仅保留精确匹配与关键词匹配' }, } const TYPE_META: Record = { diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java index 2e02a05..bc40c59 100644 --- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java +++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java @@ -4,6 +4,7 @@ import com.wok.supportbot.rag.RagContext; import com.wok.supportbot.rag.RagPipeline; import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import com.wok.supportbot.service.IntentRouter; +import com.wok.supportbot.service.PipelineToggleService; import com.wok.supportbot.service.SystemConfigService; import com.wok.supportbot.service.RagHitLogService; import jakarta.annotation.Resource; @@ -34,6 +35,10 @@ import java.util.Optional; * {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装
* {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG
* {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合
+ *
+ * 性能开关:意图路由的 LLM 分类可由 {@link PipelineToggleService} 的 + * {@code pipeline_intent_llm_enabled} 在管理后台关闭(寒暄词快速路径不受影响), + * 查询重写 / 多路扩展 / FAQ 语义匹配的开关见 {@code RagPipeline} 与 {@code FaqMatchEngine}。
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图 */ @Component @@ -58,6 +63,9 @@ public class ChatPipeline { @Resource private RagHitLogService ragHitLogService; + @Resource + private PipelineToggleService pipelineToggleService; + /** * 编排一次对话请求,产出执行决策。 *

@@ -150,12 +158,21 @@ public class ChatPipeline { /** * 意图路由:先用寒暄词列表做快速路径,未命中再调 IntentRouter 做 LLM 分类。 * 异常时返回 null(调用方默认走 RAG 检索)。 + *

+ * 性能开关:{@code pipeline_intent_llm_enabled} 关闭时跳过 LLM 分类直接返回 null, + * 调用方视为「未知意图」继续走 RAG 检索,省一次 LLM 调用。寒暄词快速路径为纯内存匹配, + * 不受该开关影响,始终保留。 */ private IntentRouter.IntentResult routeIntent(String message) { // 寒暄词快速路径(零 LLM 开销) if (isChitchat(message)) { return new IntentRouter.IntentResult("CHITCHAT", 1.0); } + // 后台已关闭意图识别 LLM 分类:跳过细粒度意图判断,直接走 RAG 检索 + if (!pipelineToggleService.isIntentLlmEnabled()) { + log.debug("意图识别 LLM 分类已被后台关闭,跳过意图路由直接走 RAG 检索"); + return null; + } try { return intentRouter.route(message); } catch (Exception e) { @@ -164,22 +181,6 @@ public class ChatPipeline { } } - /** - * 判断是否跳过 KB 检索(保留旧方法签名供 retrieveSources 等使用)。 - */ - private boolean shouldBypassRag(String message) { - if (isChitchat(message)) { - return true; - } - try { - IntentRouter.IntentResult intent = intentRouter.route(message); - return "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD; - } catch (Exception e) { - log.debug("意图路由异常,沿用 RAG: {}", e.getMessage()); - } - return false; - } - /** * 寒暄词快速判断:问候/感谢/告别等短消息无知识库检索意图。 * 与原 {@code AiController.shouldBypassKnowledgeRetrieval} 逻辑一致,作为 IntentRouter 的快速路径与兜底。 diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 9e6afe3..a31e1ad 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -1,5 +1,6 @@ package com.wok.supportbot.config; +import com.wok.supportbot.service.PipelineToggleService; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -1463,6 +1464,17 @@ public class DatabaseInitConfig { VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "llm_trace_retention_days", "30", "LLM 调用追踪记录保留天数(自动清理,默认 30 天)"); + + // AI 执行链节点开关(对话链路性能开关) + // 默认全部 true = 与历史行为完全一致,由管理员在后台「AI 执行链」页面按需关闭以压缩首 token 延迟。 + // 配置键与中文说明复用 PipelineToggleService 的定义,避免两处文案漂移。 + for (PipelineToggleService.ToggleDefinition def : PipelineToggleService.definitions()) { + jdbcTemplate.update(""" + INSERT INTO system_config (config_key, config_value, description) + VALUES (?, ?, ?) + ON CONFLICT (config_key) DO NOTHING + """, def.key(), String.valueOf(def.defaultValue()), def.description()); + } } /** diff --git a/src/main/java/com/wok/supportbot/controller/PipelineToggleController.java b/src/main/java/com/wok/supportbot/controller/PipelineToggleController.java new file mode 100644 index 0000000..7b2edda --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/PipelineToggleController.java @@ -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 执行链节点开关接口。 + *

+ * 供管理后台「AI 执行链」页面({@code /#/settings/pipeline-flow})读写对话链路上 + * 可选的性能节点开关(意图路由 / 查询重写 / 多路扩展 / FAQ 语义匹配)。 + * 开关落库到 {@code system_config} 表,保存后立即生效,无需重启。 + *

+ * 管理接口,需要 admin 角色。路由落在 SecurityFilterChain 既有的 {@code /**} 管理白名单内, + * 无需额外的 CORS 配置。 + */ +@Slf4j +@RestController +@RequestMapping("/pipeline-toggle") +public class PipelineToggleController { + + @Autowired + private PipelineToggleService pipelineToggleService; + + /** + * 查询全部链路节点开关(含中文名、说明与收益提示)。 + */ + @GetMapping + @PreAuthorize("hasRole('admin')") + public ResponseEntity> list() { + try { + List 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> update(@RequestBody Map body) { + try { + List 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() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/rag/RagPipeline.java b/src/main/java/com/wok/supportbot/rag/RagPipeline.java index bd663fa..efa67aa 100644 --- a/src/main/java/com/wok/supportbot/rag/RagPipeline.java +++ b/src/main/java/com/wok/supportbot/rag/RagPipeline.java @@ -9,6 +9,7 @@ import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; +import com.wok.supportbot.service.PipelineToggleService; import com.wok.supportbot.service.RagHitLogService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -84,6 +85,9 @@ public class RagPipeline { @Resource private CategoryFilter categoryFilter; + @Resource + private PipelineToggleService pipelineToggleService; + @Resource private RewriteQueryRewriter rewriteQueryRewriter; @@ -163,16 +167,25 @@ public class RagPipeline { /** * 统一检索 + 拼接资料文本(不含 FAQ 匹配),供 {@link #retrieve} 与 {@link #retrieveDocuments} 复用。 + *

+ * 性能开关({@link PipelineToggleService}): + *

    + *
  • {@code pipeline_multiquery_enabled} 关闭 → MULTI_QUERY 请求降级走单路原文检索,省 3 路 embedding
  • + *
  • {@code pipeline_rewrite_enabled} 关闭 → 忽略请求携带的重写策略,直接用原始问题检索,省 1 次 LLM
  • + *
*/ private RagContext retrieveDocumentsAndContext(ChatContext ctx) { List docs; String rewrittenQuery; - if ("MULTI_QUERY".equalsIgnoreCase(ctx.rewriteStrategy())) { + if (pipelineToggleService.isMultiQueryEnabled() && "MULTI_QUERY".equalsIgnoreCase(ctx.rewriteStrategy())) { // MULTI_QUERY:资料注入 system,user 消息保持原始 message docs = retrieveMultiQueryDocs(ctx.message(), ctx.categoryIds()); rewrittenQuery = ctx.message(); } else { - rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), ctx.rewriteStrategy()); + // 关闭查询重写时传 null 策略,复用 rewriteQuery 既有的「未知策略返回原文」降级分支, + // 该分支已稳定运行,不为此新增代码路径;MULTI_QUERY 被降级时同样落到此分支(策略不被识别 → 原文) + String strategy = pipelineToggleService.isRewriteEnabled() ? ctx.rewriteStrategy() : null; + rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), strategy); docs = similaritySearch(rewrittenQuery, ctx.categoryIds()); } diff --git a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java index a1a4878..cd36876 100644 --- a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java +++ b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java @@ -41,6 +41,9 @@ public class FaqMatchEngine { @Autowired private CategoryFilter categoryFilter; + @Autowired + private PipelineToggleService pipelineToggleService; + /** 向量维度,与 PgVectorStore 保持一致 */ @Value("${knowledge.vector.dimension:1024}") private int vectorDimension; @@ -97,6 +100,11 @@ public class FaqMatchEngine { } // 第三级:语义匹配 + // 性能开关:pipeline_faq_semantic_enabled 关闭时跳过,只保留前两级(精确/关键词,均为本地 SQL,无网络调用) + if (!pipelineToggleService.isFaqSemanticEnabled()) { + log.info("FAQ 语义匹配已被后台关闭,跳过第三级: question={}", trimmedQuestion); + return Optional.empty(); + } Optional semanticResult = semanticMatch(trimmedQuestion, categoryIds); if (semanticResult.isPresent()) { log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore()); diff --git a/src/main/java/com/wok/supportbot/service/PipelineToggleService.java b/src/main/java/com/wok/supportbot/service/PipelineToggleService.java new file mode 100644 index 0000000..00760df --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/PipelineToggleService.java @@ -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 执行链节点开关服务(对话链路性能开关)。 + *

+ * 一次对话在产出首个 token 之前会串行执行若干「旁路节点」——意图路由 LLM 分类、查询重写、 + * FAQ 语义匹配,每个节点都要多花一次跨网络调用,累加后直接体现在用户感知的「回复慢」上。 + * 本服务为这些节点提供可后台配置的开关,按需关闭以压缩首 token 延迟。 + *

+ * 存储复用 {@code system_config} 表(key-value 模式),默认全部开启, + * 保证升级后行为与改动前完全一致,由管理员主动提速。 + *

+ * 为什么需要内存快照:{@link SystemConfigService#getValueByKey} 无缓存、每次直查数据库, + * 而这些开关位于每请求的 hot path(一次对话需判定 3~4 次)。因此本服务在应用就绪时一次性载入 + * 全部开关值,构建不可变快照({@code volatile} + 写时整体替换,与 {@code ContentSafetyService} + * 的 DFA 树同一范式),读取路径零锁零数据库访问。 + *

+ * 生效语义:管理后台保存后立即重建快照,下一次对话即生效,无需重启。 + * 代价是直接修改数据库不会生效——请走管理后台「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 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 snapshot = Collections.emptyMap(); + + /** + * 应用就绪后载入开关快照。 + *

+ * 选用 {@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 rawValues = new HashMap<>(); + for (SystemConfig config : systemConfigService.listAll()) { + rawValues.put(config.getConfigKey(), config.getConfigValue()); + } + + Map 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 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 saveAll(Map 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) { + } + + /** + * 全部开关定义。 + *

+ * 供种子数据初始化({@code DatabaseInitConfig})等场景复用,保证配置键、中文说明 + * 在「服务定义」与「数据库种子」之间是单一事实来源,避免两处文案漂移。 + */ + public static List 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); + } +} diff --git a/src/main/resources/init-database.sql b/src/main/resources/init-database.sql index a54be88..d421487 100644 --- a/src/main/resources/init-database.sql +++ b/src/main/resources/init-database.sql @@ -851,6 +851,25 @@ INSERT INTO system_config (config_key, config_value, description) VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)') ON CONFLICT (config_key) DO NOTHING; +-- AI 执行链节点开关(对话链路性能开关) +-- 默认全部 true = 与历史行为完全一致,由管理员在后台「AI 执行链」页面按需关闭以压缩首 token 延迟。 +-- 配置键与中文说明与 PipelineToggleService.DEFINITIONS 保持一致(该处为单一事实来源)。 +INSERT INTO system_config (config_key, config_value, description) +VALUES ('pipeline_intent_llm_enabled', 'true', '是否使用 IntentRouter 做 LLM 意图分类(FAQ / RAG / CHITCHAT)') +ON CONFLICT (config_key) DO NOTHING; + +INSERT INTO system_config (config_key, config_value, description) +VALUES ('pipeline_rewrite_enabled', 'true', '是否按请求携带的 rewriteStrategy 对用户问题做重写后再检索') +ON CONFLICT (config_key) DO NOTHING; + +INSERT INTO system_config (config_key, config_value, description) +VALUES ('pipeline_multiquery_enabled', 'true', 'MULTI_QUERY 策略是否把 1 个问题扩展为多路查询并分别检索') +ON CONFLICT (config_key) DO NOTHING; + +INSERT INTO system_config (config_key, config_value, description) +VALUES ('pipeline_faq_semantic_enabled', 'true', 'FAQ 三级匹配的第三级(向量余弦相似度)是否启用') +ON CONFLICT (config_key) DO NOTHING; + -- ============================================================ -- 表 17-21: 用户认证与登录安全(与 DatabaseInitConfig 同步) -- ============================================================ diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html index 6bae2f0..14af902 100644 --- a/src/main/resources/static/sdk/test.html +++ b/src/main/resources/static/sdk/test.html @@ -11,8 +11,8 @@ - +