1. 为什么 Java 开发者需要关注 LangChain4j如果你是一名 Java 后端开发最近半年大概率被两个词反复轰炸大模型和 RAG。但每次想动手试试打开教程一看全是 PythonLangChain、LlamaIndex 的示例代码清一色pip install心里难免犯嘀咕——难道 Java 生态就只能干看着LangChain4j 就是冲着这个痛点来的。它把 LangChain 的核心思路用 Java 重新实现了一遍让你能在 Spring Boot 项目里直接调用大模型、搭建知识库问答、做函数调用而不需要额外维护一套 Python 服务。我最初接触它是因为公司内部有个工单系统想加智能问答运维明确说不想再引入 Python 运行时于是硬着头皮试了 LangChain4j结果从建工程到跑通第一个对话接口前后不到四十分钟。这篇文章面向的是有 Java 基础、但对大模型应用开发还没什么概念的开发者。我会从环境搭建讲起把模型接入、提示词模板、对话记忆、RAG 检索这几块核心能力逐个拆开每个环节都给出可运行的代码和我在实际调试中踩过的坑。你不需要提前了解 LangChain 的 Python 版本跟着走就行。需要提前说明的是LangChain4j 的版本迭代非常快API 在不同小版本之间偶有调整。我写这篇文章时用的是 0.31.0 版本如果你用的是更新的版本个别类名或方法签名可能有变化遇到编译报错先去官方仓库的 release notes 里扫一眼大概率能找到迁移说明。2. 动手前的环境准备与依赖选型2.1 JDK 与构建工具的最低要求LangChain4j 要求 JDK 17 及以上。这个门槛不算低但考虑到 JDK 17 已经是目前企业级项目的主流选择大部分新项目直接就能满足。如果你还在用 JDK 8 或 11建议先升级否则连依赖都拉不下来。构建工具用 Maven 或 Gradle 都行我习惯用 Maven下面的示例也以 Maven 为主。创建一个普通的 Maven 工程即可不需要 Spring Boot 的脚手架前期用纯 Java 跑通核心逻辑后面再考虑集成到 Web 框架里。2.2 核心依赖的引入策略LangChain4j 的依赖拆分做得比较细核心包和各个模型提供商的适配包是分开的。这样做的好处是你用什么模型就引什么包不会把一堆用不上的东西塞进 classpath。最基础的依赖是langchain4j-core但它本身不包含任何模型实现。要真正跑起来至少还需要一个模型适配包。以 OpenAI 兼容接口为例需要引入langchain4j-open-ai。如果你用的是其他厂商的模型服务LangChain4j 也提供了对应的适配模块选型时注意看官方文档的兼容性列表。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.31.0/version /dependency这里有个容易忽略的点langchain4j-open-ai默认会传递引入langchain4j-core所以不需要重复声明。但如果你同时引入了多个模型适配包要注意版本对齐避免 core 包被不同版本覆盖导致运行时出现NoSuchMethodError。我的做法是在dependencyManagement里统一锁定 LangChain4j 相关依赖的版本。2.3 模型服务的接入配置接入模型服务需要三个关键信息接口地址、API Key、模型名称。接口地址指向模型服务的兼容端点API Key 用于鉴权模型名称决定你调用的是哪个具体模型。在代码里配置这些信息有两种方式。一种是直接硬编码在 Java 代码里适合快速验证另一种是通过环境变量或配置文件注入适合正式项目。我强烈建议从第一天就用第二种方式因为 API Key 这种东西一旦提交到代码仓库后面清理起来很麻烦。ChatLanguageModel model OpenAiChatModel.builder() .baseUrl(System.getenv(MODEL_BASE_URL)) .apiKey(System.getenv(MODEL_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .build();temperature这个参数控制输出的随机性值越低输出越确定值越高越有创造性。做知识库问答时建议调到 0.2 以下做创意文案时可以调到 0.8 以上。这个参数没有绝对标准需要根据你的具体场景反复试。注意不同模型服务对参数的支持程度不一样有些模型不支持temperature或只接受特定范围的值。如果调用时报参数错误先检查模型文档确认支持哪些参数。3. 第一个可运行的对话程序3.1 最小化代码的编写与运行环境准备好之后写一个最简单的对话程序来验证链路是否通畅。新建一个 Java 类在main方法里构建模型实例然后调用generate方法传入一句问候语。public class FirstChat { public static void main(String[] args) { ChatLanguageModel model OpenAiChatModel.builder() .baseUrl(System.getenv(MODEL_BASE_URL)) .apiKey(System.getenv(MODEL_API_KEY)) .modelName(gpt-4o-mini) .build(); String response model.generate(用一句话解释什么是Java虚拟机); System.out.println(response); } }运行这段代码如果控制台打印出一段关于 Java 虚拟机的解释说明模型接入已经通了。如果报连接超时检查baseUrl是否可达如果报 401检查 API Key 是否正确如果报模型不存在检查modelName是否拼写正确。3.2 理解 generate 方法的返回值与异常处理generate方法返回的是模型生成的纯文本字符串。但在实际使用中你可能会遇到几种异常情况网络抖动导致请求失败、模型服务限流返回 429、输入内容触发内容过滤等。这些异常如果不处理程序会直接抛异常中断。我的做法是在调用外层包一层重试逻辑对可重试的异常如超时、限流做指数退避重试对不可重试的异常如参数错误、鉴权失败直接抛出并记录日志。LangChain4j 本身没有内置重试机制需要自己实现或者借助 Resilience4j 这类库。public String chatWithRetry(ChatLanguageModel model, String prompt) { int maxRetries 3; for (int i 0; i maxRetries; i) { try { return model.generate(prompt); } catch (Exception e) { if (i maxRetries - 1) throw e; try { Thread.sleep((long) Math.pow(2, i) * 1000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } throw new IllegalStateException(unreachable); }3.3 从单次对话到多轮对话的跨越单次调用generate是无状态的每次请求都是独立的模型不会记得你上一句说了什么。但真实的对话场景需要上下文比如你先问Java 有哪些集合类接着问它们有什么区别第二句里的它们指代什么模型必须知道上一轮的内容才能正确回答。LangChain4j 提供了ChatMemory接口来解决这个问题。最简单的实现是MessageWindowChatMemory它维护一个固定大小的消息窗口新消息进来时把最老的消息挤出去。窗口大小需要根据模型的上下文长度和你的业务需求来定一般设 10 到 20 轮比较合适。ChatMemory memory MessageWindowChatMemory.withMaxMessages(20); ChatAssistant assistant AiServices.builder(ChatAssistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); String answer1 assistant.chat(Java 有哪些常用的集合类); String answer2 assistant.chat(它们之间有什么区别);这里用到了AiServices它是 LangChain4j 提供的一个高层封装能把接口自动实现成可调用的代理对象。你只需要定义一个接口声明方法签名剩下的提示词组装、记忆管理、结果解析都由框架完成。4. 提示词模板与结构化输出4.1 用模板管理提示词而不是字符串拼接刚开始写提示词时很多人习惯用字符串拼接比如请把以下内容翻译成英文 text。这种做法在简单场景下能用但一旦提示词变长、变量变多代码就会变得难以维护而且容易漏掉转义导致注入问题。LangChain4j 提供了PromptTemplate支持用{{变量名}}占位运行时替换成实际值。这样做的好处是提示词和业务逻辑分离修改提示词不需要动 Java 代码也方便做多语言版本。PromptTemplate template PromptTemplate.from( 你是一个{{role}}请用{{style}}的风格回答{{question}} ); MapString, Object vars new HashMap(); vars.put(role, Java 技术专家); vars.put(style, 简洁); vars.put(question, 什么是依赖注入); Prompt prompt template.apply(vars); String answer model.generate(prompt.text());4.2 让模型返回 JSON 而不是自由文本自由文本适合展示给人看但如果你想把模型输出接到下游系统里做进一步处理就需要结构化输出。比如从一段用户描述里提取姓名、电话、地址返回 JSON 格式会方便程序解析。LangChain4j 支持通过AiServices直接把模型输出映射成 Java 对象。你只需要定义一个 POJO在接口方法上标注返回类型框架会自动在提示词里加入格式说明并把模型返回的 JSON 反序列化成对象。interface InfoExtractor { UserMessage(从以下文本中提取联系人信息{{text}}) Contact extract(V(text) String text); } class Contact { public String name; public String phone; public String address; }实测下来模型对 JSON 格式的遵循程度和提示词的明确程度强相关。如果发现返回的 JSON 解析失败可以在提示词里加一句只返回 JSON不要包含任何其他文字通常能显著提升成功率。4.3 结构化输出失败时的排查思路即使提示词写得很明确模型偶尔还是会返回带 markdown 代码块标记的 JSON或者多一句解释性文字。这时候反序列化就会失败。我的处理方式是在AiServices配置里加上hallucinatedToolNameStrategy和输出解析的容错逻辑同时记录原始返回内容方便定位问题。另一个常见问题是字段类型不匹配。比如模型把数字返回成字符串或者把 null 返回成空字符串。对于这种情况可以在 POJO 字段上加自定义的反序列化注解或者在提示词里明确说明每个字段的类型要求。提示调试结构化输出时先把模型的原始返回打印出来看不要一上来就怀疑框架有问题。十次里有八次是提示词没写清楚导致的。5. RAG 检索增强生成实战5.1 RAG 要解决的核心问题模型的知识是有截止日期的而且它不知道你公司内部的文档内容。你问它我们产品的退款政策是什么它只能瞎编。RAG 的思路是先把你的文档切块、向量化、存进向量库用户提问时先检索出最相关的几个文档块把它们和问题一起塞进提示词里让模型基于这些材料来回答。这样做的好处是不需要重新训练模型文档更新后重新灌一次向量库就行。代价是每次请求都要做一次向量检索响应时间会比纯对话长一些。5.2 文档加载、切分与向量化LangChain4j 提供了DocumentLoader和DocumentSplitter来处理文档。加载器负责把 PDF、Word、Markdown 等格式读成纯文本切分器负责把长文本切成适合向量化的小块。切分粒度是个需要反复调的参数。切得太碎每个块信息不完整检索出来答非所问切得太粗一个块里混了好几个主题向量表示不准确。我的经验是中文文档按 300 到 500 字切比较合适英文按 500 到 800 词。同时设置 10% 到 20% 的重叠避免关键信息刚好被切断。DocumentSplitter splitter DocumentSplitters.recursive(500, 50); ListDocument documents FileSystemDocumentLoader.loadDocuments( Paths.get(/path/to/docs), splitter ); EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .baseUrl(System.getenv(MODEL_BASE_URL)) .apiKey(System.getenv(MODEL_API_KEY)) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); EmbeddingStoreIngestor ingestor EmbeddingStoreIngestor.builder() .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(documents);5.3 检索策略与重排序的取舍最简单的检索是向量相似度搜索把用户问题向量化后在库里找最相似的几个块。但纯向量检索有个问题它擅长语义匹配但对关键词精确匹配不敏感。比如用户搜一个产品型号向量检索可能返回一堆语义相近但型号不同的文档。解决办法是混合检索把向量检索和关键词检索的结果合并再用重排序模型精排。LangChain4j 支持接入重排序模型但会增加一次模型调用成本和延迟都会上升。我的建议是先用纯向量检索跑通流程等发现召回质量确实不行了再加重排序不要一上来就上全套。检索返回的块数topK也需要调。设太小可能漏掉关键信息设太大提示词会超长且引入噪声。一般从 3 到 5 开始试根据回答质量调整。5.4 把检索结果注入对话的完整链路把检索和对话串起来需要用到RetrievalAugmentor。它负责在每次对话前执行检索把检索到的内容拼接到用户消息前面。RetrievalAugmentor augmentor DefaultRetrievalAugmentor.builder() .contentRetriever(EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .build()) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .retrievalAugmentor(augmentor) .build();minScore这个参数控制检索结果的最低相似度阈值。设得太低会把不相关的块也塞进去设得太高可能一个都检索不到。我一般从 0.6 开始试观察日志里检索到的内容是否和问题相关再逐步调整。6. 函数调用与外部工具集成6.1 函数调用的适用场景模型本身只能生成文本不能查数据库、不能调接口、不能算数学。函数调用也叫工具调用就是让模型在需要的时候喊一声告诉程序我需要调用某个函数参数是这些程序执行完把结果返回给模型模型再基于结果生成最终回答。典型场景包括查天气、查订单状态、做精确计算、查数据库。判断标准很简单——如果这个操作需要实时数据或者需要精确结果就应该做成函数让模型调用而不是指望模型自己编。6.2 用注解定义可调用函数LangChain4j 用Tool注解来标记可被模型调用的方法。方法名和参数名会被自动提取成函数描述发给模型所以命名要清晰让模型能看懂这个函数是干什么的。class WeatherService { Tool(查询指定城市的当前天气) String getWeather(P(城市名称) String city) { // 实际调用天气接口 return 晴25摄氏度; } } Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new WeatherService()) .build();模型收到用户问题北京今天天气怎么样后会判断需要调用getWeather参数是北京然后程序执行这个方法拿到结果再交给模型生成自然语言回答。6.3 函数调用中的常见陷阱第一个坑是参数类型不匹配。模型有时候会把数字参数传成字符串或者把日期格式传得五花八门。解决办法是在P注解里写清楚参数格式要求比如日期格式为 yyyy-MM-dd。第二个坑是函数执行超时。如果函数内部调用了外部接口接口响应慢会导致整个对话卡住。建议给函数执行加上超时控制超时后返回一个兜底结果让模型知道这次调用失败了。第三个坑是模型反复调用同一个函数。这种情况通常是因为函数返回的结果模型不理解或者提示词里没说清楚什么时候该调用。可以在系统提示词里加一句如果已经获取到足够信息请直接回答不要重复调用函数。7. 实际项目中的性能与成本控制7.1 对话记忆的存储选择MessageWindowChatMemory默认存在内存里重启就没了。生产环境需要持久化LangChain4j 提供了基于 Redis 和数据库的ChatMemoryStore实现。选型时考虑两点一是并发量Redis 适合高并发场景二是数据量如果对话历史很长数据库存储成本更低。我一般用 Redis 存最近 20 轮对话同时异步落库做长期归档。这样既保证了对话的连贯性又不会因为历史数据太大拖慢检索速度。7.2 减少不必要的模型调用每次模型调用都是钱和时间。有几个地方可以优化一是缓存对于相同或相似的问题直接返回缓存结果二是批处理如果有多个独立的分类任务可以合并成一次调用让模型批量处理三是用小模型做粗筛大模型做精答。LangChain4j 本身不提供缓存机制需要自己在业务层实现。最简单的做法是用ConcurrentHashMap做本地缓存key 是问题文本的哈希value 是模型回答。注意设置合理的过期时间避免缓存永远不更新。7.3 流式输出改善用户体验默认的generate方法是等模型生成完整回答后才返回用户要盯着空白屏幕等好几秒。流式输出可以让回答一个字一个字地显示出来体感上快很多。LangChain4j 支持StreamingChatLanguageModel通过回调接口逐 token 接收结果。集成到 Web 项目时配合 SSE 或 WebSocket 推送给前端。StreamingChatLanguageModel streamingModel OpenAiStreamingChatModel.builder() .baseUrl(System.getenv(MODEL_BASE_URL)) .apiKey(System.getenv(MODEL_API_KEY)) .modelName(gpt-4o-mini) .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(); } });流式输出有个细节要注意如果中间发生错误已经输出的部分内容无法撤回。所以前端展示时要做好标记让用户知道这次回答不完整。8. 一些让我少走弯路的经验LangChain4j 的文档更新速度跟不上代码迭代速度这是目前最大的痛点。遇到 API 对不上的情况直接去看源码里的测试用例比翻文档快得多。测试用例里覆盖了各种边界情况是很好的参考。向量库的选择上开发阶段用InMemoryEmbeddingStore就够了别一上来就搭 Milvus 或 Pinecone。等数据量超过几千条、内存扛不住了再换。换的时候只需要改EmbeddingStore的实现类上层代码不用动。提示词调试是个体力活。我的习惯是把每次调整的提示词版本和对应的回答质量记录下来形成一个简单的对照表。调了十几版之后回头看能明显看出哪些改动是有效的哪些是瞎折腾。最后说一个关于成本的心理预期RAG 场景下每次对话至少消耗两次模型调用一次 embedding 检索一次生成回答如果加了重排序就是三次。按目前的定价一千次对话的成本大概在几块钱到几十块钱之间具体取决于文档块数和回答长度。做预算时把这个算进去别等账单出来才傻眼。