LangChain4j 实战:Java 后端集成大模型与 RAG 检索增强
1. 为什么 Java 开发者现在该认真看一眼 LangChain4j如果你是一名写了几年 Java 的后端最近大概率会被两个词反复轰炸大模型和 RAG。但真到动手的时候很多人卡在第一步——Python 生态里的 LangChain 教程铺天盖地可公司项目是 Spring Boot 的总不能为了调个模型把整套技术栈换掉。LangChain4j 就是冲着这个痛点来的它把大模型调用、提示词模板、对话记忆、向量检索、工具调用这些能力用 Java 开发者熟悉的方式重新封装了一遍。我最初接触它的时候心态是又一个套壳库毕竟 Java 调 HTTP 接口也不是什么难事。但真正用起来才发现它解决的不是能不能调通的问题而是怎么把大模型能力干净地嵌进现有工程的问题。举个最直接的例子你要做一个带上下文记忆的客服问答纯手写的话得自己维护会话历史、控制 token 长度、处理流式返回、拼接系统提示词代码很快就变成一坨。LangChain4j 把这些抽象成了ChatMemory、AiServices、ChatLanguageModel这些接口写出来的代码接近声明式。这篇内容适合三类人一是完全没碰过大模型、但想在自己 Java 项目里加 AI 能力的后端二是用过 Python 版 LangChain、想迁移到 Java 的开发者三是需要给团队做技术选型、想快速摸清这个库边界的技术负责人。我会从环境搭建一路讲到 RAG 检索增强和工具调用中间穿插我自己踩过的坑尽量让你看完就能跑起来一个能用的东西而不是停留在Hello World。需要先说明一点LangChain4j 迭代非常快API 在不同小版本之间会有调整。我下面给的代码基于较新的稳定版本如果你照着跑报错第一反应应该是去核对版本号而不是怀疑自己写错了。这个库的文档更新速度跟不上代码速度是它目前最让人头疼的地方后面我会专门讲怎么应对。2. 动手前的环境准备与依赖选型2.1 JDK 与构建工具的最低要求LangChain4j 对 JDK 的要求不算苛刻JDK 17 是稳妥的起点。虽然部分模块在 JDK 11 上也能跑但你会遇到一些 record、sealed class 相关的编译问题尤其是用到AiServices动态代理的时候。我建议直接用 JDK 17 或 21这两个是目前的长期支持版本社区踩坑记录也最全。构建工具用 Maven 或 Gradle 都行我个人偏向 Maven因为 LangChain4j 的依赖坐标在 Maven Central 上很规整langchain4j-core、langchain4j-open-ai、langchain4j-embeddings这些模块划分清晰。Gradle 用户注意一点这个库的传递依赖里有时会带进来不同版本的 HTTP 客户端容易和项目里已有的 OkHttp 或 Apache HttpClient 冲突建议用dependencyInsight或dependency:tree先看一眼。2.2 模型接入方式的选择逻辑这是新手最容易纠结的地方。LangChain4j 支持多种模型接入大致分两类一类是走官方云服务的 API另一类是本地部署的模型。选择逻辑其实很简单看你的约束条件考量维度云服务 API本地部署模型上手速度几分钟搞定需要下载模型、配环境成本按 token 计费一次性硬件投入数据隐私数据出本地数据不出本地效果上限通常更强受模型规模限制网络依赖必须联网可离线我的建议是学习和验证阶段用云服务 API快速跑通链路真正要上生产、涉及敏感数据时再评估本地部署。LangChain4j 的好处是这两类接入的抽象层是统一的切换成本主要在配置不在业务代码。2.3 一个最小可运行的 Maven 配置先给一份能直接用的pom.xml依赖片段我把它精简到最小集合dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings-all-minilm-l6-v2/artifactId version0.35.0/version /dependency /dependencies这里解释一下为什么选这三个。langchain4j是核心包提供ChatLanguageModel、PromptTemplate、ChatMemory这些基础抽象langchain4j-open-ai是接入层它兼容 OpenAI 的接口协议很多国内外的模型服务都遵循这套协议所以这一个包能覆盖相当多的场景langchain4j-embeddings-all-minilm-l6-v2是本地嵌入模型做 RAG 的时候用来把文本转成向量它不需要额外调 API开箱即用对新手特别友好。注意版本号不要盲目追最新。LangChain4j 的 0.x 版本之间偶有破坏性变更选一个社区讨论多、文档相对全的版本比追新更重要。我上面写的 0.35.0 是一个相对稳定的参考点你实际使用时可以去官方仓库看一眼当前推荐版本。3. 第一个对话程序从裸调到 AiServices 封装3.1 最原始的调用方式长什么样先别急着上高级封装我们看看最底层怎么调。这样你才能理解后面那些抽象到底帮你省了什么ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(API_KEY)) .baseUrl(https://api.example.com/v1) .modelName(gpt-3.5-turbo) .temperature(0.7) .build(); String answer model.generate(用一句话解释什么是向量数据库); System.out.println(answer);这段代码里baseUrl是关键。很多模型服务兼容 OpenAI 协议你只要把baseUrl和modelName换成对应服务的值其余代码不用动。temperature控制输出的随机性0 到 1 之间越低越确定、越高越发散。做事实性问答建议调到 0.2 以下做创意生成可以到 0.8 以上。generate方法是最简单的同步调用传字符串、返回字符串。但它有个明显缺陷没有上下文。你问完什么是向量数据库再问它和传统数据库有什么区别模型根本不知道它指什么。这就是为什么需要对话记忆。3.2 引入 ChatMemory 让对话有记忆LangChain4j 提供了MessageWindowChatMemory它按消息条数保留最近的历史超出窗口的自动丢弃。这个设计很务实因为 token 是有成本的不可能无限保留ChatMemory memory MessageWindowChatMemory.withMaxMessages(10); ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(API_KEY)) .baseUrl(https://api.example.com/v1) .modelName(gpt-3.5-turbo) .build(); ChatAssistant assistant AiServices.builder(ChatAssistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); String r1 assistant.chat(什么是向量数据库); String r2 assistant.chat(它和传统数据库有什么区别);注意这里ChatAssistant是一个你自己定义的接口interface ChatAssistant { String chat(String message); }AiServices.builder会为这个接口生成动态代理实现你只管定义方法签名调用逻辑它帮你拼。这是 LangChain4j 最舒服的设计之一业务代码里看不到任何拼提示词的痕迹。3.3 为什么窗口大小不能随便设withMaxMessages(10)这个数字不是拍脑袋定的。它意味着保留最近 10 条消息用户和 AI 的各算一条。设太小模型记不住前文设太大每次请求都要把全部历史发给模型token 消耗线性增长响应也变慢。我的经验值是单轮对话为主的场景6 到 10 条足够多轮复杂任务比如让模型帮你逐步排查问题可以到 20 条。但更好的做法是用TokenWindowChatMemory它按 token 数而不是消息条数来裁剪更精确。不过它需要你指定一个TokenCountEstimator配置稍麻烦新手先用消息窗口版跑通再说。实操心得调试对话记忆的时候把每次请求实际发送的消息列表打印出来你会直观看到历史是怎么累积的。很多人以为记忆是模型自己记住的其实每次都是你把历史重新喂给它理解这一点对后面控制成本很关键。4. 提示词模板与结构化输出让模型按你的格式干活4.1 提示词模板解决的是复用问题硬编码提示词在 demo 里没问题但一旦要改就得翻遍代码。PromptTemplate让你把提示词抽出来用占位符填充PromptTemplate template PromptTemplate.from( 你是一名{{role}}请用{{style}}的风格回答{{question}} ); MapString, Object vars new HashMap(); vars.put(role, 资深后端工程师); vars.put(style, 简洁直接); vars.put(question, 什么是依赖注入); Prompt prompt template.apply(vars); String answer model.generate(prompt.text());{{}}是占位符语法apply的时候传入变量映射。这样做的好处是提示词可以集中管理甚至放到配置文件里改文案不用重新编译。我见过有团队把提示词做成数据库配置运营人员都能调这就是模板化的价值。4.2 结构化输出才是工程化的关键真正让 LangChain4j 在工程上有价值的是它能把模型的自由文本输出映射成 Java 对象。这在做信息抽取、分类、表单填充时特别有用。看个例子interface SentimentAnalyzer { UserMessage(判断下面这句话的情感倾向{{it}}) Sentiment analyze(String text); } enum Sentiment { POSITIVE, NEGATIVE, NEUTRAL }调用analyze(这个功能太好用了)返回的就是Sentiment.POSITIVE这个枚举值而不是一段需要你自己解析的文本。底层原理是 LangChain4j 会自动在提示词里加上格式说明然后解析模型返回的 JSON再反序列化成目标类型。这个能力看起来简单但省掉了大量脏活。以前你得写正则、处理模型偶尔不按格式返回的情况现在这些都被封装了。当然它不是百分百可靠模型偶尔还是会返回不符合格式的内容所以生产环境里要加异常处理和重试。4.3 结构化输出踩过的坑我最早用这个功能的时候定义了一个返回ListItem的接口结果模型返回的 JSON 字段名和我的 Java 字段对不上反序列化直接失败。后来才搞明白LangChain4j 会尽量根据字段名生成格式说明但如果字段名是缩写或者含义模糊模型就容易猜错。解决办法有两个一是用Description注解给字段加说明二是把字段名起得直白一点。比如qty改成quantitydesc改成description。别小看这个细节它能显著降低解析失败率。另一个坑是枚举值。模型返回的枚举字符串如果大小写不匹配或者带了多余空格也会解析失败。稳妥的做法是在枚举里加一个UNKNOWN兜底值解析失败时归到这一类而不是让整个流程崩掉。5. RAG 检索增强让模型回答你的私有知识5.1 为什么需要 RAG大模型的知识有截止日期而且它不知道你公司内部的文档、产品手册、历史工单。你当然可以把这些内容全部塞进提示词但 token 成本高得离谱而且超出上下文窗口就废了。RAG 的思路是先把知识库切成小块存进向量数据库用户提问时先检索出最相关的几块再把这几块作为上下文喂给模型。这样每次只传相关内容成本可控而且知识库可以随时更新不用重新训练模型。LangChain4j 对 RAG 的支持相当完整从文档加载、切分、嵌入、存储到检索每个环节都有对应组件。5.2 文档切分的粒度怎么定切分是 RAG 里最容易被忽视、却最影响效果的环节。切太大检索出来的块包含太多无关信息干扰模型切太小语义不完整检索不准。LangChain4j 提供了DocumentSplitters.recursive它按段落、句子、字符逐级尝试切分DocumentSplitter splitter DocumentSplitters.recursive(500, 50); ListTextSegment segments splitter.split(document);第一个参数 500 是每块的目标字符数第二个 50 是块之间的重叠字符数。重叠是为了避免一句话被硬生生切断导致语义丢失。我的经验是中文内容 300 到 500 字符一块比较合适英文可以到 800 到 1000。重叠部分取块大小的 10% 到 20%。注意切分粒度没有万能值它取决于你的文档类型。技术文档句子长、信息密度高块可以小一点叙述性内容上下文依赖强块要大一点。最好的办法是拿真实问题测看检索出来的块是否包含答案。5.3 嵌入与向量存储的完整链路嵌入就是把文本转成向量语义相近的文本向量距离也近。LangChain4j 的本地嵌入模型用起来最省心EmbeddingModel embeddingModel new AllMiniLmL6V2EmbeddingModel(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment).content(); store.add(embedding, segment); }InMemoryEmbeddingStore适合验证和小数据量重启就没了。生产环境要换成持久化的向量库LangChain4j 支持多种后端配置方式大同小异都是实现EmbeddingStore接口。检索的时候Embedding queryEmbedding embeddingModel.embed(question).content(); ListEmbeddingMatchTextSegment matches store.findRelevant(queryEmbedding, 3);findRelevant的第二个参数是返回最相关的几条。一般取 3 到 5 条太多会稀释相关性太少可能漏掉关键信息。5.4 把检索结果接进对话最后一步是把检索到的内容和用户问题拼起来String context matches.stream() .map(m - m.embedded().text()) .collect(Collectors.joining(\n\n)); String prompt 根据以下资料回答问题如果资料中没有答案就说不知道。\n\n 资料\n context \n\n问题 question; String answer model.generate(prompt);那句如果资料中没有答案就说不知道很重要。不加这句模型会倾向于编造答案也就是所谓的幻觉。RAG 的核心价值是让模型基于事实回答而不是自由发挥所以这个约束必须写进提示词。6. 工具调用让模型能操作你的系统6.1 工具调用的本质工具调用Function Calling让模型不只是聊天还能触发你定义的方法。比如用户问帮我查一下订单 12345 的状态模型识别出需要调用查询订单的方法把订单号作为参数传进去拿到结果后再组织成自然语言回复。LangChain4j 里定义工具很简单用Tool注解class OrderService { Tool(根据订单号查询订单状态) String queryOrderStatus(String orderId) { // 实际查询逻辑 return 已发货; } }然后在构建AiServices时注册进去OrderService orderService new OrderService(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(orderService) .build();模型会根据Tool里的描述判断什么时候该调用这个方法。描述写得越清楚判断越准。6.2 工具描述的写法直接影响成功率我踩过最典型的坑是工具方法叫getData描述写获取数据。结果模型根本不知道什么时候该用它。后来改成queryUserProfile描述写根据用户 ID 查询用户的姓名、邮箱和注册时间调用准确率立刻上来了。工具描述要回答三个问题这个工具做什么、需要什么参数、返回什么。参数的含义也要说清楚比如String id这种模型不知道是订单号还是用户号最好在描述里点明。6.3 工具调用的安全边界工具调用给了模型操作系统的能力这既是威力也是风险。我的原则是只暴露只读的、幂等的、影响范围可控的操作。查询类接口可以放心暴露删除、支付、发消息这类操作要么不暴露要么加人工确认环节。另外工具方法的参数一定要做校验。模型可能传进来格式不对的值甚至恶意构造的值。别因为是模型调用的就放松警惕它本质上还是一个外部输入源。7. 流式输出与生产环境的几个现实问题7.1 流式输出改善体验大模型生成一段长文本可能要好几秒用户盯着空白屏幕体验很差。流式输出让内容一个字一个字往外蹦感知上快很多。LangChain4j 用StreamingChatLanguageModel配合回调StreamingChatLanguageModel streamingModel OpenAiStreamingChatModel.builder() .apiKey(System.getenv(API_KEY)) .baseUrl(https://api.example.com/v1) .modelName(gpt-3.5-turbo) .build(); streamingModel.generate(讲个笑话, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { System.out.print(token); } Override public void onComplete(ResponseAiMessage response) { System.out.println(\n完成); } Override public void onError(Throwable error) { error.printStackTrace(); } });在 Web 场景里onNext里把 token 推给前端通常用 SSE 或者 WebSocket。这里要注意线程模型回调可能不在请求线程里执行涉及共享状态时要做好同步。7.2 超时、重试与降级生产环境里模型服务不是百分百可用的。网络抖动、限流、服务端故障都会发生。LangChain4j 本身的重试机制比较基础我建议在业务层包一层设置合理的超时别用默认的无限等待对可重试的错误如限流、超时做指数退避重试准备降级方案比如返回缓存结果或友好提示重试次数别太多3 次足够。重试太多次会把故障放大还可能触发更严格的限流。7.3 成本控制的实际手段token 是要花钱的尤其是对话历史累积起来之后。几个实用手段一是用TokenWindowChatMemory精确控制历史长度二是对重复问题做缓存相同问题直接返回上次结果三是把简单任务路由到便宜的小模型复杂任务才用大模型四是监控 token 消耗设置告警阈值。我见过有项目因为没控制历史长度一个用户连续对话几十轮后单次请求的 token 数暴涨成本失控。这类问题在测试环境不容易发现上线后才发现账单不对劲。8. 版本迭代快带来的应对策略LangChain4j 目前还在快速演进API 变动是常态。我自己的应对方式是锁定版本不盲目升级升级前先看 changelog重点看有没有破坏性变更把模型调用相关的代码集中封装别散落在业务各处这样升级时改动面小。另外这个库的官方示例更新往往滞后于代码遇到文档和实际行为不一致时直接看源码里的接口定义和测试用例比翻文档快。社区讨论也是个好来源很多坑别人已经踩过了。最后分享一个我自己的习惯每接入一个新能力先写一个最小的独立测试类跑通确认行为符合预期后再往业务代码里集成。这样出问题时能快速判断是库的问题还是业务代码的问题排查效率高很多。