☰
Spring AI多模型多Agent平台实战:从接入路由到RAG落地避坑
2026/10/2 18:21:09 网站建设 项目流程

简介:Snail AI 是一套基于 Spring Boot 4 与 Spring AI 构建的企业级 AI 智能体平台,面向需要多模型接入与智能体编排的 Java 开发者及企业团队,适用于知识库问答、智能客服、自动化任务等场景。平台开箱即用地提供 RAG 知识库、长期记忆、技能编排、向量检索等核心能力,并配有完善的后台管理界面和 OpenAPI 接口,便于快速集成和二次开发。资源包共含 672 个文件,约 2.26MB,以 554 个 Java 源码为主,覆盖服务、API、智能体调度等后端逻辑;另有 JS/CSS 构成管理控制台,XML/YAML 承担配置,SQL 初始化数据库,Dockerfile 支持容器化部署,目录结构清晰。内置技能编排与向量检索示例,可帮助理解 RAG 切片、向量化召回和记忆持久化的工程实现,也能作为企业级 AI 中台的起步模板。资源目前已有 25 人学习,对希望掌握 Spring AI 实战与智能体平台架构的开发者具有直接参考价值。

1. Spring AI 多模型多 Agent 平台:先看清它解决什么问题

团队里模型越接越多,GPT 类模型、智谱、通义千问各留一套调用代码,半年后主服务里堆了十几个模型 Service,换一个 Key 要改两处,加一个模型要复制一整个类——这还是在没用上 Agent 的前提下。我拆这套基于 Spring Boot 4 + Spring AI 的多模型多 Agent 管理平台时,目的很明确:把这段最乱的接入层收编成可配置、可管理的东西。模型供应商统一接入,Agent 独立注册,RAG 和记忆各归各的,技能用注解暴露给 Agent 调度。

这套东西适合两拨人:一是 Java 团队想在企业应用里落地 RAG 和 Agent,受够了 Python 服务与 Java 业务系统之间来回跳;二是已经在用 Spring AI,但多模型切换靠改代码、Agent 逻辑全写在 Controller 里的人。它真正的价值不是某个算法多深,而是把 Spring AI 里那些概念落成了可操作的模块——模型接入、Agent 生命周期、会话记忆、向量检索、技能编排,每一块都能在配置层和接口层改。后文我会从架构拆起,然后讲怎么跑起来、RAG 链路怎么调,最后把最容易翻车的五个点过一遍。看完你能直接对着源码改出自己的版本。

2. 多模型接入与 Agent 生命周期:先把 Spring AI 的边界拆清楚

2.1 多模型接入:ChatModel 抽象与多供应商配置的取舍

Spring AI 本身对模型做了统一抽象:所有模型供应商都实现ChatModel接口,上层通过ChatClient调用。这个设计在单模型场景下很舒服,但一旦你在application.yml里同时配置了智谱和百炼,Spring Boot 的自动配置会创建两个ChatModelBean,此时直接@Autowired ChatModel启动就会报NoUniqueBeanDefinitionException。

常见做法是手动维护一个路由层,把多个 ChatModel 收进 Map,按请求里的模型名分发:

