ARTICLE DETAIL

资讯详情

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

Java工程师用LangChain4j实战:构建企业级RAG知识库问答系统

Java工程师用LangChain4j实战:构建企业级RAG知识库问答系统 写Java的同学最近应该都感受到了这股大模型应用开发的风。身边的同事要么在用Python写Agent要么在折腾各种LangChain搞得好像不会点大模型开发就落伍了一样。我用LangChain4j这个Java版框架做了一个月的内部知识库问答系统今天把整个实战过程完整复盘一遍。这个框架把Java生态和LLM应用开发衔接得很自然不用换语言不用重新学一套技术栈就能把大模型能力接进业务系统。这篇内容会从选型思路、环境搭建、核心功能开发到RAG多路召回实践、踩坑记录做一次完整拆解。所有代码都来自我这一个月的实际开发贴出来的是可以直接抄作业的版本。适合已经在用Java做后端开发、想接触大模型应用、但被Python技术栈劝退的同学也适合想用私有知识库做问答、做文档分析、做客服机器人这类场景的团队参考。1. 项目整体设计与选型思路1.1 为什么在Java生态里选择LangChain4j先聊点实际的。大模型应用开发圈子确实被Python主导但国内大量业务系统是Java写的尤其是金融、电商、企业服务这类领域。为了一个AI功能去引入Python微服务意味着运维要管两套环境、团队要学新语言、代码要跨语言调用这一步的成本很多团队根本接受不了。LangChain4j是专门给Java开发者准备的LLM应用开发框架设计思路和Python那边的LangChain对齐但实现上更贴合Java语言习惯。它把大模型调用、提示词模板、对话记忆、文档加载、向量存储这些零零碎碎的事情抽象成一套统一API。我用下来最直接的感受是不用关心底层走的是OpenAI协议还是本地大模型代码里切一个配置就行。这框架的定位不是要做Java版的LangChain复刻而是把Java生态里成熟的池化、并发、泛型、类型安全这些特性用起来。比如它自带的流式调用返回的是RxJava的Flowable配合Spring WebFlux做SSE推送非常顺。这点和Python版那种异步回调的方式差别挺大写惯Java的同学接受度会高很多。1.2 技术方案取舍对照我团队成员有长期用Spring Boot MyBatis的经验所以选型时对比了几条路线对比维度LangChain4jSpring AI纯手写HTTP调用模型适配层内置多种模型Provider内置多种模型Provider需要自己封装提示词模板类型安全支持占位符校验基础支持无对话记忆内置多种存储方案基础支持无结构化输出基于类型自动映射基础映射需要手写解析RAG组件AiServices EmbeddingStoreVectorStore全部手写Java生态熟悉度高高无手写HTTP调用看着灵活但一旦涉及流式输出、函数调用、上下文管理、向量检索这些复杂功能代码量会爆炸而且每个功能都要自己去趟坑。Spring AI是Spring官方出的和Spring Boot集成最好但组件成熟度和生态丰富度目前还比不上LangChain4j。LangChain4j胜在功能全、设计灵活、文档更新快更适合当主框架用。提示如果你团队已经深度使用Spring生态Spring AI依然值得关注但当前生产环境下LangChain4j的稳定性和周边支持更靠谱。2. 环境准备与核心概念2.1 依赖引入和基础配置JDK最低要求是17我这边直接用21。Maven项目加一个依赖就行dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency这只是核心库。如果对接不同模型服务还需要加对应依赖。我项目里同时接了OpenAI兼容协议和本地部署的模型两个依赖都加上了dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-ollama/artifactId version0.35.0/version /dependencyOpenAI兼容协议这个特别重要国内很多模型服务商都提供OpenAI兼容的HTTP接口比如通义千问的兼容模式、智谱的开放平台还有自己用vLLM部署的开源模型都可以走这个依赖接入。其实不用太纠结供应商只要是兼容OpenAI格式的统统用同一个类。基础配置用代码写或者配在application.yml里都行。我建议用yml方式环境切换方便langchain4j: open-ai: chat-model: base-url: ${LLM_BASE_URL:https://api.your-provider.com/v1} api-key: ${LLM_API_KEY:sk-xxx} model-name: ${LLM_MODEL:gpt-4o-mini} temperature: 0.7 timeout: 120s2.2 核心抽象概念速览LangChain4j有一套清晰的核心抽象第一次看会有点懵但抓一个主线就通了一切围绕ChatLanguageModel展开。ChatLanguageModel大模型对话的统一入口管输入输出不管是OpenAI还是Ollama都实现这个接口。平时用OpenAiChatModel.builder()或者OllamaChatModel.builder()创建实例。ChatMessage对话消息的抽象包含SystemMessage、UserMessage、AiMessage、ToolMessage几种类型对应不同角色。ChatMemory对话记忆管理器负责把历史消息拼到下一次请求里。EmbeddingModel负责把文本转成向量做语义检索用。EmbeddingStore向量数据库的抽象接口存向量和原始内容。AiServices框架里最强大的类把大模型、记忆、工具、向量检索全部串成一个带类型的安全接口。Tool或者叫Function Calling允许让大模型输出结构化调用指令然后由你的Java代码执行具体操作。理解这些概念最关键的一点大模型本身是无状态的API调用LangChain4j的作用是做编排把记忆、工具、检索这些能力织成一张网。想通了这个后面看代码就顺了。3. 核心功能开发实战3.1 三步跑通第一个对话不要从复杂功能开始先打通最简单的对话链路。第一步创建模型实例第二步组织消息第三步发起调用并处理返回。我用OpenAI兼容模式演示代码非常简单ChatLanguageModel model OpenAiChatModel.builder() .apiKey(sk-xxx) .modelName(gpt-4o-mini) .build(); String response model.generate(用一句话介绍LangChain4j); System.out.println(response);就这么短。generate方法可以接受字符串或List 框架帮你完成请求构造、鉴权、超时重试这些琐事。跑通这一步后面的复杂功能都是在这个基础上加东西。从上面这个简单的调用可以看到调用大模型的成本极低真正的成本在怎么设计提示词、怎么组织业务逻辑、怎么控制输出质量。我见过很多初学者把大模型当数据库用问啥答啥完全不设边界最后做出来的东西根本没法上生产。第一行代码跑通之后必须考虑工程化的问题。3.2 带上下文的多轮对话第一个坑很快就会出现大模型不记得你上一句说了什么。多轮对话必须自己维护历史消息把之前的对话内容一并传过去。用ChatMemory最简单。它会在内存里维护一个消息列表每次调用前自动注入历史ChatMemory chatMemory MessageWindowChatMemory.withMaxMessages(10); ChatLanguageModel model OpenAiChatModel.builder() .apiKey(sk-xxx) .modelName(gpt-4o-mini) .build(); String firstResponse model.generate(chatMemory, 我的名字叫李明请记住); System.out.println(firstResponse); String secondResponse model.generate(chatMemory, 我叫什么名字); System.out.println(secondResponse);注意这里第二问不需要再传历史ChatMemory自己把第一轮消息拼进去了。MessageWindowChatMemory是滑动窗口式只保留最近N条消息防止Token长度爆掉。如果是超长对话场景可以考虑用向量库做持久化记忆按相似度召回相关内容而不是无脑全量带上。这是我做客服机器人时总结的经验消息窗口固定20条再加一个知识库检索模块两套机制配合使用既有短期上下文又有长期知识。那个记忆丢失的坑我踩过一次MessageWindowChatMemory是默认只有最近消息当我需要用户画像的时候发现早期的信息已经被挤掉了。后来改造方案是拆成两个记忆层短期记忆走窗口式长期用户画像单独存Redis需要时作为SystemMessage注回。这样的架构在真实业务场景下才站得住脚。3.3 用AiServices做结构化输出大模型返回内容是不可控的它对你说好的也可能给你一段废话。真实业务系统里我希望它直接返回一个对象比如判断用户意图并解析出参数。AiServices就是干这个的。先定义一个接口方法返回值直接写业务对象interface CustomerServiceAgent { SystemMessage(你是客户服务助手根据用户问题提取意图和参数) Intent detectIntent(String userMessage); } Data public class Intent { private String type; private String productName; private Integer quantity; private MapString, String params; }然后用AiServices.builder()创建代理实例CustomerServiceAgent agent AiServices.builder(CustomerServiceAgent.class) .chatLanguageModel(model) .build(); Intent intent agent.detectIntent(我要买3个华为手机壳);框架会自动构造提示词告诉模型请提取用户意图结果用JSON输出并映射到Intent类。然后解析JSON、类型校验、反序列化全部内部完成。返回的Intent对象直接可以喂给下游订单系统不用再写一堆正则解析。实际开发时这招比让大模型输出纯文本再解析可靠得多。关键点在于字段名设计要贴近业务类型要简单嵌套不要太深。太复杂的结构容易让模型产生幻觉输出非法JSON框架内置了解析失败重试逻辑但重试多次也会抛异常。现在项目里所有和大模型交互的接口响应全是强类型对象干干净净。注意结构化输出依赖模型的JSON生成能力太老的模型效果很差。用新一些的模型基本没问题但如果遇到反复解析失败先检查是不是模型版本太旧。3.4 让大模型调用业务方法结构化输出解决了数据提取问题Function Calling解决的是行动问题。比如用户说帮我查一下订单物流大模型本身不会查库但它可以输出一段工具调用指令由你的Java代码真正执行查询。先用Tool注解写工具方法public class OrderService { Tool(根据订单号查询物流状态) public String trackOrder(String orderId) { // 在这里查数据库或调外部接口 return 订单 orderId 正在派送中预计今日送达; } }然后把工具交给AiServicesinterface Assistant { String chat(String message); } OrderService orderService new OrderService(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(orderService) .build(); String answer assistant.chat(帮我查一下订单20240815001的物流信息); System.out.println(answer);内部发生了什么第一步大模型看到查物流相关描述返回一个ToolCall请求框架解析后调用OrderService.trackOrder方法拿到结果再把结果作为ToolMessage回传给模型最后模型整理成自然语言答案。这一步是把大模型从聊天机器人变成业务入口的关键。我实际项目里用Tool接了订单查询、库存查询、退换货申请、优惠券发放五个业务方法客服问答系统直接跑通闭环。工具方法命名要清晰参数要加注释因为接口名和参数描述都会被塞进提示词直接影响模型的选择准确率。工具方法一定要幂等别让模型反复调一个扣费接口把事情搞重复。3.5 流式输出体验优化还是一次性返回文本用户等待体验很差一个简单问题转圈好几秒看着就是卡住了。生产级应用必须做流式输出。ChatLanguageModel model OpenAiChatModel.builder() .apiKey(sk-xxx) .modelName(gpt-4o-mini) .build(); TokenStream tokenStream model.chat(讲一个程序员的笑话); tokenStream .onPartialResponse(System.out::print) .onCompleteResponse(ignored - System.out.println(\n---END---)) .start();TokenStream是流式调用的入口onPartialResponse拿到增量token可以实时推给前端。如果用的Spring WebFlux可以配合SseEmitter做服务端推送GetMapping(/chat/stream) public SseEmitter streamChat(RequestParam String message) { SseEmitter emitter new SseEmitter(); TokenStream tokenStream model.chat(message); tokenStream .onPartialResponse(chunk - { try { emitter.send(chunk); } catch (IOException e) { emitter.completeWithError(e); } }) .onCompleteResponse(r - emitter.complete()) .onError(e - emitter.completeWithError(e)) .start(); return emitter; }流式输出上线之后用户的等待感大大降低。实测首token差不多300ms就能出来体感和打字机一样。唯一的注意点流式响应的token计数和用量统计要在complete阶段处理不要用partial累加因为重试或中断会导致数据不准。4. 私域知识库问答RAG与多路召回实战4.1 RAG核心流程企业里的知识库问答核心难点不在对话而在怎么让大模型回答我们自己文档里的内容。RAG检索增强生成的思路是把文档切碎、转成向量、存进向量库用户提问时先检索最相关的片段再拼进提示词让大模型基于片段作答。LangChain4j做这件事特别顺。第一步加载文档Document document Document.from(我们的退款政策是用户可以在购买后7天内申请退款...); TextSplitter splitter DocumentSplitters.recursive(300, 30); ListTextSegment segments splitter.split(document);第二步建Embedding模型和向量库EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(sk-xxx) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment embeddingStore new InMemoryEmbeddingStore();第三步把所有segment转成向量存储for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment.text()).content(); embeddingStore.add(embedding, segment); }第四步构建RetrievalAugmentor并接入AiServicesRetrievalAugmentor augmentor DefaultRetrievalAugmentor.builder() .contentRetriever(EmbeddingContentRetriever.builder() .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .minScore(0.7) .maxResults(3) .build()) .build(); interface KnowledgeAssistant { String ask(String question); } KnowledgeAssistant assistant AiServices.builder(KnowledgeAssistant.class) .chatLanguageModel(model) .retrievalAugmentor(augmentor) .build(); String answer assistant.ask(退款政策是什么);用户提问时会先向量检索找出最相关的几个片段塞进提示词让大模型只根据片段回答。这样回答就基于私域知识不会乱编。整个链路跑通后知识库问答系统就成型了。4.2 多路召回优化基础RAG能用但效果一般。最大的问题在召回质量只靠向量相似度关键词匹配不敏感用户口语化提问和文档书面用语之间存在语义鸿沟单纯向量检索经常漏召回。我项目里把召回链路从单路改成了多路召回效果提升非常明显。第一路是向量检索做语义召回第二路是BM25算法做关键词匹配专门命中专有名词和精确术语第三路是对用户问题做实体抽取拿抽出来的实体名去数据库或文档索引做精确匹配。三条结果进一个融合策略按分数加权取TopN。多路召回在LangChain4j里实现并不复杂自带的ContentRetriever接口支持自定义可以写一个CompositeRetriever把多个检索器组合起来public class HybridRetriever implements ContentRetriever { private final EmbeddingContentRetriever vectorRetriever; private final KeywordRetriever keywordRetriever; Override public ListContent retrieve(Query query) { ListContent vectorResults vectorRetriever.retrieve(query); ListContent keywordResults keywordRetriever.retrieve(query); return fuse(vectorResults, keywordResults); } }融合策略这块我没有用太复杂的算法就是先按来源加权、再按分数排序、最后做一遍去重。实测在内部2000多篇技术文档的知识库上top5命中率从单路向量检索的62%提升到了79%。加了多路召回后之前问接口超时怎么办这种口语化问题也能精确定位到《服务超时排查手册》了。提示向量检索的minScore参数建议从0.7开始调。设太高召回率会骤降设太低无关内容会大量混入。实际调试时用一批典型问题做回归测试边调边看效果。4.3 向量库与文档切分细节文档切分直接影响检索质量。我之前用过固定长度切分按200字一刀切结果把完整段落内容切断了里面提到的该服务此方法这类指代词孤零零挂在段落末尾检索出来模型也读不懂。后来改用递归切分策略按标题、段落、句子三个层次切优先按自然段边界切段太长再按句号拆。每个分块尽量保持语义完整并且给分块加上文档标题作为元数据。检索的时候标题和正文一起返回大模型就能理解内容的上下文了。向量库的选择上如果数据量在一万条以下直接用InMemoryEmbeddingStore就够了。它轻量、零部署、进程内存取适合做原型和小型工具。数据量大了之后建议换独立的向量数据库。我生产环境用的是开源方案部署一个单机服务通过REST API做向量存取。LangChain4j官方有适配器切换成本很低。另外一个坑是Embedding模型必须固定。测试时如果换了Embedding模型之前存的向量全部需要重新生成否则向量空间不一致检索结果会完全乱掉。这个我在切换模型时没注意排查了两个小时才发现线上检索效果断崖式下降最后跑了一夜的脚本重新灌库才恢复。5. 与Spring Boot整合的工程化实践5.1 封装为Spring Bean前面示例代码都是在main方法里演示真实项目必须整合Spring Boot。把模型、Agent、工具类都注册成Bean用配置类统一管理。这样业务代码只需要依赖注入不需要关心实例化过程。Configuration public class LangChain4jConfig { Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .apiKey(config.getApiKey()) .modelName(config.getModelName()) .timeout(Duration.ofSeconds(120)) .build(); } Bean public EmbeddingModel embeddingModel() { return OpenAiEmbeddingModel.builder() .apiKey(config.getApiKey()) .modelName(text-embedding-3-small) .build(); } Bean public CustomerServiceAgent customerServiceAgent(ChatLanguageModel model) { return AiServices.builder(CustomerServiceAgent.class) .chatLanguageModel(model) .tools(orderService, refundService) .build(); } }有了Bean之后业务代码就清爽了比如写一个ControllerRestController RequestMapping(/api/chat) public class ChatController { Resource private CustomerServiceAgent agent; PostMapping public RIntent chat(RequestBody ChatRequest request) { Intent intent agent.detectIntent(request.getMessage()); return R.ok(intent); } }5.2 并发与性能调优大模型API调用是典型的IO密集型操作一个请求可能阻塞几十秒。千万别在主线程里直接调用模型会直接把线程池打满。我在网关层和业务层都做了异步化改造。长耗时请求用CompletableFuture异步编排配合虚拟线程JDK 21效果最好Bean(name llmExecutor) public Executor llmExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } public CompletableFutureIntent detectIntentAsync(String message) { return CompletableFuture.supplyAsync(() - agent.detectIntent(message), llmExecutor); }另一个重点是超时与重试。Model API偶尔会抖动单个请求卡30秒全部请求都在等体验直接崩掉。LangChain4j在模型层面有超时设置建议设120秒上限。我还在外层加了一个带重试的请求包装器第一次失败重试一次间隔两秒最多重试两次超过就快速失败降级。核心经验流式接口的超时时间和普通接口要分开设流式超时不只是首字节时间还要考虑整个流的读取周期。5.3 数据一致性保障知识库更新场景最容易出问题。业务侧可能同时更新文档和删除文档向量库里必须保持同步否则用户会检索到旧内容。我用了事件驱动方案文档变更时发一条MQ消息消费者收到后重新加载对应文档内容删除旧向量并插入新向量保证两边数据一致性。消息带上文档ID、版本号消费端按版本号判断是否需要处理避免乱序。这套方案上线后再也没有出现过文档已经更新但问答还是旧答案的投诉。中间件上Redis放会话状态MySQL放订单业务数据向量库存知识片段各自职责明确通过消息组件解耦。唯一要强调的是向量库的更新和业务库更新不是强事务关系做不到原子提交。现阶段业界普遍接受最终一致业务数据更新成功后发消息异步刷新向量库。这条链路的延迟大概几秒钟用户无感知。6. 常见问题与排查技巧实录6.1 高频踩坑速查表按这一个月实操下来的频率把坑整理成一张排查表现象可能原因排查与解决调用返回401API Key配错或已过期检查yml配置确认环境变量覆盖请求超时模型负载高、网络链路慢调大timeout实现流式输出返回内容被截断maxTokens设置过小调大maxTokens排查对话窗口被塞满JSON解析失败模型幻觉输出非法JSON升级模型简化字段结构使用AiServices强类型返回召回内容不相关minScore太高或Embedding模型不匹配调低minScore固定Embedding模型重新灌库多轮对话失忆ChatMemory窗口太小或未持久化调大窗口或启用持久化记忆工具反复被调用工具描述不清或提示词引导不够精炼Tool的name和description响应速度奇慢同步阻塞调用改流式改异步线程池内存激增对话消息无限堆积用窗口式ChatMemory限制条数6.2 一个典型的知识库不回答实战排查某天运营反馈知识库明明有《发票开具流程》用户问发票怎么开时系统答非所问。第一轮排查先看召回在日志里把检索到的内容片段打出来发现返回的是《报销流程》里的句子分数0.62低于minScore阈值被过滤了。原来文档里写的是开发票而非开票用户口语发票怎么开和文档表述的向量距离比较远。这就是单路向量检索的典型短板。第二轮排查是对策把BM25关键词检索加进去。BM25对发票这种精确词命中很好直接在《发票开具流程》里匹配到了相关段落。融合后得分通过了阈值问答恢复正常。从这个案例能看出多路召回不是炫技是真实业务环境下的刚需。另外一个案例是关于工具调用的。有一次用户问订单在哪里系统把trackOrder工具调用了一遍又一遍返回结果还是错误的。打开日志发现模型把订单号参数传成了用户ID工具方法要求String类型的订单ID模型就从用户问题里随手抓了个数字塞进去。这个问题的根源是Tool的参数描述不够具体模型不知道如何正确提取参数。我把参数描述改成了用户提供的完整订单号格式为纯数字字符串例如20240815001误调用率立刻下降。6.3 成本控制与Token优化技巧Token直接等于钱。上线后第一周账单比我预想的高了不少查日志发现大部分请求的输入Token都很大。罪魁祸首是系统提示词太长加了很多无关紧要的背景设定每次请求都带着跑。优化方案系统提示词精简到最短能表达意图的程度历史消息只保留最近的几轮长文档检索结果控制在3段以内不是所有请求都需要高精度模型简单意图识别用便宜模型复杂推理用高配模型夜间的离线批处理任务改用异步低优先级队列综合下来在不影响问答质量的情况下API成本大约降了40%。这里面最大的收益来自按场景选模型聊天问答用轻量模型文档分析用推理强的模型成本差异好几倍。7. 进阶扩展与个人经验7.1 从Demo到生产还要补什么跑通Demo只是第一步上生产之前还有一堆工程化事情要补模型API密钥不能硬编码用配置中心或KMS管理所有模型调用都要有trace日志记录输入、输出、Token消耗加一层安全过滤防止用户通过提示词注入让模型输出违规内容或绕过业务限制线上模型和本地模型之间做降级切换比如主模型不可用时自动切到备用模型评估集自动化回归每隔一段时间跑一遍典型问题集验证答案质量没有退化这些看起来不酷但都是生产事故的救援绳。我见过有团队没做降级模型服务商一次故障整个客服系统直接瘫痪四小时。7.2 后续可以扩展的方向基础问答跑通后可以往更复杂的方向延伸对接企业内部的数据库让模型根据用户问题自动生成SQL查询并解释结果做多Agent协作让规划Agent拆解任务、执行Agent调用工具、检查Agent审核结果把工作流引擎和模型编排结合起来把多步骤业务逻辑用可视化或代码方式定义清楚。我自己的下一步计划是把代码评审Agent做实让模型读Git diff结合团队规范做自动审查。这个场景对结构化输出和工具调用要求高但价值非常大。后续做完我会再写一篇详细的实战记录分享出来。最后分享一个我个人的体会用LangChain4j做AI应用本质上做的是大模型与工程体系的桥接。模型的能力是天花板工程的质量是地板地板塌了天花板再高也没用。框架给了你脚手架但每个业务场景的边界、安全、成本、体验还是要靠自己一点点打磨。这个项目做下来最大的收获不是会调几个API而是对整个AI应用工程化的理解深了一整个层次。
返回列表