1. 为什么Java开发者终于捞到了自己的LLM开发框架
1.1 之前Java社区做LLM应用有多别扭
先说说我自己的经历。2023年上半年我接到一个内部需求:把客服的常见问题做成一个智能问答入口,内部叫"模拟项目X"。当时生态里能选的几乎都是Python方案,我从零搭了个Python服务,用LangChain接大模型,倒是跑通了,可后续问题接踵而来——公司核心业务全在Java体系里,要调用户中心、订单服务,要做权限校验、链路追踪,全得走已有的Java服务。中间夹着一个Python旁路,既不好做负载均衡,又不好纳入现有的监控体系,光联调就折腾了两周。
后来我又试着直接用HTTP调模型接口,不用任何框架。怎么说呢,能用,但特别痛苦。每个请求要自己拼历史消息列表,要处理流式返回的格式,要自己设计工具调用的协议,Prompt稍微复杂一点就全靠字符串拼接。最崩溃的是多轮对话:你得自己维护一个消息队列,把用户说过的每一句、模型回过的每一句都存下来,再在下一次请求时整个塞进上下文。逻辑本身不难,但代码写出来又臭又长,跟业务混在一起,根本没法维护。那段时间我对"重复造轮子"有了切身体会——不是不想造,是造出来的轮子全是方的。
1.2 LangChain4j解决的核心痛点
第一次看到LangChain4j,是在某个技术社区里有人贴它和Python版LangChain的接口对比。我的第一反应是:这个项目解决的不是"Java能不能调大模型"的问题,而是"Java能不能优雅地调大模型"的问题。它把整个LLM应用链路抽象成了几个非常顺手的模块:统一模型接口、消息管理、记忆机制、工具调用、结构化输出,甚至还有一套叫AI Service的声明式编程模型,让你像写Spring接口一样描述"AI能做什么"。
它对我最大的价值有三点。第一,把对话历史、消息结构这些脏活收走了,你不需要再手动维护一串JSON数组;第二,结构化输出做得很舒服,可以让模型直接返回一个Java对象,不用自己解析JSON再挨个做类型转换;第三,工具调用(Function Calling)是原生支持的,模型说想查订单,框架直接调你注册的Java方法,不用自己实现协议。对Java团队来说,这意味着可以在不引入Python技术栈的前提下,把AI能力直接嵌进现有代码。
1.3 它和Python版LangChain、Spring AI之间怎么选
很多人会问:那它跟Spring AI有什么区别?我的理解是,Spring AI更像要和Spring生态深度绑定的思路,很多能力建立在Spring的配置体系之上;LangChain4j则更中立,它不去绑定Web框架,你的Spring Boot项目可以用,纯命令行工具也能用,甚至Android项目里都能看到有人在跑。换句话说,LangChain4j和Spring AI不是二选一的仇人,你完全可以在Spring Boot项目里引入LangChain4j,把它当成一个独立的库来用。
和Python版LangChain对比就更明显了。Python那边的概念非常多,Agent、Chain、Memory、Callback满天飞,刚上手容易迷路。LangChain4j在演进过程中砍掉了很多花哨概念,保留的多是经过了大量实战验证的模块。它管这个叫"健壮内核"。我自己的体验是:Java版的上手曲线反而更平,因为它的核心只有那么几个接口,你把模型类配上,再理解消息、记忆、工具这三件事,基本就能应付常见需求。
2. 环境准备:Maven依赖、版本选择与第一个可运行配置
2.1 依赖怎么加最省心
如果你是Maven项目,在pom.xml里加两个依赖就够了。注意这里的版本号最好用正式Release版本,别追SNAPSHOT。
<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>第一个是核心包,第二个是模型适配器。这里有个容易疑惑的地方:包里带open-ai字样,是不是只能用某一家的服务?不是。这套适配器走的是"OpenAI兼容协议",现在国内国外几乎主流模型服务商都提供兼容接口,你只需要改baseUrl和apiKey,就能接上各自的大模型。换句话说,这个模块是拿来连接事实上的统一协议,不绑定具体厂商。
2.2 版本选择的现实问题
LangChain4j的版本演进节奏比较快,我在早先项目里用0.3几的版本,隔了几个月去看已经变成0.36了,一度怀疑他们把正式版当SNAPSHOT发。不过这不影响使用,只要盯住两个原则就行。一是看Maven中央仓库上的Latest Release版本作为基线;二是确认你的Java版本符合要求,官网要求Java 17及以上,如果你还在用Java 8,那这个框架版本会很受限。我建议直接Java 17,用到的模式匹配、Record、List.of这些语法都会让你舒服很多。
2.3 模型服务的连接配置
在配置类里,我习惯新建一个ChatLanguageModel的Bean。以配置一个兼容接口为例:
ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl("http://your-model-service.example.com/v1") .apiKey(System.getenv("MODEL_API_KEY")) .modelName("your-model-name") .temperature(0.7) .timeout(Duration.ofSeconds(30)) .maxRetries(3) .build();baseUrl指向你实际使用的模型服务地址,modelName填对方服务的模型标识。temperature说实话是个玄学,0.7在通用问答里比较平衡,太低会让输出死板,太高容易跑偏。
2.4 没有API Key也能零成本跑通
很多新手卡在第一步:没有模型服务的Key,代码根本跑不起来。我后来发现其实可以在本地起一个模型服务,LangChain4j也提供了对应的适配模块,比如langchain4j-ollama。本地装好之后,配置几乎跟上面一致,只是baseUrl换成http://localhost:11434/v1,什么鉴权都不用。先不用关心推理性能,它最大的价值是让你在写代码阶段就能把框架逻辑摸透,等部署时再切回远程服务。
提示:在公司内网环境里,如果模型服务的接口不是标准的OpenAI兼容格式,最好先电话问清楚厂商是否支持兼容协议,免得接了半天都是401报错。
3. 核心抽象一次讲透:从对话消息到AI Service
3.1 Message体系:三种消息如何拼出一场对话
跟大模型对话,本质是发送一组带角色的消息。LangChain4j把消息抽象得非常直白:SystemMessage是给模型设定身份和规则的,UserMessage是用户输入,AiMessage是模型产出的回复。当你调用模型时,它接收的是这几种消息组成的列表,模型看完整段"聊天记录"后生成下一条回复。
新手最容易误解的地方是:SystemMessage不是叫一次就完的。在每次请求里,只要你想让模型保持某个角色设定,就得把这个系统提示词放进去。有些框架封装了系统提示词变量,LangChain4j当然也有,但核心逻辑没变——模型是一个无状态的函数,你给它什么上下文,它就扮演什么角色。所以千万不要以为在程序启动时设置过一次SystemMessage,后面它就会永远记得。
3.2 ChatLanguageModel:统一入口背后的设计逻辑
ChatLanguageModel是整个框架的门面接口。无论是哪家的模型服务,最终你拿到的都是这个接口的实现对象。它的核心方法只有几个,最常用的就是generate和chat。
String response = model.generate("你好,请介绍一下你自己");从这个方法入手理解框架是很好的路径。你给它一个字符串,它返回一个字符串。就这么简单。再往下,chat(List<Message>)能接收完整的消息列表。真正重要的是这个统一抽象带来的可替换性——你今天用A厂商的模型,觉得不满意,想换成B厂商,只需要改模型构建代码,业务代码一行都不用动。我实际项目里切换过一次模型服务,全项目只改了一个@Bean方法,这对比裸调HTTP接口的体验,简直是天壤之别。
3.3 给AI戴上记忆:ChatMemory的使用方式
模型本身没有记忆,前面说了,它每次都是"失忆"的。所以LangChain4j提供了ChatMemory来处理对话历史。
MessageWindowChatMemory:只记住最近N条消息,简单有效,适合绝大多数闲聊式场景。ChatMemoryProvider:一个会话绑定一份记忆,适合多用户同时对话的系统。
最朴素的用法是这样的:
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(20); memory.add(UserMessage.from("我叫老王,请记住这个名字")); model.generate(memory.messages()); memory.add(chatResponse.aiMessage());我最初用的时候总觉得别扭:怎么还要自己把模型的回复加到memory里?后来想通了,框架把记忆机制暴露给你,是为了让你保留控制权,你可以决定"模型说过的哪句话值得进记忆"——比如做敏感词过滤时,某些回复就不该被记下来。
3.4 让模型主动调用你的代码:Function Calling
这是LangChain4j最让我惊喜的能力。模型在回答你的问题时,如果发现需要某个外部数据,它可以返回一个"我想调用某某工具"的请求,框架会执行你注册的Java方法,再把结果反馈给模型继续生成。整个过程代码上非常简洁。
public class WeatherService { @Tool("获取指定城市的当前天气") public String getWeather(String city) { // 调用气象接口,返回数据 return "晴,25摄氏度"; } }把服务对象带着传给模型生成方法:
ChatLanguageModel model = ...; WeatherService weatherService = new WeatherService(); String answer = model.generate(weatherService, "上海今天天气怎么样?");模型自己会判断这个问题需要调用工具,然后框架负责把方法的入参序列化给模型解析、再反射调用方法、最后把结果回传给模型。这里的核心价值是:你不必自己实现一套协议去描述工具的入参和出参。最典型的作用就是把AI能力接入查询类服务,像查订单、查库存、查排班,全都可以做成@Tool方法。相比之前自己用Python拼JSON语义再去模拟工具调用,这简直是降维打击。
4. 实战:跑通一个带记忆的对话助手
4.1 场景设定:一个可闲聊的客服前置助手
这个Demo我不打算做得很花哨,就做一个"客服前置助手"。用户进来后可以先跟它闲聊几句,它能记住用户说的偏好,比如"我是老用户""我更关心售后政策"这类信息,再决定是否转人工。这个场景覆盖了对话连接、系统消息、记忆管理三个核心模块,不涉及数据库,也不涉及复杂工具调用,适合第一次完整跑通。
4.2 核心代码一步步拆解
先定义一个配置类,把模型和记忆准备好。
@Configuration public class AssistantConfig { @Bean ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("local") .modelName("your-local-model") .temperature(0.6) .build(); } @Bean ChatMemory chatMemory() { return MessageWindowChatMemory.withMaxMessages(20); } }withMaxMessages(20)的意思是记住最近20条消息,也就是10轮左右的对话。这很重要,因为大模型上下文窗口是有限的,你不能无限往里面塞历史,塞多了不但费钱,回答质量也会下降。
接下来写一个服务类处理对话逻辑:
@Service public class AssistantService { private final ChatLanguageModel model; private final ChatMemory memory; public AssistantService(ChatLanguageModel model, ChatMemory memory) { this.model = model; this.memory = memory; } public String chat(String userMessage) { memory.add(UserMessage.from(userMessage)); ChatResponse response = model.generate(memory.messages()); AiMessage aiMessage = response.aiMessage(); memory.add(aiMessage); return aiMessage.text(); } public void reset() { // 简单处理:清空记忆 memory.clear(); } }这段代码很直白。关键点在上面已经提过:用户消息要加入记忆,模型生成的回复也要加入记忆。如果只存用户消息不存AI回复,模型下次看到的就是一段没头没尾的对话,上下文信息会非常单薄,回答常常前言不搭后语。
4.3 流式输出:从"卡住"到"打字机"
上面的代码是阻塞式的,用户发出请求之后要等模型把整段话全部生成完才返回。对大模型来说,一个稍长的回答可能要几秒钟,用户的体验就是"卡住了"。要改成流式,LangChain4j提供了StreamingChatLanguageModel,逻辑几乎一致:
StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("local") .modelName("your-local-model") .build(); model.chat(memory.messages(), new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { // 把token实时写回前端 sseEmitter.send(token); } @Override public void onComplete(ChatResponse response) { AiMessage aiMessage = response.aiMessage(); memory.add(aiMessage); sseEmitter.complete(); } @Override public void onError(Throwable error) { sseEmitter.completeWithError(error); } });配合WebFlux的SseEmitter或者前端用EventSource,就能还原大模型对话的"打字机"效果。我强烈建议你在第一个Demo里就用流式方案,就算暂时不需要,也尽量把底层封装成流式接口,否则后期切流式要改的业务代码还挺多的。
4.4 多轮对话的正确姿势:不要自己拼历史消息
我还见过一个常见的做法,就是业务代码里用一个List<String>记录每轮的文本,然后把它们拼成一个超长字符串,再塞进SystemMessage。这种写法两个问题,一是消息角色没了,模型分不清哪些是你说的、哪些是它说的;二是上下文失控,字符串无限膨胀,迟早爆掉。正确做法是用上面那套Message对象列表,结合MessageWindowChatMemory的窗口裁剪。你只需要决定"保留多少轮历史",剩下的交给框架。
5. 进阶玩法:结构化输出、AI Service与轻量RAG
5.1 结构化输出:让模型返回Java对象而不是字符串
做业务系统时,我们很少需要一个纯文本回复。假设场景是客服工单分类:用户描述了一堆问题,系统需要产出"工单类型、紧急程度、处理建议"。用纯字符串去解析,会遇上模型回答格式不稳定的问题,今天带个编号,明天多两个换行,解析代码写得越多越崩溃。
LangChain4j一个很爽的能力是:你定义好Java对象,模型直接按JSON Schema返回对应内容,框架自动反序列化。
public record TicketClassification( String type, int urgencyLevel, String suggestedAction, List<String> relatedTags) { }然后调用时这样写:
TicketClassification result = model.generate( "系统卡顿,无法登录,已重试三次,着急用", TicketClassification.class );就这一行,模型输出的不是一段话,而是一个能直接用的TicketClassification。框架在底层已经帮你做了三步:把record转成JSON Schema描述、在给模型的Prompt里声明"你必须按这个结构输出"、再把模型返回的JSON反序列化到目标类型。这套机制比手动拼Prompt要稳得多,我在工单系统中用了大半年,没有一次因为解析失败而中断。
5.2 AI Service:用接口描述业务流程
如果说上面已经够爽了,那AI Service就是更进一步的"声明式编程"。你只需要定义一个接口,加几个注解,LangChain4j会自动生成实现类。听起来有点像MyBatis的Mapper——定义一个接口方法,SQL由注解描述,运行框架帮你执行。AI Service也是一样的思路。
public interface SentimentAnalyzer { @SystemMessage("你是一个专业的对话质量分析助手,返回JSON对象。") SentimentResult analyze(@UserMessage("请分析这句客服对话的情感倾向:{{text}}") String text); }然后从框架里拿这个接口的实现:
SentimentAnalyzer analyzer = AiServices.builder(SentimentAnalyzer.class) .chatLanguageModel(model) .build(); SentimentResult result = analyzer.analyze("你们这个处理速度也太慢了吧,等了一个小时都没人回应");这个SentimentResult对象会包含情感标签、置信度、建议等字段。它最厉害的是把Prompt和参数分离了:{{text}}是占位符,运行时框架会注入调用参数。这样业务代码非常干净,你只关心"输入一个字符串,得到一个结构化结果",至于SystemMessage怎么配合、消息怎么组装,都被框架收敛到了接口注解里。我们后来把一个客服质检模块重构成了这种写法,几百行胶水代码被压缩到只有一个接口加几个Record。
5.3 轻量RAG:把知识库喂给模型的最低成本方案
很多团队想给模型"注入企业内部知识",最常见的就是FAQ问答。最粗糙的做法是把所有FAQ拼进SystemMessage里,问题是塞不下,也浪费Token。RAG的标准做法是:先把文档切块、向量化存起来,用户提问时,先检索出最相关的几个片段,再连同问题一起交给模型回答。LangChain4j把这块也做成了相对易用的API。
我做的模拟项目X里,用了一个内存版的嵌入模型和向量存储,整个流程代码并不长:
EmbeddingModel embeddingModel = new YourEmbeddingModel(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); store.add(TextSegment.from("退货政策:签收后7天内可无理由退货。"), ...); // 查询时 Embedding queryEmbedding = embeddingModel.embed(userQuestion).content(); List<EmbeddingMatch<TextSegment>> relevant = store.findRelevant(queryEmbedding, 3); // 把相关片段拼进Prompt String context = relevant.stream() .map(m -> m.embedded().text()) .collect(Collectors.joining("\n")); String prompt = "基于以下资料回答问题:\n" + context + "\n用户问题:" + userQuestion;这当然是最原始的RAG版本,没有做重排序、没有做多路召回,但胜在流程完整。等你理解了这一步,再去接向量数据库、换更好的Embedding模型,就只是替换组件的动作了。不要一上来就追花哨的RAG架构,先把"切块-向量化-检索-拼Prompt"这条主链路跑通。
6. 真实项目中的踩坑清单与调优建议
6.1 超时、重试与并发配置
接入LLM之后最先暴露的问题是网络层。模型服务的推理时间普遍比较长,默认HTTP超时往往不够用。之前我有个服务接的模型回答稍长一点就频繁超时,排查了很久才发现是HttpClient超时设置太紧。解决方法是显式配置超时时间,并且要预留足够的缓冲,比如30秒甚至60秒。
OpenAiChatModel.builder()里有一个maxRetries参数,用于设置重试次数。但注意:并不是所有异常都适合重试。如果模型服务返回401,那是密钥问题,重试一百次也没用;如果是429限流或500服务端异常,重试才有意义。建议只对网络异常和5xx状态码做重试,并且在重试之间做指数退避,避免你的服务在模型服务抖动时反而叠加压力。
针对并发,LangChain4j的模型实现内部使用线程池管理连接,但业务侧你需要考虑线程池的大小,避免高并发时因为HTTP连接池耗尽而超时。我在Spring Boot里习惯用独立的RestClientBean并调大线程池参数。
6.2 Token计数和成本控制的土办法
Token费用是管理层在AI项目里最关心的指标,但很多开发同学一开始会忽略。模型计费是按Token数计算的,而这个数跟你肉眼看到的字数不完全一样。英文大约一个词对应1到2个Token,中文往往一个汉字就接近1到2个Token,准确数字取决于具体模型的编码器。
我最土也最有效的控制办法是:在每一次调用前后,调用模型服务返回的Token使用统计,把数据落库,做一个简单的日账单表。LangChain4j的ChatResponse里就带tokenUsage字段,分别有输入Token、输出Token、总Token。我建议不管项目多小,接上第一天就把这个计数打点做了,不然月底结算时你会被账单吓一跳。
另一个成本大头是对话历史。很多对话场景里,历史消息长得离谱,绝大多数还是无关内容。用MessageWindowChatMemory的时候,窗口设太大成本高,设太小上下文不够用,起初可以拍脑袋用20条,后期再根据业务反馈调整。不差钱可以忽略,差钱建议试试做历史摘要压缩——把早期对话定期聚合成摘要,只保留摘要和最近几轮原始消息,效果和成本都能兼顾。
6.3 Prompt工程的几条实战经验
网上关于Prompt的说法玄乎得很,我的经验就三条。第一,明确输出格式比强调语气更重要。写"请用JSON格式回复"远好于"请务必仔细回答"。第二,系统提示词要写边界,不要光写角色。比如客服助手,不但要写"你是客服助手",更要写"不要回答与业务无关的问题,不要编造订单信息"。这一点能少掉大量不可控输出。
第三,也是很容易忽略的,固定Prompt版本。把SystemMessage从代码里抽出来,放在配置文件或模板文件里,每改动一次就记录版本。因为Prompt变更的影响是隐性且巨大的——你可能只是加了一句话,模型的功能能力没变,但输出格式变了,直接导致下游解析代码崩溃。我见过不止一次因为有人偷偷改了大Prompt导致线上功能全挂的案例,所以务必把Prompt当成代码来管理,走评审,加版本号。
6.4 测试与可观测性:别让Prompt反向影响代码
LLM项目的测试策略跟传统应用很不一样。传统的单测断言输入输出,但LLM输出天然有随机性,断言"必须包含某某词"是自找麻烦。我的做法是分两层:
- 第一层,契约测试:针对结构化输出的方法,用固定Prompt和固定输入,跑多次,断言输出能按预期反序列化到目标类型,且关键字段有值、类型匹配。这层测试不关心回答内容准确不准确,只关心解析链路稳不稳。
- 第二层,样本测试:准备一份几十条的真实用户样本库,每次模型或Prompt变更后批量跑一遍,人工检查通过率。这也是为什么上一条说Prompt要做版本管理,没有版本管理你根本不知道哪条Prompt让通过率下降了多少。
最后是可观测性。每个LLM调用的上下文、最终使用的Prompt、Token用量、推理时延、返回内容前几个字符,都建议打到日志里。因为你在排查问题时,最先问的常常是"它当时到底看到了什么、它到底回了什么",有了这些日志,大部分问题能省一半排查时间。
我在真实项目里的体会是,LangChain4j并不会让模型变强,但它把很多脏活、累活、容易写错的活都收走了,让你能把精力放在业务逻辑和效果优化上。对Java团队来说,这可能是目前把大模型接进现有技术栈最顺的一条路。先跑通一个小Demo,再逐步加记忆、加结构化输出、加工具调用,你会发现整个演进过程其实没那么吓人。等你这套代码跑顺了,下一个阶段的难点就不再是"怎么调模型",而是"怎么让模型在业务里表现得更稳定"——那就是另一个故事了。