做 Java 后端久了,看到这两年大模型应用开发几乎被 Python 生态“包圆”,心里多多少少有点不是滋味。团队里最常用的技术栈明明是 Java,想接个大模型能力却总要先搭一套 Python 服务,维护成本直接翻倍。LangChain4j 就是为打破这个局面而生的 Java 版大模型应用开发框架,它把模型对话、多轮记忆、工具调用、知识库检索这些能力抽象成了 Java 开发者熟悉的接口。这篇教程会从一个最小可运行的项目讲起,逐步带你实现带记忆的助手、会调用外部工具的业务代理,以及一个最简知识库问答功能。适合刚想用 Java 接入大模型的读者,也适合那些已经在用 Spring Boot、但对大模型应用开发还比较陌生的同学。
1. 基础认知:LangChain4j 是什么、到底解决了什么问题
1.1 Java 生态为什么需要自己的 LLM 框架
先聊一个最现实的问题:既然 Python 那边已经有很成熟的 LLM 应用框架,为什么 Java 这边还要折腾一个?原因很简单——Java 项目要接入大模型时,痛点不是“能不能接”,而是“接得痛不痛”。你当然可以直接用 HTTP 客户端调用模型接口,但很快就会发现要自己处理的事情越来越多:对话历史怎么维护、工具调用的请求格式怎么拼、模型返回的 JSON 怎么保证能解析、向量检索怎么和业务数据打通……这些琐碎工作每个项目都要重复一遍。
LangChain4j 把这些公共问题收敛成了框架能力,同时保持了 Java 生态的直觉。它不要求你放弃 Spring,也不要求你写一堆胶水代码。你可以把它理解成 JDBC 之于数据库:JDBC 为不同数据库提供了统一访问接口,LangChain4j 则为不同模型服务商和不同应用组件提供了一层通用抽象。你写的业务代码不用绑死某一家厂商,后面要换模型时,通常只需要改配置。
1.2 核心模块与设计思路拆解
LangChain4j 的项目结构值得花两分钟了解一下,因为装依赖的时候就用得上。
核心模块是最底层的接口和抽象:ChatLanguageModel定义了聊天模型的调用方式,EmbeddingModel定义了向量模型的能力,ToolSpecification描述了工具调用的元信息。这套抽象是所有上层功能的基础。默认实现模块则提供了开箱即用的工具类,比如消息窗口记忆、内置的提示词渲染器、内存版向量存储等,新手做原型时基本不需要再引其他库。
对 Spring Boot 用户来说最友好的是官方提供的 starter 模块。它可以把模型客户端、嵌入模型、向量存储等自动配置成 Spring Bean,你只需要在配置文件里写清楚模型地址、密钥和模型名,代码里直接注入接口就能用。另外,针对不同模型服务商的适配模块是按需引入的,你用了哪家的模型就引哪家的扩展包,但这种做法会带来一个小小的权衡:如果写死在代码里,后续换服务商就要动代码。所以我在下面的实战示例中,会采用“通过配置指向服务商地址”的通用做法,尽量让你和具体厂商解耦。
1.3 什么场景该用它,什么场景别硬上
LangChain4j 适合的场景非常明确:企业内部知识库问答、工单自动分类、客服助手、流程自动化中需要“让模型理解指令并调用现有系统”的场景。这些场景的共同点是:对实时性要求没那么极端,但对开发效率和可维护性要求高。用框架能把核心逻辑收敛在很小的代码量里,后续接新的模型或调整检索方案都相对平滑。
但也有不该硬上的情况。比如你需要的是毫秒级的规则匹配,压根不该引入大模型;再比如你的业务对输出格式有强约束且完全不能接受不确定性,那么现阶段直接塞给模型处理还是不现实。框架本身只是工具,不能帮你消除模型的不确定性。另外,如果你完全没有一个可以稳定访问的模型服务地址,而是打算“本地随便跑个小模型”应付所有任务,那也要谨慎——小模型的指令遵循能力和工具调用成功率往往不够理想,这会影响后续所有功能的上限。
2. 环境准备:三分钟搭好最小可运行工程
2.1 JDK、构建工具和 Spring Boot 版本怎么选
我推荐直接用 JDK 17 及以上版本,配合 Maven 3.8+ 构建。Spring Boot 选择 3.x,因为 LangChain4j 对 Spring Boot 3 的集成支持最到位。有些老项目还停留在 Spring Boot 2,也能用,但配置和兼容性上要额外折腾,新手不建议开局就挑战这个复杂度。
如果你是纯 Maven 项目,不引入 Spring Boot starter,也可以直接用核心依赖。不过对大多数 Java 后端团队来说,Spring Boot 几乎是标配,所以我下面的示例基于 Spring Boot 3。先别急着引入一堆依赖,最简配置只需要一个 starter 就够。
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>0.36.0</version> </dependency>版本号以你查到的当前稳定版为准。这个 starter 会自动拉起核心模块和部分默认实现,让ChatLanguageModel等接口可以直接注入。
2.2 配置文件里的三个关键参数
很多人第一次跑不起来,往往就是卡在配置上。LangChain4j 的 spring boot starter 会从配置文件读取模型相关参数,你需要在application.yml中至少配置三个东西:模型服务地址、密钥、模型名。
langchain4j: chat-model: base-url: https://your-provider.example.com/v1 api-key: ${MODEL_API_KEY} model-name: your-model-id temperature: 0.7这里有几个容易忽略的点。base-url是模型服务的 HTTP 接口地址,不同服务商地址格式不一样,但通常都会有一个统一入口。api-key不建议直接写死在配置里,我用的是环境变量注入,${MODEL_API_KEY}会从系统环境变量读取,避免把密钥提交到代码仓库。model-name也不是随便填的,必须是你所选择模型服务上真实存在的模型标识,填错了会直接报模型不存在或鉴权失败。
2.3 第一个能跑通的模型调用
配置完成后,写一个最简单的启动类。我用CommandLineRunner在应用启动时直接发起一次对话,验证链路是否通畅。
import dev.langchain4j.model.chat.ChatLanguageModel; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class LangChain4jDemoApplication { public static void main(String[] args) { SpringApplication.run(LangChain4jDemoApplication.class, args); } @Bean CommandLineRunner demo(ChatLanguageModel model) { return args -> { String answer = model.generate("用一句话解释依赖注入"); System.out.println(answer); }; } }这段代码里ChatLanguageModel是框架的核心接口,它由 starter 自动配置成了 Bean。model.generate("...")是同步阻塞调用,直接把模型返回的文本打出来。如果你看到控制台正常输出了一句话,说明模型链路已经通了。
注意:这里我刻意没有绑定任何具体厂商的实现类名,而是依赖 spring boot starter 的自动装配。不同服务商、不同版本的 starter,配置前缀可能略有差异,如果你跑不通,第一件事就是检查你当前版本对应的配置键名。
3. 核心实践:从一次对话到结构化输出
3.1 ChatLanguageModel 的运行逻辑
ChatLanguageModel是理解 LangChain4j 的钥匙。它屏蔽了底层 HTTP 请求、鉴权、响应解析等细节,让你面对的核心概念只有“输入一段文本,输出一个回答”。但它的方法签名和返回值并不像表面那么简单,generate方法在框架内部被设计成多种重载,可以接收字符串、ChatMessage列表,也可以接收Prompt对象。
实际项目中很少直接用纯字符串调用模型。因为模型没有“记忆”,每一次调用都是独立的。如果你问完“你好”再问“我叫某某”,第二次调用如果不携带第一次的上下文,模型根本不知道“某某”是谁。所以后续的所有进阶功能——记忆、工具、RAG——本质上都是围绕“如何把更多上下文组装进这一次调用”展开的。搞明白这条主线,你看到那些抽象类时就不会晕了。
3.2 提示词模板让 Prompt 可维护
写模型应用时,提示词就是代码的一部分。如果你直接拼字符串,很快就会遇到引号转义、动态参数插值、多轮改写困难等问题。LangChain4j 提供了PromptTemplate来做这件事,它的语法很直观,用双大括号作为变量占位符。
import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.input.PromptTemplate; String templateText = """ 你是一名严谨的技术问答助手。 请基于以下资料回答问题,如果资料中没有答案,请明确说不知道。 资料:{{context}} 问题:{{question}} """; PromptTemplate template = PromptTemplate.from(templateText); String prompt = template.apply(Map.of( "context", "LangChain4j 是一个 Java 版大模型应用框架", "question", "LangChain4j 是什么?" )).text(); String answer = model.generate(prompt); System.out.println(answer);把提示词抽成模板的意义不只是让代码更整洁,更重要的是方便后续调试和迭代。你在调 prompt 时,经常需要对比不同版本的效果。如果 prompt 散落在各种字符串拼接里,改一版就要翻半天代码;集中管理后,你可以很轻松地把模板放到配置文件或独立的文本文件里,甚至做成带版本号的资源文件。
3.3 结构化输出:让模型返回可解析的 JSON
新手最容易碰到的坑是:让模型“用 JSON 格式回答”,结果返回的格式偶尔多几个解释文字,偶尔少一个引号,直接JSON.parse就报错。LangChain4j 的结构化输出能力可以把这个过程规范化。
一种常见做法是让模型返回 JSON 字符串,再用 Jackson 解析成对象。但更顺手的方案是结合框架的AiServices,把“输出对象”这件事直接声明在 Java 接口上。
import dev.langchain4j.service.AiServices; import dev.langchain4j.service.UserMessage; interface CustomerInfoExtractor { @UserMessage("从用户留言中提取客户信息:{{text}}") CustomerInfo extract(String text); } record CustomerInfo(String name, String city, String intentType) {}这个接口乍看有点“魔法”,但理解后非常顺手。框架会根据方法返回类型自动构造结构化输出指令,并负责把模型返回内容解析成CustomerInfo。你不需要手写 JSON 解析,也不需要写异常的转义规则。实例化方式也很简单:
ChatLanguageModel model = ...; // 由 Spring 注入 CustomerInfoExtractor extractor = AiServices.builder(CustomerInfoExtractor.class) .chatLanguageModel(model) .build(); CustomerInfo info = extractor.extract("我叫小明,来自上海,想咨询续费问题"); System.out.println(info); // CustomerInfo[name=小明, city=上海, intentType=续费咨询]结构化输出的核心价值在于:它把模型从“文本生成器”变成了“可编程的函数”。你定义一个 Java 方法签名,框架负责让模型输出满足签名约束的数据。这样后续业务代码可以直接使用强类型对象,而不是整天处理脏字符串。
4. 进阶实战:带记忆的助手、会调用工具的业务代理、最小 RAG
4.1 ChatMemory:多轮对话不再“失忆”
一个没有记忆的聊天机器人是没法用的。但“记忆”这件事做起来很微妙:是把每条历史消息都发给模型?还是只保留最近几轮?如果保留太长的历史,Token 消耗会快速上升,响应延迟也会增加;如果保留太短,对话又会显得“没脑子”。
LangChain4j 提供了ChatMemory接口,最常用的实现是MessageWindowChatMemory,它本质是一个滑动窗口:只保留最近 N 条消息。比如窗口大小设为 10,就表示模型每次对话时只携带最近 10 条消息。这个 N 的取值需要根据你的业务调整,不要盲目调大。
import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; interface ChatAssistant { String chat(String userMessage); } ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10); ChatAssistant assistant = AiServices.builder(ChatAssistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build(); System.out.println(assistant.chat("我叫小明,记住了吗?")); System.out.println(assistant.chat("我叫什么名字?"));第二次提问时,模型能够正确回答出“小明”,因为MessageWindowChatMemory已经把前一轮的用户消息和助手回复都放进了窗口。这个能力的背后逻辑并不复杂:AiServices每次调用模型前,会从ChatMemory中取出消息列表,加上当前用户消息一起发给模型。
这里有一个很隐蔽的坑:如果消息窗口设置得太小,业务上关键的长期信息可能会被挤出窗口。比如用户在第一轮告诉了你他的需求编号,对话进行到第八轮时如果已经刷了十几条消息,窗口里可能就找不到这个需求编号了。针对这个场景,更合理的方案是把关键信息提取出来写入“持久记忆”,而不是依赖窗口大。但那是后话了,新手先把窗口机制理解透彻。
4.2 工具调用:让模型真正操作你的业务
如果说ChatMemory让模型“记住你”,那么工具调用(Function Calling)就是让模型“帮你做事”。它的核心机制值得理解一下:模型本身不能直接执行代码,但它可以根据对话内容生成一个“调用某个工具的请求”,框架拿到这个请求后,在 Java 侧执行对应方法,再把执行结果返回给模型,最后由模型基于结果生成最终回答。
用 LangChain4j 定义一个工具非常简单,只需要在一个普通类的方法上标注@Tool。
import dev.langchain4j.agent.tool.Tool; class OrderService { @Tool("根据订单号查询订单状态") public String queryOrderStatus(String orderId) { // 这里可以是任意 Java 逻辑,比如查数据库、调外部接口 return "订单 " + orderId + " 状态:已发货"; } }然后把这个工具注册给AiServices:
interface OrderAssistant { String chat(String userMessage); } OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .tools(new OrderService()) .build(); String reply = assistant.chat("帮我查一下订单 20240115 的状态"); System.out.println(reply);当你运行这段代码时,如果模型判断需要查询订单状态,它会输出一个工具调用请求,LangChain4j 会调用queryOrderStatus方法,拿到执行结果后再让模型组织语言回答用户。整个过程对上层代码几乎是透明的——你感觉就是在和“一个懂业务逻辑的接口”对话。
工具调用虽然好用,但有几点必须注意。第一,工具方法的命名和描述必须清晰,因为模型是靠方法名和描述来决定什么时候调用它的。如果描述含糊,模型就可能在不该调用的时候乱调。第二,工具参数越少越好,参数过多会显著降低模型生成正确参数的成功率。第三,工具方法要具备幂等性和超时保护,因为模型可能会重复调用或在你没预期的时候调用,你的方法不能有严重副作用。
4.3 最小 RAG:给模型插上一个临时知识库
RAG(检索增强生成)是目前解决模型幻觉和知识更新问题最实用的手段。它的核心思路是:把文档切成片段,向量化后存入向量存储;用户提问时,先从向量存储中检索最相关的片段,然后把这些片段和问题一起交给模型,让模型基于片段作答。
LangChain4j 对 RAG 的支持很完善,新手可以用InMemoryEmbeddingStore做原型验证。它把向量存在内存里,不需要额外部署向量数据库,适合用来跑通链路。
import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; // 1. 准备文档内容,并切分成块 Document doc = Document.from("LangChain4j 是一个 Java 版大模型应用框架,支持对话、RAG、工具调用。"); List<TextSegment> segments = DocumentSplitters.recursive(300, 50).split(doc); // 2. 向量化并存入内存向量存储 EmbeddingModel embeddingModel = ...; // 同样由配置注入 EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); for (TextSegment segment : segments) { var embedding = embeddingModel.embed(segment.text()).content(); store.add(embedding, segment); }查询时只需要三步:把用户问题向量化、在向量存储中查找相似片段、把片段拼入提示词。如果你用AiServices,可以把检索器注册进去,框架会自动完成“召回片段 + 拼入上下文”的流程。
import dev.langchain4j.rag.retriever.EmbeddingStoreRetriever; EmbeddingStoreRetriever retriever = EmbeddingStoreRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(3) .build(); interface KnowledgeAssistant { String chat(String userMessage); } KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class) .chatLanguageModel(model) .retriever(retriever) .build();这样搭建出来的是最简 RAG 链路,足够你理解原理。生产环境下你大概率需要换用真正的向量数据库(比如相对于团队运维能力更成熟、部署成本可控的方案),并设计更精细的文档切分策略和检索后处理流程。但概念模型是一致的:先检索相关内容,再让模型基于内容作答。把这一点理解透了,后面就是数据库选型和工程化的问题。
5. 新手最容易踩的坑
5.1 高频错误速查表
我在实际调试中遇到过不少问题,绝大多数都是配置和环境引起的。整理成一张表,方便你对号入座。
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
启动报错,提示找不到ChatLanguageModelBean | 配置缺失或 starter 版本不匹配 | 检查application.yml中模型配置项,确认配置前缀与当前版本一致 |
| 请求报 401/403 | API Key 错误或环境变量未设置 | 确认环境变量名与配置引用一致,检查密钥是否过期 |
| 请求报 404 或模型不存在 | model-name填错 | 登录模型服务商后台,确认模型 ID 的真实值 |
| 调用模型超时 | 网络到目标服务地址连通性差 | 检查网络连通性,尝试切换到服务商提供的低延迟区域地址 |
| 返回内容不定时会多出解释文字 | 提示词约束不够强 | 在提示词中强调“只输出 JSON,不要多余内容”,或使用结构化输出接口 |
| 工具一直不被模型调用 | 工具描述不清晰或模型不支持 function calling | 优化工具名和描述,确认当前模型支持工具调用能力 |
| 对话中途“失忆”突然严重 | 消息窗口过小,关键信息被挤出 | 调大窗口,或把关键业务信息存入额外持久化记忆 |
5.2 成本和延迟控制的实用建议
模型调用是按 Token 计费的,很多新手一开始不管上下文长度,导致每次请求携带的历史消息越来越多,账单涨得很快。控制成本最简单的方法是限制消息窗口大小,同时只把真正必要的上下文传给模型。
延迟方面,如果你做的是面向用户的交互应用,建议使用流式响应(StreamingChatLanguageModel),让用户看到逐字输出,体感会好很多。LangChain4j 也提供了流式接口,用法和同步接口类似。另外,如果业务上有大量相似的请求,可以考虑做一层结果缓存,而不是每次都去问模型。
5.3 我的一些实操心得体会
这几个项目做下来,最大的感受是:先跑通最简链路,再叠加功能。不要一上来就同时上记忆、工具、RAG。我第一次做的时候,把记忆、工具和知识库全塞进一个项目,结果出了问题都不知道是哪个环节造成的——是提示词没写好,还是工具描述不对,还是检索结果太差?完全没法定位。后来改成“一次只加一个变量”,很快就稳定了。
还有一点是关于工具方法的异常处理。工具方法是会被模型“指挥”执行的,如果方法内部抛出异常,框架通常会把异常信息返回给模型,模型可能会尝试换个参数再调一次。这本身不是坏事,但你的方法必须有完善的日志和超时保护,否则可能出现意外的高频调用。建议工具方法保持无状态,不要在方法内部维护跨调用的数据。
最后再分享一个很实用的小技巧:给模型设定角色时,把“角色设定”放在提示词最前面,并且加一句明确的回答约束。比如“如果你不确定,就直接说不知道”。这听起来简单,但比很多复杂的调参更能提升回答质量和稳定性。模型应用开发和传统 Java 开发的思维很不一样,核心是“你想办法用清晰的上下文引导模型的输出”。把这条想通了,LangChain4j 用起来会顺手很多。