Spring AI多模型Agent平台实战:从RAG到技能编排的落地指南
发布时间:2026/10/8 4:59:22 锦皓数字建站

简介这是一份基于Spring Boot 4与Spring AI构建的企业级AI智能体平台Snail AI完整源码包面向需要快速搭建多模型管理、Agent编排及RAG知识库的Java研发与架构人员。平台内置可用的后台管理界面与OpenAPI接口覆盖多模型接入、智能体编排、长期记忆、技能编排和向量检索等核心能力可直接作为企业级AI应用二次开发的基础工程。压缩包共672个文件以554个Java业务代码为主搭配49个JS前端逻辑、39个XML配置、12个CSS样式另有yml环境配置、SQL初始化脚本、Dockerfile容器化部署文件及proto接口定义等整体仅2.26MB轻量且结构清晰。目前已有25人浏览学习。通过这份源码可以学习Spring AI与Spring Boot 4的工程化整合方式理解RAG知识库构建、向量检索与记忆机制的实现思路同时借助后台界面和OpenAPI机制掌握可扩展AI平台的设计方法对希望深入企业级智能体落地实践的开发者很有价值。1. 一个开箱即用的多模型 Agent 平台这次不写胶水代码做 AI 应用的工程化最耗时间的从来不是调一个模型接口而是把模型接入、知识库、Agent 编排、记忆管理这些散落的模块串起来。这套基于 Spring Boot 4 Spring AI 的多模型、多 Agent 管理平台把 RAG、向量检索、技能编排、长期记忆都做成了开箱即用的模块。你不用再自己拼 HTTP 调用、维护向量库连接、给每个 Agent 写一套状态管理。对 Java 技术栈的团队来说它是目前落地 AI 应用最顺手的底座适合已经过了“调通一个 API”阶段、准备做多 Agent 产品化的开发者。2. 架构与模型接入统一接口下的多模型路由2.1 为什么选 Spring AI 而不是自己封一层 HTTP 调用早期做 AI 应用团队里最常见的做法是直接用 RestTemplate 或 WebClient 调各家模型的 HTTP API再自己封装一个 Client。这套路短期内能用但模型一多就会出问题OpenAI 的消息结构、Claude 的 system prompt 处理、通义千问的流式返回格式各自都有差异。你以为封装好了换一个模型就要改几个分支逻辑更别提每个模型对 token 统计、超时重试的处理完全不同。Spring AI 做的事情就是把这些差异收敛成一套标准接口。它参考了 Spring 生态里 JdbcTemplate 和 RestTemplate 的设计思路把 ChatModel、EmbeddingModel、VectorStore 都做成了统一抽象。你在代码里面向 ChatModel 编程具体底层是 OpenAI 还是 DashScope完全由配置决定。这套项目直接用 Spring Boot 4 作为底座把 spring-ai-autoconfigure 的自动配置能力和 Spring Boot 4 的新特性组合在一起项目启动的时候不用自己手动创建 Bean只要配好依赖和配置项平台自动把模型客户端、向量存储客户端装配进上下文。我一般会在公司内部这样向团队解释Spring AI 之于大模型 API就相当于 Spring JDBC 之于数据库。它不解决算法问题但解决了接入层的“方言”问题。你写一套 Query在不同数据库上执行JDBC 帮你翻译你写一套 ChatClient 调用在不同模型上执行Spring AI 帮你翻译。2.2 统一模型接口配置多个模型提供方并用路由切换平台接入多模型的入口是 ChatClient 和 ChatModel。项目里通过配置类把不同的模型注册成不同的 Bean再封装一层 ModelRouter按业务场景做路由。常见做法是默认模型走 OpenAI 兼容接口国内场景切到 DashScope 或 Ollama 本地模型。先看 pom 里怎么引入dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-ollama/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-dashscope/artifactId /dependency这段配置的关键在于三个 Starter 同时存在时Spring Boot 会自动装配三套 Client但只有对应的配置存在才会生效。比如你只配了 OpenAI 的 API KeyOllama 和 DashScope 的自动配置会因为缺少关键属性而回退不会影响启动。然后是统一调用Service public class AiChatService { private final MapString, ChatModel modelMap; public AiChatService(ListChatModel models) { // 把 Spring 容器里所有的 ChatModel 按名称存起来 this.modelMap models.stream() .collect(Collectors.toMap( model - model.getClass().getSimpleName().toLowerCase(), Function.identity() )); } public String chatWithModel(String modelKey, String prompt) { ChatModel model modelMap.getOrDefault(modelKey, modelMap.values().iterator().next()); // 注意这里设置 temperature 和 maxTokens 的方式在不同版本里略有差异 return model.call(new Prompt(prompt, OpenAiChatOptions.builder() .withTemperature(0.7) .withMaxTokens(1024) .build() )).getResult().getOutput().getContent(); } }这段代码的核心逻辑是启动时把所有 ChatModel 实现类注入进来按类名存成 Map外部传入模型 key 时直接路由。参数说明temperature 控制随机性0.7 适合生成类任务做检索问答或代码生成我一般调到 0.10.3maxTokens 决定单次回复上限需要根据你的上下文长度预期来设。这里的 map key 用的是简单类名在多个同类型 Bean 存在时会有覆盖问题生产环境建议用配置前缀做 key 而不是类名。2.3 流式输出与上下文管理把模型接进来只是第一步真正影响体验的是流式输出和上下文组织。平台里用 Fluent API 组织调用ChatClient 支持 .stream() 返回 FluxPostMapping(/chat/stream) public FluxString chatStream(RequestBody ChatRequest request) { // 历史消息通过 Memory 组件从数据库载入 ListMessage history chatMemoryService.getMessages(request.getSessionId()); return ChatClient.builder() .defaultSystem(你是一个严谨的工程助手回答要简洁、准确。) .build() .prompt() .messages(history) .user(request.getQuestion()) .stream() .content(); }这段代码里有两个关键设计defaultSystem 把系统提示词固定住从接口层拿不到避免用户注入messages(history) 把历史消息一次性传给模型模型根据完整上下文作答。这里要特别注意Spring AI 的 Message 对象分 SystemMessage、UserMessage、AssistantMessage 三种从数据库组装历史记录时必须严格按角色区分否则模型会分不清哪句是用户说的、哪句是你说的。实际上流式输出在前端对接时还有一个大坑SSE 协议的数据格式。Spring AI 的 Flux 直接返回对应的是 text/event-stream前端如果用了 fetch 而不是 EventSource要自己解析data:前缀。平台里封装了一层 SseEmitter 适配把每个 token 包装成标准 SSE 帧这块建议直接复用不要自己再写一遍。3. RAG 与向量检索知识库索引、召回与重排的落地细节3.1 文档解析与分块策略chunk_size 和 overlap 怎么选RAG 的效果好坏70% 取决于文档切分而不是模型选择。很多人在复现项目时直接拿默认的分块参数跑结果召回一堆语义碎片模型回答得牛头不对马嘴。平台里内置了 DocumentReader 管线从 PDF、Word、Markdown 解析纯文本后走 TokenTextSplitter 做切分。Bean public TextSplitter textSplitter() { // 按 token 粒度切分不是按字符切分 return new TokenTextSplitter(800, 150, 10); }参数说明第一个参数是 chunk size每个文本块最多 800 token第二个是 overlap相邻块之间保留 150 token 的重叠第三个是 minChunkSize低于 10 token 的碎片直接丢弃。经验值是技术文档用 600800合同、论文这类强逻辑文本用 400500对话记录用 300。overlap 一般取 chunk size 的 15%20%太少会切断语义太多会造成信息冗余、浪费 embedding 调用。分块之后要做元数据标注这是很多人忽略的一步。给每个 chunk 打上文档名、页码、章节标题检索时就能按元数据过滤。比如你在产品手册场景下只检索“售后政策”相关的分块直接在 VectorStore 的 filter 表达式里写死来源召回精度会高一大截。3.2 向量化与索引embedding 模型选型和向量库初始化向量检索链路里embedding 模型的选择直接影响召回质量。平台支持 OpenAI 的 text-embedding-3-small、DashScope 的 text-embedding-v4以及本地 Ollama 的 nomic-embed-text。如果你处理的是中文技术文档建议优先用 DashScope 的 embedding它对中文长文本的分词和语义理解明显比通用模型稳如果完全在内网环境部署用 Ollama 跑 nomic-embed-text 是够用的但维度降到 768 后相似度阈值和重排策略都得跟着调整。项目里的向量库默认用的是 PGVectorPostgreSQL 插件方式好处是跟业务数据放同一个库备份恢复都是同一套流程。初始化脚本如下CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS knowledge_chunk ( id VARCHAR(64) PRIMARY KEY, content TEXT NOT NULL, metadata JSONB, embedding vector(1024) ); CREATE INDEX ON knowledge_chunk USING hnsw (embedding vector_l2_ops);这里有两个关键点。embedding vector(1024) 的维度必须跟你选的 embedding 模型输出维度一致DashScope text-embedding-v4 是 1024 维OpenAI text-embedding-3-small 是 1536 维配错直接报错。HNSW 索引的 vector_l2_ops 是欧氏距离算子Spring AI 默认用余弦相似度从工程角度说向量归一化之后欧氏距离和余弦相似度的排序结果等价所以不用纠结算子选择但要注意索引的 m 参数每个节点最大连接数和 ef_construction 参数默认值 16 和 64 在十万级数据量下够用过了百万级要调大。Spring AI 侧面的配置spring: ai: vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024这段配置对应的是 pgvector extension 的索引和距离计算方式。COSINE_DISTANCE 会直接把向量做归一化再算内积比 L2 更符合文本相似度场景。如果你用 Ollama 的 nomic-embed-textdimensions 要改成 768。这里改错一个数字启动时不报错但检索时向量维度不匹配会直接抛异常。3.3 召回与重排topK、相似度阈值和 rerank向量检索不是把相似度最高的几个 chunk 扔给模型就完事。实际场景里topK 取 3 还是取 8直接影响回答质量取太少上下文不足取太多模型注意力被无关信息分散。平台里对检索做了两级过滤public ListDocument retrieve(String query, String bizType, int topK, double threshold) { // 第一级向量召回按相似度过滤 ListDocument docs vectorStore.similaritySearch( SearchRequest.builder() .query(query) .topK(topK * 2) .similarityThreshold(threshold) .filterExpression(bizType bizType ) .build() ); // 第二级Reranker 重排用模型给每个 chunk 打分 return reranker.rerank(query, docs) .stream() .limit(topK) .toList(); }这段代码里的设计逻辑是向量召回时多取一倍比如最终 topK 是 5召回 10 个然后喂给 Reranker 做精排取前 5。为什么多取一倍因为向量召回的第一轮是粗排head 位置可能有语义接近但从不见面的干扰项留出余量给重排模型纠偏。相似度阈值这块经验值是 OpenAI embedding 下 0.7 起步DashScope 下 0.65 起步低于这个值说明召回内容跟问题不相关直接返回“知识库中没有找到相关内容”比硬答更符合预期。Reranker 是 RAG 链路里性价比最高的组件。常见做法是用一个轻量的 Cross-Encoder 模型跑二分类打分比如 BAAI/bge-reranker-v2-m3也可以用 GPT-4o 这类强模型做启发式重排。平台里预留了 Reranker 接口内置实现走的是 DashScope 的 rerank 服务用它之前注意看并发配额RAG 请求量大时这个环节是最容易超时的。4. 记忆与技能编排让 Agent 记住上下文并执行复杂任务4.1 记忆机制会话记忆与向量记忆的取舍多轮对话场景里没有记忆的 Agent 就是“每次都在重新认识你”。平台实现了两层记忆短期会话记忆存在 Redis 里长期记忆存到向量库。public class HybridChatMemory implements ChatMemory { private final RedisTemplateString, ListMessage redis; private final VectorStore vectorStore; public ListMessage get(String sessionId) { // 先查短期记忆最近 20 条 ListMessage recent redis.opsForValue().get(session: sessionId); // 再查长期记忆按当前消息检索相关历史结论 ListDocument longTerm vectorStore.similaritySearch( SearchRequest.builder() .query(session: sessionId 历史结论) .topK(3) .build() ); // 合并后按时间排序返回 return merge(recent, longTerm); } }这里有一个关键取舍长期记忆不能直接拼进全部消息里否则塞进一堆远古碎片模型既看不完也会被误导。常见做法是每次对话开始时用当前问题去向量库里检索跟这个问题最相关的历史结论只把 topK 条作为“记忆摘要”注入 system prompt。这样做的好处是长期记忆不是全量注入而是按需召回跟 RAG 的检索思路完全一致。短期记忆的 Redis 存储要注意过期策略我一般设 30 分钟过期超过这个时间就认为会话不活跃。另一个坑点是Redis 里存 Message 对象要用 JSON 序列化Message 接口有多个实现类反序列化需要带上类型信息否则会读出来一堆空对象。4.2 技能编排把工具调用拆成可组合的技能平台对 Agent 的定位是“能调工具、能决策流程”的执行者。里面把每个工具调用封装成 SkillAgent 根据用户意图自动选择调哪个。定义技能的方式是继承 Spring AI 的 Tool 抽象Component public class OrderQuerySkill implements Tool { Override public String name() { return queryOrder; } Override public String description() { return 根据订单号查询订单状态和物流信息适用于用户询问我的订单到哪里了; } Override public JsonNode run(JsonNode arguments) { // 从 arguments 里取 orderId调订单服务 String orderId arguments.get(orderId).asText(); Order order orderService.query(orderId); return JsonNodeFactory.instance.objectNode() .put(status, order.getStatus()) .put(logistics, order.getLogistics()); } }这里的三要素缺一不可name 是模型用来定位技能的唯一标识description 是模型判断“该不该用这个工具”的依据必须写清楚适用场景和边界条件写得越具体模型越不容易用错run 方法接收模型自动生成的 JSON 参数。写描述的时候有个技巧把“不适用”的场景也写进去比如“本工具不适用于售后维权售后问题转人工”模型看到这个会直接跳过该技能。技能编排层还支持组合技能比如“查订单 - 判断是否超时 - 超时则触发售后”这属于 Agent 的决策链路。平台里把这些技能注册成 Function Calling 的参数模型在每次回答前决定要不要调用、调哪个。这个机制天然适合“多技能并存”的业务场景。4.3 多 Agent 协作不同角色 Agent 的分工与路由单 Agent 干不了复杂活比如“写一份数据分析报告”其中涉及取数、分析、撰写三个完全不同的技能把它塞进一个 Agent 会互相干扰。平台的解法是把职责拆开专门的数据查询 Agent、分析 Agent、写作 Agent用编排器按目标路由。public class AgentOrchestrator { private final MapString, AiAgent agentRegistry; public AgentReply execute(String goal) { // 第一步规划让规划 Agent 决定任务拆解和每个子任务的负责人 Plan plan plannerAgent.createPlan(goal); ListAgentReply results plan.getSteps().stream() .map(step - { AiAgent agent agentRegistry.get(step.getAgentType()); return agent.execute(step.getTask()); }) .toList(); // 第二步汇总由主编 Agent 把各个子结果整理成最终答复 return writerAgent.synthesize(goal, results); } }这里最值得注意的细节是每个 Agent 实例都有自己的 System Prompt 和技能表。数据查询 Agent 看不到写作技能写作 Agent 也拿不到数据库连接能力边界是硬隔离的。这在多 Agent 场景里特别重要否则模型很容易跨权调用。常见的失败案例是规划 Agent 把一个任务拆给了错误的 Agent结果执行 Agent 返回“我没有这个技能”整个链路就断了。缓解办法是在编排器的规划阶段对每个子任务的 Agent 分配做一个合法性校验确认该 Agent 的技能列表里包含任务所需的技能名。这块代码不长但对稳定性的提升非常可观。记忆在多 Agent 场景下也要按 Agent 隔离每个 Agent 只读自己的会话记忆不能被编排器传过去的上下文串了。平台里 AgentContext 数据结构做了隔离确保 A Agent 的任务结果不会泄露进 B Agent 的记忆。多 Agent 协同时如果发现回答内容串味先检查是不是这一块写漏了。5. 常见问题与避坑改到怀疑人生的五个坑5.1 启动报错Spring AI 自动配置冲突现象pom 里同时引入了 OpenAI、DashScope、Ollama 三个 Starter启动时抛出No unique bean of type ChatModel或UnsatisfiedDependencyException。原因Spring AI 会自动装配多个 ChatModel BeanSpring 容器无法决定把哪个注入到你的 Service 里。这不是项目的问题是手动注入 ChatModel 时没有限定 Bean 名。解决在注入点加Qualifier(openAiChatModel)或者像我前面讲的接收ListChatModel并按类名路由。更推荐后者因为多模型平台的诉求本来就是同时持有多个模型不要只留一个。5.2 RAG 召回结果里全是无关内容现象知识库问答上线后模型回答经常引用跟问题毫无关系的文档片段甚至自相矛盾。原因chunk size 设置过大一段文本里可能包含三四个不同主题向量化后语义被稀释也有可能是 similarityThreshold 设得太低把大量低相关度 chunk 放进了上下文。解决先做坏样本分析把向量检索 topK 的原始返回打出来看。如果召回片段切得不干净把 chunk size 从 800 降到 500如果召回分数普遍低于 0.6先检查 embedding 模型是不是跟文本语言不匹配中文文本用英文 embedding 模型会有明显的分数偏低。多轮调参建议一次只动一个变量。5.3 流式输出前端只收到一整段文字现象接口返回正常但前端页面是一个字一个字往外蹦的变成了一个大段一次性出现完全丢失逐字输出效果。原因前端用的 fetch 不是 EventSource没有处理 SSE 的data:前缀或者网关层把Content-Type: text/event-stream吞掉了走了 JSON 解析。解决前端需要识别 SSE 格式按行解析data:前缀并逐段 append。后端层面在 Nginx 或 Spring Cloud Gateway 上确认没有对/chat/stream做缓冲关掉proxy_buffering否则 SSE 事件会被积压到连接结束才一起发出。我排查这个问题时用curl -N直接打接口确认后端是逐 token 返回的才能定位到是网关问题。5.4 技能调用参数类型不匹配现象Agent 明明调对了技能但执行时抛异常日志显示JsonMappingException: Cannot deserialize value of type int from String。原因Function Calling 机制由模型生成参数模型可能把数字写成了字符串比如orderId: 12345而不是12345。解决技能代码里不要直接用强类型接收统一走JsonNode然后手动转换转换失败时返回友好提示让模型重新生成参数。更稳妥的方案是给技能参数定义一个 JSON Schema 并传给模型模型按 Schema 生成类型出错的概率会明显下降。但这块本质上是概率问题必须做容错别指望模型 100% 守规矩。5.5 向量库连接数打满导致检索超时现象并发上来之后RAG 接口的 P99 延迟从 500ms 飙升到 5s日志显示HikariPool-1 - Connection is not available。原因PGVector 和业务库共用一个连接池向量检索是高耗时的查询把连接池占满了业务查询也排队等连接。解决给向量检索单独配一个数据源或者把向量库独立成库并配独立的 HikariCP 连接池。最大连接数按并发峰值估算经验公式是最大并发查询数 * 2 5。这个坑在项目里最常见也最容易在测试阶段被忽略等上线流量一来就暴露。6. 验证与进阶跑通一个多 Agent 协作场景才算完平台部署起来之后不要急着接业务先跑一个验证场景。我一般会这样做搭一个“售前知识库问答 订单查询 退款政策”三个 Agent 的联动流程。先起服务确认健康检查通过并且模型连通curl -X POST http://localhost:8080/ai/chat \ -H Content-Type: application/json \ -d {sessionId:test-001,question:我的订单 OD20250101 现在到哪了}看返回里是否包含了订单状态并且 Agent 是否主动调用了订单查询技能。然后换一个知识库问题比如“你们退款政策支持七天无理由吗”确认回答内容来自知识库。两个问题都通过后再试一个复合问题“我的订单在退款期内吗我买了有三四天”这会触发多 Agent 协同一个 Agent 查询订单时间一个 Agent 查退款政策最后由总结 Agent 给出结论。这一步走通整个平台的核心链路就都验证过了。进阶用法里最值得花时间的是监控。我在这套平台上加了 Actuator 的 metrics 上报统计每个模型调用的 token 消耗、每次 RAG 检索的召回率和 rerank 后的采纳率。这几个指标配合日志链路追踪用 traceId 贯穿 HTTP 请求到模型调用再到向量检索能让线上问题定位从“猜”变成“看数据”。具体做法很简单定义 MeterRegistry 的 Tag比如 model、bizType、topK每个调用点计时并记录 hits 和 misses。那以后我每次上线新的 Agent 技能都会强制走一遍同样的验证流程先明确技能的输入输出边界再跑一个召回样本的离线评测最后才放开线上流量。这个习惯帮我避掉了至少三次“上线即翻车”的现场。RAG 的效果没有玄学全靠一条条坏样本喂出来的Agent 的稳定性也没有银弹全靠隔离好职责边界、把每个环节的数据都留在日志里。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。