From 76ac4bdceafe36cfdc3d30cf1d6f15be447a5857 Mon Sep 17 00:00:00 2001 From: Pico-liujianhua <3177227893@qq.com> Date: Thu, 4 Jun 2026 16:38:35 +0800 Subject: [PATCH] chore: upload current project snapshot --- .gitignore | 6 + bootstrap/pom.xml | 6 + bootstrap/resources/.env.example | 19 + .../ai/ragent/core/chunk/ChunkingOptions.java | 2 +- .../ai/ragent/rag/aop/ChatQueueLimiter.java | 1 + .../ai/ragent/rag/aop/RagTraceAspect.java | 2 +- .../impl/RagTraceQueryServiceImpl.java | 6 +- .../user/service/impl/AuthServiceImpl.java | 11 +- .../application-server-middleware.yaml | 24 + debug-login-submit-error.md | 22 + ...76\350\267\257\350\257\246\350\247\243.md" | 1014 ++++++++++ ...00\346\234\257\346\226\207\346\241\243.md" | 1640 +++++++++++++++++ ...66\346\236\204\350\257\246\350\247\243.md" | 913 +++++++++ ...00\346\234\257\346\226\271\346\241\210.md" | 976 ++++++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 606 ++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 659 +++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 939 ++++++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 955 ++++++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 745 ++++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 885 +++++++++ ...76\350\267\257\350\257\246\350\247\243.md" | 775 ++++++++ ...72\345\210\266\350\257\246\350\247\243.md" | 1044 +++++++++++ ...00\346\234\257\346\226\207\346\241\243.md" | 603 ++++++ .../ragent/framework/convention/Result.java | 2 +- 24 files changed, 11850 insertions(+), 5 deletions(-) create mode 100644 bootstrap/resources/.env.example create mode 100644 bootstrap/src/main/resources/application-server-middleware.yaml create mode 100644 debug-login-submit-error.md create mode 100644 "docs/infra-ai-chat\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/infra-ai-model\346\250\241\345\235\227\346\212\200\346\234\257\346\226\207\346\241\243.md" create mode 100644 "docs/infra-ai\346\250\241\345\235\227\346\236\266\346\236\204\350\257\246\350\247\243.md" create mode 100644 "docs/\346\204\217\345\233\276\346\240\221\347\274\223\345\255\230\345\207\273\347\251\277\346\262\273\347\220\206\346\212\200\346\234\257\346\226\271\346\241\210.md" create mode 100644 "docs/\346\204\217\345\233\276\346\240\221\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\346\204\217\345\233\276\350\247\243\346\236\220\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\346\226\207\344\273\266\344\270\212\344\274\240\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\346\226\207\346\241\243\345\210\206\345\235\227\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\347\231\273\345\275\225\346\240\241\351\252\214\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\347\237\245\350\257\206\345\272\223\345\210\233\345\273\272\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\350\256\260\345\277\206\345\212\240\350\275\275\351\223\276\350\267\257\350\257\246\350\247\243.md" create mode 100644 "docs/\351\231\220\346\265\201\346\216\222\351\230\237\346\234\272\345\210\266\350\257\246\350\247\243.md" create mode 100644 "docs/\351\241\271\347\233\256\346\212\200\346\234\257\346\226\207\346\241\243.md" diff --git a/.gitignore b/.gitignore index 3def8ccdf..c86da0b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,9 @@ node_modules/ dist/ .vite/ .env.local + +### Local environment ### +.env +**/.env +!.env.example +!**/.env.example diff --git a/bootstrap/pom.xml b/bootstrap/pom.xml index 381606161..854400a15 100644 --- a/bootstrap/pom.xml +++ b/bootstrap/pom.xml @@ -11,6 +11,12 @@ bootstrap + + + org.springframework.boot + spring-boot-starter-actuator + + com.nageoffer.ai framework diff --git a/bootstrap/resources/.env.example b/bootstrap/resources/.env.example new file mode 100644 index 000000000..eef1e623d --- /dev/null +++ b/bootstrap/resources/.env.example @@ -0,0 +1,19 @@ +TZ=Asia/Shanghai + +RAGENT_SERVER_HOST=127.0.0.1 + +RAGENT_PG_USER=postgres +RAGENT_PG_PASSWORD=change-me +RAGENT_PG_PORT=5432 + +RAGENT_REDIS_PASSWORD=change-me +RAGENT_REDIS_PORT=6379 + +RAGENT_ROCKETMQ_NAMESRV_PORT=9876 + +RAGENT_RUSTFS_PORT=9000 +RAGENT_MILVUS_PORT=19530 + +# Model provider API keys +BAILIAN_API_KEY=change-me +SILICONFLOW_API_KEY=change-me diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingOptions.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingOptions.java index 2c4dcfccb..94e969615 100644 --- a/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingOptions.java +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingOptions.java @@ -26,7 +26,7 @@ * @see FixedSizeOptions 固定大小切分配置 * @see TextBoundaryOptions 文本边界切分配置(结构感知等) */ -public sealed interface ChunkingOptions permits FixedSizeOptions, TextBoundaryOptions { +public sealed interface ChunkingOptions permits FixedSizeOptions, TextBoundaryOptions { // permits 允许实现类 /** * 将配置导出为 Map,用于 API 返回和配置校验 diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatQueueLimiter.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatQueueLimiter.java index 74d85b945..e1905d627 100644 --- a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatQueueLimiter.java +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatQueueLimiter.java @@ -109,6 +109,7 @@ public void subscribeQueueNotify() { public void enqueue(String question, String conversationId, SseEmitter emitter, Runnable onAcquire) { if (!Boolean.TRUE.equals(rateLimitProperties.getGlobalEnabled())) { + // 未开启全局并发限流,直接执行任务 chatEntryExecutor.execute(onAcquire); return; } diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/RagTraceAspect.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/RagTraceAspect.java index d42c5bafa..f587ebbb1 100644 --- a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/RagTraceAspect.java +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/RagTraceAspect.java @@ -46,7 +46,7 @@ @Slf4j @Aspect @Component -@Order(Ordered.HIGHEST_PRECEDENCE + 10) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) // 确保在其他切面之前执行 @RequiredArgsConstructor public class RagTraceAspect { diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/RagTraceQueryServiceImpl.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/RagTraceQueryServiceImpl.java index 6b2c936f2..21b300680 100644 --- a/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/RagTraceQueryServiceImpl.java +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/RagTraceQueryServiceImpl.java @@ -71,7 +71,11 @@ public IPage pageRuns(RagTraceRunPageRequest request) { wrapper.eq(RagTraceRunDO::getStatus, request.getStatus()); } - IPage pageResult = runMapper.selectPage(request, wrapper); + // 使用MyBatis-Plus标准分页构造方式,适配RagTraceRunPageRequest的分页参数结构 + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(request.getCurrent(), request.getSize()); + IPage pageResult = runMapper.selectPage(page, wrapper); + + Map usernameMap = loadUsernameMap(pageResult.getRecords()); return pageResult.convert(run -> toRunVO(run, usernameMap)); } diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java index 4e8d37b79..5ea02a0e6 100644 --- a/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java @@ -27,8 +27,10 @@ import com.nageoffer.ai.ragent.framework.exception.ClientException; import com.nageoffer.ai.ragent.user.service.AuthService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class AuthServiceImpl implements AuthService { @@ -41,10 +43,13 @@ public class AuthServiceImpl implements AuthService { public LoginVO login(LoginRequest requestParam) { String username = requestParam.getUsername(); String password = requestParam.getPassword(); + log.info("[login-debug] login start, username={}", username); if (StrUtil.isBlank(username) || StrUtil.isBlank(password)) { throw new ClientException("用户名或密码不能为空"); } UserDO user = findByUsername(username); + log.info("[login-debug] user loaded, username={}, userExists={}, userId={}, role={}", + username, user != null, user != null ? user.getId() : null, user != null ? user.getRole() : null); if (user == null || !passwordMatches(password, user.getPassword())) { throw new ClientException("用户名或密码错误"); } @@ -52,9 +57,13 @@ public LoginVO login(LoginRequest requestParam) { throw new ClientException("用户信息异常"); } String loginId = user.getId().toString(); + log.info("[login-debug] before StpUtil.login, loginId={}", loginId); StpUtil.login(loginId); + log.info("[login-debug] after StpUtil.login, loginId={}", loginId); + String tokenValue = StpUtil.getTokenValue(); + log.info("[login-debug] token generated, loginId={}, tokenPresent={}", loginId, StrUtil.isNotBlank(tokenValue)); String avatar = StrUtil.isBlank(user.getAvatar()) ? DEFAULT_AVATAR_URL : user.getAvatar(); - return new LoginVO(loginId, user.getRole(), StpUtil.getTokenValue(), avatar); + return new LoginVO(loginId, user.getRole(), tokenValue, avatar); } @Override diff --git a/bootstrap/src/main/resources/application-server-middleware.yaml b/bootstrap/src/main/resources/application-server-middleware.yaml new file mode 100644 index 000000000..3806d5b13 --- /dev/null +++ b/bootstrap/src/main/resources/application-server-middleware.yaml @@ -0,0 +1,24 @@ +spring: + config: + activate: + on-profile: server-middleware + + datasource: + username: ${RAGENT_PG_USER:ljh} + password: ${RAGENT_PG_PASSWORD:ljh123456} + url: jdbc:postgresql://${RAGENT_SERVER_HOST:192.168.30.213}:${RAGENT_PG_PORT:5432}/ragent?client_encoding=UTF8 + + data: + redis: + host: ${RAGENT_SERVER_HOST:192.168.30.213} + port: ${RAGENT_REDIS_PORT:6379} + password: ${RAGENT_REDIS_PASSWORD:ljh123456} + +rocketmq: + name-server: ${RAGENT_SERVER_HOST:192.168.30.213}:${RAGENT_ROCKETMQ_NAMESRV_PORT:9876} + +milvus: + uri: http://${RAGENT_SERVER_HOST:192.168.30.213}:${RAGENT_MILVUS_PORT:19530} + +rustfs: + url: http://${RAGENT_SERVER_HOST:192.168.30.213}:${RAGENT_RUSTFS_PORT:9000} diff --git a/debug-login-submit-error.md b/debug-login-submit-error.md new file mode 100644 index 000000000..0392b4da2 --- /dev/null +++ b/debug-login-submit-error.md @@ -0,0 +1,22 @@ +# [OPEN] Debug Session: login-submit-error + +## Symptom +- Frontend `http://localhost:5173/login` clicks login and shows "系统执行出错". + +## Session +- sessionId: `login-submit-error` +- status: OPEN + +## Hypotheses +1. Frontend login request URL or proxy target is incorrect. +2. Backend login endpoint throws a runtime exception. +3. Frontend request payload does not match backend contract. +4. Frontend response parsing fails after a nominally successful response. + +## Evidence +- Pending runtime reproduction and request/response capture. + +## Next Steps +1. Inspect frontend login page, auth service, and API base configuration. +2. Inspect backend login endpoint and exception path. +3. Reproduce and capture browser network evidence. diff --git "a/docs/infra-ai-chat\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/infra-ai-chat\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..1cd5bc589 --- /dev/null +++ "b/docs/infra-ai-chat\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,1014 @@ +# Ragent infra-ai chat 链路详解 + +## 1. 文档目标 + +本文聚焦 `infra-ai` 模块中的 `chat` 路径,完整解释以下问题: + +- `infra-ai/chat` 在整个项目中的定位是什么 +- 同步聊天链路从 `LLMService.chat()` 到真实 HTTP 请求是如何跑通的 +- 流式聊天链路从 `streamChat()` 到 SSE 解析、首包探测、取消控制是如何设计的 +- 模型路由、候选选择、健康检查、熔断与 fallback 是如何协作的 +- OpenAI 兼容协议是如何被抽象为统一模板的 +- 供应商客户端、HTTP 工具类、异常分类在链路中分别扮演什么角色 +- 这条链路体现了哪些关键工程思想 + +本文覆盖 `infra-ai/chat` 目录及其依赖的 `model`、`config`、`http` 相关类,力求把整个聊天能力链路讲清楚。 + +## 2. 模块定位 + +`infra-ai` 模块不是业务编排层,而是 AI 基础设施层。 + +在 `chat` 路径中,它主要解决四类问题: + +- 给上层业务提供统一的聊天接口,不暴露供应商差异 +- 根据配置和健康状态选择合适的模型 +- 封装同步与流式调用,统一流式输出协议 +- 处理模型错误、首包超时、无内容、取消等底层复杂性 + +因此,`infra-ai/chat` 的本质是一个: + +- 模型聊天统一抽象层 +- 路由执行层 +- 流式协议适配层 + +## 3. 总体结构 + +`chat` 路径下的核心类如下: + +- `LLMService` + - 对上暴露统一聊天接口 +- `RoutingLLMService` + - 聊天总入口,负责路由、fallback、流式首包探测 +- `ChatClient` + - 供应商聊天客户端接口 +- `AbstractOpenAIStyleChatClient` + - OpenAI 兼容协议模板实现 +- `BaiLianChatClient` / `SiliconFlowChatClient` / `OllamaChatClient` + - 具体供应商客户端 +- `StreamCallback` + - 流式输出回调接口 +- `StreamAsyncExecutor` + - 流式异步执行器 +- `StreamCancellationHandle` / `StreamCancellationHandles` + - 流式取消句柄 +- `ProbeStreamBridge` + - 首包探测桥接器 +- `OpenAIStyleSseParser` + - OpenAI 风格流式响应解析器 + +依赖的关键支撑类: + +- `AIModelProperties` + - AI 模型与 provider 配置 +- `ModelSelector` + - 候选模型选择器 +- `ModelRoutingExecutor` + - fallback 路由执行器 +- `ModelHealthStore` + - 模型健康状态和熔断器 +- `ModelTarget` + - 一次实际模型调用的目标描述 +- `ModelUrlResolver` + - 解析最终请求 URL +- `HttpResponseHelper` + - 响应读取与公共校验 +- `ModelClientException` + - 模型客户端异常 + +## 4. 总框图 + +```mermaid +flowchart TD + A["业务层调用 LLMService.chat / streamChat"] --> B["RoutingLLMService"] + B --> C["ModelSelector 选择候选模型"] + C --> D["ModelRoutingExecutor 执行 fallback"] + D --> E["按 provider 找到 ChatClient"] + E --> F["BaiLian / SiliconFlow / Ollama ChatClient"] + F --> G["AbstractOpenAIStyleChatClient 模板方法"] + G --> H["ModelUrlResolver + HttpResponseHelper"] + H --> I["OkHttp 发起同步或流式 HTTP 请求"] + I --> J["同步: extractChatContent"] + I --> K["流式: OpenAIStyleSseParser 解析 SSE"] + K --> L["StreamCallback.onThinking / onContent / onComplete"] + B --> M["流式场景: ProbeStreamBridge 首包探测"] + D --> N["ModelHealthStore 失败计数/熔断/半开恢复"] +``` + +## 5. 顶层接口:`LLMService` + +统一聊天接口定义在: + +- `LLMService` + +它对外提供两类能力: + +- 同步聊天 + - `chat(String prompt)` + - `chat(ChatRequest request)` + - `chat(ChatRequest request, String modelId)` +- 流式聊天 + - `streamChat(String prompt, StreamCallback callback)` + - `streamChat(ChatRequest request, StreamCallback callback)` + +### 5.1 为什么要有 `LLMService` + +它解决的是“业务层不应该直接依赖具体供应商客户端”这个问题。 + +上层业务只需要知道: + +- 我传一个 `ChatRequest` +- 你给我字符串结果,或者流式回调结果 + +至于底层到底是: + +- 百炼 +- SiliconFlow +- Ollama +- 还是以后新增的 OpenAI 风格服务 + +都不应该暴露到业务层。 + +### 5.2 默认方法的意义 + +`chat(String)` 和 `streamChat(String, callback)` 是简化入口。 + +它们只是把单个字符串包装成: + +- `ChatRequest.builder().messages(List.of(ChatMessage.user(prompt))).build()` + +这样做的意义是: + +- 简单场景更方便 +- 复杂场景仍保留完整 `ChatRequest` 能力 + +## 6. 聊天总入口:`RoutingLLMService` + +`RoutingLLMService` 是 `LLMService` 的主实现,也是聊天链路真正的总入口。 + +它承担两个职责: + +- 同步聊天路由 +- 流式聊天路由 + +### 6.1 为什么叫 Routing + +因为它不是简单转发请求,而是先做: + +- 候选模型选择 +- 健康状态检查 +- provider client 解析 +- 失败回退 + +之后才真正把请求交给某个供应商客户端。 + +也就是说: + +- `RoutingLLMService` 是“聊天路由器” +- 不是“聊天客户端” + +## 7. 同步聊天链路 + +同步聊天入口在: + +- `RoutingLLMService.chat(ChatRequest request)` + +核心逻辑非常短: + +1. 根据 `thinking` 选择聊天候选模型 +2. 调用 `ModelRoutingExecutor.executeWithFallback(...)` +3. 根据 `target.provider` 找到对应 `ChatClient` +4. 执行 `client.chat(request, target)` +5. 成功则返回完整字符串 +6. 失败则自动尝试下一个模型 + +### 7.1 同步链路时序图 + +```mermaid +sequenceDiagram + participant Biz as 业务层 + participant LLM as RoutingLLMService + participant Selector as ModelSelector + participant Exec as ModelRoutingExecutor + participant Client as ChatClient + participant Base as AbstractOpenAIStyleChatClient + participant Provider as 模型服务 + + Biz->>LLM: chat(request) + LLM->>Selector: selectChatCandidates(thinking) + Selector-->>LLM: List + LLM->>Exec: executeWithFallback(...) + Exec->>Client: client.chat(request, target) + Client->>Base: doChat(request, target) + Base->>Provider: HTTP POST /chat + Provider-->>Base: JSON 响应 + Base-->>Client: content + Client-->>Exec: content + Exec-->>LLM: content + LLM-->>Biz: 最终回答字符串 +``` + +## 8. 流式聊天链路 + +流式聊天入口在: + +- `RoutingLLMService.streamChat(ChatRequest request, StreamCallback callback)` + +它比同步链路复杂得多,因为除了模型路由,还要解决: + +- 流式首包是否成功 +- 是否有内容 +- 是否需要快速切备用模型 +- 如何取消 +- 如何避免首包前就把脏数据推给下游 + +### 8.1 流式链路核心流程 + +1. 选择候选模型 +2. 检查是否有可用模型 +3. 逐个尝试模型 +4. 为每个模型创建 `ProbeStreamBridge` +5. 调 `client.streamChat(request, bridge, target)` +6. 阻塞等待首包探测结果 +7. 首包成功则提交缓冲并返回取消句柄 +8. 首包失败、超时、无内容则标记失败并切下一个模型 +9. 所有模型都失败则 `callback.onError(...)` + +### 8.2 为什么流式链路不能直接复用同步 fallback + +同步场景里: + +- 一次 `client.chat()` 要么返回完整字符串,要么抛异常 + +流式场景里: + +- 请求一旦发出,可能先建立连接,再过很久才首包 +- 也可能启动成功但一直没有内容 +- 还可能只收到一半就断流 + +因此,流式聊天必须引入: + +- 首包探测 +- 缓冲提交 +- 取消句柄 +- 无内容判断 + +这也是 `RoutingLLMService.streamChat()` 单独实现而不直接走 `executeWithFallback()` 的根本原因。 + +## 9. 模型候选选择:`ModelSelector` + +无论同步还是流式,第一步都会调用: + +- `ModelSelector.selectChatCandidates(deepThinking)` + +### 9.1 `ModelSelector` 做了什么 + +它会: + +- 读取 `AIModelProperties.chat` +- 根据 `deepThinking` 决定优先模型 +- 过滤掉禁用模型 +- 如果是思考模式,过滤掉不支持 `supportsThinking` 的模型 +- 按优先级排序 +- 过滤掉已经被熔断的模型 +- 最终构造 `List` + +### 9.2 什么是 `ModelTarget` + +`ModelTarget` 是“实际待调用模型”的统一描述对象。 + +它至少包含: + +- `id` + - 模型目标唯一标识 +- `candidate` + - 候选模型配置 +- `provider` + - provider 基础配置 + +它的意义是: + +- 把一次模型调用所需的配置全部收敛起来 + +后面所有调用都围绕 `ModelTarget` 展开。 + +## 10. 路由执行器:`ModelRoutingExecutor` + +同步聊天真正的 fallback 执行由: + +- `ModelRoutingExecutor.executeWithFallback(...)` + +负责。 + +### 10.1 它的抽象很关键 + +这个方法接收四个参数: + +- `capability` + - 当前能力类型,如 `CHAT` +- `targets` + - 候选模型列表 +- `clientResolver` + - 如何从 `target` 找到具体 client +- `caller` + - 如何用 `client + target` 发起调用 + +这说明: + +- 路由器本身不关心聊天、Embedding、Rerank 的细节 +- 它只关心“候选遍历 + 成功返回 + 失败切换” + +因此它是一个可复用的通用 AI 路由模板。 + +### 10.2 核心逻辑 + +它的执行流程非常直白: + +1. 候选为空直接抛异常 +2. 遍历每个 `target` +3. 通过 `clientResolver` 找 `client` +4. 检查 `healthStore.allowCall(target.id())` +5. 执行 `caller.call(client, target)` +6. 成功则 `markSuccess` 并返回 +7. 失败则 `markFailure` 并继续 +8. 所有候选失败则抛 `RemoteException` + +## 11. 模型健康管理:`ModelHealthStore` + +`ModelHealthStore` 是聊天路由稳定性的关键支撑。 + +它实现了一个轻量级断路器。 + +### 11.1 三种状态 + +- `CLOSED` + - 正常可用 +- `OPEN` + - 熔断打开,不允许调用 +- `HALF_OPEN` + - 试探恢复,只允许单次探活 + +### 11.2 状态转换逻辑 + +- 连续失败次数达到 `failureThreshold` + - 进入 `OPEN` +- `OPEN` 持续到 `openUntil` + - 超时后转 `HALF_OPEN` +- `HALF_OPEN` 成功 + - 回到 `CLOSED` +- `HALF_OPEN` 失败 + - 重新 `OPEN` + +### 11.3 为什么这个设计重要 + +如果没有健康状态: + +- 每次都会优先尝试已经坏掉的模型 +- 每次都会在第一个模型上浪费时间 + +有了 `ModelHealthStore`: + +- 坏模型能临时跳过 +- 故障恢复后又能自动试探恢复 + +这是生产可用性非常关键的一环。 + +## 12. 供应商客户端抽象:`ChatClient` + +`ChatClient` 是供应商聊天客户端统一接口。 + +它定义两个能力: + +- `chat(request, target)` +- `streamChat(request, callback, target)` + +这说明对于任意 provider,只要能实现这两个方法,就能接入整个聊天路由体系。 + +### 12.1 为什么还要有 `provider()` + +`provider()` 返回 provider 标识,例如: + +- `bailian` +- `siliconflow` +- `ollama` + +`RoutingLLMService` 会把所有 `ChatClient` 注入成: + +- `Map clientsByProvider` + +然后通过: + +- `target.candidate().getProvider()` + +找到正确的 client。 + +## 13. 供应商实现:三个具体 ChatClient + +当前主要实现有: + +- `BaiLianChatClient` +- `SiliconFlowChatClient` +- `OllamaChatClient` + +它们都非常薄,只做三件事: + +- 声明自己的 `provider()` +- 同步调用委托给 `doChat()` +- 流式调用委托给 `doStreamChat()` + +### 13.1 为什么实现这么薄 + +因为大部分协议共性被抽到了: + +- `AbstractOpenAIStyleChatClient` + +也就是说,当前这些 provider 都被视为: + +- OpenAI 兼容协议的不同实例 + +这正是模板方法模式的价值。 + +### 13.2 `OllamaChatClient` 的特殊点 + +它覆写了: + +- `requiresApiKey()` + +返回 `false`。 + +这说明基类模板已经考虑到了: + +- 某些 provider 需要鉴权 +- 某些本地服务不需要 API Key + +## 14. OpenAI 兼容模板基类:`AbstractOpenAIStyleChatClient` + +这是 `chat` 路径中最核心的一个类。 + +它把 OpenAI 风格协议下的绝大多数公共逻辑都抽象出来了。 + +### 14.1 同步调用模板:`doChat()` + +同步调用流程如下: + +1. 校验 provider 配置存在 +2. 校验 API Key(如果需要) +3. 构建请求体 `buildRequestBody(request, target, false)` +4. 构建带鉴权头的 OkHttp 请求 +5. 发起同步 HTTP 调用 +6. 如果 HTTP 非 2xx,抛 `ModelClientException` +7. 将响应体解析成 JSON +8. 用 `extractChatContent()` 提取 `choices[0].message.content` + +这条链路说明它遵循的是标准 OpenAI Chat Completions 风格响应格式。 + +### 14.2 流式调用模板:`doStreamChat()` + +流式调用流程如下: + +1. 校验 provider 与 API Key +2. 构建请求体,并设置 `stream=true` +3. 增加 `Accept: text/event-stream` +4. 使用 `streamingHttpClient.newCall(...)` +5. 交给 `StreamAsyncExecutor.submit(...)` 异步执行 +6. 返回 `StreamCancellationHandle` + +真正的流式读取发生在: + +- `doStream(...)` + +### 14.3 请求体构建:`buildRequestBody()` + +这个方法统一构建 OpenAI 风格请求体: + +- `model` +- `stream` +- `messages` +- `temperature` +- `top_p` +- `top_k` +- `max_tokens` + +其中 `messages` 来源于 `ChatRequest.getMessages()`,会被统一转成: + +- `system` +- `user` +- `assistant` + +这一步非常关键,因为它把项目内部的 `ChatMessage` 抽象,转换成了供应商协议需要的标准字段。 + +### 14.4 扩展钩子:`customizeRequestBody()` + +这是模板方法模式里的扩展点。 + +默认实现会在 `thinking=true` 时加入: + +- `enable_thinking=true` + +如果未来某个 provider 有额外私有字段,也可以在子类覆写这里。 + +这意味着: + +- 共性字段统一处理 +- 差异字段通过钩子扩展 + +## 15. 流式读取核心:`doStream()` + +`doStream()` 是流式 HTTP 读循环的核心实现。 + +它的职责是: + +- 执行 OkHttp 调用 +- 逐行读取 SSE 响应 +- 解析每一行事件 +- 调用下游 `StreamCallback` + +### 15.1 流式读取逻辑 + +读取过程大致如下: + +1. `call.execute()` +2. 检查 HTTP 状态码 +3. 获取 `ResponseBody.source()` +4. 循环 `readUtf8Line()` +5. 跳过空行 +6. 调 `OpenAIStyleSseParser.parseLine(...)` +7. 根据结果触发: + - `callback.onThinking(...)` + - `callback.onContent(...)` + - `callback.onComplete()` + +### 15.2 为什么要逐行解析 + +OpenAI 风格的流式响应本质上是: + +- SSE 格式 +- 每一行以 `data:` 开头 +- 最后以 `[DONE]` 结束 + +因此逐行读取是最自然、也最稳妥的做法。 + +### 15.3 取消处理 + +循环条件中会不断检查: + +- `cancelled.get()` + +如果取消: + +- 直接结束读取 +- 不再继续向下回调内容 + +这保证了用户点击“停止生成”后,底层不会继续输出残余内容。 + +### 15.4 为什么 `onComplete()` 只在解析到完成时触发 + +代码中只有在解析结果明确 `completed=true` 时才调用: + +- `callback.onComplete()` + +如果没有完成标记、只是连接异常结束,会抛: + +- `ModelClientException(provider + " 流式响应异常结束", INVALID_RESPONSE, null)` + +这体现了一个很重要的设计: + +- 正常完成和异常断流必须区分 + +## 16. SSE 解析器:`OpenAIStyleSseParser` + +这个类负责把每一行 SSE 文本解析成结构化事件。 + +### 16.1 它支持哪些输入格式 + +它兼容两种 OpenAI 风格字段: + +- `choice.delta.content` +- `choice.message.content` + +并且可选解析: + +- `reasoning_content` + +这意味着它不仅支持普通聊天增量内容,也兼容“思考过程”字段。 + +### 16.2 完成判定 + +它通过两种方式判断完成: + +- 数据行为 `[DONE]` +- `finish_reason` 非空 + +然后返回: + +- `ParsedEvent.completed = true` + +### 16.3 为什么解析器独立成类 + +如果把解析逻辑散在 `doStream()` 里: + +- 代码会很臃肿 +- 不利于协议适配和扩展 + +独立出来后: + +- 协议层更清晰 +- 流式读取和流式解析职责分离 + +## 17. 流式回调接口:`StreamCallback` + +流式输出统一通过 `StreamCallback` 向上游传递。 + +它定义四个事件: + +- `onContent(String content)` +- `onThinking(String content)` +- `onComplete()` +- `onError(Throwable error)` + +### 17.1 为什么不能直接把 HTTP 输出给业务层 + +因为业务层不应该关心: + +- SSE 行格式 +- OkHttp +- `[DONE]` +- `delta/message` 字段 + +业务层更关心的是: + +- 来了一段正文 +- 来了一段思考内容 +- 正常结束了 +- 出错了 + +`StreamCallback` 就是这个“协议语义翻译层”。 + +## 18. 流式异步执行器:`StreamAsyncExecutor` + +流式请求不是阻塞当前线程去读完整个响应,而是交给线程池异步执行。 + +`StreamAsyncExecutor.submit(...)` 负责: + +- 创建 `cancelled` 标志位 +- 将流式任务提交到 `Executor` +- 如果线程池拒绝执行,立刻 `call.cancel()` 并回调错误 +- 返回 `StreamCancellationHandle` + +### 18.1 为什么需要单独的异步执行器 + +它把下面这些逻辑统一抽出来了: + +- 线程池提交 +- 提交失败兜底 +- 取消句柄构造 + +避免这些样板代码在每个 provider client 里重复出现。 + +## 19. 流式取消机制:`StreamCancellationHandle` + +流式调用返回的不是字符串,而是: + +- `StreamCancellationHandle` + +它对上暴露一个很简单的能力: + +- `cancel()` + +### 19.1 具体实现:`StreamCancellationHandles.fromOkHttp(...)` + +实际取消句柄会同时做两件事: + +- `cancelled.set(true)` +- `call.cancel()` + +并通过 `AtomicBoolean once` 保证: + +- 多次取消是幂等的 + +### 19.2 为什么取消要双层控制 + +只调 `call.cancel()` 不够,因为业务循环里还在判断本地状态; +只改 `cancelled` 也不够,因为底层 HTTP 连接可能还挂着。 + +双层取消能同时覆盖: + +- 本地读取循环 +- 底层网络连接 + +## 20. 首包探测桥接器:`ProbeStreamBridge` + +这是整个 `chat` 路径中非常有工程含量的一部分。 + +它的作用是: + +- 在真正确认流式输出“可用”之前,先缓冲内容 +- 等待首包探测结果 +- 成功后再把缓冲内容统一提交给下游 + +### 20.1 为什么需要首包探测 + +流式请求常见失败场景有: + +- 请求建立成功,但迟迟没有首包 +- 请求完成了,但没有任何内容 +- 请求启动时就报错 + +如果没有首包探测: + +- 上游会过早认为流式调用成功 +- 甚至可能已经给前端推了一半不完整的状态 + +### 20.2 它怎么工作 + +`ProbeStreamBridge` 本身实现了 `StreamCallback`。 + +它在内部维护: + +- `probe` + - `CompletableFuture`,记录首包探测结果 +- `buffer` + - 缓冲的回调动作 +- `committed` + - 是否已提交缓冲 + +### 20.3 事件如何影响探测结果 + +- `onContent()` / `onThinking()` + - 认为探测成功 +- `onComplete()` + - 认为无内容完成 +- `onError()` + - 认为探测失败 + +### 20.4 `awaitFirstPacket()` 的意义 + +`RoutingLLMService` 在发起流式调用后,会阻塞等待: + +- `bridge.awaitFirstPacket(timeout)` + +如果结果是: + +- `SUCCESS` + - 提交缓冲,认为当前模型可用 +- `TIMEOUT` + - 切备用模型 +- `NO_CONTENT` + - 切备用模型 +- `ERROR` + - 切备用模型 + +这样实现了: + +- 首包级别的流式 fallback + +这比普通同步 fallback 更适合流式大模型场景。 + +## 21. URL 与 HTTP 公共工具 + +### 21.1 `ModelUrlResolver` + +它负责解析最终请求 URL,优先级是: + +- 候选模型 URL +- 否则 provider baseUrl + capability endpoint + +这使得模型配置支持两种模式: + +- 某个模型单独指定 URL +- 沿用 provider 公共 URL 和端点配置 + +### 21.2 `HttpResponseHelper` + +它统一处理这些公共逻辑: + +- `readBody()` + - 安全读取响应体文本 +- `parseJson()` + - 将响应体解析为 JSON +- `requireProvider()` + - 校验 provider 配置存在 +- `requireApiKey()` + - 校验 API Key 存在 +- `requireModel()` + - 校验模型名存在 + +这些看似琐碎,但非常重要,因为它们把: + +- 配置校验 +- 响应处理 +- 异常语义 + +从主链路中抽离了出来。 + +## 22. 错误处理与异常模型 + +底层 HTTP 和模型调用异常统一使用: + +- `ModelClientException` + +并通过: + +- `ModelClientErrorType` + +做分类,包括: + +- `UNAUTHORIZED` +- `RATE_LIMITED` +- `SERVER_ERROR` +- `CLIENT_ERROR` +- `NETWORK_ERROR` +- `INVALID_RESPONSE` +- `PROVIDER_ERROR` + +### 22.1 为什么这样设计 + +如果所有错误都只是普通 `RuntimeException`: + +- 上层很难知道是: + - 网络波动 + - HTTP 429 + - provider 认证失败 + - 响应结构错误 + +而错误分类后,可以: + +- 更好做日志治理 +- 更好做熔断决策 +- 更好排查问题 + +## 23. thinking 模式是如何传递的 + +`ChatRequest` 中有: + +- `thinking` + +它会在多个层面起作用: + +### 23.1 候选选择层 + +`ModelSelector.selectChatCandidates(deepThinking)` + +只会保留: + +- `supportsThinking = true` + +的模型。 + +### 23.2 请求体层 + +在 `AbstractOpenAIStyleChatClient.customizeRequestBody()` 中: + +- 如果 `thinking=true` +- 默认会加入 `enable_thinking=true` + +### 23.3 流式解析层 + +流式读取时: + +- `isReasoningEnabledForStream(request)` + +决定是否解析: + +- `reasoning_content` + +这说明 thinking 不是单点开关,而是贯穿: + +- 选模型 +- 发请求 +- 解析响应 + +的完整链路。 + +## 24. 这条链路的关键设计思想 + +### 24.1 接口统一,供应商解耦 + +业务层只面对 `LLMService`,不直接依赖任何 provider。 + +### 24.2 配置驱动,而不是硬编码驱动 + +模型候选、provider、优先级、thinking 支持、端点路径全部来自配置。 + +### 24.3 模板方法复用协议共性 + +`AbstractOpenAIStyleChatClient` 把 OpenAI 协议共性抽出来,最大化复用。 + +### 24.4 路由层与 client 层职责分离 + +- 路由层决定“调谁” +- client 层决定“怎么调” + +### 24.5 流式链路重视“可用性验证” + +通过 `ProbeStreamBridge` 做首包探测,而不是只要发起成功就当成功。 + +### 24.6 取消语义完整 + +取消不是简单停前端,而是: + +- 本地状态停止 +- HTTP 连接中断 + +双重保障。 + +### 24.7 错误分类明确 + +底层异常被标准化,有利于 fallback、熔断和排障。 + +## 25. 同步与流式的本质差异 + +### 25.1 同步调用 + +特点: + +- 一次请求 +- 一次结果 +- fallback 简单 + +### 25.2 流式调用 + +特点: + +- 请求与输出持续时间长 +- 需要异步执行 +- 需要取消句柄 +- 需要首包探测 +- 需要区分正常完成和异常断流 + +因此,流式调用的复杂度显著高于同步调用,这也是 `infra-ai/chat` 设计重点所在。 + +## 26. 边界场景与容错 + +### 26.1 没有可用模型 + +同步: + +- `ModelRoutingExecutor` 直接抛 `RemoteException` + +流式: + +- `RoutingLLMService.streamChat()` 直接抛无可用模型错误 + +### 26.2 provider client 缺失 + +如果 `clientsByProvider` 找不到对应 `ChatClient`: + +- 记录 warn +- 跳过当前 target + +### 26.3 HTTP 非 2xx + +会被包装成: + +- `ModelClientException` + +并进入 fallback / 错误处理链路。 + +### 26.4 流式线程池繁忙 + +`StreamAsyncExecutor` 捕获 `RejectedExecutionException` 后: + +- 取消底层 call +- 回调 `onError` +- 返回空操作句柄 + +### 26.5 流式无内容结束 + +如果建立了流但没有首包内容,`ProbeStreamBridge` 会返回: + +- `NO_CONTENT` + +然后路由层切下一个模型。 + +### 26.6 流式解析失败 + +单行解析失败只会: + +- 记录 warn + +不会直接中断整条流,这是一种“局部容错”设计。 + +## 27. 推荐阅读顺序 + +建议按下面顺序阅读源码: + +1. `LLMService` +2. `RoutingLLMService` +3. `ModelSelector` +4. `ModelRoutingExecutor` +5. `ModelHealthStore` +6. `ChatClient` +7. `AbstractOpenAIStyleChatClient` +8. `OpenAIStyleSseParser` +9. `ProbeStreamBridge` +10. `StreamAsyncExecutor` +11. `StreamCancellationHandles` +12. `HttpResponseHelper` +13. `ModelUrlResolver` +14. `BaiLianChatClient / SiliconFlowChatClient / OllamaChatClient` + +这样最容易把“入口 -> 路由 -> 执行 -> 协议 -> 流式 -> 容错”串起来。 + +## 28. 一句话总结 + +`infra-ai/chat` 本质上是一套面向大模型聊天能力的基础设施实现:它通过 `LLMService` 统一对外接口,借助 `RoutingLLMService + ModelSelector + ModelRoutingExecutor + ModelHealthStore` 完成模型候选选择、熔断降级和 fallback,再通过 `AbstractOpenAIStyleChatClient` 抽象 OpenAI 兼容协议下的同步/流式调用细节,并结合 `ProbeStreamBridge`、`OpenAIStyleSseParser`、取消句柄和错误分类机制,构建出一条兼顾统一性、可扩展性和生产稳定性的聊天能力链路。 diff --git "a/docs/infra-ai-model\346\250\241\345\235\227\346\212\200\346\234\257\346\226\207\346\241\243.md" "b/docs/infra-ai-model\346\250\241\345\235\227\346\212\200\346\234\257\346\226\207\346\241\243.md" new file mode 100644 index 000000000..a59c372b8 --- /dev/null +++ "b/docs/infra-ai-model\346\250\241\345\235\227\346\212\200\346\234\257\346\226\207\346\241\243.md" @@ -0,0 +1,1640 @@ +# Ragent `infra-ai/model` 模块技术文档 + +## 1. 文档目标 + +本文聚焦 `infra-ai` 模块中的 `model` 子包,系统解释以下问题: + +- `model` 模块在整个 `Ragent` 架构中的定位是什么 +- 它解决了哪些核心工程问题 +- `ModelSelector`、`ModelRoutingExecutor`、`ModelHealthStore`、`ModelTarget`、`ModelCaller` 分别承担什么职责 +- 模型候选选择、熔断、失败回退、半开恢复的设计思想是什么 +- 模块内部使用了哪些 Java / Spring / 并发 / 函数式编程技术 +- Chat / Embedding / Rerank 三条能力链路如何复用这套路由治理中枢 +- 后续如果要新增模型供应商、新增能力或者调整路由策略,应该从哪里扩展 + +本文不是只解释单个类,而是把 `model` 子包当成一个完整的“模型路由治理中枢”来理解。 + +--- + +## 2. 模块定位 + +`infra-ai/model` 不是业务模型定义层,也不是具体供应商协议适配层,而是整个 AI 基础设施中的 **模型选择与故障治理中枢**。 + +它位于: + +- 上层:`RoutingLLMService` / `RoutingEmbeddingService` / `RoutingRerankService` +- 下层:各类 `ChatClient` / `EmbeddingClient` / `RerankClient` + +中间,负责把“应该调用哪个模型”和“当前哪些模型还能用”这两个问题统一收敛。 + +从职责上看,这个模块主要解决 4 类问题: + +- **候选选择**:从配置中选出当前请求可用的模型 +- **优先级排序**:把默认模型、深度思考模型、优先级配置转成可执行顺序 +- **健康治理**:对连续失败模型进行熔断,避免每次都打到坏模型 +- **失败回退**:当前模型失败时自动切下一个候选模型 + +换句话说,`model` 模块解决的核心问题不是“怎么发 HTTP 请求”,而是: + +- 谁有资格参与调用 +- 谁优先被尝试 +- 谁应该暂时被跳过 +- 当前模型失败后如何平滑切备用模型 + +--- + +## 3. 在整体架构中的位置 + +`infra-ai` 的总体分层大致是: + +- 配置层:`config` +- 能力门面层:`chat` / `embedding` / `rerank` +- 路由治理层:`model` +- 协议 / HTTP 适配层:`http` 和各能力抽象基类 + +其中 `model` 子包正好处于路由治理层。 + +### 3.1 总体位置框图 + +```mermaid +flowchart TD + A["业务层 bootstrap / rag"] --> B["RoutingLLMService"] + A --> C["RoutingEmbeddingService"] + A --> D["RoutingRerankService"] + + B --> E["ModelSelector"] + C --> E + D --> E + + B --> F["ModelRoutingExecutor"] + C --> F + D --> F + + E --> G["AIModelProperties"] + E --> H["ModelHealthStore"] + E --> I["ModelTarget"] + + F --> H + F --> J["ChatClient / EmbeddingClient / RerankClient"] + + J --> K["具体 Provider Client"] + K --> L["HTTP / SSE / JSON 协议适配层"] +``` + +### 3.2 一句话总结位置 + +- `config` 负责“把配置读进来” +- `model` 负责“决定调谁、是否还能调” +- `chat/embedding/rerank` 负责“把业务请求交给路由器” +- provider client 负责“真正发请求” + +因此,`model` 模块本质上是整个 AI 调用链路的 **决策层**。 + +--- + +## 4. 模块文件总览 + +`infra-ai/model` 目录下的核心类如下: + +- [ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) +- [ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) +- [ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) +- [ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) +- [ModelCaller](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelCaller.java) + +紧邻依赖包括: + +- [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) +- [ModelCapability](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/enums/ModelCapability.java) + +直接调用方包括: + +- [RoutingLLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) +- [RoutingEmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) +- [RoutingRerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) + +--- + +## 5. 核心设计思想 + +在进入类级别之前,先理解这个模块背后的设计思想。 + +### 5.1 配置驱动而不是代码硬编码 + +模型候选、默认模型、深度思考模型、供应商、优先级、开关、熔断参数,都来自 [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) 绑定的 `application.yaml` 配置。 + +这样做的价值是: + +- 新增模型不需要改业务代码 +- 变更优先级不需要重新设计类结构 +- 本地 / 测试 / 生产可以用不同候选集 + +### 5.2 路由决策与协议调用分离 + +`model` 模块只负责: + +- 选模型 +- 排顺序 +- 健康判断 +- fallback + +它不负责: + +- HTTP 如何发 +- JSON 怎么解析 +- SSE 如何处理 + +这些都交给具体的 `Client` 和 `http` / `chat` / `embedding` / `rerank` 子包。 + +这就是典型的 **职责分离**。 + +### 5.3 统一治理三类能力 + +Chat、Embedding、Rerank 虽然调用协议不同,但“候选模型选择 -> 熔断判断 -> 失败回退”的治理流程是相同的。 + +因此作者把这部分抽成统一中枢: + +- `ModelSelector` +- `ModelRoutingExecutor` +- `ModelHealthStore` + +从而避免三套重复实现。 + +### 5.4 轻量断路器而非重型框架 + +这里没有引入 Resilience4j、Sentinel 之类的通用熔断框架,而是自己用: + +- `ConcurrentHashMap` +- `compute` +- `AtomicBoolean` +- 状态机 + +实现了一个小而专用的模型健康控制器。 + +这样做的好处是: + +- 逻辑非常贴合“模型路由”场景 +- 状态简单,容易调试 +- 依赖少,侵入小 + +### 5.5 优雅降级优先于一次失败即中断 + +这套模块的默认思想不是“某个模型失败就整个请求失败”,而是: + +- 先看下一个候选能不能成功 +- 只要有一个候选成功,请求就整体成功 + +这体现的是 **高可用优先** 的设计取向。 + +--- + +## 6. 配置模型:`AIModelProperties` + +`model` 模块的所有决策都建立在 [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) 上。 + +它通过: + +```java +@ConfigurationProperties(prefix = "ai") +``` + +把 `application.yaml` 中的 `ai.*` 读取进来。 + +### 6.1 配置结构 + +主要包括: + +- `providers` +- `chat` +- `embedding` +- `rerank` +- `selection` +- `stream` + +### 6.2 关键结构说明 + +#### `ProviderConfig` + +表示某个模型供应商的公共连接信息: + +- `url` +- `apiKey` +- `endpoints` + +适合承载: + +- 百炼 +- SiliconFlow +- Ollama +- 未来其他 OpenAI 风格服务 + +#### `ModelGroup` + +表示某一类能力的模型组,例如: + +- chat 模型组 +- embedding 模型组 +- rerank 模型组 + +内部包括: + +- `defaultModel` +- `deepThinkingModel` +- `candidates` + +#### `ModelCandidate` + +表示单个候选模型,关键字段有: + +- `id` +- `provider` +- `model` +- `url` +- `dimension` +- `priority` +- `enabled` +- `supportsThinking` + +### 6.3 选择策略配置 + +`Selection` 定义了熔断相关参数: + +- `failureThreshold` +- `openDurationMs` + +这两个值决定: + +- 连续失败多少次后进入 OPEN +- 进入 OPEN 后保持多久 + +### 6.4 配置示例 + +实际样例见 [application.yaml:L104-L179](file:///e:/java/workspace/ragent/bootstrap/src/main/resources/application.yaml#L104-L179)。 + +这说明 `model` 模块的决策完全基于配置驱动。 + +--- + +## 7. 类关系图 + +```mermaid +classDiagram + class AIModelProperties { + +Map~String,ProviderConfig~ providers + +ModelGroup chat + +ModelGroup embedding + +ModelGroup rerank + +Selection selection + +Stream stream + } + + class ModelSelector { + -AIModelProperties properties + -ModelHealthStore healthStore + +selectChatCandidates(boolean) + +selectEmbeddingCandidates() + +selectRerankCandidates() + } + + class ModelRoutingExecutor { + -ModelHealthStore healthStore + +executeWithFallback(...) + } + + class ModelHealthStore { + -AIModelProperties properties + -Map~String,ModelHealth~ healthById + +isUnavailable(String) + +allowCall(String) + +markSuccess(String) + +markFailure(String) + } + + class ModelTarget { + +String id + +ModelCandidate candidate + +ProviderConfig provider + } + + class ModelCaller { + <> + +call(client, target) + } + + class RoutingLLMService + class RoutingEmbeddingService + class RoutingRerankService + + AIModelProperties --> ModelSelector + ModelHealthStore --> ModelSelector + ModelHealthStore --> ModelRoutingExecutor + ModelSelector --> ModelTarget + ModelRoutingExecutor --> ModelCaller + RoutingLLMService --> ModelSelector + RoutingLLMService --> ModelRoutingExecutor + RoutingEmbeddingService --> ModelSelector + RoutingEmbeddingService --> ModelRoutingExecutor + RoutingRerankService --> ModelSelector + RoutingRerankService --> ModelRoutingExecutor +``` + +--- + +## 8. `ModelTarget`:运行时模型目标 + +对应源码:[ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) + +### 8.1 它是什么 + +`ModelTarget` 是一个 Java `record`: + +```java +public record ModelTarget( + String id, + AIModelProperties.ModelCandidate candidate, + AIModelProperties.ProviderConfig provider +) { +} +``` + +### 8.2 `record` 语法解释 + +`record` 是 Java 16+ 的轻量数据载体语法,适合表示不可变的数据对象。 + +它相当于自动生成了: + +- 构造器 +- `id() / candidate() / provider()` 访问器 +- `equals/hashCode` +- `toString` + +这类对象非常适合“运行时上下文快照”。 + +### 8.3 为什么需要它 + +直接传 `ModelCandidate` 不够,因为一次真实调用不仅需要候选模型配置,还需要: + +- 解析后的唯一 `id` +- 关联的 provider 配置 + +因此作者把调用时真正需要的信息收敛成 `ModelTarget`。 + +### 8.4 设计价值 + +- 统一了路由执行器的输入 +- 减少多参数传递 +- 把“配置对象”提升为“运行时调用目标” + +--- + +## 9. `ModelCaller`:把调用逻辑当作参数传入 + +对应源码:[ModelCaller](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelCaller.java) + +### 9.1 接口定义 + +```java +@FunctionalInterface +public interface ModelCaller { + T call(C client, ModelTarget target) throws Exception; +} +``` + +### 9.2 语法解释 + +#### `@FunctionalInterface` + +表示这是一个函数式接口,只允许有一个抽象方法,因此可以用 Lambda 表达式实现。 + +#### `` + +这是泛型参数: + +- `C`:客户端类型 +- `T`:返回结果类型 + +这使得它可以被三类能力复用: + +- `ChatClient -> String` +- `EmbeddingClient -> List` +- `RerankClient -> List` + +### 9.3 为什么需要这个接口 + +`ModelRoutingExecutor` 不应该知道“每种模型具体怎么调”,它只应该知道: + +- 候选怎么切 +- 成功失败怎么记 + +因此作者把“真正调用模型”的逻辑用 `ModelCaller` 抽成参数传入。 + +这就是典型的: + +- **策略模式** +- **函数式回调** +- **控制反转** + +--- + +## 10. `ModelSelector`:选择哪些模型有资格被调用 + +对应源码:[ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) + +这是整个模块的第一个核心类。 + +### 10.1 核心职责 + +它负责: + +- 从配置读取候选模型 +- 根据调用场景决定第一优先模型 +- 过滤掉禁用模型 +- 深度思考模式下过滤掉不支持 `thinking` 的模型 +- 根据优先规则排序 +- 结合健康状态剔除当前不可用模型 +- 构建 `ModelTarget` + +### 10.2 对外方法 + +主要有三个入口: + +- `selectChatCandidates(boolean deepThinking)` +- `selectEmbeddingCandidates()` +- `selectRerankCandidates()` + +这说明三种能力走的是同一套选择器,只是参数和模型组不同。 + +### 10.3 Chat 为什么有特殊逻辑 + +Chat 需要处理“深度思考模式”,因此会先走: + +- `resolveFirstChoiceModel(group, deepThinking)` + +逻辑是: + +- 如果当前请求启用了 `deepThinking` + - 优先使用 `deepThinkingModel` +- 否则 + - 使用 `defaultModel` + +这体现了 **能力场景驱动路由**。 + +### 10.4 候选过滤与排序 + +核心逻辑在 [ModelSelector.java:L94-L107](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java#L94-L107): + +```java +List enabled = candidates.stream() + .filter(c -> c != null && !Boolean.FALSE.equals(c.getEnabled())) + .filter(c -> !deepThinking || Boolean.TRUE.equals(c.getSupportsThinking())) + .sorted(Comparator + .comparing((AIModelProperties.ModelCandidate c) -> + !Objects.equals(resolveId(c), firstChoiceModelId)) + .thenComparing(AIModelProperties.ModelCandidate::getPriority, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing(AIModelProperties.ModelCandidate::getId, + Comparator.nullsLast(String::compareTo))) + .collect(Collectors.toList()); +``` + +#### 第一层过滤:启用状态 + +```java +.filter(c -> c != null && !Boolean.FALSE.equals(c.getEnabled())) +``` + +这表示: + +- 显式 `enabled=false` 的模型被排除 +- `enabled=true` 保留 +- `enabled=null` 也保留 + +作者的默认策略是: + +- **只有明确禁用才排除** + +#### 第二层过滤:thinking 能力 + +```java +.filter(c -> !deepThinking || Boolean.TRUE.equals(c.getSupportsThinking())) +``` + +意思是: + +- 普通模式不过滤 +- 深度思考模式下只保留 `supportsThinking=true` + +#### 排序规则 + +排序由 3 层组成: + +1. **首选模型优先** +2. **priority 越小越靠前** +3. **id 字典序兜底** + +第一层写法: + +```java +!Objects.equals(resolveId(c), firstChoiceModelId) +``` + +利用布尔排序把“命中首选模型”的候选排到前面。 + +### 10.5 `resolveId` 的设计 + +源码在 [ModelSelector.java:L141-L148](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java#L141-L148) + +逻辑是: + +- 优先使用配置中的 `id` +- 如果没配 `id` + - 自动生成 `provider::model` + +这样做的目的: + +- 保证每个候选都有一个稳定唯一标识 +- 健康状态记录、日志打印、fallback 追踪都能依赖这个标识 + +### 10.6 `buildAvailableTargets`:从候选配置到运行时目标 + +源码在 [ModelSelector.java:L116-L139](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java#L116-L139) + +这里做了两件非常关键的事: + +- 在选择阶段提前跳过不可用模型 +- 把候选配置组装成 `ModelTarget` + +其中: + +```java +if (healthStore.isUnavailable(modelId)) { + return null; +} +``` + +这说明选择器在“进入执行器之前”就已经做了一次健康过滤。 + +然后: + +```java +AIModelProperties.ProviderConfig provider = providers.get(candidate.getProvider()); +if (provider == null && !ModelProvider.NOOP.matches(candidate.getProvider())) { + log.warn(...); + return null; +} +``` + +这里处理了 provider 配置缺失的情况。 + +### 10.7 设计价值 + +`ModelSelector` 的价值不是简单读配置,而是把: + +- 原始配置 +- 场景约束 +- 健康状态 +- 排序策略 + +综合成一组**真正可执行的候选目标列表**。 + +因此它是整个模块的 **准入层**。 + +--- + +## 11. `ModelHealthStore`:轻量熔断器与健康状态中心 + +对应源码:[ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) + +这是整个模块的第二个核心类。 + +### 11.1 核心职责 + +它负责维护每个模型的健康状态,支持: + +- `CLOSED` +- `OPEN` +- `HALF_OPEN` + +并提供以下能力: + +- 查询当前是否不可用 +- 判断当前调用是否允许进入 +- 记录成功 +- 记录失败 + +### 11.2 内部数据结构 + +```java +private final Map healthById = new ConcurrentHashMap<>(); +``` + +#### 为什么用 `ConcurrentHashMap` + +因为模型调用是并发场景: + +- 多个请求可能同时调用同一个模型 +- 需要线程安全地维护状态 + +#### `ModelHealth` 存了什么 + +内部状态包括: + +- `consecutiveFailures` +- `openUntil` +- `halfOpenInFlight` +- `state` + +这正好覆盖了一个轻量断路器的核心状态。 + +### 11.3 状态机说明 + +```mermaid +stateDiagram-v2 + [*] --> CLOSED + CLOSED --> OPEN: 连续失败达到阈值 + OPEN --> HALF_OPEN: openUntil 到期 + HALF_OPEN --> CLOSED: 探测调用成功 + HALF_OPEN --> OPEN: 探测调用失败 +``` + +### 11.4 `isUnavailable`:选择阶段的粗粒度过滤 + +源码在 [ModelHealthStore.java:L40-L49](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java#L40-L49) + +逻辑是: + +- 如果状态是 `OPEN` 且未到恢复时间 + - 不可用 +- 如果状态是 `HALF_OPEN` 且已经有探测请求在飞 + - 不可用 +- 其他情况 + - 可用 + +这个方法会在 `ModelSelector` 里被用到,用于提前过滤。 + +### 11.5 `allowCall`:执行阶段的精细控制 + +源码在 [ModelHealthStore.java:L52-L83](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java#L52-L83) + +它和 `isUnavailable` 的区别是: + +- `isUnavailable` 偏选择阶段、快速判断 +- `allowCall` 偏执行阶段、状态切换和并发控制 + +#### 为什么还要再做一层 `allowCall` + +因为选择器和执行器之间有时间差,在并发场景下不能只依赖一次筛选。 + +所以这里做了第二道闸门。 + +#### 关键技术:`compute` + +```java +healthById.compute(id, (k, v) -> { ... }) +``` + +`ConcurrentHashMap.compute` 的好处是: + +- 对单个 key 的更新具备原子性 +- 可以在一次操作里安全完成“读旧值 + 算新值 + 写回” + +这比: + +- 先 `get` +- 再判断 +- 再 `put` + +更适合并发控制。 + +#### 关键技术:`AtomicBoolean` + +由于 `compute` 回调里需要向外传递“本次是否允许调用”的结果,所以作者用了: + +```java +AtomicBoolean allowed = new AtomicBoolean(false); +``` + +这是为了在 Lambda 里修改外部变量。 + +因为 Java Lambda 对局部变量有“必须是 effectively final”的限制,不能直接修改普通局部变量。 + +### 11.6 `markSuccess` + +源码在 [ModelHealthStore.java:L85-L99](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java#L85-L99) + +成功后会: + +- 状态重置为 `CLOSED` +- 失败计数归零 +- `openUntil` 清零 +- `halfOpenInFlight=false` + +这表示模型重新恢复健康。 + +### 11.7 `markFailure` + +源码在 [ModelHealthStore.java:L101-L125](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java#L101-L125) + +逻辑分两种: + +#### 普通关闭态失败 + +- `consecutiveFailures++` +- 如果达到阈值 + - 状态切到 `OPEN` + - 设置 `openUntil = now + openDurationMs` + - 失败计数清零 + +#### 半开探测失败 + +- 立即切回 `OPEN` +- 重新进入熔断窗口 +- 探测位复位 + +### 11.8 设计亮点 + +`ModelHealthStore` 的设计亮点在于: + +- 状态很少,逻辑很清晰 +- 直接贴合“模型调用”场景 +- 同时兼顾选择阶段和执行阶段 +- 对半开探测做了并发控制,避免多个线程同时试探同一个坏模型 + +--- + +## 12. `ModelRoutingExecutor`:统一执行 fallback + +对应源码:[ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) + +这是第三个核心类,也是“执行层总控”。 + +### 12.1 核心职责 + +它负责: + +- 遍历候选模型 +- 解析 client +- 二次健康检查 +- 执行实际调用 +- 成功则返回 +- 失败则标记并切下一个模型 +- 所有候选失败则抛统一异常 + +### 12.2 方法签名详解 + +核心方法在 [ModelRoutingExecutor.java:L41-L45](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java#L41-L45) + +```java +public T executeWithFallback( + ModelCapability capability, + List targets, + Function clientResolver, + ModelCaller caller) +``` + +#### `public` + +公共方法,可被其他服务调用。 + +#### `` + +这是泛型方法声明: + +- `C`:客户端类型 +- `T`:返回结果类型 + +它使得这个执行器能同时服务于: + +- Chat +- Embedding +- Rerank + +#### `Function clientResolver` + +Java 标准函数式接口,表示: + +- 输入:`ModelTarget` +- 输出:某种客户端 `C` + +例如: + +- `ModelTarget -> ChatClient` +- `ModelTarget -> EmbeddingClient` +- `ModelTarget -> RerankClient` + +#### `ModelCaller caller` + +自定义函数式接口,表示: + +- 给定 `client` 和 `target` +- 由调用方定义具体怎样调用 +- 最终返回 `T` + +这使执行器不关心具体协议细节。 + +### 12.3 核心执行流程 + +```mermaid +sequenceDiagram + participant S as RoutingService + participant E as ModelRoutingExecutor + participant H as ModelHealthStore + participant C as ClientResolver + participant M as ModelCaller + + S->>E: executeWithFallback(...) + loop 遍历 targets + E->>C: resolve client(target) + alt client 不存在 + E-->>E: continue + else client 存在 + E->>H: allowCall(target.id) + alt 不允许调用 + E-->>E: continue + else 允许调用 + E->>M: call(client, target) + alt 调用成功 + E->>H: markSuccess(target.id) + E-->>S: return response + else 调用失败 + E->>H: markFailure(target.id) + E-->>E: fallback 到下一个 + end + end + end + end + E-->>S: throw RemoteException +``` + +### 12.4 为什么它很重要 + +如果没有这个类,三条能力链路都会写出类似逻辑: + +- 遍历候选 +- 拿 client +- 判断健康 +- try/catch +- 成功返回 +- 失败记账 +- 全部失败抛异常 + +这会导致大量重复代码。 + +作者把它抽成通用执行模板,形成: + +- **统一 fallback 策略** +- **统一健康状态更新** +- **统一错误出口** + +### 12.5 错误处理策略 + +当所有候选都失败后,会抛出: + +```java +throw new RemoteException( + "All " + label + " model candidates failed: " + ..., + last, + BaseErrorCode.REMOTE_ERROR +); +``` + +这意味着: + +- 向上层暴露统一远程异常 +- 同时保留最后一次底层异常作为 cause + +这种设计兼顾了: + +- 业务层统一处理 +- 排障时保留根因 + +--- + +## 13. 两道健康闸门:为什么既有 `isUnavailable` 又有 `allowCall` + +这是整个模块最值得讲清楚的设计点之一。 + +### 13.1 第一层:选择阶段过滤 + +在 `ModelSelector.buildModelTarget()` 中: + +```java +if (healthStore.isUnavailable(modelId)) { + return null; +} +``` + +作用是: + +- 在生成候选列表时,先排除明显不可用模型 + +### 13.2 第二层:执行阶段准入 + +在 `ModelRoutingExecutor.executeWithFallback()` 中: + +```java +if (!healthStore.allowCall(target.id())) { + continue; +} +``` + +作用是: + +- 在真正调用前,再做一次原子判断 + +### 13.3 为什么要两层 + +因为系统是并发的,不能只靠一次静态筛选。 + +典型场景: + +1. 线程 A 选模型时,模型看起来可用 +2. 线程 B 先一步调用失败,把模型打成 `OPEN` +3. 线程 A 如果不二次判断,就会错误地继续调用这个模型 + +因此需要: + +- **选择阶段:减少明显坏模型进入候选** +- **执行阶段:保证最终调用前状态仍然有效** + +这是一种典型的 **双层校验 / 防御式设计**。 + +--- + +## 14. `ModelCapability`:统一能力标签 + +对应源码:[ModelCapability](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/enums/ModelCapability.java) + +定义了 3 种能力: + +- `CHAT` +- `EMBEDDING` +- `RERANK` + +它的作用不只是枚举,更重要的是: + +- 统一日志标签 +- 统一 URL endpoint key 推导 +- 统一错误文案 + +例如: + +- `ModelCapability.CHAT.getDisplayName()` 用于日志 +- `capability.name().toLowerCase()` 被 [ModelUrlResolver](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelUrlResolver.java) 用于解析 provider endpoint + +这体现了一个细节设计: + +- 用枚举统一“能力语义” +- 减少魔法字符串分散在各处 + +--- + +## 15. 三条主调用链如何复用 `model` 模块 + +### 15.1 Chat 路由链 + +对应 [RoutingLLMService.java](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) + +同步聊天入口: + +```java +return executor.executeWithFallback( + ModelCapability.CHAT, + selector.selectChatCandidates(Boolean.TRUE.equals(request.getThinking())), + target -> clientsByProvider.get(target.candidate().getProvider()), + (client, target) -> client.chat(request, target) +); +``` + +可以看到: + +- 选择器负责拿候选 +- 执行器负责 fallback +- `clientResolver` 负责按 provider 找到 `ChatClient` +- `caller` 负责真正 `chat(...)` + +#### 流式聊天为什么没完全复用 + +`streamChat()` 场景因为需要: + +- 首包探测 +- 流式无内容判断 +- 取消句柄 + +所以没有直接复用同步版 `executeWithFallback()` 的整套实现,而是在 [RoutingLLMService.java:L98-L218](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java#L98-L218) 中自定义了流式版本的 fallback 逻辑。 + +但它仍然复用了: + +- `ModelSelector` +- `ModelHealthStore` + +这说明作者在“复用”和“流式特殊性”之间做了平衡。 + +### 15.2 Embedding 路由链 + +对应 [RoutingEmbeddingService.java](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) + +单文本和批量文本都复用了同一套执行器: + +- `embed(text)` +- `embedBatch(texts)` +- 以及指定 `modelId` 的定向调用 + +这是泛型执行器的典型收益。 + +### 15.3 Rerank 路由链 + +对应 [RoutingRerankService.java](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) + +它和 Embedding 更像,直接复用统一 fallback 执行器。 + +### 15.4 统一复用框图 + +```mermaid +flowchart LR + A["RoutingLLMService"] --> B["ModelSelector"] + A --> C["ModelRoutingExecutor"] + + D["RoutingEmbeddingService"] --> B + D --> C + + E["RoutingRerankService"] --> B + E --> C + + C --> F["ModelHealthStore"] + B --> F +``` + +--- + +## 16. 与 HTTP / Provider 适配层的边界 + +虽然本文聚焦 `model` 模块,但要理解它,必须看清它和协议层的边界。 + +### 16.1 `model` 不负责 URL 拼接 + +URL 解析由 [ModelUrlResolver](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelUrlResolver.java) 完成。 + +它的优先级是: + +- 候选模型自定义 `url` +- 否则使用 provider `url + endpoint` + +### 16.2 `model` 不负责 HTTP 错误分类 + +HTTP 失败类型统一由 [ModelClientErrorType](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelClientErrorType.java) 和相关异常类处理。 + +### 16.3 `model` 只关心调用是否成功 + +不管底层是: + +- 401 +- 429 +- 500 +- 网络超时 +- JSON 解析失败 + +最终到了 `ModelRoutingExecutor` 这一层,都统一表现为: + +- 这次模型调用成功 +- 或者失败并触发 fallback + +这是一种非常清晰的层次边界。 + +--- + +## 17. 关键技术点详解 + +这一节专门解释模块里涉及到的技术。 + +### 17.1 Spring 组件化 + +核心类都使用: + +- `@Component` +- `@Service` +- `@Configuration` + +这意味着: + +- 由 Spring 容器统一管理生命周期 +- 通过构造器注入依赖 +- 业务方只需注入门面服务,不需要手动组装 + +### 17.2 Lombok + +常见注解: + +- `@RequiredArgsConstructor` +- `@Slf4j` +- `@Data` + +它们减少了样板代码: + +- 自动生成构造器 +- 自动生成 logger +- 自动生成 getter/setter + +### 17.3 Java Stream + +在 `ModelSelector` 中大量使用: + +- `stream()` +- `filter()` +- `sorted()` +- `collect()` + +适合表达“候选筛选 + 排序”这一类集合变换逻辑。 + +### 17.4 Comparator 链式排序 + +`Comparator.comparing(...).thenComparing(...)` + +用于表达多层排序规则: + +- 首选模型 +- priority +- id + +比手写 if/else 更清晰。 + +### 17.5 泛型 + +`ModelCaller` 和 `executeWithFallback()` 里的泛型是模块复用的关键。 + +它让同一套 fallback 机制可以作用于多种客户端和返回值。 + +### 17.6 函数式接口 + +`Function` 和 `ModelCaller` 都属于函数式风格。 + +好处是: + +- 路由逻辑与调用逻辑解耦 +- 执行器不依赖具体 client 接口 +- 复用能力大幅提升 + +### 17.7 并发容器与原子更新 + +`ConcurrentHashMap.compute()` 让状态机更新具有线程安全特性。 + +相比显式加锁,这种写法: + +- 粒度更小 +- 可读性更高 +- 性能更适中 + +### 17.8 断路器模式 + +`ModelHealthStore` 实现的是经典断路器模式: + +- `CLOSED` +- `OPEN` +- `HALF_OPEN` + +这是整个模块高可用设计的核心。 + +--- + +## 18. 典型调用过程分析 + +下面用一个具体例子说明整个 `model` 模块是如何工作的。 + +### 场景:一次同步 Chat 调用 + +假设: + +- `qwen3-max` 是首选模型 +- `glm-4.7` 是次选模型 +- `qwen3-local` 是第三备选 + +调用过程如下: + +1. `RoutingLLMService.chat(request)` 被调用 +2. `ModelSelector.selectChatCandidates(thinking)` 从配置中筛出可用候选 +3. 候选按优先级排序,形成: + - `qwen3-max` + - `glm-4.7` + - `qwen3-local` +4. `ModelRoutingExecutor.executeWithFallback(...)` 开始遍历 +5. 先尝试 `qwen3-max` +6. 如果调用成功: + - `markSuccess(qwen3-max)` + - 直接返回响应 +7. 如果失败: + - `markFailure(qwen3-max)` + - 自动切到 `glm-4.7` +8. 如果 `glm-4.7` 成功: + - 本次业务成功 + - 上层甚至不一定感知到首选模型失败过 +9. 如果全部失败: + - 抛出统一 `RemoteException` + +### 18.1 对业务层的意义 + +业务层只感知两种结果: + +- 成功拿到结果 +- 失败拿到统一异常 + +至于中间切了几个模型,全部由基础设施层消化。 + +--- + +## 19. 扩展方法:如何新增模型或调整策略 + +### 19.1 新增一个新的 Chat Provider + +通常需要: + +1. 新增 `ChatClient` 实现 +2. 让它成为 Spring Bean +3. 在 `application.yaml` 增加 provider 配置 +4. 在 `ai.chat.candidates` 增加候选项 + +`model` 模块本身通常不需要修改。 + +### 19.2 新增一个新的能力类型 + +如果以后增加例如: + +- `MODERATION` +- `OCR` +- `TOOL_REASONING` + +则可能需要: + +1. 扩展 `ModelCapability` +2. 在 `AIModelProperties` 新增模型组 +3. 新增一个 RoutingService +4. 继续复用 `ModelSelector` / `ModelRoutingExecutor` / `ModelHealthStore` + +这说明当前设计具备不错的能力扩展空间。 + +### 19.3 调整路由策略 + +如果要改“谁优先被调用”,主要改 `ModelSelector`。 + +如果要改“失败后多久恢复”,改: + +- `ai.selection.failure-threshold` +- `ai.selection.open-duration-ms` + +如果要改“失败时是否立即切下一个”,主要看 `ModelRoutingExecutor`。 + +--- + +## 20. 设计优点与局限 + +### 20.1 优点 + +- **结构清晰** + - 选择、执行、健康管理各司其职 +- **复用性高** + - 三类能力共用一套治理逻辑 +- **配置驱动** + - 易于切换模型与环境 +- **高可用** + - 支持熔断与 fallback +- **扩展友好** + - 新增 provider 基本不需要改路由核心 + +### 20.2 当前局限 + +- 健康状态只存在内存中 + - 服务重启后会丢失 +- 熔断策略较轻量 + - 没有滑动窗口、失败比例、慢调用统计等高级特性 +- `ModelSelector` 和 `ModelRoutingExecutor` 都会访问健康状态 + - 逻辑虽然合理,但理解门槛略高 +- fallback 是顺序式的 + - 不支持并发竞速、加权负载、成本感知调度 + +### 20.3 为什么这些局限是可接受的 + +对于当前项目定位来说,这套设计的重点是: + +- 简单 +- 可读 +- 易扩展 +- 足够稳定 + +它没有追求一个超复杂的分布式模型网关,而是做了一个非常适合当前系统规模的中型方案。 + +--- + +## 21. 源码阅读顺序建议 + +如果你准备从源码层面真正吃透这个模块,建议按以下顺序阅读: + +1. [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) +2. [ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) +3. [ModelCapability](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/enums/ModelCapability.java) +4. [ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) +5. [ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) +6. [ModelCaller](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelCaller.java) +7. [ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) +8. [RoutingEmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) +9. [RoutingRerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) +10. [RoutingLLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) + +这样可以先理解“数据和决策”,再看“执行与接入”。 + +--- + +## 22. 总结 + +`infra-ai/model` 模块的本质不是“模型配置类集合”,而是整个 AI 基础设施里的 **模型路由治理核心**。 + +它通过 5 个关键抽象把复杂性拆开: + +- `AIModelProperties`:描述配置世界 +- `ModelSelector`:决定谁有资格被调用 +- `ModelTarget`:描述一次真实调用目标 +- `ModelHealthStore`:维护模型健康状态 +- `ModelRoutingExecutor`:统一执行 fallback + +这套设计体现了几个很鲜明的工程思想: + +- 配置驱动 +- 职责分离 +- 模板化复用 +- 轻量熔断 +- 优雅降级 + +如果把 `infra-ai` 看成一个 AI 基础设施层,那么 `model` 模块就是其中负责“路由与治理”的中枢神经系统。 + +--- + +## 23. 面试高频问题与回答要点 + +这一节不从源码细节出发,而是从架构思想、设计权衡、可用性治理和可扩展性角度,整理一组面试官可能会问的问题。 + +建议你的回答方式不要陷入“某个类第几行怎么写”,而是聚焦: + +- 为什么这样设计 +- 解决了什么问题 +- 这种设计相比简单方案优势在哪里 +- 当前方案的边界和未来演进方向是什么 + +### 23.1 你为什么要单独做一个 `model` 模块,而不是把逻辑直接写在 Chat / Embedding / Rerank 里? + +**回答要点:** + +- 因为 Chat、Embedding、Rerank 虽然能力不同,但“候选选择、健康检查、熔断、fallback”这套治理逻辑是共性的。 +- 如果把这些逻辑分别写在三条链路里,会导致重复代码、策略不一致、后续难以维护。 +- 单独抽出 `model` 模块,本质上是在做“能力调用治理中台”。 +- 这样做的价值是: + - 路由策略统一 + - 健康治理统一 + - 可扩展性更强 + - 新增能力时可以复用现有机制 + +**一句话表达:** + +- 我把协议调用和路由治理分层了,`model` 模块负责“调谁”,具体 client 负责“怎么调”。 + +### 23.2 你这个模块最核心解决的是什么问题? + +**回答要点:** + +- 最核心解决的是多模型场景下的**稳定性和可治理性**问题。 +- 在真实生产环境里,模型服务并不是永远稳定的: + - 有的模型快但贵 + - 有的模型便宜但质量一般 + - 有的模型偶发超时或限流 +- 如果没有一层统一路由治理,业务代码会直接暴露在这些不稳定性面前。 +- `model` 模块做的事情就是: + - 根据场景选候选 + - 在失败时自动切备用 + - 对坏模型暂时熔断 + - 尽量保证业务请求成功 + +**一句话表达:** + +- 它不是在解决“如何访问大模型”,而是在解决“多模型环境下如何稳定地访问大模型”。 + +### 23.3 你为什么采用“配置驱动”而不是写死模型路由规则? + +**回答要点:** + +- 模型供应商、默认模型、优先级、开关、熔断参数都属于高变更项,不适合硬编码。 +- 不同环境对模型组合的要求不同: + - 本地开发可能偏本地模型 + - 测试环境可能走低成本模型 + - 生产环境可能同时启用多个云模型 +- 配置驱动的价值是: + - 变更成本低 + - 适合多环境部署 + - 便于运营和运维调整 + - 能把“模型治理”从代码问题转成配置问题 + +**你可以补一句:** + +- 这样设计也是为了让 AI 基础设施具备“平台化”潜力,而不是只服务一个固定模型。 + +### 23.4 你为什么选择“首选模型 + fallback”的模式,而不是只配一个默认模型? + +**回答要点:** + +- 单模型方案在 Demo 阶段够用,但在生产环境风险很高。 +- 大模型服务常见问题包括: + - 网络抖动 + - provider 限流 + - 服务端 5xx + - 响应超时 + - 某个模型能力突然下降 +- “首选模型 + fallback”的思路本质上是高可用设计: + - 优先走效果最好的模型 + - 一旦失败,自动切换备用模型 + - 业务层无需显式处理失败重试细节 + +**一句话表达:** + +- 这是把传统分布式系统里的高可用思想,迁移到了 AI 模型调用层。 + +### 23.5 你为什么自己实现轻量熔断,而不是直接用 Resilience4j 之类的框架? + +**回答要点:** + +- 这里的目标不是做一个通用服务熔断平台,而是做一个非常贴合“模型路由场景”的轻量治理器。 +- 当前场景的特点是: + - 熔断对象是模型 ID + - 逻辑很明确,就是 `CLOSED / OPEN / HALF_OPEN` + - 状态粒度比较小 + - 依赖越少越好 +- 自己实现的好处: + - 代码更容易理解 + - 行为更可控 + - 和路由器结合更自然 + - 不引入重型依赖 + +**但要补充边界意识:** + +- 如果未来需要滑动窗口、失败比例、慢调用统计、指标上报等更复杂能力,完全可以再升级到更成熟的熔断框架。 + +**这种回答会显得你有设计取舍意识,而不是“重复造轮子”。** + +### 23.6 为什么既在选择阶段过滤健康状态,又在执行阶段再次检查? + +**回答要点:** + +- 这是典型的“双层校验”设计。 +- 原因是系统存在并发,选择和执行之间有时间差。 +- 只在选择阶段判断不够,因为: + - 线程 A 选模型时它还是健康的 + - 线程 B 紧接着把它打成了 OPEN + - 线程 A 如果不在执行前再检查,就会错误调用已失效模型 +- 所以: + - 选择阶段做快速过滤,减少明显坏模型进入候选列表 + - 执行阶段再做一次原子判断,保证最终调用前状态仍然有效 + +**一句话表达:** + +- 这是一个面向并发场景的防御式设计。 + +### 23.7 为什么 `ModelRoutingExecutor` 要做成泛型方法? + +**回答要点:** + +- 因为 Chat、Embedding、Rerank 三条链路的“调用治理逻辑”相同,但: + - client 类型不同 + - 返回值不同 +- 如果不用泛型,就会写出三套重复的 fallback 执行逻辑。 +- 用泛型后,执行器只关心: + - 候选怎么遍历 + - 成功失败怎么处理 + - 健康状态怎么更新 +- 不关心: + - 具体是聊天还是向量化还是重排 + +**一句话表达:** + +- 泛型的作用是把“能力调用逻辑差异”和“治理逻辑共性”分离开。 + +### 23.8 这个模块体现了哪些典型设计模式? + +**回答要点:** + +- **策略模式** + - 不同 provider client 是不同调用策略 +- **模板方法思想** + - 统一的路由和 fallback 流程由执行器定义 +- **函数式策略注入** + - 用 `Function` 和 `ModelCaller` 注入不同解析与调用行为 +- **断路器模式** + - `CLOSED / OPEN / HALF_OPEN` +- **门面模式** + - 上层通过 `RoutingLLMService` / `RoutingEmbeddingService` / `RoutingRerankService` 访问底层复杂能力 + +你不需要把“设计模式”说得特别教科书化,但能把思想讲出来会很加分。 + +### 23.9 如果面试官问:为什么不用注册中心或服务发现来管理模型? + +**回答要点:** + +- 当前这个模块要解决的是“模型调用治理”,不是“分布式模型服务注册中心”。 +- 模型供应商多数是外部 HTTP 服务,不是内部微服务实例池。 +- 因此当前最合适的方式是: + - 用配置描述 provider 和模型候选 + - 用路由模块做可用性治理 +- 如果未来模型部署平台化、自建推理集群增多,完全可以往更动态的服务发现体系演进。 + +**重点是表达阶段性合理性:** + +- 当前设计是针对当前系统复杂度做的最优解,不是无限上纲上线。 + +### 23.10 这个模块最大的优点是什么? + +**回答要点:** + +- 我认为最大优点不是“功能多”,而是**边界清晰、职责单一、复用性高**。 +- 它把复杂的模型调用问题拆成了几个明确问题: + - 如何表达配置 + - 如何选候选 + - 如何维护健康状态 + - 如何统一 fallback +- 这样后续扩展 provider、能力类型、策略规则时,心智负担比较低。 + +### 23.11 这个模块目前最大的局限是什么? + +**回答要点:** + +- 健康状态只在内存里,实例重启会丢失 +- 熔断策略偏轻量,不支持失败率窗口、慢调用统计等高级能力 +- 路由策略目前主要还是静态优先级,不是真正的动态打分路由 +- 不具备租户级、成本级、实验流量级治理能力 + +**重点不是回避缺点,而是说明你知道系统边界。** + +### 23.12 如果让你继续升级这个模块,你会先做什么? + +**回答要点:** + +优先级可以这样回答: + +1. 把“单个 `deepThinking` 布尔值”升级成完整的路由上下文 +2. 把固定 `Comparator` 排序升级成“多维打分模型” +3. 把熔断从“连续失败次数”升级成“失败率 / 延迟 / 错误类型”综合治理 +4. 补充路由可解释性,能知道模型为什么被选中或被过滤 + +这个回答会显得你不是只会看当前代码,而是能看出系统演进方向。 + +### 23.13 为什么这个模块适合放在 `infra-ai` 而不是 `bootstrap`? + +**回答要点:** + +- 因为它处理的是 AI 基础设施共性,而不是业务域逻辑。 +- 它不关心: + - RAG 检索 + - 意图树 + - 会话管理 + - 知识库业务 +- 它只关心: + - 模型怎么选 + - 模型失败怎么切 + - provider 如何治理 + +这正符合基础设施层的职责定义。 + +### 23.14 如果面试官问:这个设计最像哪个公司/哪类系统的思路? + +**回答要点:** + +- 它更像一个“轻量版模型网关 / AI Gateway”的设计 +- 思想上类似: + - API Gateway 的路由与降级 + - RPC 框架的负载均衡与容错 + - 分布式系统的断路器与高可用设计 +- 只是对象从“微服务实例”换成了“模型候选” + +这个类比非常适合面试场景,因为面试官一般更容易理解传统分布式系统概念。 + +### 23.15 如果面试官不看代码,你应该怎么总结这部分? + +**推荐口径:** + +- 我们在 AI 基础设施层抽了一个模型路由治理模块,统一处理多模型选择、优先级排序、健康检查、熔断和失败切换。 +- 这样业务层不需要感知具体模型供应商,也不用自己处理失败重试。 +- 它的核心价值是提升多模型场景下的稳定性、可扩展性和治理能力。 +- 这部分设计本质上是把传统分布式系统里的高可用思想,迁移到了大模型调用层。 + +--- + +## 24. 面试表达建议 + +如果你拿这个模块去面试,建议你优先突出下面 4 个关键词: + +- **配置驱动** +- **职责分离** +- **高可用** +- **可扩展** + +你可以按这个顺序讲: + +1. 先讲业务问题:多模型环境不稳定,不能把调用写死 +2. 再讲模块定位:这是模型路由治理层 +3. 再讲核心设计:选择器、健康存储、fallback 执行器三件套 +4. 最后讲取舍:当前是轻量版,后续可演进成动态打分和更完整的模型网关 + +这样即使面试官完全不看源码,也能快速理解你的设计能力和技术深度。 diff --git "a/docs/infra-ai\346\250\241\345\235\227\346\236\266\346\236\204\350\257\246\350\247\243.md" "b/docs/infra-ai\346\250\241\345\235\227\346\236\266\346\236\204\350\257\246\350\247\243.md" new file mode 100644 index 000000000..5b85b645d --- /dev/null +++ "b/docs/infra-ai\346\250\241\345\235\227\346\236\266\346\236\204\350\257\246\350\247\243.md" @@ -0,0 +1,913 @@ +# Ragent `infra-ai` 模块架构详解 + +## 1. 文档目标 + +本文用于系统化学习 `infra-ai` 模块,重点回答以下问题: + +- `infra-ai` 在整个项目中的定位是什么 +- 它是如何把不同 AI 供应商统一抽象起来的 +- `chat`、`embedding`、`rerank` 三条能力链路分别如何工作 +- 模型选择、熔断、失败回退、流式首包探测等治理能力如何协同 +- 配置层、路由层、协议适配层、HTTP 层分别承担什么职责 +- 这个模块体现了哪些值得学习的工程设计思想 + +本文覆盖 `infra-ai` 模块下的以下包: + +- `chat` +- `embedding` +- `rerank` +- `model` +- `config` +- `http` +- `token` +- `enums` +- `util` + +目标不是只解释某一个类,而是把整个模块当成一个完整的 AI 基础设施层来理解。 + +--- + +## 2. 模块定位 + +`infra-ai` 不是业务编排层,而是整个项目的 **AI 基础设施层**。 + +它位于业务模块和具体模型供应商之间,主要解决四类问题: + +- 统一能力抽象:对外只暴露聊天、向量化、重排等统一接口 +- 屏蔽供应商差异:不同厂商的 URL、鉴权、协议格式不暴露给业务层 +- 路由治理:支持候选模型选择、熔断、失败回退、健康状态管理 +- 协议适配:统一处理同步响应、流式 SSE、OpenAI 风格返回结构 + +因此可以把 `infra-ai` 理解成: + +- AI 能力网关 +- 模型路由中台 +- 协议适配层 +- 供应商集成层 + +上层业务只需要关心: + +- 我要聊天 +- 我要做 embedding +- 我要做 rerank + +至于: + +- 调哪个模型 +- 挂了怎么切备用 +- 流式怎么读 +- provider URL 怎么拼 +- API Key 从哪取 + +这些复杂度都由 `infra-ai` 内部消化。 + +--- + +## 3. 模块依赖与边界 + +`infra-ai` 是一个独立 Maven 模块,见 [pom.xml](file:///e:/java/workspace/ragent/infra-ai/pom.xml)。 + +它当前只显式依赖: + +- `framework` +- `okhttp` + +这说明它的边界比较清晰: + +- 不直接依赖业务模块 +- 不关心 RAG 流程编排 +- 不关心意图树、检索、记忆等业务概念 + +它只关心两件事: + +- 如何向模型服务发请求 +- 如何把模型能力稳定地提供给上层 + +--- + +## 4. 总体分层 + +从目录结构看,`infra-ai` 可以分成 5 层: + +- 配置层 + - `config` +- 能力门面层 + - `chat` / `embedding` / `rerank` +- 路由治理层 + - `model` +- 协议与 HTTP 适配层 + - `chat` 基类、`http` +- 工具与辅助层 + - `token` / `util` / `enums` + +### 4.1 总框图 + +```mermaid +flowchart TD + A["业务层 bootstrap / rag"] --> B["infra-ai 能力接口层"] + B --> C["LLMService"] + B --> D["EmbeddingService"] + B --> E["RerankService"] + + C --> F["RoutingLLMService"] + D --> G["RoutingEmbeddingService"] + E --> H["RoutingRerankService"] + + F --> I["ModelSelector"] + G --> I + H --> I + + F --> J["ModelRoutingExecutor"] + G --> J + H --> J + + J --> K["ModelHealthStore"] + I --> L["AIModelProperties"] + I --> M["ModelTarget"] + + F --> N["ChatClient"] + G --> O["EmbeddingClient"] + H --> P["RerankClient"] + + N --> Q["AbstractOpenAIStyleChatClient"] + O --> R["AbstractOpenAIStyleEmbeddingClient"] + P --> S["具体 RerankClient"] + + Q --> T["ModelUrlResolver"] + Q --> U["HttpResponseHelper"] + R --> T + R --> U + S --> T + S --> U + + Q --> V["OkHttp 同步/流式请求"] + R --> V + S --> V + + F --> W["ProbeStreamBridge"] + Q --> X["OpenAIStyleSseParser"] + Q --> Y["StreamAsyncExecutor / StreamCancellationHandle"] +``` + +--- + +## 5. 包级职责总览 + +### 5.1 `config` + +核心类: + +- [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) + +职责: + +- 从 `application.yaml` 读取 AI 相关配置 +- 统一描述 provider、模型候选、选择策略、流式参数 + +### 5.2 `model` + +核心类: + +- [ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) +- [ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) +- [ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) +- [ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) + +职责: + +- 选择候选模型 +- 生成运行时模型目标 +- 维护健康状态与熔断 +- 统一执行 fallback + +### 5.3 `chat` + +核心类: + +- [LLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/LLMService.java) +- [RoutingLLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) +- [ChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/ChatClient.java) +- [AbstractOpenAIStyleChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/AbstractOpenAIStyleChatClient.java) + +职责: + +- 提供统一聊天能力 +- 封装同步与流式调用 +- 适配 OpenAI 风格 provider 协议 + +### 5.4 `embedding` + +核心类: + +- [EmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/EmbeddingService.java) +- [RoutingEmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) +- [EmbeddingClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/EmbeddingClient.java) +- [AbstractOpenAIStyleEmbeddingClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/AbstractOpenAIStyleEmbeddingClient.java) + +职责: + +- 提供单文本和批量文本向量化 +- 复用模型路由层 +- 复用 OpenAI 风格协议抽象 + +### 5.5 `rerank` + +核心类: + +- [RerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RerankService.java) +- [RoutingRerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) +- [RerankClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RerankClient.java) + +职责: + +- 对检索结果重排 +- 复用统一路由和治理能力 + +### 5.6 `http` + +核心类: + +- [HttpResponseHelper](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/HttpResponseHelper.java) +- [ModelUrlResolver](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelUrlResolver.java) +- [ModelClientException](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelClientException.java) +- [ModelClientErrorType](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelClientErrorType.java) + +职责: + +- 统一 URL 解析 +- 统一响应读取与 JSON 解析 +- 统一错误分类 + +### 5.7 `token` 与 `util` + +核心类: + +- [HeuristicTokenCounterService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/token/HeuristicTokenCounterService.java) +- [LLMResponseCleaner](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/util/LLMResponseCleaner.java) + +职责: + +- 提供轻量 token 估算 +- 对模型返回做辅助清洗 + +--- + +## 6. 配置体系:`AIModelProperties` + +整个模块是 **配置驱动** 的,这一点非常重要。 + +配置入口在 [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java)。 + +它包含 5 组核心配置: + +- `providers` + - 各 provider 的基础 URL、API Key、端点映射 +- `chat` + - 聊天模型组 +- `embedding` + - 向量模型组 +- `rerank` + - 重排模型组 +- `selection` + - 熔断与失败策略 +- `stream` + - 流式输出配置 + +### 6.1 `ProviderConfig` + +每个 provider 配置包含: + +- `url` + - provider 基础 URL +- `apiKey` + - 鉴权密钥 +- `endpoints` + - 不同能力的路径映射,如 `chat` / `embedding` / `rerank` + +### 6.2 `ModelGroup` + +每个能力组包含: + +- `defaultModel` +- `deepThinkingModel` +- `candidates` + +### 6.3 `ModelCandidate` + +每个候选模型包含: + +- `id` +- `provider` +- `model` +- `url` +- `dimension` +- `priority` +- `enabled` +- `supportsThinking` + +这意味着模型接入不是写死在代码里,而是: + +- 配置 provider +- 配置候选模型 +- 运行时动态选择 + +这比“代码写死某个模型名”要成熟很多。 + +--- + +## 7. 运行时对象:`ModelTarget` + +[ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) 是整个模块的运行时核心对象之一。 + +它把一次真实调用需要的关键信息打包成一个 record: + +- `id` +- `candidate` +- `provider` + +### 7.1 为什么不直接传 `ModelCandidate` + +因为一次实际调用不只需要候选模型本身,还需要: + +- 对应 provider 配置 +- 解析后的运行时唯一 ID + +所以 `ModelTarget` 代表的不是“静态配置项”,而是: + +> 一次已经准备好可调用的模型目标。 + +后续很多链路都围绕它展开: + +- URL 解析 +- provider 客户端选择 +- 熔断状态检查 +- HTTP 请求体构建 + +--- + +## 8. 路由治理层详解 + +`model` 包是整个模块最有工程价值的一层。 + +### 8.1 `ModelSelector`:负责选谁 + +[ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) 的职责是: + +- 从配置中读取候选模型 +- 过滤禁用模型 +- 在 deep thinking 场景下只保留支持思考的模型 +- 按默认模型、优先级排序 +- 过滤掉当前已经不可用的模型 +- 输出 `List` + +#### 关键方法 + +- `selectChatCandidates(boolean deepThinking)` +- `selectEmbeddingCandidates()` +- `selectRerankCandidates()` + +#### 关键策略 + +- deep thinking 时优先 `deepThinkingModel` +- 普通聊天优先 `defaultModel` +- 通过 `priority` 做次级排序 +- 通过 `healthStore.isUnavailable(modelId)` 提前剔除熔断模型 + +这层的本质是: + +> 决定“当前最值得尝试哪些模型”。 + +### 8.2 `ModelRoutingExecutor`:负责怎么切 + +[ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) 是一个能力无关的通用 fallback 执行器。 + +其核心抽象参数有四个: + +- `capability` +- `targets` +- `clientResolver` +- `caller` + +#### 它的执行逻辑 + +1. 候选为空直接报错 +2. 逐个遍历 `target` +3. 通过 `clientResolver` 解析 client +4. `healthStore.allowCall(target.id())` +5. 调用 `caller.call(client, target)` +6. 成功则 `markSuccess` +7. 失败则 `markFailure` 并尝试下一个 +8. 全部失败后抛 `RemoteException` + +#### 为什么这个抽象好 + +因为它并不关心: + +- 是聊天 +- 还是 embedding +- 还是 rerank + +它只关心“候选 -> 尝试 -> 失败切换 -> 成功返回”。 + +所以它被三条能力链路复用。 + +### 8.3 `ModelHealthStore`:负责断路器 + +[ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) 实现了一套轻量断路器。 + +#### 状态机 + +- `CLOSED` +- `OPEN` +- `HALF_OPEN` + +#### 行为规则 + +- 连续失败达到阈值 -> `OPEN` +- `OPEN` 持续到 `openUntil` +- 超时后进入 `HALF_OPEN` +- 半开成功 -> 回到 `CLOSED` +- 半开失败 -> 重新 `OPEN` + +#### 关键细节 + +- `isUnavailable()` + - 用于候选选择阶段提前过滤 +- `allowCall()` + - 用于执行阶段控制是否允许本次调用 +- `markSuccess()` + - 清空失败计数并关闭熔断 +- `markFailure()` + - 增加失败次数,必要时打开熔断 + +#### 为什么这层重要 + +如果没有这一层: + +- 每次都会先打坏模型 +- fallback 代价会持续被放大 + +有了健康状态后: + +- 故障模型会被短时间跳过 +- 恢复后又能自动试探 + +这是生产级稳定性设计。 + +--- + +## 9. 三条能力链路的统一模式 + +`chat`、`embedding`、`rerank` 三个包虽然处理的任务不同,但架构模式高度一致。 + +它们都遵循: + +1. 暴露统一 Service 接口 +2. 由 RoutingService 作为主入口 +3. 通过 ModelSelector 选候选 +4. 通过 ModelRoutingExecutor 执行 fallback +5. 通过 provider -> client 映射找到具体客户端 +6. 由 client 负责真正协议适配和 HTTP 调用 + +这说明 `infra-ai` 的核心设计不是“聊天单点设计”,而是: + +> 面向多种 AI 能力的统一路由式基础设施。 + +--- + +## 10. `chat` 路径:最复杂的一条能力链 + +`chat` 是整个模块最复杂、也最有含金量的路径。 + +详细源码链路可参考 [infra-ai-chat链路详解.md](file:///e:/java/workspace/ragent/docs/infra-ai-chat链路详解.md)。 + +这里从模块架构角度总结它的关键点。 + +### 10.1 顶层接口:`LLMService` + +[LLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/LLMService.java) 对外提供: + +- `chat(...)` +- `streamChat(...)` + +业务层只感知: + +- 同步拿字符串 +- 或流式拿 callback/取消句柄 + +### 10.2 门面实现:`RoutingLLMService` + +[RoutingLLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) 是聊天总入口。 + +#### 同步链路 + +- `selectChatCandidates(thinking)` +- `executeWithFallback(...)` +- `client.chat(request, target)` + +#### 流式链路 + +除了选择候选、调用 client 之外,还要额外处理: + +- 首包探测 +- 无内容完成 +- 首包超时 +- 流式 fallback +- 取消控制 + +### 10.3 供应商接口:`ChatClient` + +[ChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/ChatClient.java) 定义: + +- `chat(request, target)` +- `streamChat(request, callback, target)` + +具体实现: + +- [BaiLianChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/BaiLianChatClient.java) +- [SiliconFlowChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/SiliconFlowChatClient.java) +- [OllamaChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/OllamaChatClient.java) + +这些实现都很薄,主要依赖: + +- [AbstractOpenAIStyleChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/AbstractOpenAIStyleChatClient.java) + +### 10.4 模板方法模式:`AbstractOpenAIStyleChatClient` + +这个类抽取了所有 OpenAI 风格 provider 的公共逻辑: + +- provider 与 API Key 校验 +- 请求体构建 +- 同步请求发送 +- 流式请求发送 +- SSE 逐行读取 +- 响应解析 +- thinking 字段支持 + +#### 同步调用模板 + +- `doChat(...)` + +#### 流式调用模板 + +- `doStreamChat(...)` +- `doStream(...)` + +#### 关键设计思想 + +- 共性进基类 +- 差异留钩子 +- provider 子类尽量变薄 + +### 10.5 流式专有能力 + +`chat` 路径比其他能力复杂,核心是多了这几组对象: + +- [ProbeStreamBridge](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/ProbeStreamBridge.java) +- [OpenAIStyleSseParser](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/OpenAIStyleSseParser.java) +- [StreamAsyncExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/StreamAsyncExecutor.java) +- [StreamCancellationHandle](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/StreamCancellationHandle.java) +- [StreamCancellationHandles](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/StreamCancellationHandles.java) + +#### 为什么需要它们 + +因为流式调用相比同步调用要额外解决: + +- 请求已发出但没有首包 +- 请求建立成功但没有内容 +- 流式输出中断 +- 用户中途取消 +- 思考内容和正文内容分离 + +所以 `chat` 路径不是简单“多了个 stream=true”,而是多了一整套流式治理能力。 + +--- + +## 11. `embedding` 路径:统一但更轻 + +[RoutingEmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) 体现了 `infra-ai` 的统一模式。 + +### 11.1 提供的能力 + +- `embed(String text)` +- `embedBatch(List texts)` +- 支持显式指定 `modelId` + +### 11.2 它的链路 + +1. 选择 embedding 候选模型 +2. 执行 fallback +3. 按 provider 找 `EmbeddingClient` +4. 调用 `client.embed(...)` 或 `client.embedBatch(...)` + +### 11.3 和 chat 的差异 + +- 没有流式处理 +- 没有首包探测 +- 没有 thinking 模式 +- 但依然复用统一路由层和 provider 适配逻辑 + +### 11.4 学习意义 + +它说明 `infra-ai` 的架构不是围绕聊天特化出来的,而是已经抽象成一套通用模式。 + +--- + +## 12. `rerank` 路径:能力最窄但模式一致 + +[RoutingRerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) 负责重排。 + +### 12.1 输入输出 + +- 输入:`query + RetrievedChunk 列表 + topN` +- 输出:按相关性重排后的 chunk 列表 + +### 12.2 它的链路 + +1. 选 rerank 模型 +2. fallback 执行 +3. 找 provider 对应 `RerankClient` +4. 执行 `client.rerank(...)` + +### 12.3 意义 + +虽然重排业务上较窄,但它和聊天、embedding 共享了相同的基础设施模式,这正体现了模块架构的一致性。 + +--- + +## 13. HTTP 层:统一的出网工具与错误模型 + +`http` 包承担的是“底层但高频”的公共职责。 + +### 13.1 `ModelUrlResolver` + +[ModelUrlResolver](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelUrlResolver.java) 负责生成最终请求 URL。 + +优先级是: + +- 候选模型自己的 `url` +- 否则 provider `url + endpoint` + +#### 设计价值 + +- 支持模型级自定义 URL +- 也支持 provider 级公共端点 +- 增强灵活性,减少配置重复 + +### 13.2 `HttpResponseHelper` + +[HttpResponseHelper](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/HttpResponseHelper.java) 把很多细碎但重要的逻辑统一收口: + +- `readBody()` +- `parseJson()` +- `requireProvider()` +- `requireApiKey()` +- `requireModel()` + +#### 为什么值得抽出来 + +如果这些逻辑散在每个 client 里,会导致: + +- 重复代码 +- 错误语义不一致 +- 可读性变差 + +统一抽到工具类以后: + +- 模板方法更简洁 +- 公共校验一致 +- 错误行为更可预测 + +### 13.3 错误模型 + +错误统一使用: + +- [ModelClientException](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelClientException.java) +- [ModelClientErrorType](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelClientErrorType.java) + +这使得系统能区分: + +- 认证问题 +- 限流问题 +- 网络问题 +- 服务端问题 +- 响应格式问题 + +这种错误标准化对: + +- fallback +- 熔断 +- 日志排查 + +都很重要。 + +--- + +## 14. `token` 包:轻量估算,而不是精确 tokenizer + +[HeuristicTokenCounterService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/token/HeuristicTokenCounterService.java) 的定位不是精确分词器,而是一个轻量级估算器。 + +它的策略是: + +- ASCII 字符约 4 个字符算 1 token +- 其他字符约 2 个字符算 1 token +- CJK 字符近似按 1 字 1 token + +### 14.1 为什么这样设计 + +这个项目并不需要在每个地方都拿到绝对精确 token 数,而更需要: + +- 快速估算上下文长度 +- 低成本做阈值判断 +- 避免额外引入复杂 tokenizer 依赖 + +这是一个典型的工程折中: + +- 精度适中 +- 性能和依赖成本更低 + +--- + +## 15. 模块的启动与装配方式 + +虽然没有一个单独的“总配置类”来显式画出 wiring,但从 Spring 注解和构造函数依赖可以看出它的装配方式。 + +### 15.1 Spring Bean 形态 + +- `@Service` + - 各能力 Service +- `@Component` + - 路由器、健康存储、URL 工具辅助类依赖 +- `@Primary` + - `RoutingLLMService` + - `RoutingEmbeddingService` + - `RoutingRerankService` + +### 15.2 provider client 的装配方式 + +像 `RoutingLLMService`、`RoutingEmbeddingService`、`RoutingRerankService` 都是通过: + +- `List` +- `List` +- `List` + +再转成: + +- `Map` + +键就是各自 `provider()` 返回值。 + +这样一来,新增 provider 时只需要: + +1. 实现某种 `Client` +2. 声明 `provider()` +3. 注册成 Spring Bean + +原有路由层几乎不用改。 + +这是一种非常典型的 **策略注册表模式**。 + +--- + +## 16. 这个模块最核心的设计思想 + +如果你是为了系统学习,这个模块最值得提炼的是下面这些设计思想。 + +### 16.1 统一接口,隔离供应商 + +业务层只认: + +- `LLMService` +- `EmbeddingService` +- `RerankService` + +不认任何具体 provider。 + +这保证了: + +- 上层稳定 +- 底层可替换 + +### 16.2 路由层与 client 层分离 + +- 路由层决定“调谁” +- client 层决定“怎么调” + +这让: + +- fallback +- 熔断 +- provider 适配 + +彼此解耦。 + +### 16.3 模板方法最大化复用协议共性 + +像 OpenAI 风格 provider: + +- 同步 HTTP +- 流式 SSE +- 请求体格式 +- 响应结构 + +高度相似。 + +因此抽成抽象基类是非常合适的。 + +### 16.4 配置驱动优于代码写死 + +模型候选、provider URL、API Key、端点、优先级、thinking 能力,全部来自配置。 + +这让系统具备: + +- 可运维性 +- 可切换性 +- 可扩展性 + +### 16.5 同步与流式分开治理 + +同步 fallback 相对简单; +流式调用需要额外处理: + +- 首包探测 +- 无内容完成 +- 取消控制 +- 异步线程管理 + +这说明作者没有偷懒把流式当同步调用的变体,而是认真对待了流式场景的特殊复杂度。 + +### 16.6 轻量熔断而不是重型框架 + +这里没有引入复杂的外部熔断框架,而是自己实现了一个足够简单、足够有效的 `ModelHealthStore`。 + +这体现的是: + +- 工程上够用 +- 依赖尽量少 +- 与当前模型路由场景强匹配 + +--- + +## 17. 从学习角度,应该怎么读这个模块 + +推荐按“从抽象到细节、从共性到特性”的顺序阅读。 + +### 第一轮:先理解总框架 + +1. [AIModelProperties](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/config/AIModelProperties.java) +2. [ModelTarget](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelTarget.java) +3. [ModelSelector](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelSelector.java) +4. [ModelRoutingExecutor](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelRoutingExecutor.java) +5. [ModelHealthStore](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/model/ModelHealthStore.java) + +### 第二轮:读 `chat` 主链路 + +1. [LLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/LLMService.java) +2. [RoutingLLMService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/RoutingLLMService.java) +3. [ChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/ChatClient.java) +4. [AbstractOpenAIStyleChatClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/AbstractOpenAIStyleChatClient.java) +5. [ProbeStreamBridge](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/ProbeStreamBridge.java) +6. [OpenAIStyleSseParser](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/chat/OpenAIStyleSseParser.java) + +### 第三轮:读 `embedding` 和 `rerank` + +1. [RoutingEmbeddingService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/RoutingEmbeddingService.java) +2. [AbstractOpenAIStyleEmbeddingClient](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/embedding/AbstractOpenAIStyleEmbeddingClient.java) +3. [RoutingRerankService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/rerank/RoutingRerankService.java) + +### 第四轮:读 HTTP 与辅助能力 + +1. [HttpResponseHelper](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/HttpResponseHelper.java) +2. [ModelUrlResolver](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/http/ModelUrlResolver.java) +3. [HeuristicTokenCounterService](file:///e:/java/workspace/ragent/infra-ai/src/main/java/com/nageoffer/ai/ragent/infra/token/HeuristicTokenCounterService.java) + +这样读的好处是: + +- 先看通用抽象 +- 再看最复杂路径 +- 再看同模式的其他能力 +- 最后补底层工具 + +不容易迷失在 provider 细节里。 + +--- + +## 18. 学习时最值得问自己的问题 + +在读这个模块时,建议反复问自己这几个问题: + +- 为什么上层不是直接调某个 `ChatClient` +- 为什么 `ModelSelector` 和 `ModelRoutingExecutor` 要拆成两个类 +- `ModelTarget` 为什么不能被 `ModelCandidate` 代替 +- 为什么流式路径需要首包探测,但 embedding/rerank 不需要 +- 为什么 OpenAI 风格协议要抽模板基类,而不是每个 provider 自己实现一遍 +- 为什么错误要分类型,而不是都抛一个普通异常 +- 为什么 token 估算选择启发式而不是精确 tokenizer + +这些问题想通了,这个模块的架构思想基本也就真正掌握了。 + +--- + +## 19. 一句话总结 + +`infra-ai` 模块本质上是一套面向多 AI 能力的基础设施中台:它以配置驱动的方式统一管理 provider 和模型候选,通过 `ModelSelector + ModelRoutingExecutor + ModelHealthStore` 构建模型选择、熔断与 fallback 能力,再以 `chat / embedding / rerank` 三条门面链路对上提供统一接口,并借助模板方法、协议适配、流式首包探测和错误标准化机制,把底层大模型供应商差异屏蔽在模块内部,为上层 RAG/Agent 业务提供稳定、可扩展、可治理的 AI 能力底座。 diff --git "a/docs/\346\204\217\345\233\276\346\240\221\347\274\223\345\255\230\345\207\273\347\251\277\346\262\273\347\220\206\346\212\200\346\234\257\346\226\271\346\241\210.md" "b/docs/\346\204\217\345\233\276\346\240\221\347\274\223\345\255\230\345\207\273\347\251\277\346\262\273\347\220\206\346\212\200\346\234\257\346\226\271\346\241\210.md" new file mode 100644 index 000000000..43c8caace --- /dev/null +++ "b/docs/\346\204\217\345\233\276\346\240\221\347\274\223\345\255\230\345\207\273\347\251\277\346\262\273\347\220\206\346\212\200\346\234\257\346\226\271\346\241\210.md" @@ -0,0 +1,976 @@ +# Ragent 通用缓存击穿治理技术方案 + +## 1. 文档目标 + +本文不再只讨论“意图树缓存”这一条链路,而是从项目整体视角,设计一套可复用的缓存击穿治理能力,解决多个模块中普遍存在的: + +- Redis miss 后直接回源数据库 +- 并发 miss 时重复查库、重复回填 +- 空结果不缓存导致持续打库 +- 各个模块各自实现、缺乏统一策略与配置 + +本文希望回答以下问题: + +- 当前项目里哪些缓存模块存在相同问题 +- 为什么不应该继续做“意图树专项治理” +- 一套通用的缓存击穿治理能力应该长什么样 +- 应该抽象成哪些公共类、公共配置和扩展点 +- 业务模块如何以最小改动接入 +- 首批接入哪些模块最合适 +- 如何做日志、测试、灰度与后续扩展 + +本文目标是输出一份“可以直接据此编码”的技术方案文档,而不是停留在概念层面的优化建议。 + +## 2. 背景与问题来源 + +当前项目中,已经至少存在两类典型的 Redis 配置型缓存: + +- 意图树缓存 +- 查询术语映射缓存 + +它们的共同特点是: + +- 数据更新频率较低 +- 读频率相对更高 +- 缓存 miss 后都需要回源数据库 +- 更新时都采用“写后删缓存”的模式 + +这种模式本身没有问题,但如果没有额外的并发保护,在缓存 miss 瞬间就会出现典型的缓存击穿风险。 + +## 3. 当前项目中的现状 + +### 3.1 意图树缓存 + +当前意图树相关链路大致如下: + +- 入口:`DefaultIntentClassifier.loadIntentTreeData()` +- 缓存管理:`IntentTreeCacheManager` +- 数据库回源:`DefaultIntentClassifier.loadIntentTreeFromDB()` +- 写后失效:`IntentTreeServiceImpl` + +当前读取模式可以概括为: + +1. 从 Redis 读取 `ragent:intent:tree` +2. miss 后直接查库构树 +3. 回写 Redis + +这一实现简单直接,但缺少并发 miss 保护。 + +### 3.2 查询术语映射缓存 + +当前术语映射相关链路大致如下: + +- 入口:`QueryTermMappingService.loadMappings()` +- 缓存管理:`QueryTermMappingCacheManager` +- 数据库回源:`QueryTermMappingMapper.selectList(...)` +- 写后失效:`QueryTermMappingAdminServiceImpl` + +当前读取模式同样是: + +1. 从 Redis 读取 `ragent:query-term:mappings` +2. miss 后直接查库 +3. 回写 Redis + +这说明项目中已经不止一个模块在重复实现同一种缓存读取模式。 + +### 3.3 共性问题 + +这些缓存模块目前普遍存在以下问题: + +- 没有分布式锁 +- 没有拿锁后二次读缓存 +- 没有空结果短 TTL 缓存 +- 没有锁失败后的等待与重读 +- 没有统一的兜底策略 +- 没有统一的日志与配置模型 + +换句话说,当前问题不是“意图树设计得不够好”,而是“项目缺少一个通用的缓存击穿治理能力”。 + +## 4. 为什么不能继续做意图树专项方案 + +如果继续只在 `IntentTreeCacheManager` 里补分布式锁,短期确实能解决意图树问题,但会带来新的工程问题: + +- 术语映射缓存仍然会保留同样的风险 +- 以后新增其他缓存模块时还会重复踩坑 +- 每个模块都自己维护一套锁、TTL、重试和兜底逻辑 +- 各模块参数名、日志格式、异常处理方式不一致 +- 后续压测与排障缺乏统一视角 + +因此,本次设计应从“专项修补”升级为“通用能力建设”。 + +## 5. 设计目标 + +本次方案的目标如下: + +1. 为项目提供一套通用的缓存击穿治理模板 +2. 允许业务模块以最小改动接入,不重写全部缓存逻辑 +3. 在高并发 miss 场景下,只允许一个实例执行回源与回填 +4. 支持空结果缓存,避免持续打空数据库 +5. 支持锁失败后短暂等待再读缓存 +6. 支持可配置兜底策略 +7. 支持不同缓存 key 拥有不同 TTL、空值 TTL、锁 key 与兜底策略 +8. 保持与项目现有 `StringRedisTemplate + ObjectMapper + Redisson` 技术栈一致 + +## 6. 非目标 + +本次方案不打算解决以下问题: + +- 不引入本地 JVM 多级缓存 +- 不统一重构所有缓存类的业务语义命名 +- 不引入复杂的逻辑过期与异步刷新机制 +- 不修改数据库表结构 +- 不把所有 Redis 使用场景都纳入本框架 + +本次仅针对“缓存 miss 后需要回源加载”的典型 cache-aside 场景。 + +## 7. 方案总览 + +本次设计建议新增一个通用组件,暂定命名为: + +- `CacheAsideGuardTemplate` + +它负责统一处理以下流程: + +1. 读取缓存 +2. 判断是 miss 还是 hit +3. miss 时尝试获取分布式锁 +4. 拿锁后二次读缓存 +5. 二次 miss 才执行数据库回源 +6. 根据回源结果写入正常缓存或空结果缓存 +7. 锁失败时短暂等待后重读缓存 +8. 重读后仍 miss 时执行兜底策略 + +业务模块不再自己写“Redis miss -> 直接查库 -> 回写缓存”的流程,而是把以下内容交给模板: + +- 这个缓存的 key 是什么 +- 这个缓存的 value 类型是什么 +- 如何判断“空结果” +- miss 后如何从数据库加载 +- 回源失败时的兜底偏好是什么 + +## 8. 通用能力应包含什么 + +### 8.1 通用模板 + +需要一个统一入口,例如: + +```java +public interface CacheAsideGuardTemplate { + + T getOrLoad(CacheAccessSpec spec); +} +``` + +这个入口负责封装所有通用流程。 + +### 8.2 通用策略对象 + +需要一个描述缓存行为的配置对象,例如: + +- `CacheAccessSpec` + +它负责携带: + +- cache key +- lock key +- 类型信息 +- 正常 TTL +- 空值 TTL +- 是否允许缓存空值 +- 锁租约时间 +- 重读次数 +- 重读间隔 +- 锁失败后的兜底策略 +- 数据加载函数 +- 空值判断函数 +- 空值提供函数 + +### 8.3 通用结果对象 + +需要一个内部读取结果对象,用于区分: + +1. 缓存未命中 +2. 缓存命中但为空值 +3. 缓存命中且有值 + +否则“空值缓存”在泛化后依然无法正确落地。 + +### 8.4 通用配置模型 + +需要一套全局默认配置,同时允许单个缓存覆盖: + +- 默认 TTL +- 默认空值 TTL +- 默认锁租约时间 +- 默认重试次数 +- 默认重试间隔 +- 默认兜底策略 + +## 9. 为什么缓存协议必须通用升级 + +这是整套方案里最关键的部分。 + +### 9.1 当前协议的问题 + +当前很多缓存方法的约定是: + +- `null` 表示 miss +- 非 `null` 表示 hit + +这个约定一旦遇到“空集合也要缓存”的场景,就会失效。 + +例如: + +- Redis 中存储 `[]` +- 反序列化后得到空 `List` +- 业务层再用 `isEmpty()` 判断 miss + +最终会把“命中的空缓存”误判成 miss。 + +### 9.2 升级后的协议 + +需要在模板内部统一引入: + +```java +private record CacheReadResult( + boolean hit, + T value +) { +} +``` + +其中: + +- `hit = false` 表示缓存完全不存在 +- `hit = true` 表示缓存存在,不管值是不是空集合、空对象或空字符串 + +空不空由 `emptyPredicate` 判断,而不是由 `hit` 决定。 + +这样模板才能同时支持: + +- `List` +- `Map` +- 普通对象 +- 允许缓存空集合的场景 + +## 10. 建议的总体架构 + +建议新增一组通用类,放在偏基础设施的位置,例如: + +- `framework/cache/` 或 `bootstrap/common/cache/` + +更推荐放在: + +- `framework/cache/` + +因为这套能力不只服务 RAG 模块,未来其他模块也可能复用。 + +建议的核心类如下: + +- `CacheAsideGuardTemplate` +- `DefaultCacheAsideGuardTemplate` +- `CacheAccessSpec` +- `CacheReadResult` +- `CacheFallbackMode` +- `CacheBreakdownProperties` + +## 11. 通用类设计建议 + +### 11.1 `CacheFallbackMode` + +建议定义统一兜底枚举: + +```java +public enum CacheFallbackMode { + RETURN_EMPTY, + LOAD_ONCE +} +``` + +语义如下: + +- `RETURN_EMPTY` + - 锁失败且多次重读后仍 miss,直接返回“空值提供器”返回的空值 +- `LOAD_ONCE` + - 锁失败且多次重读后仍 miss,允许当前请求单次回源 + +### 11.2 `CacheAccessSpec` + +建议设计为一个泛型策略对象,示意如下: + +```java +@Builder +public record CacheAccessSpec( + String cacheKey, + String lockKey, + JavaType javaType, + Duration ttl, + Duration emptyTtl, + boolean cacheEmptyValue, + Duration lockLease, + int retryReadTimes, + Duration retryReadInterval, + CacheFallbackMode fallbackMode, + Supplier loader, + Predicate emptyPredicate, + Supplier emptyValueSupplier +) { +} +``` + +说明: + +- `cacheKey` + - Redis 中存放业务值的 key +- `lockKey` + - 该缓存对应的回源锁 key +- `javaType` + - Jackson 反序列化类型 +- `ttl` + - 正常值缓存时长 +- `emptyTtl` + - 空值缓存时长 +- `cacheEmptyValue` + - 是否允许缓存空值 +- `lockLease` + - 分布式锁租约时间 +- `retryReadTimes` + - 锁失败后重读缓存的次数 +- `retryReadInterval` + - 每次重读前的等待时间 +- `fallbackMode` + - 最终兜底策略 +- `loader` + - miss 时的数据库加载逻辑 +- `emptyPredicate` + - 判定是否为空值 +- `emptyValueSupplier` + - 统一返回空值实例 + +### 11.3 `CacheBreakdownProperties` + +建议增加全局默认配置,例如: + +```java +@Data +@ConfigurationProperties(prefix = "app.cache.breakdown") +public class CacheBreakdownProperties { + private long defaultTtlSeconds = 604800; + private long defaultEmptyTtlSeconds = 60; + private long defaultLockLeaseSeconds = 10; + private int defaultRetryReadTimes = 3; + private long defaultRetryReadIntervalMs = 80; + private CacheFallbackMode defaultFallbackMode = CacheFallbackMode.LOAD_ONCE; +} +``` + +说明: + +- 这是全局默认值 +- 具体业务缓存仍可以在 `CacheAccessSpec` 中覆盖 + +### 11.4 `DefaultCacheAsideGuardTemplate` + +建议它依赖: + +- `StringRedisTemplate` +- `ObjectMapper` +- `RedissonClient` +- `CacheBreakdownProperties` + +该类只负责模板流程,不关心任何具体业务含义。 + +## 12. 通用模板主流程设计 + +### 12.1 主入口 + +建议统一入口如下: + +```java +public T getOrLoad(CacheAccessSpec spec) +``` + +### 12.2 主流程 + +模板执行流程如下: + +1. 直接从 Redis 读取缓存字符串 +2. 若 `get()` 返回 `null`,认定为 miss +3. 若返回非 `null`,按 `javaType` 反序列化并直接返回 +4. miss 后尝试获取 `RLock` +5. 拿到锁后再次读取缓存 +6. 若二次读取已命中,直接返回 +7. 若二次读取仍 miss,则执行 `loader` +8. 使用 `emptyPredicate` 判断是否为空值 +9. 若为空值且允许缓存空值,则按 `emptyTtl` 回写 +10. 若为正常值,则按 `ttl` 回写 +11. 返回结果 +12. 若没拿到锁,则按 `retryReadTimes` 与 `retryReadInterval` 轮询重读缓存 +13. 重读仍 miss,则按 `fallbackMode` 执行兜底 + +## 13. 通用模板伪代码 + +```java +public T getOrLoad(CacheAccessSpec spec) { + CacheReadResult firstRead = readCache(spec); + if (firstRead.hit()) { + return normalizeValue(firstRead.value(), spec); + } + + RLock lock = redissonClient.getLock(spec.lockKey()); + boolean locked = false; + try { + locked = lock.tryLock(0, spec.lockLease().toSeconds(), TimeUnit.SECONDS); + if (locked) { + CacheReadResult secondRead = readCache(spec); + if (secondRead.hit()) { + return normalizeValue(secondRead.value(), spec); + } + + T loaded = spec.loader().get(); + T safeValue = loaded == null ? spec.emptyValueSupplier().get() : loaded; + + if (spec.emptyPredicate().test(safeValue)) { + if (spec.cacheEmptyValue()) { + writeCache(spec, safeValue, spec.emptyTtl()); + } + return safeValue; + } + + writeCache(spec, safeValue, spec.ttl()); + return safeValue; + } + + return retryReadCacheOrFallback(spec); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return fallbackAfterLockFailure(spec); + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } +} +``` + +## 14. 为什么模板必须由业务传入空值判断逻辑 + +不同缓存的“空值”定义并不一样: + +- `List`:可能是 `list.isEmpty()` +- `Map`:可能是 `map.isEmpty()` +- 普通对象:可能是 `obj == null` +- 某些聚合对象:可能是字段为空但对象本身不为空 + +因此不能在模板里写死: + +- `value == null` +- 或 `value instanceof Collection && isEmpty()` + +正确做法是把“空值定义权”交给具体缓存调用方,由 `emptyPredicate` 提供。 + +## 15. 为什么模板必须支持空值提供器 + +业务模块需要一个统一的“空结果实例”,否则在泛型模板里会出现很多问题: + +- `null` 容易和 miss 混淆 +- `List.of()`、`Collections.emptyMap()`、空 DTO 的构造方式并不统一 + +因此建议每个 `CacheAccessSpec` 都提供: + +- `emptyValueSupplier` + +这样模板在以下场景里都能保持行为一致: + +- loader 返回 `null` +- fallback 直接返回空值 +- 读缓存命中但值反序列化为 `null` + +## 16. 锁失败后的通用策略 + +当线程未拿到锁时,不应立刻查库。 + +模板建议统一行为: + +1. 按配置等待很短时间 +2. 重读缓存 +3. 循环若干次 +4. 最终再决定兜底 + +推荐默认值: + +- 重读次数:3 +- 重读间隔:80ms + +这样既能给持锁线程留出回填时间,也不会让请求等待过久。 + +## 17. 通用兜底策略 + +### 17.1 `RETURN_EMPTY` + +适用场景: + +- 允许当前请求短暂拿不到业务数据 +- 更关注数据库保护 +- 即使返回空值也不会严重影响主流程 + +### 17.2 `LOAD_ONCE` + +适用场景: + +- 业务正确性优先 +- 返回空值会明显影响核心链路 +- 希望极端情况下仍允许单次回源 + +### 17.3 默认推荐 + +默认建议: + +- `LOAD_ONCE` + +原因: + +- 对大多数读取型配置缓存,直接返回空值会影响业务准确性 +- 在已经做过“锁失败后轮询重读”的前提下,真正走到兜底的概率较低 + +## 18. 关于是否在兜底分支回写缓存 + +建议第一版规则如下: + +- 持锁线程负责缓存回填 +- 兜底线程默认不回写缓存 + +原因: + +- 减少锁外重复回写 +- 降低模板复杂度 +- 保持“谁拿锁谁负责回填”的清晰职责边界 + +后续如确实需要,可以再扩展: + +- `fallbackWriteBackEnabled` + +但不建议第一版加入。 + +## 19. Redis 读写设计建议 + +### 19.1 读取时不建议先 `hasKey()` + +不建议: + +- `hasKey() + get()` + +推荐: + +- 直接 `get()` +- `null` 表示 miss +- 非 `null` 表示 hit + +原因: + +- Redis 只打一跳 +- 减少非原子窗口 +- 更利于模板统一化 + +### 19.2 值的存储格式 + +建议继续使用 JSON 字符串。 + +原因: + +- 当前项目已经使用 `ObjectMapper` +- 对 `List`、`Map`、对象都适用 +- 模板泛型化后仍然容易处理 + +### 19.3 TTL 随机抖动 + +建议模板层支持可选 TTL 抖动能力,例如: + +- 正常 TTL 基础值 + 随机抖动 + +虽然当前核心问题是击穿而不是雪崩,但统一在模板层预留此能力成本很低。 + +## 20. 建议的分层接入方式 + +为了避免业务模块失去语义表达,建议不要直接删掉现有 `*CacheManager`。 + +更推荐的方式是: + +1. 保留业务语义层的 `IntentTreeCacheManager`、`QueryTermMappingCacheManager` +2. 让它们内部委托 `CacheAsideGuardTemplate` +3. 将本模块特有的 key、类型、空值定义、loader 逻辑封装在对应 manager 内 + +这样做的好处是: + +- 业务调用方依然使用熟悉的语义接口 +- 通用模板只负责共性逻辑 +- 以后替换实现时业务层改动更小 + +## 21. 推荐的接入模式 + +### 21.1 模板层 + +模板层只提供一个统一入口: + +```java +cacheAsideGuardTemplate.getOrLoad(spec) +``` + +### 21.2 业务缓存层 + +业务缓存管理器仍然暴露语义化方法,例如: + +- `loadIntentTree(...)` +- `loadMappings(...)` + +### 21.3 调用层 + +上层业务只依赖本模块 cache manager,不直接依赖模板。 + +这样能避免模板泛型细节泄露到大量业务代码里。 + +## 22. 首批接入模块建议 + +### 22.1 意图树缓存 + +接入优先级: + +- 高 + +原因: + +- 是分类链路基础数据 +- 删除缓存后并发访问概率较高 +- 缓存值是 `List`,非常适合作为模板接入示例 + +建议接入方式: + +- 保留 `IntentTreeCacheManager` +- 在内部新增: + - `loadIntentTree(Supplier> loader)` +- 上层 `DefaultIntentClassifier` 改为调用该方法 + +### 22.2 术语映射缓存 + +接入优先级: + +- 高 + +原因: + +- 也是典型的配置型缓存 +- 当前同样是 miss 后直接查库 +- 缓存值是 `List`,与意图树场景高度相似 + +建议接入方式: + +- 保留 `QueryTermMappingCacheManager` +- 在内部新增: + - `loadMappings(Supplier> loader)` +- 上层 `QueryTermMappingService` 调整为调用新入口 + +### 22.3 后续扩展模块 + +后续可以继续排查以下场景是否适合接入: + +- 其他配置类缓存 +- 低频写、高频读、允许 Redis 缓存的字典类数据 +- 需要空值缓存的列表型查询结果 + +不建议纳入本模板的场景: + +- 强事务一致性缓存 +- 需要复杂 Lua 原子操作的场景 +- 非 cache-aside 模式的特殊 Redis 数据结构 + +## 23. 意图树接入示例设计 + +### 23.1 目标接口 + +`IntentTreeCacheManager` 建议提供: + +```java +public List loadIntentTree(Supplier> loader) +``` + +其内部委托模板: + +```java +return cacheAsideGuardTemplate.getOrLoad( + CacheAccessSpec.>builder() + .cacheKey("ragent:intent:tree") + .lockKey("ragent:intent:tree:lock") + .javaType(objectMapper.getTypeFactory() + .constructCollectionType(List.class, IntentNode.class)) + .ttl(Duration.ofDays(7)) + .emptyTtl(Duration.ofSeconds(60)) + .cacheEmptyValue(true) + .lockLease(Duration.ofSeconds(10)) + .retryReadTimes(3) + .retryReadInterval(Duration.ofMillis(80)) + .fallbackMode(CacheFallbackMode.LOAD_ONCE) + .loader(loader) + .emptyPredicate(List::isEmpty) + .emptyValueSupplier(List::of) + .build() +); +``` + +### 23.2 上层改法 + +`DefaultIntentClassifier` 不再自己控制 miss 回源与缓存回填,而是改成: + +```java +List roots = intentTreeCacheManager.loadIntentTree(this::loadIntentTreeFromDB); +``` + +然后只负责: + +- `flatten` +- 筛选叶子节点 +- 构造 `id2Node` + +## 24. 术语映射接入示例设计 + +### 24.1 目标接口 + +`QueryTermMappingCacheManager` 建议提供: + +```java +public List loadMappings(Supplier> loader) +``` + +其内部同样委托模板,只是业务参数不同: + +- cache key:`ragent:query-term:mappings` +- lock key:`ragent:query-term:mappings:lock` +- value 类型:`List` +- 空值判断:`List::isEmpty` +- 空值提供器:`List::of` + +### 24.2 上层改法 + +`QueryTermMappingService.loadMappings()` 可以改成: + +1. 把数据库查询与排序逻辑保留在当前 service +2. 用 lambda 形式传给 `QueryTermMappingCacheManager.loadMappings(...)` + +这样 cache manager 仍保持“只做缓存协作,不直接依赖 mapper”的职责边界。 + +## 25. 推荐的配置结构 + +建议在 `application.yaml` 中新增全局默认配置: + +```yaml +app: + cache: + breakdown: + default-ttl-seconds: 604800 + default-empty-ttl-seconds: 60 + default-lock-lease-seconds: 10 + default-retry-read-times: 3 + default-retry-read-interval-ms: 80 + default-fallback-mode: LOAD_ONCE +``` + +如果后续需要,也可以为具体业务缓存增加局部配置,例如: + +```yaml +rag: + cache: + intent-tree: + ttl-seconds: 604800 + empty-ttl-seconds: 60 + query-term-mappings: + ttl-seconds: 604800 + empty-ttl-seconds: 60 +``` + +第一版建议: + +- 先用全局默认值 +- 业务缓存只在代码中覆盖个别差异项 + +## 26. 日志与观测设计 + +通用模板应在统一位置打印日志,这样未来所有接入模块都能共享观测口径。 + +### 26.1 `debug` + +- 缓存命中 +- 缓存命中为空值 +- 锁失败后第 N 次重读命中 + +### 26.2 `info` + +- 缓存 miss,准备尝试获取锁 +- 获取锁成功,准备回源 +- 回源成功并写入正常缓存 +- 回源成功并写入空值缓存 + +### 26.3 `warn` + +- 获取锁失败,进入重读流程 +- 多次重读后仍 miss,进入兜底 +- 获取锁被中断 + +### 26.4 `error` + +- Redis 读取失败 +- Redis 反序列化失败 +- 回源失败 +- Redis 回写失败 + +建议统一日志字段: + +- `cacheKey` +- `lockKey` +- `fallbackMode` +- `retryIndex` +- `isEmptyValue` +- `valueType` + +## 27. 异常处理策略 + +### 27.1 Redis 读取异常 + +建议: + +- 记录 `error` +- 逻辑上视为 miss +- 继续走模板流程 + +### 27.2 锁获取中断 + +建议: + +- `Thread.currentThread().interrupt()` +- 记录 `warn` +- 进入统一兜底流程 + +### 27.3 loader 异常 + +建议: + +- 不在模板里吞掉真实异常 +- 保留异常语义抛给调用方 + +原因: + +- 如果数据库异常却被模板静默转为空值,容易掩盖真实故障 + +### 27.4 Redis 回写异常 + +建议: + +- 记录 `error` +- 但不影响当前已加载成功的数据返回给调用方 + +## 28. 测试设计 + +通用模板测试应独立于业务模块测试。 + +### 28.1 模板层测试 + +至少覆盖: + +- 缓存命中直接返回 +- 空值缓存命中直接返回 +- miss 后拿锁并回源成功 +- miss 后拿锁并写空值缓存 +- 拿锁后二次读命中 +- 锁失败后轮询重读命中 +- 锁失败后走 `RETURN_EMPTY` +- 锁失败后走 `LOAD_ONCE` +- loader 抛异常 +- Redis 反序列化异常 + +### 28.2 业务接入测试 + +至少覆盖: + +- 意图树缓存删除后并发读取,只有一个线程真正回源 +- 术语映射缓存删除后并发读取,只有一个线程真正回源 +- 空树和空映射列表会被短 TTL 缓存 + +## 29. 实施步骤建议 + +建议按以下顺序实施。 + +### 第一步:建设通用模板 + +新增: + +- `CacheFallbackMode` +- `CacheAccessSpec` +- `CacheBreakdownProperties` +- `CacheAsideGuardTemplate` +- `DefaultCacheAsideGuardTemplate` + +先把模板能力建设完整,并为其补单元测试。 + +### 第二步:接入意图树缓存 + +原因: + +- 业务价值高 +- 链路清晰 +- 作为首个接入案例可以验证模板抽象是否合理 + +### 第三步:接入术语映射缓存 + +原因: + +- 场景与意图树非常接近 +- 可以验证模板是否真正具备通用性 + +### 第四步:复盘并抽取进一步约束 + +在首批两个模块接入完成后,复盘以下问题: + +- `CacheAccessSpec` 是否字段过多 +- 是否需要 builder 默认值 +- 是否需要 TTL 抖动开关 +- 是否需要按缓存名暴露统计指标 + +### 第五步:再考虑更多模块接入 + +只有在模板已经经过两个业务模块验证后,再逐步推广。 + +## 30. 风险与注意事项 + +### 30.1 不要让模板直接感知数据库 + +模板只应该接收 `loader`,不能依赖具体 mapper 或 service。 + +否则它会从“通用缓存模板”退化成“业务聚合类”。 + +### 30.2 不要让业务层直接操作分布式锁 + +一旦业务层开始自己写锁逻辑,就会破坏模板统一性。 + +锁应只存在于模板内部。 + +### 30.3 不要删除现有语义化 cache manager + +保留业务语义层,是为了: + +- 保持调用层可读性 +- 避免泛型模板在业务中蔓延 +- 为后续局部特化保留空间 + +### 30.4 空值缓存 TTL 不宜过长 + +否则后台刚新增配置数据后,短时间内仍可能持续读到空值缓存。 + +因此仍然需要保留: + +- 写后删缓存 + +### 30.5 `LOAD_ONCE` 不是绝对零回源 + +它的目标是控制回源放大,而不是完全杜绝所有异常分支下的回源。 + +## 31. 一句话总结 + +本次方案的核心不是继续给 `IntentTreeCacheManager` 单独补锁,而是在项目层面建设一套通用的 cache-aside 击穿治理模板:统一处理缓存命中判定、分布式锁单飞回源、空值短 TTL、锁失败重读缓存和兜底策略,再由 `IntentTreeCacheManager`、`QueryTermMappingCacheManager` 这类语义化业务缓存管理器以最小改动接入,从而让“缓存击穿治理”从单点修补升级为可持续复用的基础能力。 diff --git "a/docs/\346\204\217\345\233\276\346\240\221\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\346\204\217\345\233\276\346\240\221\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..25f351761 --- /dev/null +++ "b/docs/\346\204\217\345\233\276\346\240\221\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,606 @@ +# Ragent 意图树链路详解 + +## 1. 文档目标 + +本文聚焦 Ragent 中“意图树”这条链路,完整解释以下问题: + +- 意图树到底是什么,为什么系统需要它 +- 意图树原始数据来自哪里 +- 为什么数据库里存的是扁平结构,而运行时要构造成树 +- `DefaultIntentClassifier.loadIntentTreeData()` 做了哪些事 +- `allNodes`、`leafNodes`、`id2Node` 三种视图分别有什么作用 +- Redis 缓存为什么要缓存整棵树,什么时候失效 +- 这棵树最终是如何参与意图分类、歧义澄清、检索和工具调用的 + +本文覆盖从数据库 `t_intent_node` 到 Redis 缓存、再到分类器运行时使用的完整链路。 + +## 2. 一句话定义 + +意图树本质上是一棵“系统能力目录树”: + +- 上层节点负责组织业务域和系统范围 +- 下层叶子节点负责承载真正可执行的知识库、系统直答或 MCP 工具能力 + +换句话说,它不是简单的标签列表,而是一套带层级、带语义、带执行元数据的树形路由结构。 + +## 3. 总框图 + +```mermaid +flowchart TD + A["IntentNodeDO rows in t_intent_node"] --> B["loadIntentTreeFromDB()"] + B --> C["第一遍: DO -> IntentNode, 放入 id2Node"] + C --> D["第二遍: 按 parentId 组装 children"] + D --> E["fillFullPath 递归补路径"] + E --> F["roots 根节点列表"] + F --> G["saveIntentTreeToCache(roots)"] + F --> H["loadIntentTreeData()"] + H --> I["flatten(roots) -> allNodes"] + I --> J["filter isLeaf -> leafNodes"] + I --> K["toMap(id -> node) -> id2Node"] + J --> L["buildPrompt(leafNodes)"] + K --> M["LLM返回id后映射回节点"] + K --> N["IntentNodeRegistry.getNodeById"] + N --> O["IntentGuidanceService 查父节点/系统域"] + M --> P["NodeScore -> 后续 KB / SYSTEM / MCP 路由"] +``` + +## 4. 这条链路在系统中的位置 + +意图树链路的核心入口在: + +- `DefaultIntentClassifier.loadIntentTreeData()` + +分类时的调用顺序大致是: + +```text +用户问题 + -> +IntentResolver.resolve(...) + -> +IntentClassifier.classifyTargets(question) + -> +DefaultIntentClassifier.loadIntentTreeData() + -> +拿到 leafNodes / id2Node + -> +构造分类 Prompt 给 LLM +``` + +因此,意图树并不是一个旁路组件,而是整个意图分类与能力路由的基础数据结构。 + +## 5. 原始数据层:数据库里存的是什么 + +底层实体是: + +- `IntentNodeDO` + +对应表: + +- `t_intent_node` + +数据库里存的是**扁平结构**,每一行代表一个节点,而不是天然的嵌套树。 + +### 5.1 关键字段 + +`IntentNodeDO` 中最关键的字段有: + +- `intentCode` + - 节点的业务唯一标识 +- `parentCode` + - 父节点的业务标识 +- `level` + - 节点层级:`DOMAIN / CATEGORY / TOPIC` +- `kind` + - 节点类型:`KB / SYSTEM / MCP` +- `name` + - 展示名称 +- `description` + - 节点描述 +- `examples` + - 示例问法 +- `collectionName` + - 对应 KB 节点的向量库集合 +- `mcpToolId` + - 对应 MCP 节点的工具 ID +- `topK` + - 节点级检索参数 +- `promptTemplate` + - 节点级 Prompt 模板 +- `paramPromptTemplate` + - MCP 参数提取模板 +- `enabled` + - 是否启用 + +### 5.2 为什么数据库是扁平结构 + +扁平结构的优点很明显: + +- 表设计简单 +- CRUD 方便 +- 节点增删改查成本低 +- 容易与管理后台结合 + +但它不适合运行时分类,因为: + +- 无法直接表达树形父子关系 +- 无法直接拿到某个节点的路径 +- 无法快速筛选叶子节点 + +所以系统选择: + +- 存储层用扁平表 +- 运行时再构造成树 + +这是一种很常见、也很合理的工程做法。 + +## 6. 运行时节点层:`IntentNode` + +运行时真正使用的是: + +- `IntentNode` + +它相比数据库实体,多了几个非常重要的运行时能力字段: + +- `id` + - 节点唯一标识,实际使用 `intentCode` +- `parentId` + - 父节点 ID,实际使用 `parentCode` +- `children` + - 子节点列表 +- `fullPath` + - 从根到当前节点的完整路径 +- `kind` + - 节点能力类型 +- `examples` + - 示例问题列表 + +还提供了几个关键判断方法: + +- `isLeaf()` +- `isKB()` +- `isMCP()` +- `isSystem()` + +这说明 `IntentNode` 不只是数据容器,而是“运行时可参与分类与路由判断的节点对象”。 + +## 7. 整条链路的总入口:`loadIntentTreeData()` + +这是整条意图树链路的总入口,职责可以概括成: + +1. 先尝试从 Redis 读取已构建好的树 +2. Redis 没有时,从数据库加载并构树 +3. 从根节点列表进一步生成三种运行时视图 + +返回的是一个内部临时结构: + +```java +private record IntentTreeData( + List allNodes, + List leafNodes, + Map id2Node +) {} +``` + +这三个字段是这条链路最关键的设计之一,后文会重点解释。 + +## 8. 第一步:优先从 Redis 读取整棵树 + +`loadIntentTreeData()` 进入后,第一步是: + +```java +List roots = intentTreeCacheManager.getIntentTreeFromCache(); +``` + +缓存管理器是: + +- `IntentTreeCacheManager` + +### 8.1 Redis 里存的是什么 + +Redis key: + +- `ragent:intent:tree` + +缓存值是: + +- 根节点列表 `List` 序列化后的 JSON + +也就是说,Redis 里缓存的不是扁平表数据,而是**已经构建好的整棵树**。 + +### 8.2 为什么缓存的是根节点列表 + +因为一棵树最自然的持久化入口就是: + +- 所有根节点 + +只要根节点在,且每个节点都有 `children`,整棵树就能被完整还原。 + +### 8.3 为什么要用 Redis 缓存 + +原因主要有三点: + +- 意图分类请求频繁,不能每次都查库构树 +- 构树虽然不复杂,但重复做没有必要 +- 意图树属于低频更新、高频读取的配置数据 + +因此,把整棵树缓存起来非常划算。 + +## 9. 第二步:缓存不存在时从数据库构树 + +如果 Redis 没有命中,系统会调用: + +- `loadIntentTreeFromDB()` + +这是真正把“扁平节点表”变成“树结构”的核心步骤。 + +它可以拆成四个阶段。 + +### 9.1 阶段一:查询所有有效节点 + +SQL 查询条件是: + +- `deleted = 0` +- `enabled = 1` + +这意味着只有: + +- 未删除 +- 已启用 + +的节点才会进入运行时意图树。 + +这样做的意义是: + +- 禁用节点不会被模型看到 +- 已逻辑删除节点不会污染分类结果 + +### 9.2 阶段二:第一遍遍历,先把所有节点建出来 + +这一阶段会把每条 `IntentNodeDO` 转成 `IntentNode`,并放入: + +- `Map id2Node` + +这里最关键的映射是: + +- `node.id = intentCode` +- `node.parentId = parentCode` + +这意味着: + +- 运行时真正依赖的是业务编码,而不是数据库主键 + +这么设计的好处是: + +- 业务语义更稳定 +- 父子关系表达更自然 +- 便于管理后台和业务代码统一引用 + +同时,这一遍会保证: + +- `children` 一定不为 `null` + +这是为了让第二遍挂子节点时不发生空指针。 + +### 9.3 阶段三:第二遍遍历,组装父子关系 + +有了 `id2Node` 之后,系统开始真正构树: + +- `parentId` 为空:视为根节点,加入 `roots` +- `parentId` 不为空且能找到父节点:挂到 `parent.children` +- `parentId` 不为空但找不到父节点:兜底也加入 `roots` + +这一步的工程意义非常强: + +- 父子关系在运行时才真正建立 +- 即使配置存在脏数据,也尽量不丢节点 + +“找不到父节点也当根节点”的设计很实用,因为: + +- 它优先保障“节点不丢失” +- 不让整棵树因为局部配置问题而断裂得更严重 + +### 9.4 阶段四:递归填充 `fullPath` + +树组装完成后,会执行: + +- `fillFullPath(roots, null)` + +其逻辑是: + +- 根节点:`fullPath = name` +- 子节点:`fullPath = parent.fullPath + " > " + name` + +最终会形成类似: + +- `业务系统` +- `业务系统 > OA系统` +- `业务系统 > OA系统 > 系统介绍` + +`fullPath` 非常重要,因为它后面会进入 LLM 分类 Prompt,帮助模型理解节点的上层语义归属。 + +## 10. 第三步:构造 `IntentTreeData` 三种运行时视图 + +构树完成后,`loadIntentTreeData()` 还不会直接把 `roots` 交给分类器,而是进一步加工成三种运行时视图。 + +### 10.1 `allNodes` + +通过 `flatten(roots)` 把整棵树拍平成一个列表。 + +它的作用是: + +- 全量遍历所有节点 +- 方便统一过滤和统计 +- 为后续生成其他视图提供基础数据 + +### 10.2 `leafNodes` + +从 `allNodes` 中筛出: + +- `IntentNode.isLeaf() == true` + +这代表真正参与分类的候选节点集合。 + +为什么只取叶子节点? + +- 叶子节点最细粒度 +- 叶子节点才真正绑定 KB / MCP / SYSTEM 执行信息 +- 上层节点更适合做组织结构,而不是最终分类目标 + +### 10.3 `id2Node` + +把 `allNodes` 再转成: + +- `Map` + +它的作用是: + +- 根据节点 ID 快速找回完整节点对象 +- 把 LLM 返回的 `id` 映射回运行时节点 +- 支持运行期“按节点 ID 查节点”的能力 + +这也是 `IntentNodeRegistry.getNodeById()` 的基础。 + +## 11. 为什么必须有这三种视图 + +这是整个实现里非常值得注意的设计点。 + +### 11.1 只有树结构不够 + +如果只有 `roots`: + +- 遍历某个指定节点很慢 +- 找叶子节点不方便 +- 根据 ID 查节点也不方便 + +### 11.2 只有扁平列表也不够 + +如果只有 `allNodes`: + +- 父子关系和路径语义不明显 +- 无法方便地表达层级结构 + +### 11.3 三种视图各司其职 + +- `roots` + - 用于保留完整树结构 +- `allNodes` + - 用于全量遍历 +- `leafNodes` + - 用于分类候选 +- `id2Node` + - 用于快速索引和结果映射 + +这说明作者没有把“树结构”当成唯一数据形态,而是根据运行场景准备了最合适的访问视图。 + +## 12. 这棵树是如何参与分类的 + +分类入口是: + +- `classifyTargets(String question)` + +这里最关键的一句就是: + +- `IntentTreeData data = loadIntentTreeData();` + +然后分类器使用: + +- `data.leafNodes` + - 构造 Prompt +- `data.id2Node` + - 把模型返回的 `id` 找回完整节点 + +### 12.1 `leafNodes` 如何用于构造 Prompt + +分类器会遍历每个叶子节点,拼出: + +- `id` +- `path` +- `description` +- `type` +- `toolId` +- `examples` + +再套进模板 `intent-classifier.st`。 + +也就是说,LLM 看到的不是 Java 树对象,而是一份“结构化意图目录清单”。 + +### 12.2 `id2Node` 如何用于映射分类结果 + +LLM 返回的是: + +- `id` +- `score` + +分类器再通过: + +- `id2Node.get(id)` + +把这个 `id` 映射回完整 `IntentNode`,从而得到: + +- 节点类型 +- 路径 +- 工具 ID +- Collection 名称 +- Prompt 模板 + +这一步非常关键,因为后续的 KB/MCP/SYSTEM 路由都依赖完整节点对象,而不是只有一个字符串 ID。 + +## 13. 这棵树还被谁使用 + +意图树不仅仅服务于分类器。 + +### 13.1 `IntentNodeRegistry.getNodeById()` + +`DefaultIntentClassifier` 同时实现了: + +- `IntentNodeRegistry` + +因此其他组件可以通过它按 ID 查节点。 + +### 13.2 歧义澄清服务 + +`IntentGuidanceService` 会在分析歧义时,沿着节点的 `parentId` 向上找父节点: + +- 找系统域 +- 找 domain 名称 +- 做歧义分组和澄清提示 + +这说明意图树不只是分类字典,也承载了“父子语义关系”。 + +### 13.3 检索与工具调用 + +后续路由依赖节点的: + +- `kind` +- `collectionName` +- `mcpToolId` +- `topK` +- `promptTemplate` + +所以叶子节点实际上就是: + +- 路由节点 +- 配置节点 +- 执行节点 + +三者合一。 + +## 14. 缓存什么时候失效 + +意图树缓存不是永久可信的,它会在配置发生变化时被清除。 + +失效入口在: + +- `IntentTreeServiceImpl` + +以下操作后都会调用: + +- `intentTreeCacheManager.clearIntentTreeCache()` + +包括: + +- 新增节点 +- 更新节点 +- 删除节点 +- 批量启用 +- 批量停用 +- 批量删除 + +这说明意图树缓存遵循的是: + +- 读取时尽量走缓存 +- 写入配置后主动失效 + +这是一种非常典型的“配置缓存”模式。 + +## 15. 为什么这样设计是合理的 + +### 15.1 存储层简单 + +数据库存扁平表,方便管理和维护。 + +### 15.2 运行时高效 + +构造成树后,路径、叶子节点、父子关系都更容易处理。 + +### 15.3 分类更准确 + +借助 `fullPath + description + examples + type`,LLM 能更准确地区分同名或相近节点。 + +### 15.4 扩展性强 + +新增一个知识主题或 MCP 工具,不需要改分类器算法,只要新增配置节点即可。 + +### 15.5 路由与配置统一 + +节点既描述“是什么”,又描述“怎么执行”。 + +## 16. 关键设计思想 + +### 16.1 业务编码优先,而不是数据库主键优先 + +树关系基于: + +- `intentCode` +- `parentCode` + +而不是数据库主键。 + +这让整棵树更接近业务语义层。 + +### 16.2 上层节点负责组织,下层叶子节点负责执行 + +这是一种“树负责语义组织,叶子负责最终路由”的设计。 + +### 16.3 一棵树,多种访问视图 + +不是只保留树本身,而是按运行需求派生多种视图。 + +### 16.4 缓存的是构建结果,而不是原始表数据 + +这样下次读取时不用重复构树,性价比更高。 + +## 17. 边界情况与容错 + +### 17.1 Redis 读取失败 + +缓存读取失败会返回 `null`,随后自动回退到数据库构树。 + +### 17.2 数据库中不存在任何有效节点 + +系统会返回空的 `IntentTreeData`: + +- `allNodes = []` +- `leafNodes = []` +- `id2Node = {}` + +不会直接抛异常。 + +### 17.3 子节点引用了不存在的父节点 + +当前实现会把该节点兜底当根节点,避免节点直接丢失。 + +### 17.4 LLM 返回未知节点 ID + +分类器会通过 `id2Node` 校验,找不到就直接跳过。 + +## 18. 推荐阅读顺序 + +建议按下面顺序阅读源码: + +1. `DefaultIntentClassifier.loadIntentTreeData()` +2. `IntentTreeCacheManager` +3. `DefaultIntentClassifier.loadIntentTreeFromDB()` +4. `DefaultIntentClassifier.fillFullPath()` +5. `DefaultIntentClassifier.flatten()` +6. `IntentNode` +7. `DefaultIntentClassifier.buildPrompt()` +8. `DefaultIntentClassifier.classifyTargets()` +9. `IntentGuidanceService.fetchParent()` +10. `IntentTreeServiceImpl` 中的缓存失效逻辑 + +这样最容易把“构树 -> 缓存 -> 使用 -> 失效”整条链路串起来。 + +## 19. 一句话总结 + +Ragent 的意图树链路本质上是一套“扁平配置表到运行时能力树”的转换机制:系统先从 `t_intent_node` 读取启用节点,以 `intentCode / parentCode` 组装父子关系并递归补全 `fullPath`,再派生出 `allNodes`、`leafNodes`、`id2Node` 三种运行时视图,并将构建结果缓存到 Redis,最终为 LLM 意图分类、歧义澄清、知识检索和 MCP 工具路由提供统一的结构化能力地图。 diff --git "a/docs/\346\204\217\345\233\276\350\247\243\346\236\220\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\346\204\217\345\233\276\350\247\243\346\236\220\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..a67a4ccdd --- /dev/null +++ "b/docs/\346\204\217\345\233\276\350\247\243\346\236\220\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,659 @@ +# Ragent 意图解析链路详解 + +## 1. 文档目标 + +本文聚焦 Ragent 问答主链路中的“意图解析”步骤,详细解释以下问题: + +- `resolveIntents(ctx)` 这一步到底解决什么问题 +- 为什么在 `rewriteQuery(ctx)` 之后必须紧接着做意图解析 +- 一个问题如何从自然语言变成“可路由的意图候选” +- 意图解析结果如何影响后续的歧义澄清、系统直答、知识检索和 MCP 工具调用 +- 这套实现的工程取舍、阈值控制和容错策略是什么 + +本文覆盖从 `StreamChatPipeline.resolveIntents()` 到 `IntentResolver`、`IntentClassifier`,再到后续消费链路的完整闭环。 + +## 2. 总体定位 + +在问答主流水线中,意图解析位于: + +```text +loadMemory(ctx) + -> +rewriteQuery(ctx) + -> +resolveIntents(ctx) + -> +handleGuidance(ctx) + -> +handleSystemOnly(ctx) + -> +retrieve(ctx) +``` + +这意味着它处在“问题理解”与“能力编排”之间,是整条链路的分流点。 + +一句话概括: + +> `resolveIntents(ctx)` 的本质,是把“改写后的用户问题”变成“带分数的意图候选集合”,从而告诉系统接下来该走哪条处理路径。 + +## 3. 总框图 + +```mermaid +flowchart TD + A["StreamChatPipeline resolveIntents(ctx)"] --> B["IntentResolver.resolve(rewriteResult)"] + B --> C["取 subQuestions 或 rewrittenQuestion"] + C --> D["并行处理每个子问题"] + D --> E["IntentClassifier.classifyTargets(question)"] + E --> F["加载最新意图树"] + F --> G["构造 Prompt + 调用 LLM 打分"] + G --> H["解析 JSON -> NodeScore 列表"] + H --> I["按阈值过滤 + 单子问题限量"] + I --> J["构造成 SubQuestionIntent"] + J --> K["全局总量裁剪 capTotalIntents"] + K --> L["ctx.setSubIntents(...)"] + L --> M["handleGuidance 歧义澄清"] + L --> N["handleSystemOnly 系统直答"] + L --> O["retrieve 分流到 KB / MCP"] +``` + +## 4. 入口代码 + +入口非常短,在 `StreamChatPipeline` 中: + +```java +private void resolveIntents(StreamChatContext ctx) { + List subIntents = intentResolver.resolve(ctx.getRewriteResult()); + ctx.setSubIntents(subIntents); +} +``` + +表面看只有两行,但这一步完成了整个问答链路里最关键的“路由标签生成”工作。 + +它做的事情非常明确: + +1. 接收上一步查询改写得到的 `RewriteResult` +2. 调用 `IntentResolver` 做意图识别 +3. 把结果写回 `ctx.subIntents` + +从这一步之后,系统就不再只是“拿着一个问题往下走”,而是拿着一组“子问题 -> 意图候选列表”继续处理。 + +## 5. 这一步到底有什么用 + +如果没有意图解析,系统只能用一种固定方式处理所有问题,例如: + +- 所有问题都统一去做知识库检索 +- 所有问题都统一交给大模型自由回答 +- 所有问题都无法区分是否应该调用工具 + +这会导致几个明显问题: + +- 该走工具调用的问题被错误拿去检索 +- 该直接回答的问题多走了一轮检索,成本更高 +- 一个问题里混合多个子问题时,后续处理会非常粗糙 +- 无法做歧义澄清,因为根本不知道候选方向有几个 + +因此这一步的根本价值是: + +- 给问题做“能力路由” +- 把自然语言输入转换成结构化决策依据 + +## 6. 输入是什么 + +意图解析的输入是 `RewriteResult`,其结构是: + +```java +public record RewriteResult(String rewrittenQuestion, List subQuestions) { +} +``` + +也就是说,这一步并不是直接处理原始问题,而是处理已经经过查询改写的结果。 + +输入包含两部分: + +### 6.1 `rewrittenQuestion` + +这是改写后的主问题,通常: + +- 术语更统一 +- 表达更完整 +- 指代更明确 + +### 6.2 `subQuestions` + +这是对复杂问题进行拆分后的子问题列表。 + +例如用户问: + +- “报销流程怎么走,审批记录在哪看,超预算怎么办?” + +改写后可能变成: + +- 主问题:围绕报销流程、审批记录和超预算处理的查询 +- 子问题: + - 报销流程怎么走? + - 审批记录在哪看? + - 超预算怎么办? + +这就是后续意图解析要处理的基本单位。 + +## 7. 输出是什么 + +`resolveIntents()` 最终输出的是: + +- `List` + +结构如下: + +```java +public record SubQuestionIntent(String subQuestion, List nodeScores) { +} +``` + +它表达的是: + +- 每个子问题 +- 对应一组意图候选 +- 每个候选带一个匹配分数 + +而候选分数的结构是: + +```java +public class NodeScore { + private IntentNode node; + private double score; +} +``` + +这意味着系统不是做“唯一分类”,而是做“带分数的候选分类”。 + +这种设计很重要,因为实际问题常常不是单意图的: + +- 可能多问句混合 +- 可能知识检索和工具调用混合 +- 可能存在歧义,需要多个候选供后续判断 + +## 8. `IntentResolver.resolve()` 主流程 + +`IntentResolver.resolve()` 的核心流程可以概括为: + +```text +从 RewriteResult 里拿到子问题 + -> +对每个子问题并行做意图分类 + -> +对每个子问题保留候选意图 + -> +做全局总量裁剪 + -> +返回 List +``` + +下面分步骤展开。 + +## 9. 第一步:确定要处理哪些问题 + +核心逻辑如下: + +```java +List subQuestions = CollUtil.isNotEmpty(rewriteResult.subQuestions()) + ? rewriteResult.subQuestions() + : List.of(rewriteResult.rewrittenQuestion()); +``` + +这里的策略很简单: + +- 如果上一步已经拆出了多个子问题,就逐个处理 +- 如果没有拆分结果,就退化成只处理一个改写后的主问题 + +这说明系统天然支持两类情况: + +- 单问题场景 +- 多问句场景 + +也说明意图解析是面向“子问题粒度”的,而不是只针对整个大问题做粗分类。 + +## 10. 第二步:并行处理每个子问题 + +代码中使用了 `CompletableFuture.supplyAsync(...)`,并通过 `intentClassifyExecutor` 并行处理各个子问题。 + +### 10.1 为什么并行 + +原因有三个: + +- 一个问题可能拆出多个子问题 +- 每个子问题的分类都要走一遍 LLM 意图识别 +- 串行会显著增加总耗时 + +所以这里的并行化,是为了降低整条问答链路在“问题理解阶段”的延迟。 + +### 10.2 并行粒度是什么 + +并行粒度不是“一个 LLM 调用内部并行”,而是: + +- 每个子问题独立提交一个分类任务 + +这种粒度划分非常自然,因为子问题之间本来就是相互独立的。 + +## 11. 第三步:对子问题做意图分类 + +每个子问题内部,最终调用的是: + +```java +intentClassifier.classifyTargets(question) +``` + +当前默认实现是: + +- `DefaultIntentClassifier` + +这个分类器不是写死 `if/else`,而是基于“意图树 + LLM 打分”的方式工作。 + +## 12. 分类器做了什么 + +`DefaultIntentClassifier.classifyTargets(question)` 可以拆成 6 个步骤。 + +### 12.1 加载最新意图树 + +分类器会先通过 `IntentTreeCacheManager` 从缓存读取意图树。 + +如果缓存没有,再从数据库加载,并回写到缓存。 + +这里的核心思想是: + +- 意图定义不是硬编码在代码里的 +- 而是配置化、树形化管理 +- 并且允许在运行时保持最新 + +### 12.2 扁平化意图树并抽取叶子节点 + +分类器会把整棵树展开,然后只取叶子节点参与分类。 + +为什么只分类叶子节点? + +- 叶子节点是最细粒度的可执行意图 +- 后续做 KB 检索、工具调用、Prompt 选择都需要较细粒度的信息 +- 如果只分类到大类,会损失精度 + +### 12.3 构造分类 Prompt + +分类器会把所有叶子节点信息组织进一个专门的 Prompt,并构造如下请求: + +- `system`: 意图分类规则 +- `user`: 当前子问题文本 + +同时参数非常保守: + +- `temperature = 0.1` +- `topP = 0.3` +- `thinking = false` + +这表明这一步追求的是: + +- 稳定 +- 可重复 +- 少发散 + +而不是创意表达。 + +### 12.4 调用 LLM 打分 + +LLM 返回的不是自然语言解释,而是结构化 JSON,里面通常包含: + +- `id` +- `score` + +这相当于把模型变成一个“树节点打分器”。 + +### 12.5 解析 JSON + +分类器会把 LLM 返回的 JSON 转成 `NodeScore` 列表。 + +同时做容错: + +- 允许返回数组 +- 也兼容外层包一层 `results` +- 未知节点 ID 会跳过 +- 解析失败则返回空列表 + +### 12.6 按分数降序排序 + +最终分类结果会按 `score` 从高到低排序,这样后续就可以直接做: + +- 阈值过滤 +- TopK 裁剪 +- 歧义判断 + +## 13. 第四步:对子问题结果做阈值过滤 + +在 `IntentResolver.classifyIntents(question)` 里,分类结果会继续经过两层过滤: + +```java +scores.stream() + .filter(ns -> ns.getScore() >= INTENT_MIN_SCORE) + .limit(MAX_INTENT_COUNT) + .toList(); +``` + +对应常量: + +- `INTENT_MIN_SCORE = 0.35` +- `MAX_INTENT_COUNT = 3` + +### 13.1 最低分数阈值的作用 + +`INTENT_MIN_SCORE` 的意义是: + +- 低于这个分数的候选,认为相关性不够 +- 不应该继续参与后续 RAG 处理 + +这可以避免: + +- 模型“随便猜一个意图” +- 低置信候选污染后续检索和工具调用 + +### 13.2 单子问题意图数量上限的作用 + +`MAX_INTENT_COUNT` 限制单个子问题最多保留几个候选。 + +作用是: + +- 防止意图过多导致后续分支爆炸 +- 控制检索成本和工具调用规模 +- 让后续歧义判断更聚焦 + +## 14. 第五步:局部异常降级 + +如果某个子问题在分类时抛异常,系统不会让整个链路失败,而是降级为: + +- `new SubQuestionIntent(q, List.of())` + +这说明这里的设计原则是: + +- 局部失败,整体继续 +- 允许某个子问题“无意图” +- 不因为一个子问题失败打挂整个会话 + +在生产系统里,这是非常关键的鲁棒性设计。 + +## 15. 第六步:全局总量裁剪 + +即使每个子问题都只保留最多 3 个候选,多个子问题叠加后,总意图数仍可能过多。 + +因此 `IntentResolver` 还做了一轮全局裁剪:`capTotalIntents(...)`。 + +### 15.1 为什么要做全局裁剪 + +例如: + +- 有 3 个子问题 +- 每个子问题都留下 3 个候选 +- 总数就变成 9 个 + +这会导致: + +- 后续检索范围太大 +- MCP 工具调用过多 +- 歧义空间过大 +- 响应速度和准确率都受影响 + +### 15.2 裁剪策略 + +这个方法不是简单“全局取前 3”,而是做了一个更公平的策略: + +1. 如果总数未超限,直接返回 +2. 每个子问题至少保留 1 个最高分意图 +3. 剩余配额再按全局高分继续分配 + +这套策略的好处是: + +- 不会因为总量裁剪让某个子问题完全没有意图 +- 同时又能兼顾全局高分优先 + +这是一种非常实用的工程折中。 + +## 16. 意图结果如何被后续消费 + +这一步的价值,最终体现在后续几个阶段如何使用它。 + +### 16.1 用于歧义澄清:`handleGuidance(ctx)` + +在 `StreamChatPipeline` 中,意图解析之后立刻调用歧义检测: + +- 使用 `ctx.getRewriteResult().rewrittenQuestion()` +- 使用 `ctx.getSubIntents()` + +`IntentGuidanceService.detectAmbiguity(...)` 会根据: + +- 候选数是否足够多 +- 候选分数是否接近 +- 是否命中不同系统/域 + +来判断是否需要先给用户一个澄清提示。 + +如果没有意图候选,就无法做这种“多候选歧义判断”。 + +### 16.2 用于系统直答:`handleSystemOnly(ctx)` + +系统会检查: + +- 每个子问题是否都是 `SYSTEM` 类型 + +如果全部都是系统类意图,就直接走: + +- `streamSystemResponse(...)` + +而不进入知识检索。 + +这意味着意图解析的结果直接决定: + +- 要不要绕过 RAG 检索 + +### 16.3 用于检索分流:`retrieve(ctx)` + +检索阶段会对每个 `SubQuestionIntent` 再拆成: + +- `kbIntents` +- `mcpIntents` + +对应过滤工具就是: + +- `NodeScoreFilters.kb(...)` +- `NodeScoreFilters.mcp(...)` + +也就是说,意图解析的输出不是只决定“是否检索”,还决定: + +- 哪些意图走知识库检索 +- 哪些意图走 MCP 工具调用 + +### 16.4 用于 Prompt 规划:`mergeIntentGroup(...)` + +在进入最终回答生成前,系统还会把多个子问题的意图合并成一个 `IntentGroup`: + +- `mcpIntents` +- `kbIntents` + +这样在 Prompt 构造和回答阶段,系统就知道这次回答整体上依赖了哪些能力来源。 + +## 17. `NodeScoreFilters` 的意义 + +`NodeScoreFilters` 是一个很小但很关键的工具类。 + +它统一了两个核心过滤规则: + +### 17.1 `mcp(scores)` + +只保留: + +- `node != null` +- `kind = MCP` +- `mcpToolId` 非空 + +含义是: + +- 只有真正能映射到工具执行器的意图,才算 MCP 意图 + +### 17.2 `kb(scores)` + +只保留: + +- `node != null` +- 类型为 KB 或默认可视作 KB + +含义是: + +- 这类意图适合进入知识检索通道 + +这个工具类的价值在于: + +- 让“分类阶段”和“执行阶段”之间的语义边界更清晰 +- 避免过滤逻辑在多个地方重复书写 + +## 18. 为什么说这一步是“能力路由层” + +从架构职责上看,可以把前后步骤理解成: + +### 18.1 `rewriteQuery(ctx)` + +负责: + +- 把问题整理清楚 +- 统一术语 +- 拆出子问题 + +### 18.2 `resolveIntents(ctx)` + +负责: + +- 判断问题属于哪些能力域 +- 生成可执行候选 +- 为后续路径选择提供依据 + +### 18.3 `retrieve(ctx)` / `streamSystemResponse(...)` + +负责: + +- 真正执行知识检索、工具调用、系统回答 + +因此 `resolveIntents(ctx)` 的本质位置就是: + +- 从“理解问题”过渡到“路由能力”的桥梁层 + +## 19. 这套设计的优点 + +### 19.1 支持多问句 + +一个输入可以拆成多个子问题,各自分类,各自处理。 + +### 19.2 支持混合能力场景 + +同一次用户输入中,可以同时: + +- 一部分走 KB +- 一部分走 MCP + +### 19.3 支持歧义澄清 + +因为保留的是候选意图,而不是单一结果,所以后续可以做澄清。 + +### 19.4 可配置、可扩展 + +意图树来自缓存/数据库,而不是硬编码。 + +### 19.5 有阈值、有裁剪、有降级 + +这是比较成熟的生产实现特征。 + +## 20. 这套设计的代价与取舍 + +任何设计都有取舍,意图解析也不例外。 + +### 20.1 引入了额外一次 LLM 调用 + +好处: + +- 问题理解更准 + +代价: + +- 增加耗时和成本 + +### 20.2 多子问题并行会增加系统复杂度 + +好处: + +- 总耗时更短 + +代价: + +- 线程池管理、异常收敛更复杂 + +### 20.3 候选式分类比单选更灵活,但后续也更复杂 + +好处: + +- 支持歧义澄清和混合意图 + +代价: + +- 需要额外的阈值和裁剪逻辑 + +## 21. 值得注意的实现细节 + +### 21.1 只在改写之后做意图解析 + +这不是偶然,而是为了先把问题标准化,再做分类。 + +### 21.2 单子问题裁剪和全局裁剪是两层控制 + +第一层控制单问题复杂度,第二层控制整个请求复杂度。 + +### 21.3 分类器使用叶子节点,而不是中间节点 + +这让后续执行路径更明确。 + +### 21.4 歧义判断依赖的是“候选接近度” + +所以保留多个候选是有意义的,不是冗余设计。 + +### 21.5 意图解析失败不会打断主链路 + +它是增强能力,但实现方式是容错优先。 + +## 22. 可能的边界场景 + +### 22.1 子问题没有任何高分候选 + +此时该子问题会得到空意图列表,后续可能: + +- 进入检索空结果兜底 +- 或被当成需要澄清/弱路由的情况 + +### 22.2 LLM 返回未知意图节点 ID + +分类器会跳过这些节点,不会把脏数据带入主链路。 + +### 22.3 多个子问题总意图数过多 + +`capTotalIntents()` 会进行全局裁剪。 + +### 22.4 一个问题本身就是纯系统问题 + +这一步会给出 `SYSTEM` 候选,后续直接触发 `handleSystemOnly()`。 + +## 23. 推荐阅读顺序 + +建议按下面顺序读源码: + +1. `StreamChatPipeline.resolveIntents()` +2. `IntentResolver.resolve()` +3. `IntentResolver.classifyIntents()` +4. `DefaultIntentClassifier.classifyTargets()` +5. `NodeScoreFilters` +6. `IntentGuidanceService.detectAmbiguity()` +7. `RetrievalEngine.buildSubQuestionContext()` + +这样最容易把“生成意图 -> 消费意图”这条链路连起来。 + +## 24. 一句话总结 + +Ragent 的意图解析链路本质上是一套“基于改写结果、面向子问题粒度、使用意图树和 LLM 打分生成候选、再用阈值与裁剪控制复杂度”的能力路由机制:它不是简单回答“用户问了什么”,而是回答“这个问题接下来应该交给系统的哪类能力去处理”,并为歧义澄清、系统直答、知识检索和 MCP 工具调用提供统一的结构化决策依据。 diff --git "a/docs/\346\226\207\344\273\266\344\270\212\344\274\240\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\346\226\207\344\273\266\344\270\212\344\274\240\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..665a022d5 --- /dev/null +++ "b/docs/\346\226\207\344\273\266\344\270\212\344\274\240\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,939 @@ +# Ragent 文件上传链路详解 + +## 1. 文档目标 + +本文聚焦 `knowledge` 模块中的“文件上传链路”,完整解释下面这些问题: + +- 前端上传一个文件后,请求先进入哪里 +- `Controller -> Service -> 存储 -> 数据库` 是如何串起来的 +- 为什么上传成功后文档状态是 `PENDING`,而不是立刻完成 +- 本地文件上传和 URL 导入分别怎么处理 +- 上传时为什么还要同时保存 `processMode`、`chunkStrategy`、`pipelineId` 这些字段 +- 上传阶段和后续“分块 / 向量化 / 入检索库”之间是如何衔接的 + +本文的重点不是只解释某一个方法,而是把整条链路从 HTTP 接口、参数绑定、业务校验、文件落存储、文档元数据建模,一直到后续异步分块触发的关系全部讲清楚。 + +## 2. 链路总览 + +先给出整条链路的总体图。 + +```mermaid +flowchart TD + A["前端 multipart/form-data 请求"] --> B["KnowledgeDocumentController.upload(...)"] + B --> C["KnowledgeDocumentServiceImpl.upload(...)"] + C --> D["查询 KnowledgeBaseDO"] + D --> E["解析 SourceType"] + E --> F["校验来源与调度配置"] + F --> G["解析 ProcessModeConfig"] + G --> H{"sourceType"} + H -->|FILE| I["FileStorageService.upload(...)"] + H -->|URL| J["RemoteFileFetcher.fetchAndStore(...)"] + I --> K["StoredFileDTO"] + J --> K + K --> L["组装 KnowledgeDocumentDO"] + L --> M["documentMapper.insert(...)"] + M --> N["返回 KnowledgeDocumentVO"] + N --> O["文档状态 = PENDING"] + O --> P["后续调用 startChunk(docId)"] + P --> Q["事务消息 + MQ"] + Q --> R["executeChunk / runChunkTask"] + R --> S["文本抽取 / 分块 / embedding / 向量入库"] +``` + +这个图体现了一个非常关键的事实: + +> 上传链路的职责是“先把原始文件和文档元数据稳定落下来”,而不是“在上传接口里直接做完全部知识处理”。 + +## 3. 入口:`KnowledgeDocumentController.upload` + +上传接口入口在 [KnowledgeDocumentController](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java#L63-L68): + +```java +@PostMapping(value = "/knowledge-base/{kb-id}/docs/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) +public Result upload(@PathVariable("kb-id") String kbId, + @RequestPart(value = "file", required = false) MultipartFile file, + @ModelAttribute KnowledgeDocumentUploadRequest requestParam) { + return Results.success(documentService.upload(kbId, requestParam, file)); +} +``` + +这段代码有 3 个关键点。 + +### 3.1 请求类型是 `multipart/form-data` + +这说明接口支持同时接收: + +- 二进制文件流 +- 普通表单字段 + +也就是说,前端不是发纯 JSON,而是发一个混合表单。 + +### 3.2 `file` 是可选参数 + +`file` 的声明是: + +```java +@RequestPart(value = "file", required = false) MultipartFile file +``` + +这意味着: + +- 当 `sourceType=file` 时,必须有 `file` +- 当 `sourceType=url` 时,`file` 可以为空 + +这一点直接决定了该接口不是“只能传本地文件”的上传接口,而是统一兼容: + +- 本地文件上传 +- URL 文档导入 + +### 3.3 表单配置和文件内容是分开的 + +上传配置放在 [KnowledgeDocumentUploadRequest](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/request/KnowledgeDocumentUploadRequest.java): + +- `sourceType` +- `sourceLocation` +- `scheduleEnabled` +- `scheduleCron` +- `processMode` +- `chunkStrategy` +- `chunkConfig` +- `pipelineId` + +这说明一次“上传文档”请求,不只是把文件传上来,还同时声明了: + +- 文档从哪里来 +- 是否启用定时刷新 +- 后续用什么方式处理 + +所以从 API 设计上看,上传接口更准确的语义不是“上传文件”,而是: + +> 创建一条知识文档并定义它后续的处理策略。 + +## 4. 服务入口:`KnowledgeDocumentServiceImpl.upload` + +真正的业务主线在 [KnowledgeDocumentServiceImpl.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L122-L154): + +```java +public KnowledgeDocumentVO upload(String kbId, KnowledgeDocumentUploadRequest requestParam, MultipartFile file) +``` + +这个方法可以拆成 6 个阶段: + +1. 校验知识库存在 +2. 解析来源类型 +3. 校验来源和调度配置 +4. 把原始内容落到存储中 +5. 解析处理模式配置 +6. 组装并插入文档记录 + +下面按顺序详细展开。 + +## 5. 第一阶段:校验知识库存在 + +代码在 [KnowledgeDocumentServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L123-L124): + +```java +KnowledgeBaseDO kbDO = knowledgeBaseMapper.selectById(kbId); +Assert.notNull(kbDO, () -> new ClientException("知识库不存在")); +``` + +这一步看起来简单,但语义非常重要。 + +### 5.1 为什么上传必须先查知识库 + +因为文档不是一个孤立资源,它必须从属于某个知识库。 + +后续很多操作都依赖知识库: + +- 文件要存到哪个 bucket +- 向量最终要写入哪个 collection +- 文档启停时要影响哪个知识空间 + +### 5.2 这里拿到的 `kbDO` 后续有什么用 + +上传链路里马上会用到: + +- `kbDO.getCollectionName()` + +它会被当作: + +- 文件存储 bucket 名 +- 后续向量空间关联标识 + +也就是说,知识库本身不仅是数据库里的一条元数据,还是后续资源定位的锚点。 + +## 6. 第二阶段:解析来源类型 + +代码在 [KnowledgeDocumentServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L126-L127): + +```java +SourceType sourceType = SourceType.normalize(requestParam.getSourceType()); +validateSourceAndSchedule(sourceType, requestParam); +``` + +来源类型定义在 [SourceType](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/enums/SourceType.java): + +- `FILE("file")` +- `URL("url")` + +### 6.1 `normalize(...)` 做了什么 + +`SourceType.normalize(...)` 的逻辑是: + +- 空值直接报错 +- 兼容多种文件别名 + - `file` + - `localfile` + - `local_file` +- 非法值直接抛异常 + +这一步的目的不是简单做个枚举转换,而是: + +- 尽早把输入归一化 +- 从这里开始让后续代码只面向明确的两类来源处理 + +### 6.2 为什么来源类型这么关键 + +因为从这一刻起,上传链路会走两条不同分支: + +- `FILE` + - 直接接收 `MultipartFile` + - 走本地文件上传逻辑 +- `URL` + - 不依赖上传文件 + - 走远程文件抓取逻辑 + +所以 `sourceType` 是整个上传链路的第一个分叉点。 + +## 7. 第三阶段:校验来源和调度配置 + +校验逻辑在 [validateSourceAndSchedule](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L707-L726)。 + +它主要处理两类规则: + +- 来源相关规则 +- 定时刷新相关规则 + +### 7.1 URL 模式必须有 `sourceLocation` + +```java +if (SourceType.URL == sourceType && !StringUtils.hasText(sourceLocation)) { + throw new ClientException("来源地址不能为空"); +} +``` + +这说明: + +- `FILE` 模式依赖 `file` +- `URL` 模式依赖 `sourceLocation` + +二者的输入要求是不一样的。 + +### 7.2 什么情况下才会校验定时刷新 + +方法内部先调用: + +```java +if (!isScheduleEnabled(sourceType, request)) { + return; +} +``` + +而 [isScheduleEnabled](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L703-L705) 的定义是: + +```java +return SourceType.URL == sourceType && Boolean.TRUE.equals(request.getScheduleEnabled()); +``` + +这说明只有在下面两个条件同时满足时,调度才生效: + +- 来源类型是 `URL` +- 请求显式开启 `scheduleEnabled=true` + +### 7.3 定时表达式校验做了什么 + +如果定时刷新开启,系统会校验: + +- `scheduleCron` 不能为空 +- cron 表达式必须合法 +- 触发间隔不能小于系统允许的最小周期 + +这一步使用了 `CronScheduleHelper.isIntervalLessThan(...)` 做最小间隔保护。 + +### 7.4 为什么上传阶段就做调度校验 + +因为调度配置本身就是文档的一部分。 + +这个项目的设计不是: + +- 先上传文档 +- 后面单独再创建定时任务 + +而是: + +- 上传文档时一并定义“这份文档未来是否自动刷新” + +这说明调度在知识文档模型里是内聚的,而不是外挂功能。 + +## 8. 第四阶段:把原始内容落存储 + +这是上传链路中最核心的物理落盘阶段。 + +代码入口在 [resolveStoredFile](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L747-L753): + +```java +private StoredFileDTO resolveStoredFile(String bucketName, SourceType sourceType, String sourceLocation, MultipartFile file) { + if (SourceType.FILE == sourceType) { + Assert.notNull(file, () -> new ClientException("上传文件不能为空")); + return fileStorageService.upload(bucketName, file); + } + return remoteFileFetcher.fetchAndStore(bucketName, sourceLocation); +} +``` + +这一步的设计意义非常大: + +- 多种来源统一收敛成 `StoredFileDTO` +- 后续组装文档记录时,不再区分文件来自本地还是远程 + +也就是说: + +> `resolveStoredFile(...)` 是“多来源归一化”的核心适配点。 + +下面分两条支线讲。 + +## 9. 分支一:本地文件上传链路 + +本地文件模式下,会调用: + +- [FileStorageService.upload(String bucketName, MultipartFile file)](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/FileStorageService.java#L33-L33) + +当前实现是: + +- [S3FileStorageService.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L57-L76) + +### 9.1 第一步:基础校验 + +```java +validateBucketName(bucketName); +Assert.isFalse(file == null || file.isEmpty(), "上传文件不能为空"); +``` + +它确保: + +- bucketName 合法 +- 上传文件不为空 + +### 9.2 第二步:读取原始元信息 + +```java +String originalFilename = file.getOriginalFilename(); +long size = file.getSize(); +``` + +这里取的是上传文件的基础属性,后面会用于: + +- 记录文档名 +- 记录文件大小 +- 构造 S3 key + +### 9.3 第三步:用 Tika 探测 MIME 类型 + +```java +try (InputStream is = file.getInputStream()) { + detectedContentType = TIKA.detect(is, originalFilename); +} +``` + +这里非常值得注意: + +- 系统不只依赖前端传来的 content-type +- 而是主动用 `Tika` 探测文件类型 + +这样做的好处是: + +- 更稳 +- 不容易被前端错误声明的 MIME 类型误导 +- 后续抽取和分类更可靠 + +### 9.4 第四步:再次打开流并上传 + +```java +try (InputStream is = file.getInputStream()) { + return streamUploadToS3(bucketName, is, size, originalFilename, detectedContentType); +} +``` + +为什么这里重新打开了一次流? + +代码注释已经解释: + +- `MultipartFile.getInputStream()` 每次调用都会返回一个新流 +- 第一次流被 Tika 用来探测类型 +- 第二次流才真正拿去上传 + +### 9.5 第五步:流式上传到 S3 + +真正上传发生在 [streamUploadToS3](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L122-L143)。 + +它分成三步: + +#### 1. 生成 S3 key + +```java +String s3Key = generateS3Key(originalFilename); +``` + +它不是直接使用原始文件名,而是用随机 key + 原扩展名。 + +这样做的好处: + +- 避免重名覆盖 +- URL 更稳定 +- 与原始展示名解耦 + +#### 2. 生成预签名 PUT URL + +```java +PresignedPutObjectRequest presignedReq = s3Presigner.presignPutObject(...) +``` + +#### 3. 用 `HttpURLConnection` 执行流式上传 + +```java +streamPutViaPresignedUrl(presignedReq, inputStream, size, detectedContentType); +``` + +这里是整个上传实现里最专业的地方之一。 + +作者没有直接使用同步 `putObject`,而是: + +- 用预签名 URL +- 配合 `HttpURLConnection.setFixedLengthStreamingMode(size)` +- 直接把输入流持续写到远端 + +代码注释明确说明了原因: + +- AWS SDK 某些同步/异步上传 API 在签名管线里可能缓冲 payload +- 对大文件上传不友好 +- 预签名 URL + 流式 PUT 可以显著降低堆内存占用 + +这说明这里的上传实现不是“先求能用”,而是已经考虑了: + +- 文件尺寸 +- 内存占用 +- 上传路径的工程稳定性 + +### 9.6 第六步:返回统一 `StoredFileDTO` + +上传完成后会构造: + +- `url`,例如 `s3://bucket/key` +- `detectedType` +- `size` +- `originalFilename` + +这就是上传链路后续使用的统一文件描述对象。 + +## 10. 分支二:URL 导入链路 + +如果 `sourceType=url`,不会使用 `MultipartFile`,而是走: + +- [RemoteFileFetcher.fetchAndStore](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/handler/RemoteFileFetcher.java#L59-L73) + +这条链路更复杂,因为远程站点不可靠,系统要自己做更多保护。 + +### 10.1 第一步:尝试 HEAD 预检 + +```java +HttpClientHelper.HttpHeadResponse headResponse = tryHead(url); +``` + +目的: + +- 拿 `Content-Length` +- 拿 `Content-Type` +- 拿文件名 + +如果 HEAD 失败,不会直接失败,而是: + +- 记 debug 日志 +- 继续走直接下载 + +这体现了远程导入链路的容错思路。 + +### 10.2 第二步:做大小限制检查 + +```java +checkSizeLimit(maxBytes, headContentLength); +``` + +也就是说,远程文件导入同样受系统上传大小限制控制。 + +### 10.3 第三步:打开远程流 + +```java +try (HttpClientHelper.HttpFetchStream response = httpClientHelper.openStream(url, Map.of(), maxBytes)) { +``` + +此时系统真正开始拉取远程文件内容。 + +### 10.4 第四步:为什么先落临时文件 + +核心代码: + +```java +return uploadViaTemp(bucketName, response.bodyStream(), fileName, contentType, maxBytes); +``` + +这里作者没有直接把远程响应流转发给 S3。 + +原因在注释里写得非常清楚: + +- 某些源站的 `Content-Length` 不可靠 +- HEAD 返回值也不一定可信 +- 如果用固定长度流式上传,一旦字节数和声明长度不一致,上传会失败 + +所以这里统一策略是: + +- 先下载到本地 temp 文件 +- 统计真实大小 +- 再用真实大小上传到 S3 + +这是一种“牺牲一点临时磁盘 I/O,换上传稳定性”的工程取舍。 + +### 10.5 第五步:真正上传临时文件 + +在 [uploadViaTemp](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/handler/RemoteFileFetcher.java#L140-L163) 中: + +- 先 `copyWithLimit(...)` +- 再用 `Files.newInputStream(tempFile)` 打开本地临时文件 +- 调用 `fileStorageService.upload(bucketName, tempInputStream, size, fileName, contentType)` +- 最后删除临时文件 + +这意味着 URL 导入最终仍然统一复用了 `FileStorageService`。 + +所以从上层看: + +- `FILE` 模式是“用户传本地文件 -> 存 S3” +- `URL` 模式是“系统拉远程文件 -> 临时落地 -> 再存 S3” + +最后都收敛成相同的 `StoredFileDTO`。 + +## 11. 第五阶段:解析后续处理模式 + +文件一旦存好,上传链路就进入“文档处理配置建模”阶段。 + +逻辑在 [resolveProcessModeConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L728-L745)。 + +支持两种模式: + +- `CHUNK` +- `PIPELINE` + +### 11.1 `CHUNK` 模式 + +如果是 `chunk` 模式: + +```java +ChunkingMode chunkingMode = ChunkingMode.fromValue(request.getChunkStrategy()); +String chunkConfig = validateAndNormalizeChunkConfig(chunkingMode, request.getChunkConfig()); +return new ProcessModeConfig(processMode, chunkingMode, chunkConfig, null); +``` + +这一步要求: + +- 必须指定分块策略 +- 必须指定合法的 chunkConfig JSON + +并且 `validateAndNormalizeChunkConfig(...)` 会校验: + +- JSON 语法合法 +- 必要字段齐全 + +### 11.2 `PIPELINE` 模式 + +如果是 `pipeline` 模式: + +```java +if (!StringUtils.hasText(request.getPipelineId())) { + throw new ClientException("使用Pipeline模式时,必须指定Pipeline ID"); +} +ingestionPipelineService.get(request.getPipelineId()); +``` + +也就是说: + +- 必须有 `pipelineId` +- 指定的 pipeline 必须真的存在 + +### 11.3 为什么上传阶段就要定死处理模式 + +因为这份文档后续处理不是临时决定的。 + +系统希望在文档记录里直接保存: + +- 用什么模式处理 +- 用什么分块策略 +- 用哪个 pipeline + +这样后续: + +- 重试分块 +- 定时刷新 +- 启用/禁用后重建向量 + +都可以直接复用这份配置,而不需要用户每次重新传一遍参数。 + +## 12. 第六阶段:构建 `KnowledgeDocumentDO` + +文件存好、处理模式解析完之后,系统开始构建文档数据库对象。 + +代码在 [KnowledgeDocumentServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L131-L150)。 + +### 12.1 基础身份字段 + +```java +.kbId(kbId) +.docName(stored.getOriginalFilename()) +``` + +- `kbId` + - 该文档属于哪个知识库 +- `docName` + - 展示名称,直接取上传或远程推断出的原始文件名 + +### 12.2 初始状态字段 + +```java +.enabled(1) +.chunkCount(0) +.status(DocumentStatus.PENDING.getCode()) +``` + +这里很关键: + +- `enabled=1` + - 文档默认可用 +- `chunkCount=0` + - 因为上传阶段还没开始分块 +- `status=PENDING` + - 表示“文件已经收到了,但还没真正处理” + +这说明上传链路只是前半程,不代表内容已经进入检索系统。 + +### 12.3 文件元信息字段 + +```java +.fileUrl(stored.getUrl()) +.fileType(stored.getDetectedType()) +.fileSize(stored.getSize()) +``` + +这三项分别表示: + +- 原始文件存储地址 +- 检测出的文件类型 +- 文件大小 + +其中 `fileUrl` 很重要,它是后续: + +- 文本抽取 +- 重新处理 +- 定时刷新后的旧文件清理 + +的基础定位信息。 + +### 12.4 来源与调度字段 + +```java +.sourceType(sourceType.getValue()) +.sourceLocation(SourceType.URL == sourceType ? StrUtil.trimToNull(requestParam.getSourceLocation()) : null) +.scheduleEnabled(isScheduleEnabled(sourceType, requestParam) ? 1 : 0) +.scheduleCron(isScheduleEnabled(sourceType, requestParam) ? StrUtil.trimToNull(requestParam.getScheduleCron()) : null) +``` + +这组字段说明: + +- 系统不会丢掉“原始来源” +- URL 文档的真实来源地址会被保留下来 +- 是否自动刷新、刷新周期也会一起保存 + +这为后续调度系统提供了完整上下文。 + +### 12.5 处理模式字段 + +```java +.processMode(modeConfig.processMode().getValue()) +.chunkStrategy(modeConfig.chunkingMode() != null ? modeConfig.chunkingMode().getValue() : null) +.chunkConfig(modeConfig.chunkConfig()) +.pipelineId(modeConfig.pipelineId()) +``` + +这组字段是整条链路里最重要的“后处理定义”。 + +它们告诉系统: + +- 这份文档以后走 `chunk` 还是 `pipeline` +- 如果走 `chunk`,具体用哪种策略和参数 +- 如果走 `pipeline`,对应哪个 pipeline + +也就是说,文档记录本身已经变成了一个“可重复执行的处理任务定义”。 + +### 12.6 审计字段 + +```java +.createdBy(UserContext.getUsername()) +.updatedBy(UserContext.getUsername()) +``` + +这里保存了当前操作者。 + +这对管理后台和问题追踪很重要。 + +## 13. 第七阶段:写库并返回 + +构建完 `KnowledgeDocumentDO` 后,执行: + +```java +documentMapper.insert(documentDO); +return BeanUtil.toBean(documentDO, KnowledgeDocumentVO.class); +``` + +### 13.1 写库代表什么 + +写库完成后,系统里已经稳定保存了: + +- 原始文件存储地址 +- 文档来源类型 +- 调度配置 +- 处理模式配置 +- 文档当前状态 + +从这一刻起,这份文档已经是系统中的正式资源。 + +### 13.2 为什么返回 `KnowledgeDocumentVO` + +因为前端通常需要立即展示: + +- 文档名称 +- 当前状态 +- 文件大小 +- 处理模式 +- 是否开启调度 + +所以这里直接把 `DO` 转成 `VO` 返回,而不是只给一个 `docId`。 + +## 14. 上传完成后,为什么还没有分块 + +这是理解这条链路最关键的一点。 + +上传方法结束时,文档状态是: + +- `PENDING` + +这意味着: + +- 文件已存储 +- 文档元数据已写库 +- 但还没有进行: + - 文本抽取 + - chunk 切分 + - embedding + - 向量入库 + +也就是说,上传只是知识处理生命周期的第一阶段。 + +真正开始处理内容,要调用: + +- [startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L156-L186) + +## 15. 上传后如何衔接到分块处理 + +后续链路是: + +1. 调用 `startChunk(docId)` +2. 事务消息把文档状态改成 `RUNNING` +3. 发送 `KnowledgeDocumentChunkEvent` +4. MQ 消费者调用 `executeChunk(docId)` +5. 最终进入 `runChunkTask(documentDO)` + +对应代码: + +- [startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L156-L186) +- [executeChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L188-L197) +- [runChunkTask](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L199-L241) +- [KnowledgeDocumentChunkConsumer](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/mq/KnowledgeDocumentChunkConsumer.java#L42-L58) + +这条衔接链路说明: + +- 上传阶段和处理阶段被明确解耦 +- 用户上传请求不会阻塞在长耗时任务上 +- 同一份文档可以后续重试、重跑、重新切分 + +## 16. 为什么这条上传链路这样设计 + +这条链路背后有几个很强的工程设计意图。 + +### 16.1 上传和处理解耦 + +如果在上传接口里直接做文本抽取、分块、embedding: + +- 请求会很慢 +- 容易超时 +- 失败后用户体验差 +- 难以支持重试 + +所以作者把它拆成: + +- 上传阶段:确保文件和元数据可靠落地 +- 处理阶段:异步消费,慢慢做内容处理 + +### 16.2 多来源统一收敛 + +无论是: + +- 用户本地上传文件 +- 系统远程拉 URL 文件 + +最终都会收敛成: + +- `StoredFileDTO` + +这让后续文档建模代码不需要关心来源差异。 + +### 16.3 文档记录不是“文件表”,而是“处理任务定义” + +文档表里保存的不只是: + +- 文件 URL +- 文件大小 + +还保存了: + +- 来源信息 +- 调度信息 +- 分块策略 +- pipeline 信息 + +这让文档成为一个可重复执行、可自动刷新、可重新入索引的业务对象。 + +### 16.4 对远程导入做了专门的稳定性设计 + +`RemoteFileFetcher` 这条链路很能体现作者的工程意识: + +- 先 HEAD +- 做大小限制 +- 远程流先落 temp +- 再用真实字节数上传 +- 临时文件保证清理 + +这不是简单“下载一下文件”,而是明显考虑过: + +- 远程站点不靠谱 +- `Content-Length` 不稳定 +- 大文件限制 +- 流式上传兼容性 + +## 17. 关键代码片段与职责归纳 + +### 17.1 Controller 入口 + +- [KnowledgeDocumentController.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java#L63-L68) +- 职责: + - 接收 multipart 请求 + - 转发给 service + +### 17.2 Service 主入口 + +- [KnowledgeDocumentServiceImpl.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L122-L154) +- 职责: + - 串联整条上传业务逻辑 + +### 17.3 来源和调度校验 + +- [validateSourceAndSchedule](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L707-L726) +- 职责: + - 校验 `sourceLocation` + - 校验 cron + - 控制 URL 调度场景 + +### 17.4 文件归一化入口 + +- [resolveStoredFile](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L747-L753) +- 职责: + - 把 FILE / URL 两条支线统一成 `StoredFileDTO` + +### 17.5 本地文件存储实现 + +- [S3FileStorageService.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L57-L76) +- [streamUploadToS3](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L122-L143) +- 职责: + - MIME 探测 + - S3 流式上传 + - 返回统一存储描述 + +### 17.6 URL 拉取实现 + +- [RemoteFileFetcher.fetchAndStore](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/handler/RemoteFileFetcher.java#L59-L73) +- [uploadViaTemp](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/handler/RemoteFileFetcher.java#L140-L163) +- 职责: + - 远程 HEAD/GET + - 大小限制 + - 临时文件落地 + - 再上传到统一存储 + +### 17.7 后续处理衔接 + +- [startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L156-L186) +- [runChunkTask](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L199-L241) +- 职责: + - 把 `PENDING` 文档推进到真正的内容处理阶段 + +## 18. 一个完整的时序示例 + +假设用户上传一个 PDF 文件,并选择: + +- `sourceType=file` +- `processMode=chunk` +- `chunkStrategy=structure_aware` + +真实时序如下: + +1. 前端发 `multipart/form-data` +2. Controller 收到 `kbId + file + requestParam` +3. Service 校验知识库存在 +4. 解析 `sourceType=file` +5. 校验来源和调度配置 +6. 调 `fileStorageService.upload(collectionName, file)` +7. `S3FileStorageService` 用 Tika 探测类型 +8. 通过预签名 URL 流式 PUT 到 S3 +9. 返回 `StoredFileDTO` +10. Service 解析 `processMode=chunk` +11. 校验 `chunkStrategy/chunkConfig` +12. 组装 `KnowledgeDocumentDO` +13. 插入 `knowledge_document` 表 +14. 返回 `KnowledgeDocumentVO` +15. 此时状态为 `PENDING` +16. 后续用户再调用 `startChunk(docId)` +17. 系统异步执行文本抽取、分块、embedding 和向量入库 + +## 19. 学习这条链路的推荐顺序 + +建议按下面顺序阅读源码: + +1. [KnowledgeDocumentController.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java#L63-L68) +2. [KnowledgeDocumentServiceImpl.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L122-L154) +3. [validateSourceAndSchedule](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L707-L726) +4. [resolveProcessModeConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L728-L745) +5. [resolveStoredFile](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L747-L753) +6. [S3FileStorageService.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L57-L76) +7. [RemoteFileFetcher.fetchAndStore](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/handler/RemoteFileFetcher.java#L59-L73) +8. [startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L156-L186) +9. [runChunkTask](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L199-L241) + +这样能最顺畅地把“上传”和“后续处理”连成一条完整链路。 + +## 20. 一句话总结 + +Ragent 的文件上传链路本质上是一条“先可靠入库、再异步处理”的知识文档创建链路:它先通过 `Controller -> KnowledgeDocumentServiceImpl.upload` 完成知识库校验、来源识别、调度参数校验和处理模式解析,再根据 `FILE` 或 `URL` 两种来源将原始内容统一落到 S3 存储并收敛为 `StoredFileDTO`,随后把文件元信息、来源信息、调度配置和后处理策略一起写入 `KnowledgeDocumentDO`,将文档状态置为 `PENDING`,最后通过 `startChunk -> MQ -> runChunkTask` 在后续阶段完成文本抽取、分块、embedding 与向量入库,从而把一次上传动作演化为一条可重试、可调度、可持续更新的知识处理任务。 diff --git "a/docs/\346\226\207\346\241\243\345\210\206\345\235\227\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\346\226\207\346\241\243\345\210\206\345\235\227\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..94d407d0d --- /dev/null +++ "b/docs/\346\226\207\346\241\243\345\210\206\345\235\227\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,955 @@ +# Ragent 文档分块链路详解 + +## 1. 文档目标 + +本文聚焦 [KnowledgeDocumentController.startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java#L73-L77) 这条“文档分块”入口,完整解释下面几个问题: + +- 用户点击“开始分块”之后,请求先进入哪里 +- 为什么这里只是一个很短的接口,但背后实际是一条很长的异步处理链 +- 为什么系统使用 RocketMQ 事务消息,而不是直接在接口里同步执行 +- `chunk` 模式和 `pipeline` 模式分别怎么跑 +- 文本是如何被抽取、切分、Embedding,并最终写入 Chunk 表和向量库的 +- 失败时状态怎么回滚、日志怎么记录、为什么这条链路支持重复触发 + +本文的重点不是只解释某一个方法,而是把这条链路从 HTTP 入口、服务层状态机、事务消息、MQ 消费、分块策略、Embedding、向量持久化,到最终状态落库和运行日志全部串起来。 + +## 2. 链路总览 + +先看整体流程图: + +```mermaid +flowchart TD + A["POST /knowledge-base/docs/{doc-id}/chunk"] --> B["KnowledgeDocumentController.startChunk(docId)"] + B --> C["KnowledgeDocumentServiceImpl.startChunk(docId)"] + C --> D["构造 KnowledgeDocumentChunkEvent"] + D --> E["sendInTransaction 发送 RocketMQ 事务消息"] + E --> F["本地事务: 文档状态更新为 RUNNING"] + F --> G["同步 URL 文档调度记录"] + G --> H["事务提交后消息可投递"] + H --> I["KnowledgeDocumentChunkConsumer.onMessage"] + I --> J["documentService.executeChunk(docId)"] + J --> K["runChunkTask(documentDO)"] + K --> L{"processMode"} + L -->|chunk| M["文本抽取 -> 分块 -> Embedding"] + L -->|pipeline| N["IngestionEngine 执行流水线"] + M --> O["persistChunksAndVectorsAtomically"] + N --> O + O --> P["删除旧 Chunk/旧向量"] + P --> Q["写入新 Chunk/新向量"] + Q --> R["文档状态更新为 SUCCESS"] + K --> S["写入分块执行日志"] + K --> T["异常时标记 FAILED 并记录 errorMessage"] +``` + +这个图体现了一个关键事实: + +> “开始分块”不是一个同步计算接口,而是一个“触发异步摄入任务”的入口。 + +也就是说,Controller 看起来只有一行 `documentService.startChunk(docId)`,但真实职责是: + +- 接受用户触发 +- 可靠地把文档状态切到 `RUNNING` +- 把异步任务可靠投递到 MQ +- 后续由消费者执行真正的重活 + +## 3. 入口:`KnowledgeDocumentController.startChunk` + +入口代码在 [KnowledgeDocumentController](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java#L73-L77): + +```java +@PostMapping("/knowledge-base/docs/{doc-id}/chunk") +public Result startChunk(@PathVariable(value = "doc-id") String docId) { + documentService.startChunk(docId); + return Results.success(); +} +``` + +这段代码本身很薄,只做两件事: + +1. 通过 `@PathVariable` 取出文档 ID +2. 把业务交给 `KnowledgeDocumentService` + +这是一种典型的分层设计: + +- Controller 只负责 HTTP 协议层 +- Service 负责业务状态流转 + +所以真正需要重点看的,不是 Controller,而是 [KnowledgeDocumentServiceImpl.startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L157-L186)。 + +## 4. 服务入口:`KnowledgeDocumentServiceImpl.startChunk` + +核心代码如下: + +```java +public void startChunk(String docId) { + KnowledgeDocumentChunkEvent event = KnowledgeDocumentChunkEvent.builder() + .docId(docId) + .operator(UserContext.getUsername()) + .build(); + + messageQueueProducer.sendInTransaction( + chunkTopic, + docId, + "文档分块", + event, + arg -> { + int updated = documentMapper.update( + new LambdaUpdateWrapper() + .set(KnowledgeDocumentDO::getStatus, DocumentStatus.RUNNING.getCode()) + .set(KnowledgeDocumentDO::getUpdatedBy, event.getOperator()) + .eq(KnowledgeDocumentDO::getId, docId) + .ne(KnowledgeDocumentDO::getStatus, DocumentStatus.RUNNING.getCode()) + ); + if (updated == 0) { + KnowledgeDocumentDO documentDO = documentMapper.selectById(docId); + Assert.notNull(documentDO, () -> new ClientException("文档不存在")); + throw new ClientException("文档分块操作正在进行中,请稍后再试"); + } + KnowledgeDocumentDO documentDO = documentMapper.selectById(docId); + event.setKbId(documentDO.getKbId()); + scheduleService.upsertSchedule(documentDO); + } + ); +} +``` + +这个方法可以拆成 4 个阶段: + +1. 构造分块事件 +2. 发送 RocketMQ 事务消息 +3. 在本地事务里把文档状态切到 `RUNNING` +4. 补齐事件上下文并同步调度记录 + +下面逐段解释。 + +## 5. 第一阶段:构造任务事件 + +事件类定义在 [KnowledgeDocumentChunkEvent](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/mq/event/KnowledgeDocumentChunkEvent.java#L35-L54): + +```java +public class KnowledgeDocumentChunkEvent implements Serializable { + private String docId; + private String kbId; + private String operator; +} +``` + +这里的 3 个字段分别承担不同职责: + +- `docId` + - 唯一定位这次要处理的文档 +- `kbId` + - 便于后续按知识库维度补充上下文 + - 注意它不是一开始就填,而是在本地事务里查文档后补进去 +- `operator` + - 记录是谁触发了这次任务 + - 这个字段后面会通过 `UserContext` 透传给消费者线程 + +这说明消息体不是“只带一个文档 ID”的极简载荷,而是带了最基本的操作上下文。 + +## 6. 第二阶段:为什么这里要用事务消息 + +### 6.1 如果直接同步执行,会有什么问题 + +如果在 HTTP 请求里直接做下面这些动作: + +- 打开文件流 +- 抽取文本 +- 执行分块 +- 调用 Embedding API +- 写 Chunk 表 +- 写向量库 + +会有几个明显问题: + +- 请求耗时很长,接口容易超时 +- 一次文档重分块可能比较重,不适合占用 Web 线程 +- 失败恢复麻烦,用户看不到稳定的处理中状态 +- 多次重复点击时容易并发重入 + +所以项目采用的是: + +> 请求线程只负责“可靠触发任务”,真正的重计算交给 MQ 消费者异步执行。 + +### 6.2 为什么不是普通异步消息,而是事务消息 + +这里最核心的一致性问题是: + +- 如果消息先发出去了,但数据库状态没改成 `RUNNING` +- 或者数据库已经改成 `RUNNING`,但消息没发出去 + +系统状态都会不一致。 + +因此代码使用的是 [MessageQueueProducer.sendInTransaction](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/mq/producer/MessageQueueProducer.java#L41-L55): + +- 先发 half 消息 +- 再执行本地事务 +- 本地事务成功才提交消息 +- 本地事务失败则回滚消息 + +这就是“状态更新”和“任务投递”之间的可靠绑定。 + +## 7. RocketMQ 事务消息底座 + +### 7.1 生产者适配器 + +真正执行发送的是 [RocketMQProducerAdapter.sendInTransaction](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/mq/producer/RocketMQProducerAdapter.java#L65-L90)。 + +它做了几件事: + +- 为每次事务消息生成 `txId` +- 把本地事务逻辑注册到 `DelegatingTransactionListener` +- 用 `MessageWrapper` 包装业务载荷 +- 在消息头里写入 `txId` 和 `topic` +- 调用 `rocketMQTemplate.sendMessageInTransaction(...)` + +这里的 [MessageWrapper](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/mq/MessageWrapper.java#L36-L62) 很重要,它统一包了: + +- `keys` +- `body` +- `uuid` +- `timestamp` + +也就是说,业务消息在进 MQ 之前先被封成一个统一信封,便于日志、幂等和回查。 + +### 7.2 本地事务监听器 + +[DelegatingTransactionListener](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/mq/producer/DelegatingTransactionListener.java#L65-L102) 负责两件事: + +- `executeLocalTransaction` + - 从内存 Map 里取出当前消息对应的本地事务逻辑 + - 用 `TransactionTemplate` 包一层数据库事务 + - 成功返回 `COMMIT` + - 异常返回 `ROLLBACK` +- `checkLocalTransaction` + - Broker 回查时,按 `topic` 找到对应的 `TransactionChecker` + - 再由业务实现类根据数据库状态判断到底该 `COMMIT` 还是 `ROLLBACK` + +这套设计的关键点在于: + +> 本地事务逻辑是“当前实例内存态”,但回查逻辑必须是“可跨实例共享的 DB 判断逻辑”。 + +因为 RocketMQ 回查时,Broker 可能把请求打到集群内任意一个实例。 + +### 7.3 文档分块的事务回查器 + +文档分块对应的回查实现是 [KnowledgeDocumentChunkTransactionChecker](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/mq/KnowledgeDocumentChunkTransactionChecker.java#L42-L65)。 + +核心判断只有一句: + +```java +return documentDO != null + && DocumentStatus.RUNNING.getCode().equals(documentDO.getStatus()); +``` + +这句话的业务含义是: + +- 如果数据库里文档已经变成 `RUNNING` + - 说明本地事务已经提交 + - 这条消息应该被真正投递 +- 如果不是 `RUNNING` + - 说明本地事务没有成功提交 + - 这条消息应该回滚 + +这就把“MQ 最终是否投递”与“数据库状态是否切换成功”绑定起来了。 + +## 8. 第三阶段:本地事务里到底做了什么 + +`startChunk` 里的本地事务逻辑,不是简单更新状态,而是做了 3 件关键事。 + +### 8.1 用条件更新把文档状态切到 `RUNNING` + +代码在 [KnowledgeDocumentServiceImpl.startChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L169-L175): + +```java +new LambdaUpdateWrapper() + .set(KnowledgeDocumentDO::getStatus, DocumentStatus.RUNNING.getCode()) + .set(KnowledgeDocumentDO::getUpdatedBy, event.getOperator()) + .eq(KnowledgeDocumentDO::getId, docId) + .ne(KnowledgeDocumentDO::getStatus, DocumentStatus.RUNNING.getCode()) +``` + +这里最重要的是最后一行: + +- `ne(status, RUNNING)` + +它表示: + +- 只有当前不在 `RUNNING` 状态时,才允许更新 + +这实际上就是一个轻量级防重入控制。 + +### 8.2 为什么 `updated == 0` 时要二次查询 + +如果更新条数为 0,代码会再次 `selectById(docId)`,然后区分两种情况: + +- 文档不存在 +- 文档存在,但已经在分块中 + +于是最终能给前端返回更准确的业务错误: + +- `"文档不存在"` +- `"文档分块操作正在进行中,请稍后再试"` + +这比简单抛一个“更新失败”要友好得多。 + +### 8.3 在这里同步调度记录 + +本地事务的最后还调用了 [KnowledgeDocumentScheduleServiceImpl.upsertSchedule](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentScheduleServiceImpl.java#L50-L119)。 + +这个动作只会对 `URL` 类型文档生效,因为 `syncSchedule(...)` 里先判断了: + +```java +if (!SourceType.URL.getValue().equalsIgnoreCase(documentDO.getSourceType())) { + return; +} +``` + +这说明一个细节: + +- 上传阶段只是保存“调度配置” +- 真正创建或更新调度记录,是在首次启动分块时 + +从业务语义上看,这相当于把“开始分块”视为文档正式进入托管生命周期的起点。 + +## 9. 状态机:文档状态如何流转 + +文档状态定义在 [DocumentStatus](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/enums/DocumentStatus.java#L30-L55): + +- `PENDING` +- `RUNNING` +- `FAILED` +- `SUCCESS` + +结合上传链路和分块链路,完整状态流转是: + +```text +上传成功 -> PENDING +触发分块 -> RUNNING +处理成功 -> SUCCESS +处理失败 -> FAILED +再次触发分块 -> 再次进入 RUNNING +``` + +注意这里有一个非常值得学习的设计点: + +> 系统只禁止“RUNNING 时再次触发”,并不禁止对 `FAILED` 或 `SUCCESS` 文档重新分块。 + +这意味着: + +- 失败后可以重试 +- 成功后也可以重建 Chunk 和向量 + +这对知识库运维非常重要,因为分块策略、Embedding 模型、原始文件内容都可能变化。 + +## 10. MQ 消费入口:`KnowledgeDocumentChunkConsumer` + +消息提交成功后,会由 [KnowledgeDocumentChunkConsumer](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/mq/KnowledgeDocumentChunkConsumer.java#L42-L58) 消费: + +```java +public void onMessage(MessageWrapper message) { + KnowledgeDocumentChunkEvent event = message.getBody(); + UserContext.set(LoginUser.builder().username(event.getOperator()).build()); + try { + documentService.executeChunk(event.getDocId()); + } finally { + UserContext.clear(); + } +} +``` + +这段代码的重点不是“调用了 `executeChunk`”,而是这句: + +```java +UserContext.set(LoginUser.builder().username(event.getOperator()).build()); +``` + +它说明消费者线程会把消息里的 `operator` 恢复成当前线程上下文。 + +这样后面这些写库动作仍然能拿到统一的操作人: + +- `updatedBy` +- Chunk 的 `createdBy / updatedBy` +- 失败标记时的审计字段 + +这是异步任务里非常常见、也非常容易遗漏的技术细节。 + +## 11. 任务执行入口:`executeChunk` + +[KnowledgeDocumentServiceImpl.executeChunk](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L188-L197) 先做一次文档存在性检查: + +```java +KnowledgeDocumentDO documentDO = documentMapper.selectById(docId); +if (documentDO == null) { + log.warn("文档不存在,跳过分块任务, docId={}", docId); + return; +} +runChunkTask(documentDO); +``` + +这一步的意义是: + +- 防御消息延迟或重试导致的“文档已被删除”场景 +- 避免消费者因为查不到文档而反复报错重试 + +真正的总编排逻辑在 [runChunkTask](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L199-L248)。 + +## 12. 总编排:`runChunkTask` + +`runChunkTask` 可以理解成“文档分块任务调度器”,它统一负责: + +- 建立分块执行日志 +- 判断走 `chunk` 还是 `pipeline` +- 执行实际处理 +- 持久化 Chunk 和向量 +- 更新最终状态 +- 捕获异常并写失败日志 + +### 12.1 先插入一条运行日志 + +日志实体是 [KnowledgeDocumentChunkLogDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeDocumentChunkLogDO.java#L39-L119)。 + +在任务开始时会先插入一条 `RUNNING` 记录,提前把这些维度保存下来: + +- `docId` +- `status` +- `processMode` +- `chunkStrategy` +- `pipelineId` +- `startTime` + +这说明项目不是等任务结束后才补日志,而是采用: + +> “先记开始,再记结果”的运行日志模型。 + +这样即使任务中途崩了,也至少知道它启动过。 + +### 12.2 记录分阶段耗时 + +`runChunkTask` 里维护了 4 组时间指标: + +- `extractDuration` +- `chunkDuration` +- `embedDuration` +- `persistDuration` + +再加上总耗时 `totalDuration`,就能很清楚地知道: + +- 慢在文本抽取 +- 还是慢在分块策略 +- 还是慢在 Embedding API +- 还是慢在写库 + +这对后续性能调优和线上排障非常有用。 + +### 12.3 按 `processMode` 分叉 + +分支判断在 [runChunkTask](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L219-L231): + +```java +if (ProcessMode.PIPELINE == processMode) { + chunkResults = runPipelineProcess(documentDO); +} else { + ChunkProcessResult result = runChunkProcess(documentDO); + ... + chunkResults = result.chunks(); +} +``` + +这里的架构思想很清晰: + +- 上层编排统一 +- 下层处理实现可替换 + +不管走哪种模式,最后都要返回统一的 `List`,然后进入同一个持久化出口。 + +## 13. 默认主路径:`chunk` 模式 + +默认主路径是 [runChunkProcess](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L298-L322)。 + +它实际上包含 3 个核心阶段: + +1. Extract +2. Chunk +3. Embed + +### 13.1 Extract:从文件存储里打开流并提取纯文本 + +代码是: + +```java +try (InputStream is = fileStorageService.openStream(documentDO.getFileUrl())) { + String text = parserSelector.select(ParserType.TIKA.getType()).extractText(is, documentDO.getDocName()); +} +``` + +这里有两个关键角色。 + +第一个是 `fileStorageService.openStream(...)`: + +- 不直接依赖本地磁盘 +- 说明文档原始文件可能存放在 OSS、S3、NFS 或其他对象存储 + +第二个是 [DocumentParserSelector](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/parser/DocumentParserSelector.java#L42-L82): + +- 这是一个解析器策略选择器 +- 当前分块链路里固定选了 `TIKA` + +真正做文本抽取的是 [TikaDocumentParser](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/parser/TikaDocumentParser.java#L48-L78): + +- 调用 Apache Tika 解析 PDF、Word、Excel、PPT 等多种格式 +- 解析后再走 `TextCleanupUtil.cleanup(text)` 做文本清洗 + +所以这里的“抽取文本”不是简单读字符串,而是一个文档理解过程: + +- 二进制文件 +- 经过 Tika 解析 +- 变成后续可切分的纯文本 + +### 13.2 Chunk:按分块策略切成 `VectorChunk` + +抽取完文本后,代码会先构造分块配置: + +```java +ChunkingMode chunkingMode = ChunkingMode.fromValue(documentDO.getChunkStrategy()); +ChunkingOptions config = buildChunkingOptions(chunkingMode, documentDO); +ChunkingStrategy chunkingStrategy = chunkingStrategyFactory.requireStrategy(chunkingMode); +List chunks = chunkingStrategy.chunk(text, config); +``` + +这一段背后有 4 层抽象。 + +#### 13.2.1 `ChunkingMode` + +[ChunkingMode](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingMode.java#L34-L152) 定义了两种策略: + +- `FIXED_SIZE` +- `STRUCTURE_AWARE` + +并且每个模式都负责: + +- 定义自己的默认参数 +- 把 JSON 配置转成类型安全的 `ChunkingOptions` + +这意味着“策略”和“配置结构”是绑在一起的,而不是所有策略共用一套松散 Map。 + +#### 13.2.2 `ChunkingOptions` + +[ChunkingOptions](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingOptions.java#L22-L35) 是一个 sealed interface。 + +它的意义是: + +- 固定大小策略用 `FixedSizeOptions` +- 结构感知策略用 `TextBoundaryOptions` + +这样每个策略都能拿到自己强类型的参数对象,避免到处手写魔法字符串。 + +#### 13.2.3 `ChunkingStrategyFactory` + +[ChunkingStrategyFactory](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkingStrategyFactory.java#L34-L81) 会在启动时把所有 `ChunkingStrategy` Bean 注册成一个 `Map`。 + +因此运行时只需要: + +```java +chunkingStrategyFactory.requireStrategy(chunkingMode) +``` + +就能拿到对应实现。 + +这就是典型的“策略模式 + 工厂模式”组合。 + +#### 13.2.4 为什么文档里存的是 `chunkConfig` JSON + +`buildChunkingOptions(...)` 的实现非常简单: + +```java +Map config = parseChunkConfig(documentDO.getChunkConfig()); +return mode.createOptions(config); +``` + +这说明数据库里保存的是一份 JSON 配置,而不是固定列。 + +好处是: + +- 新增分块策略时不需要频繁改表 +- 每种策略可以拥有不同配置键 +- 上传时即可固化当时的分块参数 + +这对后续“重跑同一文档”很重要,因为它能复现当时的处理语义。 + +### 13.3 两种分块策略到底怎么切 + +#### 13.3.1 固定大小策略 + +[FixedSizeTextChunker](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/strategy/FixedSizeTextChunker.java#L43-L156) 的核心特点是: + +- 按 `chunkSize` 切分 +- 相邻块保留 `overlapSize` +- 优先在换行、中文句末标点、英文句末标点处对齐边界 +- 对 URL 断行、中文词软换行做保守归一化 + +它更像一种“通用文本切刀”: + +- 追求稳定 +- 配置直接 +- 对普通纯文本比较友好 + +#### 13.3.2 结构感知策略 + +[StructureAwareTextChunker](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/strategy/StructureAwareTextChunker.java#L43-L298) 更高级一些,它会先把文本扫描成块: + +- 标题块 +- 段落块 +- 代码块 +- 原子块(图片、链接) + +再按 `min / target / max` 预算把这些“结构块”打包成最终 Chunk。 + +它的核心目标不是“严格按字数切”,而是: + +> 尽量不破坏 Markdown/结构化文本的语义边界。 + +所以这个策略更适合: + +- Markdown 文档 +- 结构清晰的技术文档 +- 希望标题、段落、代码块尽量完整保留的场景 + +### 13.4 Embed:批量生成向量 + +分块之后,代码会调用 [ChunkEmbeddingService.embed](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/core/chunk/ChunkEmbeddingService.java#L34-L75): + +```java +chunkEmbeddingService.embed(chunks, embeddingModel); +``` + +它做了几件事: + +- 如果 Chunk 为空,直接返回 +- 如果每个 Chunk 已经有 embedding,就不重复算 +- 提取所有文本内容,走 `embeddingService.embedBatch(...)` +- 再把返回的 `List>` 回填成 `float[]` + +这里体现了两个工程点: + +- 使用批量 Embedding,而不是逐条调用,减少模型 API 开销 +- `VectorChunk` 作为统一中间对象,同时携带内容、索引、ID、metadata、embedding + +也就是说,`VectorChunk` 是这条链路里贯穿处理阶段和持久化阶段的核心数据模型。 + +## 14. 可扩展路径:`pipeline` 模式 + +如果文档的 `processMode` 是 `pipeline`,则进入 [runPipelineProcess](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L335-L378)。 + +这条路径不是直接用内置分块器,而是走 ingestion 子系统。 + +### 14.1 它先做什么 + +它会先: + +- 读取 `pipelineId` +- 查询知识库拿 `collectionName` +- 读取整份文件字节数组 +- 加载 `PipelineDefinition` + +然后构造 [IngestionContext](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L354-L363): + +```java +IngestionContext context = IngestionContext.builder() + .taskId(docId) + .pipelineId(pipelineId) + .rawBytes(fileBytes) + .mimeType(documentDO.getFileType()) + .vectorSpaceId(VectorSpaceId.builder() + .logicalName(kbDO.getCollectionName()) + .build()) + .skipIndexerWrite(true) + .build(); +``` + +这里最值得注意的是: + +- `skipIndexerWrite(true)` + +### 14.2 为什么要 `skipIndexerWrite(true)` + +因为 ingestion pipeline 里本来就可能有索引节点,但在知识文档链路里,项目不想让 pipeline 直接把结果写进向量库。 + +原因是这条链路希望: + +- 无论走 `chunk` 还是 `pipeline` +- 最终都回到同一个 `persistChunksAndVectorsAtomically(...)` 出口 + +这样可以统一做: + +- 删除旧 Chunk +- 新建 Chunk +- 删除旧向量 +- 新建向量 +- 更新文档状态 + +换句话说,pipeline 负责“加工结果”,而不是负责“最终提交”。 + +### 14.3 Pipeline 引擎如何执行 + +[IngestionEngine](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/ingestion/engine/IngestionEngine.java#L57-L87) 的主流程是: + +1. 构建节点映射 +2. 验证流水线配置 +3. 找起始节点 +4. 按 `nextNodeId` 链式执行 +5. 把结果和节点日志写回 `IngestionContext` + +这说明 pipeline 不是写死的流程,而是一种配置驱动的图式执行引擎。 + +### 14.4 Pipeline 中的 Chunker / Indexer 节点 + +分块节点是 [ChunkerNode](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/ingestion/node/ChunkerNode.java#L57-L78): + +- 从 `rawText` 或 `enhancedText` 取输入 +- 按配置选择分块策略 +- 生成 Chunk +- 直接在节点内做 Embedding +- 把结果放回 `context.setChunks(...)` + +索引节点是 [IndexerNode](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/ingestion/node/IndexerNode.java#L80-L112): + +- 检查向量空间 +- 组装 `VectorChunk` +- 正常情况下可直接写向量库 +- 但如果 `context.isSkipIndexerWrite()` 为真,就只准备数据、不真正落库 + +因此,在知识文档分块链路中: + +- pipeline 内部可以完成解析、清洗、分块、embedding +- 但最终写库仍然由知识模块统一接管 + +这是一个非常典型的“可扩展处理 + 统一提交”设计。 + +## 15. 统一提交出口:`persistChunksAndVectorsAtomically` + +不管前面走 `chunk` 还是 `pipeline`,最后都汇总成 `List`,然后进入 [persistChunksAndVectorsAtomically](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L250-L274)。 + +核心逻辑如下: + +```java +transactionOperations.executeWithoutResult(status -> { + knowledgeChunkService.deleteByDocId(docId); + knowledgeChunkService.batchCreate(docId, chunks); + vectorStoreService.deleteDocumentVectors(collectionName, docId); + vectorStoreService.indexDocumentChunks(collectionName, docId, chunkResults); + documentMapper.updateById(updateDocumentDO); +}); +``` + +这段代码体现了整条链路最重要的业务语义: + +> 重分块不是“增量追加”,而是“整文档重建”。 + +也就是先删旧结果,再写新结果。 + +### 15.1 为什么先删旧 Chunk + +`knowledgeChunkService.deleteByDocId(docId)` 的含义是: + +- 文档重新分块时,旧 Chunk 全部失效 +- 不尝试做复杂的 diff 合并 + +这样做的好处是: + +- 逻辑简单 +- 不会留下历史脏数据 +- chunkIndex 可以从新的结果重新排序 + +### 15.2 为什么 `batchCreate` 不在这里直接写向量 + +[KnowledgeChunkServiceImpl.batchCreate](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeChunkServiceImpl.java#L157-L236) 支持一个 `writeVector` 参数,但这里调用的是默认版本: + +```java +knowledgeChunkService.batchCreate(docId, chunks); +``` + +也就是: + +- 只写 Chunk 表 +- 不重复写向量库 + +原因很简单: + +- `VectorChunk` 在上游已经带好了 embedding +- 如果这里再让 ChunkService 自己 embed + 写向量,会造成重复计算 + +所以当前实现把职责拆开成: + +- `KnowledgeChunkService` + - 管理 Chunk 表元数据 +- `VectorStoreService` + - 管理向量索引 + +### 15.3 向量库写入时保存了什么 + +以 PostgreSQL 实现 [PgVectorStoreService](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/PgVectorStoreService.java#L41-L123) 为例,插入的是: + +- `id` + - 使用 `chunkId` +- `content` + - Chunk 文本 +- `metadata` + - JSON,至少带 `collection_name`、`doc_id`、`chunk_index` +- `embedding` + - `pgvector` 类型的向量 + +这意味着向量表里的记录不仅能做相似度检索,还能反查: + +- 属于哪个知识库 +- 属于哪个文档 +- 是该文档的第几个 Chunk + +### 15.4 这个“Atomically”到底有多原子 + +方法名叫 `persistChunksAndVectorsAtomically`,它表达的核心意图是: + +- 把 Chunk 表、向量索引、文档状态更新视为一个统一提交单元 + +但从工程上要分两层理解: + +1. 业务语义层面 + - 代码把“删旧 + 建新 + 改状态”收敛到一个事务块里 + - 尽量避免部分成功、部分失败 +2. 底层资源层面 + - 如果向量存储实现是 PostgreSQL 且复用同一事务资源,一致性更强 + - 如果切换到独立向量库实现,比如 Milvus,则这里更偏“应用层顺序一致”,并不是严格 XA 分布式事务 + +所以更准确的理解应当是: + +> 这是一个“统一业务提交出口”,而不是承诺所有底层资源都具备严格分布式原子事务。 + +## 16. 失败处理:如何把任务收口到 `FAILED` + +`runChunkTask` 外层包了一层 `try-catch`。 + +一旦任何环节抛异常,就会执行两件事: + +### 16.1 标记文档失败 + +[markChunkFailed](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L387-L395) 会把文档状态更新为 `FAILED`。 + +这保证了前端和运维侧能明确知道: + +- 任务不是还在运行 +- 而是已经结束,但失败了 + +### 16.2 更新分块执行日志 + +[updateChunkLog](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L276-L292) 会把这些信息补齐: + +- `status=failed` +- 各阶段耗时 +- `totalDuration` +- `errorMessage` +- `endTime` + +因此失败不会只停留在日志文件里,而是会被结构化地写进数据库,便于页面展示和问题排查。 + +## 17. 数据模型:这条链路最终落了哪些表 + +### 17.1 文档主表:`t_knowledge_document` + +[KnowledgeDocumentDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeDocumentDO.java#L40-L156) 里和分块链路最相关的字段有: + +- `processMode` +- `chunkStrategy` +- `chunkConfig` +- `pipelineId` +- `status` +- `chunkCount` +- `enabled` +- `fileUrl` + +它记录的是“这份文档的处理配置和当前处理结果”。 + +### 17.2 Chunk 表:`t_knowledge_chunk` + +[KnowledgeChunkDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeChunkDO.java#L40-L115) 保存的是结构化分块结果: + +- `kbId` +- `docId` +- `chunkIndex` +- `content` +- `contentHash` +- `charCount` +- `tokenCount` +- `enabled` + +它更像是“文档分块后的业务主表”。 + +### 17.3 分块日志表:`t_knowledge_document_chunk_log` + +[KnowledgeDocumentChunkLogDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeDocumentChunkLogDO.java#L39-L119) 保存的是每次执行记录: + +- 跑的是 `chunk` 还是 `pipeline` +- 用的什么策略 +- 每个阶段耗时 +- 成功还是失败 +- 失败原因 + +也就是说,这张表记录的不是“当前状态”,而是“每一次运行历史”。 + +## 18. 这条链路的几个关键设计点 + +### 18.1 上传与分块解耦 + +上传成功后文档先进入 `PENDING`,等用户主动触发 `startChunk` 再进入真正处理。 + +这样做的好处是: + +- 上传接口更轻 +- 失败边界更清晰 +- 允许上传后再调整处理配置 + +### 18.2 异步化但不丢一致性 + +它不是简单地“扔一条异步消息就完了”,而是通过事务消息确保: + +- 状态更新成功,消息才能投递 +- 状态更新失败,消息自动回滚 + +这是这条链路最核心的工程价值。 + +### 18.3 统一中间模型 `VectorChunk` + +不管来自: + +- 内置分块器 +- pipeline 节点 + +最终都统一成 `VectorChunk`,再进入持久化。 + +这让上游处理方式可扩展,而下游持久化逻辑保持稳定。 + +### 18.4 重跑语义清晰 + +系统允许对非 `RUNNING` 文档重新触发分块,并采用“删旧建新”的方式重建: + +- Chunk 全量重建 +- 向量全量重建 + +这让运维操作和配置调整都比较简单,不容易产生脏状态。 + +## 19. 一句话总结整条链路 + +如果把这条链路压缩成一句话,可以这样理解: + +> `startChunk` 本质上是“把文档处理任务可靠地切到异步执行系统中”,然后由消费者完成“文本抽取 -> 分块 -> Embedding -> Chunk/向量持久化 -> 状态更新 -> 执行日志记录”的完整摄入闭环。 + +## 20. 面试/汇报时可以怎么讲 + +如果你后面要把这部分作为项目亮点讲给面试官,可以抓住下面这几个点: + +- 文档分块入口采用 `HTTP + RocketMQ 事务消息`,把“状态切换”和“任务投递”绑定,避免消息和数据库状态不一致 +- 文档分块执行采用统一编排器 `runChunkTask`,支持 `chunk` 和 `pipeline` 两种处理模式 +- 分块结果统一抽象为 `VectorChunk`,将上游处理策略和下游持久化解耦 +- 重分块采用“删旧建新”的全量重建语义,简化一致性维护 +- 执行过程记录结构化分块日志,包含分阶段耗时、错误信息和运行历史,便于排障与调优 + +如果你愿意,我下一步可以继续基于这份文档,帮你补一份“文档分块链路时序图版”或者“适合面试讲解的 3 分钟口述稿”。 diff --git "a/docs/\347\231\273\345\275\225\346\240\241\351\252\214\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\347\231\273\345\275\225\346\240\241\351\252\214\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..84d98f339 --- /dev/null +++ "b/docs/\347\231\273\345\275\225\346\240\241\351\252\214\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,745 @@ +# 登录校验链路详解 + +## 1. 文档目标 + +本文档面向 `Ragent` 项目中的认证与授权子系统,系统梳理“登录、登录态建立、请求校验、用户上下文注入、角色鉴权、异常处理”这条完整链路。 + +文档重点不在于逐行解释代码,而在于从工程实现和架构设计角度回答以下问题: + +- 项目中的登录校验到底由哪些组件共同完成 +- 请求从进入系统到完成鉴权经历了哪些关键步骤 +- Sa-Token 在这个项目里扮演什么角色 +- 为什么还要引入 `UserContext` +- 角色校验和登录校验分别在哪里发生 +- 异常时系统如何向前端返回统一结果 + +本文覆盖的核心代码如下: + +- [AuthController](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/AuthController.java) +- [AuthServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java) +- [SaTokenConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenConfig.java) +- [UserContextInterceptor](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/UserContextInterceptor.java) +- [SaTokenStpInterfaceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenStpInterfaceImpl.java) +- [UserContext](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/UserContext.java) +- [LoginUser](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/LoginUser.java) +- [GlobalExceptionHandler](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java) +- [application.yaml](file:///e:/java/workspace/ragent/bootstrap/src/main/resources/application.yaml#L185-L191) + +--- + +## 2. 总体设计概览 + +项目的登录校验并不是由单个类独立完成,而是由一组组件协作完成: + +- `AuthController` / `AuthServiceImpl` + - 负责用户名密码登录、登出、token 下发 +- `SaInterceptor` + - 负责对进入系统的请求做统一登录校验 +- `UserContextInterceptor` + - 负责把认证框架中的登录身份转成业务侧可直接使用的用户上下文 +- `SaTokenStpInterfaceImpl` + - 负责把项目自己的用户表角色信息接入 Sa-Token 的角色校验体系 +- `GlobalExceptionHandler` + - 负责把未登录、无权限、业务异常等统一转换成标准响应 + +可以把整个子系统理解为 5 个层次: + +1. 认证入口层 +2. 认证状态建立层 +3. 请求登录校验层 +4. 业务用户上下文层 +5. 授权与异常处理层 + +--- + +## 3. 技术选型与认证模型 + +### 3.1 采用的认证框架 + +项目使用的是 **Sa-Token** 作为登录认证与授权框架。 + +Sa-Token 在本项目中的职责包括: + +- 登录态创建 +- token 生成与解析 +- 登录校验 +- 角色校验 +- 登出时会话清理 + +关键配置位于 [application.yaml](file:///e:/java/workspace/ragent/bootstrap/src/main/resources/application.yaml#L185-L191): + +```yaml +sa-token: + token-name: Authorization + timeout: 2592000 + is-concurrent: true + is-share: false + token-style: simple-uuid + is-log: false + is-print: false +``` + +### 3.2 当前采用的 token 模型 + +从配置可见,当前项目不是 JWT 模式,而是 Sa-Token 默认的 **会话式 token 模型**: + +- token 名称:`Authorization` +- token 风格:`simple-uuid` +- token 超时:`2592000` 秒,约 30 天 +- 支持并发登录:`is-concurrent: true` +- 不共享同一 token:`is-share: false` + +这意味着: + +- 每次登录会生成一个随机 token +- token 本身不携带完整用户声明信息 +- 认证的核心不是“解析 JWT 内容”,而是“基于 token 识别当前登录态” + +这种模型的优点是: + +- 实现简单 +- 接入成本低 +- 和 Sa-Token 的拦截器、角色系统天然兼容 + +边界是: + +- 更依赖 Sa-Token 的会话状态管理 +- 不像 JWT 那样天然适合作为完全无状态令牌 + +--- + +## 4. 认证对象与接口契约 + +### 4.1 登录请求模型 + +登录请求模型定义在 [LoginRequest](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/request/LoginRequest.java): + +```java +public class LoginRequest { + private String username; + private String password; +} +``` + +这说明当前项目的登录方式是最基础的: + +- 用户名 +- 密码 + +没有额外引入: + +- 验证码 +- 多因子认证 +- 短信登录 +- 第三方 OAuth + +### 4.2 登录响应模型 + +登录响应模型定义在 [LoginVO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/vo/LoginVO.java): + +```java +public class LoginVO { + private String userId; + private String role; + private String token; + private String avatar; +} +``` + +返回字段说明: + +- `userId` + - 当前登录用户 ID +- `role` + - 当前用户角色 +- `token` + - Sa-Token 生成的登录 token +- `avatar` + - 用户头像地址 + +这里的设计意图很明确: + +- 前端在登录成功后需要 token 继续访问受保护接口 +- 前端页面往往也需要当前用户基础展示信息 + +因此登录接口一次性返回: + +- 身份凭据 +- 基础用户展示信息 + +--- + +## 5. 登录链路详解 + +### 5.1 登录入口 + +登录入口在 [AuthController.login()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/AuthController.java#L43-L45): + +```java +@PostMapping("/auth/login") +public Result login(@RequestBody LoginRequest requestParam) { + return Results.success(authService.login(requestParam)); +} +``` + +特点: + +- 登录接口路径是 `/auth/login` +- 认证相关接口被统一放在 `/auth/**` +- 返回值包装成项目统一的 `Result` + +### 5.2 用户名密码校验 + +真正的登录逻辑在 [AuthServiceImpl.login()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java#L42-L67)。 + +执行过程如下: + +1. 读取用户名和密码 +2. 校验非空 +3. 根据用户名查询用户 +4. 校验密码是否匹配 +5. 校验用户 ID 是否存在 +6. 调用 `StpUtil.login(loginId)` 建立登录态 +7. 调用 `StpUtil.getTokenValue()` 取回 token +8. 构造 `LoginVO` 返回前端 + +关键语句: + +```java +String loginId = user.getId().toString(); +StpUtil.login(loginId); +String tokenValue = StpUtil.getTokenValue(); +``` + +### 5.3 这里发生了什么 + +`StpUtil.login(loginId)` 是整个登录链路的核心。 + +它的语义是: + +- 以 `loginId` 作为当前用户的登录标识 +- 通知 Sa-Token 建立一条登录态 +- 生成对应 token +- 让后续请求能够通过 token 识别当前用户 + +在本项目中,`loginId` 直接使用数据库中的用户主键 ID,而不是用户名。 + +这样设计的优点是: + +- 用户名允许修改时,登录态不会失效 +- 角色、头像、用户名等展示信息可以独立更新 +- 会话标识稳定,不依赖业务字段变更 + +### 5.4 密码校验实现现状 + +当前密码匹配逻辑在 [AuthServiceImpl.passwordMatches()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java#L85-L90): + +```java +private boolean passwordMatches(String input, String stored) { + if (stored == null) { + return input == null; + } + return stored.equals(input); +} +``` + +这说明当前实现是 **明文比对**,没有引入: + +- BCrypt +- PBKDF2 +- Argon2 +- 盐值存储 + +从工程角度看,这种实现适合: + +- Demo +- 教学项目 +- 本地演示环境 + +但如果走生产化,必须升级为: + +- 单向哈希 +- 带盐值 +- 可配置密码编码器 + +这是当前登录模块最明显的安全改进点之一。 + +--- + +## 6. 请求校验链路详解 + +登录完成后,后续请求必须带 token 访问受保护资源。 +这一阶段由 [SaTokenConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenConfig.java) 统一接管。 + +### 6.1 登录拦截器注册 + +核心代码在 [SaTokenConfig.addInterceptors()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenConfig.java#L55-L78): + +```java +registry.addInterceptor(new SaInterceptor(handler -> { + ... + StpUtil.checkLogin(); +})) + .addPathPatterns("/**") + .excludePathPatterns("/auth/**", "/error"); +``` + +其语义为: + +- 拦截所有请求 +- 排除 `/auth/**` +- 排除 `/error` +- 对其余请求执行 `StpUtil.checkLogin()` + +### 6.2 `StpUtil.checkLogin()` 的作用 + +`StpUtil.checkLogin()` 是 Sa-Token 的标准登录校验入口。 + +它要解决的问题是: + +- 当前请求有没有携带合法 token +- 当前 token 对应的登录态是否仍然有效 +- 当前请求是否处于已登录状态 + +若校验失败,会抛出 `NotLoginException`。 +该异常最终由 [GlobalExceptionHandler.notLoginException()](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L92-L96) 统一处理,返回: + +- 统一错误码 +- 错误消息:`未登录或登录已过期` + +### 6.3 为什么放行 `/auth/**` + +原因很直接: + +- `/auth/login` 本身就是建立登录态的入口 +- 如果登录接口也被 `checkLogin()` 拦截,就会形成死循环 + +所以认证入口必须从受保护资源中排除。 + +--- + +## 7. 为什么要特殊处理 `OPTIONS` 和 `ASYNC` + +在 [SaTokenConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenConfig.java#L58-L74) 和 [UserContextInterceptor](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/UserContextInterceptor.java#L61-L70) 里,都对两类请求做了特殊处理: + +- `OPTIONS` +- `DispatcherType.ASYNC` + +### 7.1 `OPTIONS` 请求 + +`OPTIONS` 一般是浏览器的 CORS 预检请求。 + +如果预检请求被拦截: + +- 浏览器会认为跨域校验失败 +- 真正业务请求甚至还没发出就被阻断 + +所以项目对 `OPTIONS` 直接放行,是标准的前后端分离兼容处理。 + +### 7.2 `ASYNC` 请求 + +项目中存在 SSE 流式响应。 +Spring MVC 在异步请求生命周期中可能触发 `asyncDispatch`,此时原有 Sa-Token 上下文不一定还能完整获取。 + +如果异步调度仍然强制执行登录校验或用户上下文填充,可能导致: + +- 二次校验失败 +- 异步回调时上下文丢失 +- SSE 连接异常中断 + +因此项目对异步调度做了显式跳过,这属于对 **SSE + 鉴权框架组合场景** 的兼容处理。 + +--- + +## 8. 用户上下文注入机制 + +### 8.1 为什么有了 Sa-Token 还要 `UserContext` + +这是本项目登录校验设计里非常关键的一层。 + +理论上业务代码也可以到处直接调用: + +- `StpUtil.getLoginIdAsString()` + +但这样会带来问题: + +- 业务代码直接依赖认证框架 +- 每次取用户信息都可能要重复查库 +- 代码语义混乱,身份信息访问分散 + +所以项目引入了自己的业务上下文容器: + +- [UserContext](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/UserContext.java) + +它的定位不是认证框架,而是: + +- 当前请求范围内的业务用户快照容器 + +### 8.2 `UserContextInterceptor` 的工作方式 + +[UserContextInterceptor](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/UserContextInterceptor.java#L61-L89) 的流程是: + +1. 从 Sa-Token 获取 `loginId` +2. 根据 `loginId` 查用户表 +3. 封装成 `LoginUser` +4. 放入 `UserContext` +5. 请求完成后清理上下文 + +关键代码: + +```java +String loginId = StpUtil.getLoginIdAsString(); +UserDO user = userMapper.selectById(loginId); + +UserContext.set( + LoginUser.builder() + .userId(user.getId().toString()) + .username(user.getUsername()) + .role(user.getRole()) + .avatar(...) + .build() +); +``` + +### 8.3 `LoginUser` 的作用 + +[LoginUser](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/LoginUser.java) 是一个轻量级用户快照对象,仅包含: + +- `userId` +- `username` +- `role` +- `avatar` + +这说明项目对“当前登录用户”做了一个有边界的抽象: + +- 不把完整 `UserDO` 整个塞进上下文 +- 只放业务高频需要的身份字段 + +这是一个合理的上下文建模方式。 + +### 8.4 为什么用 TTL + +[UserContext](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/UserContext.java#L28-L28) 使用的是: + +```java +private static final TransmittableThreadLocal CONTEXT = new TransmittableThreadLocal<>(); +``` + +这不是普通 `ThreadLocal`,而是 **TTL(TransmittableThreadLocal)**。 + +它的意义在于: + +- 在使用线程池的场景下,允许上下文跨线程传递 +- 比普通 `ThreadLocal` 更适合异步编排较多的系统 + +虽然本模块里主要是 Web 请求上下文,但项目本身包含: + +- SSE +- 异步任务 +- 多链路编排 + +所以选择 TTL 是一种更稳健的上下文方案。 + +### 8.5 为什么要在 `afterCompletion` 清理 + +`UserContext` 底层依赖线程本地变量。 +如果请求结束不清理,在 Web 容器线程复用时就可能出现“上个请求的用户污染下个请求”的严重问题。 + +因此 [UserContextInterceptor.afterCompletion()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/UserContextInterceptor.java#L86-L89) 必须执行: + +```java +UserContext.clear(); +``` + +这属于典型的线程上下文生命周期管理。 + +--- + +## 9. 角色鉴权机制 + +登录校验解决的是“你是不是已登录”,角色鉴权解决的是“你能不能访问这个接口”。 + +### 9.1 角色数据从哪里来 + +项目通过 [SaTokenStpInterfaceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenStpInterfaceImpl.java) 实现 Sa-Token 的 `StpInterface`。 + +关键方法是 [getRoleList()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenStpInterfaceImpl.java#L62-L79): + +1. 读取 `loginId` +2. 校验 `loginId` 是否为数字 +3. 根据 `loginId` 查用户表 +4. 读取 `user.role` +5. 返回单元素角色列表 + +也就是说,Sa-Token 的角色系统和项目自己的 `user` 表完成了集成。 + +### 9.2 权限列表为什么为空 + +[getPermissionList()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenStpInterfaceImpl.java#L50-L53) 当前直接返回空列表: + +```java +return Collections.emptyList(); +``` + +这说明当前项目: + +- 角色校验已接入 +- 细粒度权限点校验尚未使用 + +换句话说,本项目当前授权模型是: + +- **基于角色的粗粒度授权** + +而不是: + +- **基于权限点的细粒度 RBAC** + +### 9.3 业务接口如何做角色校验 + +比如 [UserController](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/UserController.java#L69-L100) 中多个接口直接写了: + +```java +StpUtil.checkRole("admin"); +``` + +这意味着: + +- 用户列表、创建用户、更新用户、删除用户等接口 +- 只有 `admin` 角色可访问 + +如果校验失败,Sa-Token 会抛出 `NotRoleException`,然后由 [GlobalExceptionHandler.notRoleException()](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L101-L105) 转为统一错误响应: + +- 错误消息:`权限不足` + +--- + +## 10. 登出链路 + +登出入口在 [AuthController.logout()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/controller/AuthController.java#L51-L54)。 + +实际实现非常简洁,在 [AuthServiceImpl.logout()](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/service/impl/AuthServiceImpl.java#L69-L72) 中: + +```java +StpUtil.logout(); +``` + +语义是: + +- 清除当前登录态 +- 使当前 token 失效 + +后续请求再访问受保护资源时,会被 `checkLogin()` 拦截并返回“未登录或登录已过期”。 + +--- + +## 11. 异常处理与前后端契约 + +认证与授权链路中主要涉及 3 类异常: + +### 11.1 业务校验异常 + +例如: + +- 用户名为空 +- 密码为空 +- 用户名密码错误 + +这些通常通过 `ClientException` 抛出,最终由 [GlobalExceptionHandler.abstractException()](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L73-L87) 统一返回。 + +### 11.2 未登录异常 + +由 `StpUtil.checkLogin()` 触发 `NotLoginException`,最终返回: + +- `未登录或登录已过期` + +处理逻辑见 [GlobalExceptionHandler.notLoginException()](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L92-L96)。 + +### 11.3 无角色权限异常 + +由 `StpUtil.checkRole("admin")` 触发 `NotRoleException`,最终返回: + +- `权限不足` + +处理逻辑见 [GlobalExceptionHandler.notRoleException()](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L101-L105)。 + +这种统一异常处理机制的价值在于: + +- 前端不需要感知底层 Sa-Token 具体异常类型 +- 整个系统输出统一风格的 `Result` +- 日志、审计和用户提示可以集中管理 + +--- + +## 12. 完整时序图 + +下面给出登录和受保护请求两个关键时序。 + +### 12.1 登录时序 + +```text +前端 + -> POST /auth/login +AuthController + -> AuthServiceImpl.login() +AuthServiceImpl + -> UserMapper.selectOne(username) + -> 校验用户名密码 + -> StpUtil.login(userId) + -> StpUtil.getTokenValue() + -> 返回 LoginVO(userId, role, token, avatar) +前端 + -> 保存 token +``` + +### 12.2 受保护接口访问时序 + +```text +前端 + -> 请求受保护接口,携带 Authorization token +SaInterceptor + -> StpUtil.checkLogin() + -> 校验 token 对应登录态 +UserContextInterceptor + -> StpUtil.getLoginIdAsString() + -> UserMapper.selectById(loginId) + -> UserContext.set(LoginUser) +Controller / Service + -> 业务逻辑执行 + -> 如有需要执行 StpUtil.checkRole("admin") +请求结束 + -> UserContextInterceptor.afterCompletion() + -> UserContext.clear() +``` + +--- + +## 13. 模块设计思想 + +这一套实现体现了几个比较清晰的设计思想。 + +### 13.1 认证与业务上下文分层 + +Sa-Token 负责: + +- 建立登录态 +- 校验登录态 +- 提供角色校验 + +`UserContext` 负责: + +- 面向业务层提供统一用户访问接口 + +这避免了所有业务代码都直接耦合 Sa-Token。 + +### 13.2 登录校验与授权校验分层 + +项目明确区分了: + +- 登录校验:`StpUtil.checkLogin()` +- 角色校验:`StpUtil.checkRole(...)` + +这让链路职责非常清晰。 + +### 13.3 兼容异步与 SSE 场景 + +针对 `OPTIONS` 和 `ASYNC` 的处理说明作者不是只考虑传统同步接口,而是考虑了: + +- 跨域预检 +- SSE 异步调度 + +这是一种工程化实现,而不是只在最简单场景下可用。 + +### 13.4 统一异常出口 + +所有认证与授权异常最终都汇总到 `GlobalExceptionHandler`,从而对前端提供统一结果格式。 + +--- + +## 14. 当前实现的优点 + +- 接入链路清晰,Controller、Service、Interceptor、Context、RoleProvider 分层明确 +- 认证框架和业务上下文解耦,业务层大多数场景只依赖 `UserContext` +- 支持统一登录校验和接口级角色控制 +- 对 SSE/异步请求有兼容处理 +- 统一异常处理,前端交互稳定 +- 代码量小、可读性好,适合中小型系统快速落地 + +--- + +## 15. 当前实现的风险与改进空间 + +### 15.1 密码明文比对 + +这是当前最明显的安全短板。 +建议升级为: + +- `BCryptPasswordEncoder` +- 数据库存储加密哈希后的密码 +- 统一密码编码器组件 + +### 15.2 `UserContextInterceptor` 缺少空值防御 + +当前代码直接: + +```java +UserDO user = userMapper.selectById(loginId); +``` + +然后继续访问 `user.getId()` 等字段。 +如果数据库中查不到用户,会触发空指针。 + +建议补充: + +- 用户不存在时的显式异常 +- 用户被删除或禁用时的会话失效处理 + +### 15.3 授权粒度偏粗 + +当前只接入角色列表,没有接入权限点列表。 +如果后续后台能力增多,建议演进为: + +- 角色 + 权限点 +- 按接口或按资源维度授权 + +### 15.4 缺少登录安全增强 + +当前未看到以下机制: + +- 登录失败次数限制 +- 图形验证码 +- 密码复杂度校验 +- 强制修改初始密码 +- 登录审计记录 + +这些在生产化系统中通常需要补齐。 + +### 15.5 token 生命周期策略可继续细化 + +目前配置只有统一超时时间。 +后续可以考虑: + +- 活跃续期策略 +- 多端设备管理 +- 踢人下线 +- 单设备登录 + +--- + +## 16. 面试表达建议 + +如果把这一部分讲给面试官,建议按下面顺序表达: + +1. 项目使用 Sa-Token 做认证鉴权,token 采用 `simple-uuid` 风格,不是 JWT +2. 登录时通过用户名密码校验后,调用 `StpUtil.login(userId)` 建立登录态并返回 token +3. 后续请求通过 `SaInterceptor` 统一执行 `checkLogin()` 做登录校验 +4. 校验通过后,再由 `UserContextInterceptor` 把当前登录用户写入 `UserContext` +5. 业务接口需要权限控制时,直接执行 `StpUtil.checkRole("admin")` +6. 认证异常和授权异常最终由全局异常处理器统一转换成前端可消费的结果 + +这种表述方式比直接背代码更像“理解了系统设计”。 + +--- + +## 17. 一句话总结 + +本项目的登录校验体系采用 **Sa-Token + Spring MVC 拦截器 + 业务用户上下文** 的组合设计:登录时建立基于 `userId` 的会话式 token,受保护请求进入后先做统一登录校验,再注入业务侧 `UserContext`,最后按角色完成授权控制,并通过全局异常处理器对认证失败和权限失败进行统一响应。 diff --git "a/docs/\347\237\245\350\257\206\345\272\223\345\210\233\345\273\272\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\347\237\245\350\257\206\345\272\223\345\210\233\345\273\272\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..fb18b180a --- /dev/null +++ "b/docs/\347\237\245\350\257\206\345\272\223\345\210\233\345\273\272\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,885 @@ +# Ragent 知识库创建链路详解 + +## 1. 文档目标 + +本文聚焦知识库创建接口 [KnowledgeBaseController.createKnowledgeBase](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeBaseController.java#L52-L58),完整解释下面这些问题: + +- 前端发起一次“创建知识库”请求后,链路先进入哪里 +- `Controller -> Request -> Service -> DB / 对象存储 / 向量空间` 是如何串起来的 +- 为什么这个接口不仅会写数据库,还会同时创建对象存储桶和向量空间 +- `collectionName`、`embeddingModel`、`kbId` 三者分别承担什么职责 +- `@Transactional` 在这里到底保证了什么,又没有保证什么 +- 当前 `pg` 模式和 `milvus` 模式下,向量空间初始化有什么本质差异 +- 这条链路和后续“上传文档 / 分块 / 检索”之间是什么关系 + +本文的重点不是只解释某一段 Controller 代码,而是把“一个知识库在系统里是怎么落地成一组可用资源”的全过程讲清楚。 + +## 2. 接口快照 + +接口入口在 [KnowledgeBaseController](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeBaseController.java#L52-L58): + +```java +/** + * 创建知识库 + */ +@PostMapping("/knowledge-base") +public Result createKnowledgeBase(@RequestBody KnowledgeBaseCreateRequest requestParam) { + return Results.success(knowledgeBaseService.create(requestParam)); +} +``` + +这段代码很短,但背后串起了 3 类资源的初始化: + +1. 知识库元数据记录:`t_knowledge_base` +2. 对象存储桶:RustFS 的 S3 Bucket +3. 向量空间:`pgvector` 或 `Milvus` + +也就是说,这个接口的真实语义不是“插一条知识库记录”,而是: + +> 创建一个可承载后续知识文档上传、分块、向量检索的完整知识空间。 + +## 3. 请求与响应 + +### 3.1 实际访问路径 + +项目在 [application.yaml](file:///e:/java/workspace/ragent/bootstrap/src/main/resources/application.yaml#L1-L5) 中配置了: + +```yaml +server: + servlet: + context-path: /api/ragent +``` + +因此完整接口地址是: + +```text +POST /api/ragent/knowledge-base +Content-Type: application/json +``` + +### 3.2 请求体对象 + +请求体定义在 [KnowledgeBaseCreateRequest](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/request/KnowledgeBaseCreateRequest.java#L22-L38): + +```java +@Data +public class KnowledgeBaseCreateRequest { + + private String name; + + private String embeddingModel; + + private String collectionName; +} +``` + +三个字段的含义分别是: + +- `name` + - 知识库的业务名称,给用户和后台界面看 +- `embeddingModel` + - 该知识库后续做向量化时默认使用的嵌入模型 +- `collectionName` + - 该知识库绑定的“统一逻辑空间名” + - 后面会同时用于: + - RustFS Bucket 名 + - 向量空间 logical name + - 后续文档上传、向量写入时的资源定位锚点 + +### 3.3 响应结构 + +Controller 返回的是 [Result](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/convention/Result.java#L36-L90),由 [Results.success(...)](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/Results.java#L39-L46) 构造。 + +成功响应形态大致如下: + +```json +{ + "code": "0", + "message": null, + "data": "1912345678901234567", + "requestId": null +} +``` + +其中 `data` 返回的是知识库主键 `kbId`。 + +### 3.4 一个典型请求示例 + +```json +{ + "name": "员工制度知识库", + "embeddingModel": "qwen3-embedding:8b-fp16", + "collectionName": "kb_employee_policy" +} +``` + +## 4. 链路总览 + +先给出整条链路的总框图。 + +```mermaid +flowchart TD + A["前端 POST /api/ragent/knowledge-base"] --> B["KnowledgeBaseController.createKnowledgeBase(...)"] + B --> C["Spring MVC 反序列化 JSON -> KnowledgeBaseCreateRequest"] + C --> D["Sa-Token 登录拦截"] + D --> E["UserContextInterceptor 写入当前用户"] + E --> F["KnowledgeBaseServiceImpl.create(...)"] + F --> G["名称重复校验"] + G --> H["构造 KnowledgeBaseDO"] + H --> I["knowledgeBaseMapper.insert(...)"] + I --> J["创建 RustFS S3 Bucket"] + J --> K["构造 VectorSpaceSpec"] + K --> L["vectorStoreAdmin.ensureVectorSpace(...)"] + L --> M{"rag.vector.type"} + M -->|pg| N["PgVectorStoreAdmin: 确保统一向量表 HNSW 索引存在"] + M -->|milvus| O["MilvusVectorStoreAdmin: 创建物理 Collection"] + N --> P["返回 kbId"] + O --> P + P --> Q["Results.success(kbId)"] +``` + +这个图揭示了一个很重要的事实: + +> 一个知识库不是单纯的数据库概念,它是“元数据 + 文件存储命名空间 + 向量检索命名空间”的组合体。 + +## 5. 入口层:`KnowledgeBaseController.createKnowledgeBase` + +接口入口很薄,只有一行真正业务代码: + +```java +return Results.success(knowledgeBaseService.create(requestParam)); +``` + +这说明 Controller 层只承担 3 个职责: + +1. 暴露 HTTP 路由 +2. 让 Spring 把 JSON 绑定成 `KnowledgeBaseCreateRequest` +3. 用统一返回体包裹 Service 的结果 + +### 5.1 为什么这里没有复杂逻辑 + +这是比较标准的分层设计: + +- `Controller` + - 只处理协议层 +- `Service` + - 承担业务编排 +- `Mapper` + - 负责数据库访问 +- `VectorStoreAdmin` / `S3Client` + - 负责外部资源初始化 + +这样做的好处是: + +- HTTP 细节和业务逻辑解耦 +- 以后如果要做 RPC / MQ 触发,不必把业务逻辑搬来搬去 + +### 5.2 这里没有 `@Valid` + +这段代码只有: + +```java +@RequestBody KnowledgeBaseCreateRequest requestParam +``` + +没有: + +- `@Valid` +- `@Validated` +- `@NotBlank` +- `@Pattern` + +这意味着这条接口没有走 Bean Validation 自动参数校验。 + +因此很多输入约束并不是在 Controller 层被拦住,而是要么: + +- 在 Service 里手工校验 +- 要么直接在后续逻辑里触发异常 + +这个细节非常重要,因为它直接影响接口健壮性。 + +## 6. 认证与用户上下文 + +虽然 Controller 上没有显式写权限注解,但这条接口默认会经过全局拦截。 + +### 6.1 登录检查 + +在 [SaTokenConfig](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/SaTokenConfig.java#L55-L93) 中,项目注册了 `SaInterceptor`: + +```java +registry.addInterceptor(new SaInterceptor(handler -> { + StpUtil.checkLogin(); +})) +``` + +并且对 `/**` 生效,只排除了 `/auth/**` 和 `/error`。 + +所以正常情况下: + +- 未登录请求会在进入 Controller 前被拦下 +- 已登录请求才会继续往下执行 + +### 6.2 当前用户写入 `UserContext` + +同样在 `SaTokenConfig` 里,还注册了 [UserContextInterceptor](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/user/config/UserContextInterceptor.java#L61-L89)。 + +它会: + +1. 从 Sa-Token 里取出 `loginId` +2. 查用户表得到完整用户信息 +3. 封装成 `LoginUser` +4. 写入 [UserContext](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/context/UserContext.java#L33-L99) + +后面的业务层正是通过: + +```java +UserContext.getUsername() +``` + +来给知识库记录填充 `createdBy` 和 `updatedBy`。 + +所以这条链路的一个隐藏前提是: + +> 知识库创建并不是匿名接口,业务审计信息依赖请求线程里的登录用户上下文。 + +## 7. 服务入口:`KnowledgeBaseServiceImpl.create` + +真正的业务主线在 [KnowledgeBaseServiceImpl.create](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L66-L112): + +```java +@Transactional +@Override +public String create(KnowledgeBaseCreateRequest requestParam) { + ... +} +``` + +从业务动作上看,这个方法可以拆成 6 个阶段: + +1. 名称重复校验 +2. 组装知识库实体 +3. 写入 `t_knowledge_base` +4. 创建 RustFS Bucket +5. 创建向量空间 +6. 返回知识库 ID + +下面按顺序展开。 + +## 8. 第一阶段:名称重复校验 + +代码在 [KnowledgeBaseServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L69-L78): + +```java +String name = requestParam.getName().replaceAll("\\s+", ""); +Long count = knowledgeBaseMapper.selectCount( + new LambdaQueryWrapper() + .eq(KnowledgeBaseDO::getName, name) + .eq(KnowledgeBaseDO::getDeleted, 0) +); +if (count > 0) { + throw new ServiceException("知识库名称已存在:" + requestParam.getName()); +} +``` + +### 8.1 它在做什么 + +这里先把名称做了一次“空白字符归一化”: + +- `知识库A` +- `知 识 库 A` +- ` 知识库A ` + +都会被压成去空白后的字符串再去查重。 + +这说明它想实现的是: + +> 名称的业务唯一性,而不是字符串字面完全一致。 + +### 8.2 为什么还要带上 `deleted = 0` + +[KnowledgeBaseDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeBaseDO.java#L40-L81) 使用了 `@TableLogic`: + +```java +@TableLogic +private Integer deleted; +``` + +这表示知识库采用逻辑删除,而不是物理删除。 + +因此查重时必须排除已删除记录,否则被删除的历史知识库会永久占住名字。 + +### 8.3 这个实现有两个值得注意的点 + +第一,查重使用的是“去空白后的名字”,但真正入库时保存的是原始 `requestParam.getName()`。 + +也就是说: + +- 校验语义按“归一化名称”判断 +- 展示语义按“原始名称”保存 + +第二,当前代码没有先做空值保护。 + +如果 `requestParam.getName()` 为 `null`,这里的 `replaceAll(...)` 会直接触发空指针异常,而不是优雅返回参数错误。 + +这正是前面提到“接口没有 Bean Validation”带来的实际影响。 + +## 9. 第二阶段:组装知识库实体 + +代码在 [KnowledgeBaseServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L80-L87): + +```java +KnowledgeBaseDO kbDO = KnowledgeBaseDO.builder() + .name(requestParam.getName()) + .embeddingModel(requestParam.getEmbeddingModel()) + .collectionName(requestParam.getCollectionName()) + .createdBy(UserContext.getUsername()) + .updatedBy(UserContext.getUsername()) + .deleted(0) + .build(); +``` + +这里构造的是 [KnowledgeBaseDO](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/entity/KnowledgeBaseDO.java#L40-L81),它映射表 `t_knowledge_base`。 + +### 9.1 关键字段解释 + +- `id` + - 主键,`@TableId(type = IdType.ASSIGN_ID)` + - 由 MyBatis-Plus 在插入时自动生成 +- `name` + - 知识库展示名 +- `embeddingModel` + - 后续文档向量化使用的嵌入模型标识 +- `collectionName` + - 知识空间的统一逻辑名 +- `createdBy` / `updatedBy` + - 当前登录用户名 +- `createTime` / `updateTime` + - 通过 `FieldFill` 自动填充 +- `deleted` + - 逻辑删除标记 + +### 9.2 为什么 `collectionName` 很关键 + +这个字段不是随便的备注名,而是整个知识库后续资源定位的核心主键之一。 + +它会在后续链路里继续承担三种角色: + +1. 对象存储 Bucket 名 +2. 向量空间 logical name / collection 名 +3. 文档上传与向量写入时的命名空间标识 + +比如后续文档上传链路中,[KnowledgeDocumentServiceImpl.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L122-L154) 会直接用: + +```java +resolveStoredFile(kbDO.getCollectionName(), ...) +``` + +把 `collectionName` 当作文件落桶的目标 bucket。 + +所以: + +> 创建知识库时填下来的 `collectionName`,会贯穿知识库后续的全部文件与向量生命周期。 + +## 10. 第三阶段:写入知识库元数据 + +代码在 [KnowledgeBaseServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L89-L89): + +```java +knowledgeBaseMapper.insert(kbDO); +``` + +看起来只有一行,但背后有几个技术点。 + +### 10.1 使用的是 MyBatis-Plus 的 `BaseMapper` + +[KnowledgeBaseMapper](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/dao/mapper/KnowledgeBaseMapper.java) 继承 `BaseMapper`,因此具备标准 CRUD 能力。 + +这里不需要手写 SQL,原因是: + +- 表结构简单 +- 插入逻辑是标准单表写入 + +### 10.2 `kbId` 什么时候可用 + +由于 `KnowledgeBaseDO.id` 使用了: + +```java +@TableId(type = IdType.ASSIGN_ID) +``` + +所以在 `insert(kbDO)` 之后,`kbDO.getId()` 已经可取。 + +这也是为什么方法最后能直接: + +```java +return String.valueOf(kbDO.getId()); +``` + +### 10.3 这里只是“元数据层成功” + +这一行成功仅代表: + +- 数据库里有了这条知识库记录 + +还不代表: + +- 对象存储桶已经可用 +- 向量空间已经可用 + +所以从系统语义上讲,知识库真正“可用”的判定必须等后续两个外部资源也初始化成功。 + +## 11. 第四阶段:创建 RustFS 对象存储桶 + +代码在 [KnowledgeBaseServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L91-L102): + +```java +String bucketName = requestParam.getCollectionName(); +try { + s3Client.createBucket(builder -> builder.bucket(bucketName)); + log.info("成功创建RestFS存储桶,Bucket名称: {}", bucketName); +} catch (BucketAlreadyOwnedByYouException | BucketAlreadyExistsException e) { + ... + throw new ServiceException("存储桶名称已被占用:" + bucketName); +} +``` + +### 11.1 为什么知识库创建要建 Bucket + +因为这个项目里,知识文档的原始文件不是直接塞数据库,而是存到 S3 兼容对象存储中。 + +[RestFSS3Config](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/config/RestFSS3Config.java#L39-L75) 提供了 `S3Client` Bean,它连接的是: + +```yaml +rustfs: + url: http://localhost:9000 +``` + +也就是 RustFS 提供的 S3 兼容服务。 + +### 11.2 为什么 `bucketName` 直接等于 `collectionName` + +代码是: + +```java +String bucketName = requestParam.getCollectionName(); +``` + +这表示系统刻意把: + +- 文件存储命名空间 +- 向量空间命名空间 + +统一成同一个逻辑名字。 + +这样做的好处是: + +- 一个知识库对应一个统一的资源命名空间 +- 后续定位文件与向量不需要做额外映射表 +- 代码实现更简单 + +代价是: + +- `collectionName` 变成强约束字段 +- 一旦命名策略有问题,会同时影响文件存储和向量空间 + +### 11.3 为什么只捕获两个异常 + +这里特意捕获的是: + +- `BucketAlreadyOwnedByYouException` +- `BucketAlreadyExistsException` + +这说明该接口最关心的是“命名冲突”。 + +如果 Bucket 名已经被占用,就直接抛: + +```java +throw new ServiceException("存储桶名称已被占用:" + bucketName); +``` + +语义非常明确: + +> 知识库的逻辑空间名必须在对象存储层也是唯一的。 + +### 11.4 这一步与后续上传链路的关系 + +后续文件上传服务 [S3FileStorageService](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/S3FileStorageService.java#L145-L166) 在真正上传文件时,会直接: + +```java +s3Client.putObject(... .bucket(bucketName) ...) +``` + +所以如果这里不提前建桶,后续上传文档时就没有落点。 + +因此: + +> 创建知识库时建桶,本质上是在为未来的原始文档上传提前准备物理存储空间。 + +## 12. 第五阶段:创建向量空间 + +代码在 [KnowledgeBaseServiceImpl](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeBaseServiceImpl.java#L104-L110): + +```java +VectorSpaceSpec spaceSpec = VectorSpaceSpec.builder() + .spaceId(VectorSpaceId.builder() + .logicalName(requestParam.getCollectionName()) + .build()) + .remark(requestParam.getName()) + .build(); +vectorStoreAdmin.ensureVectorSpace(spaceSpec); +``` + +### 12.1 为什么要先抽象成 `VectorSpaceSpec` + +[VectorStoreAdmin](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/VectorStoreAdmin.java#L20-L37) 是一个跨向量引擎统一抽象: + +```java +void ensureVectorSpace(VectorSpaceSpec spec); +``` + +也就是说,知识库服务层并不关心底层到底是: + +- PostgreSQL + pgvector +- 还是 Milvus + +它只表达一件事: + +> 请确保这个逻辑向量空间存在。 + +这是一种典型的“业务层只依赖抽象,底层存储按配置切换”的设计。 + +### 12.2 `VectorSpaceId` 的语义 + +[VectorSpaceId](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/VectorSpaceId.java#L29-L41) 里定义了: + +- `logicalName` +- `namespace` + +当前这里只设置了 `logicalName = collectionName`。 + +含义是: + +- 业务层对这个知识空间的统一名字是 `collectionName` +- 至于底层怎么把这个名字映射成具体存储结构,由不同引擎实现决定 + +### 12.3 `remark` 有什么用 + +这里的: + +```java +.remark(requestParam.getName()) +``` + +本质上是在给向量空间附一个可读备注。 + +在 Milvus 模式下,这个值会被当作 collection 的描述信息写进去。 + +## 13. 向量空间初始化的两种实现 + +这是这条链路里最重要、最容易被忽略的技术点之一。 + +### 13.1 当前配置用的是 `pg` + +[application.yaml](file:///e:/java/workspace/ragent/bootstrap/src/main/resources/application.yaml#L44-L51) 中当前配置是: + +```yaml +rag: + vector: + type: pg +``` + +因此当前实际注入的 `vectorStoreAdmin` 是 [PgVectorStoreAdmin](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/PgVectorStoreAdmin.java#L27-L63)。 + +### 13.2 `pg` 模式不是“每个知识库建一张向量表” + +[PgVectorStoreAdmin.ensureVectorSpace](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/PgVectorStoreAdmin.java#L37-L52) 的逻辑是: + +1. 检查 `pg_indexes` 里是否存在固定名字的 HNSW 索引 +2. 如果没有,就在统一表 `t_knowledge_vector` 上创建索引 + +也就是说,在 `pg` 模式下: + +- 不会为每个 `collectionName` 真正创建独立物理表 +- 也不会真的创建独立 collection +- `collectionName` 更像统一向量表中的逻辑空间标识 + +这一点还能从 [PgVectorStoreService](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/PgVectorStoreService.java#L41-L123) 看出来: + +- 向量统一写入 `t_knowledge_vector` +- `collectionName` 被塞到 `metadata.collection_name` +- 查询和删除时再按 metadata 过滤 + +所以在 `pg` 模式下,这条创建链路里的“创建向量空间”更准确地说是: + +> 确保统一向量表具备索引能力,并约定后续把该知识库的数据按 `collectionName` 做逻辑隔离。 + +### 13.3 `milvus` 模式才会真正建物理 Collection + +[MilvusVectorStoreAdmin.ensureVectorSpace](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/vector/MilvusVectorStoreAdmin.java#L46-L120) 则完全不同。 + +它会: + +1. `hasCollection(logicalName)` 判断 collection 是否已存在 +2. 定义字段: + - `id` + - `content` + - `metadata` + - `embedding` +3. 设置向量维度 `rag.default.dimension` +4. 配置 HNSW 索引和 `COSINE` 度量 +5. 真正调用 `milvusClient.createCollection(...)` + +所以在 `milvus` 模式下: + +- `collectionName` 会映射成一个真实的 Milvus collection +- 不同知识库是物理隔离的 + +### 13.4 统一抽象带来的价值 + +虽然底层实现差异很大,但上层 `KnowledgeBaseServiceImpl.create(...)` 完全不需要改代码。 + +这体现了项目在向量层做的两个设计目标: + +1. 业务层统一 +2. 存储层可切换 + +这也是 `VectorStoreAdmin` 这个抽象存在的真正意义。 + +## 14. 第六阶段:返回知识库 ID + +方法最后返回: + +```java +return String.valueOf(kbDO.getId()); +``` + +为什么这里返回的是 `kbId`,而不是整条知识库对象? + +因为这个创建接口的职责偏“命令型”: + +- 核心是完成创建动作 +- 前端只要拿到主键,就可以再调详情接口或跳到知识库管理页 + +这属于比较典型的“创建后返回主键”的 API 风格。 + +## 15. 统一异常与返回收口 + +这条链路抛出来的异常最终会被 [GlobalExceptionHandler](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/web/GlobalExceptionHandler.java#L55-L130) 收口。 + +### 15.1 业务异常 + +如果业务里抛的是: + +- [ServiceException](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/exception/ServiceException.java#L26-L55) +- [ClientException](file:///e:/java/workspace/ragent/framework/src/main/java/com/nageoffer/ai/ragent/framework/exception/ClientException.java#L23-L52) + +都会进入: + +```java +@ExceptionHandler(value = {AbstractException.class}) +``` + +最后返回统一失败响应。 + +### 15.2 未知异常 + +如果这里因为空指针、网络错误等抛了未捕获异常,则会走: + +```java +@ExceptionHandler(value = Throwable.class) +``` + +返回通用服务端错误。 + +这意味着: + +- 业务层可以大胆抛异常 +- Controller 不需要自己写 try-catch +- 整个接口响应格式能保持一致 + +## 16. 事务边界与一致性分析 + +这是这条链路最值得深入理解的工程点。 + +### 16.1 `@Transactional` 到底覆盖了什么 + +`create(...)` 方法标了: + +```java +@Transactional +``` + +它能保证的是: + +- 数据库操作在一个本地事务里 +- 如果方法抛出运行时异常,数据库插入会回滚 + +它不能天然保证的是: + +- RustFS Bucket 回滚 +- Milvus / pgvector 外部资源回滚 + +因为这两者都不是同一个数据库事务资源。 + +### 16.2 资源创建顺序 + +当前顺序是: + +1. `insert(kbDO)` +2. `createBucket(bucketName)` +3. `ensureVectorSpace(spaceSpec)` + +所以不同失败点的后果不一样。 + +```mermaid +flowchart LR + A["DB insert 成功"] --> B["Bucket 创建成功"] + B --> C["向量空间创建成功"] + + A -.Bucket 创建失败.-> R1["抛异常 -> DB 回滚"] + B -.向量空间创建失败.-> R2["抛异常 -> DB 回滚,但 Bucket 可能已存在"] +``` + +### 16.3 最关键的风险点 + +如果流程走到: + +1. 数据库插入成功 +2. Bucket 创建成功 +3. `ensureVectorSpace(...)` 失败 + +那么结果会是: + +- 数据库事务回滚 +- 但已经创建出的 Bucket 不会自动删除 + +这就会留下“外部资源孤儿”。 + +因此从严格意义上讲,这条链路不是分布式强一致事务,而是: + +> 以数据库事务为中心,外部资源 best effort 初始化的链路。 + +### 16.4 为什么项目仍然这样设计 + +因为对这个场景来说,完整引入分布式事务或补偿机制会让实现复杂度显著上升。 + +当前方案的优点是: + +- 简单直接 +- 主链路短 +- 大多数情况下足够实用 + +它的代价是: + +- 需要接受极少数情况下的外部残留资源 +- 后续最好有后台治理或人工清理机制 + +这类取舍非常典型,面试时讲出来会显得你理解的是“真实工程”,不是纸面理想模型。 + +## 17. 这条链路和后续文档处理的关系 + +知识库创建只是知识链路的起点,后面还有两条重要子链路依赖它: + +### 17.1 文件上传链路依赖 Bucket + +后续文档上传时,[KnowledgeDocumentServiceImpl.upload](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L122-L154) 会拿: + +```java +kbDO.getCollectionName() +``` + +作为 bucket 名传给文件存储层。 + +所以如果知识库创建时没把对象存储准备好,后续上传文档根本无处落盘。 + +### 17.2 分块 / 向量化链路依赖 `embeddingModel` 与 `collectionName` + +在分块链路里,[KnowledgeDocumentServiceImpl.runChunkProcess](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/service/impl/KnowledgeDocumentServiceImpl.java#L298-L322) 会用: + +- `kbDO.getEmbeddingModel()` + - 决定调用哪个 embedding 模型 + +而最终向量写入阶段会用: + +- `kbDO.getCollectionName()` + - 决定向量写到哪个逻辑空间 + +所以在知识库创建阶段填下来的这两个字段,其实提前决定了: + +- 文档怎么向量化 +- 向量往哪里写 + +### 17.3 检索阶段依赖同一个逻辑空间 + +检索阶段也会基于这个知识空间做范围收敛。 + +因此从生命周期角度看: + +- `createKnowledgeBase` + - 建立知识空间 +- `uploadDocument` + - 往空间里放原始文档 +- `startChunk` + - 把文档转换成 Chunk 和向量 +- `retrieve` + - 从该空间里检索知识 + +知识库创建是整条知识链路的“空间初始化入口”。 + +## 18. 这条接口的几个关键技术要点 + +### 18.1 接口不是只写一张表 + +它同时协调了: + +- 元数据层 +- 对象存储层 +- 向量空间层 + +所以这是一个典型的“资源编排型接口”。 + +### 18.2 `collectionName` 是统一命名空间主线 + +它不是普通字段,而是整个知识库生命周期里的关键锚点: + +- 建桶时用它 +- 建向量空间时用它 +- 上传文件时用它 +- 写向量时用它 + +### 18.3 当前 `pg` 模式和 `milvus` 模式语义不同 + +上层 API 一样,但底层语义不同: + +- `pg` + - 逻辑隔离 +- `milvus` + - 物理 collection 隔离 + +这是非常重要的源码级理解点。 + +### 18.4 事务只覆盖数据库 + +这条链路不是强一致分布式事务,而是“数据库事务 + 外部资源初始化”模型。 + +### 18.5 参数校验仍有提升空间 + +从当前源码看,这个接口缺少: + +- `name` 非空校验 +- `collectionName` 命名规范校验 +- `embeddingModel` 合法性校验 + +因此现在的鲁棒性更多依赖后续逻辑和外部系统报错。 + +## 19. 面试时可以怎么讲 + +你可以这样概括这条链路: + +> 创建知识库接口表面上只是一个 `POST /knowledge-base`,但它背后实际上在初始化一个完整知识空间。业务层先做名称去重,再落 `t_knowledge_base` 元数据,然后基于 `collectionName` 同时创建 RustFS 的对象存储桶和向量空间。这里的关键点是 `collectionName` 贯穿了后续文件上传、分块、向量写入和检索;另外虽然方法标了 `@Transactional`,但事务只覆盖数据库,不覆盖 S3 和向量库,所以它属于数据库中心事务加外部资源初始化的模型。在当前 `pg` 配置下,所谓创建向量空间其实是确保统一向量表具备索引并通过 `collectionName` 做逻辑隔离;如果切到 Milvus,则会真正创建物理 collection。 + +## 20. 一句话总结 + +这条 [createKnowledgeBase](file:///e:/java/workspace/ragent/bootstrap/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeBaseController.java#L52-L58) 链路的本质是: + +> 用一次同步接口调用,初始化一个知识库在系统中的三层基础设施形态:数据库元数据、对象存储命名空间、向量检索命名空间,从而为后续文档上传、分块和检索提供统一承载空间。 diff --git "a/docs/\350\256\260\345\277\206\345\212\240\350\275\275\351\223\276\350\267\257\350\257\246\350\247\243.md" "b/docs/\350\256\260\345\277\206\345\212\240\350\275\275\351\223\276\350\267\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000..5266cb975 --- /dev/null +++ "b/docs/\350\256\260\345\277\206\345\212\240\350\275\275\351\223\276\350\267\257\350\257\246\350\247\243.md" @@ -0,0 +1,775 @@ +# Ragent 记忆加载链路详解 + +## 1. 文档目标 + +本文聚焦 Ragent 问答主链路中的“记忆加载”部分,完整解释以下问题: + +- `StreamChatPipeline.loadMemory()` 到底做了什么 +- 为什么它不是简单“查历史消息” +- 历史消息、会话摘要、当前问题三者是什么关系 +- 摘要何时生成、何时加载、何时参与 Prompt +- 这套设计背后的工程权衡是什么 + +本文覆盖从流水线入口到数据库读写、再到摘要压缩回写的完整闭环。 + +## 2. 总框图 + +```mermaid +flowchart TD + A["StreamChatPipeline loadMemory(ctx)"] --> B["ConversationMemoryService loadAndAppend(...)"] + B --> C["load(...)"] + B --> D["append current user"] + C --> E["DefaultConversationMemoryService.load(...)"] + E --> F["loadLatestSummary"] + E --> G["loadHistory"] + F --> H["attachSummary"] + G --> H + H --> I["ctx.setHistory(...)"] + D --> J["ConversationMemoryStore.append(...)"] + J --> K["conversation_message table"] + J --> L["conversation table"] + J --> M["SummaryService compressIfNeeded(...)"] + M --> N["async summarize after assistant message"] +``` + +## 3. 入口在哪里 + +记忆加载从问答流水线第一步开始: + +```java +private void loadMemory(StreamChatContext ctx) { + List history = memoryService.loadAndAppend( + ctx.getConversationId(), + ctx.getUserId(), + ChatMessage.user(ctx.getQuestion()) + ); + ctx.setHistory(history); +} +``` + +对应代码位置: + +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/service/pipeline/StreamChatPipeline.java` +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/memory/ConversationMemoryService.java` +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/core/memory/DefaultConversationMemoryService.java` + +这一步的核心结论只有一句: + +> `loadMemory()` 不是“只读历史”,而是“先加载旧记忆,再追加本轮用户消息”。 + +这个顺序非常关键,后文会反复提到。 + +## 4. 记忆模型的核心概念 + +在理解代码前,先明确这套记忆系统中的 3 类信息: + +### 4.1 最近历史消息 + +指最近若干轮原始对话消息,通常是: + +- `user` +- `assistant` +- `user` +- `assistant` + +它们的特点: + +- 保真度最高 +- 适合保留近距离上下文 +- 成本随轮数线性增长 + +### 4.2 会话摘要 + +指对更早历史对话做压缩后的摘要文本。 + +它的特点: + +- 压缩更早上下文 +- 节省 token +- 以 `system` 消息形式注入模型上下文 + +### 4.3 当前用户问题 + +即本轮最新提问,例如: + +- “这个接口为什么会走摘要加载?” + +它的特点: + +- 在 `loadMemory()` 阶段会先写入存储 +- 但不会被塞进 `ctx.history` +- 后续在真正构造模型请求时,会作为“当前 user message”单独加入 + +这个设计避免了当前问题重复出现两次。 + +## 5. 第一层入口:`loadAndAppend()` + +`ConversationMemoryService` 中的默认实现如下: + +```java +default List loadAndAppend(String conversationId, String userId, ChatMessage message) { + List history = load(conversationId, userId); + append(conversationId, userId, message); + return history; +} +``` + +这段代码确定了整条链路最重要的时序: + +```text +先 load 历史 +再 append 当前用户消息 +最后返回旧历史 +``` + +### 5.1 这个时序意味着什么 + +它意味着: + +- `ctx.history` 中拿到的是“当前问题出现之前的历史” +- 当前问题已经入库,但不在返回的历史列表里 +- 后续 Prompt 构造时,当前问题会单独再加一次 + +### 5.2 为什么不先 append 再 load + +如果先把当前问题写入,再去读历史,会带来两个问题: + +- 当前问题会混入历史,和后续单独追加的 user message 重复 +- 查询改写、意图识别等阶段拿到的历史语义会变模糊 + +所以作者故意把它拆成: + +- “旧记忆”负责描述上下文 +- “当前问题”负责驱动本轮任务 + +## 6. 第二层:`load()` 如何加载旧记忆 + +`DefaultConversationMemoryService.load()` 是真正的旧记忆加载入口。 + +其核心逻辑可以概括成: + +```text +参数校验 + -> +并行加载摘要 +并行加载最近历史 + -> +等待两个任务完成 + -> +把摘要挂到历史最前面 + -> +返回最终 history +``` + +### 6.1 参数校验 + +如果 `conversationId` 或 `userId` 为空,直接返回空列表。 + +原因很直接: + +- 记忆按“用户 + 会话”隔离 +- 任一维缺失,都不该冒险读取别人的消息或错误上下文 + +### 6.2 并行加载的原因 + +代码使用两个 `CompletableFuture` 并发读取: + +- 摘要 +- 历史消息 + +这样做的设计动机是: + +- 这两类数据来源不同 +- 都是读操作,天然适合并发 +- 对流式问答来说,首包速度很重要 + +因此这里不是为了炫技,而是为了尽量缩短问答入口阶段的冷启动等待。 + +## 7. 历史消息加载链路 + +最近历史消息由 `JdbcConversationMemoryStore.loadHistory()` 加载。 + +### 7.1 查询上限如何确定 + +历史保留条数来自配置: + +- `rag.memory.history-keep-turns` + +对应代码: + +```java +private int resolveMaxHistoryMessages() { + int maxTurns = memoryProperties.getHistoryKeepTurns(); + return maxTurns * 2; +} +``` + +这代表系统的基本假设是: + +- 1 轮 = 1 条 `user` + 1 条 `assistant` + +默认配置见 `MemoryProperties`: + +- `historyKeepTurns = 8` + +因此默认最多取最近 `16` 条消息。 + +### 7.2 数据从哪里查 + +`JdbcConversationMemoryStore.loadHistory()` 会调用: + +- `ConversationMessageService.listMessages(...)` + +再由 `ConversationMessageServiceImpl` 从 `conversation_message` 表读取。 + +查询特点: + +- 按 `createTime` 排序 +- 带 `limit` +- 只查当前 `conversationId + userId` +- 只查未删除消息 + +### 7.3 为什么传 `DESC` 最后又变成正序 + +在 `JdbcConversationMemoryStore` 中调用了: + +- `ConversationMessageOrder.DESC` + +但 `ConversationMessageServiceImpl.listMessages()` 在收到降序查询后,会在内存中 `Collections.reverse(records)`。 + +其最终效果是: + +- 先高效地从数据库取最近 N 条消息 +- 再在返回前转回时间正序 + +这样上层拿到的历史永远是: + +```text +较早消息 -> 较晚消息 +``` + +这很适合后续直接喂给模型。 + +### 7.4 消息如何转换为统一结构 + +数据库记录最终会被转成 `ChatMessage`: + +- `user` -> `ChatMessage.user(...)` +- `assistant` -> `ChatMessage.assistant(...)` + +统一数据结构的价值在于: + +- 后续无论是 Prompt 组装还是模型调用,都只面对一种抽象 +- 屏蔽了数据库表结构细节 + +### 7.5 为什么只保留 `user/assistant` + +历史加载阶段会过滤消息,只保留: + +- `USER` +- `ASSISTANT` + +原因是: + +- 历史对话本质上是对话轮次,不应混入别的系统消息 +- 摘要会单独以 `SYSTEM` 角色追加 +- 这样能避免角色语义混乱 + +### 7.6 `normalizeHistory()` 为什么要裁掉前缀 assistant + +代码会把历史开头连续的 `ASSISTANT` 消息裁掉,直到遇到第一个 `USER` 为止。 + +这段逻辑非常有意思,它解决的是一个容易被忽视的 Prompt 质量问题: + +- 如果只保留最近 N 条消息,可能截断在一轮对话中间 +- 此时最前面可能是 `assistant` 回复 +- 模型看到这样的上下文,语义上会不自然 + +所以系统选择: + +- 让历史尽量从 `user` 开始 + +这会损失部分消息,但换来更好的上下文完整性。 + +### 7.7 历史加载失败怎么办 + +如果历史读取失败: + +- 记 error 日志 +- 返回空列表 + +这说明“记忆增强”不是主链路硬依赖,系统宁可降级,也不直接让聊天失败。 + +## 8. 摘要加载链路 + +摘要由 `JdbcConversationMemorySummaryService.loadLatestSummary()` 加载。 + +### 8.1 数据来源 + +摘要查询最终走: + +- `ConversationGroupService.findLatestSummary(...)` + +再由 `ConversationGroupServiceImpl` 从 `conversation_summary` 表中取最新一条摘要记录。 + +查询特点: + +- 只按 `conversationId + userId` +- 只取未删除记录 +- `order by id desc limit 1` + +也就是说,系统只关心“这个会话最新的摘要快照”。 + +### 8.2 摘要为什么被转成 `SYSTEM` 消息 + +代码中: + +```java +return new ChatMessage(ChatMessage.Role.SYSTEM, record.getContent()); +``` + +这意味着摘要被当成系统上下文,而不是普通对话历史。 + +原因是: + +- 摘要是压缩后的背景说明 +- 它不属于用户自然发言 +- 也不属于模型真实输出 + +从 LLM 视角看,它更像: + +> 系统提供的、可用于理解上下文的辅助背景。 + +### 8.3 摘要还会再包一层模板 + +摘要不会直接裸塞进上下文,而是走 `decorateIfNeeded()`: + +```text + +... + +``` + +模板来自: + +- `bootstrap/src/main/resources/prompt/context-format.st` + +这样做的价值很大: + +- 让模型明确知道这是一段摘要,而不是普通聊天记录 +- 降低模型把摘要当成“当前用户发言”的风险 +- 让 Prompt 结构更稳定、可控 + +### 8.4 摘要加载失败怎么办 + +如果摘要读取异常: + +- 记 warn 日志 +- 返回 `null` + +这里日志等级是 `warn`,而不是 `error`,说明作者认为: + +- 摘要是增强项 +- 没有摘要不是致命错误 + +## 9. 摘要和历史是如何拼接的 + +当两个并行任务都完成后,`attachSummary()` 负责合并结果。 + +逻辑如下: + +- 如果历史为空,直接返回空列表 +- 如果没有摘要,直接返回历史 +- 如果有摘要,把装饰后的摘要放到最前面,再拼接历史 + +最终结构通常是: + +```text +[system] ... +[user] 最近几轮问题 1 +[assistant]最近几轮回答 1 +[user] 最近几轮问题 2 +[assistant]最近几轮回答 2 +... +``` + +### 9.1 这里有一个重要行为细节 + +如果“历史为空但摘要存在”,当前实现仍然返回空列表,而不是只返回摘要。 + +这是因为 `attachSummary()` 第一行就是: + +- `if (CollUtil.isEmpty(messages)) return List.of();` + +这意味着: + +- 摘要不是独立存在的上下文载体 +- 它被设计成“最近历史的前缀增强” + +这不一定是问题,但它是一个值得面试时提出来讨论的实现细节。 + +## 10. 当前用户消息的追加链路 + +`load()` 结束后,`loadAndAppend()` 会调用 `append()` 写入本轮用户消息。 + +### 10.1 追加入口 + +`DefaultConversationMemoryService.append()` 做两件事: + +1. `memoryStore.append(...)` +2. `summaryService.compressIfNeeded(...)` + +这说明“写消息”和“触发摘要压缩”被绑定在一次追加动作里。 + +### 10.2 用户消息如何落库 + +`JdbcConversationMemoryStore.append()` 会把 `ChatMessage` 转为 `ConversationMessageBO`,再调用: + +- `ConversationMessageService.addMessage(...)` + +从而写入 `conversation_message` 表。 + +此外,如果当前消息是 `USER`,还会额外调用: + +- `ConversationService.createOrUpdate(...)` + +作用是: + +- 创建或更新会话主记录 +- 更新最近问题 +- 更新最近时间 + +所以在 `loadMemory()` 阶段,虽然问答还没开始,但会话已经先被“激活”了。 + +### 10.3 为什么要在这么早就写入用户消息 + +这样做有几个明显好处: + +- 即使后续链路失败,用户问题仍然能被记录 +- 管理台/聊天页可以尽早看到本轮输入 +- 有利于后续会话标题、失败兜底、重试分析 + +这是一种典型的“先记用户输入,再处理业务”的工程策略。 + +## 11. 摘要压缩链路 + +虽然本文主题是“记忆加载”,但摘要压缩和它天然构成闭环,因此必须一起理解。 + +### 11.1 什么时候触发摘要压缩 + +`compressIfNeeded()` 有两个硬条件: + +- `summaryEnabled == true` +- 当前追加消息的角色是 `ASSISTANT` + +也就是说: + +- 用户消息写入时不会触发摘要压缩 +- 只有一轮问答完成、assistant 消息落库后,才会异步检查是否要生成新摘要 + +这点非常关键,因为它决定了: + +> 本次 `loadMemory()` 读到的摘要,一定来自“更早的一次或多次对话”,而不是当前这轮新问题。 + +### 11.2 为什么只在 assistant 消息后压缩 + +因为一轮对话的语义通常在 assistant 回复后才完整闭环。 + +如果在 user 消息写入后就压缩,会出现: + +- 只压进问题,没有答案 +- 摘要语义不完整 + +所以作者把触发时机放在 assistant 完成之后,这是合理的。 + +### 11.3 压缩是同步还是异步 + +压缩通过 `CompletableFuture.runAsync(...)` 异步执行。 + +这样设计的原因: + +- 摘要生成要调用 LLM,成本高、耗时长 +- 不应阻塞主聊天请求 + +因此摘要压缩属于后台维护型任务,而不是实时强一致链路。 + +### 11.4 为什么需要分布式锁 + +摘要任务使用了 Redisson 分布式锁: + +- 锁 key:`ragent:memory:summary:lock:{userId}:{conversationId}` + +其目的在于: + +- 防止同一会话在多实例环境下并发生成重复摘要 +- 保证同一会话摘要更新的串行性 + +这说明作者明确考虑了分布式部署场景,而不是只按单机思路写代码。 + +### 11.5 何时开始生成摘要 + +阈值来自配置: + +- `summaryStartTurns` + +系统统计的是: + +- 当前会话的 `user` 消息总数 + +只有当用户轮次数达到阈值后,才进入摘要逻辑。 + +默认值: + +- `summaryStartTurns = 9` + +配合默认 `historyKeepTurns = 8`,含义是: + +- 当对话轮数刚刚超过“原文保留上限”时,开始把更早消息压成摘要 + +这是一种很自然的切换点设计。 + +### 11.6 哪一段消息会被压缩 + +压缩逻辑的核心边界是: + +- 保留最近 `historyKeepTurns` 轮 user turn 对应的消息不动 +- 将更早、且尚未被摘要覆盖的消息取出来做摘要 + +这里借助了几个查询: + +- `countUserMessages(...)` +- `findLatestSummary(...)` +- `listLatestUserOnlyMessages(...)` +- `listMessagesBetweenIds(...)` +- `findMaxMessageIdAtOrBefore(...)` + +其中最关键的两个边界点是: + +- `afterId`:上一次摘要已经覆盖到哪里 +- `cutoffId`:这次要保留的最新窗口从哪里开始 + +最终只对 `(afterId, cutoffId)` 之间的消息生成新摘要。 + +### 11.7 为什么摘要支持增量合并 + +摘要生成时,如果已有旧摘要,系统会把旧摘要作为一条 `assistant` 消息放进压缩提示词里: + +- 明确要求“仅用于合并去重,不得作为事实新增来源” +- 如果与本轮对话冲突,以本轮对话为准 + +这说明摘要不是每次全量重算,而是: + +- 旧摘要 + 新增历史消息 -> 合并生成新摘要 + +这样做的优点: + +- 成本更低 +- 不必每次把所有历史都重新喂给模型 + +### 11.8 为什么摘要有长度上限 + +摘要生成提示中明确约束: + +- 严格不超过 `summaryMaxChars` +- 仅一行 + +默认: + +- `summaryMaxChars = 200` + +这么做是为了保证摘要真正起到“压缩”作用,而不是从长对话变成另一段长对话。 + +## 12. 记忆加载和摘要压缩的时间关系 + +这条链路最容易搞混的是“读取摘要”和“生成摘要”的先后关系。 + +可以用下面这张时序图理解: + +```text +第 N 轮开始: + load old summary + recent history + append current user message + ... 执行问答 ... + append assistant answer + async compressIfNeeded + +第 N+1 轮开始: + load latest summary generated after round N + load recent history + append current user message +``` + +因此有一个非常重要的结论: + +> 当前轮看到的摘要,来自之前轮次的压缩结果;当前轮产生的新摘要,最早也是下一轮加载时才会被读到。 + +## 13. 配置项如何影响整条链路 + +配置类为 `MemoryProperties`,关键项如下: + +### 13.1 `historyKeepTurns` + +作用: + +- 最近保留多少轮原始消息 + +影响: + +- `loadHistory()` 的最大消息条数 +- 摘要压缩时的保留窗口 + +### 13.2 `summaryEnabled` + +作用: + +- 是否开启摘要压缩 + +影响: + +- 关闭时,系统只保留最近几轮原文,不生成摘要 + +### 13.3 `summaryStartTurns` + +作用: + +- 从第几轮开始触发摘要 + +影响: + +- 过小会导致过早压缩 +- 过大会导致历史膨胀 + +### 13.4 `summaryMaxChars` + +作用: + +- 摘要最大长度 + +影响: + +- 太短会丢信息 +- 太长会失去压缩意义 + +## 14. 这套设计背后的核心思想 + +### 14.1 “短期记忆 + 长期摘要” + +这是整套方案最核心的思想: + +- 近处上下文保留原文,保证细节 +- 远处上下文压缩成摘要,控制成本 + +这在 LLM 应用里是非常典型且实用的方案。 + +### 14.2 “加载与压缩解耦” + +加载发生在问答入口,要求: + +- 快 +- 稳 +- 可降级 + +压缩发生在后台异步阶段,要求: + +- 不阻塞用户 +- 能增量推进 + +两者解耦后,用户体验和系统成本都更容易平衡。 + +### 14.3 “当前问题与历史分离” + +系统刻意让: + +- `history` 只代表旧上下文 +- 当前问题单独进入本轮模型输入 + +这样可以降低重复、歧义和上下文污染。 + +### 14.4 “容错优先” + +无论是: + +- 摘要加载失败 +- 历史加载失败 +- 摘要压缩失败 + +系统都不会直接让问答主链路报错,而是尽量降级为“无记忆”或“无摘要”模式。 + +这是典型的生产系统思路。 + +## 15. 值得注意的实现细节 + +### 15.1 `loadMemory()` 本质上是读写混合操作 + +名字叫“load”,但实际做的是: + +- 读旧记忆 +- 写当前消息 + +这在阅读源码时必须牢记。 + +### 15.2 旧历史不包含当前问题 + +这是 Prompt 组装时避免重复的关键。 + +### 15.3 摘要是 `SYSTEM` 而不是 `ASSISTANT` + +这是一种显式的 Prompt 工程策略,不是随便选的角色。 + +### 15.4 摘要有模板包装,不是裸文本 + +这能帮助模型更稳定地理解“这是一段摘要”。 + +### 15.5 历史从 `USER` 开始做归一化 + +这体现了作者对“上下文是否自然完整”的敏感度。 + +### 15.6 摘要压缩只在 assistant 消息后触发 + +这保证了被压缩的是完整轮次,而不是半轮对话。 + +## 16. 可能的边界场景 + +### 16.1 只有摘要,没有最近历史 + +当前实现会返回空列表,不会只返回摘要。 + +这可能导致: + +- 某些极端场景下,已有摘要但未被使用 + +如果未来要增强,可以考虑: + +- 当历史为空但摘要存在时,允许只返回摘要 + +### 16.2 摘要压缩失败 + +系统会回退为旧摘要或不使用摘要,不影响主问答流程。 + +### 16.3 历史窗口截断在 assistant 开头 + +`normalizeHistory()` 会主动裁剪,避免不自然上下文。 + +### 16.4 多实例同时压缩同一会话 + +Redisson 分布式锁负责避免重复压缩。 + +## 17. 学习这条链路的建议顺序 + +建议按下面顺序阅读源码: + +1. `StreamChatPipeline.loadMemory()` +2. `ConversationMemoryService.loadAndAppend()` +3. `DefaultConversationMemoryService.load()` +4. `JdbcConversationMemoryStore.loadHistory()` +5. `JdbcConversationMemorySummaryService.loadLatestSummary()` +6. `DefaultConversationMemoryService.attachSummary()` +7. `DefaultConversationMemoryService.append()` +8. `JdbcConversationMemorySummaryService.compressIfNeeded()` +9. `JdbcConversationMemorySummaryService.doCompressIfNeeded()` + +这样能最顺畅地把“加载链路”和“压缩链路”连起来。 + +## 18. 一句话总结 + +Ragent 的记忆加载链路本质上是一套“近端原文保留、远端摘要压缩、加载与压缩解耦、当前问题独立建模”的多轮对话上下文管理方案:它在问答入口并行读取最近历史和最新摘要,将摘要包装成 `system` 上下文拼接到历史前方,同时提前持久化当前用户问题,并在 assistant 回复落库后异步触发增量摘要压缩,从而在上下文质量、Token 成本和系统稳定性之间取得平衡。 diff --git "a/docs/\351\231\220\346\265\201\346\216\222\351\230\237\346\234\272\345\210\266\350\257\246\350\247\243.md" "b/docs/\351\231\220\346\265\201\346\216\222\351\230\237\346\234\272\345\210\266\350\257\246\350\247\243.md" new file mode 100644 index 000000000..48bbb1490 --- /dev/null +++ "b/docs/\351\231\220\346\265\201\346\216\222\351\230\237\346\234\272\345\210\266\350\257\246\350\247\243.md" @@ -0,0 +1,1044 @@ +# Ragent 限流排队机制详解 + +## 1. 文档目的 + +本文档专门讲解 Ragent 项目中聊天问答入口使用的“限流排队”机制。目标不是只说明“它会限流”,而是把这套机制的设计动机、代码结构、Redis 数据结构、并发控制方式、Lua 原子操作、SSE 生命周期、超时拒绝处理、跨节点唤醒、边界场景与调优策略讲清楚。 + +如果你正在学习这个项目,建议把本文与以下几个类对照着一起看: + +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatRateLimitAspect.java` +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/aop/ChatQueueLimiter.java` +- `bootstrap/src/main/java/com/nageoffer/ai/ragent/rag/config/RAGRateLimitProperties.java` +- `bootstrap/src/main/resources/lua/queue_claim_atomic.lua` +- `bootstrap/src/main/resources/application.yaml` + +## 2. 为什么不能只做普通限流 + +很多 Web 项目做限流,第一反应是: + +- 固定时间窗口限流 +- 令牌桶限流 +- 拿不到名额直接报 `429` + +但大模型聊天和普通接口有明显区别: + +- 单次请求耗时长,可能持续数秒到数十秒 +- 请求过程中要长期占用连接和下游模型资源 +- 请求成本高,不仅消耗线程,还消耗模型配额和供应商调用额度 +- 问答是流式的,不能简单地把请求丢给后台后立即结束 +- 用户对体验敏感,直接拒绝所有超量请求会让产品体验很差 + +因此,本项目没有采用“超了就拒绝”的最简单方案,而是实现了一套更适合 AI 问答场景的机制: + +- 控制全局最大并发数 +- 超过并发数的请求进入排队队列 +- 队列中的请求等待可用名额 +- 等待时间超过阈值后再拒绝 +- 释放名额时尽快唤醒等待方 +- 拒绝时仍通过 SSE 规范返回,而不是粗暴中断 + +一句话概括: + +> 这是一套面向长耗时流式任务的“全局并发控制 + 公平排队 + 超时拒绝 + 分布式唤醒”机制。 + +## 3. 机制在系统中的位置 + +问答主链路中,限流排队发生在真正执行业务流水线之前。 + +整体位置如下: + +```text +前端发起 /rag/v3/chat SSE 请求 + ↓ +Controller 建立 SseEmitter + ↓ +进入 RAGChatServiceImpl.streamChat() + ↓ +被 @ChatRateLimit 对应的 AOP 切面拦截 + ↓ +ChatQueueLimiter.enqueue() 决定: + ├─ 直接放行执行 + ├─ 进入排队等待 + └─ 超时后拒绝 + ↓ +获得执行资格后,才真正调用问答主流程 +``` + +这里最关键的一点是: + +- `@IdempotentSubmit` 更偏入口防重复点击 +- `@ChatRateLimit` 才是真正负责“聊天资源并发治理”的核心机制 + +## 4. 入口切面:`ChatRateLimitAspect` + +### 4.1 拦截点 + +切面定义如下: + +```java +@Around("@annotation(com.nageoffer.ai.ragent.rag.aop.ChatRateLimit)") +``` + +这意味着: + +- 只要方法上标了 `@ChatRateLimit` +- 调用该方法时不会直接执行原方法 +- 而是先进入切面逻辑 + +当前被拦截的核心方法是: + +- `RAGChatServiceImpl.streamChat(...)` + +### 4.2 切面做了什么 + +切面 `limitStreamChat(...)` 的职责并不复杂,但很关键: + +1. 从方法参数中取出 `question`、`conversationId`、`SseEmitter` +2. 如果 `conversationId` 为空,先生成一个实际会话 ID +3. 把执行逻辑封装成一个 `Runnable onAcquire` +4. 把请求交给 `ChatQueueLimiter.enqueue(...)` +5. 由 `ChatQueueLimiter` 决定这个请求是立即执行、排队,还是最终拒绝 + +切面本身并不真正做排队,而是把“排队策略”下沉给 `ChatQueueLimiter`。 + +### 4.3 为什么切面层做这一层包装 + +好处有 3 个: + +- 业务代码无侵入,服务层不需要写一堆排队模板代码 +- 所有流式聊天入口都可以统一复用这套限流排队机制 +- 便于在切面中顺便挂 Trace、异常处理等横切逻辑 + +## 5. 配置项说明 + +配置类为 `RAGRateLimitProperties`,主要参数如下: + +- `rag.rate-limit.global.enabled` + - 是否开启全局限流 +- `rag.rate-limit.global.max-concurrent` + - 最大并发执行数 +- `rag.rate-limit.global.max-wait-seconds` + - 请求在队列中的最大等待时间 +- `rag.rate-limit.global.lease-seconds` + - permit 的租约时长,超时自动回收 +- `rag.rate-limit.global.poll-interval-ms` + - 轮询队列的周期 + +当前项目配置: + +```yaml +rag: + rate-limit: + global: + enabled: true + max-concurrent: 1 + max-wait-seconds: 3 + lease-seconds: 30 + poll-interval-ms: 200 +``` + +这组值的含义是: + +- 全局只允许 1 个聊天请求同时执行 +- 其他请求最多排队 3 秒 +- 每 200ms 至少轮询一次是否能获得执行资格 +- 某个执行中的请求最多持有 permit 30 秒,超时后 permit 会被 Redis 自动回收 + +这组参数在开发或演示环境下比较保守,目的通常是: + +- 严格保护模型下游 +- 明确观察排队效果 +- 避免本地机器或测试模型被打爆 + +## 6. 核心数据结构设计 + +这套机制的核心不在一个类,而在几个 Redis 结构之间的组合。 + +### 6.1 全局信号量:`RPermitExpirableSemaphore` + +使用名称: + +- `rag:global:chat` + +职责: + +- 限制“当前最多有多少个聊天请求在执行” +- 谁拿到 permit,谁才算真正拥有执行资格 +- permit 自带过期时间,防止持有方异常退出后永不释放 + +为什么不用普通锁: + +- 普通锁只适合“1 个执行者” +- 这里需要的是“允许 N 个并发执行者” +- 所以更适合用 semaphore + +为什么不用普通 semaphore,而用 expirable semaphore: + +- 普通 semaphore 如果进程挂了,permit 可能丢不回来 +- expirable semaphore 有租约,到期自动回收 +- 更适合长连接、流式、跨节点场景 + +### 6.2 排队队列:`RScoredSortedSet` + +使用名称: + +- `rag:global:chat:queue` + +职责: + +- 保存等待执行的请求 +- 按 score 排序,score 越小越靠前 +- 用于维护基本公平的排队顺序 + +队列元素: + +- 成员值:`requestId` +- score:递增序号 `seq` + +### 6.3 排队序号生成器:`RAtomicLong` + +使用名称: + +- `rag:global:chat:queue:seq` + +职责: + +- 为每个入队请求生成单调递增序号 +- 保证入队顺序尽量稳定 + +### 6.4 发布订阅通知:`RTopic` + +使用名称: + +- `rag:global:chat:queue:notify` + +职责: + +- permit 释放后通知所有节点 +- 让等待中的请求不必死等下一个固定轮询周期 +- 提升从“有空位”到“拿到执行资格”的响应速度 + +## 7. 从请求进入到真正执行的完整流程 + +下面按代码执行顺序详细说明。 + +### 7.1 第一步:进入 `enqueue()` + +方法入口: + +- `ChatQueueLimiter.enqueue(String question, String conversationId, SseEmitter emitter, Runnable onAcquire)` + +参数含义: + +- `question`:本次请求的用户问题 +- `conversationId`:会话 ID +- `emitter`:SSE 连接对象 +- `onAcquire`:真正拿到执行资格后要执行的业务逻辑 + +从设计上看,这个方法不直接关心业务流程,只关心一件事: + +> 当前这个请求,什么时候可以合法开始执行。 + +### 7.2 第二步:如果没开启限流,直接放行 + +代码逻辑: + +- 如果 `globalEnabled != true` +- 直接提交到 `chatEntryExecutor` +- 不做排队 + +这说明本机制是一个可开关的治理层,不会强行耦合业务逻辑。 + +### 7.3 第三步:生成请求上下文并入队 + +在开启限流时,会做这些事情: + +1. 解析用户 ID +2. 初始化 `cancelled` 标记 +3. 初始化 `permitRef` +4. 生成 `requestId` +5. 获取 Redis 队列对象 +6. 通过原子序号 `nextQueueSeq()` 获取排队顺序号 +7. 将 `requestId` 放入 ZSET 队列 + +为什么要先入队,再尝试执行? + +- 因为所有请求都要先进入一个统一的排队体系 +- 这样才能在分布式环境下形成比较稳定的先来先服务顺序 +- 如果先尝试直接抢 permit,再决定要不要入队,顺序会更混乱 + +### 7.4 第四步:注册 SSE 生命周期释放动作 + +方法里定义了一个 `releaseOnce`,并绑定到: + +- `emitter.onCompletion` +- `emitter.onTimeout` +- `emitter.onError` + +这个释放动作做了 4 件事: + +1. 把本地状态标记为 `cancelled` +2. 从排队队列中移除当前请求 +3. 如果已经拿到 permit,则释放 permit +4. 发布队列通知,唤醒其他等待者 + +这一步很关键,因为 SSE 不是普通短请求。连接可能: + +- 正常结束 +- 客户端主动断开 +- 服务端超时 +- 处理中途报错 + +无论哪种情况,都必须把“排队状态”和“并发 permit”清理掉,否则系统很容易出现: + +- 名额泄漏 +- 队列脏数据 +- 后续请求永远拿不到执行资格 + +## 8. 快速尝试执行:`tryAcquireIfReady` + +请求入队之后,不会立刻进入等待,而是会先尝试一次快速放行。 + +### 8.1 第一个判定:请求是否已经取消 + +如果 `cancelled` 已经是 `true`,说明连接已经结束或被取消,不再继续后续逻辑。 + +### 8.2 第二个判定:当前是否有空闲 permit + +通过 `availablePermits()` 获取当前剩余许可数。 + +如果没有剩余 permit: + +- 当前请求虽然已经入队 +- 但没有进入执行窗口 +- 只能继续等待 + +这里要注意: + +- 队列和 permit 是两个不同维度 +- “在队列里靠前”不等于“可以立刻执行” +- 必须同时满足“有空位”和“排位满足条件” + +### 8.3 第三个判定:当前请求是否位于允许进入的队头窗口 + +这是整个机制最值得学习的部分之一。 + +作者没有直接在 Java 里: + +- 先查 rank +- 再 decide +- 再 remove + +而是使用 Lua 脚本做了一个原子 claim。 + +Lua 脚本文件: + +- `queue_claim_atomic.lua` + +脚本逻辑: + +1. 取当前请求在队列中的排名 `ZRANK` +2. 如果请求不在队列中,返回失败 +3. 如果 `rank >= availablePermits`,返回失败 +4. 否则说明当前请求位于可进入的队头窗口 +5. 记录它原始 score +6. 原子地把该请求从队列移除 +7. 返回 claim 成功 + +### 8.4 为什么要判断 `rank < availablePermits` + +假设当前: + +- 队列前面有多个请求 +- 剩余 permit 为 2 + +那么此时排在前 2 名的请求都应该有资格争取执行资格,而不是只让第 1 个请求尝试。 + +这是一个很重要的设计点: + +- 如果只允许 rank = 0 的请求去拿 permit,会导致已有空位时吞吐不够高 +- 允许 `rank < availablePermits`,意味着“队头窗口内的请求都可以竞争当前空位” + +这在多 permit 并发时能更充分利用资源。 + +### 8.5 为什么要用 Lua 脚本 + +如果不用 Lua,而是 Java 里分两步操作: + +1. 先 `ZRANK` +2. 再 `ZREM` + +在多实例并发情况下会出现竞态: + +- 两个实例都看到自己满足条件 +- 两边都继续执行 +- 结果顺序和公平性被破坏 + +Lua 的价值在于: + +- 在 Redis 内部完成“检查 + 出队” +- 这两个动作天然原子 +- 不会被其他客户端插队打断 + +## 9. claim 成功后为什么还要再拿 permit + +claim 成功只代表: + +- 你在队列顺序上已经进入“允许尝试执行”的窗口 + +但这不等于你已经真正拿到了执行名额。 + +真正的执行名额还是由 `RPermitExpirableSemaphore` 控制。 + +因此流程是: + +1. 先通过 Lua 抢占队列执行资格 +2. 再通过 semaphore 真正拿 permit + +这是“双层保护”。 + +### 9.1 为什么不能只靠队列,不靠 semaphore + +因为队列只解决顺序,不解决“当前正在运行的请求数量”。 + +即使你排到队头,也得确认: + +- 当前系统确实还有执行名额 +- 没有被其他节点同时抢走 + +### 9.2 `tryAcquirePermit()` 的行为 + +代码逻辑: + +- 获取 `RPermitExpirableSemaphore` +- 调用 `trySetPermits(maxConcurrent)`,确保 permit 总数初始化完成 +- `tryAcquire(0, leaseSeconds, TimeUnit.SECONDS)` + +这里的关键点: + +- `0` 表示不等待,立即返回 +- `leaseSeconds` 表示该 permit 有自动过期时间 + +### 9.3 claim 成功但 permit 获取失败怎么办 + +这是这套机制处理得非常细的一处。 + +如果出现这种情况,说明: + +- 你已经原子地把自己从队列里移除了 +- 但真正的 permit 被别的实例在极短时间内拿走了 + +项目做法是: + +1. 重新生成新的队列序号 +2. 把请求重新放回队列 +3. 发布队列通知 +4. 返回失败,继续等待后续机会 + +为什么要重新入队而不是报错? + +- 这不是业务错误 +- 只是一个瞬时竞争失败 +- 重新入队更符合公平排队语义 + +## 10. 获得执行资格后如何真正开始执行 + +一旦: + +- 队列 claim 成功 +- permit 获取成功 + +系统就会: + +1. 把 permitId 写入 `permitRef` +2. 再次检查是否已经取消 +3. 发布通知,提示其他等待方状态可能已变化 +4. 提交到 `chatEntryExecutor` +5. 真正执行 `onAcquire` + +这里的 `onAcquire` 本质上就是切面包装好的: + +- Trace 上下文初始化 +- 调用原始 `streamChat(...)` +- 异常处理 + +注意这里为什么不是当前线程直接执行: + +- 限流器更像一个调度层 +- 真正业务执行应该交给业务入口线程池 +- 这样排队线程、轮询线程、业务执行线程职责更清晰 + +## 11. 如果拿不到资格,就进入排队轮询 + +如果初次 `tryAcquireIfReady()` 失败,请求不会立刻被拒绝,而是进入 `scheduleQueuePoll(...)`。 + +### 11.1 轮询时会维护什么状态 + +会计算: + +- `deadline`:最大允许等待到什么时候 +- `intervalMs`:轮询间隔 +- `futureRef`:当前定时任务句柄 + +并为这个请求构造一个 `poller`。 + +### 11.2 `poller` 每次执行做什么 + +每次轮询都会执行以下逻辑: + +1. 如果请求已取消,注销自己并停止轮询 +2. 如果已超过 `deadline`,执行超时拒绝逻辑 +3. 否则继续尝试 `tryAcquireIfReady()` +4. 如果成功,则注销自己并停止轮询 + +### 11.3 为什么还要做定时轮询 + +因为系统不能只依赖通知机制。 + +Pub/Sub 有两个现实问题: + +- 通知可能存在时序延迟 +- 某些情况下需要有一个兜底再检查机制 + +所以项目采用的是: + +- 固定轮询兜底 +- Pub/Sub 提前唤醒加速 + +这是一种比较稳妥的工程实现。 + +## 12. 为什么还需要 `PollNotifier` + +如果只有固定轮询,假设: + +- `poll-interval-ms = 200` +- 某个请求刚在第 1ms 释放 permit + +那么等待中的其他请求最差可能还要再等 199ms 才会发现空位。 + +为了减少这种无意义等待,项目引入了 `PollNotifier`。 + +### 12.1 `PollNotifier` 的职责 + +- 管理当前所有正在等待的 `poller` +- 收到 permit 释放通知后,主动触发它们立即再跑一轮 + +### 12.2 通知来源 + +每次以下动作发生时都会调用 `publishQueueNotify()`: + +- permit 释放 +- 请求超时出队 +- 请求重新入队 +- 某些状态转换导致可用窗口变化 + +对应 Redis Topic: + +- `rag:global:chat:queue:notify` + +### 12.3 本地监听 + +组件启动时在 `@PostConstruct` 中订阅 Topic。 + +一旦收到消息,就执行: + +- `pollNotifier.fire()` + +### 12.4 `fire()` 为什么这么复杂 + +`fire()` 不是简单地“遍历所有 poller 逐个执行”,而是做了几层优化: + +- `pendingNotifications` 用来合并短时间内多次通知 +- `firing` 保证同一时间只有一个通知执行器在跑 +- 如果当前没有可用 permit,直接跳过遍历 +- 遍历结束后,如果执行期间又积压了通知,再补一轮 + +它要解决的问题是: + +- 避免 permit 释放时所有线程疯狂唤醒,形成惊群 +- 避免重复无效触发 +- 尽量把通知合并成更少次数的批量检查 + +这是一个很典型的“本地通知压缩”优化设计。 + +### 12.5 为什么还要做清理线程 + +`PollNotifier` 内部有一个清理线程,每分钟清理注册时间超过 5 分钟的 poller。 + +作用: + +- 防止极端情况下 poller 泄漏 +- 即使某个请求因异常路径没正常注销,也能被最终清掉 + +这是一层“资源兜底回收”。 + +## 13. 超时拒绝机制 + +### 13.1 什么情况下触发拒绝 + +当请求在队列中等待时间超过: + +- `rag.rate-limit.global.max-wait-seconds` + +就会进入超时拒绝逻辑。 + +### 13.2 拒绝前先做什么 + +在真正返回拒绝结果之前,系统会: + +1. 从队列中移除当前请求 +2. 发布通知 +3. 注销 poller +4. 调用 `recordRejectedConversation(...)` + +### 13.3 为什么拒绝还要记录会话 + +这是本项目设计得非常“产品化”的地方。 + +作者没有简单地: + +- 直接断开连接 +- 直接报 HTTP 错 + +而是将这次拒绝也视为一次“有结果的会话交互”。 + +`recordRejectedConversation(...)` 会: + +- 如果用户问题不为空,把用户问题写入会话 +- 再写入一条 assistant 消息:`系统繁忙,请稍后再试` +- 如果是新会话,尽量补一个标题 +- 生成一个 `taskId` + +这样做的结果是: + +- 前端会话列表不会莫名其妙少一条 +- 用户能明确看到自己的问题被系统接收过,但因为系统忙被拒绝 +- 拒绝信息也会体现在会话历史中 + +### 13.4 SSE 返回给前端的事件 + +拒绝场景下,系统依然按 SSE 协议发送规范事件: + +1. `meta` +2. `reject` +3. `finish` +4. `done` + +这意味着前端不需要为“拒绝”额外切一套全新的协议处理逻辑。 + +从协议设计角度看,这是一种非常优秀的统一化处理方式。 + +## 14. permit 的释放机制 + +### 14.1 正常释放 + +正常执行完成后,SSE 连接关闭会触发: + +- `onCompletion` + +进而调用 `releaseOnce`,释放 permit。 + +### 14.2 异常释放 + +如果执行过程中报错,会触发: + +- `onError` + +同样会走 `releaseOnce`。 + +### 14.3 超时释放 + +如果连接超时,会触发: + +- `onTimeout` + +也会释放 permit。 + +### 14.4 自动过期释放 + +即使以上事件都没触发,例如: + +- 节点进程突然崩溃 +- JVM 被 kill +- 某些异常路径没走到释放逻辑 + +permit 也会在 `leaseSeconds` 到期后由 Redis 自动回收。 + +这相当于“显式释放 + 自动过期回收”的双保险。 + +## 15. 这套机制中的关键技术细节 + +这一节专门讲几个最容易被忽略,但非常关键的细节点。 + +### 15.1 为什么先看可用 permit,再执行 Lua claim + +代码里先调用 `availablePermits()`,如果结果小于等于 0,直接返回。 + +原因: + +- 可以减少无意义的 Lua 调用 +- 当前没有任何执行空位时,根本没必要去算自己是不是在队头窗口 + +这是一种性能优化。 + +### 15.2 为什么 Lua claim 成功后还要再竞争一次 permit + +因为: + +- `availablePermits()` 只是一个瞬时快照 +- 多节点可能同时看到“有 1 个空位” +- 真正的全局一致性还得靠 semaphore 再确认一次 + +也就是说: + +- Lua 负责排队顺序的一致性 +- semaphore 负责并发名额的一致性 + +两者解决的问题不同,缺一不可。 + +### 15.3 为什么重新入队时要生成新的序号 + +如果 claim 成功但 permit 获取失败,代码会重新用 `nextQueueSeq()` 给它一个新的序号再入队。 + +这意味着它不会回到原来的精确位置,而是“重新加入队列尾部附近”。 + +这种策略的含义是: + +- 既然本次竞争执行资格失败,就按新的排队轮次处理 +- 避免复杂的原位置恢复逻辑 +- 实现更简单,也更容易保证一致性 + +代价是: + +- 极端高竞争下,个别请求的绝对公平性可能略受影响 + +但在工程上,这是一个可以接受的折中。 + +### 15.4 为什么拒绝时还要查用户 ID + +在 `recordRejectedConversation(...)` 中,如果当前 `userId` 为空,会尝试用 `StpUtil.getLoginIdAsString()` 再取一次。 + +这是一个兜底逻辑。 + +原因: + +- 某些异步场景下用户上下文可能不完整 +- 但记录会话又必须知道归属用户 + +说明作者对“线程切换后上下文可能丢失”的问题是有防御意识的。 + +### 15.5 为什么 `runOnAcquire` 里只打日志不抛异常 + +`runOnAcquire(...)` 如果失败只是记录日志: + +- 不向上传播复杂异常 + +原因是: + +- 到这个阶段,限流器已经完成它的职责 +- 业务层异常应由业务层自己的回调和 SSE 关闭逻辑处理 +- 限流器不应该把自己耦合成业务异常中枢 + +### 15.6 为什么通知器只在有 permit 时才遍历 poller + +`PollNotifier.fire()` 里先检查: + +- `permitSupplier.getAsInt() <= 0` + +如果没有可用 permit,就不遍历 poller。 + +这是个小但非常有效的优化: + +- 否则每次通知都会把所有等待请求都白跑一遍 +- 在高频通知场景下会产生很多无意义开销 + +## 16. 这套设计解决了什么问题 + +从问题到方案可以总结为: + +### 问题 1:如何控制全局最大并发 + +方案: + +- `RPermitExpirableSemaphore` + +### 问题 2:并发超限时如何不直接报错 + +方案: + +- `ZSET` 队列排队 + +### 问题 3:多实例如何保持较稳定顺序 + +方案: + +- `RAtomicLong` 生成全局递增序号 + +### 问题 4:如何防止“检查排名 + 出队”竞态 + +方案: + +- Lua 原子 claim + +### 问题 5:如何防止 permit 泄漏 + +方案: + +- emitter 生命周期释放 +- permit 租约自动回收 + +### 问题 6:如何让等待中的请求更快被唤醒 + +方案: + +- Redis Topic + PollNotifier + +### 问题 7:超时拒绝时如何保持良好用户体验 + +方案: + +- 记录拒绝消息到会话 +- 通过 SSE 规范事件返回 + +## 17. 可能的边界场景与行为分析 + +### 场景 1:请求刚入队,客户端立刻断开 + +行为: + +- `releaseOnce` 会触发 +- 请求会从队列移除 +- 如果已经拿到 permit,也会释放 + +结果: + +- 不会留下排队脏数据 + +### 场景 2:请求 claim 成功,但提交到业务线程池失败 + +行为: + +- 释放 permit +- 重新入队 +- 发布通知 + +结果: + +- 请求不会直接丢失 +- 仍有机会再次进入执行流程 + +### 场景 3:节点崩溃,permit 没显式释放 + +行为: + +- Redis 在租约到期后自动回收 permit + +结果: + +- 系统不会永久卡死 + +### 场景 4:通知很多,等待请求也很多 + +行为: + +- `PollNotifier` 会合并通知 +- 不会每次都无限制地并发触发所有 poller + +结果: + +- 一定程度上缓解惊群问题 + +### 场景 5:多个节点同时看到可用 permit + +行为: + +- 它们都可能尝试 claim 和 acquire +- 最终通过 Lua + semaphore 双重控制决定谁成功 + +结果: + +- 不会突破全局并发上限 + +## 18. 设计上的取舍与可能的局限 + +这套实现很强,但也有一些值得注意的取舍。 + +### 18.1 它追求的是“工程上足够公平”,不是数学上的绝对公平 + +因为: + +- claim 成功但 acquire 失败后会重新入队 +- 重新入队时拿的是新序号 + +因此在极端高竞争场景下: + +- 某些请求可能被轻微延后 + +但这换来了: + +- 实现简单 +- 一致性更稳 +- 可维护性更高 + +### 18.2 轮询本身有成本 + +即便有通知机制,轮询仍然存在: + +- 调度线程开销 +- Redis 调用开销 +- Lua 执行开销 + +不过当前有这些缓冲手段: + +- 固定轮询间隔可配置 +- 先检查 permit,再决定是否执行 Lua +- 通知器做合并 + +### 18.3 新会话拒绝时会生成拒绝消息 + +优点是体验统一。 + +但也意味着: + +- 系统繁忙时可能会产生一些“只包含拒绝信息”的会话记录 + +是否接受这种设计,取决于产品要求。 + +当前项目显然更偏向: + +- 保证用户感知完整 +- 让前端交互统一 + +## 19. 调优建议 + +### 19.1 `max-concurrent` + +适合根据以下因素决定: + +- 下游模型吞吐能力 +- 模型供应商限额 +- 本机/集群 CPU 与内存 +- SSE 长连接承载能力 + +经验上: + +- 本地开发:可以很小,例如 1~2 +- 测试环境:根据模型资源适度提高 +- 生产环境:要结合压测结果而不是拍脑袋 + +### 19.2 `max-wait-seconds` + +值太小: + +- 用户容易频繁被拒绝 + +值太大: + +- 用户排队太久体验差 +- 前端等待时间拉长 + +一般应根据平均请求耗时来设定: + +- 如果平均问答耗时 2~4 秒,等待时间可以略高于平均耗时 +- 如果平均耗时更长,则要考虑是否做多级排队提示或页面提示 + +### 19.3 `lease-seconds` + +值太小: + +- 正常长请求还没结束 permit 就被 Redis 回收 +- 可能导致并发数被错误放大 + +值太大: + +- 异常场景下 permit 回收太慢 + +建议: + +- 至少覆盖大多数聊天请求的 P99 时长 +- 再留一定安全余量 + +### 19.4 `poll-interval-ms` + +值太小: + +- Redis 访问压力更高 + +值太大: + +- 请求获得空位后的响应变慢 + +当前 200ms 是一个比较折中的配置。 + +如果已经有 Pub/Sub 通知辅助,一般不需要把轮询间隔调得特别小。 + +## 20. 学习建议:如何读这部分代码 + +建议按如下顺序阅读: + +### 第一遍:先看整体入口 + +- `ChatRateLimitAspect.limitStreamChat` +- `ChatQueueLimiter.enqueue` + +目标: + +- 先知道“请求没有直接执行,而是被交给限流器调度” + +### 第二遍:看执行资格判定 + +- `tryAcquireIfReady` +- `availablePermits` +- `claimIfReady` +- `queue_claim_atomic.lua` +- `tryAcquirePermit` + +目标: + +- 理解队列顺序和 permit 许可是两层不同机制 + +### 第三遍:看等待与唤醒 + +- `scheduleQueuePoll` +- `publishQueueNotify` +- `PollNotifier.fire` + +目标: + +- 理解为什么项目用了“轮询 + 通知”的混合方案 + +### 第四遍:看异常和拒绝路径 + +- `recordRejectedConversation` +- `sendRejectEvents` +- `releasePermit` + +目标: + +- 理解系统在“没拿到名额”或“执行出错”时如何保持一致性和体验 + +## 21. 总结 + +Ragent 的这套限流排队机制并不是一个简单的“接口限流器”,而是一套专门为长耗时、流式输出、跨节点执行的大模型聊天场景设计的资源治理方案。 + +它的核心思路可以概括为: + +1. 用切面把排队治理从业务代码里剥离出来 +2. 用 Redis `ZSET` 维护全局排队顺序 +3. 用 `RPermitExpirableSemaphore` 控制全局最大并发 +4. 用 Lua 脚本保证队头 claim 的原子性 +5. 用 `Pub/Sub + 本地通知器` 提升 permit 释放后的唤醒效率 +6. 用 SSE 生命周期钩子和租约机制保证资源最终释放 +7. 用统一的拒绝消息与会话记录保证用户体验一致 + +从工程价值上看,这部分代码非常值得学习,因为它把以下能力组合到了一起: + +- 分布式并发控制 +- 公平排队 +- 原子状态迁移 +- 流式连接生命周期管理 +- 异步调度 +- 降级与拒绝处理 +- 用户体验一致性 + +如果你掌握了这一套机制,不只是能看懂这个项目的问答入口治理,也能把很多思路迁移到其他“高成本、长耗时、需排队”的服务场景中。 diff --git "a/docs/\351\241\271\347\233\256\346\212\200\346\234\257\346\226\207\346\241\243.md" "b/docs/\351\241\271\347\233\256\346\212\200\346\234\257\346\226\207\346\241\243.md" new file mode 100644 index 000000000..86508e3af --- /dev/null +++ "b/docs/\351\241\271\347\233\256\346\212\200\346\234\257\346\226\207\346\241\243.md" @@ -0,0 +1,603 @@ +# Ragent AI 项目技术文档 + +## 1. 项目简介 + +Ragent AI 是一个面向企业知识问答场景的 Agentic RAG 平台,目标不是做一个只能演示的问答 Demo,而是构建一套覆盖文档处理、知识检索、意图识别、模型生成、工具调用、链路追踪与后台运维的完整工程系统。 + +项目采用前后端分离架构,后端使用 Maven 多模块进行职责拆分,前端使用 React 构建用户问答界面和管理后台。整体设计兼顾以下几个目标: + +- 面向真实企业场景,支持文档入库、问答会话、知识库管理、模型配置、链路追踪等完整闭环。 +- 面向复杂 AI 应用,支持查询重写、多通道检索、会话记忆、MCP 工具调用与流式响应。 +- 面向工程稳定性,支持限流、熔断、故障降级、上下文透传、异步处理和可观测性。 +- 面向后续扩展,通过接口化与注册表机制支持模型、检索通道、入库节点、工具执行器等低成本扩展。 + +从定位上看,这个项目既可以作为 AI 应用的学习样板,也可以作为中等复杂度企业知识问答系统的技术原型。 + +## 2. 项目目标与核心能力 + +### 2.1 业务目标 + +Ragent 主要解决以下几类问题: + +- 企业内部知识分散在 PDF、Markdown、网页、对象存储等多个来源,检索和统一问答困难。 +- 单纯依赖向量检索容易出现召回不稳定、精确匹配不足、意图误判等问题。 +- 单模型供应商存在稳定性和成本风险,需要可路由、可降级的模型层。 +- 会话场景中上下文会不断膨胀,需要在问答质量和 Token 成本之间做平衡。 +- 生产环境需要限流、追踪、监控、后台管理,而不仅仅是“调一下模型接口”。 + +### 2.2 核心能力 + +项目当前覆盖的核心能力包括: + +- 文档处理:支持多来源文档获取、解析、清洗、切块、增强、索引。 +- 知识检索:支持多通道并行检索、去重与重排。 +- 问答编排:支持问题重写、意图识别、Prompt 组装、上下文格式化、流式生成。 +- MCP 调用:支持将非知识类问题路由到工具调用链路。 +- 记忆管理:支持滑动窗口、多轮摘要、标题生成与持久化。 +- 高可用治理:支持并发限流、模型熔断、自动降级、首包探测与连接超时控制。 +- 后台运维:支持知识库、入库流程、意图树、样例问题、系统配置、Trace 追踪等管理页面。 + +## 3. 总体技术架构 + +### 3.1 总体架构分层 + +项目采用“前端 + 后端多模块 + 中间件/模型服务”的架构方式,整体可以抽象为以下几层: + +```text +前端层 +├─ 用户问答界面 +└─ 管理后台 + +应用接入层 +├─ HTTP API +├─ SSE 流式输出 +└─ 登录鉴权 / 参数校验 / 统一返回 + +业务编排层 +├─ 对话与消息管理 +├─ RAG 问答流程编排 +├─ 入库 Pipeline 编排 +├─ 知识库管理 +└─ MCP 调用编排 + +AI 基础设施层 +├─ Chat / Embedding / Rerank 路由 +├─ 模型健康检查与降级 +├─ 流式回调与首包探测 +└─ Token 估算 + +基础设施层 +├─ 数据库访问 +├─ Redis / RocketMQ / 分布式能力 +├─ Trace / 异常 / 幂等 / 分布式 ID +└─ 上下文透传 / SSE 封装 + +存储与外部依赖 +├─ PostgreSQL +├─ Redis +├─ Milvus 或 pgvector +├─ S3 / 对象存储 +├─ RocketMQ +└─ 第三方大模型供应商 / 本地模型服务 +``` + +### 3.2 架构设计思路 + +Ragent 的核心思想不是把所有逻辑堆在一个 Spring Boot 工程里,而是通过模块和接口将不同关注点解耦: + +- `framework` 层解决横切问题,例如统一响应、异常体系、Trace 上下文、SSE 发送器、幂等和分布式 ID。 +- `infra-ai` 层解决模型访问问题,屏蔽不同模型供应商的差异,提供统一的 Chat、Embedding、Rerank 服务。 +- `bootstrap` 层承载具体业务能力,包括知识库、问答、入库、意图树、后台管理等。 +- `mcp-server` 独立作为工具服务入口,便于和主业务服务隔离部署。 + +这种拆分方式的价值在于:模型切换不影响业务编排,基础能力升级不影响业务模块,新增业务能力也不需要修改底层 AI 访问逻辑。 + +## 4. 模块划分与职责说明 + +### 4.1 `framework` 模块 + +这是整个项目的基础设施模块,主要提供通用能力,避免业务模块重复造轮子。 + +主要内容包括: + +- Web 相关:统一响应体、全局异常处理、SSE 发送封装。 +- 上下文能力:用户上下文、应用上下文、Trace 上下文。 +- 数据库支持:MyBatis-Plus 配置、自动填充、通用约定对象。 +- 分布式能力:Snowflake 分布式 ID、幂等注解与 AOP、消息包装。 +- 中间件集成:Redis、RocketMQ 的通用封装。 +- 统一规范:错误码、异常基类、通用消息对象、检索结果对象。 + +这个模块的核心价值是把“所有业务都可能用到的技术能力”抽出来,业务侧只依赖接口和约定。 + +### 4.2 `infra-ai` 模块 + +这是模型基础设施模块,负责封装多模型供应商接入和统一路由。 + +主要内容包括: + +- Chat 能力:统一 `ChatClient` 抽象,封装百炼、Ollama、SiliconFlow 等模型接口。 +- Embedding 能力:统一 `EmbeddingClient` 与 `EmbeddingService`。 +- Rerank 能力:统一 `RerankClient` 与 `RoutingRerankService`。 +- 路由能力:模型候选管理、优先级选择、失败计数、健康状态维护。 +- 流式能力:SSE 解析、流式桥接、回调接口、异步执行器。 +- 通用能力:URL 解析、模型错误分类、Token 粗略估算。 + +这个模块的核心价值是:业务层不需要关心底层到底接的是哪个模型供应商,也不需要为每个供应商写一套单独流程。 + +### 4.3 `bootstrap` 模块 + +这是主业务模块,承载绝大多数业务逻辑。 + +可以再分成几个主要子域: + +- `rag`:问答主链路,包括对话、意图识别、查询重写、检索、Prompt 组装、记忆、MCP 调用、Trace、流式输出。 +- `knowledge`:知识库与文档管理,包括文档上传、切块、索引、定时任务、调度和状态流转。 +- `ingestion`:通用入库 Pipeline,支持从不同来源拉取文档并经过节点编排完成处理。 +- `admin`:后台仪表盘与管理能力。 +- `user`:登录、用户管理、权限相关。 +- `core`:文档解析、切块等相对底层但仍属于业务语义范围的能力。 + +这个模块可以理解为整个系统的“业务心脏”。 + +### 4.4 `mcp-server` 模块 + +该模块是独立的 MCP 服务应用,主要职责是: + +- 作为 MCP 协议服务端提供工具能力。 +- 便于业务主服务与工具服务隔离部署。 +- 让主业务系统通过 MCP 协议访问外部工具,而不是把所有工具逻辑强耦合到主工程中。 + +它当前结构相对轻量,但为后续扩展 Agent 工具生态预留了清晰边界。 + +### 4.5 `frontend` 前端工程 + +前端是独立的 React 应用,分为用户侧问答界面和管理后台两大部分。 + +主要职责包括: + +- 提供聊天界面、流式输出展示、Markdown 渲染、反馈按钮等问答体验。 +- 提供知识库、入库任务、意图树、系统设置、链路追踪、用户管理等后台界面。 +- 通过 `services` 目录统一封装 API 请求,通过 `stores` 管理全局状态。 + +前端技术上不只是“能用”,而是做成了完整的可视化控制台,这一点对项目展示和运维也很重要。 + +## 5. 技术栈与选型路线 + +### 5.1 后端技术栈 + +- 语言与框架:`Java 17`、`Spring Boot 3` +- Web 与接口:`Spring MVC`、`SSE` +- ORM:`MyBatis-Plus` +- 认证鉴权:`Sa-Token` +- 缓存与分布式:`Redis`、`Redisson` +- 消息队列:`RocketMQ` +- 对象存储:`AWS S3 SDK` +- 文档解析:`Apache Tika` +- 向量存储:`Milvus`、`pgvector` +- 工具协议:`MCP SDK` +- 线程上下文透传:`TransmittableThreadLocal` + +### 5.2 前端技术栈 + +- 前端框架:`React 18` +- 开发语言:`TypeScript` +- 构建工具:`Vite` +- 样式体系:`Tailwind CSS` +- 状态管理:`Zustand` +- 路由:`React Router` +- 数据请求:`Axios` +- 表单与校验:`React Hook Form`、`Zod` +- UI 组件:`Radix UI` +- 图表与可视化:`Recharts` +- 内容渲染:`React Markdown` + +### 5.3 技术路线说明 + +项目没有选择强依赖外部 AI 编排框架的路线,而是更偏向“自研业务编排 + 自主抽象 AI 能力”的实现方式。这条路线的特点是: + +- 学习成本更高,但代码可控性更强。 +- 适合深入理解每一个环节的工程细节。 +- 不容易被第三方框架版本升级绑架。 +- 更利于展示设计能力,而不是只展示 API 调用能力。 + +如果从求职项目角度看,这种技术路线的价值很高,因为它能体现出开发者对架构边界、抽象能力和工程化的理解。 + +## 6. 核心业务链路 + +### 6.1 问答主链路 + +RAG 问答是系统最核心的运行流程,可以概括为: + +```text +用户提问 + ↓ +会话校验与限流 + ↓ +问题重写 / 多问题拆分 + ↓ +意图识别与歧义判断 + ↓ +知识检索或 MCP 工具调用 + ↓ +检索结果后处理与上下文格式化 + ↓ +Prompt 组装 + ↓ +模型流式生成 + ↓ +SSE 推送前端 + ↓ +消息持久化 / Trace 记录 / 用户反馈 +``` + +其中涉及的关键能力如下: + +- 查询重写:把口语化、上下文依赖强的问题改写成更适合检索的结构化问题。 +- 意图识别:将问题路由到知识问答、工具调用或歧义引导分支。 +- 多通道检索:通过多个检索策略并行执行,提升召回率和鲁棒性。 +- Prompt 编排:根据场景模板、检索上下文、记忆摘要和用户问题生成最终提示词。 +- 流式输出:答案以 SSE 的方式逐段返回,提升用户体感速度。 +- 持久化与可观测:对消息、摘要、Trace 节点等进行记录,方便后续分析与回放。 + +### 6.2 文档入库链路 + +文档入库不是简单地“上传文件 -> 转 embedding”,而是抽象成了一个可编排的 Pipeline。 + +基本链路如下: + +```text +文档来源 + ↓ +Fetcher 拉取 + ↓ +Parser 解析 + ↓ +Chunker 切块 + ↓ +Enhancer / Enricher 增强 + ↓ +Indexer 建索引 + ↓ +写入知识库与向量存储 +``` + +这一链路的关键设计点: + +- 通过 `IngestionNode` 抽象节点,让每个处理步骤具有统一生命周期。 +- 通过 `PipelineDefinition` 和节点配置支持不同流程编排。 +- 每个节点有独立日志和结果对象,便于定位失败位置。 +- 支持多种来源抓取,例如本地文件、HTTP、S3、飞书等。 +- 支持结构化文档与文本清洗,增强对企业文档场景的适配。 + +### 6.3 MCP 工具调用链路 + +项目并没有把所有问题都当成知识库问答,而是区分“知识型问题”和“工具型问题”。 + +当问题被识别为工具调用场景时,系统大致流程如下: + +```text +用户问题 + ↓ +意图识别 + ↓ +匹配 MCP 工具 + ↓ +参数提取 + ↓ +调用 MCP 服务 + ↓ +将工具返回结果与知识上下文融合 + ↓ +模型组织最终答案 +``` + +这种设计使项目具备从“RAG 系统”向“通用 Agent 系统”升级的能力。 + +## 7. 核心技术设计亮点 + +### 7.1 多通道检索架构 + +传统项目常见的做法是单路向量检索,但 Ragent 采用了多通道检索与后处理流水线的架构。 + +其优势在于: + +- 单路检索失败时,其他通道可以兜底。 +- 不同通道适合不同问题类型,可以兼顾精确匹配和语义召回。 +- 后处理器链可以统一做去重、打分调整和重排。 + +该设计本质上是“并行召回 + 串行精排”的组合路线,兼顾效果和可扩展性。 + +### 7.2 意图树与歧义引导 + +项目中使用树形意图体系而不是简单分类标签,其原因是企业问答的意图通常具有层级结构,例如业务域、子系统、具体动作。 + +这一设计带来的价值: + +- 便于按照层级缩小搜索空间。 +- 便于为不同意图绑定不同知识库、Prompt 或工具。 +- 当识别置信度不足时,可触发引导澄清,而不是直接给出错误回答。 + +这使系统在复杂业务场景下的路由更加稳定。 + +### 7.3 模型路由、熔断与自动降级 + +项目将模型访问抽象为统一服务,并支持: + +- 多候选模型配置。 +- 按优先级选择候选。 +- 失败阈值统计。 +- 熔断打开与冷却等待。 +- 半开探测与恢复。 +- 自动切换下一个候选。 + +这套设计解决了两个核心问题: + +- 某个模型供应商不稳定时,不能把整个业务一起拖垮。 +- 不同模型能力和成本不同,需要有策略地调度。 + +从工程角度看,这部分是把传统微服务治理思路迁移到了模型访问层。 + +### 7.4 首包探测与流式回调桥接 + +流式问答场景中,模型切换是个很麻烦的问题。如果一个模型已经开始输出一半,再切换到另一个模型,用户端就可能收到半截脏数据。 + +项目通过“首包探测 + 缓冲桥接”的方式解决这一问题,核心思想是: + +- 在确定当前候选模型可用之前,不立即把数据直接推给前端。 +- 先缓存流式片段,待确认输出正常后再正式转发。 +- 一旦当前模型不可用,优先切换模型,而不是把错误暴露给终端用户。 + +这是一种很典型的“面向用户体验”的流式容错设计。 + +### 7.5 会话记忆压缩 + +多轮会话最常见的问题是上下文越来越长,最终导致: + +- Token 成本增加。 +- 模型延迟上升。 +- 上下文噪声变多。 +- 历史信息丢失或超窗。 + +项目采用“最近轮次保留 + 历史摘要压缩”的方式处理记忆: + +- 最近若干轮原文保留,保证短期对话连续性。 +- 超过阈值后进行摘要,形成长期记忆。 +- 摘要持久化,避免会话恢复时完全丢失历史。 + +这是一种兼顾成本和效果的常见工程路线。 + +### 7.6 队列式并发限流 + +很多 AI 项目只做简单令牌桶或 QPS 限制,但面对模型调用耗时长、成本高的场景,仅靠简单限流并不够。 + +项目采用基于 Redis 的排队式并发控制方案,其思路是: + +- 请求先进入有序集合排队。 +- 通过 Lua 脚本原子判断是否满足出队条件。 +- 使用许可过期机制避免死锁。 +- 通过 Pub/Sub 通知等待方唤醒。 + +这种做法更适合“单位请求成本高、需要保护下游模型服务”的场景。 + +### 7.7 全链路 Trace 能力 + +项目在 RAG 问答主流程中做了较细粒度的链路追踪,记录节点输入输出、耗时、异常等信息。 + +其价值主要体现在: + +- 问题排查时能快速定位是意图识别、检索、Prompt 还是模型调用出了问题。 +- 便于分析线上效果,例如哪个环节耗时高、哪个节点异常频繁。 +- 可以支撑后台 Trace 页面展示,提升系统可维护性。 + +对于 AI 应用来说,Trace 不是锦上添花,而是生产可用的基础能力之一。 + +## 8. 数据与中间件架构 + +### 8.1 PostgreSQL + +PostgreSQL 在项目中承担核心业务数据存储职责,包括: + +- 会话与消息 +- 知识库与文档 +- 文档切块记录 +- 意图树 +- 反馈数据 +- Trace 运行记录 +- 入库流程与任务 +- 系统配置等 + +项目同时支持 `pgvector`,意味着在较轻量场景下可以使用 PostgreSQL 同时承担结构化存储与向量检索能力。 + +### 8.2 Redis + +Redis 在项目中承担多种角色: + +- 登录态与鉴权相关缓存。 +- 限流与并发控制。 +- 分布式协调能力。 +- 可能的临时状态与上下文辅助。 + +这说明 Redis 在本项目中不仅是缓存,而是承担了“控制面中间件”的角色。 + +### 8.3 Milvus / pgvector + +项目通过配置支持两种向量存储路线: + +- `Milvus`:更适合独立向量检索和较强的向量能力场景。 +- `pgvector`:更适合轻量化部署或统一存储场景。 + +这种设计体现了项目对不同部署复杂度和不同资源条件的兼容。 + +### 8.4 RocketMQ + +RocketMQ 主要用于异步任务与事件驱动场景,例如文档处理相关消息流转、反馈等异步逻辑。 + +引入 MQ 的意义在于: + +- 降低主链路阻塞。 +- 解耦处理时序。 +- 更适合执行耗时型、可重试任务。 + +### 8.5 S3 / 对象存储 + +项目通过 S3 协议访问对象存储,适合存放文档原件或外部文件源,有利于和本地文件系统解耦。 + +## 9. 前端架构说明 + +前端并不是简单的演示页,而是一个具备业务分区的完整 Web 应用。 + +### 9.1 页面结构 + +主要包含两类页面: + +- 用户侧:聊天首页、消息展示、流式回答、示例问题、反馈交互。 +- 管理侧:仪表盘、知识库、文档、切块、入库任务、意图树、样例问题、系统配置、链路追踪、用户管理。 + +### 9.2 前端分层 + +前端目录结构可以概括为: + +- `pages`:页面级路由组件。 +- `components`:通用组件和业务组件。 +- `services`:接口请求层。 +- `stores`:全局状态管理。 +- `hooks`:可复用逻辑,例如聊天状态和流式处理。 +- `types`:类型定义。 + +这种组织方式对 React 项目来说比较清晰,便于后续维护。 + +### 9.3 与后端协作方式 + +前端主要通过以下方式与后端配合: + +- 普通数据接口通过 HTTP 调用。 +- 问答输出通过 SSE 接收流式消息。 +- 管理后台使用 REST 风格接口进行增删改查与配置管理。 + +## 10. 配置体系与运行依赖 + +从主配置可以看出,项目运行依赖以下外部组件: + +- PostgreSQL +- Redis +- RocketMQ +- Milvus 或 PostgreSQL + pgvector +- 对象存储服务 +- 一个或多个大模型供应商接口 +- MCP 服务端 + +### 10.1 核心配置项 + +比较关键的配置分组包括: + +- `rag.vector`:选择向量存储类型。 +- `rag.memory`:控制会话记忆保留轮数与摘要策略。 +- `rag.rate-limit`:控制全局并发与等待时间。 +- `rag.search.channels`:控制多通道检索阈值与召回倍率。 +- `rag.trace`:控制链路追踪。 +- `ai.chat / embedding / rerank`:配置模型候选与默认模型。 +- `sa-token`:登录与会话管理。 + +这种配置设计说明项目很多关键能力都是可配置的,而不是写死在代码里。 + +## 11. 可扩展性设计 + +项目在很多地方都预留了扩展点,这是它区别于普通 Demo 的重要特征。 + +### 11.1 可扩展点一览 + +- 新增检索通道:实现 `SearchChannel`。 +- 新增后处理器:实现 `SearchResultPostProcessor`。 +- 新增模型供应商:实现对应 `ChatClient`、`EmbeddingClient` 或 `RerankClient`。 +- 新增入库节点:实现 `IngestionNode`。 +- 新增文档来源抓取器:实现 `DocumentFetcher`。 +- 新增 MCP 工具执行器:实现 `McpToolExecutor`。 +- 新增 Prompt 场景模板:补充模板文件和加载逻辑。 + +### 11.2 扩展方式的设计特点 + +项目扩展点普遍采用以下方法: + +- 面向接口编程,而不是在主流程里写大量 `if-else`。 +- 通过 Spring Bean 自动发现,降低手工注册成本。 +- 通过注册表、工厂、责任链等模式管理组件集合。 +- 通过配置与上下文判断是否启用特定能力。 + +这使新增功能时通常只需要“新增实现类 + 注册为 Bean + 补配置”,而不是修改大量核心代码。 + +## 12. 项目的工程价值 + +从工程实践角度看,Ragent 的价值主要体现在以下几个方面: + +- 不只是接模型 API,而是覆盖了 AI 应用落地的完整链路。 +- 不只是单体业务代码,而是有明确的模块边界和基础设施抽象。 +- 不只是功能可用,而是考虑了限流、降级、追踪、异步和用户体验。 +- 不只是 RAG,而是已经具备向 Agent 与工具编排继续演进的结构基础。 +- 不只是后端工程,也配套了完整前端控制台和后台管理界面。 + +如果作为学习项目,它适合用来理解“企业级 AI 应用应该怎么拆、怎么跑、怎么治理”。 + +如果作为求职项目,它适合突出以下能力: + +- Java 后端分层设计能力 +- AI 应用工程化能力 +- 中间件与并发治理能力 +- 向量检索与知识问答系统设计能力 +- 全栈交付与系统化思维 + +## 13. 推荐学习路线 + +如果希望系统掌握这个项目,建议按下面的路线阅读和实践。 + +### 第一阶段:建立全局认知 + +- 阅读 `README.md`,先理解项目定位、核心能力和模块划分。 +- 阅读 `pom.xml` 与各模块 `pom.xml`,理解模块依赖关系。 +- 阅读 `bootstrap/src/main/resources/application.yaml`,了解中间件和模型配置项。 +- 打开前端目录,了解用户侧与管理后台的页面结构。 + +目标是先知道“系统有哪些部分,各自负责什么”,不要一开始就钻进具体实现。 + +### 第二阶段:跟一条问答链路 + +- 从问答控制器入口出发,跟踪一次聊天请求。 +- 看问题重写、意图识别、检索、Prompt、模型流式输出和消息持久化。 +- 同时观察 Trace、限流和 SSE 回调在链路中的作用。 + +目标是理解系统最核心的一条业务主线。 + +### 第三阶段:跟一条入库链路 + +- 从知识文档上传或入库任务入口出发。 +- 跟踪文档抓取、解析、切块、增强、向量写入和状态变更。 +- 看异步消费、定时调度和任务日志记录。 + +目标是理解“知识是怎么进入系统并最终可检索”的。 + +### 第四阶段:研究稳定性设计 + +- 重点看模型路由、失败计数、熔断与降级逻辑。 +- 重点看分布式限流、上下文透传和 SSE 封装。 +- 重点看 Trace 节点记录和异常处理体系。 + +目标是理解项目为什么不仅能跑,而且尽量跑得稳。 + +### 第五阶段:做一项小扩展 + +建议任选一个方向进行练手: + +- 新增一种检索通道。 +- 新增一种入库节点。 +- 新增一个 MCP 工具。 +- 优化一个 Prompt 模板。 +- 给后台补一个统计页面。 + +真正做一次扩展,才算从“看懂项目”进入“能够掌控项目”。 + +## 14. 总结 + +Ragent AI 的技术路线可以概括为一句话: + +“用传统 Java 工程化方法,构建一个具备 RAG、Agent、MCP、模型治理和后台运维能力的企业级 AI 应用平台。” + +它的核心价值不在于使用了多少热门名词,而在于把这些能力拆解成了清晰的模块、稳定的链路和可扩展的工程结构。对于学习者来说,这个项目适合用来建立完整的 AI 应用工程视角;对于求职者来说,这个项目适合用来证明自己不仅懂 Java 后端,也理解 AI 应用的落地方式。 diff --git a/framework/src/main/java/com/nageoffer/ai/ragent/framework/convention/Result.java b/framework/src/main/java/com/nageoffer/ai/ragent/framework/convention/Result.java index a40d9b569..e0c3551f9 100644 --- a/framework/src/main/java/com/nageoffer/ai/ragent/framework/convention/Result.java +++ b/framework/src/main/java/com/nageoffer/ai/ragent/framework/convention/Result.java @@ -34,7 +34,7 @@ * @param 响应数据的类型 */ @Data -@Accessors(chain = true) +@Accessors(chain = true) // 开启链式调用 public class Result implements Serializable { @Serial