做Java后端的这几年我心里一直有个疙瘩眼看着Python圈子把LangChain玩得风生水起各种Agent、RAG说搭就搭我们这边却只能守着Spring Boot写CRUD。不是没试过直接调大模型API但Prompt拼接、上下文管理、模型切换这些活全得自己手搓代码越写越像打补丁。直到我用LangChain4j和Spring做了一次深度整合才算是把这块短板补上了。这篇内容就是我从零开始把一个普通Spring Boot项目接上LangChain4j的完整记录包括依赖、配置、AI Service、流式响应、对话记忆和RAG每一步都有代码和踩坑过程给你省点弯路。标题里这个“4”可能是系列教程的第四篇但就内容而言它讲的就是Java生态里怎么把LLM能力无缝嵌进Spring。LangChain4j这个名字看起来眼熟其实和Python的LangChain没有代码关系是专门为JVM重写的框架。它的核心价值在于把大模型接入、提示词管理、上下文记忆、向量检索这些高频操作抽象成Java接口而Spring的自动配置和依赖注入恰好能把这一套编排得明明白白。适合谁看适合那些Spring Boot用得熟练、希望用Java原生方式做AI应用的开发者不管是接商业大模型API还是接本地部署的开源模型都能在这套方案里找到落点。1. 整合方案与设计思路为什么Java项目要这么接1.1 为什么是LangChain4j而不是继续手写HTTP调用很多团队一开始图省事直接用RestTemplate或者WebClient去调模型接口。这种方案对付一两个Demo还可以一旦业务复杂起来就扛不住了你要自己维护Prompt模板自己存对话历史自己处理流式解析模型服务商换了一个回调逻辑又要重写一遍。这些活跟业务本身没什么关系全是重复劳动。LangChain4j把这些通用能力沉淀成了一个个模块聊天模型归聊天模型嵌入模型归嵌入模型记忆归记忆检索归检索。你在代码里操作的是一组稳定的Java接口具体后面是GPT、通义千问还是本地Ollama都被隔离在配置层。这种设计思路其实和Spring的核心理念很像面向接口编程具体实现由容器注入。所以LangChain4j和Spring天然地合拍整合起来的成本比想象中低很多。1.2 整合之后的整体链路是什么样以我现在的项目为例请求的完整链路大致是这样用户在前端发来一句话请求打到Spring的ControllerController调用一个用AiService定义的接口LangChain4j在运行时自动把用户的输入和可选的对话记忆、检索到的参考资料一起组装成提示词发送给大模型模型返回结果后再原路带回来。整个过程里业务代码只写了两样东西接口定义和配置项剩下的编排逻辑全在框架内部完成。这个链路里Spring扮演的角色很关键模型实例、记忆存储、向量库、内容检索器都是Spring容器里的Bean通过application.yml统一配置。换模型厂商的时候不需要动业务代码改配置就行。这也是我这次整合过程中体会最深的一点你可以先把整套架子搭好模型底座想换随时换。2. 依赖引入与基础配置把模型接进Spring的两种方式2.1 版本要求与依赖清单先说一下硬性条件。LangChain4j的Spring Boot Starter要求JDK 17以上Spring Boot 3.x如果你还在用Spring Boot 2.x建议先升级否则直接引入会报各种不兼容的错。我用的版本组合是组件版本JDK17Spring Boot3.3.xLangChain4j0.36.2依赖方面核心的Starter有两个langchain4j-spring-boot-starter提供基础的自动配置支持langchain4j-open-ai-spring-boot-starter是针对OpenAI兼容接口的模型接入。很多情况下你只需要引入后面这个它会把基础Starter传递依赖进来。Maven坐标如下dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai-spring-boot-starter/artifactId version0.36.2/version /dependency如果你打算用本地部署的Ollama跑通全流程就换成langchain4j-ollama-spring-boot-starter。这一类Starter做的事情说白了就是根据配置文件自动创建ChatModel、StreamingChatModel、EmbeddingModel这些Bean注册到Spring容器里。你不需要自己手动new对象。2.2 application.yml里的核心配置项配置这块直接决定你能不能跑起来我贴一下我项目里的配置langchain4j: open-ai: api-key: ${AI_API_KEY:sk-xxxx} base-url: ${AI_BASE_URL:https://api.openai.com} chat-model: model-name: gpt-4o-mini temperature: 0.7 max-tokens: 4096 log-requests: true log-responses: true embedding-model: model-name: text-embedding-3-small几点说明。api-key和base-url我都用了环境变量占位符避免把密钥写死在代码仓库里。chat-model下面配置的是对话模型的参数temperature控制答案随机性做客服问答我习惯调到0.3左右做头脑风暴类场景可以放到0.8。embedding-model是向量化模型做检索的时候才会用到。log-requests和log-responses建议开启调试阶段能看到实际发给模型的请求内容排查Prompt问题非常有帮助。如果你用的是兼容OpenAI接口的第三方服务只需要把base-url改成服务商提供的地址模型名称改成对方支持的模型名就行。LangChain4j对这类兼容接口的适配做得相当好这也是它比直接绑死某一家厂商的封装库更实用的原因。2.3 用Ollama本地模型跑通全流程的配置很多时候开发阶段直接用远程大模型API调试成本高、反馈慢我习惯在本地用Ollama先跑通逻辑。方式很简单引入对应的Starter然后配置换成这样langchain4j: ollama: base-url: http://localhost:11434 chat-model: model-name: qwen2.5:7b temperature: 0.7 embedding-model: model-name: nomic-embed-textOllama跑起来之后把base-url指向本机的11434端口模型名还按上面的来。这样你在本地改代码、验证链路都很快等逻辑稳定了再切回远程模型配置文件一改就行业务代码一行不用动。这也是我强烈建议你从一开始就使用Spring配置方式接入模型的原因可替换性比在代码里写死要强太多了。3. 声明式AI服务从第一个聊天接口到流式推送3.1 从直接注入ChatModel开始理解底层在接触AiService之前有必要先看一眼最基础的用法知道框架底层到底在做什么。Spring容器里现在有ChatModel这个Bean你可以直接在Service里注入它Service public class ChatServiceImpl implements ChatService { private final ChatModel chatModel; public ChatServiceImpl(ChatModel chatModel) { this.chatModel chatModel; } public String chat(String userMessage) { return chatModel.chat(userMessage); } }ChatModel接口的chat方法接收一个字符串返回一个字符串表面看简单得有点过分其实内部完成了一整套模型调用的流程。你还能传入ChatRequest对象做更精细的控制比如携带历史消息、附带工具调用等。这种方式适合那些需要高度自定义的场景不过日常业务里用起来还是不够“Spring”。3.2 AiService声明式接口最省心的业务接入方式LangChain4j借鉴了Spring Data的套路直接用接口定义AI服务框架在运行时动态生成实现。用法是这样的定义一个接口加上AiService注解和UserMessage注解AiService public interface ChatAssistant { UserMessage(你现在是一个专业的Java技术顾问请用简洁清晰的语言回答用户的问题。) String chat(UserMessage String userMessage); }把这个接口注入到Controller里直接就能用了RestController public class ChatController { private final ChatAssistant chatAssistant; public ChatController(ChatAssistant chatAssistant) { this.chatAssistant chatAssistant; } PostMapping(/chat) public Result chat(RequestBody ChatRequest request) { String answer chatAssistant.chat(request.getMessage()); return Result.success(answer); } }代码信息密度很高。UserMessage定义了系统级别的角色提示词方法参数上的UserMessage这个位置用法略有区别实际场景中更常用V做变量注入会把用户的实时输入动态拼进Prompt。你可以在提示词里用{{变量名}}占位符然后在方法参数上通过V(变量名)传值AiService public interface ChatAssistant { UserMessage(你是{{role}}请用{{style}}的风格回答我{{question}}) String chat(V(role) String role, V(style) String style, V(question) String question); }这种写法把Prompt模板和调用参数彻底解耦改提示词的时候不用动Java代码。从我的使用体验来说AiService这层抽象是LangChain4j最值得用的功能它让业务代码几乎没有任何AI调用的痕迹读起来就是一个普通Spring服务。3.3 流式输出从一行行等结果到实时打字机效果大模型生成内容有几秒的延迟如果让用户干等一个完整JSON回来体验非常糟糕。所以要接流式输出让内容一个Token一个Token地往客户端吐。LangChain4j的流式方案是这样的AiService public interface StreamingChatAssistant { UserMessage(用流畅的中文回答用户问题) void stream(UserMessage String userMessage, TokenStream tokenStream); }注意这里方法返回类型是void但多了一个TokenStream参数。调用方通过TokenStream注册回调tokenStream .onPartialResponse(System.out::println) .onCompleteResponse(response - { // 完整响应这里可以拿到Token用量统计 log.info(complete response: {}, response.aiMessage().text()); }) .onError(error - { log.error(stream error, error); }) .start();在Web场景里可以结合SseEmitter把每一个onPartialResponse回调里的内容推给前端实现类似ChatGPT的打字机效果。我实际做的时候发现一个容易踩坑的地方就是start()千万别漏调漏了的话回调方法一个都不会执行而且还不报错排查起来很迷惑。后来我养成一个习惯写完流式方法先看一眼是不是每个调用链上都记得调了start()。3.4 异步执行与超时控制流式接口不能阻塞Servlet线程太久所以我通常会把AI调用丢到独立的线程池里执行或者直接用Spring的Async注解。如果你用的是Spring Boot 3.x配置一个简单的线程池Bean就行Bean public Executor aiExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ai-request-); executor.initialize(); return executor; }线程池参数要结合模型响应速度和你自己的接口QPS来定不要拍脑袋设一个最大值就不管了。另外如果你的模型服务商API经常慢建议在客户端侧加一个超时时间。OpenAI兼容接口的Starter在底层用的是Java HTTP客户端配置超时的方式是在ChatModel构造时指定如果完全依赖Starter自动配置可能需要自己覆盖Bean定义。这块我建议在集成测试阶段就压出合理的超时阈值。4. 对话记忆与RAG让模型记住上下文并按资料回答4.1 对话记忆的三种实现方式默认情况下模型是没有记忆的每次请求都是一次独立的对话。要做多轮聊天就必须把历史消息打包后发给模型。LangChain4j有几种现成的记忆方案按复杂程度排序MessageWindowChatMemory只在内存里保存最近N条消息适合单机Demo和原型验证。PersistentChatMemoryStore把消息存到外部存储Redis、数据库重启不丢适合多实例部署。自定义ChatMemoryStore根据业务灵活定制比如按用户维度存、按会话维度存、加过期时间等。Spring整合里的用法很简单在AiService接口方法上加一个MemoryId参数AiService public interface ChatAssistant { UserMessage(你是一个乐于助人的助手) String chat(MemoryId Long sessionId, UserMessage String userMessage); }sessionId就是业务侧的会话ID同一个ID的对话会被LangChain4j自动维护成一组上下文。框架底层有一个ChatMemory的Bean它知道当前memoryId对应哪些历史消息并自动把它们放进去。但这里有个特别需要注意的坑默认的ChatMemory是进程内内存多实例部署时用户请求落到不同节点各自维护各自的上下文聊天记录就“分裂”了。我在一台服务器上单测没发现问题压测时一上多实例立刻露馅。解决方式是在要用Redis这类外部存储实现ChatMemoryStore。我之前用一个简单的Jdbc实现三个方法重写一下把消息序列化进表里效果非常稳。4.2 RAG完整落地从文档加载到检索问答RAG检索增强生成的原理一句话概括先把知识文档切块、向量化、存储到向量库用户提问时先从向量库里检索出相关内容再把问题和检索结果一起发给大模型让模型基于资料作答。这样做的好处是模型不需要“记住”你的私有知识也不容易一本正经地胡说八道。LangChain4j的RAG链路分两段。第一段是文档的导入和向量化下面这是我在项目里实际跑通过的代码public void importDocument() { Document document DocumentUtils.documentFromFile( /data/faq.txt, new TextDocumentParser()); ListTextSegment segments DocumentSplitters.recursive(300, 60) .split(document); ListEmbedding embeddings embeddingModel.embed(segments).content(); embeddingStore.addAll(embeddings, segments); }DocumentSplitters.recursive(300, 60)的意思是每个分块最多300字符、重叠60字符。重叠部分的设计很妙目的是避免一个完整句子恰好被从中间切开导致语义断裂。这里300和60不是拍脑袋定的我试过200和400200的块太小导致检索结果碎片化400的块又太大上下文窗口容易被无关内容占满最终300字符配合60字符重叠效果最好。第二段是查询阶段。定义一个带检索能力的AI服务核心是用EmbeddingStoreContentRetriever把向量库接进对话链路Bean public ContentRetriever contentRetriever(EmbeddingStoreTextSegment embeddingStore, EmbeddingModel embeddingModel) { return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build(); }maxResults控制回复时取几个分块。我在一个售后知识问答项目里做过对比取3块比较合适既能覆盖问题涉及的多个方面又不至于让Prompt过于臃肿。minScore是相似度下限低于这个阈值的内容会被丢弃避免返回一堆不相干的东西误导模型。然后在手动构建AI服务时把contentRetriever接进去RagAssistant assistant AiServices.builder(RagAssistant.class) .chatModel(chatModel) .contentRetriever(contentRetriever) .build();如果用的是AiService自动发现可以通过自定义ContentRetriever的Bean来覆盖默认行为LangChain4j的Spring整合支持按类型自动装配。RAG跑通之后整个项目就是“本地知识库大模型”的构型了业务数据不用拿去训练也能让模型开口说出库里的内容。4.3 直接发给模型的Prompt长什么样为了让你更直观地理解上述RAG链路做了什么我把框架发给模型的Prompt大致形态还原一下。前半部分是系统提示后半部分会加上检索到的资料块模型被要求基于这些资料作答。你可以开启log-requests看真实请求调试阶段几乎每一次都要核对这个内容确认检索块确实是按预期拼进去的。这个过程中最常见的坑是检索到的内容质量不佳。如果你的文档分块太大关键信息被淹没在无关文本里模型就会“找不到重点”。如果minScore设得太低比如0.2一堆弱相关片段也会被送进去模型会被噪音带偏。我一般建议从0.5到0.7之间开始调先看几组实际检索的分数分布再决定阈值。5. 常见问题与排查实录5.1 版本冲突和依赖混乱Spring Boot对Starter依赖的版本管理很敏感LangChain4j又依赖了不少开源组件比如Jackson、protobuf版本冲突几乎是每个接入者都会遇到的第一个坎。最典型的报错是NoSuchMethodError或者ClassNotFoundException出现这种问题先别怀疑框架八成是依赖版本乱了。我的排查习惯是三步走。先查看依赖树定位重复的组件和版本差异再检查是不是Spring Boot的版本太高或太低尤其是Spring Boot 3.2和3.3之间对某些底层库的版本要求有变化最后看LangChain4j官方文档里标注的兼容版本范围。在看依赖树的时候重点关注几个容易出问题的库它们往往被多个组件传递依赖冲突概率最高。5.2 配置了Starter却报找不到Bean这个问题的典型表现是自动配置类没有生效报错说找不到ChatModel或EmbeddingModel。排查看三种可能。第一Starter引入错误。比如你配了langchain4j-open-ai-spring-boot-starter但配置项的前缀写成了Ollama的自动配置类会因为缺少匹配的属性而放弃创建Bean。第二配置前缀不对langchain4j.open-ai和langchain4j.ollama对应不同的绑定类写错一个字母都绑定不上。第三自动装配被排查Spring Boot的EnableAutoConfiguration也有一票否决的机制你项目里如果手动定义了一个同类型的Bean而且标了Primary自动配置的那个就会被忽略。排查方法是先打开debugtrue看自动配置报告里面会列出哪些自动配置类生效、哪些被排除了以及原因这个比凭空猜有用得多。5.3 流式接口不回数据start()漏调是常见原因前面提过了。还有一个高频问题用SseEmitter配合Servlet异步时明明onPartialResponse里有输出但前端收不到任何内容。这大概率是忘了设置SseEmitter的超时时间默认超时是30秒而大模型流式输出超过30秒很常见超时之后连接直接断掉前端看到的表现就是“一直没数据”。解决方案是把SseEmitter的超时时间适当调大同时在前端把请求超时时间也调到同步的位置两边对齐才会有一个完整的体验。另外注意在onCompleteResponse结束后主动complete()不然连接会一直挂着浪费连接资源。5.4 AI请求引发的线程安全问题Java的HttpClient和LangChain4j内部的状态管理大部分是线程安全的所以并发调用ChatModel或者AiService一般没问题。但要注意在自定义的ChatMemoryStore实现里不要用简单的HashMap多线程读写同一个会话上下文时会有一致性和可见性问题。我自己早期用HashMap实现过一个内存版压测直接打出了脏数据后来换成ConcurrentHashMap并且对单个会话加读写锁问题才消失。还有一个容易忽略的点不要在回调方法里做线程阻塞操作。如果你在onPartialResponse回调里同步写数据库等于把整个流式链路拖慢最终导致模型响应变慢、连接超时。解决办法是回调里只做轻量的转发持久化操作丢给异步线程池去处理。写在最后的几个体会把Spring和LangChain4j整合起来做了几个项目之后我的一个明显感受是Java做AI应用不再是一件别扭的事情。以前我们总觉得AI开发是Python团队的专利现在LangChain4j这个框架配合Spring的成熟生态完全能让后端团队用自己熟悉的方式把大模型能力落地到业务里这比临时拉一个Python团队效率高得多。我自己的一个经验是刚开始不要急着上各种高阶玩法先把一个最简单的AiService跑通再用流式替换非流式接着加对话记忆最后再上RAG。每加一块验证一块有问题能立刻定位到具体模块。这个顺序非常推荐你试试。如果你的业务正好需要快速把大模型接进现有Spring系统这个组合可以省掉非常多的无用功。