十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java 21 + Spring Boot 3 构建企业级 RAG 与智能体实战

Java 21 + Spring Boot 3 构建企业级 RAG 与智能体实战 1. 为什么我放弃了 Python 全家桶转向 Java 21 Spring Boot 31.1 一个真实的技术选型场景去年底我接手了一个企业知识库项目需求很明确把公司内部散落在 Confluence、飞书文档、PDF 手册、数据库表结构说明里的知识统一管起来让业务同事能用自然语言提问系统给出带出处的答案并且能自动执行一些简单任务比如“帮我查一下上季度华东区的退货率然后生成一份周报草稿”。第一反应当然是 Python 那套FastAPI LangChain 向量库 某个智能体框架。原型两天就跑通了demo 演示效果也不错。但真正往生产环境推的时候问题一个接一个冒出来。团队里八个后端全是 Java 技术栈日常维护的是 Spring Boot 微服务集群CI/CD、监控告警、日志采集、权限体系全是围绕 Java 生态建的。我要是扔一个 Python 服务进去等于凭空多出一套运行时、一套依赖管理、一套部署流程、一套排查工具链。运维同事看我的眼神都不对了。更现实的问题是这个 RAG 系统不是孤立的。它要调用现有的用户中心做鉴权要读订单库做数据查询要对接内部工单系统触发流程要复用已有的消息队列做异步任务。这些全是 Java 写的。Python 服务要接进来要么走 HTTP 绕一圈要么维护两套 SDK怎么都别扭。所以我做了一个决定用 Java 21 Spring Boot 3 重写整个 RAG 和智能体工作流引擎。这不是为了“卷”恰恰是为了不卷——让 AI 能力长在团队已有的技术土壤里而不是另起炉灶。1.2 Java 21 Spring Boot 3 到底带来了什么很多人对 Java 做 AI 应用的印象还停留在“笨重、慢、生态差”。这个印象放在五年前可能成立但 Java 21 是个分水岭。虚拟线程是最大的变量。RAG 系统里大量操作是 IO 密集型的调嵌入模型 API、查向量库、调大模型生成、读写文档。传统 Java 线程池在这种场景下要么开太大导致上下文切换开销爆炸要么开太小导致吞吐上不去。虚拟线程让每个请求可以独占一个线程来写同步代码底层自动做调度代码简单了吞吐反而上去了。我实测下来同样的硬件虚拟线程模式下 RAG 问答的并发处理能力比传统线程池高了将近三倍。Spring Boot 3这边对 GraalVM 原生镜像的支持成熟了很多启动速度从秒级降到毫秒级内存占用也大幅下降。对于需要弹性伸缩的 AI 服务来说这意味着扩容时不用等半分钟才能接流量。另外 Spring Boot 3 全面迁移到 Jakarta EE 命名空间虽然迁移时改包名有点烦但长期看生态统一了少了很多历史包袱。Spring AI这个项目值得单独提一句。它把大模型调用、嵌入、向量库操作这些能力抽象成了 Spring 风格的 API用起来跟 JdbcTemplate 一样自然。虽然成熟度还不如 LangChain但核心的 ChatClient、EmbeddingClient、VectorStore 这些抽象已经够用了而且跟 Spring Boot 的自动配置结合得很好少写很多胶水代码。1.3 这套方案适合谁如果你所在的团队是 Java 为主的技术栈正在考虑把 AI 能力集成到现有系统里又不想引入 Python 运行时带来的运维复杂度那这套方案就是为你准备的。它不适合快速做原型验证的场景——那种情况 Python 确实更快。但如果你要的是一个能长期维护、能跟现有系统深度集成、能扛住生产流量的企业级方案Java 这条路是走得通的。下面我会从架构设计、核心模块实现、实操踩坑几个维度把整个系统的搭建过程拆开来讲。代码量不小但我会把关键逻辑和设计决策说清楚你照着搭一遍应该能跑起来。2. 整体架构设计与模块拆解2.1 分层架构从接入到生成的完整链路整个系统我分成了五层每层职责清晰层与层之间通过接口交互方便替换实现。接入层负责处理 HTTP 请求、SSE 流式推送、鉴权和限流。这一层用 Spring Boot 的 WebFlux 做响应式处理因为 RAG 问答天然适合流式返回——大模型生成是逐 token 出来的用 SSE 推给前端体验最好。虚拟线程在这里跟 WebFlux 配合得很好阻塞式的向量库查询和模型调用可以放在虚拟线程里跑不阻塞事件循环。编排层是智能体工作流引擎的核心。它负责解析用户意图、决定调用哪些工具、按什么顺序调用、如何处理中间结果。我设计了一个基于 DAG 的工作流模型每个节点是一个可执行单元节点之间通过有向边定义依赖关系。编排层根据当前上下文动态决定执行路径支持条件分支、循环、并行执行。能力层封装了所有原子能力向量检索、关键词检索、大模型调用、工具调用、记忆读写。每个能力都是一个 Spring Bean通过统一的接口暴露给编排层。这样做的好处是新增一个工具只需要实现接口并注册不用改编排逻辑。存储层包括向量库、关系库、缓存、对象存储。向量库我选的是 Milvus因为它对 Java 客户端支持好性能也够。关系库存对话历史、工作流定义、用户配置这些结构化数据。缓存用 Redis 存会话上下文和热点数据。对象存储放原始文档和生成的报告。基础设施层是日志、监控、配置、服务发现这些。用 Micrometer 做指标采集Prometheus Grafana 做监控面板ELK 做日志聚合。这些跟普通 Spring Boot 应用没区别直接复用现有体系。2.2 为什么选 DAG 而不是链式编排市面上很多智能体框架用链式编排就是一条线走到底先检索再重排再生成。简单场景够用但企业场景往往需要更灵活的控制。举个例子用户问“对比一下我们和竞品在定价策略上的差异”。这个问题需要先检索内部定价文档再检索竞品公开信息然后分别总结最后做对比分析。链式编排很难表达这种“分叉再合并”的逻辑而 DAG 天然支持。我的 DAG 引擎支持几种节点类型检索节点从向量库或关键词索引拉取相关内容LLM 节点调用大模型做生成或推理工具节点执行外部 API 调用或数据库查询条件节点根据上游结果决定走哪个分支聚合节点把多个上游结果合并。节点之间通过上下文对象传递数据每个节点可以读取和写入上下文。工作流定义用 JSON 描述存在关系库里支持热更新。这样业务人员改流程不用重新部署服务改完 JSON 刷新一下就行。2.3 向量检索与关键词检索的混合策略纯向量检索有个问题对精确匹配不敏感。比如用户问“工单编号 TK-2024-001 的处理进度”向量检索可能返回一堆语义相似但编号不对的文档。纯关键词检索又缺乏语义理解能力用户换个说法就搜不到了。我的做法是混合检索先用向量检索召回 Top 50再用 BM25 做关键词检索召回 Top 50然后合并去重用 RRF 算法做融合排序最后取 Top 10 送给大模型。RRF 的好处是不需要调权重对两路检索的分数分布不敏感工程上很稳。向量化用的是国产嵌入模型通过 Spring AI 的 EmbeddingClient 调用。文档切分策略上我没有用固定长度切分而是按语义段落切每个 chunk 控制在 300 到 500 字之间重叠 50 字。这样既保证语义完整又不会让 chunk 太大影响检索精度。2.4 智能体工作流的触发与调度智能体不是每次都要走完整工作流。我的设计是两级触发第一级用轻量级意图分类模型判断用户问题属于哪类任务第二级根据任务类型选择对应的工作流模板。意图分类我用了一个小模型做 few-shot 分类准确率能到 90% 以上推理延迟在 50ms 以内。分类结果映射到工作流 ID编排层加载对应 DAG 开始执行。如果分类置信度低就走兜底的通用问答工作流。工作流执行过程中每个节点的执行结果都会写入上下文同时记录执行日志。如果某个节点失败支持重试和降级。比如向量库查询超时可以降级到只走关键词检索大模型调用失败可以切换到备用模型。3. 核心模块的代码实现与关键细节3.1 虚拟线程配置与 IO 密集型任务优化Java 21 的虚拟线程用起来很简单但有几个坑要注意。首先不要用Executors.newVirtualThreadPerTaskExecutor()直接替换所有线程池。虚拟线程适合 IO 密集型任务CPU 密集型任务用平台线程更合适。我的做法是给 RAG 相关的 IO 操作单独配一个虚拟线程执行器其他计算任务还是用固定大小的平台线程池。Configuration public class ThreadConfig { Bean(ragIoExecutor) public ExecutorService ragIoExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } Bean(cpuExecutor) public ExecutorService cpuExecutor() { int cores Runtime.getRuntime().availableProcessors(); return new ThreadPoolExecutor( cores, cores * 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() ); } }其次虚拟线程里不要用synchronized做长时间阻塞。虚拟线程遇到synchronized会 pin 住载体线程失去虚拟线程的优势。要用ReentrantLock替代。我在向量库客户端和 HTTP 客户端里都检查过确保没有长时间持有synchronized的代码。还有一个细节数据库连接池要调大。虚拟线程可以开很多但底层数据库连接是有限的。我用 HikariCP最大连接数设成 50配合虚拟线程的调度实际并发处理能力比之前用 200 个平台线程还高。3.2 RAG 检索链的完整实现检索链是整个系统的核心我把它拆成了四个步骤查询改写、混合检索、重排、上下文组装。查询改写这一步很多人会忽略但对检索质量影响很大。用户的问题往往口语化、有指代、有省略。比如“它上个月的销量怎么样”直接拿去检索肯定不行。我用一个大模型做查询改写把口语化问题转成适合检索的查询语句同时做指代消解和关键词提取。public String rewriteQuery(String originalQuery, ListMessage history) { String prompt 你是一个查询改写助手。根据对话历史把用户的最新问题改写成适合检索的独立查询语句。 要求 1. 消解代词指代补全省略信息 2. 提取核心关键词 3. 保持原意不变 4. 只输出改写后的查询不要解释 对话历史 %s 用户问题%s .formatted(formatHistory(history), originalQuery); return chatClient.prompt(prompt).call().content(); }混合检索部分向量检索用 Milvus 的 Java SDK关键词检索用 Elasticsearch 的 BM25。两路并行执行用 CompletableFuture 配合虚拟线程总延迟取决于较慢的那一路。public ListDocument hybridRetrieve(String query, int topK) { CompletableFutureListDocument vectorFuture CompletableFuture.supplyAsync( () - vectorStore.similaritySearch(query, topK), ragIoExecutor); CompletableFutureListDocument keywordFuture CompletableFuture.supplyAsync( () - keywordSearch(query, topK), ragIoExecutor); return vectorFuture.thenCombine(keywordFuture, this::rrfFusion) .join(); }RRF 融合的公式很简单每个文档的得分是1 / (k rank)k 取 60。两路检索的得分相加按总分排序。这个算法不需要归一化对分数尺度不敏感实测效果比加权求和稳定。重排用了一个交叉编码器模型对 Top 20 做精排。交叉编码器比向量相似度准但计算量大所以只对少量候选做。重排后取 Top 5 送给大模型。上下文组装要注意 token 预算。大模型的上下文窗口有限要把最相关的文档放前面同时控制总长度。我设了一个 token 计数器超过预算就截断或丢弃低分文档。3.3 智能体工作流引擎的 DAG 实现工作流引擎的核心是 DAG 的拓扑排序和执行调度。我用了一个简单的实现每个节点记录入度和出度从入度为 0 的节点开始执行执行完一个节点就减少下游节点的入度入度变 0 就加入执行队列。public class WorkflowEngine { public WorkflowContext execute(WorkflowDefinition definition, WorkflowContext context) { MapString, Node nodes definition.getNodes(); MapString, Integer inDegree new HashMap(); MapString, ListString downstream new HashMap(); // 构建入度和下游映射 for (Node node : nodes.values()) { inDegree.putIfAbsent(node.getId(), 0); for (String next : node.getNextNodes()) { inDegree.merge(next, 1, Integer::sum); downstream.computeIfAbsent(node.getId(), k - new ArrayList()).add(next); } } // 从入度为 0 的节点开始 QueueString queue new LinkedList(); for (Map.EntryString, Integer entry : inDegree.entrySet()) { if (entry.getValue() 0) { queue.offer(entry.getKey()); } } while (!queue.isEmpty()) { String nodeId queue.poll(); Node node nodes.get(nodeId); NodeResult result node.execute(context); context.putNodeResult(nodeId, result); for (String next : downstream.getOrDefault(nodeId, List.of())) { int newDegree inDegree.merge(next, -1, Integer::sum); if (newDegree 0) { queue.offer(next); } } } return context; } }每个节点实现Node接口execute方法接收上下文返回结果。上下文是一个线程安全的 Map节点可以读取上游结果也可以写入自己的结果。条件节点比较特殊它根据上下文中的某个值决定激活哪条分支。实现上条件节点执行后只把选中的分支加入下游其他分支的入度不减自然就不会被执行。并行执行方面同一批入度为 0 的节点可以并行跑。我用虚拟线程提交任务用 CountDownLatch 等待全部完成后再继续。这样检索节点可以并行调多个数据源LLM 节点可以并行调多个模型做投票。3.4 工具调用的注册与发现机制智能体要能调用外部工具比如查数据库、调 API、发邮件。我设计了一个工具注册中心工具实现统一的Tool接口通过 Spring 的Component自动注册。public interface Tool { String getName(); String getDescription(); ToolResult execute(MapString, Object params); } Component public class OrderQueryTool implements Tool { Override public String getName() { return order_query; } Override public String getDescription() { return 查询订单信息。参数orderId订单编号返回订单详情; } Override public ToolResult execute(MapString, Object params) { String orderId (String) params.get(orderId); // 查询逻辑 return ToolResult.success(orderData); } }工具注册中心在启动时扫描所有Tool实现把名称和描述注册到内存。编排层需要调用工具时把工具列表和描述传给大模型让大模型决定调哪个工具、传什么参数。大模型返回工具调用请求后注册中心根据名称找到对应工具执行结果再回传给大模型做下一步推理。这里有个细节工具描述要写清楚参数格式和返回值含义大模型才能正确调用。我踩过的坑是描述太模糊大模型传的参数格式不对导致工具执行失败。后来我把每个工具的参数 schema 用 JSON Schema 描述清楚准确率提升了很多。4. 实操过程中的踩坑记录与排查技巧4.1 虚拟线程导致的数据库连接耗尽上线压测时遇到一个诡异问题并发量一上来服务就卡死日志里全是获取数据库连接超时。查了半天发现是虚拟线程的锅。虚拟线程可以开几十万个但数据库连接池只有 50 个。大量虚拟线程同时请求数据库连接把连接池占满后面的请求全在排队。更麻烦的是虚拟线程在等待连接时不会释放载体线程导致平台线程也被占住整个服务失去响应。解决办法有两个一是给数据库操作加信号量限流控制同时访问数据库的虚拟线程数量二是用Semaphore做背压超过阈值直接拒绝或排队。private final Semaphore dbSemaphore new Semaphore(40); public Result queryWithLimit(String sql) { if (!dbSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { throw new ServiceBusyException(数据库繁忙请稍后重试); } try { return jdbcTemplate.queryForObject(sql, Result.class); } finally { dbSemaphore.release(); } }信号量设成 40比连接池的 50 略小留一点余量给其他操作。这样既保护了数据库又不会让请求无限堆积。4.2 向量检索的精度调优Milvus 的默认索引参数对中文场景不一定最优。我一开始用 IVF_FLATnlist 设的 1024检索精度只有 70% 左右。后来换成 HNSWM 参数设 16efConstruction 设 200检索时 ef 设 64精度提升到 92%延迟只增加了 3ms。还有一个坑是向量归一化。嵌入模型输出的向量如果不做归一化内积和余弦相似度的结果会不一致。我在写入和查询时都做了 L2 归一化确保距离度量一致。文档切分也调了好几版。最初按固定 500 字切结果把表格和代码块切断了检索出来的内容不完整。后来改成按语义段落切遇到表格和代码块保持完整chunk 大小动态调整。这个改动让答案的完整度提升明显。4.3 大模型调用的超时与重试策略大模型 API 偶尔会超时或返回错误必须做重试。但重试不能无脑重试要区分错误类型。超时和 5xx 错误可以重试4xx 错误重试没意义。重试要加退避第一次等 1 秒第二次等 2 秒第三次等 4 秒。重试次数不要超过 3 次否则用户等太久。我用 Resilience4j 做重试和熔断。配置如下resilience4j: retry: instances: llmCall: maxAttempts: 3 waitDuration: 1s enableExponentialBackoff: true exponentialBackoffMultiplier: 2 retryExceptions: - java.util.concurrent.TimeoutException - org.springframework.web.client.HttpServerErrorException circuitbreaker: instances: llmCall: slidingWindowSize: 20 failureRateThreshold: 50 waitDurationInOpenState: 30s熔断器在失败率超过 50% 时打开30 秒内直接拒绝请求给大模型服务恢复的时间。30 秒后进入半开状态放几个请求试探成功就关闭熔断器。4.4 常见问题速查表问题现象可能原因排查方法解决方案服务启动慢GraalVM 原生镜像未启用检查启动日志耗时启用 AOT 编译和原生镜像检索结果不相关查询改写未生效打印改写前后查询调整改写 prompt增加 few-shot 示例大模型返回乱码编码不一致检查请求和响应编码统一用 UTF-8设置 Content-Type工作流卡死DAG 存在环检查节点依赖关系启动时做拓扑排序校验有环直接报错内存溢出上下文对象太大用 MAT 分析堆转储限制上下文大小及时清理中间结果SSE 连接断开代理超时检查网关超时配置设置心跳保活调整超时时间4.5 几个让我印象深刻的坑坑一Spring AI 的版本兼容性。Spring AI 还在快速迭代不同版本 API 变化很大。我一开始用 0.8.0后来升到 1.0.0-M1发现 ChatClient 的 API 全变了改了半天。建议锁定版本不要盲目升级。坑二Milvus 的集合加载。Milvus 的集合查询前需要 load如果没 load 就查会报错。而且 load 是异步的要等加载完成才能查。我在启动时加了一个健康检查确保集合加载完成后再接流量。坑三虚拟线程与 ThreadLocal。虚拟线程支持 ThreadLocal但每个虚拟线程都有自己的副本大量虚拟线程会导致内存暴涨。我把 ThreadLocal 换成了 ScopedValueJava 21 预览特性作用域结束自动清理内存问题解决了。坑四大模型输出的 JSON 解析。让大模型返回 JSON 格式时它经常在 JSON 外面包一层 markdown 代码块标记。我写了一个清洗方法先去掉json 和再解析。另外要设置responseFormat为 JSON能提高格式正确率。5. 性能优化与生产环境适配5.1 缓存策略哪些该缓存哪些不该RAG 系统里缓存能省很多钱和时间。但不是所有东西都适合缓存。嵌入向量可以缓存。同样的文本嵌入结果是一样的。我用 Redis 做嵌入缓存key 是文本的 SHA-256value 是向量。命中率很高因为很多文档会被反复检索。检索结果可以缓存。同样的查询短时间内检索结果不会变。我设了 5 分钟过期热点查询能省掉向量库查询的开销。大模型生成结果要谨慎缓存。生成结果跟上下文强相关同样的查询在不同对话历史下答案可能不同。我只对完全独立的通用问答做缓存带上下文的问答不缓存。工作流执行结果可以缓存。如果工作流定义和输入参数都没变执行结果应该是一样的。我用 Caffeine 做本地缓存减少重复计算。5.2 监控指标需要关注哪些数据生产环境跑起来后我重点监控几个指标检索延迟 P99。超过 500ms 就要排查可能是向量库负载高或查询太复杂。大模型调用成功率。低于 95% 要告警可能是 API 限流或网络问题。工作流执行时长。按工作流类型分别统计突然变长可能是某个节点出了问题。Token 消耗量。按天统计突然暴涨可能是被恶意刷了或者有死循环。缓存命中率。低于 60% 要检查缓存策略是否合理。这些指标用 Micrometer 采集推给 PrometheusGrafana 做面板。告警规则用 Alertmanager 配关键指标异常时发通知。5.3 弹性伸缩与资源规划AI 服务的资源消耗跟普通 Web 服务不一样。CPU 和内存的配比要重新考虑。向量检索是内存密集型Milvus 需要大内存。大模型调用是网络密集型CPU 要求不高。工作流编排是 CPU 密集型需要多核。我的部署方案是Milvus 单独部署配 32GB 内存应用服务配 8 核 16GB跑在 Kubernetes 里根据 CPU 和自定义指标做 HPA。自定义指标用工作流队列长度队列长了就扩容短了就缩容。虚拟线程让单 Pod 的并发能力提升很多原来需要 10 个 Pod 扛的流量现在 3 个就够了。但要注意虚拟线程对 CPU 的利用更充分CPU 使用率会比以前高监控要相应调整阈值。5.4 安全与权限企业场景不能忽视企业知识库涉及敏感信息权限控制必须做好。我在检索层加了权限过滤。每个文档块在入库时打上权限标签检索时根据用户角色过滤。Milvus 支持标量字段过滤可以在查询时加filter条件只返回用户有权限看的文档。工具调用也要鉴权。不是所有用户都能调所有工具。我在工具注册中心加了权限校验执行前检查用户是否有该工具的调用权限。审计日志不能少。谁在什么时候问了什么问题、系统检索了哪些文档、调用了哪些工具、生成了什么答案全部记录。出了问题能追溯。6. 这套方案后续还能怎么扩展6.1 多模态能力的接入现在系统只处理文本但企业知识库里有很多图片、表格、PPT。后续可以接入多模态嵌入模型把图片和表格也向量化检索时支持跨模态查询。大模型也可以用多模态版本直接理解图片内容。6.2 工作流的可视化编排现在工作流定义是手写 JSON对业务人员不友好。可以做一个可视化编排界面拖拽节点、连线、配置参数生成 JSON 存库。这样业务人员能自己调整流程不用找开发。6.3 智能体的自我进化现在工作流是静态定义的智能体不会自己优化。后续可以加入反馈机制用户对答案的评分、点击行为、追问情况作为信号来调整工作流参数。比如某个检索节点经常召回不相关文档就自动降低它的权重。6.4 与现有系统的深度集成这套系统目前是独立部署的后续可以把 RAG 能力做成 Spring Boot Starter其他 Java 服务引入依赖就能用。这样订单系统、工单系统、CRM 都能快速接入 AI 能力不用每个系统都搭一套。我在实际落地这套方案的过程中最大的体会是技术选型没有绝对的对错只有适不适合。Python 在 AI 领域生态确实更丰富但 Java 在企业级集成、运维体系、团队技能匹配上有不可替代的优势。用 Java 21 Spring Boot 3 做 RAG 和智能体不是要跟 Python 比谁更 AI而是让 AI 能力真正长在企业的技术主干上能维护、能扩展、能扛住生产流量。如果你也在面临类似的选择希望这篇分享能给你一些参考。
返回列表