资讯中心

LangChain4j+SpringBoot构建企业级RAG对话系统实战指南

📅 2026/10/4 0:45:16
LangChain4j+SpringBoot构建企业级RAG对话系统实战指南
简介这套项目是一份面向Java开发者与AI应用工程师的智能对话系统实战代码包基于LangChain4j与SpringBoot构建重点演示RAG检索增强生成、MCP模型上下文协议、向量化存储与搜索、多模态图像合成、流式输出及工具调用等完整链路适合需要在大模型应用中落地Java方案的读者。压缩包共94个文件大小1.42MB核心为59个Java源码、15个XML配置、13个properties属性文件并附说明txt、PDF文档及README目录按langchain4j-01至14拆分为14个渐进式子模块从HelloWorld、Boot集成到RAG、Embedding、对话记忆、图像对话、流式响应、函数调用等均有可运行工程。目前已有247人学习下载。资源价值在于可直接对照源码理解LangChain4j各项能力的设计与调用方式借助MCP保持多轮上下文一致通过向量化实现精准检索还能看到完整的多模态、工具调用与流式实现为二次开发或毕设选题提供现成的实验基础。1. 把 LangChain4j 和 SpringBoot 揉进一个能交付的对话系统六件事一次讲透接手知识库问答项目时团队在 Java 技术栈里选型最后落在 LangChain4j 上原因是它对 SpringBoot 的集成最舒服不需要像 Python 版那样单独起一个 Agent 服务。这份实战项目就是围绕 LangChain4j 与 SpringBoot 展开的涵盖 RAG 检索增强生成、MCP 模型上下文协议、向量化存储与搜索、多模态图像合成、流式输出、工具调用与函数六块内容。你如果是做企业知识库、客服机器人、智能助手这类 Java 后端或者正拿这类项目做毕设这套东西能直接照着落地但必须先把依赖版本、检索参数、MCP 握手这几个坑填平否则跑起来全是玄学。2. 工程骨架与模型接入SpringBoot 3.x 下的 LangChain4j 起步2.1 LangChain4j 在 SpringBoot 项目里到底接在哪一层很多第一次接触的人会把 LangChain4j 理解成一个类似 MyBatis 的持久层框架实际上它更接近一套「LLM 应用的 Java SDK」。它把模型调用、消息历史、文档切分、向量检索、工具调用全部封装成了可组合的组件而 SpringBoot 负责把这些组件装进容器、暴露成 HTTP 接口。这条链路的典型分层是Controller 层接收用户请求Service 层组装ChatLanguageModel、ChatMemory、EmbeddingStore再通过AiService或Assistant这种代理接口完成一次带上下文的对话。LangChain4j 的AiService有点像 MyBatis 的 Mapper 代理你只管定义一个接口方法框架自动把模型调用、记忆注入、工具选择全部编排好。public interface Assistant { SystemMessage(你是企业知识库助手回答必须基于检索到的资料。) String chat(MemoryId String userId, UserMessage String userMessage); }这个接口没有实现类LangChain4j 在启动时会用动态代理生成实现。MemoryId按用户隔离对话记忆UserMessage标记用户输入位置。我一般会把知识库检索逻辑放在SystemMessage里做前置注入这样每次请求都能携带检索结果而不是让模型凭记忆回答。2.2 依赖与配置用 starter 还是手动拼装LangChain4j 官方提供了langchain4j-spring-boot-starter它会自动装配绝大部分组件但自动装配对版本耦合很敏感。SpringBoot 3.2.x 之前和之后的自动配置机制有差异如果你用的是 3.4.x 这种较新版本某些老版本 starter 的自动配置会静默失效现象是ChatLanguageModel这个 Bean 一直注入不进去。我习惯的做法是 starter 和手动配置各留一半。starter 负责模型工厂和自动配置扫描关键的模型网关参数全部走application.ymllangchain4j: open-ai: chat-model: base-url: ${LLM_BASE_URL:https://api.example.com/v1} api-key: ${LLM_API_KEY:} model-name: ${LLM_MODEL:gpt-4o-mini} temperature: 0.7 max-tokens: 2048 timeout: 30sbase-url指向兼容 OpenAI 协议的网关企业内部一般不会直连官方接口而是走统一的模型网关。timeout这个参数很关键默认值偏小流式对话时如果模型端思维链较长很容易触发超时。上面这种写法把敏感配置交给了环境变量代码仓库里不落任何密钥。如果不想用 starter纯手动拼装也只要一个配置类Configuration public class ChatModelConfig { Bean ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .baseUrl(https://api.example.com/v1) .apiKey(System.getenv(LLM_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .logRequests(true) .logResponses(true) .build(); } }logRequests和logResponses这两个开关在联调阶段一定要打开它能让你看到每次请求实际发送的 prompt 和工具调用结果排查 RAG 注入问题和工具参数绑定问题都靠它。等上了生产环境再关掉否则日志量会非常大。2.3 记忆窗口与会话隔离多轮对话的边界条件智能对话系统默认是无状态的每次请求都是独立调用。要让模型记住上下文必须引入ChatMemory。LangChain4j 提供了MessageWindowChatMemory按条数或 token 数维护一个滑动窗口。Bean ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .chatMemoryStore(new InMemoryChatMemoryStore()) .build(); }maxMessages(20)表示保留最近 20 条消息超过后最旧的消息被挤出。这里有个容易被忽略的点20 条消息如果包含长文档内容token 数可能轻松超过模型的上下文窗口。所以生产环境我更倾向用 token 数限制窗口而不是条数限制。另外InMemoryChatMemoryStore只适合单机演示多实例部署时必须换成 Redis 存储否则用户在 A 实例的对话记忆B 实例完全感知不到。3. RAG 检索增强生成向量化存储与搜索的完整链路3.1 从文档到向量切分、Embedding、入库三步走RAG 的逻辑很简单把文档切成片段向量化后存进向量库用户提问时先把问题向量化查出最相似的片段再把这些片段拼进 prompt。但真正落地时切分粒度、嵌入模型、向量库选型每一个环节都会影响回答质量。以项目里的知识库模块为例文档先经过加载器读入内存然后交给切分器。LangChain4j 里最常用的是DocumentByParagraphSplitter按段落切段落过长时再按句子切。DocumentByParagraphSplitter splitter new DocumentByParagraphSplitter( 500, 80 ); ListTextSegment segments splitter.split(document);两个参数分别是maxSegmentSize和maxOverlapSize。maxSegmentSize控制在 300 到 500 之间比较稳妥太短会丢失上下文太长会导致向量语义被稀释。maxOverlapSize是相邻片段的重叠字数设 80 到 120 能保住跨段落的语义衔接。实际经验是切分参数不值得一开始就花大量时间调先用 400/100 跑通链路再根据 hit rate 反向调整。Embedding 入库的代码是整条链路的核心EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .baseUrl(https://api.example.com/v1) .apiKey(System.getenv(LLM_API_KEY)) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment embeddingStore InMemoryEmbeddingStore.fromJson( /data/embedding-store.json ); for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment).content(); embeddingStore.add(embedding, segment); }text-embedding-3-small的向量维度是 1536bge-m3这类开源模型是 1024。向量库的维度必须和嵌入模型严格一致否则检索时直接报维度不匹配。InMemoryEmbeddingStore适合原型验证它把向量数据序列化到一个 JSON 文件重启不丢但大规模场景必须换 PGVector 或 Milvus。如果你的项目里用了数据库PGVector 是最省事的选择不需要额外维护一套向量库集群PgVectorStore.builder() .dataSource(dataSource) .dimension(1024) .createTable(true) .build();createTable(true)会自动建一张embedding_store表dimension必须和嵌入模型输出维度一致。这一点在项目里如果用了 bge-m3 就填 1024用了 OpenAI 3-small 就填 1536。3.2 检索参数topK、minScore 与召回质量的取舍向量检索不是简单地把 top N 个结果丢给模型。LangChain4j 的EmbeddingSearchRequest给了两个关键参数一个是拉多少条候选一个是过滤阈值。EmbeddingSearchRequest request EmbeddingSearchRequest.builder() .queryEmbedding(queryEmbedding) .maxResults(5) .minScore(0.5) .build(); EmbeddingSearchResult result embeddingStore.search(request); ListTextSegment relevant result.matches().stream() .map(EmbeddingMatch::embedded) .toList();maxResults(5)只拉 5 条候选minScore(0.5)把相似度低于 0.5 的片段全部滤掉。这里的相似度是余弦相似度具体分布取决于嵌入模型。OpenAI 的 text-embedding-3 系列分数普遍偏高0.5 可能过滤太少bge-m3 的分数分布则更分散0.5 可能过滤太多。我在项目里调参的思路是先不设minScore跑一批真实问题看分数分布找到明显分层的拐点再设阈值而不是拍脑袋定一个数值。检索到片段后要把片段拼进 prompt 才能让模型基于资料回答。LangChain4j 的PromptTemplate可以结构化地做这件事PromptTemplate template PromptTemplate.from( 以下是知识库资料请严格基于资料内容回答 {{#each contexts}} [{{index}}] {{this}} {{/each}} 用户问题{{question}} ); Prompt prompt template.apply(Map.of( contexts, relevant, question, userMessage ));{{#each contexts}}是 LangChain4j 的 Mustache 模板语法把检索到的多个片段逐个展开。index编号很重要它让模型能引用具体哪条资料减少编造。整个 prompt 模板写完后通过chatModel.chat(prompt.toUserMessage())发起生成。3.3 命中率验证RAG 效果好不好拿数据说话很多项目跑通 RAG 后demo 看着不错一上真实数据就答非所问。核心原因是没做命中率评估。命中率hit rate的定义是测试问题是否成功召回了正确答案所在的片段。我一般会准备三五十条测试问题比如「退货政策是什么」「发票抬头怎么修改」每条标注出对应答案在哪个文档的哪个段落然后跑检索看正确答案是否出现在maxResults的候选里。这个验证过程可以写成一个简单的脚本curl -X POST http://localhost:8080/api/rag/test \ -H Content-Type: application/json \ -d {question: 退货政策是什么, expected_fragment: 七天无理由退货} | jq .hit接口返回一个 JSONhit字段标出这条测试用例是否命中。线上环境我会把这个检查挪到 CI 里每次切换嵌入模型或修改切分参数后强制跑一遍防止 RAG 效果悄悄劣化。这种回归测试的价值在换了模型版本后特别明显——模型升级后向量分布变了之前调好的minScore可能全废没有回归脚本就只能靠用户投诉发现。4. 工具调用、MCP 与多模态图像合成让对话系统具备行动能力4.1 Tool 注解把 Java 方法变成模型可调用的函数对话系统如果只能聊天没有价值真正有用的是让它能查订单、建工单、调接口。LangChain4j 的工具调用机制就是让模型在对话过程中决定「要不要调用某个函数」模型输出一个结构化的函数调用请求框架反序列化参数后执行 Java 方法再把返回值交回给模型继续生成。Service public class OrderToolService { Tool(根据订单号查询订单状态) public String queryOrderStatus(ToolParam(订单号) String orderId) { Order order orderMapper.selectByOrderId(orderId); if (order null) { return 未找到订单; } return 订单 orderId 状态为 order.getStatus() 金额 order.getAmount(); } }Tool注解的描述非常关键模型完全靠描述决定什么时候调用这个工具。描述里的动作和参数要写得像人话「根据订单号查询订单状态」比「查询状态」准确得多。ToolParam的参数说明同样重要模型需要知道参数含义才能正确地从用户对话中抽取。工具返回值要精简。模型会把返回值再拼进 prompt 重新生成一轮如果方法里返回了整张订单表几十个字段模型理解成本和 token 消耗都会暴涨。我在项目里统一要求工具方法返回不超过 200 字的摘要文本。工具注册到模型侧的方式有两种一是启动时扫描所有Tool注解方法二是手动构建ToolSpecification列表。扫描方式更符合 SpringBoot 习惯Configuration public class ToolConfig { Bean ToolSpecification orderTool(ToolService toolService) { return ToolSpecification.builder() .name(queryOrderStatus) .description(查询订单状态) .addParameter(orderId, TYPE_STRING, 订单号) .build(); } }手动构建的方式适合工具参数动态变化的场景addParameter可以精确控制 JsonSchema 结构但代码量明显更大。原型阶段用Tool注解足够工具数量超过二十个后再考虑手动管理。4.2 MCP 模型上下文协议专门处理工具接入膨胀问题工具调用本身不难难在每个工具对接方式都不一样有的走 REST API有的走数据库有的需要鉴权。MCPModel Context Protocol就是解决这个问题的标准化协议它把工具、数据源、 Prompt 模板统一封装成可被模型发现的资源模型侧只要会讲 MCP 协议就能访问任意通过 MCP Server 暴露的能力。在 SpringBoot 项目里接入 MCP Server 的典型做法是引入langchain4j-mcp模块然后在配置里注册 Server 端点langchain4j: mcp: servers: order-server: type: http url: https://mcp.example.com/order token: ${MCP_TOKEN:}这个配置表示客户端连接一个http类型的 MCP 端点token是访问密钥。启动时 LangChain4j 会向该端点发送initialize握手拉取工具清单之后对话过程中模型可以调用这些远程工具对本项目来说相当于在本地工具之上又加了一层远程能力。我在项目里更常用的场景是集成内部的运维工具 MCP 服务比如通过 MCP Server 暴露日志查询接口对话系统里直接输入「查一下订单服务的报错日志」模型就能通过 MCP 工具拉取日志并给出分析。这里的边界要注意MCP Server 的延迟通常比本地工具高一个数量级如果工具调用发生在流式输出的中间环节用户会明显感觉到卡顿。所以高延迟 MCP 工具尽量放在用户明确请求时再触发不要作为默认的 RAG 上下文注入。4.3 流式输出与多模态图像合成SSE 和文生图接口的配合流式输出是对话体验的底线。ChatLanguageModel.chat()会一次性返回完整结果长回答要等十几秒用户端一片空白。LangChain4j 的流式接口叫TokenStream它把生成过程拆成了逐个 token 回调GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String message) { SseEmitter emitter new SseEmitter(60_000L); chatModel.chat(message) .onPartialResponse(emitter::send) .onComplete(r - { emitter.complete(); }) .onError(e - { emitter.completeWithError(e); }) .start(); return emitter; }SseEmitter是 SpringBoot 原生对 SSE 的支持onPartialResponse每次收到 token 就推给前端。这里的start()必须调用否则流式请求根本不会发起。前端用EventSource或 axios 的流式解析都能接。项目里有个细节onComplete里除了complete()还要关闭底层连接否则 SseEmitter 会跑满超时时间才断开占用连接资源。多模态图像合成是这套系统里相对独立的一块。对话系统侧发起文生图请求模型返回图片地址或 Base64 数据再通过对话接口回传public String generateImage(String prompt) { ImageModel imageModel OpenAiImageModel.builder() .baseUrl(https://api.example.com/v1) .apiKey(System.getenv(LLM_API_KEY)) .modelName(dall-e-3) .size(1024x1024) .build(); Image image imageModel.generate(prompt).content(); return image.url().toString(); }size参数强行指定1024x1024不同模型支持的尺寸集合不一样dall-e-3 还支持 1792x1024 横版和 1024x1792 竖版。生成接口耗时通常在 10 到 30 秒不能放在流式对话的同步链路里要单独异步执行生成完成后再推送给前端。实际项目里我倾向于把这个能力封装成独立的 ImageController由前端单独调用而不是塞进对话上下文——这样对话端的超时控制不会牵连图像生成。5. 避坑指南向量对齐、SSE 断开与 MCP 握手五类高频问题5.1 现象换嵌入模型后向量检索返回空结果原因嵌入模型换了向量维度跟着变但PgVectorStore的表结构还停留在旧维度插入时数据库直接拒绝写入。更隐蔽的情况是InMemoryEmbeddingStore加载了旧的 JSON 文件旧向量维度和新模型不一致检索时全部失败。解决每次更换 Embedding 模型时强制重置向量库。内存库直接删除 JSON 文件重建PGVector 用DROP TABLE IF EXISTS embedding_store清掉旧表再让createTable(true)按新维度重建。我在工程目录里会保留一个scripts/reset_vector_store.sql切换模型后先执行它再重新灌入文档。5.2 现象SSE 流式响应在 Nginx 层被截断原因前端收到的流式内容断断续续甚至只收到第一段。问题出在反向代理的缓冲上Nginx 默认会缓冲 SSE 响应缓冲满了才转发给客户端流式的实时性完全丧失。SpringBoot 侧如果设置了server.compression.enabledtrueGzip 压缩也会干扰 SSE 的推流。解决Nginx 对 SSE 的 location 要单独关闭缓冲location /api/stream { proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; proxy_read_timeout 120s; }SpringBoot 侧关掉对 SSE 接口的压缩或者直接在application.yml里把mime-types配置调整为不包含text/event-stream。这个调试过程很折磨人因为本地联调一切正常一到测试环境就断流最先怀疑的是后端代码。5.3 现象MCP 工具连接握手失败日志报 401 或握手超时原因MCP Server 的 token 过期或配置的type与 Server 端实际传输方式不匹配。有一次项目里 Server 端实际是streamable-http协议配置却写的type: http握手时消息格式不一致一直报协议解析错误。解决先确认 Server 端暴露的协议类型用 curl 手动验证curl -X POST https://mcp.example.com/mcp \ -H Authorization: Bearer $MCP_TOKEN \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -d {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:debug,version:1.0}}}看返回的 JSON-RPC 响应结构是否和 LangChain4j 客户端期望的一致。手动握手通过后再检查 SpringBoot 配置里的url是否填的是 Server 的根路径而不是带了版本号的子路径。这类问题大半出在协议版本不匹配MCP 的协议版本还在演进Server 和客户端版本差了几个迭代就握不上。5.4 现象工具调用时模型参数和 Java 方法参数对不上原因模型要调queryOrderStatus时把参数名写成了order_id而 Java 方法是orderId反序列化失败后在工具调用环节直接抛异常。如果ToolParam没有给描述模型更容易猜错参数键名。解决工具方法的参数命名遵循两个原则一是参数键名和模型常见的表达保持一致二是在ToolParam描述里写明「订单号数字字符串」。另外要允许工具方法入参为null时返回提示语模型可能拿不到完整参数就尝试调用与其让框架抛异常不如让工具方法给出友好提示。5.5 现象长期运行后内存向量库检索越来越慢且结果质量下降原因InMemoryEmbeddingStore的数据量撑大了之后每次检索都是全量暴力比对没有索引结构。同时旧数据的同质化内容越来越多拉出来的topK片段内容高度重复回答质量自然下降。解决原型验证阶段就规划好向 PGVector 迁移的时间点。切分入库逻辑本来就做好了迁移只需要把EmbeddingStore的 Bean 从一个内存实现换成PgVectorStore其余代码不用动。不差资源就直接上向量数据库别让内存库在数据量超过几万条后继续扛生产流量。6. 验证与进阶把 RAG 命中率和流式完整性变成自动化检查项目跑通之后最先要做的是给整个对话系统建立一套可重复的验证流程。我在交付这类项目时会写一组测试用例如下先构造 30 到 50 个真实业务问题每个问题标注标准答案所在的文档片段和预期关键词然后用统一的脚本发到/api/rag/test接口统计三个维度RAG 命中率、回答关键词覆盖度、流式输出是否完整结束。RAG 命中率是最核心的指标。它反映的是检索环节能不能把正确答案的片段拉进候选集拉不进来模型生成得再漂亮也是无源之水。回答关键词覆盖度是粗筛比如标准答案是「七天无理由退货」模型输出里必须包含「七天」和「退货」这两个实词。流式完整性检测则是在客户端累积接收的 token 数对比最后一个事件里接口返回的 token 总数做一次比对对不上就意味着流中间断了。var emitter new TestSseObserver(); chatModel.chat(请介绍你们公司的退货政策) .onPartialResponse(emitter::onNext) .onComplete(emitter::onComplete) .start(); emitter.awaitCompletion(60, TimeUnit.SECONDS); ListString tokens emitter.tokens(); Assertions.assertTrue(tokens.size() 10, 流式输出 token 数异常);这个测试类不发真实 HTTP 请求而是直接挂在ChatLanguageModel的TokenStream上用回调收集所有 token。它可以跑在 CI 里每次代码改动后自动验证流式链路没有被破坏。另外我建议项目里保留一个「原始请求日志」开关。每轮对话把模型收到的完整 prompt、检索到的片段索引、工具调用的入参和返回值全部记录到一个 JSON 文件或日志索引中。出问题的时候search日志能看到检索阶段哪些片段被召回、各自的得分是多少generation日志能看到模型到底引用了哪几个片段工具调用日志能看到模型参数和实际执行参数的差异。三段日志并在一起就能还原一次问答的全部决策过程。这个习惯是我踩过一次坑以后养成的。当时线上回答质量突然下滑排查半天没有头绪最后翻日志发现是切分参数在版本发布时被误改掉片段从 400 字被改成 2000 字导致向量语义严重稀释检索全部命中到错误段落。从那以后我每次发布前都会强制走一遍三件事跑 RAG 命中率回归测试、确认检索日志的片段索引分布正常、检查流式测试用例通过数。这套检查不算复杂但能拦住绝大多数隐蔽劣化希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案