玩转 Spring AI 第一篇:一套 RAG + MCP + 实时搜索的工程是怎么拼起来的

发布时间:2026/10/18 5:02:32
玩转 Spring AI 第一篇:一套 RAG + MCP + 实时搜索的工程是怎么拼起来的
玩转 Spring AI 第一篇一套 RAG MCP 实时搜索的工程是怎么拼起来的Java 后端接大模型这两年基本绕不开 Spring AI。但真到你打开一个完整工程的时候会发现难点根本不在怎么调一次 chat而在另一堆问题上向量库放在哪、嵌入模型用什么、实时搜索从哪来、业务方法怎么变成模型能调用的工具、流式输出怎么推给浏览器、这些东西又分别跑在哪个端口上。笔者手上有一份比较完整的工程快照两个 Maven 模块、一个免构建的前端、21 篇讲义把 Spring AI 的 model / vector store / advisors / MCP client / MCP server 五条能力线全用上了。它不是玩具 Demo因为它已经把上下文从哪来这件事拆成了三条独立链路也因为它里面藏了不少真实工程才会有的取舍和瑕疵。这一篇不写代码怎么写先把整栋房子的骨架看清楚模块怎么分、端口怎么连、版本锁在哪、一条请求从浏览器走到模型再走回来要经过哪几站。后面四篇再逐条链路拆。1. 先把地图画出来两个 Java 模块 四个外部进程这个工程最容易被名字误导模块叫mcp-client和mcp-server看起来像MCP 双进程。其实mcp-client才是业务主应用它同时是 ChatClient 的宿主、RAG 的检索方、联网搜索的调用方、SSE 推送方而mcp-server只是一个把业务方法暴露成工具的独立进程。浏览器Vue2 ElementUI axios无构建 │ EventSource │ /sse/connect?userIdxxx POST │ /chat/doChat | /rag/search | /internet/search ▼ ┌───────────────────────── mcp-client :9090 ─────────────────────────┐ │ Controller → Service → ChatClient │ │ │ │ │ │ │ │ │ ├─ ToolCallbackProviderMCP 工具集合│ │ │ │ ├─ MessageChatMemoryAdvisor会话记忆│ │ │ │ └─ stream() → FluxString │ │ │ ├─ RedisVectorStore ──→ redis-stack :9379 │ │ │ ├─ OkHttp ────────────→ searxng :6080 │ │ │ └─ SSEServer静态 SseEmitter 池 │ │ └─ dotenv 读 .env → OPENAI_API_KEY / BASE_URL / MODEL │ └───────────────┬──────────────────────────────┬──────────────────────┘ SSE │ 本地 9060 SSE │ 远程 stdio 子进程 ▼ ▼ mcp-server :9060 ──→ MySQL :5506 高德 MCP / server-filesystem(npx) Tool 方法注册端口是这套拓扑里最容易记错的部分一次性列全全部实读自配置文件非推测进程监听端口容器映射出处mcp-clientdev9090—mcp-client/src/main/resources/application-dev.yml:2mcp-clientprod9999—application-prod.yml:2mcp-serverdev9060—mcp-server/.../application-dev.yml:2mcp-serverprod9999—application-prod.yml:2Redis Stack宿主机9379-p 9379:6379讲义6-2-1:8-11、application.yml:36MySQL宿主机5506-p 5506:3306讲义02:24、mcp-server/application.yml:9SearXNG宿主机6080-p 6080:8080讲义7-2:12、application.yml:45前端静态站5501VSCode Live Server 之类application-dev.yml:4-5的website.domain注意 dev 和 prod 的mcp-client与mcp-server端口不同这意味着生产环境里前端连的是 9090 还是 9999取决于讲义第 10 章那台服务器上到底怎么起的——这一条笔者没有取到一手证据后面凡是涉及生产拓扑的地方都会标注。还有一个必须现在就发现的事实mcp-client的CorsConfig依赖一个只有 dev profile 才提供的占位符。// CorsConfig.java:17Value(${website.domain})privateStringdomain;而application-prod.yml里只有两行——server.port: 9999没有website.domain。也就是说直接把 profile 切到 prod 启动占位符解析这一步就过不去除非用-Dwebsite.domain...之类外部注入但仓库里没有这种配置。这是典型的教学工程按 dev 一路跑通生产配置从没真正落地过的痕迹。2. 版本清单这套工程到底锁了哪些依赖接大模型的工程有个共性麻烦Spring AI 的 starter 名字长、命名在 1.0 前后改过一轮再叠加 MyBatis-Plus 与 Boot 3 的mybatis-spring兼容问题光是把 pom 抄对就要折腾半小时。所以这份清单值得完整抄下来。2.1 构建基座项值出处Spring Boot 父 POM3.5.0spring-boot-starter-parentrelativePath/父pom.xml:23-28Java21maven.compiler.source/target三个 pom 各写一遍父pom.xml:18-20、子模块 pom模块仅mcp-client、mcp-server父工程packagingpom/packaging父pom.xml:8-11,10Spring AI BOMspring-ai-bom1.0.0两个子模块各自importmcp-client/pom.xml、mcp-server/pom.xml的dependencyManagement依赖拉取提示父 pom 注释里建议加阿里云镜像mirrorOf: central父pom.xml:30-38这里要说清一件容易混的事工程锁的 1.0.0 不是当前版本。笔者 2026-09-29 用 GitHub Releases API 核对了一遍Spring AI tag发布时间UTCv1.0.92026-06-12v1.1.82026-06-12v2.0.02026-06-12v2.0.12026-08-21v2.1.0-M12026-09-25也就是说1.0.x线的维护版本已经走到1.0.9而官方主版本号已经进入2.0.x。三个不同线的版本在同一天2026-06-12出现说明官方在并行维护多条版本线。如果你要照这套工程练锁 1.0.0 是最稳的复现路径因为 starter 的 artifactIdspring-ai-starter-model-openai、spring-ai-starter-vector-store-redis这种model-/vector-store-前缀命名就是 1.0 这一代定下来的升到 2.x 之前请先确认配置项前缀和 Advisor 类名在你选的版本里还叫这个。这个提醒不是空话——本工程里就有一个类名/包名对不上的实例讲义9-1里 XML 的resultMap type写com.itzixi.entity.Product而代码实际是com.itzixi.pojo.Product。2.2 mcp-client 的依赖spring-boot-starter-web spring-boot-starter-aop spring-boot-starter-test spring-ai-starter-model-openai spring-ai-advisors-vector-store spring-ai-starter-vector-store-redis spring-ai-starter-model-transformers spring-ai-starter-mcp-client spring-ai-mcp spring-ai-tika-document-reader lombok commons-lang3 3.17.0 groovy-json 4.0.27 hutool-all 5.8.30 dotenv-java 3.2.0 okhttp 4.12.0这张依赖表里藏着一个后面第二篇要重点讲的事实spring-ai-tika-document-reader和spring-ai-advisors-vector-store都进了 pom但代码里一个都没用上——文档读取走的是TextReader检索问答走的是手写的{context}/{question}模板而不是QuestionAnswerAdvisor。依赖引了、能力没用这是教程型工程很常见的提前铺路也是读代码时必须交叉验证的原因。2.3 mcp-server 的依赖依赖版本备注spring-ai-starter-mcp-server-webfluxBOM 管用 WebFlux 版而非 MVC 版MCP 服务端是响应式 SSEcom.mysql:mysql-connector-j8.0.33显式锁版本com.baomidou:mybatis-plus-boot-starter3.5.10.1排除org.mybatis:mybatis-springorg.mybatis:mybatis-spring3.0.4排除后又手工回补spring-boot-starter-mailBOM 管邮件工具用flexmark-all0.64.8Markdown → HTMLMyBatis-Plus 这一对排除 回补的动作值得单独说一句。Boot 3.x 要求mybatis-spring3.x而 MP 3.5.10.1 传递依赖的版本与 Boot 3.5 对不上时标准处置就是把传递依赖排掉、再显式声明一个兼容版本——这不是课程花招是 Boot 3 迁移期真实存在的依赖冲突修法。笔者在自己的项目里遇到过完全一样的形状只是当时是靠报错反推出来的。3. 一个 ChatClient 是怎么被组装出来的这套工程真正Spring AI 味最浓的地方是ChatServiceImpl的构造器。整个类 200 多行但大模型能力全部来自这几行// ChatServiceImpl.java:57-65publicChatServiceImpl(ChatClient.BuilderchatClientBuilder,ToolCallbackProvidertools,ChatMemorychatMemory){this.chatClientchatClientBuilder.defaultToolCallbacks(tools)// ① 全部 MCP 工具.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())// ② 会话记忆// .defaultSystem(systemPrompt) // ③ 系统提示词当前注释掉.build();}ChatClient.Builder由spring-ai-starter-model-openai自动配置注入你不需要自己new。这一行构造里同时挂了三件事正好对应 Spring AI 的三个抽象层挂上去的东西来自哪里作用ToolCallbackProvider toolsspring-ai-starter-mcp-client按 yml 里的 sse/stdio 配置自动装配让模型能调用外部工具第四篇MessageChatMemoryAdvisorspring-ai-chat-client的 advisor 包 一个ChatMemoryBean把历史消息带进下一轮 promptdefaultSystem(...)未启用代码里的systemPrompt常量人设/系统提示词调用侧只有两种形态贯穿全篇非流式chatClient.prompt(prompt).call().content() 流式 chatClient.prompt(prompt).stream().content() → FluxString这里有个读代码时必须发现的断层ChatMemory是从构造参数直接注入的但整个仓库里没有任何ChatMemory或ChatMemoryRepository的Bean声明也没有spring.ai.chat.memory.*配置。那它从哪来只能是 Spring AI 1.0.0 的自动配置给了一个默认实现默认是按会话窗口放在内存里的那类。这一条笔者没能从工程里取到一手依据只能作为读代码时的合理推断标注出来——而它的工程含义很实际如果ChatMemory是进程内存实现那么服务一旦重启、或者多实例部署会话记忆就会断。这在第五篇谈部署时会再撞上一次。Q1既然有了 ChatClient 的 stream()为什么还要自己写一套 SSE这是这套工程最值得琢磨的设计选择也是第二、五篇的分水岭。FluxString直接作为 MVC 返回值确实能流式吐给浏览器工程里HelloController就是这么干的返回FluxChatResponse/FluxString。但主业务链路没用这条捷径原因看前端就明白Flux 直出 → 一条 HTTP 响应只有当前这个请求能看到且要浏览器自己解析流 SseEmitter 池 → 服务端握着 userId → SseEmitter 的映射可以随时向任意用户推事件聊天场景需要的是服务端知道该推给谁一次POST /chat/doChat请求本身不应该承载回答回答是从另一条已经建立的/sse/connect?userIdxxx长连接上推下来的。所以工程用SSEServer维护了一个静态连接池doChat只负责触发与推送返回值是void。Flux直出与SseEmitter池子的区别维度Flux作为 MVC 返回值SSEServerSseEmitter本工程主链路连接方向客户端发起请求响应边生成边收浏览器先建立长连接之后服务端主动推触发与推送是否同一条连接同一条不同POST 触发GET 长连接推送服务端能否群发不能能sendMsgToAllUsers是否需要业务关联键不需要需要userIdbotMsgId典型用途单请求单回答、SSE 演示页多轮对话、多端同步、消息中心代价在第五篇细讲为了跨这个边界代码用了Flux.toStream()等于把响应式流又拽回了阻塞世界。4. 三条能力链路其实只有一段不一样这是笔者读完整个工程后最想说清楚的结构性结论也是这套工程值得抄的地方。工程对外暴露三个聊天入口前端用两个互斥勾选框路由只勾知识库搜索 → POST /rag/search → 先查向量库再问模型 只勾联网搜索 → POST /internet/search → 先搜 SearXNG再问模型 两个都不勾 → POST /chat/doChat → 直接问模型三条链路的后半段完全同构FluxStringstringFluxchatClient.prompt(prompt).stream().content();ListStringliststringFlux.toStream().map(chatResponse-{StringcontentchatResponse.toString();SSEServer.sendMsg(userId,content,SSEMsgType.ADD);// 逐 token 推returncontent;}).collect(Collectors.toList());StringfullContentlist.stream().collect(Collectors.joining());SSEServer.sendMsg(userId,JSONUtil.toJsonStr(newChatResponseEntity(fullContent,botMsgId)),SSEMsgType.FINISH);// 收尾推完整 JSONdoChat、doChatRagSearch、doInternetSearch三个方法里这段代码是一字不差重复的。差别只有一件事喂给模型的 prompt 里那段上下文从哪来。链路上下文来源关键类/方法上下文形状普通对话无ChatServiceImpl.doChat用户原话直接进chatClient.prompt(prompt)RAGRedis 向量库DocumentServiceImpl.doSearch→redisVectorStore.similaritySearch(question)【上下文】{拼接的 Document.getText}【问题】{question}实时搜索自建 SearXNGSesrXngServiceImpl.search→ OkHttp?qformatjsoncontext\n[来源] url \n [摘要] content \n /context重复多次三条链路的提示词模板都写在 Java 常量里文本块而且 RAG 与联网两条都留了同一句兜底“如果没有查到请回复不知道。”// ChatServiceImpl.java:123-134RAG 模板privatestaticfinalStringragPROMPT 基于上下文的知识库内容回答问题 【上下文】 {context} 【问题】 {question} 【输出】 如果没有查到请回复不知道。 如果查到请回复具体的内容。不相关的近似内容不必提到。 ;这就是简单理解RAG、联网搜索、微调在工程视角下干的其实是同一件事——往 prompt 里塞上下文区别只在塞的内容从哪来、什么时候塞。三条链路把这个等式摆得比一般 Demo 明白得多向量库离线知识 ┐ 搜索引擎实时知识├─→ 拼进 prompt ─→ ChatClient ─→ Flux ─→ SSE 推给浏览器 会话记忆历史知识┘顺带就能看出这套工程没有做的事检索结果与搜索结果都没有和 MCP 工具混进同一次调用spring-ai-advisors-vector-store引了却没用QuestionAnswerAdvisorRAG 的 topK、相似度阈值一次都没出现在代码里——全走 Spring AI 库默认值SearchRequest.DEFAULT_TOP_K 4、相似度阈值默认0.0即不过滤出处是官方 Vector Stores 参考页第二篇会具体讲这两个默认值在真实文档上意味着什么。这些都留给后面几篇。5. 前后端契约三个字段撑起整条流式链路前端是 Vue 2 ElementUI 2.15.13 axios marked全部用script直引本地libs/没有任何构建步骤。这个选择对读者很友好改一行刷新即可但也决定了所有地址是硬编码的baseURL: http://10.0.0.8:5501之类写死在js/request.js:3-10SSE 地址写死在pages/spring-ai.html:167。整条对话链路的契约只有三个字段{currentUserName:前端生成的 userId,message:用户输入,botMsgId:12 位随机串}currentUserName页面created()时用Math.random().toString(36).substring(2, 12)生成同时用作 SSE 连接的 userIdspring-ai.html:127-133。这就是服务端该推给谁的答案。botMsgId每次发送前由generateRandomId(12)生成。它是一次回答的流身份——服务端每推一个 token 都往chatList里botMsgId匹配的那条气泡追加finish事件到达后把这条气泡内容整体替换成marked.parse(message)渲染好的 HTML并把me.botMsgId null。message用户原话。为什么非要有botMsgId因为add事件推的是碎片finish事件推的是全文两者必须能被前端认作同一条消息add(你) add(好) add(我是) …… → 逐字拼进气泡打字机效果 finish({message:完整 Markdown 原文,botMsgId:xxx}) → 整条重渲染表格/代码块/标题才正确如果不做这一步收尾整体重渲染Markdown 的表格和代码块会在逐字追加的过程中长期处于语法不完整状态渲染出来是乱的。这是流式聊天 UI 的一个通用套路值得单独抄下来增量负责快全量负责对。后端事件名由SSEMsgType枚举固定枚举事件名语义按源码注释MESSAGEmessage单发的普通消息ADDadd消息追加适用于流式 stream 推送FINISHfinish消息完成CUSTOM_EVENTcustom_event自定义DONEdone自定义注释标了 ChatGLM v4顺带一处工程里的真实错配spring-ai.html:262监听的是customEvent驼峰而后端事件名是custom_event下划线——这个监听永远不会触发。另外js/apis/admin/doctorApi.js:27-32里还留着getRecords → GET /ollama/getRecords但后端没有对应 Controller属于另一段没有落地或被删掉的能力。读教程工程时这类前端残留接口往往比正文更能提示你课程原本想做哪一步。6. 上手前必须知道的四个工程真相这一节是笔者自己的部分不是课程内容。把一套教程工程当学习材料没问题当生产模板要非常小心因为下面四条都会跟着你进生产。6.1 压缩包里带着真实形态的凭据工程快照里有两份.envDeepSeek 与通义千问 DashScope 兼容模式各一份、高德 MCP 连接 URL 上的key、163 邮箱账号与授权码、Redisrequirepass、MySQL 的root口令讲义里还直接写了服务器公网地址。这些值笔者在本系列里一律写作sk-REDACTED/REDACTED不复现原值。但有一件事必须说清只要一份课件压缩包在网盘上流传过里面所有凭据就应该视为已泄露。如果你的工程是这种形态正确的动作顺序是轮换密钥 → 把凭据从仓库里请出去 → 再谈功能。工程里用的 dotenv 方案本身是对的值得抄// Application.java:16-22DotenvdotenvDotenv.configure().ignoreIfMissing().load();dotenv.entries().forEach(entry-System.setProperty(entry.getKey(),entry.getValue()));SpringApplication.run(Application.class,args);配合 yml 里全部写成占位符密钥就只存在于.envspring:ai:openai:api-key:${OPENAI_API_KEY}base-url:${OPENAI_BASE_URL}# 例https://api.deepseek.comchat:options:model:${OPENAI_MODEL}# 例deepseek-chat / qwq-plus这套写法真正的价值在于换模型只换 .env同一份代码把.env从 DeepSeek 那份换成通义那份base-url与model就整体切换业务代码零改动。这也是工程用spring-ai-starter-model-openai而不是各厂商 SDK 的原因——只要厂商提供 OpenAI 兼容端点Spring AI 这套 ChatClient 就照用不误。6.2 dev / prod 分离只做了一半mcp-server的两个 profile 文件各只有一行端口。数据源、Redis、邮箱、MCP server 名称全写在主application.yml里而那里的数据库地址是jdbc:mysql://127.0.0.1:5506/...、口令是明文。按这份配置直接切 prod应用会去连它自己那台机器的 5506。生产配置的真实形态在仓库里没有答案可能存在第三份配置或-D参数笔者无法核实。这条要记住的判断是profile 只有端口差异等于没有做环境分离。6.3 讲义与代码会互相打脸同一个工程里笔者至少核到这几处对不上都属于照笔记做会崩、照代码跑才通主题讲义代码采信Redis 端口6-3写port: 8379application.yml与6-2-1都是937993798379 视为笔误MCP server 端口8-8写server.port: 8090dev9060/ prod9999代码sse-endpoint8-8没提spring.ai.mcp.server.sse-endpoint: /sse代码client 的 MCP 连接8-7只配了高德多了server2 → http://localhost:9060 stdio代码JDBC 端口9-1写 33065506代码主键策略9-1写id-type: assign_id该行值为空无法核实空值行为resultMap 包名com.itzixi.entity.Productcom.itzixi.pojo.Product代码MySQL 版本文件名叫Docker安装MySQL8.md正文与镜像是mysql:9.3.0无法判定Redis 密码6-2-1的docker run带requirepass6-2-2 docker-compose.yml没有REDIS_ARGS两份互斥打包插件10-9要求加buildfinalName两个子模块 pom都没有build段快照与讲义不一致最后一行尤其要紧按当前快照mvn package产出的 jar 名是带版本号的而讲义的启动命令写的是nohup java -jar mcp-server.jar。照抄会直接找不到文件。6.4 有些能力是引而未用或注释停用Tika文档读取器、向量Advisor、defaultSystem(systemPrompt)、前端的图片理解按钮已注释、/ollama/getRecords接口——都属于铺了路没通车。这不算坏事甚至很真实笔者自己的仓库里也有一堆注释掉的下一步。但它决定了一件事读教程工程必须以代码为准讲义只是索引。后面四篇每篇都会带一段讲义没写但必须补上的坑其中最有代表性的两个先剧透SearXNG 的 JSON 输出默认是关闭的第三篇以及 MCP 工具的注册其实只需要启动类里一个 Bean第四篇。Q2那这套工程值不值得照着敲一遍笔者的判断是值得但要带着这份体检报告去敲。值得的部分很实在它没有停留在调一次 chat而是把 RAG、联网搜索、工具调用、流式推送这四件真实产品一定会做的事都各自落了一条能跑的链路配置项、类名、端口全部可核对前端零构建卡住了容易定位AOP 埋了耗时日志ServiceLogAspect3000mserror、2000mswarn这在等大模型响应时非常有用。要提防的部分就是 §6 那四条。尤其是Flux.toStream()那个桥接——它把流式链路变成阻塞链路连带POST /chat/doChat的返回语义、线程占用、以及Nginx 60s 超时下的长连接表现全部变复杂而第五篇会说明为什么这是这套工程最需要在生产前重写的地方。7. 最后总结看结构mcp-client是业务主应用mcp-server只是工具宿主四个外部进程Redis Stack、MySQL、SearXNG、stdio 子进程各占一个端口别被模块名误导。看版本Boot 3.5.0 Java 21 spring-ai-bom 1.0.0是这套工程的复现基线官方当前已走到1.0.9 / 1.1.8 / 2.0.1 / 2.1.0-M1升级前先核对 starter 命名与配置前缀。看核心一个ChatClient.Builder挂上工具 记忆 未启用的系统提示词全工程的大模型能力都从这一行出来。看骨架三条链路只有上下文来源不同后半段stream → SSE add → finish完全同构——想理解 Spring AI先理解这个同构。看契约userId决定推给谁botMsgId决定拼到哪条气泡增量求快、全量求对这是流式聊天 UI 的通用解法。对于后端 / 架构开发者我个人更关注的是这份快照暴露的不是框架问题而是教程工程到生产工程之间那段没人补的距离——凭据管理、profile 分离、依赖冲突修法、阻塞式流桥接、反向代理对长连接的影响。后面四篇按链路拆第二篇 RAG 与向量库第三篇自建 SearXNG 实时搜索第四篇 MCP 工具第五篇 SSE 流式与上云部署。事实说明本文所有版本号、端口、类名、配置项、代码片段与讲义 vs 代码差异均来自 2026-09-29 对课程工程快照_springai_src/springai-mcp-rag-dev含 23 个 Java 文件、9 份配置、21 篇讲义的逐文件实读引用位置记为文件:行号底稿见同目录research-springai-course.md。以下三项未取到一手依据正文只作提问或明确标注不作断言生产环境的真实拓扑与端口映射、ChatMemoryBean 的来源与默认实现语义、MyBatis-Plusid-type空值的绑定行为。第 4 节引用的SearchRequest默认值topK 4、相似度阈值0.0取自 Spring AI 1.0 参考文档 Vector Stores 页2026-09-29 核对。Spring AI 与 MCP 的版本时间线取自 GitHub Releases API2026-09-29 查询。凡标笔者判断/我个人的段落为工程观点非课程或官方表述。所有密钥、口令、邮箱、公网地址已脱敏。参考资料 致谢[1] Spring AI 官网 Spring AI GitHub 仓库 Releases API 原始数据[2] Spring Boot 项目页[3] Model Context Protocol 官网 MCP 规范仓库 Releases[4] MCP 官方 servers 集合 awesome-mcp-servers[5] Redis 官网Redis Stack / Vector Sets[6] SearXNG 文档站 SearXNG 上游searx/settings.yml[7] MyBatis-Plus 官网[8] MySQL 官网 Nginx 官网 Docker 官网[9] OkHttp 文档 Hutool 官网 flexmark-java[10] dotenv-java[11] DeepSeek 开放平台 阿里云百炼DashScope 兼容模式 高德开放平台[12] Smithery MCP 目录 MCP.so 魔搭社区[13] 课程随附工程压缩包与 21 篇讲义本地课件未公开链接——本文所有文件:行号均指向该快照凭据与公网地址已在引用时脱敏。