
看到“别卷 Python 了”这几个字Java 党应该都会心一笑。确实过去聊 RAG、聊智能体社区里的 Demo 几乎全是 Python 生态但真到了企业级落地阶段Java 21 Spring Boot 3 这套组合反而更有底气——类型安全、虚拟线程、成熟的可观测体系以及现成的企业运维底座都是生产环境绕不开的硬指标。这个项目就是我做的一个企业级 RAG 智能体工作流引擎核心解决三件事私有知识库问答、多智能体按流程协作、以及把这一切平滑嵌入到 Spring Boot 3 的服务体系里。如果你后端主栈是 Java又想在 AI 应用方向做出能扛得住并发、能对接权限审计、能让人放心上线的工程这篇文章应该正好戳中你的需求。1. 项目背景与整体架构设计1.1 为什么是 Java 21 Spring Boot 3而不是 Python 全家桶先说结论Python 生态在模型验证阶段确实无出其右LangChain、LlamaIndex 这类框架半小时就能跑通一个 Demo大部分人做 RAG 的第一反应都会去 Python 那边找轮子。但这套方案的短板通常不在“跑通”而在“落地”。等到要对接公司统一的权限体系、审计日志、监控告警和灰度发布Python 方案往往要额外做很多工程治理而 Java 恰好把这些东西沉淀了很多年。我早期用 LangChain 搭过一个私有知识库问答原型当时觉得挺顺利直到接入真实业务才发现几个很现实的问题一是团队成员对 Python 的依赖管理和部署链路不熟悉二是在高并发下 Python 侧要用大量异步或者多进程方案去扛可观测性和排查工具链也相对分散。于是我开始认真思考用 Java 重写正好赶上 Java 21 发布虚拟线程Virtual Threads解决了我最大的顾虑——智能体工作流里大量的模型调用、工具调用都是 IO 密集型操作用传统线程池要么浪费资源要么需要极其精细的线程模型设计。虚拟线程让“一个任务一个线程”变成很自然的事阻塞成本几乎可以忽略。Spring Boot 3 这边Spring AI 也在快速补齐 RAG 和智能体的抽象哪怕不用 Spring AI单单靠 Spring Boot 3 的自动配置、Actuator 指标、Micrometer Tracing 以及 GraalVM 原生镜像支持就能把整个应用的可运维性提升一个大台阶。这里我不是劝所有人抛弃 Python恰恰相反模型实验、prompt 调试、数据分析这类场景 Python 依然是利器。但如果你要的是一个企业级、多人协作、长期迭代的 AI 服务Java 21 Spring Boot 3 的工程优势会随着项目规模扩大越来越明显。1.2 引擎整体模块划分与关键设计目标整个引擎我拆成了五个核心模块加一个横向的可观测层每个模块只负责一件事模块之间通过接口通信避免后续演变成大泥球。接入层提供 REST API 和消息队列消费者对外暴露三类能力创建工作流实例、查询实例状态、手动重试某个节点。这里没有做成同步全链路返回因为智能体工作流的耗时通常是秒级甚至分钟级同步等待对调用方不友好所以我用异步任务加结果回调的方式请求进来后立刻返回一个 workflowId后续调用方可以通过这个 ID 拉取状态或订阅完成事件。工作流引擎层是整个系统的中枢负责解析 DAG 定义、维护节点状态机、调度执行、处理条件分支和并行分支。它不关心某个节点具体执行什么只关心“谁先谁后、失败怎么办、超时怎么办”这样设计和后续扩展新节点类型解耦。Agent 运行时层往上是智能体抽象把规划、工具调用、结果观察这几步封装成可复用的执行循环往下接的是工具注册中心和模型适配层工具中心负责管理外部系统的调用权限和参数校验模型适配层屏蔽了不同大模型 API 的差异。RAG 组件层是相对独立的一块包括了文档解析、文本切分、向量化、双路召回和重排。存储层选择了 PostgreSQL 加 pgvector 作为向量数据库另外用 Redis 做分布式锁和状态缓存。可观测层把所有关键路径上的耗时指标、调用链信息都收集起来这个后面会详细讲。设计目标上我把“可重试”放在第一位。智能体工作流里任何一个环节都可能失败比如大模型 API 超时、第三方工具报错、向量库连接抖动如果不好好设计失败恢复一个看起来简单的流程在真实环境里会频繁卡死。第二是可观测没有 traceId 贯穿的工作流引擎出了问题基本只能靠日志去猜。第三是可降级当大模型服务不可用时引擎至少要保证已经完成的部分不丢能排队等待恢复。2. RAG 链路从文档解析到检索增强的工程化改造2.1 文档接入、切分与元数据设计RAG 链路的第一道坎不是模型而是文档解析。企业知识库里的文档往往五花八门PDF 导出版、Word 排版版、Markdown、HTML、甚至扫描件每个格式都有自己的坑。PDF 里我遇到过文字被拆成碎片、表格数据错位、页眉页脚混入正文Word 里最常见的是分节符导致章节识别失败。最终方案是接入层统一走 Apache Tika 做格式识别和文本抽取再针对不同格式做定制后处理比如对 PDF 先尝试读取内嵌文本层实在拿不到再走 OCR避免扫描件完全不可用。切分策略直接影响检索质量。最初我按固定字符数 500 切一段结果非常糟糕因为切分点完全无视文档语义结构经常把一张表格拆成两半检索倒是能召回但答案上下文总是缺胳膊少腿。后来改成“结构化切分”先按 Markdown 或 PDF 的章节标题层级做粗切分把文档拆成章、节、小节然后再对每个小节内部做递归字符切分按 token 数而不是字符数来控制长度。实际操作中每个块我控制在 500 到 800 个 token重叠率 10% 到 15%这个参数对于企业制度类文档效果比较稳定。切分之外元数据设计同样重要。每个块都会带着文档 ID、章节标题、页码、块序号、更新时间和权限标识这既是为了检索时做前置过滤也是为了让回答能够引用来源。权限字段尤其关键知识库检索必须遵守最小权限原则否则一个低权限用户可能通过巧妙构造问题把高权限文档内容拼出来。2.2 向量库选型与“双路召回 RRF 融合”检索方案向量库的选择我花了很长时间做对比主要候选是 pgvector、Milvus 和 Elasticsearch 8。三者在定位上有明显差异我整理过一张对比表维度pgvectorMilvusElasticsearch 8运维复杂度低复用 PostgreSQL高依赖 etcd、MinIO 等组件中需要管理 ES 集群数据一致性与业务数据同库天然一致需要额外同步逻辑需要额外同步或 CDC混合检索能力SQL 内自定义灵活支持稀疏稠密混合关键词检索最强向量是附加能力典型适用场景中小规模、PostgreSQL 重度用户大规模向量检索、独立部署已深度使用 ES 的业务最终我选的是 pgvector原因是团队本来就重度使用 PostgreSQL引入 pgvector 意味着少维护一套独立组件业务元数据、向量和审批权限都能放在同一个事务里一致性问题少很多。HNSW 索引构建时的参数值得注意我在 16 核 32G 的实例上把 m 设为 16、ef_construction 设为 128检索时 ef_search 设为 20这是一组相对平衡的默认值。如果文档量级继续增长到千万级到时候再考虑引入 Milvus 也不迟。检索方案我没有走单一的向量召回而是采用了“向量召回 关键词召回 RRF 融合”。原因很实际纯向量检索对语义相似但字面差异大的表述效果好但在企业知识库里很多查询其实是精确的术语匹配比如工单编号、型号规格、人名这时候 BM25 关键词检索反而更准。我分别用 pgvector 召回 topK 的 2 倍结果用 PostgreSQL 全文检索召回同样数量然后用 RRF倒数排名融合把两组候选融合排序。RRF 的公式很简单score Σ 1 / (k rank)k 通常取 60。这个方案的好处是几乎不需要调权重两个召回源在 rank 层面的竞争力天然可比。2.3 重排与上下文压缩提升答案质量的最后一环召回回来的候选块不能直接一股脑塞给大模型否则会出现两个问题一是相关性靠前的块不代表对回答最有帮助可能前几名都是泛泛的介绍真正有用的细节排在后面二是上下文窗口有限塞太多无关内容会稀释答案的注意力还白白增加 token 成本。所以我加了 rerank 阶段用 cross-encoder 模型对候选块逐一和 query 打相关分再按分数重新取前三到五块。相比双路召回cross-encoder 的计算量更大所以只对融合后的前 20 个候选做打分延迟在百毫秒级别完全可以接受。上下文压缩是我后来补充的一步一开始没做结果发现某些候选中夹带着大量表格噪音或重复表述大模型容易被带偏。简单做法是先做文本去重再按 query 做近似句子筛选把与 query 语义距离过远的句子剪掉最后还要控制送入模型的上下文总长度给系统提示词留足空间。我一般把最终上下文限制在 1500 个 token 左右再配上每个来源块的标题和页码让回答能输出“根据《XX制度》第X章”这样的引用。3. 智能体抽象与工作流引擎实现3.1 Agent 接口与工具调用机制智能体部分我做的不是那种“一个万能 Agent 自动完成所有事”的乌托邦设计而是可控的、可编排的多智能体协作。每个 Agent 本质是一个函数接收场景化的输入经过内部执行循环产出一个确定结果。我把这个抽象成 Java 接口public interface Agent { String name(); AgentResult run(AgentContext context); default void validate(AgentConfig config) { // 每次执行前校验参数和权限 } }内部执行循环是规划、调用、观察、反思四步。先由大模型根据当前任务拆解下一步动作再通过 Function Calling 调用注册好的工具观察工具返回后再决定继续还是汇总给用户。工具注册中心是重点每个工具都有唯一的名称、描述、参数 JSON Schema 和权限标识。工具描述写得越清楚大模型调用工具的准确率越高这一点在调试时体会特别深——同样一个查询日报的工具描述里写明“入参需包含 yyyy-MM-dd 格式的 date 字段”调用成功率立刻上去了。工具调用必须有审计。每一个工具调用我都会记录调用人、入参、返回摘要、耗时和 token 消耗企业场景里这个审计链路不能省否则一旦 AI 误调用了某个敏感接口连事后定位都做不到。3.2 从状态机到 DAG工作流引擎的落地设计一开始我尝试用状态机来管理智能体协作后来发现不够用。状态机适合描述单一实体的状态迁移但智能体工作流天然是网状结构一个节点执行完可能并行发散出两条分支也可能根据条件跳过某个步骤用状态机硬建模会让流程定义变得非常别扭。于是改成 DAG 编排每个工作流定义由节点和边组成节点指向具体的 Agent 或工具边上可以挂条件表达式。DAG 执行器维护两类状态实例级状态和节点级状态。实例状态包括待运行、运行中、成功、失败、已取消节点状态包括待执行、运行中、成功、失败、超时。每次执行节点前会先检查对应的全局幂等 key确保同一个工作流实例的重试不会重复执行某个副作用的工具调用。这一点在对接“发送邮件”“创建工单”这类工具时必须做否则一个超时重试就能让用户收到三封一模一样的邮件。条件分支我用 SpEL 表达式来实现。节点执行完后会返回 routing 结果引擎根据边上配置的表达式决定下一个节点是谁。比如一个工单处理流程如果问题分类是“网络”走网络排查 Agent如果是“账号”走账号处理 Agent如果都不匹配走人工兜底节点。这样流程定义是配置化的新增一个分支不需要改代码。3.3 基于虚拟线程的并行节点调度并行调度放在 Java 21 这个背景下就显得顺理成章。每个节点内部都有大量 IO 等待如果用传统线程池并行度设置在几十就已经压力很大但虚拟线程可以做到一个实例一个线程阻塞时自动让出载体线程。我在引擎里直接用Executors.newVirtualThreadPerTaskExecutor()配合 CompletableFuture 来编排并行节点代码比之前的线程池方案简洁太多。ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); ListCompletableFutureNodeResult futures parallelNodes.stream() .map(node - CompletableFuture.supplyAsync(() - executeNode(instance, node), executor)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();需要提醒的是并行分支的结果汇总要格外小心。某个节点失败不能简单让整个工作流失败我在引擎里给每个并行组配置了“全部成功”或“任一成功”的汇聚策略。比如“并行同时查库存和查历史订单”必须全部成功才能进入下一环节但“并行调三个 AI 模型做答案生成”只要有一个成功就可以接受。汇总结点会收集每个分支的结果写回工作流上下文供下游节点读取。4. Spring Boot 3 集成细节与可观测性建设4.1 虚拟线程配置与踩坑记录Spring Boot 3.2 开始支持在 application.yml 里直接开启虚拟线程Tomcat 接收请求后会跑在虚拟线程上不用再自己包装线程池spring: threads: virtual: enabled: true这个配置开起来之后接口层面的并发能力立刻提升了一个档次。但踩坑是后面才来的代码里只要出现synchronized关键字并且里面发生了阻塞操作虚拟线程就可能被 pinned 到载体线程上导致并发能力回落甚至引发性能抖动。排查这个问题花了不少时间最后把阻塞代码里的synchronized全部改成了ReentrantLock。另一个坑是 ThreadLocal。虚拟线程里 ThreadLocal 的读写成本变高了而且如果你用虚拟线程池跑大量任务ThreadLocal 里存的数据容易在任务之间造成困惑。我的建议是引擎内部传递上下文尽量用显式参数而不是靠 ThreadLocal 隐式传递尤其是在智能体执行循环这种高频率上下文切换的地方显式传参不仅更清晰也避免了不少隐性 bug。4.2 关键配置连接池、超时、限流与追踪接入大模型 API 之后我才意识到超时配置是整个系统稳定性的生命线。大模型服务经常出现偶发慢请求如果客户端没有超时保护工作流实例会一直挂在某个节点上连接池也会被占满。我在模型适配层统一设置了连接超时 3 秒、读取超时 60 秒并做了指数退避重试退避区间为 1 秒、2 秒、4 秒和 8 秒最多重试 3 次。向量检索这种内部依赖则严格限制在 2 秒内超过就返回预置兜底答案而不是让用户无限等待。限流也是必须要做的。智能体工作流的 token 消耗是非线性的一个流程可能触发好几轮模型调用并发稍微一高账单就会非常恐怖。我在接入层做了一层基于 Redis 的令牌桶限流按用户维度限制每分钟的请求数同时也限制了每个工作流实例的最大模型调用次数防止某个异常流程循环调用模型把额度刷光。追踪方面引入micrometer-tracing-bridge-otel在接入层生成 traceId然后手动传给模型调用、向量检索和工具执行这样整个工作流的状态变化、耗时和调用链都能在日志系统里串起来。我还把每次模型调用的 token 数、延迟和工具调用次数挂到 Micrometer 指标上配合 Actuator 暴露给监控系统。5. 常见问题与排查技巧实录5.1 检索不准先别急着换模型检索效果不理想很多人第一反应是换更强的向量模型其实大多数问题出在数据处理和检索策略上。我排查过几个典型案例有的是切分粒度太粗一整章被当作一个向量块512 维向量根本表达不了这么丰富的语义召回结果自然泛泛有的是切分时表格被拦腰截断检索倒是命中了但答案信息不完整还有的是查询权限没过滤导致低相关性的高权限文档混进了候选集。遇到检索不准我建议按这个顺序排查先看切分结果是否符合文档语义结构再看候选集里有没有真正包含答案的块如果有但排名靠后优先考虑加 rerank最后才考虑换嵌入模型或做领域微调。5.2 工作流卡死与超时问题工作流卡死是上线初期最频繁的问题。第一类是虚拟线程被 pinned问题出在代码里的synchronized改成锁后解决。第二类是某个外部工具一直没有超时整个节点一直挂着后来我给所有工具调用加了强制超时并配置了超时后的失败策略可以重试、可以跳过、也可以走人工兜底节点。第三类是并行分支中有个分支不结束导致汇聚节点一直等这个通过给每个节点单独记录开始时间和超时阈值解决超时节点会被主动标记失败。排查这类问题最好用的手段就是日志里把节点 ID、实例 ID、traceId 打全没有这个基础单靠肉眼比对日志时间线会非常痛苦。5.3 内存与性能问题排查JVM 堆内存设置不当会直接导致容器被杀。我一开始在容器里给 JVM 设置了固定的-Xmx4g结果宿主机内存一紧张容器直接被 OOM Killer 杀掉没有任何堆转储。后来改成-XX:MaxRAMPercentage75让 JVM 自己感知容器内存上限遇到 OOM 时输出 heap dump配合 Arths 做现场分析。向量库这方面也要注意HNSW 索引是常驻内存的千万级向量对内存要求很高实际项目中要提前估算索引大小别等上线后才发现内存不够。5.4 问题排查速查表现象可能原因排查方向解决建议回答内容明显缺失上下文切分太碎或表格被截断检查切分结果和 chunk 内容调整结构化切分参数检索结果相关性差嵌入模型与领域不匹配抽样检查向量召回结果增加 rerank必要时微调嵌入模型工作流节点长时间不结束外部调用未设置超时查看节点耗时与 traceId统一设置超时和失败策略并发一高接口变慢虚拟线程 pinned 或连接池耗尽抓线程栈与连接池监控替换 synchronized扩容连接池容器频繁 OOMJVM 未感知容器内存限制检查容器日志与堆转储使用 MaxRAMPercentage 并导出 dump工具被重复调用重试导致重复执行检查幂等 key 与日志增加全局和节点级幂等校验模型调用费用增长异常缺少限流与控制查看 token 指标和调用次数接入层限流、限制单实例最大调用次数6. 项目源码结构、部署实践与性能实测6.1 工程目录与核心类说明整个工程项目按模块分包核心结构如下com.example.ragagent ├── controller // REST 接入层 │ └── WorkflowController.java ├── engine // 工作流引擎 │ ├── WorkflowEngine.java │ └── node │ ├── NodeInstance.java │ └── WorkflowInstance.java ├── agent // 智能体抽象 │ ├── Agent.java │ └── tool │ ├── ToolRegistry.java │ └── ToolCallRecord.java ├── rag // RAG 组件 │ ├── parser │ │ └── DocumentParser.java │ ├── chunk │ │ └── ChunkSplitter.java │ ├── vector │ │ └── VectorStore.java │ └── retrieve │ ├── Retriever.java │ └── Reranker.java ├── model // 大模型适配 │ ├── llm │ │ └── LlmClient.java │ └── embedding │ └── EmbeddingClient.java └── config // Spring 配置类似WorkflowEngine这类核心类我通常会让它保持相对薄只做状态流转和调度真正的业务逻辑下沉到 Agent 里。这样做的好处是单元测试很好写——构造一个 DAG 定义和输入上下文跑一遍就能知道节点流转是否符合预期不需要把外部依赖全部 mock 掉。Retriever是另一个值得细看的类它完整实现了双路召回、RRF 融合和重排三段式。LlmClient则在大模型 API 之上加了超时、重试和 token 计量业务方调用模型的时候甚至不需要关心底层的重试策略。6.2 Docker 部署、JVM 参数与压测数据部署上我做了多阶段构建的 Docker 镜像基础镜像用 Eclipse Temurin 21构建阶段用 Maven 打包运行阶段只拷贝 jar 包镜像体积控制在 300MB 以内。启动脚本里固定加了一些 JVM 参数java -XX:MaxRAMPercentage75 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/logs/java.hprof \ -jar app.jar另外在 Docker Compose 里把 PostgreSQL、Redis 和引擎服务编排在一起依赖关系先拉起存储再做服务健康检查避免服务启动时连不上数据库导致大量报错重试。压测环境我用的是 16 核 32G 的虚机数据库和应用在同一台机器上用 50 并发持续压了 30 分钟。单轮问答场景包含一次检索加一次模型调用P95 响应时间在 2.1 秒左右主要耗时集中在大模型生成阶段检索和重排加在一起在 800 毫秒以内。一个包含四个串行节点的工作流平均完成时间约 8 秒同样流程如果中间两个节点改成并行平均完成时间能降到 5 秒左右。这组数据不算突破天际但对于企业内部知识库问答场景来说已经足够支撑日常使用而且因为是 Java 体系横向扩容非常容易加节点就能扛更多并发。最后再说一点我做这个项目的体会。真正把 RAG 和智能体做成企业级服务最花精力的往往不是模型选型而是切分质量、超时控制、幂等重试和可观测性这些“脏活”。Python 生态能让你快速验证想法但 Java 21 加 Spring Boot 3 的组合在这些工程化问题上给了我足够的底气。如果你也正在用 Java 尝试做类似的 AI 应用我建议先别急着把工作流编排做得特别复杂踏踏实实把文档切分、检索召回、超时限流、traceId 贯穿这四件事做扎实效果比堆一堆花哨的 Agent 节点实在得多。