做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的Controller,Controller调用一个用@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,建议先升级,否则直接引入会报各种不兼容的错。我用的版本组合是:
| 组件 | 版本 |
|---|---|
| JDK | 17 |
| Spring Boot | 3.3.x |
| LangChain4j | 0.36.2 |
依赖方面,核心的Starter有两个,langchain4j-spring-boot-starter提供基础的自动配置支持,langchain4j-open-ai-spring-boot-starter是针对OpenAI兼容接口的模型接入。很多情况下,你只需要引入后面这个,它会把基础Starter传递依赖进来。Maven坐标如下:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId> <version>0.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()); List<TextSegment> segments = DocumentSplitters.recursive(300, 60) .split(document); List<Embedding> embeddings = embeddingModel.embed(segments).content(); embeddingStore.addAll(embeddings, segments); }DocumentSplitters.recursive(300, 60)的意思是每个分块最多300字符、重叠60字符。重叠部分的设计很妙,目的是避免一个完整句子恰好被从中间切开导致语义断裂。这里300和60不是拍脑袋定的,我试过200和400,200的块太小导致检索结果碎片化,400的块又太大,上下文窗口容易被无关内容占满,最终300字符配合60字符重叠,效果最好。
第二段是查询阶段。定义一个带检索能力的AI服务,核心是用EmbeddingStoreContentRetriever把向量库接进对话链路:
@Bean public ContentRetriever contentRetriever(EmbeddingStore<TextSegment> 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,自动配置的那个就会被忽略。
排查方法是先打开debug=true看自动配置报告,里面会列出哪些自动配置类生效、哪些被排除了,以及原因,这个比凭空猜有用得多。
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系统,这个组合可以省掉非常多的无用功。