☰
Spring Boot 4 + Spring AI 实战:多模型接入、RAG 与 Agent 编排
2026/10/2 14:09:41 网站建设 项目流程

简介:Snail AI 定位为企业级 AI 智能体平台,基于 Spring Boot 4 与 Spring AI 构建,面向需要统一接入多模型、编排智能体流程、搭建 RAG 知识库、管理长期记忆和技能插件的 Java 技术团队,尤其适合在企业内部落地智能客服、知识问答、自动化助手等场景。压缩包共 672 个文件,整体仅 2.26MB,以 554 个 Java 源码文件为主体,承担后端核心逻辑;49 个 JavaScript 与 12 个 CSS 组成后台管理界面的前端资源;39 个 XML 配置用于应用装配与配置文件,另含 3 个 imports 导入描述、SQL 数据库脚本、Dockerfile 容器部署文件、proto 接口定义文件等,结构清晰、轻量易读,可作为企业级 Agent 平台的二次开发基线。目前已有 25 人浏览学习,适合具备 Spring Boot 基础、希望深入 Agent/RAG 实践或研究智能体平台架构的中高级开发者。资源涵盖多模型管理、智能体编排、RAG 检索、记忆持久化等功能的后端实现,并配套后台管理界面与 OpenAPI 接口,便于直接切换大模型、维护向量知识库与自定义技能;借助 Dockerfile 可快速搭建本地运行环境,减少从零集成的重复工作,也能帮助团队更快理解现代 Java AI 应用的工程组织,适合私有化部署与深度定制场景。

1. 从“能跑”到“能落”:Spring Boot 4 + Spring AI 这套平台到底解决了什么

先说一个反直觉的结论:现在很多团队做 AI 应用,卡住的不是模型能力,而是“模型接入”和“上下文管理”这两件脏活。你拿 ChatGPT 写个 demo 很容易,但一旦要接企业知识库、要多个 Agent 协作、要会话里有记忆、要按技能编排调用链,就会发现 prompt 拼来拼去、向量库换来换去、线程池被 LLM 调用堵死——这些事每个项目都在重复造轮子。Spring Boot 4 + Spring AI 这个组合,本质就是把“多模型接入、RAG、记忆、技能编排、Agent 协作”这些 AI 应用里的高频能力,收敛成一套可以开箱即用的平台底座。它不是一个前端 demo 项目,而是一个让后端团队可以快速把 AI 能力嵌进现有 Spring Boot 服务体系里的中间件层。

这篇文章我会站在一线开发的视角,把这套平台的架构拆开,讲清楚 Spring Boot 4 和 Spring AI 各自的角色、多模型接入怎么配、RAG 和向量检索落地时那些坑、Agent 编排用哪种模型设计、以及线上调度和扩缩容怎么处理。所有配置和代码都是可抄作业的,但我也会明确说哪些参数是默认值就够,哪些必须根据你的业务调,免得你照着抄完发现线上翻车。适合的读者是:已经有 Spring Boot 基础、想在项目里引入 AI 能力的后端开发;以及被 LangChain 那套 Python 生态搞烦了、想在 JVM 体系里统一 AI 能力的团队。

2. Spring Boot 4 与 Spring AI 的选型逻辑:为什么是它们俩组合

2.1 Spring Boot 4 带来了什么,不只是一个版本号

Spring Boot 4 不是一个简单的数字升级。它基于 Spring Framework 7,底层把 Jakarta EE 的基线标到了 Servlet 6.1,这意味着你在用 Boot 4 时,容器环境、内嵌 Tomcat、依赖管理都跟 Boot 3 有本质差别。很多团队升级踩坑,第一刀就切在 javax 到 jakarta 的包名迁移上,但 Spring Boot 4 直接把这个问题写进了基线,新的代码不需要再担心老包名。

从应用角度,Spring Boot 4 最值得关注的是它对 GraalVM 原生镜像的支持进一步成熟,配合 Spring AI 的场景,可以做到很低的启动延迟和内存占用。AI 应用里 LLM 调用是 IO 密集型,但 Agent 编排和技能编排往往是 CPU 密集型,这两类负载混在一个应用里,原生镜像的启动时间从十几秒压到几百毫秒,对弹性扩缩容非常友好。如果你用的是 JDK 17,建议直接上 21,Spring Boot 4 对 21 的支持是完整且经过充分测试的。

