1. 先说结论:Java工程师搞LLM开发,为什么终于不用再眼红Python了
这几年做大模型应用,圈子里默认的姿势是Python。LangChain、LlamaIndex、AutoGen,清一色Python生态,导致很多Java后端工程师想做个AI问答、做个人知识库助手,第一反应都是“得转语言”。说实话,这挺憋屈的——明明是业务系统里的核心落地环节,偏偏要用另一套技术栈来写。
LangChain4j就是来解决这个问题的。它是Java生态里对标LangChain的LLM应用开发框架,Apache 2.0协议,用Java 17就能跑,Spring Boot项目更是可以直接集成。它提供了一整套面向大语言模型的封装能力:对话补全、结构化输出、工具调用、记忆管理、RAG检索增强、流式输出,基本上你在Python生态里见过的AI应用范式,它都有对应的Java实现。而且它对模型服务商的兼容做得比较到位,OpenAI、Anthropic、Google Gemini、Ollama、DashScope(兼容OpenAI协议),以及各类OpenAI兼容网关都能接。
这篇文章的定位是实战入门,不是把官方文档翻译一遍。我会从搭建项目开始,用真实可跑的代码把聊天对话、AI接口声明、工具调用、RAG、流式输出这几条主线走一遍,最后把我实际踩过的坑一并交代清楚。适合谁看?第一类是Java后端工程师,想不脱离Spring Boot技术栈直接上手LLM开发;第二类是已经会用Python调API、但希望把AI能力嵌入到Java业务系统里的开发者;第三类是准备做毕业设计或内部工具,想快速验证“AI+业务”想法的人。
先说个我心里话:LangChain4j的API设计比LangChain更贴近Java程序员的使用习惯。它在类型上明显下过功夫,比如把“模型对话”抽象成消息列表,把“AI能力”抽象成接口方法,这种设计在Java工程里读起来非常舒服。下面我按一条完整的实战路径来讲,你跟着搭完,会得到一个能聊天的本地服务,以及一个能调用你业务方法的AI接口。
2. 环境准备:从零搭建一个最小可跑的LangChain4j项目
2.1 依赖和JDK版本怎么选
LangChain4j要求Java 17以上,这个没什么可妥协的。Spring Boot项目建议用3.x,配合官方提供的langchain4j-spring-boot-starter,它能自动装配ChatLanguageModel、EmbeddingModel这些核心Bean,省掉大量手工实例化代码。
Maven依赖这样加:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <properties> <langchain4j.version>0.31.0</langchain4j.version> </properties> <dependencies> <!-- Spring Boot Web 模块 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- LangChain4j 核心 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 不指定具体厂商时的通用依赖 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- Spring Boot 集成 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>这里有个新手容易踩的坑:langchain4j-open-ai这个artifactId不只是用来接OpenAI官方服务的,它管的是“兼容OpenAI接口协议”的所有服务端。你本地用Ollama、用国产模型的OpenAI兼容网关、用企业内部统一接入层,只要对方暴露的是/v1/chat/completions这类接口,都可以走这个依赖。也就是说,你不需要为每家服务商引入一个独立SDK。
2.2 配置文件的正确写法
在application.properties里配置模型接入参数:
# 模型服务商的基础地址,示例为本地Ollama,实际使用请替换为你的模型服务地址 langchain4j.open-ai.chat-model.base-url=http://localhost:11434/v1 langchain4j.open-ai.chat-model.api-key=not-needed langchain4j.open-ai.chat-model.model-name=qwen2.5:7b langchain4j.open-ai.chat-model.temperature=0.7 langchain4j.open-ai.chat-model.timeout=60s如果你用的是需要真实API Key的商业模型服务,同样按这个结构配,把base-url和api-key改成服务商提供的地址和密钥。我的建议是:首次跑通阶段优先用本地模型,理由很直接——免费、无鉴权限制、调试失败的成本为零。等整个调用链路验证没问题了,再切商业模型体验效果差异。
2.3 容器的第一段代码:让模型回个话
依赖配好、配置写好之后,先不急着写接口测试,在启动类里直接用CommandLineRunner做一次冒烟测试,能省后面一大半排查精力。
@SpringBootApplication public class LangChain4jDemoApplication { public static void main(String[] args) { SpringApplication.run(LangChain4jDemoApplication.class, args); } @Bean CommandLineRunner smokeTest(ChatLanguageModel chatLanguageModel) { return args -> { String response = chatLanguageModel.generate("用一句话介绍你自己,不要超过20个字"); System.out.println("模型返回:" + response); }; } }如果你能看到控制台输出一行模型回复,说明环境整条链路已经通了。这一步的意义很大,它会把你从“依赖问题、配置问题、网络问题、模型服务问题”这四个大坑里单独摘出来。之后再写业务代码,出现异常时你至少能确定不是底层连接的问题。
3. 核心抽象三件套:ChatLanguageModel、AiServices、ChatMemory
很多新手上来就查“怎么用LangChain4j实现聊天机器人”,然后照着示例敲了一堆Chain开头的API,越写越糊涂。我的建议是:先丢掉“链”的概念,把框架当成三类对象的组合。
3.1 ChatLanguageModel:所有大模型调用的统一入口
ChatLanguageModel是整个框架对模型能力的最小抽象。它屏蔽了各家模型的协议差异,让你用同一种方式向模型发请求:
@Service public class ChatService { private final ChatLanguageModel chatLanguageModel; public ChatService(ChatLanguageModel chatLanguageModel) { this.chatLanguageModel = chatLanguageModel; } public String answer(String userMessage) { return chatLanguageModel.generate(userMessage); } public String multiTurn(List<ChatMessage> messages) { return chatLanguageModel.generate(messages); } }注意multiTurn方法传入的是List<ChatMessage>。这个ChatMessage接口有三个实现:SystemMessage(系统提示词)、UserMessage(用户输入)、AiMessage(模型回复)。多轮对话的本质,就是把这三种消息按时间顺序排成一个列表交给模型。这个设计其实和LLM的底层原理是一致的——模型本身没有记忆,它只是根据上下文窗口里的所有内容生成回复。
3.2 AiServices:让AI能力像写接口一样声明式
这个类是LangChain4j对我个人而言最舒服的设计。你定义一个接口,然后在接口方法上声明AI的行为,框架在运行时帮你生成实现,把大模型调用包装成普通方法调用。
public interface CustomerAssistant { @SystemMessage("你是一个耐心的客服助手,只能根据提供的订单信息回答,不能编造内容。") String answer(@UserMessage("{{message}}") String message, @V("order") Order order); }然后这样装配:
CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class) .chatLanguageModel(chatLanguageModel) .build(); String reply = assistant.answer("我的订单什么时候发货?", order);是不是有MyBatis-Mapper接口那味儿了?这就是LangChain4j给Java开发者最大的红利——用声明式接口来定义AI能力,而不是像Python那样随手拼字符串提示词。它能帮你做的远不止提示词拼接,结构化输出、Few-Shot示例、工具绑定,全都可以通过接口注解来描述。
3.3 ChatMemory:聊天记忆的两种打开方式
模型不记事儿,这是刚需问题。LangChain4j里的ChatMemory负责维护多轮对话历史,最常用的是MessageWindowChatMemory,它按“轮数”管理窗口:
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); AssistantWithMemory assistant = AiServices.builder(AssistantWithMemory.class) .chatLanguageModel(chatLanguageModel) .chatMemory(chatMemory) .build();这里有个值得注意的细节:maxMessages(20)指的是消息条数而不再是“轮”数。一次用户提问加上一次模型回复算两条消息,所以20条消息实际上大约维持10轮对话。如果你希望记忆窗口按Token数来限制(因为大模型的上下文是Token限制的),可以用TokenWindowChatMemory,它通过TokenCountEstimator估算每条消息占用的Token量,达到上限后自动丢弃最早的记录。
不过我得提醒一句:凡是把ChatMemory直接放在AiServices里的做法,本质上都是“单会话记忆”。生产环境如果做多用户隔离,每个用户的会话要有独立的ChatMemory实例,这就要用到ChatMemoryProvider。后续我在第6章里专门演示怎么配置。
4. 工具调用实战:把“查天气”“下单”这类Java方法交给模型调度
4.1 Function Calling到底解决了什么问题
大模型本质上是“文本生成器”,它能编出非常合理的答案,但它没有能力去查实时数据、读写你的数据库、调用你的支付接口。工具调用(Function Calling)解决的就是这个衔接问题:你定义好Java方法,把方法的功能、参数、返回值描述给模型,模型在回答用户问题时决定“该不该调用这个方法、参数应该填什么”,然后调用的结果再拿回去继续生成最终答案。
我举一个最常见的业务场景。用户问:“帮我查一下明天的天气,适合穿什么衣服?”如果模型没有工具,它只能凭训练数据猜,错误率高得离谱。但只要你给它一个getWeather(city, date)方法,它就会先调用这个工具拿到15摄氏度的数据,再基于这个数据生成穿衣建议。
4.2 用@Tool注解注册一个查询方法
@Slf4j @Component public class WeatherTools { @Tool("根据城市和日期查询天气,日期格式为yyyy-MM-dd") public String getWeather(@P("城市名称,如'上海'") String city, @P("日期,格式yyyy-MM-dd") String date) { // 这里替换为真实的天气API调用或查库逻辑 if ("上海".equals(city)) { return "{\"city\":\"上海\",\"date\":\"2025-01-15\",\"temp\":12,\"condition\":\"小雨\"}"; } return "{\"city\":\"" + city + "\",\"date\":\"" + date + "\",\"temp\":20,\"condition\":\"多云\"}"; } }把这个工具绑定到AI服务上:
public interface WeatherAssistant { @SystemMessage("你是一个天气助手。查询到天气数据后,结合数据回答用户问题,并给出穿衣建议。") String query(@UserMessage("{{message}}") String message); } WeatherAssistant assistant = AiServices.builder(WeatherAssistant.class) .chatLanguageModel(chatLanguageModel) .tools(new WeatherTools()) .build(); String answer = assistant.query("上海明天天气怎么样?穿什么衣服合适?"); System.out.println(answer);整个执行轨迹大致是:用户提问 → 模型判断需要查天气 → 框架自动调用getWeather方法 → 把返回结果追加给模型 → 模型基于结果生成最终回复。你不需要写任何判断逻辑,模型自己会决策。
4.3 工具描述写不好,效果会大打折扣
这里有一个很多新手忽略的细节:工具方法的方法名、@Tool的描述、@P参数描述,这三样东西直接决定模型能否正确调用工具。大模型不是通过读你Java代码理解工具的,它是靠描述文本“猜测”工具的用途、参数含义和参数格式。
我在一个内部订单系统里做验证时,有同事把一个参数写成@P("订单号") String orderId,没有强调格式。结果模型在调用时把“上一笔订单”这种口语化表述直接原样传给了工具,工具拿着一串中文去查数据库,自然查不到任何结果。后来我建议把描述改成@P("订单号,纯数字字符串,如100231"),并且给工具描述补充了一句“不要修改参数,原样传入”,调用准确率才明显改观。
是的,没有看到任何可见的矛盾。如果工具的选择细微差别,请确认:调用的第一层是“模型决策”,第二层才由框架去执行链接。参数唯一。
还要注意一个重要的安全问题:权限边界。一个工具方法是一个公开的Java组件方法,任何注册了该工具的AI服务都能调用。改用查资,稳定运行。不举例子,不展开。
5. RAG入门:让大模型基于你自己的文档回答问题
5.1 不要一上来就微调模型,先做检索增强
每次有人问我“我想让模型回答我公司内部文档里的东西,要不要微调”,我的回答都是:先做RAG。微调的成本和风险高得多,而真正能回答的文档,大概率只需要精确的检索。
17日生产。这句话的实际含义是用户的 每次解析。文件等资料。知识库迅速变化的场景。
RAG的思路只有三步:
- 将文档切分为若干小块;
- 把每块文本转换成向量(Embedding);
- 用户提问时,将问题转成同维度向量,在向量库中找最相似的几个文本块,把这些文本块作为上下文与问题一起交给模型回答。
这个方案的最大优势是:不需要重新训练模型,只需要更新文档内容,答案就随文档更新而更新。
5.2 文档切分、Embedding、向量存储的完整链路
在LangChain4j中,一个可跑的RAG流程用代码展示出来非常直观。假设我这里有一个写有公司报销制度的Markdown文档,我要基于它回答问题。
第一步,准备好你的Embedding模型和向量存储:
EmbeddingModel embeddingModel = new OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder() .baseUrl("http://localhost:11434/v1") .apiKey("not-needed") .modelName("nomic-embed-text") // Ollama 本地嵌入模型 .build(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();本地没有向量数据库时,可以先用InMemoryEmbeddingStore来做内存向量存储。它能把Embedding数据序列化到磁盘,适合小规模实验。生产环境再切换成PgVectorEmbeddingStore或者MilvusEmbeddingStore,接口不变。
第二步,切分文档并写入向量库:
// 将文档按段落切分 DocumentSplitter splitter = DocumentSplitterImpl.builder().build(); Document document = Document.from(fileText); List<TextSegment> segments = splitter.split(document); // 逐段生成向量并入库 for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment.text()).content(); embeddingStore.add(embedding, segment); }第三步,让AI服务在回答前自动带上检索结果:
public interface ReimbursementAssistant { @SystemMessage("你是一个报销政策助手,只能根据提供的资料回答,找不到答案时直接说明。") String answer(@UserMessage("{{message}}") String message); } ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder() .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .maxResults(3) .minScore(0.6) .build(); ReimbursementAssistant assistant = AiServices.builder(ReimbursementAssistant.class) .chatLanguageModel(chatLanguageModel) .contentRetriever(contentRetriever) .build();5.3 RAG参数里值得打磨的两个地方
maxResults(3)和minScore(0.6)是我强调要在意的两个参数。它们直接决定了最终质量问题。
先看minScore。向量检索返回的是一个相似度分数,通常在0到1之间,越高表示语义越接近。如果把minScore设得太低,比如0.4,会捞出大量弱相关的文本片段,反而把模型“带偏”;太高则可能一个问题搜不到任何内容。我根据跨多项目的经验来看,0.6是比较好的起点,再根据具体文档语义分布做微调。
再说到maxResults。模型上下文费用,以及对检索结果的选择性问题。我记得在本地知识库的RAG场景里,3个结果通常就够了。建议 你要看你的片段切分:切得片段越碎、希望1024个字符,上下文几字节高度。
上面第三步,不通过contentRetriever时,模型回答几乎只靠训练数据里那些不完整的监督信息。带上之后,注意检索结果是放在SystemMessage或自动令上下文中,压制住了“幻觉”。这里你可能会问切到什么程度要数据、配置好隐藏。
6. 流式输出与多轮记忆:聊天体验从“能用”到“好用”
6.1 StreamingChatModel 实现打字机效果
前面的示例中,模型返回是一整段一次性输出。如果对话比较短还好,如果生成一段长文字,会让用户面对几秒甚至几十秒的白屏,交互效果会很差。产品级的Chat界面基本都是“打字机流式输出”——每秒输出几个字或几十个字,用户可以边看边等。
LangChain4j对这个场景有独立的StreamingChatModel接口,配合TokenStream来做增量订阅:
@RestController @RequestMapping("/api/chat") public class ChatController { private final StreamingChatModel streamingChatModel; public ChatController(StreamingChatModel streamingChatModel) { this.streamingChatModel = streamingChatModel; } @GetMapping(value = "/stream", produces = "text/plain;charset=UTF-8") public void stream(String message, HttpServletResponse response) throws IOException { response.setContentType("text/event-stream;charset=UTF-8"); response.setHeader("Cache-Control", "no-cache"); response.setCharacterEncoding("UTF-8"); streamingChatModel.generate(message) .onPartialResponse(token -> { try { // 每次拿到一个文本片段就立刻写入响应流 response.getWriter().write(token); response.getWriter().flush(); } catch (IOException e) { throw new RuntimeException(e); } }) .onComplete(r -> { try { response.getWriter().close(); } catch (IOException e) { throw new RuntimeException(e); } }) .onError(e -> { try { response.getWriter().write("[生成失败]"); response.getWriter().close(); } catch (IOException ex) { throw new RuntimeException(ex); } }) .start(); } }前端用EventSource或者fetch流式接口接住这段SSE格式的响应,就能实现打字机效果。对比一次返回约10秒,给用户平白无故一屏等待,这体验差距值得重视。
6.2 多用户会话隔离,不共享ChatMemory
现在到多用户会话,如果你还是用第3章那种单个ChatMemory装配,那所有用户都会共享同一份历史记录——用户A问了自己订单信息,用户B的对话里会被带上。这种事故在生产环境绝对不能发生。
正确做法是通过ChatMemoryProvider为每个会话ID提供独立的记忆:
ChatMemoryProvider chatMemoryProvider = chatMemoryId -> MessageWindowChatMemory.builder() .chatMemoryId(chatMemoryId) .maxMessages(20) .build(); StreamChatAssistant assistant = AiServices.builder(StreamChatAssistant.class) .chatLanguageModel(chatLanguageModel) .chatMemoryProvider(chatMemoryProvider) .build();然后在接口方法上标记记忆ID:
public interface StreamChatAssistant { TokenStream chat(@MemoryId String memoryId, @UserMessage("{{message}}") String message); }前端调用时把当前登录用户ID或会话ID作为memoryId传入,每个用户天然隔离。存量用户再次进入服务时,历史消息需要从Redis或数据库自行加载,而不要指望框架保留数据。
6.3 Token成本与上下文长度控制
接商业模型时,Token就是钱,上下文越短成本越低。我在一个内部文档问答项目中发现,加上系统提示词、检索出的三段文本之后,每次请求的上下文大概膨胀到原来的6倍。想办法压缩是划算的——成本在推理阶段细化项目,真实可控。
建议的默认做法:
- 系统提示词控制在150字以内,只写职责边界和输出风格,不要绕来绕去;
- 检索结果用固定前缀和分隔符包住,避免模型把参考资料误当成用户指令;
- 把历史对话窗口从20条压缩到12~16条,能保留上下文的大部分价值但成本瘦身。
7. 踩坑记录:给新手的几条硬核建议
最后把这段时间里踩过的坑成型整理一下,都是真人真事,希望你在入坑的时候能少花点时间。
7.1 版本不匹配的连带问题
LangChain4j版本演进比较快,很多API发生过调整。比如早期版本的ChatMessage必须手动构造,后来统一为chatLanguageModel.generate(List<ChatMessage>)接口,官方Starter的自动配置也一直在变化。如果你参照网上旧示例写代码,经常遇到Cannot resolve method这类编译错误。我的建议是:除非有特殊原因,使用和官方文档当前版本一致的新版本,老博客文章里的API在0.3x版本里可能并不通用,优先参考docs官方仓库的示例代码。
7.2 本地模型和商业模型的差异会导致“同样的代码,不同的结果”
同一个LangChain4j应用,从本地Ollama切到商业模型服务之后,最明显的变化是提示词敏感度完全不同。本地小模型的“工具理解和指令跟随”表现通常弱于商业模型,所以本地跑通后不代表商业模型一定能正确输出结构化内容。我遇到过一个小规模的模型把@P描述里的示例值当成硬编码返回,而不是调用工具去获取数据。这个坑属于正常范围,遇到之后先换供参考,你未必是代码写错了。
7.3 工具方法里不要写“需要用户二次确认”的逻辑依赖
进程调用链要弄清楚:模型决定是否调用工具时,已经把“调用意愿”嵌入到响应里了,框架检测到工具调用指令就会自动执行Java方法。如果你的工具里还有弹窗确认、人工审批这类阻塞逻辑,整个交互就会卡在那里。业务有确认要求的,应该把工具拆成“预申请”和“确认”两步,让模型分两次决策。
7.4 在真实代码中,永远不要把API Key硬编码
把api-key写在application.properties里的教训,我毕业第一年就遇到了。现在即便是一个本地Demo,我也会用环境变量引用:
langchain4j.open-ai.chat-model.api-key=${LLM_API_KEY}代码提交、仓库开源、截图分享时,至少密钥不会连着配置文件一起泄露出去。
老实说,LangChain4j从0.26一路用到0.31,我的整体体会是:它正处在一个比较关键的成长期,文档和生态还没有LangChain那么盛,但对Java开发者来说已经是一个完全可以落地技术路线了。如果你在自己的项目里也只跑通了Chat和AiServices,接下来值得你先拆解数据库查询的@Tool工具,再尝试内置的RAG组件。每次多跑通一个重要功能,你会发现自己离“Java也可以做AI应用”这个正反馈又近了一步。