先说个真实感受:在Java生态里做LLM应用,过去很长一段时间都处于“看得到吃不到”的状态。Python那边LangChain、LlamaIndex玩得飞起,各种Agent、RAG、Memory组件随手一拼就是一个智能应用,而Java工程师想接大模型,往往只能自己封装HTTP请求、自己维护对话状态、自己写解析逻辑……不是说不行,就是太原始、太碎了,维护成本很高。直到LangChain4j出现,这个局面才算真正被打破。
LangChain4j,简单说就是把LangChain那一套“用大模型构建应用”的抽象能力搬到了Java世界,专门解决Java工程师接入LLM时最头疼的工程化问题。它是一个开源框架,核心目标是给Java/Kotlin/Android开发者提供一套完整的LLM应用开发工具链:对话管理、结构化输出、工具调用、Memory记忆、RAG检索增强、多模型切换等等,都能通过统一API搞定。本文要讲的这套新手实战教程,我尽量不堆概念,全程用可运行的代码和真实踩坑经历来带你走一遍,适合已经会用Spring Boot、但对LangChain4j完全陌生的Java后端开发,也适合那些被Python版LangChain“劝退”、想留在Java体系内做AI应用的同学。
1. 为什么Java团队应该认真考虑LangChain4j
1.1 从一次“手搓OpenAI客户端”的教训说起
先说一个我自己的项目经历。之前给公司做内部知识库问答机器人,第一版图省事,直接用RestTemplate封装了OpenAI的Chat Completions接口。核心逻辑很简单:把用户问题拼进Prompt,加上历史消息数组,POST出去,解析返回的JSON。跑通demo只花了一个下午,心里还挺美。
结果上线的第一个月就出事了。第一,多轮对话的历史消息管理完全靠手动拼List,用户对话一长,Token直接爆掉,时不时要自己截断;第二,模型偶尔返回非法JSON,解析崩溃,系统直接抛异常;第三,想给模型加个“查数据库”的能力,得自己写函数调用的协议,复杂到想骂人;第四,后来要换国内某家大模型,发现人家的请求格式跟OpenAI不完全一致,又要改一层适配……到那个阶段我就明白了:LLM应用的复杂度不在“调API”,而在API之外的工程问题。
LangChain4j恰恰是把这些“API之外的事”都抽象好了。它内置了统一的消息协议、内存管理策略、函数调用注册机制、模型适配层,让我从“面向JSON编程”回到“面向业务编程”。这一点,是用过的Java开发者都能明显感知到的差异。
1.2 LangChain4j与纯手写、Spring AI的横向对比
很多Java工程师第一次接触这个领域时会纠结:有现成的HTTP客户端,为什么要用框架?Spring官方后来也推出了Spring AI,那跟LangChain4j又怎么选?我自己的看法是这样的(纯个人使用感受,供参考):
| 维度 | 手写RestTemplate方案 | Spring AI | LangChain4j |
|---|---|---|---|
| 学习曲线 | 低,但后续维护高 | 中等 | 中等偏下 |
| 多模型适配 | 几乎为零,全自己写 | 部分支持 | 支持主流大厂模型,接口统一 |
| Memory管理 | 手动维护消息列表 | 基础支持 | 内置多种Memory实现,可自定义 |
| 工具/函数调用 | 手写协议,非常痛苦 | 支持 | 支持,注解驱动,非常爽 |
| RAG生态 | 自己拼向量库 | 起步阶段 | 相对更完整,有独立模块 |
| Java体系贴合度 | 完全手动 | 非常高 | 高 |
| 社区活跃度 | - | 官方背书 | 独立社区,迭代快 |
说实话,Spring AI背靠Spring官方,未来肯定有优势,但至少到现在这个时间点,LangChain4j在功能的完整性和灵活度上更胜一筹,尤其是Memory和AiServices这套抽象,设计得很成熟,很多场景能让我少写几百行样板代码。而且它的模块化做得很好,不绑架你的技术栈,你可以只引入需要的包。
1.3 这套框架解决了哪些“真实痛点”
我记得第一次看LangChain4j文档时,脑子里最大的一个感受是:它把我之前手写代码时所有“不舒服”的地方都给填平了。举几个具体例子:
- 消息管理的痛:ChatMemory模块把用户消息、AI消息、系统消息统一管理,自带滚动窗口策略,再也不用自己数Token来截断历史了。
- 结构化输出的痛:用
AiServices加@SystemMessage、@UserMessage注解,配合BeanOutputParser,能让模型直接返回一个类型安全的Java对象,而不是裸的JSON字符串。 - 函数调用的痛:只需要给Service接口加一个方法,再标注
@Tool注解,框架自动生成工具协议,模型需要查数据时就调用你的Java方法,跟RPC一样自然。 - 多模型切换的痛:OpenAI、通义、文心、Ollama本地模型……换一个
ChatLanguageModel实现类就行,业务代码几乎不用动。
这些痛点不是Demo级别的“花活”,而是生产项目里每天都要面对的。LangChain4j把这些问题提到框架层面解决,我觉得这才是它真正的价值所在。
2. 环境准备与第一个可运行的最小Demo
2.1 依赖引入与版本避坑
先说版本和依赖,这是新手最容易栽跟头的地方。LangChain4j目前对Java 8/11/17都有支持,但我的建议是直接用Java 17,因为很多高级特性和部分依赖(比如向量存储的客户端)对旧版本兼容性一般,没必要给自己找麻烦。
我用的是Maven,引入核心依赖如下:
<properties> <langchain4j.version>0.31.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 这里以OpenAI为例,国内模型可换 langchain4j-dashscope 或 langchain4j-qwen 等 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>这里有个特别重要的坑:LangChain4j的版本迭代非常快,0.x阶段API变动频繁,我最早用0.24版本写的代码,升级到0.31时就有不少Breaking Change。很多网上教程用的是老版本,你照着写大概率编译不过。建议以你引入的实际版本的官方文档为准,不要把网上贴的旧代码直接复制。
2.2 从“你好,大模型”到流式对话
依赖准备好之后,我们来写第一个能跑的Demo。这一步的目标很简单:让模型回复我们,并且能处理多轮对话的上下文。
import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.memory.chat.MessageWindowChatMemory; public class QuickStartDemo { public static void main(String[] args) { // 1. 创建模型(这里用OpenAI协议,API Key建议从环境变量读取) ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); // 2. 创建带窗口记忆的聊天内存,最多保留最近20条消息 MessageWindowChatMemory memory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); // 3. 先扔一句系统提示词 memory.add(SystemMessage.from("你是一个资深的Java技术顾问,回答要简洁、准确。")); // 4. 模拟用户连续提问 String question1 = "Java 21的虚拟线程和平台线程有什么区别?"; memory.add(UserMessage.from(question1)); AiMessage answer1 = model.generate(memory.messages()); System.out.println("AI回复:" + answer1.text()); memory.add(answer1); // 关键:把AI回复也塞回记忆里 String question2 = "那它在Tomcat里能直接用吗?"; memory.add(UserMessage.from(question2)); AiMessage answer2 = model.generate(memory.messages()); System.out.println("AI回复:" + answer2.text()); } }看到没有?全程我们都没有手动拼接JSON数组。memory.messages()返回的就是框架维护好的完整消息链,传给model.generate()即可。这点对于做过多轮对话的人来说,真的是解放。
2.3 我用这个Demo验证过的几个关键点
这个最简单的例子背后,其实涉及到好几个容易踩的细节,我得单独拿出来说:
- 模型名称的选择:
gpt-4o-mini性价比高,做Demo完全够用。如果你接的是国内模型,比如通义千问对应的qwen-plus,或者本地跑的Ollama模型如qwen2.5:7b,模型名的传法都不一样,别硬套。 - 超时时间:
timeout参数强烈建议设置。模型接口偶尔会“思考很久”,不设超时的话,你的接口调用方会先超时,你这边还在傻傻等待,最后抛出一堆晦涩的异常。 - Memory的消息顺序:消息顺序必须严格按照“先系统、再用户、再助手、再用户”这种交替顺序。如果乱序,部分模型不会报错,但回答质量会明显下降,这是我自己实测过的。
- Token限制:
MessageWindowChatMemory只按条数管理,不按Token数管理,所以如果你的业务是长文档对话,最好自己扩展一个按Token裁剪的Memory实现。
跑通这个Demo,你已经算是“入了门”。后面要做的,就是把框架的能力真正用起来。
3. 理解LangChain4j的灵魂:AiServices与结构化输出
3.1 AiServices是什么,凭什么说它是灵魂
很多新手跑完上面的Demo,觉得“这跟普通的SDK封装有什么区别”?区别就在AiServices。这个类是整个LangChain4j的精华,它做的事情可以用一句话概括:把一个普通的Java接口,变成一个有大脑、会调用工具的智能代理。
举个例子。你有一个Service接口:
public interface CustomerSupportAgent { String answer(String userQuestion); }普通的实现就是写一个类,answer方法里写业务逻辑。而用AiServices,你可以这样:
CustomerSupportAgent agent = AiServices.builder(CustomerSupportAgent.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new CustomerInfoTool()) // 注册工具,模型可以按需调用 .build(); String response = agent.answer("帮我查一下订单2024001的物流状态");看到区别了吗?answer方法里没有任何业务代码,但当你调用它时,模型会“思考”需要哪些信息,如果发现需要查订单数据,就会自动调用你注册的CustomerInfoTool里的方法,拿到结果后组织语言回复你。这就是Agent的能力,而这一切在Java里通过接口和注解就完成了。
3.2 @SystemMessage、@UserMessage与参数绑定
AiServices的功能不止“自动调用工具”,它还支持非常灵活的消息模板。看这个例子:
public interface TranslationService { @SystemMessage("你是一名专业的技术文档翻译。请将用户提供的内容翻译成{{language}},保持术语准确。") String translate(@UserMessage String text, @V("language") String language); }这里的{{language}}是模板占位符,@V("language")会把参数值填进去。@SystemMessage定义的是系统提示词,@UserMessage标注的是用户消息来源。这比手动拼Prompt优雅一万倍,尤其适合封装公司内部的“稳定业务逻辑”——把Prompt工程固化在接口上,调用方完全感知不到大模型的存在。
我来整理一下AiServices的几种典型用法,方便你对照自己的场景:
| 用法 | 核心注解/配置 | 典型场景 |
|---|---|---|
| 简单问答 | 无注解,直接String入参 | 对话机器人 |
| 结构化信息抽取 | 返回值用泛型/Record | 从非结构化文本提取实体 |
| 带模板的问答 | @UserMessage + @V | 翻译、总结、格式化 |
| 工具调用 | @Tool标注方法 | 查数据库、调第三方API |
| 流式输出 | 返回类型用Flux<String> | 打字机效果的流式回复 |
3.3 结构化输出:让模型返回Java对象
这一点我单独拿出来讲,因为它在实际开发中的价值怎么强调都不为过。没有框架的时候,让模型返回JSON,你得在Prompt里写“你必须返回JSON格式,不要包含其他文字”,然后祈祷模型听话,再做JSON反序列化,还要处理各种格式污染。
在LangChain4j里,你只需要定义好Java类型,然后让接口方法直接返回这个类型:
public record OrderInfo(String orderId, String customerName, String status, double amount) { } public interface OrderParser { OrderInfo parseOrder(@UserMessage String text); } // 调用 OrderParser parser = AiServices.builder(OrderParser.class) .chatLanguageModel(model) .build(); OrderInfo info = parser.parseOrder("订单号2024001,张三,已发货,金额299.00元"); System.out.println(info.status()); // 输出:已发货框架会自动生成“请提取信息,输出Json格式”的Prompt,并把模型输出解析成OrderInfo对象。如果模型输出不合法,有些解析器还能自动纠错,重试一次。这个能力在做信息抽取、表单识别、客服工单结构化时非常实用,我后来在好几个项目里都靠它省掉了大量正则解析代码。
4. 实战:做一个带“多路召回”的知识库问答系统
4.1 RAG整体架构与技术选型
聊完了基础,我们进入一个真正有点复杂度的实战项目:知识库问答系统。这是目前LLM应用落地最广泛的方向,热搜词里的“多路召回”就是这类系统的常见优化手段。
我先把RAG的经典流程搭起来:文档加载 -> 文本切分 -> 向量化入库 -> 用户问题向量化 -> 相似度检索 -> 拼接Prompt喂给LLM -> 生成回答。LangChain4j对这一整套流程都有对应的模块支持。
我的技术选型如下:
- 嵌入模型(Embedding):用
langchain4j-open-ai模块里自带的OpenAiEmbeddingModel,模型名text-embedding-3-small - 向量存储:本地开发用
InMemoryEmbeddingStore(不依赖外部服务,重启丢失),生产环境建议换成PgVectorEmbeddingStore或Qdrant、Milvus等 - 文档解析:官方
DocumentParser,支持TXT、Markdown、PDF - 文本切分:
DocumentSplitter系列的RecursiveCharacterDocumentSplitter
4.2 文档切分与向量化入库的工程细节
先看代码。这一步最容易出问题的地方有三个:切分粒度、重叠窗口、元数据保留。
import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.TextDocumentParser; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.InMemoryEmbeddingStore; import dev.langchain4j.model.embedding.EmbeddingModel; // 1. 加载文档 Document doc = FileSystemDocumentLoader.loadDocument( Path.of("/path/to/your/java-knowledge.md"), new TextDocumentParser() ); // 2. 切分:每段最多500字符,重叠100字符 DocumentSplitter splitter = DocumentSplitters.recursive(500, 100); List<TextSegment> segments = splitter.split(doc); // 3. 逐段向量化并入存储 EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment.text()).content(); store.add(embedding, segment); }关于切分参数,我的建议是:
- 如果知识库内容以代码为主,切分粒度放到300字符左右,太长了会把不相关的代码逻辑揉到一起;
- 如果是技术文档、说明手册,500~800字符比较合适,既保留上下文语义,又不会超过向量模型的单次输入限制;
- 重叠窗口建议设置为切片长度的20%,这样能有效避免“一句话被拦腰截断,前半段和后半段语义割裂”的问题;
- 生产环境一定要给segment保留元数据,比如来源文档名、章节号、页码,方便回答时引用出处。
4.3 多路召回:向量检索+关键词检索的融合策略
这里引入“多路召回”的概念。你在做题或做搜索系统时应该知道,单一检索方式容易漏召回。比如向量检索擅长语义相似,但用户如果提问包含某个精确实体ID、型号、人名,纯向量检索的效果往往不如关键词索引(因为embedding可能把精确词泛化了)。
我的做法是同时跑两路:
- 第一路:向量相似度检索,取Top K;
- 第二路:基于关键词/或基于简单的全文索引(比如Lucene或数据库的全文搜索),用BM25类似的打分机制取Top K;
- 最后合并:去掉重复文档,按新鲜度或置信度重排序,取前几个片段进入Prompt。
简化版代码如下:
// 向量召回 List<EmbeddingMatch<TextSegment>> vectorMatches = store.findRelevant( questionEmbedding, 3); // 关键词召回(这里用最简单的contains过滤,生产可换成Lucene/ES) List<TextSegment> keywordMatches = segments.stream() .filter(seg -> seg.text().contains(keyword)) .limit(3) .toList(); // 融合:按优先级合并,去重 Map<String, TextSegment> merged = new LinkedHashMap<>(); for (EmbeddingMatch<TextSegment> match : vectorMatches) { merged.putIfAbsent(match.embedded().text(), match.embedded()); } for (TextSegment segment : keywordMatches) { merged.putIfAbsent(segment.text(), segment); } // 拼接上下文 List<TextSegment> finalContexts = new ArrayList<>(merged.values()); String context = finalContexts.stream() .map(TextSegment::text) .collect(Collectors.joining("\n---\n")); String prompt = """ 基于以下资料回答问题,如果资料中没有明确的答案,请直接回答“根据现有知识库无法回答”。 资料: %s 问题:%s """.formatted(context, question);注意:多路召回不是越多越好,重点是“召回质量”和“上下文长度”的权衡。我实测过,3~4个片段(每个500字左右)基本够用,超过4个核心内容位置会靠后,模型对中间信息的关注度会下降,反而影响回答效果。
4.4 让回答能“引用出处”的小技巧
知识库问答有个致命问题:模型可能会胡说八道,明明资料里没有的知识,它为了“帮到你”硬编一个答案。我在生产项目里用了一个很有效的办法:强制模型在回答末尾附上参考片段编号。
做法是在Prompt里增加约束:
String prompt = """ 请严格依据“资料”内容回答。 如果参考答案中有多条内容,请在回答末尾标注引用的片段编号,格式如 [1][3]。 资料: [1] %s [2] %s [3] %s 问题:%s """.formatted(...);这样模型回答时,天然会优先使用给出的资料内容,而且能追溯到来源。用户看到引用之后,对结果的信任度会高很多。这个技巧成本为零,但价值极高。
5. 常见报错与调优:从踩坑到稳定运行
5.1 与模型通信相关的报错清单
写到这里,我把自己在实战中遇到的报错和解决方案整理成一张表,每一个都是真实遇到并解决的:
| 报错现象 | 根因 | 解决方案 |
|---|---|---|
OpenAiHttpException: 401 | API Key无效或环境变量没读取到 | 优先检查System.getenv是否拿到了值,不要硬编码在代码里 |
SocketTimeoutException | 模型接口响应慢,默认超时太短 | 显式配置timeout(Duration.ofSeconds(60)),甚至更长 |
JsonMappingException | 模型返回了被Markdown代码块包裹的JSON | 用解析器的容错模式,或者Prompt明确“不要用markdown代码块包裹JSON” |
TokenLimitExceeded | 输入的Prompt+历史消息超过模型上下文窗口 | 检查Memory条数设置,减少召回片段数量,或换更长上下文的模型 |
ClassNotFoundException | 引入了某个模块的包,但没有对应依赖 | 检查langchain4j-open-ai等模块的传递依赖,缺啥补啥 |
| 模型回答牛头不对马嘴 | 消息历史顺序错乱、缺少SystemMessage | 用MessageWindowChatMemory管理,不要手动塞消息 |
5.2 上下文管理与Token成本控制的经验
做LLM应用,Token就是钱。尤其是在内网知识库这种“文档又长、调用又多”的场景,成本控制是必须考虑的事。我总结了几个省钱又实用的经验:
- 优先做检索再做大模型推理,而不是把整个文档全塞进去。RAG的意义就在这:每次只把最相关的2-3个片段喂给模型,而不是把500页文档全部拼接。
- 对话历史没必要无限保留。绝大多数业务场景,20条以上的历史消息对回答质量几乎没有帮助,该截断就截断。
- Embedding模型的成本也要关注。如果知识库有海量文档,离线离线批量先算好向量,千万别每次问答都重复向量化全库文档。
- 流式输出(Streaming)也是降本手段之一。用户看到前几个字出来之时,心理等待时间大大缩短,但实际消耗Token其实差不多。不过用户体验提升非常明显。
关于流式输出,LangChain4j支持得很到位,接口返回Flux<String>即可配合WebFlux做SseEmitter,这一块网上资料也不少,本文就不展开了。
5.3 生产化前的最后检查清单
最后,我给准备把LangChain4j项目推上生产的你一份检查清单,这些都是我自己踩过的坑总结出来的:
- API Key不要写死在代码里:用环境变量或配置中心管理,最好支持多Key轮询,防止单Key限额。
- 配置好重试与降级:模型服务偶尔抖动,用Resilience4j或简单重试机制兜底,避免核心链路直接挂掉。
- 敏感信息过滤:大模型API请求会经过外部服务,要确保知识库里没有未脱敏的客户隐私数据再送出去。
- 日志记录Prompt和响应:出问题时能回溯,建议只记摘要和Token数,不记录完整敏感内容。
- 多路召回要留日志:记录哪一路召回了哪些片段、最终选了哪几个,这是优化检索效果的基础数据。
- 灰度发布:Prompt调整对回答风格影响巨大,先对小比例流量生效,观察用户反馈再全量。
以上6条如果你全部做到了,至少能避免80%的线上故障。我个人第一次上线LangChain4j应用时,就是忽略了第2条重试机制,结果大模型服务一波动,用户侧直接看到报错页面,被骂惨了。后来加了重试和降级,稳如老狗。
LangChain4j这个框架还在快速迭代中,踩了一些坑之后我也习惯了——跟上它官方的Roadmap更新,看Release Notes,比收藏老教程靠谱得多。毕竟工具会变,但“用工程化方法把大模型能力嵌进业务”这件事的方向,不会变。