另外,Spring Boot 4 的自动配置机制更强调条件化装配,Spring AI 正好利用了这一点。你引入某个模型 Starter,它的自动配置类只有在对应的 API Key 配置存在时才生效,不会像以前那样默认加载一堆用不到的 Bean。这就让多模型接入的配置面变得非常干净。

2.2 Spring AI 在 JVM 生态里的定位:不是 LangChain 的替代,是另一种解法

Spring AI 的官方定位是给 Spring 生态提供 AI 应用抽象层,包括ChatModel、EmbeddingModel、VectorStore、Memory这些核心接口。它跟 LangChain 最大的区别是:LangChain 是一套独立的框架,有自己的链和代理抽象;Spring AI 是建立在 Spring 的依赖注入和自动配置之上的,它的可观测性、事务管理、重试机制天然跟 Spring 体系融合。

选择 Spring AI 而不是 LangChain4j,核心理由有两条:第一,Spring AI 是 Spring 官方孵化的项目,后续跟 Spring Boot 4 的版本兼容性有保障,不用自己处理第三方库和 Boot 版本之间的微妙冲突;第二,Spring AI 的Advisor机制比 LangChain4j 的过滤器更贴近 Spring 开发者的习惯,你可以在调用链路上插入鉴权、日志、RAG 增强、记忆管理,像写 Spring AOP 一样自然。

不过要清醒一点:Spring AI 的生态成熟度还比不上 LangChain,尤其是 Agent 部分,很多高级编排能力还在快速迭代中。我的做法是:把 Spring AI 当作模型接入和 RAG 的稳定底座,Agent 编排层用自己写的协调器包一层,这样既拿到官方支持,又不会被框架的未稳定 API 绑架。

2.3 平台的整体模块划分:接入层、编排层、存储层

这套平台按职责可以拆成三层。接入层负责统一所有模型提供方的 API 差异,包括 OpenAI、Azure OpenAI、智谱、通义、Ollama 本地模型等,对外暴露统一接口,让上层不感知具体供应商。编排层负责 RAG 管道的组装、Agent 的规划与执行、技能调用的路由,这是整个平台的核心。存储层负责向量索引、会话记忆、技能定义和权限策略的持久化。

模块划分上有两个关键决策。第一,向量库和业务库分开,不要为了省事把向量塞进 PostgreSQL 的 JSON 字段里,除非你的数据量在十万条以下且对召回精度不敏感。第二,Agent 执行引擎和 Web 服务模块物理隔离,因为 Agent 的循环调用会长时间占用工作线程,如果把 Tomcat 线程池和 Agent 执行线程混在一起,并发一高就会出现线程饥饿,普通请求被 Agent 任务堵死。

3. 多模型接入的最小配置:从 OpenAI 到本地 Ollama 的完整落地

3.1 引入依赖与起步配置:一个可抄作业的最小工程

不管接哪个模型,起步步骤是一样的:引入spring-ai-starter和你需要的具体模型 Starter。以智谱和 Ollama 为例,下面是pom.xml里的核心依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-zhipu</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency>

这里注意,Spring AI 的 GroupId 从早期版本开始就是org.springframework.ai,但具体模块的命名在不同版本里变过几次,尤其是spring-ai-starter-model-*这种命名是从 1.0 之后才稳定下来的。如果你们的仓库管理严格,建议用 BOM 统一管理版本,避免不同模块版本不一致导致的 NoSuchMethodError。

然后是配置文件,用application.yml管理多模型的关键点在于:每个模型的base-url和api-key要独立配置,同时要为每个模型定义一个业务别名,后续代码里通过别名注入,不直接依赖具体实现类。

spring: ai: zhipu: base-url: https://open.bigmodel.cn/api/paas/v4 api-key: ${ZHIPU_API_KEY} chat: options: model: glm-4-plus temperature: 0.7 ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b temperature: 0.3

配置完这两段,Spring AI 的自动配置会在容器里创建两个ChatModelBean,Bean 名称默认是zhipuChatModel和ollamaChatModel。如果你想要自己的别名,可以用@Qualifier或者在配置类里重新包装。这里有个很容易忽视的点:temperature的默认值在不同模型上表现差异很大,智谱的 GLM 系列对temperature的敏感度比 OpenAI 高,实测同样的 0.7 在 GLM 上回答会更跳跃,所以业务场景不同要单独调,不能用一套参数套所有模型。

