Java生态做大模型应用开发,很长时间里一直有个尴尬:Python那边各种编排框架一轮接一轮,Java开发者要么直接裸调HTTP接口,要么自己撸一套提示词管理、上下文维护、知识库检索的工具链。LangChain4j就是来补这个位子的——它把大模型编排能力完整搬到了JVM生态,让你能用Spring Boot的直觉去做RAG、Agent、结构化输出这些事。这篇文章我会从框架定位、快速上手、核心组件一直拆到RAG实战和踩坑经验,把LangChain4j的完整学习路径串一遍,适合准备在Java项目里接大模型、做知识库问答,或者正在选型AI开发框架的读者。
1. LangChain4j是干什么的?先弄懂它解决什么问题
1.1 Java接大模型的三个老大难问题
很多人第一次在Java项目里接大模型,第一反应是直接用HTTP客户端调API。跑通一个最简单的对话,确实只要几十行代码,但做到真实业务里,三个问题立刻冒出来。
第一个问题是上下文管理。大模型本身是没有记忆的,每次对话要把历史消息拼成一个Messages数组发过去。用户多问几轮,你就要手动维护这个数组:哪些该保留、哪些该裁剪、token超了怎么办。第二个问题是输出不可控。同一个JSON字段,模型可能这次给字符串、下次给数字,你要写一堆解析容错代码。第三个问题是模型厂商一换,全部重写。今天接这家API,明天换那家,请求格式、鉴权方式、流式返回都不一样,封装的代码跟着改,维护成本高到你怀疑人生。
如果只是写个demo玩,这些问题忍忍也就过去了。真到生产环境,还要面对知识库检索、工具调用、多轮对话、流式响应这些更高层的需求,靠裸调API去堆,代码会迅速失控。
1.2 LangChain4j的设计思路:接口抽象与模块拆解
LangChain4j解决这些问题的方式,可以概括成两句话:面向接口编程,按功能拆模块。
核心库定义了一套通用的接口和数据结构。比如ChatLanguageModel表示对话模型,EmbeddingModel表示向量模型,ChatMessage是一组通用的消息类型,EmbeddingStore是向量存储的统一入口。你的业务代码依赖的是这些抽象接口,而不是某一家厂商的具体实现。
真正和厂商API打交道的代码,被隔离在单独的模块里。想接哪家服务,就往pom里加对应的依赖,然后在builder里配置模型名、密钥、地址就行。换模型厂商的时候,业务代码基本不用动,改配置和依赖就够了。
模块化也体现在功能边界上。文档解析、向量存储、聊天记忆、模型服务这些能力都是独立模块。你只需要引入自己用得到的那几个包,不会出现一个框架拖进来一大堆用不上的依赖。
1.3 什么场景适合用,什么场景不建议硬上
聊完设计思路,再说说我的判断。LangChain4j适合的场景很明确:你是Java技术栈、要在业务系统里做大模型集成、希望把提示词管理、上下文记忆、知识库问答这些能力沉淀成可维护的工程化代码。尤其是团队已经使用Spring Boot,它的Spring Boot Starter集成做得相当顺,配置风格和Spring的约定一致,团队上手成本很低。
但有些场景我不推荐硬上。如果你的需求就是单次调用API、返回结果打个日志就完事,引入整个框架反而笨重,不如直接写个简单的HTTP工具类。如果你们的流量大到对单次请求延迟极其敏感、需要自己完全掌控网络层和序列化细节,框架的抽象层反而可能成为干扰项。框架解决的是编排问题,不是性能问题,这个边界要分清。
2. 十分钟跑通第一个对话程序
2.1 环境准备与依赖引入
开始写代码之前,先把环境准备好。LangChain4j对Java版本有要求,我建议直接用JDK 17或者更高版本,Spring Boot项目的话用3.x。Maven和Gradle都支持,下面以Maven为例。
基础的依赖是这几个:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.36.2</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.36.2</version> </dependency>第一个依赖是核心库,包含接口定义、消息模型、记忆、AI Service这些基础能力。第二个依赖是OpenAI兼容协议的实现模块。现在很多大模型服务都提供OpenAI兼容接口,用同一个协议就能对接,这是目前最通用的接入方式。如果你用的是本地部署的模型服务,通常也会提供兼容端点,把下面的baseUrl换成你本地服务的地址就行。
版本号我用的是0.36.x系列,LangChain4j版本迭代很快,API在不同版本之间会有调整。你引入时去Maven仓库看一眼最新版本,以你实际使用的版本为准。
2.2 用代码创建一个ChatModel
创建对话模型是整个框架最简单的入口。代码如下:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("sk-你的密钥") .baseUrl("https://你使用的模型服务地址/v1") .modelName("your-chat-model") .temperature(0.7) .build(); String answer = model.generate("用一句话介绍你自己"); System.out.println(answer);这段代码做了三件事:指定模型服务地址和鉴权信息、选择具体模型名称、设置生成参数。temperature控制随机性,数值越高回答越发散,做客服问答我喜欢调到0.3到0.5,做头脑风暴才调高。
关键点是这个model对象是ChatLanguageModel接口类型,你的代码里所有业务逻辑都依赖这个接口。后续想换模型,把builder这部分的实现换掉,调用方完全无感。这就是前面说的面向接口编程,第一次跑通的人可能觉得多此一举,等真换了模型服务就会发现真香。
2.3 流式对话和基本配置项
对话场景里,用户最直观的体验是"字一个个蹦出来",这需要流式输出。LangChain4j里流式模型是另一个接口,叫StreamingChatLanguageModel:
StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .apiKey("sk-你的密钥") .baseUrl("https://你使用的模型服务地址/v1") .modelName("your-chat-model") .build(); model.generate("给我讲个笑话", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println("\n--- 完成 ---"); } });流式回调里的onNext会不断收到增量文本,onComplete是全部生成完毕。实际项目中,你在onNext里把token推给前端,SSE或者WebSocket都行。
这里有个小坑要提醒:流式接口和同步接口是两套模型类,不能直接互相转换。如果你的逻辑里既需要流式也需要同步,两个对象都建,反正常量配置可以抽出来复用。
3. 拆解核心组件:消息、记忆、AI服务和工具调用
3.1 ChatMessage体系:大模型看到的"上下文"长什么样
跑通简单对话之后,下一步要理解消息体系。LangChain4j把对话内容抽象成几种消息类型:SystemMessage是系统提示词,用来设定角色和行为边界;UserMessage是用户输入;AiMessage是模型回复;ToolMessage是工具调用返回的结果。
为什么要设计这么一套消息结构?因为大模型API本身就是无状态的,你发给它什么消息列表,它就基于这个列表生成回复。前面说过,多轮对话时你要手动把历史消息拼接起来。LangChain4j做的事情就是帮你把这套消息列表标准化,并提供管理手段。
看一个最基础的多轮消息拼接:
List<ChatMessage> messages = new ArrayList<>(); messages.add(SystemMessage.from("你是一个技术文档助手,回答要简洁准确。")); messages.add(UserMessage.from("什么是LangChain4j?")); messages.add(AiMessage.from("它是Java生态的LLM编排框架。")); messages.add(UserMessage.from("它的RAG怎么实现?")); String answer = model.generate(messages);这段代码里,我把之前的对话历史原封不动塞回了消息列表。实际开发中你是不会手写这个列表的,因为历史消息会越来越多,你需要一个东西帮你在内存里维护和裁剪它,这就是ChatMemory。
3.2 ChatMemory:让对话记住上下文,而不是每次重发
直接往消息列表里堆历史,很快会遇到token超限的问题。LangChain4j提供了ChatMemory接口和默认实现,我日常用得最多的是MessageWindowChatMemory。
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .chatMemoryProvider(DefaultChatMemoryProvider.builder().build()) .build(); chatMemory.add(UserMessage.from("我叫A同学")); chatMemory.add(AiMessage.from("好的,A同学。")); String answer = model.generate(chatMemory.messages());MessageWindowChatMemory翻译过来是"消息窗口记忆",核心逻辑就是只保留最近N条消息,超出窗口的旧消息自动丢弃。这个方式简单粗暴但非常有效,它基于"太远的对话对当前回复几乎没有帮助"这个经验直觉。
但是注意,只按条数裁剪有个隐患:如果单条消息特别长,窗口太小会截断上下文,一条消息特别短时又会浪费窗口。更精细的做法是按token数量裁剪,这就要引入Tokenizer。Tokenizer负责把消息切成token序列,MessageWindowChatMemory也可以设置maxTokens而不是maxMessages。按token裁剪更贴合模型的上下文长度限制,但需要额外引入对应模型的Tokenizer实现,设置起来也多一步。新手先用maxMessages跑通,等业务需要再换成token方式。
3.3 AiServices:把一次LLM调用写成一个Java接口
如果说ChatMemory解决的是"上下文管理"的脏活,那AiServices解决的就是"业务代码和LLM交互之间的胶水代码"。这个设计我非常喜欢,它的思想是:把一次LLM交互声明成一个Java接口,方法上打注解,框架帮你生成实现。
interface Assistant { @SystemMessage("你是一个专业的技术客服,回答要耐心。") String chat(@UserMessage String userText); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); String reply = assistant.chat("我的服务启动失败了,怎么办?");看到没有,你完全不写提示词拼接、不关心消息列表构造,业务代码里就是一个普通接口调用,单元测试的时候甚至可以用Mock实现替换掉AI逻辑。这让上层代码变得非常干净,也让AI能力可以和Spring的依赖注入无缝配合——把assistant声明成Bean,注入到任意Service里就行。
AiServices还支持把LLM的返回值直接映射成自定义Java对象,这就是结构化输出。你可以定义一个带字段的DTO,声明接口方法返回它,框架会引导模型按你的描述生成JSON并反序列化。这个能力在做信息抽取、表单生成这类需求时特别高效。我自己的实践是,凡是和业务强相关的LLM交互,都优先考虑定义成AiService,纯聊天的场景才直接用model.generate。
3.4 Tool Calling:让大模型学会"调你的方法"
前面那些能力都还是"文本进、文本出",Tool Calling让大模型真正触达了你的系统和数据。它的原理简单说就是:你得告诉模型"有哪些函数可以调用",模型在生成回答时如果觉得需要某个函数,会返回一个调用请求,框架帮你执行真实的Java方法,再把结果传回模型,模型基于结果生成最终回复。
在LangChain4j里,定义一个给模型用的工具方法只需要一个注解:
class OrderTool { private final OrderService orderService; @Tool("查询指定订单的当前状态,参数是订单号") public String queryOrderStatus(String orderId) { return orderService.queryStatus(orderId); } } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTool()) .build();当用户问"我的订单A12345到哪一步了",模型看到queryOrderStatus这个工具的说明,就会自动提取订单号、发起调用、拿结果组织回答。整个链路里,模型不只是"一个会说话的接口",而是变成了能读写数据、能触发动作的执行入口。
这里有几个要点。工具的注解描述一定要写清楚,模型靠你这段描述决定什么时候调用,描述含糊它就不敢调或乱调。工具方法返回的字符串是给模型看的,尽量返回结构化、信息完整的文本,模型才能据此组织准确回复。最后,Tool Calling需要模型本身支持function calling,选模型时要确认这个能力。
4. RAG实战:拿自己的文档做知识库问答
4.1 RAG到底在做什么:先搞清楚五个环节
RAG,检索增强生成,解决的核心问题是让模型回答"你资料库里才有答案"的问题。比如你有一份几十页的内部运维手册,想让同事用自然语言提问时得到基于这份手册的准确回答,模型没读过这份手册,但RAG可以把它喂进去。
一个完整的RAG链路有五个环节:文档加载、文档分片、内容向量化、向量检索、结合检索结果生成回答。前四步是把你的知识变成"模型能检索的东西",最后一步是让模型看着检索到的资料说话。
理解了这五个环节,你就知道LangChain4j对应的组件是什么了:Document负责加载原始文档,DocumentSplitter负责拆片,EmbeddingModel负责向量化,EmbeddingStore负责存向量,ContentRetriever负责检索。下面按顺序走一遍。
4.2 文档加载与分片:PDF和Word进来,怎么变成chunk
文档加载在LangChain4j里异常简单,核心类就是Document:
Document document = Document.fromFile( Path.of("运维手册.pdf"), new ApachePdfBoxTextExtractor() );不同格式的文档需要不同的解析器,处理PDF用ApachePdfBoxTextExtractor,处理Word可以在pom里引入对应的document-parser模块,然后换成对应的TextExtractor。注意一点,扫描版PDF(图片形式)需要OCR才能提取文字,纯文本提取器是拿不到内容的,手动确认你的PDF是不是文本型。
加载进来的文档很可能又长又乱,直接把整个文档塞给向量化模型是不行的。模型对单次输入有长度限制,而且一个长文档的语义太宽泛,检索时向量匹配精度会下降。所以要对文档做分片,LangChain4j的splitter组件就是干这个的:
DocumentSplitter splitter = DocumentSplitters.recursive(500, 50); List<TextSegment> segments = splitter.split(document);这个例子里的500是每个片的目标长度(字符数),50是片与片之间的重叠长度。为什么要重叠?因为一个句子被拦腰截断后语义就散了,让前后片共享一部分内容,检索时不容易漏掉关键信息。具体数值取决于你的文档语言:中文我习惯按500到800字符一片,英文可以适当放宽,但别超过向量模型的上限。
4.3 Embedding与向量存储:把文本变成模型能"算"的东西
分片之后,每一片TextSegment要变成一个向量。这个向量是文本在高维空间里的数学表示,语义相近的两段文本,它们的向量在空间里也相近。向量化的代码如下:
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey("sk-你的密钥") .baseUrl("https://你使用的模型服务地址/v1") .modelName("your-embedding-model") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment).content(); store.add(embedding, segment); }这段代码把每一片文本都转成向量,存进了EmbeddingStore。EmbeddingStore你可以理解成一个专门用来做相似度搜索的数据库,同一片文本的原文(TextSegment)和向量是绑定存储的,检索时按向量距离从小到大排序找到最相似的几片。
我用的是InMemoryEmbeddingStore,所有向量都放在JVM内存里,项目重启数据就没了,所以它只适合学习和demo。生产环境需要用持久化的向量存储,LangChain4j对PGVector、Redis、以及一些云向量库都有兼容模块,换个实现类就行,代码结构和这里完全一样。
4.4 检索与生成:让答案从你的文档里来
向量库有了,接下来训练"召回的机器"。LangChain4j里负责检索的组件是ContentRetriever:
EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.75) .build();maxResults是每次检索返回几条最相关的文本片段,minScore是相似度的最低门槛。这两个参数直接影响回答质量:数量太少可能信息不足,数量太多又会把不相关的噪音塞给模型;门槛设得太高会经常搜不到东西,太低又会拿一堆不相关内容凑数。我一般从maxResults=3、minScore=0.7起步,根据实测问答效果微调。
检索本身跑通之后,把检索和生成拼起来。你可以用AiServices的withRetriever方式,也可以手动在问答逻辑里先调retriever取回相关片段,再拼进prompt。我习惯用AiServices整合,因为它把链路封装得最干净:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build(); String answer = assistant.chat("备份任务失败了应该先查哪个日志?");注意一个关键点:入库时用的embedding模型和检索时用的embedding模型必须是同一个。换模型等于换了向量空间,之前存进库里的向量全部作废,检索结果会莫名其妙地差。
4.5 从demo到生产:换个持久化存储没那么难
InMemoryEmbeddingStore好用但不持久,这是很多新手项目上线没多久就撞上的墙。从demo走向生产,第一件事就是把向量存储换掉。
以PGVector为例,你需要引入langchain4j-pgvector模块,然后在程序里创建一个PGVectorEmbeddingStore。它的构建参数比内存版多几个,主要是表名、维度数和距离类型:
EmbeddingStore<TextSegment> store = PgVectorEmbeddingStore.builder() .host("localhost") .port(5432) .database("ai_chat") .user("postgres") .password("password") .table("document_chunks") .dimension(1536) .build();dimension必须和你的embedding模型输出的维度一致,不同模型输出的向量维度不一样,这个参数填错了运行时一定会报错。除了存储换掉,索引、清理策略、权限控制这些运维问题也要跟上,但框架层面给到你的就是"换个store实现"这一步,复杂度可控。
走到这一步,一个可以上线跑的知识库问答已经成型了。后续如果文档量大,还可以加定时异步更新、增量索引,但核心骨架你已经有了。
5. 踩坑实录:我在LangChain4j上遇到的那些问题
5.1 高频问题速查表
纸上谈兵没意思,把我在实际项目里、以及带新人时反复遇到的高频问题整理成了一张表,按这个排查能省下大半天:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 调用时报模型名称错误 | modelName填的与模型服务开放的不一致 | 查服务商文档确认模型名,不要照抄网上示例 |
| 中文知识库检索结果相关度低 | 分片过大或过小,切片切碎了语义 | 降低分片大小,增大overlap,换中文友好的embedding模型 |
| 对话轮数一多就报token超限 | 没有配置ChatMemory,或窗口设太大 | 用MessageWindowChatMemory限制maxTokens |
| 项目重启后知识库问答"失忆" | 用了InMemoryEmbeddingStore | 换成PGVector等持久化向量库 |
| RAG回答"找不到资料"但库里明明有 | 入库和检索用了不同的embedding模型 | 统一使用同一个embedding模型 |
| 工具调用不触发/拿到错误参数 | 工具描述含糊,或模型不支持function calling | 把@Tool描述写细,换支持function calling的模型 |
| 返回内容格式不稳定 | temperature太高 | 结构化输出或客服场景把temperature调到0.3以下 |
这张表里的问题不是理论猜测,基本上都是新手期必踩的坑。特别是embedding模型不一致那个,我见过不止一个人折腾半天,最后发现是测试环境拿两个不同的模型分别跑了入库和检索。
5.2 调试和排查要养成的两个习惯
用LangChain4j调AI应用,最难受的一点是"看起来都对,结果不对"。我调试时有两个非常管用的习惯。
第一个是打印prompt。模型回答不符合预期,九成原因出在提示词上,而不是代码Bug。不要直接在代码里瞎改参数,把真正发给模型的消息列表原样打出来,看看省略了什么、顺序对不对、格式乱不乱,问题一眼就能看出来。LangChain4j的消息结构是可打印的,ChatMemory里的messages列表打个日志成本很低,但效果巨大。
第二个是关注相似度分数。RAG检索出来的内容对不对,别凭感觉判断。把EmbeddingStoreContentRetriever检索结果的score打印出来,看看用户问题的向量和召回到的文档片是否真的足够接近。如果score一直偏低,说明你的分片策略或embedding模型不太适配,这个时候调splitter参数比调prompt更有效。
5.3 几个值得记住的避坑建议
最后分享几个长期实践中总结的建议,每个都是从真实事故里换来的。
第一,别在生产环境用InMemoryEmbeddingStore。不只是数据会丢的问题,它还占JVM堆内存,文档库一大,GC压力会让你怀疑人生。
第二,流式响应不要跨线程共享模型实例。StreamingChatLanguageModel内部的连接状态不是线程安全的,并发场景要按请求维度创建实例,或者用连接池管理。
第三,注意依赖版本冲突。LangChain4j会传递依赖一些HTTP客户端和JSON库,如果你的项目里已经有其他版本,找包冲突的时间可能比写代码还长。遇到莫名其妙的序列化异常,先检查jackson版本。
第四,不要一上来就搞Agent。先跑通对话,再加RAG,最后引入Tool Calling。每一步都验证稳定了再叠加,不然出了问题你根本分不清是哪一层在捣乱。
我个人在实际操作中的体会是,LangChain4j真正值钱的地方不在于某个炫酷特性,而在于它逼着你想清楚整个AI应用链路的结构:消息怎么组织、记忆怎么管理、知识怎么检索、工具怎么暴露。把这一套捋顺了,后续不管模型服务商怎么换、模型怎么升级,你的应用代码都能稳如磐石。这也是我建议你学习这个框架时,多花点时间在核心抽象和设计思路上,而不是急着跟版本号追新功能的原因。