简介:一份基于LangChain4j与SpringBoot的智能对话系统实战源码包,面向掌握Java基础、希望落地大模型应用的开发者与架构师。项目覆盖RAG检索增强生成、MCP模型上下文协议、向量化存储与搜索、多模态图像合成、流式输出及工具调用等关键技术,并拆分为helloworld、boot集成、多模型接入、低高级API、模型参数、chat-image、stream、memory、prompt、persistence、embedding、RAG、函数调用、MCP共十余个渐进式模块,便于按图索骥学习。压缩包共94个文件,以59个Java源码为主,配合XML和properties配置、Markdown与PDF说明文档、YAML部署配置等,整体仅1.42MB,轻量且结构清晰。目前已有247人学习下载,适合想快速上手LangChain4j并构建生产级对话系统的Java开发者,可从中获得完整可运行的示例工程与集成排错思路。
1. 从“能聊”到“能干活”:LangChain4j与SpringBoot智能对话系统的几道必过关
把大模型接进Java后端,最常见的翻车方式是Controller里写个RestTemplate直接POST模型接口,prompt拼字符串,上下文自己管。前两周很爽,等产品提“资料问答、连内部系统、打字机输出”时,发现全在造轮子。LangChain4j + SpringBoot正好把这堆事收成一条链路:RAG检索增强生成、向量化存储与搜索、MCP模型上下文协议、工具调用与函数、流式输出。它不是一个聊天Demo,而是把“能聊”推到“能干活”的工程化框架。这篇笔记按标题里的技术栈逐层拆解,给出能直接抄的最小实现和参数边界,面向Java后端与需要落地LLM能力的业务团队。方案本地模型打底,不依赖外部闭源服务,跑通后再换生产级模型也省事。
2. 搭出最小对话工程:LangChain4j的选型理由与Spring Boot版本对齐
2.1 为什么选LangChain4j:接口抽象比自拼HttpClient多了什么
先回答一个很多人纠结的问题:既然模型厂商都给了SDK,为什么不直接调?因为一旦项目里同时出现两三家模型、两套记忆方案、一套自己的工具调用解析代码,每次换模型都是伤筋动骨。LangChain4j给的是可互换抽象:ChatLanguageModel管对话、EmbeddingModel管向量化、StreamingChatLanguageModel管流式、ToolProvider管工具调用、ContentRetriever管检索召回。业务代码面对的是这些接口,不是某家厂商的HTTP响应结构。
我用它落地过两个项目,最直接的体感是换模型成本极低。本地调试用Ollama,测试通过后把配置切到云厂商模型,Service层代码一行不用动。这就是抽象层的价值,自拼HTTP做不到这一点,因为每家模型的工具调用协议、流式事件格式都不一样,你得为每一家写一套解析器。
LangChain4j对Spring Boot生态也友好,提供了spring-boot-starter,依赖一引、配置文件一写,LangChain4j就自动装配好了,和写Spring Data JPA的感觉很像。对于Java团队来说,学习曲线比引入一套Python微服务低得多。
2.2 Spring Boot 3.x + Java 17基线:版本对齐决定自动配置生不生效
Spring Boot 3.x要求Java 17起步,这是硬前提。真正容易踩坑的是LangChain4j的starter和Spring Boot小版本的适配节奏:Spring Boot发版通常先于starter适配,社区里一堆“springboot版本太高导致Bean没装配”的问题就是这么来的。Spring Boot 3.5刚出那阵,直接把工程升上去,日志里看不到LangChain4j自动配置条目,注入ChatLanguageModel直接抛NoSuchBeanDefinitionException。
我的处理习惯是先用BOM统一版本,不追最新小版本。LangChain4j的版本以Maven Central当前稳定版为准,工程里用属性占位统一管理。依赖这样配:
<properties> <langchain4j.version>用你引入时的最新稳定版</langchain4j.version> <spring-boot.version>3.3.x 或 3.4.x 稳定线</spring-boot.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-lancedb</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>三个依赖各管一件事:starter负责自动装配ChatLanguageModel等核心Bean,ollama模块提供本地模型调用能力,lancedb模块用于后面的向量化存储与搜索。版本号不写死在正文里是因为这项目迭代很快,抄一个过时版本反而让你启动时踩坑。升级Spring Boot前,先去Maven Central看LangChain4j的pom依赖了哪个Spring Boot版本,比它高就等适配,别硬升。
2.3 第一个能回复的接口:本地Ollama起一个qwen2.5对话服务
常见做法是本地先用Ollama跑开源模型,验证链路通顺后再接生产模型。先装Ollama拉两个模型,对话用qwen2.5:7b,后面RAG的向量化用bge-m3:
ollama pull qwen2.5:7b ollama pull bge-m3拉完在application.yml里配置LangChain4j:
langchain4j: ollama: chat-model: base-url: http://localhost:11434 model-name: qwen2.5:7b temperature: 0.7 timeout: 60s embedding-model: base-url: http://localhost:11434 model-name: bge-m3temperature这里解释一下:0.7是我做通用问答的起点,偏低一点比如0.3适合工具调用和检索问答,因为那些场景要确定性,太高会让模型把工具参数编得天花乱坠。timeout设60秒是给长文本留余量,本地模型跑7B参数在CPU上可能慢,超时太短会让你误判成服务不可用。
Controller里直接注入LangChain4j的ChatLanguageModel:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatLanguageModel chatModel; public ChatController(ChatLanguageModel chatModel) { this.chatModel = chatModel; } @GetMapping("/sync") public String syncChat(@RequestParam String message) { return chatModel.chat(message); } }启动Spring Boot后请求/api/chat/sync?message=你好,能看到qwen2.5正常回复。到这里最小对话工程就通了。这个接口虽然简单,但它验证了三件事:依赖版本正确、自动装配生效、本地模型服务可用。任何一环出问题都能在这个最小例子上快速定位,而不是等业务代码写完了才发现环境没通。后面切的RAG、工具调用、MCP,都是在这个基座上叠加能力。
3. RAG检索增强生成闭环:中文切分、向量化存储与检索参数
3.1 文档切分:默认Splitter为什么在中文上翻车
RAG检索增强生成的核心思路是“先检索再回答”:把知识库文档切成块,向量化存起来,用户提问时先搜出相关片段,拼接进Prompt再交给模型。听起来简单,第一个坑就出在切分上。LangChain4j默认的递归切分是按空格、换行这类分隔符递归拆的,英文没问题,中文没有天然空格,最终会退化成按字符硬切,把一句话从中间劈开。
我见过最典型的翻车现场:检索命中的片段全是“我们产品支持…”这种半句话,模型拿残缺上下文瞎编。解决思路是给切分器显式传中文标点分隔符,让它先按句号、问号、叹号切,再按段落切,最后才按长度切:
DocumentSplitter splitter = DocumentSplitters.recursive( 500, // maxSegmentSize:单块最大字符数 50, // 相邻块重叠字符数 List.of("。", "!", "?", "\n\n", "\n", ";", ",", " ") // 分隔符优先级从高到低 ); Document document = Document.fromFile(Paths.get("knowledge_base.txt")); List<TextSegment> segments = splitter.split(document);maxSegmentSize和overlap是两个要调的核心参数。块太大,检索定位不准,模型上下文被无关内容占满;块太小,语义不完整,召回的片段表达不了完整信息。500字符是个中庸起点,技术文档可以放到800,对话型FAQ用300就够。overlap的50字符是缓冲带,防止恰好切在关键句中间。
3.2 向量化存储与搜索:bge-m3、LanceDB与minScore/topK
分好的块要转成向量存进向量库。向量化这步用bge-m3,检索式embedding模型,中文效果稳定,Ollama本地就能跑。向量库选LanceDB,嵌入式、单文件、零运维,对本地工程和中小团队足够了;等数据量上百万再迁Milvus或pgvector,LangChain4j的EmbeddingStore接口是统一的,迁移时只改构建代码。
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("bge-m3") .build(); EmbeddingStore<TextSegment> store = LanceDbEmbeddingStore.builder() .path(Paths.get("./.lancedb")) .embeddingTableName("knowledge_segments") .build();这里有个容易忽略的点:bge-m3输出1024维向量,embeddingTableName一旦建好,维度就写死在表结构里了。如果中途换了个不同维度的embedding模型,查询时会直接报错,没有后悔药,只能删表重建。所以接入前先定死embedding模型,这是我在项目里吃过亏的地方。
知识库能不能存图片,也是很多人问的。答案是可以,但别直接把图片二进制塞进向量库。常见做法是把图片用多模态模型转成CLIP向量,或者退一步把图片的标题、OCR文本、场景描述做成文本块进向量库,检索命中后把图片URL放在TextSegment的metadata里返回,前端拿URL渲染。标题里的多模态图像合成是生成侧的能力,和这里的存储侧是两条线,后面第6章再展开。
检索时的两个参数直接决定问答质量。minScore是相似度阈值,topK是返回片段数。bge-m3的相似度分布比较集中,经常落在0.5到0.7之间,把minScore设成0.8会导致明明库里有答案却召回为空,这是典型的“rag瓶颈”不在模型而在参数。我的参数经验如下:
| 参数 | 推荐起点 | 调优方向 |
|---|---|---|
| minScore | 0.6 | 检索结果噪音多就上调,结果为空就下调 |
| topK | 5 | 知识库大、答案分散就加到8,最多不超过10 |
| maxSegmentSize | 500 | 技术文档用800,FAQ用300 |
3.3 把检索接回对话:EmbeddingStoreContentRetriever与AiServices
向量库有了,接下来把它接进对话链路。LangChain4j用AiServices把模型、检索器、工具这些零件组装成一个完整的Assistant接口,调用方只面对一个业务方法:
EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build(); String answer = assistant.answer("你们的退款政策是什么?");Assistant接口自己定义:
public interface Assistant { String answer(String question); }AiServices会在运行时生成这个接口的代理实现,内部流程是:模型收到用户问题,自动生成一条检索query,用query去向量库召回相关片段,拼进Prompt,再生成最终回答。调用方无感知,像在调一个普通Java方法。
这里有个细节值得注意:检索query可以由LangChain4j自动改写,有时模型会把“退款政策”扩写成“退货退款流程与条件”,这对召回有帮助,但也可能改写偏了。如果发现检索命中质量不稳定,可以在Assistant接口方法上用@MemoryId和@UserMessage显式控制Prompt模板,把检索query固定成原文,减少模型的自由发挥空间。
4. 工具调用、MCP模型上下文协议与流式输出:让模型能操作业务系统
4.1 函数调用:把库存查询这类业务方法注册成模型可调用的工具
对话系统能问答还不够,业务方真正想要的是“帮我查一下订单状态”“把这条记录标为已处理”。这就是工具调用,LangChain4j里用@Tool注解把Java方法暴露给模型。模型不直接执行方法,它只是根据对话内容生成“调用哪个函数、传什么参数”的JSON,框架负责反射调用并把结果回填给模型,模型再组织语言回复用户。
@Component public class OrderTools { @Tool("查询订单状态,参数为订单号") public String queryOrderStatus(@Parameter("订单号") String orderId) { // 这里调真实订单服务 return "订单 " + orderId + " 状态:已发货,预计3天内到达"; } }装配时把工具对象传给AiServices:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .tools(new OrderTools()) .build();@Tool的description和@Parameter的description不是注释,是给模型看的说明书,写清楚“参数是什么格式、方法返回什么内容”,模型才知道什么时候该调、该传什么值。工具方法返回值建议直接返回人类可读的字符串或JSON字符串,别返回一个Java对象让模型猜字段含义。我见过有人返回一个Order对象带八个字段,模型读不出哪些该说给用户,回答就显得生硬。
4.2 MCP模型上下文协议:一个标准接口接进所有外部能力
MCP模型上下文协议解决的是“工具多了怎么管”的问题。企业内部有订单系统、库存系统、文档库、自动化脚本,每个都自研一套HTTP接口,模型要对接N个系统就得写N套对接代码。MCP把这些能力统一成客户端-服务器模型:能力方实现一个MCP Server,用JSON-RPC 2.0暴露工具列表和调用入口;LangChain4j作为MCP客户端,通过McpToolProvider发现并调用这些工具。写过一次接入代码,后面接任何MCP Server都是同一套逻辑。
List<McpServer> servers = List.of( McpServer.stdio("order-server", "python3", "/opt/mcp/order_server.py"), McpServer.http("http://localhost:8081/mcp") ); ToolProvider mcpToolProvider = McpToolProvider.builder() .mcpServers(servers) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .toolProvider(mcpToolProvider) .build();注意McpServer和McpToolProvider的类路径在不同LangChain4j小版本里有过调整,我这里的写法对应1.x的常见形态,如果你引入的版本编译不过,直接在IDE里看依赖源码的包名改一下就行。stdio适合本地脚本类能力,比如一个Python写的文档处理服务;http适合独立部署的远程服务。MCP规范里除了Tools,还有Resources,可以把内部文档作为上下文资源暴露给模型做参考,这个在后续版本里支持度会越来越高。
MCP的价值在于让模型能力接入从“点对点”变成“一对多”。你写一个MCP Server,Claude Desktop能用、LangChain4j能用、其他支持MCP的客户端都能用。团队里做一次投入,多处复用。
4.3 流式输出:StreamingChatLanguageModel配合SseEmitter打字机效果
对话系统不做流式输出,用户等5秒看一个完整回复还能忍,等15秒就以为服务挂了。流式输出把token一个个推给前端,首包延迟降到几百毫秒,体验是质的差别。LangChain4j里的流式是StreamingChatLanguageModel,Spring Boot侧用SseEmitter做服务端推送。
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String message) { SseEmitter emitter = new SseEmitter(60_000L); streamingChatModel.stream(message, new StreamingResponseHandler<ChatResponse>() { @Override public void onNext(String token) { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { throw new RuntimeException(e); } } @Override public void onComplete() { emitter.complete(); } @Override public void onError(Throwable error) { emitter.completeWithError(error); } }); return emitter; }SseEmitter构造器里的60000是超时毫秒数,模型生成时间超过60秒连接会断,长回答场景建议放到120秒。onNext里每个token都是增量推给前端,前端用EventSource或fetch流式接口逐段渲染,效果就是打字机。这里有个实际部署的坑:如果前端连的是Nginx,Nginx默认会缓冲响应,导致前端拿到的不是增量token而是攒好的一整包,流式白做了。后面第5章专门讲这个排查。
5. LangChain4j + RAG + MCP落地避坑:5个高频故障排查记录
5.1 Spring Boot版本升级后Bean丢失
现象:工程升级Spring Boot小版本后重启,注入ChatLanguageModel的地方抛NoSuchBeanDefinitionException,日志里找不到LangChain4j自动配置条目。
原因:langchain4j-spring-boot-starter的自动配置类依赖Spring Boot内部的条件注解和SPI机制,starter适配的是老版本Spring Boot,新版本里这些机制变了,自动配置静默跳过。
解决:先看starter的pom里声明的Spring Boot版本,LangChain4j官方通常会跟随较新的Spring Boot发版,但你可能升级过快。同步LangChain4j版本到支持当前Spring Boot的版本;如果业务不允许升级LangChain4j,就手动定义Bean兜底,在配置类里new一个OllamaChatModel,绕过starter的自动装配。
5.2 中文文档切碎导致召回断章
现象:检索命中的片段经常是半个句子,尤其长段落文档,回答时模型拿残缺内容硬编。
原因:切分器按长度硬切,中文没有空格分隔,默认分隔符列表里中文标点优先级不够,导致最外层按字符硬切先于按句子切触发。
解决:显式传中文标点优先级列表,把句号、叹号、问号放在最前面,让句子成为第一优先级的切分边界。还有一个排查技巧:把切分后的片段打印出来,人工读一遍,看到“我们产品支”这种残句就说明切分器配置不对,不用等线上效果暴露问题。
5.3 minScore阈值太高导致检索结果为空
现象:同一个问题,昨天能回答今天回答不了,或者知识库里明明有对应内容,检索结果却是空的。
原因:embedding模型的相似度分布会随文档内容和query写法变化,bge-m3的分数经常集中在0.5~0.7区间,minScore配0.8会把所有结果过滤掉。这个问题的迷惑性在于“检索逻辑没报错,只是结果为空”,很容易误判成向量库没数据。
解决:先写个临时接口打印query的相似度分数分布,看到实际区间再定minScore。我的经验是本地bge-m3用0.6起步,生产环境用云厂商embedding模型时0.7起步。排查顺序:先看分数分布,再看topK,最后才怀疑向量库数据没写入。
5.4 流式输出被网关缓冲成了一次性返回
现象:本地Postman测试流式输出正常,部署到服务器后前端一次性拿到完整回复,打字机效果消失。
原因:Nginx默认开启proxy_buffering,SSE响应被缓冲到end才转发给客户端。这是流式输出最常见、也最容易被忽略的部署问题。
解决:在Nginx对应location里关掉缓冲:
location /api/chat/stream { proxy_buffering off; proxy_cache off; proxy_read_timeout 120s; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; }proxy_read_timeout 120s是给长回答留的空间,不然模型生成超过60秒Nginx先断连。关掉chunked_transfer_encoding是防止SSE和chunked编码叠加出兼容问题。部署后验证方法:curl -N地址看输出是否逐段出现。
5.5 MCP工具列表为空或调用超时
现象:McpToolProvider装配成功,但模型回答“我没有找到可用的工具”,或者工具能发现但调用一直转圈没响应。
原因:MCP工具列表为空,优先怀疑MCP Server进程没起来。stdio类型的Server要依赖Python或Node环境,脚本路径不对、解释器版本不对都会静默失败。工具调用超时,要么是Server的JSON-RPC响应不符合规范,要么是工具参数Schema定义太复杂,模型生成的参数校验不过。
解决:先手动在命令行执行MCP Server的启动命令,确认它能正常输出JSON-RPC的初始化响应。工具参数Schema尽量用基础类型,每个字段配description,避免用复杂的嵌套object,模型不是程序员,面对深嵌套结构很容易生成不合法参数。还有一个玄学点:MCP协议版本和客户端SDK版本不一致也会工具列表为空,排查时先确认两边协议版本对齐。
6. 再进一步:多路召回对抗RAG瓶颈,图像合成能力挂成工具
6.1 多路召回:向量检索加上关键词召回,别把宝押在一路上
纯向量检索的瓶颈是语义相似不等于答案存在。问“订单ORD-2024-001发货没”,这种精确编号查询,向量检索效果反而不如直接关键词匹配。LangChain4j的多路召回是组合多个ContentRetriever,把向量召回和关键词召回的结果合并去重后一起交给模型:
ContentRetriever multiRetriever = CompositeContentRetriever.builder() .contentRetrievers(List.of( vectorRetriever, // 向量语义召回 keywordRetriever // 基于Elasticsearch或本地倒排索引的关键词召回 )) .build();关键词召回可以用Elasticsearch的全文检索,或者轻量级方案用LanceDB的FTS全文索引。合并时按文档ID去重,给两条路各自设置不同的topK,向量路多给一些,关键词路精一点。我个人习惯是向量topK=5,关键词topK=3,合并后总量控制在7以内,防止把模型上下文撑爆。这个组合在专有名词、订单号、产品型号类问题上提升非常明显,值得做。
6.2 多模态图像合成:把本地图像生成服务封装为工具
标题里的多模态图像合成,落地路径是把图像生成能力封装成一个工具,让模型在对话中根据用户描述触发。本地用Stable Diffusion WebUI的txt2img接口做demo很合适,先起服务,再写一个工具方法:
@Tool("根据商品描述合成商品效果图,返回图片URL") public String generateImage(@Parameter("商品描述,包含颜色、材质、风格") String prompt) { // 调用本地 SD WebUI 的 txt2img 接口 Map<String, Object> body = Map.of( "prompt", prompt + ", product shot, studio lighting", "negative_prompt", "blurry, low quality", "steps", 20, "width", 512, "height", 512 ); // 用 RestClient 或 RestTemplate POST 到 /sdapi/v1/txt2img // 响应里取 images[0] 的 base64,解码后存到本地静态目录 // 返回 "http://your-server/images/xxx.png" return imageUrl; }steps=20是速度和质量的平衡点,太低出图粗糙,太高等待时间长。这个工具注册进AiServices后,用户说“帮我生成一款蓝色亚麻衬衫的效果图”,模型会自动调用它,返回图片URL给前端展示。图像合成和RAG可以联动:知识库里商品信息的检索结果作为prompt的一部分,让生成结果更贴合业务描述,这就是多模态在系统层面的闭环。
做这个项目时我最大的教训是别急着上复杂度。先把Ollama本地对话跑通,再叠加RAG,然后加工具调用和MCP,最后才碰多模态。每一步都验证清楚再走下一步,比一次性搭全套翻车后四处排查高效得多。工具参数不写清楚就上生产,模型会用五花八门的格式调用你的业务方法,这类问题只能靠经验和日志慢慢磨。希望这些踩坑记录能帮你在做LangChain4j + SpringBoot这套方案时少走几段弯路。希望帮到你。
本文还有配套的精品资源,点击获取