3.2 统一调用接口与多模型 Router 的实现

当你接了三五个模型之后,业务上最头痛的是每个模型返回格式微有差异,比如有的模型返回content字段,有的返回text字段,有的会带reasoning内容。Spring AI 的ChatModel.call()已经统一了返回类型为ChatResponse,但实际开发中你需要做一层自己的AiRouter,按业务路由到不同模型,同时记录延迟和 token 消耗。

这里我直接给出一个可用的多模型 Router 骨架:

@Service public class AiRouter { private final ChatModel zhipuChatModel; private final ChatModel ollamaChatModel; private final MeterRegistry meterRegistry; public AiRouter(@Qualifier("zhipuChatModel") ChatModel zhipuChatModel, @Qualifier("ollamaChatModel") ChatModel ollamaChatModel, MeterRegistry meterRegistry) { this.zhipuChatModel = zhipuChatModel; this.ollamaChatModel = ollamaChatModel; this.meterRegistry = meterRegistry; } public ChatResponse route(String modelAlias, String prompt) { long start = System.currentTimeMillis(); ChatModel target = resolve(modelAlias); ChatResponse response = target.call(new Prompt(prompt)); meterRegistry.timer("ai.call.duration", "model", modelAlias) .record(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS); return response; } private ChatModel resolve(String alias) { return switch (alias) { case "zhipu" -> zhipuChatModel; case "ollama" -> ollamaChatModel; default -> zhipuChatModel; }; } }

这段代码有几个值得注意的设计意图。第一,通过@Qualifier注入具体 Bean,绕开了 Spring AI 自动装配时可能出现的多个ChatModel冲突问题;如果你不写@Qualifier,Spring 会因为同时存在多个ChatModel类型 Bean 而启动失败。第二,用 Micrometer 的MeterRegistry记录每次调用的耗时,这是后续做模型降级和成本分析的基础数据,别等到线上模型超时了才开始埋点。

Router 做好了之后,业务代码里调用aiRouter.route("zhipu", userPrompt)就行。这个时候你还可以顺手加一个简单的熔断逻辑:连续超时超过 3 次就切到备用模型,实现非常简单,用CircuitBreaker注解包一层即可。

3.3 模型提供的稳定性与降级策略:不能把命交给单家厂商

AI 应用的线上事故里,模型厂商接口抖动占了很大比例。平台层面要做的是多模型自动降级,而不是等客服电话。

我的做法是定义一个ModelChaosExecutor,在调用失败时按优先级切换:

@Component public class ModelFailoverExecutor { private final List<ModelRoute> routes = List.of( new ModelRoute("zhipu", 0), new ModelRoute("ollama", 1) ); public ChatResponse executeWithFailover(String prompt) { for (ModelRoute route : routes) { try { return route.chatModel().call(new Prompt(prompt)); } catch (Exception e) { log.warn("model {} failed, switching to next, error={}", route.name(), e.getMessage()); } } throw new IllegalStateException("all models unavailable"); } }

这里有个血泪教训:降级策略的关键是「超时也要兜底」,很多模型 SDK 内部没有默认读超时,或者默认超时长达 120 秒。你必须在全局配置里把连接超时和读超时压下来,通常连接 3 秒、读 30 秒是合理值。否则一旦上游抖动,你的 Agent 线程会被挂住几分钟。

超时配置在 Spring AI 里可以通过RestClient的配置覆盖,但最省心的做法是在接入层统一设置一个ResponseErrorHandler,把非 2xx 响应快速转换成异常,避免 SDK 的默认行为把错误体包装成通用异常丢失信息。

3.4 本地模型与云端模型混合部署的实操清单

很多团队会在开发环境用 Ollama 跑本地模型,生产环境切云端 API,这套平台的配置方式天然支持这种混合部署。本地模型的优势是隐私和数据不出内网,劣势是显卡资源有限,并发能力弱。混合部署的最佳实践是:低并发内部工具用本地模型,高并发用户请求走云端;把本地模型当作降级后备,而不是主力。

Ollama 接入要特别注意base-url必须指向http://localhost:11434,而不是https://ollama.com,很多人第一次配错就是去查 ollama.com 的 API 地址。另外 Ollama 拉取的模型名称要跟 Spring AI 里的model配置完全一致,比如qwen2.5:7b和qwen2.5:7b-instruct-q4_K_M是两个不同的模型串,配错会导致 404。

