看到“别卷 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。三者在定位上有明显差异,我整理过一张对比表:
| 维度 | pgvector | Milvus | Elasticsearch 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(); List<CompletableFuture<NodeResult>> 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:MaxRAMPercentage=75,让 JVM 自己感知容器内存上限,遇到 OOM 时输出 heap dump,配合 Arths 做现场分析。向量库这方面也要注意,HNSW 索引是常驻内存的,千万级向量对内存要求很高,实际项目中要提前估算索引大小,别等上线后才发现内存不够。
5.4 问题排查速查表
| 现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 回答内容明显缺失上下文 | 切分太碎或表格被截断 | 检查切分结果和 chunk 内容 | 调整结构化切分参数 |
| 检索结果相关性差 | 嵌入模型与领域不匹配 | 抽样检查向量召回结果 | 增加 rerank,必要时微调嵌入模型 |
| 工作流节点长时间不结束 | 外部调用未设置超时 | 查看节点耗时与 traceId | 统一设置超时和失败策略 |
| 并发一高接口变慢 | 虚拟线程 pinned 或连接池耗尽 | 抓线程栈与连接池监控 | 替换 synchronized,扩容连接池 |
| 容器频繁 OOM | JVM 未感知容器内存限制 | 检查容器日志与堆转储 | 使用 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:MaxRAMPercentage=75 \ -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 节点实在得多。