Spring Boot集成LangChain4j实战:RAG、MCP与流式输出全解析
发布时间:2026/10/8 10:54:20 作者:尧图编辑部 阅读量:1,286

简介这是一份基于LangChain4j与SpringBoot的智能对话系统开发实战项目面向希望掌握Java生态下大模型应用开发的工程师与学习者。项目围绕RAG检索增强生成、MCP模型上下文协议、向量化存储与搜索、多模态图像合成、流式输出及工具调用等核心技术展开覆盖从基础集成到高级特性的完整实现路径。压缩包共94个文件以Java源文件59个为主辅以XML配置、properties配置及README等文档整体仅1.42MB结构清晰便于研读。目前已有247人学习下载。资料内含多个递进式工程模块如helloworld、boot-integration、chat-memory、chat-embedding、chat-rag、chat-mcp等可直接在SpringBoot中运行并二次开发既能用于理解LangChain4j各组件用法也能作为企业级智能客服、知识库问答等场景的代码底座。1. 基于LangChain4j与SpringBoot的智能对话系统这个资源真正的价值如果你已经试过在 Spring Boot 里接大模型接口大概率会遇到这么个场景聊天接口通了模型也能一本正经回话可一旦问到内部资料它就开始胡说八道。这份资源的价值就在于它不是一个只封装了 OpenAI SDK 的“玩具聊天室”而是把 Java 后端做 AI 应用时最常被问到的几个硬骨头——RAG 检索增强生成、向量化存储与搜索、MCP 模型上下文协议、工具调用与函数、SSE 流式输出、多模态图像合成——全部揉进了一个可跑的 Spring Boot 工程里。适合刚入门想把大模型接进业务的 Java 后端也适合想评估 LangChain4j 落地深度的架构师照着模块拆解能少走至少两个月的弯路。2. Spring Boot 搭 LangChain4j工程结构、依赖装配与模型接入配置2.1 工程结构怎么拆按能力拆模块不按页面拆我拆这份项目时第一印象是它的 module 划分非常“治愈强迫症”。它不是传统那种 controller/service/mapper 三层堆在一起的单模块工程而是按 AI 能力边界拆的。拆完之后每个模块职责极其清楚对话模块只负责会话上下文RAG 模块只负责知识库读写MCP 模块只负责外部工具连接流式模块只负责 SSE 推送多模态模块单独接图像接口。这样拆的直接好处是——哪块翻车了定位只需要十分钟。核心依赖就集中在pom.xml里我摘出最关键的几段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.x/version /parent properties java.version17/java.version langchain4j.version1.0.0-beta1/langchain4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-spring-boot-starter/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-easy-rag/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-mcp/artifactId version${langchain4j.version}/version /dependency /dependencies先说langchain4j-spring-boot-starter它通过 Spring Boot 的自动配置机制帮你把ChatLanguageModel、EmbeddingModel、StreamingChatLanguageModel这些核心 Bean 直接装配进容器。你不需要手动new一个模型客户端配置全部走application.yml。langchain4j-open-ai是对 OpenAI 兼容接口的适配实现当前主流的国产大模型也大多兼容这个协议所以这块是通用底座。langchain4j-easy-rag是简化版 RAG 封装适合起步项目后期如果要精细控制召回可以直接绕开它手写检索链路后面第三章会细说。2.2 模型接入配置ChatLanguageModel 与 EmbeddingModel 的参数项目的application.yml里最核心的配置长这样langchain4j: open-ai: chat-model: api-key: ${LLM_API_KEY} model-name: gpt-4o temperature: 0.7 max-tokens: 2048 timeout: 30s embedding-model: api-key: ${LLM_API_KEY} model-name: text-embedding-3-small streaming-chat-model: temperature: 0.5 max-tokens: 1024这里有几个参数值得单独说。temperature控制随机性做知识库问答我一般压到 0.3 以下否则答案会飘做闲聊或创意文案可以放到 0.7。max-tokens决定单次回复的上限注意它包含思考链的长度如果你用的模型带推理过程给 2048 都可能截断。timeout是极易忽略的坑默认 10 秒在模型推理高峰期根本不够我习惯调到 30 秒以上。关于模型选用项目默认走的是 OpenAI 兼容协议。这意味着你只要把api-key和base-url换成任意兼容服务商的地址就能切到别的模型。实际生产中很多人会把model-name写成 gpt-4o 但 base-url 指向国内服务这种情况只要两边协议兼容就能跑通。2.3 选型对照Spring AI 与 LangChain4j为什么这份资源押注后者拆这份资源之前我也犹豫过要不要优先写 Spring AI。两者的定位差异其实挺明显表格Java 侧两种 AI 框架的选型对照对比项LangChain4jSpring AI成熟度更早支持 RAG、Tool 调用、MCP背靠 Spring 官方但年轻工具调用Tool注解即插即用函数回调需手动装配RAG 生态有 easy-rag 快速起步拆分点开放部分封装偏黑盒MCP 支持独立模块支持 stdio/HTTP起步较晚中文社区资料相对丰富官方文档为主做一个合格的选择核心不是“谁更好”而是“谁更容易在当前项目里改造”。这份资源显然押注了 LangChain4j而 Spring Boot 3.x 是承载它最稳妥的底座。接下来进入核心链路。3. RAG 检索增强生成向量化存储、知识库构建与多路召回3.1 RAG 链路拆解从文档到答案的五步管道RAG 的原理可以浓缩成一句话先把你自己的知识库切碎、嵌入成向量存起来收到用户问题后先做相似度检索把命中的片段塞进提示词再让大模型基于这些片段回答。这个项目里的 RAG 链路分五步文档加载、文本切分、向量化、存储索引、检索召回。第五步是最容易返工的。新手常见的翻车是把 PDF 直接整个塞进向量库结果模型什么都答不对。原因是模型输入有 token 上限且整篇文档的向量表征会被噪声冲淡。必须做切分。项目里用的是递归切分器关键逻辑如下DocumentSplitter splitter DocumentSplitters.recursive( 800, // 每个切块目标长度按字符数算 200, // 重叠长度防止语义被切断 new OpenAiTokenizer(gpt-4o) ); ListTextSegment segments splitter.split(document);800是每块的目标长度不是硬上限。按中文场景800 字符大约能覆盖三四段完整论述既不会因为太短导致语义碎片化也不会因为太长浪费向量空间。200是块与块之间的重叠区让段落边界处的语义不丢失。OpenAiTokenizer按 token 计算长度而不是按字符数这样更贴近模型的实际输入窗口。切分完了之后执行向量化入库// 批量向量化并写入存储 ListEmbedding embeddings embeddingModel.embedAll(segments).content(); for (int i 0; i segments.size(); i) { embeddingStore.add(embeddingIdGenerator.generate(), embeddings.get(i), segments.get(i)); }这里要注意的是embeddingModel.embedAll()是批处理接口效率远高于单条循环调用。embeddingStore.add()的第三个参数是原始文本必须传。很多人在这一步把TextSegment丢了导致检索出来的只有向量无法回溯原文答案也就无从拼起。3.2 向量化存储与搜索内存、Redis、Milvus 怎么选项目里向量库是抽象成接口的EmbeddingStoreT是一个顶层接口下面的实现各有适用场景存储实现适合规模部署成本检索性能项目里的位置InMemoryEmbeddingStore万级以下文档零部署小规模够用本地调试最爽RedisEmbeddingStore十万级需 Redis 模块中上生产常用MilvusEmbeddingStore百万级以上重高大规模知识库在这个项目里本地开发直接用内存存储生产环境切 Redis。切换只需要改一行依赖和一行配置这也是把EmbeddingStore抽象成接口带来的好处。如果你问我个人的习惯我在并发不高、数据量十万级以内的项目里倾向直接上 Redis因为运维链路简单且 LangChain4j 对 Redis 的向量搜索封装已经够成熟。检索端的核心代码其实很短Embedding questionEmbedding embeddingModel.embed(question).content(); ListEmbeddingMatchTextSegment matches embeddingStore.findRelevant(questionEmbedding, 5, 0.75);findRelevant的三个参数值得琢磨5是 TopK表示取最相似的 5 条片段0.75是最小相似度阈值。这里有个实践参数阈值不是越高越好太高会把一些其实能辅助回答的片段全过滤掉最终答案质量反而下降。我一般把阈值定在 0.650.75 之间宁可多召回再靠大模型判断也不要漏召回。TopK 也不是越大越好——塞进 prompt 的片段越多token 占用越高如果知识库里有大量互相冲突的内容召回太多反而会干扰模型。3.3 多路召回与 RAG 瓶颈向量检索不够还得补关键词只用向量检索的 RAG 有一个明显的瓶颈向量检索本质是语义近似它对专有名词、精确编号、代码片段这类文本极不友好。比如用户问“接口文档里提到的 SYS_ERR_001 是什么”纯向量检索会把语义相近但编号不同的片段召回精确命中率很惨。项目里破这个瓶颈用的是多路召回。在向量召回的同时用传统的关键词检索走一遍最后做分数融合向量相似度和关键词命中各自加权再合并。伪代码示意// 向量召回分数 double vectorScore match.score(); // 关键词召回分数命中得分累加这里用简化计分 double keywordScore keywordMatcher.score(question, segment.text()); // 加权融合 double finalScore 0.7 * vectorScore 0.3 * keywordScore;这个 0.7/0.3 的权重是我在项目中反复调出来的向量权重过高精确编号问题依旧关键词权重过高语义召回全面退化。理想的做法是把融合结果再做一个重排——用重排模型或干脆让大模型对候选片段打分。但重排需要额外的模型调用开销项目把重排作为可选开关默认不开启只在追求精度的知识库场景才开。RAG 的另一个瓶颈在上下文管理一次召回 5 条每条 800 字符加上系统提示词和用户问题上下文已经接近 5000 token如果再接工具调用和多轮历史token 很容易爆炸。项目里对多轮对话的历史做了滑动窗口只保留最近两轮这是控制 token 消耗最朴素也最有效的办法。4. 工具调用与函数从 Tool 注解到 MCP 模型上下文协议4.1 工具调用与函数Tool 是留给 LangChain4j 的钩子大模型本身不会查数据库、不会调外部系统工具调用就是给它装上一双手。这份项目里做工具调用的姿势很统一定义一个 Spring Bean方法上打Tool注解LangChain4j 会自动把方法的描述、参数结构生成给模型。Component public class OrderTools { Tool(根据订单号查询订单状态) public String queryOrderStatus(P(订单号例如 SO20240001) String orderNo) { // 这里走真实订单服务省略实现 return orderService.queryStatus(orderNo); } }关键细节在P注解上它定义参数的描述。模型靠这个描述来理解参数填什么描述写得越具体模型传参的准确率越高。我见过有人把参数描述写成“订单号”结果模型经常把用户闲聊里的数字当成订单号传进来改成“订单号格式为 SO 开头”之后准确率立刻上来了。这就是 LangChain4j 的一个特点工具描述本质上是给模型看的 prompt。4.2 MCP 模型上下文协议让外部工具接入有一个统一标准MCPModel Context Protocol解决的问题是每个工具厂商都自定义一套接入方式AI 应用连十个工具就要写十套适配逻辑。MCP 把这个标准化了工具以 MCP Server 的形式暴露能力AI 应用通过 MCP Client 连接模型通过协议发现工具、调用工具、接收结果。这份资源里 MCP 模块的接入方式是标准做法先建立传输层再建客户端McpTransport transport new StdioMcpTransport.Builder() .command(npx) .args(-y, modelcontextprotocol/server-everything) .build(); McpClient mcpClient McpClient.builder() .transport(transport) .build(); // 把 MCP 工具暴露给 LangChain4j ToolProvider toolProvider new McpToolProvider(mcpClient);这段代码里有两个关键选择。StdioMcpTransport走的是标准输入输出流适合启动一个本地子进程来提供工具如果你的工具部署在远程服务器上就要换HttpMcpTransport。项目里两种都封装了配置切个开关就可以。这里最容易踩坑的是同步问题LangChain4j 调用 MCP 工具的整个流程涉及模型、MCP 客户端、工具服务三方的多次往返每一轮都有超时风险。特别是通过npx启动的 stdio 子进程首次启动要下载依赖可能耗时十几秒模型在那边等结果等到超时。后面避坑章节会专门讲这个。4.3 流式输出到文件的 MCP 实践MCP 协议里还有一个容易被忽略的能力服务端可以主动推送内容客户端可以做流式消费。项目里有个典型的场景——让模型把长篇回答直接写入文件而不是一次性返回全文给前端。做法是在 MCP Server 里注册一个写文件的工具客户端把流式片段持续转发给它// 客户端侧把模型流式输出转发给 MCP 文件工具 streamingChatModel.chat(prompt, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { mcpFileTool.append(token); } Override public void onComplete(ResponseAiMessage response) { mcpFileTool.flush(); } });这个模式适合生成报告、导出代码、落盘日志这类场景。核心收益是即使模型生成了上万字的内容也不需要全部加载进内存再一次性写文件边生成边落盘内存占用恒定。这个实践在项目里的注释写得很清楚属于非常有工程价值的细节。5. 避坑排查LangChain4j 与 Spring Boot 实战中的常见问题这一章是我拆这个项目时才真正意识到“这些坑不是文档能教你的”。每条都按现象、原因、解决来写希望你在复现时能少折腾几晚。5.1 坑一SSE 流式输出前端永远只收到一条完整响应现象后端日志显示 onNext 一直在触发但前端浏览器网络面板里看不到逐字返回而是一次性收到全部内容。原因这个坑十有八九出在网关或 Nginx 的缓冲上。text/event-stream响应被 Nginx 缓冲了SSE 的实时性完全失效。另一个原因是 Spring Boot 的内嵌容器对响应头的配置不对导致Content-Type没有正确识别为流。解决如果你用了 Nginx必须显式关闭对该路径的缓冲location /api/chat/stream { proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; add_header Content-Type text/event-stream; }同时在后端确认响应头里Content-Type: text/event-stream;charsetUTF-8和Cache-Control: no-cache都在。这两处检查是 SSE 能逐字推送的前提缺一个都白搭。5.2 坑二向量化之后中文知识库召回率极差现象知识库全是中文文档检索出来的片段牛头不对马嘴TopK 返回的第一名往往不是正确答案。原因问题出在切分环节。文本按 800 字符硬切中文一句话没说完就被切断嵌入模型拿到的是语义半截的片段召回自然差。这跟分词没有绝对关系而是切分粒度没有对齐中文语义边界。解决改用按句号、问号、感叹号做边界感知的切分本质上是给切分器一个“不要随便断句”的约束。项目里后来改成了自定义切分器先用正则把文本按中英文标点拆成句子再累积组装成块保证每个块至少包含完整句子。改完之后同一个问题从 Top1 召回的答非所问直接变成了基本命中。5.3 坑三MCP 工具调用超时模型一直空等现象对话界面转圈十几秒日志里模型侧显示在等待工具调用结果但 stdio 子进程迟迟没有输出。原因首次通过npx -y拉取 MCP Server 依赖时网络慢导致进程启动超过模型等待时长。模型侧的超时先触发工具结果回来时已经没有接收方了。解决两招。第一招是提前预热应用启动时主动拉取一次 MCP Server 并调用一个健康检查工具把依赖下载时间消耗在启动阶段。第二招是把模型调用的超时从默认值调大并给工具的每次调用单独设置超时上限。项目里最终做法是两个一起上预热解决首次慢启动超时参数解决偶发抖动。从那以后我再也不把模型超时和工具超时混为一谈了。5.4 坑四Spring Boot 自动配置冲突报错指向 langchain4j 包现象应用启动时报 Bean 冲突提示ChatLanguageModel存在多个候选实例注入失败。原因如果同时引入了 LangChain4j 的 starter 和另一个 AI 框架的自动配置两边都会尝试创建模型 Bean。Spring Boot 默认按照类型注入遇到多个同类型候选就抛出NoUniqueBeanDefinitionException。解决排查依赖树确认只保留一个框架的自动配置。如果确实需要共存就在配置类上加Primary指定主 Bean或者在application.yml中关闭其中一个框架的自动配置开关比如spring.ai.autoconfigure.enabledfalse。这类问题的特征是启动日志长、错误栈深但根因永远在依赖注入这一层。5.5 坑五多模态图像接口报尺寸超限或格式不支持现象用户上传一张手机照片接口直接 400日志提示图像尺寸或者格式不受支持。原因很多图像模型接口对输入有硬性限制比如边长上限、文件大小上限、只支持 JPEG/PNG。手机原图动辄几 MB直接砸过去必挂。解决在调用图像接口之前做一次前置处理把图片压缩到模型支持的最大边长同时转成标准 JPEG。用 Java 的 ImageIO 就能完成这个操作BufferedImage img ImageIO.read(inputStream); BufferedImage scaled Scalr.resize(img, Scalr.Method.QUALITY, 1024); ImageIO.write(scaled, jpg, outputStream);项目里封了一个ImagePreProcessor统一做压缩、格式转换、大小校验所有图像接口入口都走它。图像模块的其余细节放到下一章展开。6. 流式输出与多模态图像合成端到端调试与验证技巧6.1 从 StreamingChatLanguageModel 到 SSE 逐字推送流式输出是对话系统体验的分水岭。要实现逐字返回后端要用StreamingChatLanguageModel通过回调把每个 token 推给前端。我用 Spring WebFlux 的Flux封装了 SSE 接口RestController public class ChatStreamController { private final StreamingChatLanguageModel streamingChatModel; GetMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestParam String message) { Sinks.ManyString sink Sinks.many().unicast().onBackpressureBuffer(); streamingChatModel.chat(message, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { sink.tryEmitNext(token); } Override public void onComplete(ResponseAiMessage response) { sink.tryEmitComplete(); } Override public void onError(Throwable error) { sink.tryEmitError(error); } }); return sink.asFlux() .map(token - ServerSentEvent.builder(token).build()) .doOnError(e - log.error(流式输出异常, e)); } }调试这个接口有个非常实用的技巧用 curl 直接看原始响应流比前端看效果更能定位问题curl -N --no-buffer \ -H Accept: text/event-stream \ http://localhost:8080/api/chat/stream?message你好-N关闭 curl 的缓冲作用等同于上面 Nginx 的proxy_buffering off。如果你用 curl 能看到逐字输出但浏览器里看不到那问题一定在前端 EventSource 的处理逻辑上如果 curl 里都是一次性返回后端或中间件的问题。这个定位方法帮我省下无数次两边扯皮。验证流式输出的效果我建议关注两个指标首 token 延迟TTFT和 token 间延迟。首 token 延迟超过 5 秒体验就很糟糕这时检查模型服务和网络链路token 间延迟超过 2 秒多半是后端在阻塞推送。6.2 多模态图像合成的接口接入与提示词多模态这块项目拆成两个方向一个是“多模态理解”即模型读取用户上传的图片并回答问题另一个是“图像合成”由模型生成图片。图像合成在 Java 侧走的是独立的图像生成接口构造请求时需要把描述、尺寸、数量参数化MapString, Object params new HashMap(); params.put(prompt, 产品宣传图背景为浅灰色主体居中); params.put(size, 1024x1024); params.put(n, 1); params.put(response_format, b64_json);提示词的质量直接决定图像合成结果的上限。项目里我把提示词总结成三段式主体描述 风格关键词 负面排除。比如生成一张电商海报正面的主体描述要写清楚“某个产品摆放在木质桌面上”风格写“摄影棚灯光柔和阴影”负面排除写“不要文字不要水印”。这三段缺一段输出就可能偏得让人血压飙升。验证图像合成的效果项目里做了个很朴素的策略把每次生成的提示词、参数和输出图文件名记录到一张表里人工评分后沉淀出可以复用的有效参数组合。这个方法比盲目调参靠谱得多因为图像模型的输出质量高度依赖提示词的措辞同样的描述换一种说法效果完全不同。6.3 最后的调试习惯这个项目从头到尾拆下来我最大的感受是LangChain4j 本身不复杂复杂的是它和 Spring Boot、流式输出、向量库、MCP 工具之间那一堆容易忽略的边界条件。你踩一遍上面的坑之后最该培养的习惯是把所有可调参数集中到一个配置文件里——模型超时、切分块大小、召回 TopK、相似度阈值、流式缓冲开关全放一起调参时一次改齐不用满工程乱翻。从那以后我每次在 Spring Boot 里集成 LangChain4j 都会强制自己先跑一遍第五章的五个坑检查清单再往新功能上堆代码。希望帮到你。本文还有配套的精品资源点击获取