4. RAG 落地的核心链路:向量检索与知识库增强不能只调一个 API

4.1 RAG 的最小工作流:从文档加载到回答生成的五个环节

RAG 不是简单地把用户问题发给模型再拼接一段知识,它是一条完整的数据管道。最少可用链路需要五个环节:文档解析与加载、文本切分、向量化、向量检索入库、带上下文的生成。每个环节都有独立的配置和调优空间。

我一般会把 RAG 分为离线管道和在线管道。离线管道负责把新文档处理成向量并入库,在线管道只负责从向量库召回内容再走模型生成。这样做的好处是:离线任务失败不影响线上回答质量,文档更新可以走定时任务批量处理。

Spring AI 里文档加载用DocumentReader,切分用DocumentSplitter,向量化用EmbeddingModel,存储用VectorStore。下面是一段典型的离线管道代码:

@Service public class RAGIngestionService { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public RAGIngestionService(VectorStore vectorStore, EmbeddingModel embeddingModel) { this.vectorStore = vectorStore; this.embeddingModel = embeddingModel; } public void ingest(String filePath) { // 1. 读取文档 List<Document> documents = new PagePdfDocumentReader(filePath).get(); // 2. 按 512 字符切块,重叠 64 字符 TokenTextSplitter splitter = new TokenTextSplitter(512, 64); List<Document> chunks = splitter.apply(documents); // 3. 向量化并入库 vectorStore.add(chunks); } }

4.2 切分策略的参数调优:为什么固定字符数不是好选择

TokenTextSplitter的构造参数是块大小和重叠大小,直接决定了召回质量。切分太小,语义不完整,检索召回一堆残句;切分太大,模型上下文膨胀,超出 context window,反而降低了后续生成的精确度。

这块没有标准答案,但有几个经验参数可以参考:中文场景建议块大小 300~500 字,重叠 30~80 字;代码类文档建议按方法或类切分,而不是按固定字符切,否则一个类被切得四分五裂;表格类文档建议整表保留,不切开。

如果你用的是 PDF 或 Word 的复杂排版,还要处理表格和图片。常见做法是用规则检测到表格区域后单独提取,做成 Markdown 表格再切分,而不是把 PDF 解析出的纯文本直接喂给切分器。遇到混合排版页面,宁可这个页面整页入库,也不要让切分器把文本流切断在一个无意义的位置。

这套平台的默认切分器是基于 token 数切分的,但 token 数和字符数在中文场景下不是等价的,一个中文字符在 Llama 系分词器里可能占 0.6~1.5 个 token,所以你需要对你的语料做一次抽样统计,跑一遍实际切分结果,看 chunk 语义是否完整。不要相信默认参数直接上线,这个环节是 RAG 项目翻车的高发地。

4.3 向量库选型与数据库连接配置:PostgreSQL 还是专用向量库

向量检索需要一个 connector,Spring AI 支持多种VectorStore实现,包括 PGVector、Milvus、Qdrant、Chroma、Redis 等。选型时可以从查询延迟、过滤能力、部署运维成本三个维度权衡。小团队且已有 PostgreSQL 实例,直接选 PGVector 最省事;数据量大且需要复杂过滤,Milvus 或 Qdrant 更专业。

这里给出一张选型对比表:

向量库适合数据量过滤器支持运维成本延迟表现
PGVector百万级以下较好低一般
Qdrant千万级优秀中优秀
Milvus十亿级优秀高优秀
Redis十万级一般低优秀

我个人的建议是:没有作死需求就别一开始上 Milvus,PGVector 先扛住上线,等数据量真的涨到百万级再迁移。迁移的代价主要是重新向量化一次所有文档,而这本身也是离线管道的一次重跑,成本可控。

如果你的平台已经定义好了VectorStore接口,切换数据库只需要改配置和依赖,业务代码几乎不动。比如从 PGVector 切到 Qdrant,只需要替换 starter 依赖和修改连接参数:

spring: ai: vectorstore: qdrant: host: localhost port: 6333 collection-name: ai_platform_docs

但注意,切换向量库时,Embedding 模型尽量保持一致,否则同一个文档在不同向量库里的向量空间完全不兼容,检索会变成瞎撞。向量库切换必须连同全量重建一起做,这是切库的默认前提。

4.4 Agentic RAG 与普通 RAG 的差异:该不该让模型决定查几次

如果你的平台目标用户是需要复杂问题分解的场景,那要区分普通 RAG 和 Agentic RAG。普通 RAG 是固定的一路检索加生成;Agentic RAG 是让模型自己决定检索几次、每次检索什么关键词、是否需要调整检索策略。后者效果上限高,但失败模式和成本也更难控制。

以“对比上季度和本季度的销售数据”这种问题为例,普通 RAG 会把问题整体向量化,检索一堆混合内容,生成时可能只从中挑一段答,信息不完整。Agentic RAG 会把问题拆成两个子查询“上季度销售总额”“本季度销售总额”,每个子查询独立走检索,再把两个结果拼在一起让模型汇总。这样的回答质量通常明显提升。

Spring AI 里的Advisor机制可以帮你实现最基础的 Agentic RAG 增强,通过自定义 Advisor 在每次 LLM 调用前动态拼接检索结果。但更复杂的多跳检索,一般还是要靠 Agent 框架来实现,这个我在第 5 章展开。

4.5 知识库更新与同名向量冲突:增量入库的三个注意点

知识库不是一次性建好就结束的。当你更新某个文档,旧 chunk 还留在向量库里,新旧内容重复,检索召回会出现语义污染。平台需要注意以下几点:

第一,文档级别维护一个 version,入库时带上自定义 metadata 标记 source 和 version;检索时优先按 version 过滤。Spring AI 的Filter机制支持这种 metadata 过滤,比如vectorStore.similaritySearch(SearchRequest.query(...).withFilterExpression("source == 'contract_2024.pdf'"))。

第二,删除旧版本时要通过 source 定位所有相关 chunk,而不是按 chunk id 删。因为切分器每次运行产生的 chunk id 可能随机变化,按 chunk id 删容易漏。

第三,如果文档更新很频繁,建议用异步任务在低峰期重建索引,而不是在线调用里同步更新。同步更新会阻塞请求线程,而且 embedding 调用可能要花好几秒,用户等不起。

5. 多 Agent 与技能编排:构建可协作的 Agent 工作流

5.1 Agent 和 Skill 的边界:别让 Agent 变成一个巨型 if-else

技能编排是这套平台最容易被误解的部分。很多人把 Agent 理解为“一个能自主决策的大模型”,但实际落地时你会发现,单 Agent 做不了复杂任务,多 Agent 协作又有通信和状态同步的成本。更可行的方案是「一个 Supervisor Agent + 多个 Skill Worker」,Supervisor 负责理解意图、拆分任务、调度工人,Skill Worker 每个只做一件具体的事,比如“翻译”“摘要”“代码生成”“数据库查询”。

这个设计能落地的关键,是要在代码层面明确Skill的接口。每个 Skill 必须有独立的名称、描述、输入输出 schema,Supervisor 通过 LLM 能力选择调用哪个 Skill。这个 schema 很关键,LLM 的 function calling 依赖准确的参数描述,schema 写得含糊,模型就会传错参数。

一个常见的反模式是把五六个工具函数拼进一个大 Prompt,让模型自己选。短期内可用,但一旦工具数量超过十个,模型选择准确率暴跌,同时还容易互相干扰。更科学的是用 Spring AI 的@Tool注解定义技能,利用框架自动生成 function calling schema,再交给 Agent 调度。

下面是定义技能的最小示例:

@Component public class SearchSkills { @Tool(description = "根据关键词搜索企业内部知识库,返回最相关的文档片段") public String searchKnowledge(String keyword, int topK) { // 调用 vectorStore 查询 return "检索结果: " + keyword; } @Tool(description = "将文本翻译成目标语言,languageCode 取值为 zh/en/fr") public String translate(String text, String languageCode) { // 调用翻译模型的接口 return text; } }

5.2 用 Spring AI 构建 Supervisor Agent:loop 的终止条件与 Token 控制

Spring AI 里实现 Agent loop 不是开箱即用的,官方给了ChatClient的结构化输出和工具调用能力,但循环控制需要自己写。我一般会做一个AgentCoordinator,核心是一个 while 循环,每轮让模型决定下一步动作,直到模型输出 final answer 或达到最大轮数。

@Service public class AgentCoordinator { private final ChatClient chatClient; public AgentCoordinator(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个任务规划助手,必须严格按技能列表选择工具,不得随意编造。") .build(); } public String execute(String userTask) { int maxSteps = 5; Message currentMessage = new UserMessage(userTask); for (int i = 0; i < maxSteps; i++) { ChatResponse response = chatClient.call(new Prompt(List.of( currentMessage, new ToolCallMessage-Agent... ))); // 解析 response 里的 tool call // 执行工具 // 把工具结果追加为新的消息 // 判断是否生成 final answer } throw new IllegalStateException("agent loop exceeded max steps"); } }

不要把上面的伪代码当作可以直接运行的东西,但这里的关键点是:

  • 每一轮的 history 必须包含上一轮的工具结果,否则模型失去上下文,会重复调用同一个工具。
  • 必须设置maxSteps,否则模型可能在复杂问题上无限循环,token 费用快速爆炸。5 步是一个保守值,复杂任务可以提至 8,但不要超过 10。
  • 每轮的 prompt 里都要带上全部技能清单和它们的描述,这是 function calling 模式的要求,但也意味着 token 开销会累积。平台层面最好把技能描述缓存起来,避免每次构造 Prompt 都拼接一遍。

5.3 多 Agent 协作时的上下文传递与记忆共享

多 Agent 之间不能各自为政,否则协作就退化成“把一堆模型输出硬拼在一块”。最简单可靠的模式是:所有 Worker 都共享同一个ConversationMemory,Supervisor 在派发任务时把相关记忆片段一并传给 Worker。这里注意,不是共享全部原始会话,而是共享「经过摘要或抽取的上下文」。

平台的记忆管理要分层:短期记忆存当前会话的最近 N 轮原始消息;长期记忆存跨会话的用户偏好和任务结论;工作记忆存当前 Agent 执行过程中的中间产物。三层分开存储,避免互相污染。

我常用的实现是 Redis 里存短期记忆,键名是sessionId:recent,值为 JSON 数组,过期时间设成会话空闲超时。长期记忆存 PostgreSQL,用 metadata 标记用户 ID、标签、时间,检索时按用户过滤。Spring AI 的MessageMemory接口提供了基础能力,但它的默认实现是内存存储,多实例部署下会丢失记忆,必须封装一层 Redis 实现替换。

技能编排的另一个坑是技能调用的鉴权。不要把数据库查询技能暴露给所有普通用户,必须在 Agent 层加权限过滤。Supervisor 在派发技能前检查当前用户的角色组,如果没有该技能权限,直接拒绝并告知用户,而不是把权限校验逻辑写在技能内部,否则绕过 Agent 直接调技能接口就泄露权限。

5.4 Agent 的失败恢复:如何优雅处理工具异常与模型幻觉

Agent 执行过程中,工具异常是常态而不是异常。比如搜索技能连不上向量库、翻译技能超时、数据库查询返回空。处理策略是:工具异常要反馈给模型,让模型判断是否换一种方式重试,而不是直接终止整个 Agent。一个典型的错误处理循环长这样:

try { Object result = executeSkill(skillName, args); return result; } catch (Exception e) { return "工具执行失败,错误信息: " + e.getMessage() + "。请尝试换一个技能或改变参数重试。"; }

这个返回值会作为 tool response 回到模型那里,模型可能会说“那我试试关键词模糊搜索”。这种自我纠正能力是 Agent 有价值的体现。但要注意,不能无限重试,否则一个坏工具会把 Agent 拖进死循环。常见的做法是记一个toolErrorCount,超过 2 次就强制切给人工兜底。

模型幻觉在 Agent 里表现得特别明显:模型可能声称执行了某个技能并返回了一个虚构结果,但实际上根本没调用工具。缓解办法有两个:一是在 Prompt 里强调“只能使用工具结果回答,不得自行编造”;二是用输出约束,让模型返回结构化结果,其中必须包含source_tool字段,平台层面校验该字段是否真实存在。这个方法虽然不能 100% 消幻觉,但能堵住最恶劣的例子。

6. 线上部署要避开的 6 个常见坑:从线程配置到向量库连接

6.1 线程池被 LLM 调用占满:默认 Tomcat 线程池不适合 AI 场景

这是线上最容易翻车的地方。普通 web 请求处理很快,Tomcat 默认 200 线程足够。但 Agent 的循环调用一次可能耗时 30 秒以上,如果 50 个用户同时发起 Agent 任务,线程池直接撑爆,其他所有请求排队。

解决思路是把 LLM 调用统一放到独立的ExecutorService中,用 CompletableFuture 异步编排,不让阻塞的模型调用直接占用 Tomcat 工作线程。平台里要做的是定义一个专门的AiExecutor,可控并发、可排队、可拒绝:

@Bean("aiTaskExecutor") public ExecutorService aiTaskExecutor() { return new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadFactoryBuilder().setNameFormat("ai-worker-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); }

参数怎么定?核心线程数建议为 CPU 核心数乘以 2,最大线程数不超过 16,队列长度 100。如果并发任务超过队列容量,拒绝策略用CallerRunsPolicy会让调用线程亲自执行任务,虽然违背了异步原则,但至少在负载极高时降级为同步,不会丢失任务。另一个方案是用AbortPolicy并返回 429,但这对用户不友好。

6.2 向量检索耗时异常:默认精确检索在高数据量下不可用

PGVector 的默认索引是 IVFFlat 还是 HNSW,直接决定大数据量下的检索速度。Spring AI 的 PGVector Starter 默认创建索引时用的是 ivfflat 还是 hnsw,不同版本不一样。一段常见翻车现场是:数据量从几万涨到几十万,检索延迟从几十毫秒涨到好几秒,原因是索引没建或者索引参数不合适。

正确做法是建 HNSW 索引,并设置合适的m和ef_construction参数:

CREATE INDEX ON vector_store USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m控制每个节点的最大连接数,越大精度越高但内存占用越大;ef_construction控制构建索引时的搜索范围,越大索引质量越好但构建时间越长。m=16, ef_construction=64是精度和资源的均衡值。另外,检索时也要指定ef_search,Spring AI 的 PGVector 支持通过请求参数传入,如果没传,默认值可能很低,影响召回质量。

6.3 Embedding 模型不一致导致的召回率丧失:换库必须全量重建

这个坑特别隐蔽。你在开发时用 OpenAI 的text-embedding-3-small向量化了一批文档,后来因为成本改用智谱的 embedding,结果发现原来能召回的内容全都召不回了。原因很简单:不同模型的向量空间不同,余弦相似度没有可比性。换 embedding 模型等于切换了整个向量空间,旧向量全部作废。

所以平台上线前就要定好 embedding 模型,尽量不要中途更换。真必须换时,全量文档重新向量化是唯一解法,没有后悔药。在线管道和离线管道里要保持 embedding 模型的配置一致,不要一个用 A 模型一个用 B 模型。

6.4 会话记忆在分布式环境下的失效:本地内存不是记忆

Spring AI 默认的InMemoryChatMemory只在单实例内有效。如果你部署了两个副本,用户第一次请求打到实例 A,记忆存在 A;第二次请求负载均衡到实例 B,模型完全没上下文,用户感觉“这 AI 是不是失忆了”。

平台里必须把记忆存储外置到 Redis,以下是使用 Spring AIChatMemory接口自定义 Redis 实现的思路:

@Component public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; @Override public List<Message> get(String conversationId, int lastN) { ListOperations<String, String> ops = redisTemplate.opsForList(); List<String> jsonList = ops.range("chat:" + conversationId, -lastN, -1); // 反序列化成 Message 列表 } @Override public void add(String conversationId, Message message) { redisTemplate.opsForList().rightPush("chat:" + conversationId, serialize(message)); redisTemplate.expire("chat:" + conversationId, Duration.ofHours(24)); } }

这样每个实例都从 Redis 读记忆,彻底解决分布式失效。这里有个次要问题:Redis 列表按时间顺序存取,如果用户消息和辅助消息交替出现,反序列化时要保留messageType字段,否则模型分不清哪些是用户说的、哪些是工具返回的,推理会混乱。

6.5 Agent 技能列表过长的 Token 压力:动态裁剪技能描述

当技能数量增长到二三十个,每个技能的 schema 描述都很长,一轮 function calling 的 prompt 可能就有好几千 token,成本飙升,且模型对技能的注意力和选择准确率下降。平台需要按用户意图动态裁剪技能列表。

常见的做法是先让模型对用户问题做一次轻量意图分类,再根据意图只注入相关技能子集。比如用户问“翻译一段合同”,就只注入翻译技能和文档技能,其他技能全部排除。这个分类调用成本很低,用一个小模型就能做,却可以大幅降低主模型的 token 消耗。

另外,技能描述写得好也能省 token:描述控制在 20 字以内,参数的 description 控制在 10 字以内,格式统一为“动词 + 对象 + 场景”。不要写“用于从知识库中检索与关键词相关的文档片段,并返回前 topK 个结果,适合企业内部搜索场景”,直接写“搜索知识库,返回前 topK 个文档片段”即可。冗长表述除了浪费 token 外,还可能让模型误解触发条件。

6.6 本地大模型的内存不足:Ollama 模型的 OOM 与 swap 抖动

最后这条属于硬件玄学,但也必须说:Ollama 拉取 7B 模型并加载到 GPU 显存,或者 CPU 模式下加载到内存,都要占用极大资源。一个 7B 的量化模型在 CPU 推理时需要至少 8GB 内存,如果平台部署在一台只有 8GB 的云主机上,Ollama 启动后系统就会疯狂 swap,然后所有接口都变慢,包括与 AI 无关的普通接口。

排查方法是看dmesg里有没有 OOM killer 记录,以及free -h确认 swap 是否被大量占用。解决上,要么换更大的机型,要么给 Ollama 设置环境变量限制并发推理数量,比如OLLAMA_NUM_PARALLEL=1让它一次只处理一个请求,避免十几个并发切割内存。开发环境本地跑 7B 模型没问题,但生产环境想用本地模型做主力,请先算清楚物理内存账,不要只看模型文件只有 4GB 就以为 8GB 刚好够。

7. 从可用到好用:验证 RAG 召回质量的三个量化指标与一套评测脚本

RAG 系统上线后,你还需要一个可验证的质量基线。不要问“效果怎么样”,要问你“hit rate 是多少、MRR 是多少、生成幻觉率是多少”。我用三个指标来验证这套平台的核心链路:

  • Hit Rate:在测试集里,针对每个问题预先标注正确答案所属的文档 chunk,检索结果 top K 里是否包含该 chunk。包含则命中。这个指标衡量检索环节的能力。
  • MRR (Mean Reciprocal Rank):对每个问题,看答案第一次出现在检索结果中的位置,取倒数后求平均。它衡量排序质量,比 hit rate 更能反映真实体验。
  • Faithfulness:生成答案中每个关键论点是否能在检索到的文档里找到对应依据。人工或用一个评估模型判断。

下面是一段用于批量评测 hit rate 的最小脚本,跑在独立测试工程里,不干扰线上服务:

import requests import json KB_CHUNKS = [ {"chunk_id": "doc1_3", "text": "本季度销售总额为 2300 万元..."}, {"chunk_id": "doc2_7", "text": "上季度销售总额为 1800 万元..."}, ] def evaluate_hit_rate(questions_with_gold): hit = 0 total = len(questions_with_gold) for q, gold_chunk_id in questions_with_gold: resp = requests.post("http://localhost:8080/api/v1/retrieve", json={"query": q, "top_k": 5}) results = json.loads(resp.text)["chunks"] ids = [item["chunk_id"] for item in results] if gold_chunk_id in ids: hit += 1 return hit / total test_cases = [ ("本季度销售总额是多少?", "doc1_3"), ("上季度销售与对比", "doc2_7"), ] print("Hit Rate @5 =", evaluate_hit_rate(test_cases))

这个脚本只是框架,你需要准备至少 50 组标注好的 QA 对才能得到有统计意义的结果。要持续做回归测试,每次调整切分参数、换 embedding 模型、改检索逻辑后都跑一遍,防止某次优化让召回变差还不自知。

我自己的习惯是每周跑一次评测集,把 hit rate 和 MRR 记录到一个表格里,跟本周改动绑定。如果某个改动让 hit rate 掉超过 3 个百分点,立即回滚对比,而不是继续叠加。RAG 调优的黑匣子效应很强,很多参数看着是优化,实测却是负向,只有长期跟踪这条评测曲线,才不会在优化中迷失方向。

这套平台做到这个阶段,其实你的收获不只是代码,而是一套“接入多模型、管理知识库、编排 Agent、持续观测质量”的完整方法论。把这三个指标当作你团队的质量卡点,所有功能改动都先过一遍评测,能为你省下大量跟业务方扯皮的时间。希望这些踩过的坑和验证方法能帮到你,让你的 Spring Boot 4 + Spring AI 平台真正从能演示的 demo,变成敢于扛线上流量的底座。

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

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

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

立即咨询