1. 为什么Java工程师做AI应用,总是卡在"不会选框架"这一步
先说个我自己的经历。去年团队接了个内部知识库问答的需求,技术栈清一色Java + Spring Boot。当时摆在我面前的选择其实挺尴尬:直接用HTTP调大模型API,代码写起来倒是不难,但Prompt拼串、历史记录维护、模型切换、流式输出这些逻辑全得自己造轮子。后来发现了LangChain4j这个库,折腾了两周把第一个版本跑通,才意识到Java生态做大模型应用,其实缺的从来不是能力,而是一个趁手的框架。
LangChain4j,名字里那个4j就是for Java的意思。它就是LangChain在Java世界的对应实现,目标很直接:让Java开发者能用熟悉的方式,把大语言模型集成进自己的业务系统。我见过不少Java工程师对LangChain4j的第一反应是"这玩意儿是不是Python生态的玩具移植过来,水土不服",实际用下来你会发现,它对Java开发者反而更友好——强类型、IDE提示、Spring Boot天然集成,这些东西在Python生态里反而不一定有。
这篇文章我打算从零开始,带你把LangChain4j跑起来,覆盖环境准备、核心概念、一个能跑的通话Demo、结构化输出、函数调用,以及我自己踩过的一些坑。适合谁看?有Java基础、Spring Boot能上手,想做大模型应用但不想放弃Java主栈的工程师,这篇文章基本就是为你准备的。
2. 先把地基打好:依赖引入与模型接入配置
2.1 Maven依赖怎么选,才不踩版本坑
LangChain4j目前主要在Maven Central发布,坐标是dev.langchain4j。要跑一个最基础的对话Demo,其实只需要两个依赖:核心包和模型接入包。
<properties> <langchain4j.version>0.35.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 这里以OpenAI为例,后面会讲怎么换成本地模型 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>版本选择上我的建议是:不要无脑追最新,也不要停留在太老的版本。LangChain4j的API在早期版本里变动比较频繁(0.20之前改过不少类名),0.30之后的版本整体稳定多了。如果你用的Spring Boot 3.x,还有个官方提供的langchain4j-spring-boot-starter,配合properties配置文件可以使用:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>${langchain4j.version}</version> </dependency>不过这玩意儿比较"魔法"——自动配置确实很省事,但对新手来说出问题反而不好排查。我建议先把非Spring的方式玩明白,理解了核心对象是怎么构建的,再上Spring Boot Starter体验"自动装配"的便利。
2.2 模型接入:OpenAI、Azure、Ollama本地模型怎么选
选模型接入模块,说白了就是看你实际用哪家大模型。LangChain4j在这方面做了一个很聪明的解耦:底层LLM可以是任何厂商的,上层业务代码不感知。因为我这边演示环境是Java,为了兼顾成本和数据安全,我会用可本地运行的Ollama聊一聊,同时也会给出OpenAI等主流云的接入代码。
以OpenAI为例,接入代码极简:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build();如果你的网络环境无法直连OpenAI的API域名,可以使用国内兼容OpenAI协议的服务商或代理网关,只需改动baseUrl参数,这是OpenAI兼容生态的通用做法:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("sk-xxx") // 关于AK的获取,这里不做展开 .baseUrl("https://你的兼容端点地址/v1") .modelName("gpt-4o-mini") .build();换成本地模型,用Ollama跑一个开源模型是最稳的方案。先安装Ollama,拉一个模型下来,然后接入方式长这样:
ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build();这里要特别提醒:modelName里面那个冒号版本号不能省,省略会导致模型加载失败。我第一次配的时候图省事写了qwen2.5,结果Ollama一直给我报模型不存在,折腾了半天才意识到是版本号的问题。
云端和本地的选择逻辑其实很简单:个人开发、快速验证、数据敏感度高的需求优先本地;追求效果、并发场景大、不想折腾硬件的就用云端。LangChain4j的好处是,这两种切换只需要改构建那段代码,业务逻辑零改动。
2.3 搞清楚ChatLanguageModel、StreamingChatLanguageModel和ChatModel的关系
很多刚接触LangChain4j的人会被这几个名字绕晕。其实LangChain4j给模型交互分了三个层级:
ChatLanguageModel:最基础的同步对话接口,调用一次等一次结果,适合请求–响应模式的场景。StreamingChatLanguageModel:流式对话接口,结果是一小块一小块吐出来的,适合做打字机效果的对话场景。ChatModel:一个更上层的抽象,内部集成了记忆、工具调用、RAG等能力,适合做复杂的Agent场景。
新手可以这么理解:ChatLanguageModel是"手动挡",你给它消息列表,它把回复还给你,简单直接;StreamingChatLanguageModel是"换挡更频繁的手动挡",适合实时展示;ChatModel是"自动挡",它内部帮你处理了很多细节,但前提是你得配好各种组件。
我的建议是入门阶段先把ChatLanguageModel玩透,把请求响应模型搞懂,再逐步尝试Streaming和ChatModel,否则遇到问题都不知道在哪个环节出的错。
3. 核心概念速览:Message、Prompt和Memory,别被名词吓到
LangChain4j里的概念其实不复杂,核心就几个:Message(消息)、Prompt(提示词)、Memory(记忆)。这几个名词看着高深,用生活化的类比一下就通了。
3.1 Message与角色:AI聊天其实是一次次"角色扮演"
Message就是一个带角色标签的话。常见的角色有三种:SystemMessage(系统设定)、UserMessage(用户发言)、AiMessage(AI回复)。你可以把SystemMessage理解为"演员的剧本设定",它告诉模型"你是什么、你该怎么说话、你有哪些制约"。
List<ChatMessage> messages = List.of( SystemMessage.from("你是一位精通Java的编程导师,回答要简洁专业。"), UserMessage.from("给我讲讲什么是@Bean注解?") ); ChatLanguageModel model = OpenAiChatModel.builder() // 或OllamaChatModel .apiKey("...") .modelName("...") .build(); String answer = model.chat(messages);这段代码跑通后,你就掌握了LangChain4j最核心的用法:构造消息列表,把它交给模型,拿到返回字符串。
3.2 Prompt Template:从"字符串拼接"到"模板占位"的进化
做AI应用最忌把用户输入直接拼进Prompt的字符串里——第一是容易注入,第二是写死了没法复用。LangChain4j给了PromptTemplate,用占位符的方式解决这个问题:
PromptTemplate template = PromptTemplate.from(""" 你是金融客服助手,请根据以下问题进行回答, 要求:使用中文,不超过100字。 问题:{{question}} """); Prompt prompt = template.apply(Map.of("question", "什么是复利?")); String answer = model.chat(prompt.toUserMessage());这样做的最大好处是:Prompt模板本身和业务逻辑解耦,你可以把模板放到配置文件、数据库甚至专门的Prompt管理平台里,运营人员改话术不需要动代码。
3.3 ChatMemory:让AI"记住"上下文,而不是每次都失忆
没有Memory的AI是"金鱼脑子"——你问完一句,下一句它就忘了前面聊了什么。要实现多轮对话,必须把历史消息回传给模型。最简单的方式是手动维护一个消息列表:
List<ChatMessage> messages = new ArrayList<>(); messages.add(SystemMessage.from("你是Java助手")); messages.add(UserMessage.from("什么是JVM?")); String reply1 = model.chat(messages); messages.add(AiMessage.from(reply1)); messages.add(UserMessage.from("那它和JDK有什么区别?")); String reply2 = model.chat(messages);LangChain4j提供了MessageWindowChatMemory,可以自动帮我们管理聊天历史窗口,比如只保留最近的10条消息,避免Token越滚越大:
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(10) .build(); chatMemory.add(UserMessage.from("什么是JVM?")); String reply1 = model.chat(chatMemory.messages()); chatMemory.add(AiMessage.from(reply1)); chatMemory.add(UserMessage.from("那它和JDK有什么区别?")); String reply2 = model.chat(chatMemory.messages());4. 从零构建一个"能对话的Java控制台程序",解剖模型运行机制
概念看过不少,终归要动手。这一节我们不用Spring Boot,就用纯Java写一个能在控制台里聊天的小程序,彻底看清LangChain4j的运行逻辑。
4.1 主程序代码与运行流程
先建一个Maven项目,把2.1节的依赖装好,然后写主类。我用的是本地Ollama的qwen2.5:7b,这样不需要任何外网API密钥,跑起来成本为零。
import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.ollama.OllamaChatModel; import java.util.ArrayList; import java.util.List; import java.util.Scanner; public class ConsoleChatApp { public static void main(String[] args) { ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .temperature(0.7) .build(); List<dev.langchain4j.data.message.ChatMessage> history = new ArrayList<>(); history.add(SystemMessage.from("你是一个乐于助人的AI助手,请用简洁的Python或自然语言回答问题。")); Scanner scanner = new Scanner(System.in); System.out.println("LangChain4j 控制台对话已启动,输入exit退出。"); while (true) { System.out.print("你 > "); String input = scanner.nextLine(); if ("exit".equalsIgnoreCase(input.trim())) { break; } history.add(UserMessage.from(input)); String answer = model.chat(history); history.add(AiMessage.from(answer)); System.out.println("AI > " + answer); } scanner.close(); } }跑起来之后的效果大概是:
你 > 讲一个笑话 AI > 为什么程序员总分不清万圣节和圣诞节?因为 Oct 31 = Dec 25!程序结构非常直白:循环里,用户输入变成UserMessage,收到回复变成AiMessage,两个都被塞进历史列表。下一轮对话时,整个历史列表一起发给模型,模型才有上下文。
4.2 为什么"先有SystemMessage"很重要,以及Temperature参数的意义
上面代码里我加了一条SystemMessage.from("你是一个乐于助人的AI助手……")。这一步很多人会忽略,觉得系统消息可有可无。但实际做项目后你会发现,系统消息是控制模型角色的关键。比如你做客服机器人,系统消息要写明"你是某电商平台的客服,你的职责是处理售后退换货问题,不能回答与产品无关的问题",模型的行为框架很大程度上由这段文字锚定。
再说temperature。它是控制随机性的参数:值越低,输出越确定,适合做信息抽取、SQL生成、代码生成;值越高,输出越发散,适合做文案、创意生成。我的习惯是:工具调用场景设0.2以下,闲聊场景0.7到1.0,代码生成0.1。之前做过一个数据抽取任务,temperature设为默认值,结果模型偶尔会把文档里没有的数字"编"出来,调到0.2之后就稳了很多。
4.3 从"同步阻塞"到"流式输出",体验打字机效果
上面这个控制台程序是同步的,一次请求要等到全部生成完才返回。做聊天产品,这种体验基本没法接受——用户对着转圈的按钮等十几秒很痛苦。LangChain4j对应的解决方案是StreamingChatLanguageModel。
import dev.langchain4j.model.ollama.OllamaStreamingChatModel; import dev.langchain4j.model.chat.StreamingChatLanguageModel; StreamingChatLanguageModel streamingModel = OllamaStreamingChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build(); streamingModel.chat(List.of(UserMessage.from("写一首关于秋天的短诗")), new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println(); // 换行 } @Override public void onError(Throwable error) { error.printStackTrace(); } });这里onNext会在每个Token生成后被回调,你可以把Token逐个返回给前端。我自己头一回看到这个效果的时候还是很惊喜的,因为前端几乎不用改动,轮询或者SSE通道直接推就行。要注意的是流式接口是异步的,测试的时候别在main方法里刚发出请求就结束进程,可以Thread.sleep等待一段时间,或者用CountDownLatch阻塞住。
5. 正经干活:结构化输出、Function Calling以及多轮对话的工程写法
控制台Demo只是开胃菜。真正放到项目里,我们通常希望模型不只是"聊一句漂亮话",而是能按预定义格式返回数据、能触发工具、能融入Spring Boot事务。
5.1 结构化输出:让AI返回JSON而不是散文,用强类型绑定
很多业务场景下,我们要的不是一段自然语言,而是一份规整的数据。比如让模型抽取出用户问题里的"出发时间、目的地",再交给后续订票系统处理。直接解析自然语言不稳定,JSON才是机器之间交流的正确姿势。
LangChain4j里有两个方案。第一个是用ObjectMapper加Prompt指令,把返回的JSON字符串反序列化;第二个是用官方的高阶API:定义一个POJO,直接从模型拿对象。
public record TripInfo(String destination, String departureDate, String cityOfDeparture) {} ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build(); TripInfo info = model.chat( UserMessage.from("我想订一张下周三从北京到上海的机票"), new JsonSchemaGenerator(), // 序列化约束 TripInfo.class ); System.out.println(info);高阶API的关键方法是chat(UserMessage, JsonSchemaGenerator, Class<T>),LangChain4j会生成对应的JSON Schema并通过结构化输出约束模型,然后把模型返回的JSON自动装配成TripInfo对象。我自己用下来的感受:50B以上的模型,结构化输出基本稳;7B这样的本地小模型虽然也能用,但偶尔字段会偏,写业务时要做好兜底校验。
5.2 Function Calling:让AI能"动手",调用你的Java方法
只让AI讲话是"嘴强王者",让它调你写的Java方法,才能真正干活。逻辑是:你把Java方法包装成一个"工具"暴露给模型,模型在需要时输出一个"我想调用这个方法,参数是xxx"的请求,LangChain4j帮我们把请求翻译成真正的方法调用。
这里用@Tool注解是最省事的:
public class WeatherTool { @Tool("查询指定城市当前的天气状况") public String getWeather(String city) { if ("上海".equals(city)) { return "上海当前28℃,多云"; } return city + "当前25℃,晴"; } }配好工具后,把工具对象作为参数传进chat方法:
ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build(); String response = model.chat( List.of(UserMessage.from("帮我查一下上海的天气")), new WeatherTool() ); System.out.println(response);一句"查上海的天气",模型会按照@Tool注释里的描述,决定调用getWeather("上海"),然后把结果组织成一句自然语言回复。这在Java里的写法极其顺滑——Tool类只需要是普通Java类,Spring Bean也可以直接注入进去。
5.3 多轮对话中的"状态管理":从内存到数据库到Redis
前面控制台Demo里的ChatMemory是内存态,进程一重启对话记录就没了。真实项目中,多轮对话的状态往往需要存到Redis、MySQL或者MongoDB。LangChain4j虽然没有把Redis状态的实现封装得那么细,但ChatMemory接口本身提供了一个很好的抽象层,你可以自己实现存储逻辑。
我常用的一个办法是:给每轮会话分配一个conversationId,把ChatMessage列表按ID序列化后存Redis,每次请求先从Redis取出历史消息,再追加当前消息。需要注意控窗口大小——大模型的Token限制是硬约束,对话越长成本越高,一般截断到最近20轮左右即可。
5.4 和Spring Boot合体:@Service注入模型,接口对外暴露
Spring Boot环境下,你只需要把模型构建逻辑放进一个@Configuration或@Bean,然后像普通Service一样注入使用。
@Configuration public class LlmConfig { @Bean public ChatLanguageModel chatLanguageModel() { return OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build(); } } @Service public class ChatService { private final ChatLanguageModel chatLanguageModel; public ChatService(ChatLanguageModel chatLanguageModel) { this.chatLanguageModel = chatLanguageModel; } public String chat(String userMessage) { return chatLanguageModel.chat(UserMessage.from(userMessage)); } }如果你的团队在用Spring Boot 3,还可以直接用官方的langchain4j-spring-boot-starter,配置项写到application.yml里即可。模型相关Bean会自动装配,@Tool注解的Bean也会被自动扫描挂载,代码量进一步压缩。
6. 避坑记录:我在LangChain4j上踩过的5个真实的坑
这部分是实践积累下来的经验,按麻烦程度从高到低给你排一排。
6.1 坑一:JDK版本过低,或者多模块项目依赖混乱
LangChain4j从某个版本开始要求JDK 17以上。如果你还在用JDK 8跑传统Java项目,引入依赖后大概率会在ClassNotFoundException或UnsupportedClassVersionError上卡住。我建议直接用JDK 17或21,别在JDK 8上挣扎。另外,如果项目是多模块Maven结构,注意把LangChain4j的版本号统一放在父POM的dependencyManagement里,否则子模块各自引用不同版本,容易产生NoSuchMethodError这种诡异问题。
6.2 坑二:模型名称或API地址写错,导致请求连环失败
这个问题在接入各家模型时最常踩。用Ollama时modelName必须和ollama list里显示的名称完全一致(包括标签);用云厂商时baseUrl结尾是否带/v1也有讲究,不同厂商协议路径不太一样。我的排查顺序是:先直接用curl测API连通性和模型名,再怼到代码里。与其在Java日志里挣扎,不如先确保HTTP层是通的。
6.3 坑三:结构化输出返回了JSON但反序列化失败,看清字段约束
LangChain4j的结构化输出在高版本里加了JsonSchema约束,这个机制对小模型的约束力会大打折扣。本地7B模型经常返回"多余的引导语",比如"好的,以下是结果:{...}"。这种脏数据别直接丢给Jackson,解析之前先做一轮字符串清理。再者,如果POJO里字段命名和模型实际返回不一致(比如蛇形命名vs驼峰命名),也会反序列化失败。通用做法是给字段加@JsonProperty注解,把映射关系写死。
6.4 坑四:Tool调用时参数类型总是报错,原因在Schema自动生成
@Tool注解方法里的参数类型,LangChain4j会自动推导成JSON Schema。如果参数是复杂对象或者泛型,自动推导可能生成错误的Schema,导致模型构造出无法解析的调用参数。我的做法是:工具方法的参数尽量用简单类型(String、Integer、Boolean、List ),复杂对象自己拆成简单参数,避免让模型猜。
6.5 坑五:异步流式响应丢消息,线程/异步编排要注意
流式输出用的StreamingResponseHandler是异步回调,老手也可能在这里翻车。举例:一个请求还没回调完,用户就发起了下一个请求,或者服务被熔断了,onError没有得到恰当处理。流式的状态管理、超时控制、异常上报这三个维度,必须从设计层面就一起规划好。我习惯在onComplete和onError里都做日志记录,并保证无论正常还是异常,前端都能收到明确的结束信号。
7. 进阶方向:RAG、多Agent、国产模型接入与MCP生态
聊完基础能力,谈谈LangChain4j在真实项目里还有哪些深水区。
7.1 RAG:把业务知识塞给模型,而不是靠微调
业务问答系统通常要回答"我们公司的请假流程是什么"这类问题,这种知识模型训练时并不掌握。通用做法是RAG:把文档切块,向量化,存入向量数据库;用户提问时,先做相似度检索,把最相关的片段塞进Prompt,再让模型基于这些片段回答。
LangChain4j对RAG的支持相当完整。流程可以拆成四步:文档加载(DocumentLoader)、文档切分(DocumentSplitter)、向量化存储(EmbeddingStore)、检索与回答(RetrievalAugmentor)。文档加载常见的有PDF、Word、Markdown,切分要控制好块大小——太小的块会丢失上下文,太大的块会浪费Token。
EmbeddingStore embeddingStore = InMemoryEmbeddingStore.builder().build(); EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("nomic-embed-text") .build(); Document document = FileDocumentLoader.load(new File("src/main/resources/knowledge.md")); DocumentSplitter splitter = DocumentSplitters.recursive(500, 100); List<Document> chunks = splitter.split(document); List<Embedding> embeddings = embeddingModel.embedAll(chunks); embeddingStore.addAll(embeddings);之后构造ChatModel时挂上RetrievalAugmentor,模型就能自动检索了。
7.2 多Agent协作:让不同的"角色"分工干活
LangChain4j里支持多Agent协作的初步形态:你可以给不同的Agent不同的SystemMessage和Tool集合,由LLM决定谁来处理当前任务。Java在Agent编排这块不如Python生态花哨,但基础模式是够用的。实际项目里我们常用"计划-执行"模式:最高层的Agent负责任务拆解,下面的子Agent各管一块(查天气、查库存、查物流),最后汇总结果。
7.3 国产模型接入:DeepSeek、通义千问等通过OpenAI兼容协议进来
这几年国产模型进展很快。好消息是,绝大多数国产模型都提供了OpenAI兼容接口,LangChain4j的OpenAiChatModel改改baseUrl和apiKey就能接入。比如接DeepSeek:
ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("https://api.deepseek.com/v1") .apiKey("你的key") .modelName("deepseek-chat") .build();用国内模型的好处是部署在国内,API访问延迟正常,内容合规风险小。接入方式和OpenAI兼容协议保持一致,切换成本极小。
7.4 MCP与Java工具生态:值得关注的演进方向
如果说RAG解决的是"模型不知道的知识",MCP(Model Context Protocol,模型上下文协议)解决的是"模型接不到的技能"。你不需要把每个工具都"写死"进LangChain4j代码里,MCP允许模型服务与工具服务通过标准协议互联。现在社区里已经有人把Spring Boot的接口通过MCP暴露给LLM,让模型直接调用现有REST接口。Java在这一块的步伐虽然慢,但方向很明朗,后续值得密切关注。
8. 给新手的最后建议:按"最小闭环"路径去实践,而不是贪多
写了这么多年Java,我的体会是:入门AI框架最大的障碍不是API不会用,而是"想一步到位"的心理。我见过很多初学者一上来就打算做"完全体"的RAG + Agent + 多模型 + 多轮对话,结果三天下来连一个最基本的对话都没跑通,直接劝退。
我的建议是,按这样一条"最小闭环"路径去实践:先跑通一个"打印一行Hello式的对话Demo",确保网络HTTP层通;再用SystemMessage控制角色,验证Prompt的影响力;接着解析一次结构化输出,让模型返回Java对象;最后给Agent加一个工具,让它"动手"。每一步都能看到反馈,每一步的成就感都会推着你往前走。
工具选型上,如果你正好有Java技术栈,LangChain4j是目前最值得投入的方向。它也远没有到进大厂题库的程度,真到了需要选技术方案的那一天,做过一个完整Demo和没做过,说出来的话分量完全不一样。
写这篇东西的时候,我个人最大的体会是:AI应用开发的蓝图仍在快速演进,框架的API三个月可能就不一样了,但"消息、Prompt、记忆、工具"这四个抽象概念短期内不会变。把这四个概念吃透,不管LangChain4j后续怎么升级,你迁移到任何新框架都会非常快。