1. 为什么是 LangChain4jJava 生态的迟到者反而更顺手说实话看到标题里带Java 版这三个字你应该能理解我的兴奋点在哪。最近小半年身边不少 Java 开发的老同事都在同一个问题上打转Python 那边玩大模型应用已经玩出花了LangChain、LlamaIndex 这些框架教程满天飞可我们的主力技术栈是 Java。难道要为了写个 AI 工具专门再拉一个 Python 服务出来项目组里会 Java 的人一大把会 Python 的人凤毛麟角这显然不现实。LangChain4j 解决的就是这个问题。它是 Java 生态里的 LLM 应用开发框架名字里的4j就是 for Java 的意思。它把和大模型对话、管理多轮记忆、调用外部工具、做 RAG 检索增强这些高频操作全部封装成了符合 Java 习惯的 API。你用 Spring Boot 写 Web 服务的经验在这里几乎可以无缝迁移。我最初对它没抱太大期望毕竟 Python 生态的 LangChain 太成熟了Java 版很容易做成一个能用但不顺手的移植品。实际用了两周后我的评价是它是那种迟到但更懂你的框架。很多在 Python 版里需要自己拼装的东西比如给模型返回结构化 JSON、自动映射成 Java 对象在 LangChain4j 里就是一个接口加几个注解的事体感反而更好。这篇教程面向的读者是已经会 Java 基础语法、能写 Spring Boot 接口但对大模型应用开发还比较陌生的开发者。我会从依赖引入讲到 RAG 实战全程用可复制的代码和真实项目里的坑来展开。你不需要提前会 Python也不需要读一遍 LangChain 文档跟着走完应该能搭出第一个能回答业务问题的 Java AI 服务。2. 开工第一关依赖引入与 API Key 配置2.1 最小依赖集少一个都不行LangChain4j 的模块化做得很彻底核心包和模型适配包是分开的。刚上手的人容易犯的错是只引了核心包就开跑结果找不到ChatLanguageModel接口——不是代码写错了是适配包没引。我的最小依赖组合是这样的dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency这里解释一下两个包的分工。langchain4j是框架本体提供对话接口、消息模型、提示词模板、文档处理这些核心能力。它不绑定任何具体的大模型厂商。langchain4j-open-ai是适配层负责把框架的调用翻译成某个兼容 OpenAI Chat Completions 协议的模型服务能理解的请求格式。我用的是一个国内云厂商提供的兼容接口改一下 baseUrl 就能用不需要换代码。如果你的项目用的是别的模型服务商对应找langchain4j-ollama、langchain4j-azure-open-ai这类适配包即可API 的用法是完全一致的。这也是我推荐新手用 LangChain4j 的原因之一上层代码只写一次换模型厂商只是改依赖和配置的事。2.2 基础 URL 和 Key 别写死在代码里配置模型连接时最让我意外的是baseUrl这个参数。我一开始以为框架会默认去调官方地址结果官方地址确实能调通但如果你用的不是官方模型服务就必须显式指定。这里指的是你所在网络环境可以访问的模型服务地址通常是云服务商提供给你的接入点。我的配置方式是这样的ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .baseUrl(System.getenv(LLM_BASE_URL)) .modelName(demo-model) .logRequests(true) .logResponses(true) .build();logRequests和logResponses是调试期的神配置开启后框架会把发给模型服务的完整请求体打印出来。我第一次看到真实的请求结构时才真正理解系统提示词、用户消息、历史记录是怎么拼在一起发出去的。生产环境记得关掉日志太多了。API Key 走环境变量而不是配置文件是我踩过坑之后的建议。配置文件迟早会被提交到 Git 仓库建议不要省这一步。一句话总结这个阶段的目标不是写出多复杂的逻辑而是先让一条消息发出去、把回答收回来把整条链路跑通。链路通了后面所有的功能都是在它上面做加法。3. 跑通第一个对话从同步响应到流式输出3.1 同步调用最简单的问答机器配置好ChatLanguageModel之后第一次调用其实只有一行代码String answer model.generate(用一句话解释什么是依赖注入); System.out.println(answer);如果你只是做个内部工具让用户输入、等结果、展示结果同步调用完全够用。它是阻塞式的调用方发出一条消息后要等模型把完整回答生成完方法才返回。这里我建议新手先做一个小实验来建立直觉问一个需要模型思考的问题比如请分步骤说明如何设计一个订单状态机然后把开始时间和返回时间打出来。你会发现这个耗时通常在 5 到 15 秒之间取决于模型和网络。这个数字会在后面直接影响你的 API 设计决策。同步调用还有一个隐形好处调试简单。你不用处理回调、不用管线程断点打在answer上就能看到模型生成的完整内容。我最初调试提示词效果时全部用同步方式等逻辑理顺了再改流式。如果项目里已经用了 Spring Boot你可以顺手把模型实例注册成 Bean注入到 Service 里用。这一步不做也行但注册成单例 Bean 能省掉重复创建连接的开销是进入到正式项目前的必要操作。3.2 流式调用让用户看到打字机效果同步调用最常见的体验问题是用户点一下按钮页面要白屏好几秒。这在大模型应用里几乎是不可接受的交互体验。解决方式是流式输出让模型每生成一小段内容就推给前端用户能看到文字像打字机一样蹦出来。LangChain4j 的流式接口是StreamingChatLanguageModelStreamingChatLanguageModel streamingModel OpenAiStreamingChatModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .baseUrl(System.getenv(LLM_BASE_URL)) .modelName(demo-model) .build(); streamingModel.generate(给我讲一个程序员的小故事, new StreamingResponseHandlerChatResponse() { Override public void onNext(String token) { System.out.print(token); // 每次进入这里就是收到一小块增量文本 } Override public void onComplete(ChatResponse response) { System.out.println(\n生成完成); } Override public void onError(Throwable error) { error.printStackTrace(); } });要点在于onNext拿到的是这一批增量而不是完整答案。框架底层用的是 SSEServer-Sent Events协议模型服务每生成一小段就通过 HTTP 长连接推给客户端。我在第一次跑通流式时专门把token累积到一个 StringBuilder 里最后打印整段才发现这中间其实有顺序和去重的细节要考虑。实际做 Web 项目时需要把这种回调桥接给 WebSocket 或 SSE 响应流。我当时做模拟项目X一个 AI 报告生成器时就是onNext里往 WebSocket session 写数据让前端实时渲染。要注意的是onNext是异步回调别在里面做耗时操作直接转发即可。3.3 系统提示词与多轮记忆模型记住上下文的关键单条问答跑通后下一个问题立刻就会出现怎么让模型记住用户之前说了什么答案其实藏在上面的请求日志里。你每次调用generate时框架只发了一轮对话给模型服务。模型本身没有任何记忆它只是看到你这次请求里带的内容。所谓的多轮对话本质上是把历史消息拼进同一个请求让模型根据完整上下文继续输出。LangChain4j 为此封装了ChatMemory概念最常用的MessageWindowChatMemory像一个滑动窗口ChatMemory chatMemory MessageWindowChatMemory.builder() .maxMessages(10) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build();maxMessages控制保留最近多少条消息。这里有个容易被忽略的细节如果窗口太小模型聊几句就失忆如果窗口太大请求携带的 token 数会暴涨费用和延迟同步上升。数值需要根据你的业务对话轮数和模型上下文长度来权衡没有绝对正确的答案。我会把多轮记忆和后面的结构化输出结合在一起讲因为AiServices这个 API 是 LangChain4j 最值得称道的部分。它在 Java 里带来的开发体验比其他语言框架的字符串拼提示词要优雅得多。4. 让模型学会返回 Java 对象结构化输出实战4.1 从字符串 JSON 到强类型对象的思路转变大多数人第一次让模型返回结构化内容时都会写这样的提示词请以 JSON 格式返回字段包括 a、b、c。然后拿到字符串手动用 Jackson 解析。这条路能走但有两个问题。第一模型偶尔会在 JSON 外面包一层解释文字解析直接报错。第二提示词和代码是分离的改字段名时极易漏改字符串内容在编译期没有任何校验。LangChain4j 的AiServices用 Java 接口描述模型应该做什么和返回什么类型由框架在底层自动完成提示词拼装、JSON 解析、异常重试。我第一次看到这套 API 时确实有眼前一亮的感觉——这就是 Java 注解驱动开发在 AI 时代的延续。4.2 用接口声明 AI 能力假设我们要做一个客户评论情感分析功能输入一句评论文本模型返回一个结构化的情感判断。先定义一个返回类型public record SentimentAnalysis(String sentiment, double confidenceScore) { }这里的record是 Java 16 的语法。如果你还在用 Java 8/11可以换成一个普通类字段加 getter/setter效果一样。我用 record 是因为它简洁序列化和反序列化都有天然支持。然后定义一个接口用方法和注解来描述AI 需要做的事interface SentimentAnalyzer { SystemMessage(你是一个专业的客户反馈情感分析助手。只输出分析结果不要输出多余解释。) UserMessage(请分析以下客户评论的情感倾向{{text}}) SentimentAnalysis analyze(String text); }看到了吗{{text}}是占位符框架会自动把方法入参绑定进去。SystemMessage定义系统提示词UserMessage定义用户消息模板。这比在业务代码里拼字符串干净太多了。接着构建一个实例SentimentAnalyzer analyzer AiServices.builder(SentimentAnalyzer.class) .chatLanguageModel(model) .build(); SentimentAnalysis result analyzer.analyze(这家的配送速度很快但包装破损了);result就是一个真正的SentimentAnalysis对象不用跟 JSON 字符串纠缠。我在某图像处理 Demo 的评论分析模块里用了这套方式team 里新来的同事看代码时直接就能明白模型在做什么、输入是什么、输出是什么维护成本降低很多。4.3 提示词写的越具体返回结果越稳定结构化输出最大的坑不是框架不会解析而是提示词设计不当导致模型返回了与字段语义不符的内容。比如你期望sentiment只返回positive、negative、neutral三个值如果提示词里没说清楚模型可能给你返回好评或有点正面虽然也能塞进 String 字段但下游程序判断equals(positive)时全都对不上。实践中我推荐在提示词里明确枚举值和格式要求必要时给出示例UserMessage( 请分析以下客户评论的情感倾向{{text}} 要求 - sentiment 只能从 positive、negative、neutral 三个值中选择 - confidenceScore 是 0 到 1 之间的小数表示置信度 示例{{example}} )参数example可以从方法入参传入再配合UserMessage里的变量绑定很快就能调出稳定结果。另一个实战经验是当方法返回复杂对象、模型又偶尔解析失败时可以考虑给返回值加一层兜底。比如返回OptionalSentimentAnalysis或者用 record 包的默认值配合 Jackson 的默认解析配置避免一个坏样本导致整个业务链路崩溃。我会在后面的五个坑里再展开讲这个主题因为它确实是 AI 应用从 demo 到生产最关键的环节之一。5. 把本地资料接进来RAG 最小可行实现5.1 不训练模型也能让模型懂你的私有资料在实际项目里模型训练时不可能见过你的内部文档。你要让 AI 回答我们公司报销流程是什么这类问题时最朴素的办法是把整个制度文档塞进提示词。可文档一长token 费用和响应延迟都会爆炸。RAG检索增强生成的思路是每次提问时先从资料库里检索出最相关的几段内容只把这几段拼进提示词给模型。LangChain4j 对 RAG 的支持也是模块化的先切分文档再向量化存入向量库回答问题时先检索再生成。整个链条最基础的三样东西是嵌入模型、文档切分器、向量存储。嵌入模型的作用是把一段文本变成一串浮点数向量语义相近的文本向量距离也近。我不建议一开始就纠结哪个嵌入模型效果最好选择一个普遍可用的嵌入模型就足够了。向量存储则专门用来做相似度检索。开发阶段不必急着上专门的数据库用内置的内存向量存储即可。文档切分器是把长文档切成小块的手段切多大会直接影响检索质量下面会专门说。5.2 一条完整的处理链路我整理了一个可以直接跑的流程用于把自己的 Markdown 文档接进 RAG。先准备文档切分和入库的环境DocumentSplitter splitter DocumentSplitters.recursive(500, 100); EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .baseUrl(System.getenv(LLM_BASE_URL)) .modelName(embedding-model) .build(); EmbeddingStoreTextSegment embeddingStore new InMemoryEmbeddingStore();recursive(500, 100)表示每个片段最多 500 个字符片段之间重叠 100 个字符。重叠部分是为了防止一句话恰好被切断导致语义不完整。切分粒度以及按段落切还是按固定长度切是影响 RAG 效果最深的两个参数建议对比几次再确定。然后写一个加载文档的方法Document document Document.from(你的文本内容可以是文件、字符串、数据库读出的内容); EmbeddingStoreIngestor ingestor EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(document);查询时构建一个检索组件EmbeddingStoreContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build(); AssistantWithDocs assistant AiServices.builder(AssistantWithDocs.class) .chatLanguageModel(model) .contentRetriever(retriever) .build();maxResults决定每次最多检索几段相关内容并拼接进提示词。minScore是相似度阈值低于这个分数视为不相关不拼进去。这个阈值建议实际打几条测试问题来调调得太低会出现无关内容调得太高会检索不出来。这之后调用 assistant 的方式和普通AiServices一样框架会自动完成检索、拼接、生成、格式化。我在某跨平台系统的帮助中心接入这个方案后模型回答客服类问题的准确率提升非常明显——关键是它能把几百页文档压缩成每次只带三小段相关内容成本也可控。5.3 用 InMemoryEmbeddingStore 起步用独立向量库收尾开发调试阶段我一直推荐InMemoryEmbeddingStore零部署、零配置、数据放内存里。但它有两个硬伤不能忽视内存占用随数据量线性增长且应用重启后数据全丢。当文档规模到了几百份、查询并发上来之后就要迁到独立的向量存储组件比较常见的有 pgvector、Milvus、Redis 的向量能力等。LangChain4j 为这些方案提供了对应的适配包。迁移时接口结构基本不变你需要改的是EmbeddingStore的构建方式以及确认目标向量库的相似度阈值和索引参数。Be careful不同存储组件对相似度分数的定义不一定一致有的越大越相似有的越小越相似迁移后务必回归一次minScore。6. 入门阶段最容易踩的五个坑6.1 超时默认配置扛不住真实模型响应我见过不少新手第一次用ChatLanguageModel调通后直接照搬到线上接口结果晚上高峰期接口成片超时。原因很朴素模型响应慢的时候可能长达 10 秒、20 秒而默认的 HTTP 连接超时和读取超时并不一定足够或者网关层面的超时限制比客户端还短。LangChain4j 的模型 builders 提供了超时配置项。以OpenAiChatModel.builder()为例可以设置timeout(Duration.ofSeconds(60))这是给底层 HTTP 客户端的整体超时。实际项目里我还会同步检查 Web 框架的网关超时不要让它们卡在中间。另外凡是调用大模型的接口前端调用最好都用异步方式不然一个慢请求很容易占光整个服务的连接池。6.2 Token 消耗无意识把全量历史都发给模型多轮对话里最容易烧钱的地方是MessageWindowChatMemory窗口配太大或设置不当。比如maxMessages设成 50而你的业务场景根本不需要那么多历史那么每次请求都会把几十条消息重复发一遍token 消耗成倍增长。更隐蔽的开销来自 RAG 的maxResults。如果把检索结果数设成 5加上基础提示词和文档片段单次请求的上下文就有几千 token。当成千上万的请求跑起来时费用就会体感明显。排查技巧我建议直接看模型服务的调用日志请求里实际携带了多少 token 一目了然。另外很多模型服务按输入和输出分开计费输出 token 单价常常比输入高。所以让模型多干正事、少说废话不只是风格问题也是成本问题。系统提示词里写上答案控制在 N 字以内、不要输出多余解释日积月累能省不少钱。6.3 并发共享同一个模型实例到底安不安全LangChain4j 的模型实例本身被设计为线程安全的多个请求可以同时用它调用同一个底层连接。我看过不少人担心这点自己 new 了一堆模型实例反而白白占用资源。但是ChatMemory就有状态了。如果你在AiServices里配了一个单例ChatMemory并且多个用户共用这一个服务实例就会出现用户 A 的消息出现在用户 B 的对话上下文里。我吃过这个亏。解决方法有几个层面内存型为每个用户维护独立的ChatMemory按用户 ID 存一个 Map。适合单机、用户量不大的场景。数据库型会话结束时持久化到库下次启动恢复。无状态方案每次请求显式带上需要的全部上下文不走服务端记忆。从架构上看无状态方案更稳但需要调用方自己维护历史记录想要体验好倾向于按会话维度管理ChatMemory。关键是不要所有用户共享同一个记忆。6.4 结构化输出的坏 JSON问题即便用了AiServices也不能 100% 保证返回结果完美映射成 Java 对象。模型偶尔会在正文里多出一个标点、少一个字段或者对 JSON 嵌套结构理解偏差。框架通常会自动重试但重试也意味着用户等待时间变长。我总结的应对思路按优先级排列在提示词里提供目标字段的示例值模型照猫画虎的成功率显著提高。在方法返回值的设计上留好容错空间使用Optional或定义带默认值的 record。对关键业务做一次字段级校验发现异常就提示用户暂时无法理解请换个说法。千万别把一个不能保证 100% 的环节想成 100%生产系统要有兜底。6.5 日志打印会泄露业务数据我在调试时开启的logRequests和logResponses会包含实际发送给模型服务的提示词内容。如果对话里带用户个人信息、内部文档内容这些日志一旦进入集中式日志平台就是数据泄露隐患。生产环境务必关闭logRequests或者至少在关闭前先确认日志脱敏机制。我现在的做法是调试时开、预发环境关、生产环境永远不开。AI 应用比普通接口多了一个外部系统可见的维度除了日志还要考虑三方模型服务商能否看到这些数据这属于技术上容易被忽略的合规问题需要公司安全团队介入评估。7. 一个更贴近真实项目的串联示例理论说太多还是给一个抄作业级别的完整骨架吧。假设我们要做一个IT 运维知识库问答助手输入是运维文档输出是带参考资料来源的解答。定义返回结构public record Answer(String content, String source) { }定义 AI 接口interface OpsAssistant { UserMessage( 请根据下面提供的资料回答用户问题{{question}} 如果资料中没有答案请直接说资料中未找到相关信息。 ) Answer answer(String question); }构建ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .baseUrl(System.getenv(LLM_BASE_URL)) .modelName(demo-model) .timeout(Duration.ofSeconds(60)) .build(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); EmbeddingStoreContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.7) .build(); OpsAssistant assistant AiServices.builder(OpsAssistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build();然后写一个loadDocuments方法把文档逐个 ingest 进向量库。之后任何时候提问answer方法都会自动完成检索 - 加提示词 - 生成 - 解析对象的流程。这是我到目前为止用的最高频的模板几乎适用于所有用 AI 解释私有文档的场景。那个source字段怎么填充其实 LangChain4j 的检索器在检索到内容时会把对应的段落来源信息放进上下文中你可以在提示词里要求模型据实返回。这一步依赖文档加载时保留元数据我在加载文档时会尽量把文件名、章节标题写入元数据。到了这一步你已经把一个能聊天的玩具升级成能在业务里帮忙查资料的实用工具了。接下来要深入的方向其实都是围绕这个骨架做减法或加法加工具调用让模型可以执行动作加向量库选型让检索规模更大加评估方法让提示词优化有据可依。不过那是下一篇文章的话题了。根据我个人经验入门 LangChain4j 最值的投入就是把前面这七个章节都亲手敲一遍不要复制粘贴。每敲一遍你会对提示词、上下文、对象映射这些概念产生比读十篇教程都深的体感。遇到不对劲的地方打开请求日志看一眼答案往往就在里面。
阅读完成 · 觉得有帮助?