
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 groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.36.2/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.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 Service3.1 Message体系三种消息如何拼出一场对话跟大模型对话本质是发送一组带角色的消息。LangChain4j把消息抽象得非常直白SystemMessage是给模型设定身份和规则的UserMessage是用户输入AiMessage是模型产出的回复。当你调用模型时它接收的是这几种消息组成的列表模型看完整段聊天记录后生成下一条回复。新手最容易误解的地方是SystemMessage不是叫一次就完的。在每次请求里只要你想让模型保持某个角色设定就得把这个系统提示词放进去。有些框架封装了系统提示词变量LangChain4j当然也有但核心逻辑没变——模型是一个无状态的函数你给它什么上下文它就扮演什么角色。所以千万不要以为在程序启动时设置过一次SystemMessage后面它就会永远记得。3.2 ChatLanguageModel统一入口背后的设计逻辑ChatLanguageModel是整个框架的门面接口。无论是哪家的模型服务最终你拿到的都是这个接口的实现对象。它的核心方法只有几个最常用的就是generate和chat。String response model.generate(你好请介绍一下你自己);从这个方法入手理解框架是很好的路径。你给它一个字符串它返回一个字符串。就这么简单。再往下chat(ListMessage)能接收完整的消息列表。真正重要的是这个统一抽象带来的可替换性——你今天用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 StreamingResponseHandlerAiMessage() { 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 多轮对话的正确姿势不要自己拼历史消息我还见过一个常见的做法就是业务代码里用一个ListString记录每轮的文本然后把它们拼成一个超长字符串再塞进SystemMessage。这种写法两个问题一是消息角色没了模型分不清哪些是你说的、哪些是它说的二是上下文失控字符串无限膨胀迟早爆掉。正确做法是用上面那套Message对象列表结合MessageWindowChatMemory的窗口裁剪。你只需要决定保留多少轮历史剩下的交给框架。5. 进阶玩法结构化输出、AI Service与轻量RAG5.1 结构化输出让模型返回Java对象而不是字符串做业务系统时我们很少需要一个纯文本回复。假设场景是客服工单分类用户描述了一堆问题系统需要产出工单类型、紧急程度、处理建议。用纯字符串去解析会遇上模型回答格式不稳定的问题今天带个编号明天多两个换行解析代码写得越多越崩溃。LangChain4j一个很爽的能力是你定义好Java对象模型直接按JSON Schema返回对应内容框架自动反序列化。public record TicketClassification( String type, int urgencyLevel, String suggestedAction, ListString 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(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); store.add(TextSegment.from(退货政策签收后7天内可无理由退货。), ...); // 查询时 Embedding queryEmbedding embeddingModel.embed(userQuestion).content(); ListEmbeddingMatchTextSegment 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再逐步加记忆、加结构化输出、加工具调用你会发现整个演进过程其实没那么吓人。等你这套代码跑顺了下一个阶段的难点就不再是怎么调模型而是怎么让模型在业务里表现得更稳定——那就是另一个故事了。