1. 为什么是 LangChain4j:Java 生态的"迟到者"反而更顺手
说实话,看到标题里带"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> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.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 的流式接口是StreamingChatLanguageModel:
StreamingChatLanguageModel streamingModel = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName("demo-model") .build(); streamingModel.generate("给我讲一个程序员的小故事", new StreamingResponseHandler<ChatResponse>() { @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拿到的是"这一批增量"而不是"完整答案"。框架底层用的是 SSE(Server-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里的变量绑定,很快就能调出稳定结果。
另一个实战经验是:当方法返回复杂对象、模型又偶尔解析失败时,可以考虑给返回值加一层兜底。比如返回Optional<SentimentAnalysis>,或者用 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(); EmbeddingStore<TextSegment> 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(); EmbeddingStore<TextSegment> 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 最值的投入,就是把前面这七个章节都亲手敲一遍,不要复制粘贴。每敲一遍,你会对提示词、上下文、对象映射这些概念产生比读十篇教程都深的体感。遇到不对劲的地方,打开请求日志看一眼,答案往往就在里面。