ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LangChain4j实战:Java工程师的大模型应用开发指南

LangChain4j实战:Java工程师的大模型应用开发指南 先说个真实感受在Java生态里做LLM应用过去很长一段时间都处于“看得到吃不到”的状态。Python那边LangChain、LlamaIndex玩得飞起各种Agent、RAG、Memory组件随手一拼就是一个智能应用而Java工程师想接大模型往往只能自己封装HTTP请求、自己维护对话状态、自己写解析逻辑……不是说不行就是太原始、太碎了维护成本很高。直到LangChain4j出现这个局面才算真正被打破。LangChain4j简单说就是把LangChain那一套“用大模型构建应用”的抽象能力搬到了Java世界专门解决Java工程师接入LLM时最头疼的工程化问题。它是一个开源框架核心目标是给Java/Kotlin/Android开发者提供一套完整的LLM应用开发工具链对话管理、结构化输出、工具调用、Memory记忆、RAG检索增强、多模型切换等等都能通过统一API搞定。本文要讲的这套新手实战教程我尽量不堆概念全程用可运行的代码和真实踩坑经历来带你走一遍适合已经会用Spring Boot、但对LangChain4j完全陌生的Java后端开发也适合那些被Python版LangChain“劝退”、想留在Java体系内做AI应用的同学。1. 为什么Java团队应该认真考虑LangChain4j1.1 从一次“手搓OpenAI客户端”的教训说起先说一个我自己的项目经历。之前给公司做内部知识库问答机器人第一版图省事直接用RestTemplate封装了OpenAI的Chat Completions接口。核心逻辑很简单把用户问题拼进Prompt加上历史消息数组POST出去解析返回的JSON。跑通demo只花了一个下午心里还挺美。结果上线的第一个月就出事了。第一多轮对话的历史消息管理完全靠手动拼List用户对话一长Token直接爆掉时不时要自己截断第二模型偶尔返回非法JSON解析崩溃系统直接抛异常第三想给模型加个“查数据库”的能力得自己写函数调用的协议复杂到想骂人第四后来要换国内某家大模型发现人家的请求格式跟OpenAI不完全一致又要改一层适配……到那个阶段我就明白了LLM应用的复杂度不在“调API”而在API之外的工程问题。LangChain4j恰恰是把这些“API之外的事”都抽象好了。它内置了统一的消息协议、内存管理策略、函数调用注册机制、模型适配层让我从“面向JSON编程”回到“面向业务编程”。这一点是用过的Java开发者都能明显感知到的差异。1.2 LangChain4j与纯手写、Spring AI的横向对比很多Java工程师第一次接触这个领域时会纠结有现成的HTTP客户端为什么要用框架Spring官方后来也推出了Spring AI那跟LangChain4j又怎么选我自己的看法是这样的纯个人使用感受供参考维度手写RestTemplate方案Spring AILangChain4j学习曲线低但后续维护高中等中等偏下多模型适配几乎为零全自己写部分支持支持主流大厂模型接口统一Memory管理手动维护消息列表基础支持内置多种Memory实现可自定义工具/函数调用手写协议非常痛苦支持支持注解驱动非常爽RAG生态自己拼向量库起步阶段相对更完整有独立模块Java体系贴合度完全手动非常高高社区活跃度-官方背书独立社区迭代快说实话Spring AI背靠Spring官方未来肯定有优势但至少到现在这个时间点LangChain4j在功能的完整性和灵活度上更胜一筹尤其是Memory和AiServices这套抽象设计得很成熟很多场景能让我少写几百行样板代码。而且它的模块化做得很好不绑架你的技术栈你可以只引入需要的包。1.3 这套框架解决了哪些“真实痛点”我记得第一次看LangChain4j文档时脑子里最大的一个感受是它把我之前手写代码时所有“不舒服”的地方都给填平了。举几个具体例子消息管理的痛ChatMemory模块把用户消息、AI消息、系统消息统一管理自带滚动窗口策略再也不用自己数Token来截断历史了。结构化输出的痛用AiServices加SystemMessage、UserMessage注解配合BeanOutputParser能让模型直接返回一个类型安全的Java对象而不是裸的JSON字符串。函数调用的痛只需要给Service接口加一个方法再标注Tool注解框架自动生成工具协议模型需要查数据时就调用你的Java方法跟RPC一样自然。多模型切换的痛OpenAI、通义、文心、Ollama本地模型……换一个ChatLanguageModel实现类就行业务代码几乎不用动。这些痛点不是Demo级别的“花活”而是生产项目里每天都要面对的。LangChain4j把这些问题提到框架层面解决我觉得这才是它真正的价值所在。2. 环境准备与第一个可运行的最小Demo2.1 依赖引入与版本避坑先说版本和依赖这是新手最容易栽跟头的地方。LangChain4j目前对Java 8/11/17都有支持但我的建议是直接用Java 17因为很多高级特性和部分依赖比如向量存储的客户端对旧版本兼容性一般没必要给自己找麻烦。我用的是Maven引入核心依赖如下properties langchain4j.version0.31.0/langchain4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency !-- 这里以OpenAI为例国内模型可换 langchain4j-dashscope 或 langchain4j-qwen 等 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency /dependencies这里有个特别重要的坑LangChain4j的版本迭代非常快0.x阶段API变动频繁我最早用0.24版本写的代码升级到0.31时就有不少Breaking Change。很多网上教程用的是老版本你照着写大概率编译不过。建议以你引入的实际版本的官方文档为准不要把网上贴的旧代码直接复制。2.2 从“你好大模型”到流式对话依赖准备好之后我们来写第一个能跑的Demo。这一步的目标很简单让模型回复我们并且能处理多轮对话的上下文。import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.memory.chat.MessageWindowChatMemory; public class QuickStartDemo { public static void main(String[] args) { // 1. 创建模型这里用OpenAI协议API Key建议从环境变量读取 ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); // 2. 创建带窗口记忆的聊天内存最多保留最近20条消息 MessageWindowChatMemory memory MessageWindowChatMemory.builder() .maxMessages(20) .build(); // 3. 先扔一句系统提示词 memory.add(SystemMessage.from(你是一个资深的Java技术顾问回答要简洁、准确。)); // 4. 模拟用户连续提问 String question1 Java 21的虚拟线程和平台线程有什么区别; memory.add(UserMessage.from(question1)); AiMessage answer1 model.generate(memory.messages()); System.out.println(AI回复 answer1.text()); memory.add(answer1); // 关键把AI回复也塞回记忆里 String question2 那它在Tomcat里能直接用吗; memory.add(UserMessage.from(question2)); AiMessage answer2 model.generate(memory.messages()); System.out.println(AI回复 answer2.text()); } }看到没有全程我们都没有手动拼接JSON数组。memory.messages()返回的就是框架维护好的完整消息链传给model.generate()即可。这点对于做过多轮对话的人来说真的是解放。2.3 我用这个Demo验证过的几个关键点这个最简单的例子背后其实涉及到好几个容易踩的细节我得单独拿出来说模型名称的选择gpt-4o-mini性价比高做Demo完全够用。如果你接的是国内模型比如通义千问对应的qwen-plus或者本地跑的Ollama模型如qwen2.5:7b模型名的传法都不一样别硬套。超时时间timeout参数强烈建议设置。模型接口偶尔会“思考很久”不设超时的话你的接口调用方会先超时你这边还在傻傻等待最后抛出一堆晦涩的异常。Memory的消息顺序消息顺序必须严格按照“先系统、再用户、再助手、再用户”这种交替顺序。如果乱序部分模型不会报错但回答质量会明显下降这是我自己实测过的。Token限制MessageWindowChatMemory只按条数管理不按Token数管理所以如果你的业务是长文档对话最好自己扩展一个按Token裁剪的Memory实现。跑通这个Demo你已经算是“入了门”。后面要做的就是把框架的能力真正用起来。3. 理解LangChain4j的灵魂AiServices与结构化输出3.1 AiServices是什么凭什么说它是灵魂很多新手跑完上面的Demo觉得“这跟普通的SDK封装有什么区别”区别就在AiServices。这个类是整个LangChain4j的精华它做的事情可以用一句话概括把一个普通的Java接口变成一个有大脑、会调用工具的智能代理。举个例子。你有一个Service接口public interface CustomerSupportAgent { String answer(String userQuestion); }普通的实现就是写一个类answer方法里写业务逻辑。而用AiServices你可以这样CustomerSupportAgent agent AiServices.builder(CustomerSupportAgent.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new CustomerInfoTool()) // 注册工具模型可以按需调用 .build(); String response agent.answer(帮我查一下订单2024001的物流状态);看到区别了吗answer方法里没有任何业务代码但当你调用它时模型会“思考”需要哪些信息如果发现需要查订单数据就会自动调用你注册的CustomerInfoTool里的方法拿到结果后组织语言回复你。这就是Agent的能力而这一切在Java里通过接口和注解就完成了。3.2 SystemMessage、UserMessage与参数绑定AiServices的功能不止“自动调用工具”它还支持非常灵活的消息模板。看这个例子public interface TranslationService { SystemMessage(你是一名专业的技术文档翻译。请将用户提供的内容翻译成{{language}}保持术语准确。) String translate(UserMessage String text, V(language) String language); }这里的{{language}}是模板占位符V(language)会把参数值填进去。SystemMessage定义的是系统提示词UserMessage标注的是用户消息来源。这比手动拼Prompt优雅一万倍尤其适合封装公司内部的“稳定业务逻辑”——把Prompt工程固化在接口上调用方完全感知不到大模型的存在。我来整理一下AiServices的几种典型用法方便你对照自己的场景用法核心注解/配置典型场景简单问答无注解直接String入参对话机器人结构化信息抽取返回值用泛型/Record从非结构化文本提取实体带模板的问答UserMessage V翻译、总结、格式化工具调用Tool标注方法查数据库、调第三方API流式输出返回类型用FluxString打字机效果的流式回复3.3 结构化输出让模型返回Java对象这一点我单独拿出来讲因为它在实际开发中的价值怎么强调都不为过。没有框架的时候让模型返回JSON你得在Prompt里写“你必须返回JSON格式不要包含其他文字”然后祈祷模型听话再做JSON反序列化还要处理各种格式污染。在LangChain4j里你只需要定义好Java类型然后让接口方法直接返回这个类型public record OrderInfo(String orderId, String customerName, String status, double amount) { } public interface OrderParser { OrderInfo parseOrder(UserMessage String text); } // 调用 OrderParser parser AiServices.builder(OrderParser.class) .chatLanguageModel(model) .build(); OrderInfo info parser.parseOrder(订单号2024001张三已发货金额299.00元); System.out.println(info.status()); // 输出已发货框架会自动生成“请提取信息输出Json格式”的Prompt并把模型输出解析成OrderInfo对象。如果模型输出不合法有些解析器还能自动纠错重试一次。这个能力在做信息抽取、表单识别、客服工单结构化时非常实用我后来在好几个项目里都靠它省掉了大量正则解析代码。4. 实战做一个带“多路召回”的知识库问答系统4.1 RAG整体架构与技术选型聊完了基础我们进入一个真正有点复杂度的实战项目知识库问答系统。这是目前LLM应用落地最广泛的方向热搜词里的“多路召回”就是这类系统的常见优化手段。我先把RAG的经典流程搭起来文档加载 - 文本切分 - 向量化入库 - 用户问题向量化 - 相似度检索 - 拼接Prompt喂给LLM - 生成回答。LangChain4j对这一整套流程都有对应的模块支持。我的技术选型如下嵌入模型Embedding用langchain4j-open-ai模块里自带的OpenAiEmbeddingModel模型名text-embedding-3-small向量存储本地开发用InMemoryEmbeddingStore不依赖外部服务重启丢失生产环境建议换成PgVectorEmbeddingStore或Qdrant、Milvus等文档解析官方DocumentParser支持TXT、Markdown、PDF文本切分DocumentSplitter系列的RecursiveCharacterDocumentSplitter4.2 文档切分与向量化入库的工程细节先看代码。这一步最容易出问题的地方有三个切分粒度、重叠窗口、元数据保留。import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.TextDocumentParser; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.InMemoryEmbeddingStore; import dev.langchain4j.model.embedding.EmbeddingModel; // 1. 加载文档 Document doc FileSystemDocumentLoader.loadDocument( Path.of(/path/to/your/java-knowledge.md), new TextDocumentParser() ); // 2. 切分每段最多500字符重叠100字符 DocumentSplitter splitter DocumentSplitters.recursive(500, 100); ListTextSegment segments splitter.split(doc); // 3. 逐段向量化并入存储 EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment.text()).content(); store.add(embedding, segment); }关于切分参数我的建议是如果知识库内容以代码为主切分粒度放到300字符左右太长了会把不相关的代码逻辑揉到一起如果是技术文档、说明手册500~800字符比较合适既保留上下文语义又不会超过向量模型的单次输入限制重叠窗口建议设置为切片长度的20%这样能有效避免“一句话被拦腰截断前半段和后半段语义割裂”的问题生产环境一定要给segment保留元数据比如来源文档名、章节号、页码方便回答时引用出处。4.3 多路召回向量检索关键词检索的融合策略这里引入“多路召回”的概念。你在做题或做搜索系统时应该知道单一检索方式容易漏召回。比如向量检索擅长语义相似但用户如果提问包含某个精确实体ID、型号、人名纯向量检索的效果往往不如关键词索引因为embedding可能把精确词泛化了。我的做法是同时跑两路第一路向量相似度检索取Top K第二路基于关键词/或基于简单的全文索引比如Lucene或数据库的全文搜索用BM25类似的打分机制取Top K最后合并去掉重复文档按新鲜度或置信度重排序取前几个片段进入Prompt。简化版代码如下// 向量召回 ListEmbeddingMatchTextSegment vectorMatches store.findRelevant( questionEmbedding, 3); // 关键词召回这里用最简单的contains过滤生产可换成Lucene/ES ListTextSegment keywordMatches segments.stream() .filter(seg - seg.text().contains(keyword)) .limit(3) .toList(); // 融合按优先级合并去重 MapString, TextSegment merged new LinkedHashMap(); for (EmbeddingMatchTextSegment match : vectorMatches) { merged.putIfAbsent(match.embedded().text(), match.embedded()); } for (TextSegment segment : keywordMatches) { merged.putIfAbsent(segment.text(), segment); } // 拼接上下文 ListTextSegment finalContexts new ArrayList(merged.values()); String context finalContexts.stream() .map(TextSegment::text) .collect(Collectors.joining(\n---\n)); String prompt 基于以下资料回答问题如果资料中没有明确的答案请直接回答“根据现有知识库无法回答”。 资料 %s 问题%s .formatted(context, question);注意多路召回不是越多越好重点是“召回质量”和“上下文长度”的权衡。我实测过3~4个片段每个500字左右基本够用超过4个核心内容位置会靠后模型对中间信息的关注度会下降反而影响回答效果。4.4 让回答能“引用出处”的小技巧知识库问答有个致命问题模型可能会胡说八道明明资料里没有的知识它为了“帮到你”硬编一个答案。我在生产项目里用了一个很有效的办法强制模型在回答末尾附上参考片段编号。做法是在Prompt里增加约束String prompt 请严格依据“资料”内容回答。 如果参考答案中有多条内容请在回答末尾标注引用的片段编号格式如 [1][3]。 资料 [1] %s [2] %s [3] %s 问题%s .formatted(...);这样模型回答时天然会优先使用给出的资料内容而且能追溯到来源。用户看到引用之后对结果的信任度会高很多。这个技巧成本为零但价值极高。5. 常见报错与调优从踩坑到稳定运行5.1 与模型通信相关的报错清单写到这里我把自己在实战中遇到的报错和解决方案整理成一张表每一个都是真实遇到并解决的报错现象根因解决方案OpenAiHttpException: 401API Key无效或环境变量没读取到优先检查System.getenv是否拿到了值不要硬编码在代码里SocketTimeoutException模型接口响应慢默认超时太短显式配置timeout(Duration.ofSeconds(60))甚至更长JsonMappingException模型返回了被Markdown代码块包裹的JSON用解析器的容错模式或者Prompt明确“不要用markdown代码块包裹JSON”TokenLimitExceeded输入的Prompt历史消息超过模型上下文窗口检查Memory条数设置减少召回片段数量或换更长上下文的模型ClassNotFoundException引入了某个模块的包但没有对应依赖检查langchain4j-open-ai等模块的传递依赖缺啥补啥模型回答牛头不对马嘴消息历史顺序错乱、缺少SystemMessage用MessageWindowChatMemory管理不要手动塞消息5.2 上下文管理与Token成本控制的经验做LLM应用Token就是钱。尤其是在内网知识库这种“文档又长、调用又多”的场景成本控制是必须考虑的事。我总结了几个省钱又实用的经验优先做检索再做大模型推理而不是把整个文档全塞进去。RAG的意义就在这每次只把最相关的2-3个片段喂给模型而不是把500页文档全部拼接。对话历史没必要无限保留。绝大多数业务场景20条以上的历史消息对回答质量几乎没有帮助该截断就截断。Embedding模型的成本也要关注。如果知识库有海量文档离线离线批量先算好向量千万别每次问答都重复向量化全库文档。流式输出Streaming也是降本手段之一。用户看到前几个字出来之时心理等待时间大大缩短但实际消耗Token其实差不多。不过用户体验提升非常明显。关于流式输出LangChain4j支持得很到位接口返回FluxString即可配合WebFlux做SseEmitter这一块网上资料也不少本文就不展开了。5.3 生产化前的最后检查清单最后我给准备把LangChain4j项目推上生产的你一份检查清单这些都是我自己踩过的坑总结出来的API Key不要写死在代码里用环境变量或配置中心管理最好支持多Key轮询防止单Key限额。配置好重试与降级模型服务偶尔抖动用Resilience4j或简单重试机制兜底避免核心链路直接挂掉。敏感信息过滤大模型API请求会经过外部服务要确保知识库里没有未脱敏的客户隐私数据再送出去。日志记录Prompt和响应出问题时能回溯建议只记摘要和Token数不记录完整敏感内容。多路召回要留日志记录哪一路召回了哪些片段、最终选了哪几个这是优化检索效果的基础数据。灰度发布Prompt调整对回答风格影响巨大先对小比例流量生效观察用户反馈再全量。以上6条如果你全部做到了至少能避免80%的线上故障。我个人第一次上线LangChain4j应用时就是忽略了第2条重试机制结果大模型服务一波动用户侧直接看到报错页面被骂惨了。后来加了重试和降级稳如老狗。LangChain4j这个框架还在快速迭代中踩了一些坑之后我也习惯了——跟上它官方的Roadmap更新看Release Notes比收藏老教程靠谱得多。毕竟工具会变但“用工程化方法把大模型能力嵌进业务”这件事的方向不会变。
返回列表