@Component public class RouterChatModel implements ChatModel { private final Map<String, ChatModel> models; public RouterChatModel( @Qualifier("zhipuAiChatModel") ChatModel zhipuAiChatModel, @Qualifier("dashScopeChatModel") ChatModel dashScopeChatModel) { this.models = Map.of( "zhipu", zhipuAiChatModel, "dashscope", dashScopeChatModel); } @Override public ChatResponse call(Prompt prompt) { // 从 Prompt 的 options 里取模型名,默认走 dashscope String modelName = resolveModelName(prompt); ChatModel target = models.getOrDefault(modelName, models.get("dashscope")); return target.call(prompt); } private String resolveModelName(Prompt prompt) { // 从 prompt.getOptions().getModel() 解析,兼容不同供应商的模型名写法 String model = prompt.getOptions().getModel(); if (model == null) return "dashscope"; if (model.contains("glm")) return "zhipu"; return "dashscope"; } }

这里有两个关键点。第一,@Qualifier必须写清楚,否则 Spring 自己猜注入目标会直接报错;第二,resolveModelName这个解析逻辑是 Router 的核心,我一般建议在Prompt的options里显式传一个自定义参数,而不是靠模型名字符串去猜——不同供应商的模型名经常带前缀,靠 contains 匹配迟早出问题。

配置层面长这样,智谱和百炼的 Key 都放进环境变量,代码里不落任何明文密钥:

spring: ai: chat: dashscope: api-key: ${DASHSCOPE_API_KEY:} options: model: qwen-plus temperature: 0.7 zhipuai: api-key: ${ZHIPU_API_KEY:} options: model: glm-4-air temperature: 0.6

注意temperature是供应商侧参数,部分模型不支持时会静默忽略;如果你发现同一个值在 A 供应商有效、B 供应商没反应,优先查该模型官方文档,不要怀疑 Spring AI 的代码。另外,如果你的模型走的是 OpenAI 兼容格式的自建网关,配置方式完全一样,把base-url指向网关地址就行,Spring AI 的ChatModel实现会按兼容协议解析。

2.2 Agent 生命周期:注册、启停、状态与技能绑定

Agent 在这套设计里不是简单的"提示词 + 模型"封装,它有独立的状态和技能列表。一个 Agent 至少包含:id、name、systemPrompt、绑定的模型名、短期记忆窗口、技能列表、运行状态。状态用枚举管理,IDLE 和 RUNNING 是核心两个,技能编排或异步任务里很容易出现并发复用同一个 Agent 导致上下文串线,所以状态必须显式维护。

@Service public class AgentService { private final Map<String, Agent> agents = new ConcurrentHashMap<>(); public Agent register(Agent agent) { agents.put(agent.id(), agent); return agent; } public ChatResponse execute(String agentId, String userMessage) { Agent agent = agents.get(agentId); if (agent == null || agent.status() != AgentStatus.IDLE) { throw new IllegalStateException("Agent 不存在或正在执行: " + agentId); } agent.status(AgentStatus.RUNNING); try { UserMessage message = new UserMessage(userMessage); ChatMemory memory = agent.memory(); memory.add(message); Prompt prompt = new Prompt(memory.messages(), agent.options()); return agent.chatModel().call(prompt); } finally { agent.status(AgentStatus.IDLE); } } }

register是给管理后台用的,新 Agent 通过接口注册进来,不用改代码;execute是核心执行路径,注意finally里必须把状态复位,否则一次异常后这个 Agent 永远处于 RUNNING,后续请求全部被拒。这是我在实际项目里踩过的坑——当时一个外部接口超时把状态卡死在 RUNNING,整个 Agent 池三小时后才被监控发现。

Agent 的技能列表用注解暴露,Spring AI 会把带@Tool的方法转成模型可调用的工具描述。技能绑定发生在注册阶段,我一般会在Agent构造时把技能列表注入,并给每个技能一个name做唯一标识。多个 Agent 可以共享同一个技能类实例,但要注意技能实现类内部不要持有会话级状态,否则并发下会串数据。

2.3 会话与记忆:线程级 MessageWindow 与持久化召回

记忆是 Agent 最容易做糊的部分。这套项目的设计把记忆分两层:短期记忆用 Spring AI 自带的MessageWindowChatMemory,只负责当前会话内的多轮上下文;长期记忆落到向量库或数据库,按对话主题召回历史片段。

// 短期记忆:固定窗口大小,超出后丢弃最旧消息 ChatMemory shortTerm = MessageWindow.builder() .maxMessages(16) .build(); // 长期记忆:会话结束后把摘要和关键结论写入向量库 ConversationRecord record = ConversationRecord.builder() .conversationId("session-001") .summary(summaryText) .build(); vectorStore.add(List.of(record));

maxMessages不是越大越好。我经验值是 8 到 16,超过 16 条以后,模型对早期消息的注意力明显下降,响应变慢且费用上升。长期记忆的召回发生在会话开始时——把当前用户问题先向量化,去库里检索相关的历史会话摘要,作为上下文拼进系统提示词。这套方案的优点是短期记忆不落盘、速度快,长期记忆按需召回,不会把整个历史一股脑塞给模型。缺点是两个层之间需要同步策略,否则会出现短期记忆和长期召回内容互相矛盾,这个我在避坑章再展开。

3. 把平台跑起来:环境准备、配置与第一个 Agent 对话

3.1 环境清单与版本基线

先明确版本基线。标题写的是 Spring Boot 4,实际拆解时我用的 Spring Boot 4.x 配套 Spring AI 1.0,这两个大版本在依赖管理上是配套的,不需要你手动对齐很多版本号。如果你的生产环境还压在 Spring Boot 3.5,也可以跑,只要把 spring-ai BOM 版本降下来,代码层面基本不用动。

组件版本/用途说明
JDK21+Spring Boot 4 默认支持虚拟线程,Agent 这种 IO 密集场景直接受益
Maven3.9+构建和依赖管理
MySQL8.0+Agent 注册信息、技能配置、会话记录的持久化
Redis7.x短期记忆、分布式锁、接口级缓存
pgvector / Milvus按调研选型向量检索,RAG 的存储底座
模型 API Key智谱 / 百炼至少一个,两个都配才能演示多模型路由

向量库选型是很多人一开始就卡住的问题。项目默认可以用 pgvector,因为它不需要额外部署一套服务,在 MySQL 旁边的 PostgreSQL 实例里建个扩展就能跑,适合团队里第一次上 RAG 的场景。如果你们的向量数据量级到了千万级以上、或对检索延迟有硬性要求,再上 Milvus。别为了"高性能"一上来就部署独立向量库,运维成本会被低估。

3.2 配置与启动:从 yml 到第一个 Agent 对话

把项目拉下来后,第一步不是启动,是把配置和基础设施准备好。我有一次直接mvn spring-boot:run,结果启动到一半就报数据库连不上——因为没看 README 的依赖要求。现在按这个顺序来:

spring: application: name: ai-agent-platform datasource: url: jdbc:mysql://localhost:3306/ai_agent_platform username: root password: ${MYSQL_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver ai: chat: dashscope: api-key: ${DASHSCOPE_API_KEY:} options: model: qwen-plus vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE data: redis: host: localhost port: 6379

初始化数据库表结构,项目里带了 SQL 脚本:

# 初始化 MySQL 表结构 mysql -uroot -p < sql/init.sql # 启动应用 mvn spring-boot:run

启动成功后,日志里会出现 Spring AI 相关的初始化信息。验证最小链路不要直接问复杂的业务问题,先用一个最简单的请求确认模型连通:调用 Agent 执行接口,传agentId=default-agent,消息内容写"请回复'连接正常'"。如果模型返回了这四个字,说明从应用到模型供应商的链路是通的。这一步很多人跳过,直接测 RAG,结果 RAG 有问题时根本分不清是模型的问题还是检索的问题。

3.3 同时接入智谱与百炼:两个 Key 的共存配置

单模型跑通后,多模型共存是下一个坎。把 2.1 里的 RouterChatModel 注册成 Bean,配置里同时放两个供应商:

spring: ai: chat: dashscope: api-key: ${DASHSCOPE_API_KEY:} options: model: qwen-plus temperature: 0.7 zhipuai: api-key: ${ZHIPU_API_KEY:} options: model: glm-4-air temperature: 0.6

这段配置完成后,Spring 容器里会有两个 ChatModel Bean。如果 2.1 的 Router 没有生效,启动时会在任何注入ChatModel的地方报NoUniqueBeanDefinitionException。判断依据很简单:报错信息里出现expected single matching bean but found 2,就是没走 Router。此时要么把 Router 组件补上,要么在所有注入点写@Qualifier("zhipuAiChatModel")——但这等于把路由逻辑散落在业务代码里,后续维护会非常难受。

多模型切换真正的价值在故障降级。比如百炼限流了,Router 里加一个简单的降级策略:try-catch捕获限流异常后自动切换到备选模型,这个过程对调用方透明。我在生产环境里就是这么用的,某个模型供应商不稳定时,切换不需要发版。

4. RAG 与向量检索落地:文档分块、入库与召回参数调优

4.1 从文档到向量:分块、Embedding 与入库链路

RAG 的第一步是把文档变成可检索的向量。整个过程有四个环节:文档解析、分块、向量化、入库。项目里常用TokenTextSplitter做分块,它在中文场景下按 token 数切,比按字符或按段落切更可控。

// 分块参数:块大小 512,重叠 100 TextSplitter splitter = new TokenTextSplitter(512, 100); // 原始文档可以来自 PDF、Word、Markdown,统一转成 Document Document doc = new Document(fileContent, Map.of("source", "tutorial-001")); List<Document> chunks = splitter.split(doc); // 向量化并写入 pgvector EmbeddingModel embeddingModel = new OpenAiEmbeddingModel(...); VectorStore vectorStore = new PgVectorStore(jdbcTemplate, embeddingModel, 1024); vectorStore.add(chunks);

分块参数直接决定检索质量,这个没有理论最优值,但有几个实际规律。块越小,检索越精确,但上下文碎片化严重,模型可能看不到完整逻辑;块越大,上下文越完整,但检索噪声也越大。中文技术文档我一般从 512 起步,重叠设 100。重叠的意义在于保证跨块的关键信息不会被拦腰切断,比如一个术语在前一块结尾、下一块开头,没有重叠就两个块都搜不到完整含义。

分块大小适用场景问题
256问答型 FAQ、短条款长逻辑丢失,需要额外拼接上下文
512技术文档、操作手册较均衡,中文场景常用起点
1024长章节、合同条文噪声增加,检索准确率下降

PgVectorStore构造方法里那个1024是向量维度。这个数字必须与 Embedding 模型输出维度严格一致,否则入库或检索会直接报维度不匹配。不同 Embedding 模型的维度差别很大,用智谱的 embedding-3 和用阿里百炼的 text-embedding-v3,维度不一样,切换模型前必须先建新表或改字段。

4.2 检索链路:TopK、相似度阈值与相关性重排

入库之后是检索。RAG 项目里经常有个错觉:向量检索结果一定相关。实际完全不是这样,向量的相似度排序和人类理解的"相关"差距很大,所以检索链路要做三件事:召回、过滤、重排。

// 检索参数:topK 召回 10 条,相似度阈值 0.35 SearchRequest request = SearchRequest.builder() .query(question) .topK(10) .similarityThreshold(0.35) .build(); List<Document> hits = vectorStore.similaritySearch(request);

topK是召回数量,不是最终结果数量。10 是一个稳妥的起步值,召回太少容易漏,太多则噪声变大,后续重排阶段会再把质量差的排下去。similarityThreshold是过滤阈值,pgvector 的 cosine 距离在 0 到 2 之间,值越小越相近。0.35 是个经验起步点,如果你的检索结果总是空,先看是不是阈值设太高;反过来,如果结果一堆明显不相关的内容,就把阈值往上提到 0.5 左右。

如果对结果还不满意,就做重排。重排有两种落地方式:一是调独立的 rerank 模型,把召回文本逐条打分重新排序;二是规则重排——把用户问题里的关键词提取出来,对召回结果做关键词命中加权。规则重排不需要额外模型,成本低,在英文文档上效果一般,但中文技术文档里关键词命中非常有效,因为专业术语的区分度很高。

顺着这个链路往深走,就是现在讨论比较多的 Agentic RAG——不是一次检索完事,而是让 Agent 判断当前召回结果够不够回答,不够就改写查询再搜一轮。项目里这套能力是可以落地的,在 Agent 技能里加一个search_docs工具,让模型自主决定是否调用。它的收益在复杂问题上很明显,但要注意控制循环次数,不然一次提问可能引发四五轮检索,延迟和成本都是问题。

4.3 用 Hit Rate 和 MRR 量化 RAG 效果

RAG 最玄学的部分就是"感觉效果变好了"——感觉不靠谱。我建议项目里建一个固定的小测试集,50 个左右的问题,每个问题标注标准答案所在的文档 ID。每次改检索参数、换分块策略或换 Embedding 模型后,跑一遍测试集看两个指标:

指标计算方式看什么
Hit Rate正确答案出现在检索结果中的比例召回有没有漏
MRR正确答案在结果中的排名倒数取平均排得够不够靠前

比如 50 个问题里,40 个的正确答案出现在 Top 10 召回内,Hit Rate 就是 0.8。正确答案平均排在第 2 位,MRR 就是约 0.5。改进的方向就可以量化了:Hit Rate 低,优先改分块和重排;MRR 低且 Hit Rate 高,优先调 topK 和阈值。这套评估跑起来以后,RAG 参数调整就不是玄学,而是有数据反馈的迭代。

5. 避坑手册:Spring AI + RAG 最常见的 5 个翻车现场

5.1 技能注解不生效:Agent 不调用 @Tool 方法

现象:代码里写好了@Tool注解的方法,Agent 的对话里也能看到工具描述,但模型就是死活不调用,或者调用时报参数解析错误。

原因:最常见的是工具方法注入了容器,但 Agent 构建时没有把ToolCallback列表传进去,Spring AI 只有在ToolCallback出现在 Prompt 的toolCallbacks里时才会把工具描述发给模型。其次是工具方法的参数类型太复杂,用了自定义对象且没有合理的 JSON Schema 映射。

解决:把技能类实例传给 Agent 构造器,并确认方法参数只用基础类型、String、或简单record,复杂对象在模型侧会生成一堆无法解析的 JSON 字段,直接导致调用失败。

5.2 多模型配置后启动失败:NoUniqueBeanDefinitionException

现象:配置里加完第二个模型供应商,应用启动直接报expected single matching bean but found 2。

原因:Spring AI 自动配置把两个供应商都注册成了ChatModelBean,而项目里至少有一处注入ChatModel没有指定@Qualifier。

解决:动手前先全局搜索ChatModel注入点,统一改成经过 Router 调度的方式。不要在某个业务 Service 里单独注入@Qualifier("zhipuAiChatModel")——短时间内能跑,侧后面加第三个模型时又是一轮全局改动。我现在的习惯是所有业务代码只依赖RouterChatModel这个门面。

5.3 向量检索结果为空或乱:维度、距离类型、阈值

现象:文档成功入库,但检索时要么什么也查不到,要么返回一堆完全不相关内容。

原因:三个方向排查。第一,Embedding 模型维度与库表维度不一致,这个问题通常在入库时报错,但也有静默失败的数据库实现;第二,距离类型设置错误,比如用EUCLIDEAN_DISTANCE却按 cosine 的阈值标准过滤;第三,similarityThreshold设得过高,把本应该召回的结果全过滤了。

解决:先确认维度,再确认distance-type与SearchRequest的阈值量纲一致,最后把阈值降到 0.2 试一次——如果降阈值后能召回,说明是阈值问题;还不行就检查入库时有没有执行 Embedding。排查 RAG 问题时,先把链路拆成"入库→检索→生成"三段,逐段验证,效率最高。

5.4 会话记忆越聊越乱:短期窗口与长期召回互相矛盾

现象:同一个用户连续问同一个话题,Agent 的表现在第三轮开始明显退化;或者长期记忆召回了三个月前的内容,和当前对话上下文冲突,回答里出现自相矛盾。

原因:短期记忆的MessageWindow没有按会话隔离,多个会话共用了同一个ChatMemory实例;或者长期记忆召回时没有携带会话 ID 过滤条件,把其他用户的记录也召回了。

解决:确保每个会话创建独立的MessageWindow,用conversationId做维度;长期记忆的向量检索条件里加入 session 过滤字段。我一般会在ConversationRecord里打上userId和conversationId两个标签,检索时在SearchRequest的 filter 里同时限定,这个过滤步骤不能省。

5.5 Spring Boot 4 与 Spring AI 版本依赖错位

现象:项目一启动就报NoSuchMethodError或类定义找不到,明显是三个不同模块用了同一个类的不同版本。

原因:Spring AI 不同 minor 版本对 Spring Boot 4 的适配进度不一样,有些三方依赖又间接引了旧版 spring-ai。

解决:用 BOM(Bill of Materials)统一管理版本,不要在每个模块单独写 spring-ai 版本。我把spring-ai-bom和spring-boot-dependencies都在父 POM 的dependencyManagement里声明,子模块只写 groupId 和 artifactId。版本冲突排查时,跑一遍mvn dependency:tree,看哪些 jar 被重复引入,再在 BOM 覆盖。

6. 进阶玩法:自定义多 Agent 路由与技能编排的两种实现

6.1 意图路由:把用户请求分给最合适的 Agent

多 Agent 平台不是说把所有 Agent 暴露给前端让用户选,而是让系统判断请求该交给谁。我在项目里的做法是定义一个 Router Agent,它的唯一职责是从候选 Agent 列表里选目标:

String routePrompt = """ 根据用户问题,从以下 Agent 中选择最合适的一个: 1. sql-agent:处理数据查询、报表类问题 2. rag-agent:处理文档知识库问答 3. chat-agent:日常闲聊、开放对话 只返回 agent 名称,不要解释。 用户问题:%s """.formatted(userMessage);

这个方案简单直接,模型的选择准确率与候选 Agent 的系统提示词描述质量强相关。如果你的 Agent 描述写得模糊,路由结果会随机跳动。另一个方向是用向量匹配做路由——把每个 Agent 的典型问题样例向量化,用户问题来了先做向量相似度匹配,命中率达不到再用模型判断。

6.2 技能编排:把查询能力封装成 Tool 链

技能编排的落地方式是让业务能力变成模型可调用的工具。我给项目加过一个 NL2SQL 技能:Agent 收到自然语言问题后,先生成 SQL,再执行查询,最后把结果组织成回答。这里有两个关键设计——SQL 生成用专用模型或专用提示词模板,避免把数据库结构暴露给主模型;执行结果必须做长度限制,不然一次查询返回上千行,上下文瞬间被撑爆。

从那以后,我每次接入新的模型供应商或改 RAG 参数,都强制自己走一遍完整流程:先看配置清单再启动,启动了先测连通性再测业务,改完参数跑一遍测试集看 Hit Rate 和 MRR。这个习惯帮我少走了很多弯路,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询