ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java工程师的LLM应用开发实战:LangChain4j入门与避坑指南

Java工程师的LLM应用开发实战:LangChain4j入门与避坑指南

Java 后端做 LLM 应用这件事,过去两年我一直处于一种“看 Python 那边热闹,自己这边没趁手兵器”的状态。直到把 LangChain4j 完整跑下来几个项目,才确定这确实是 Java 生态里最值得投入的一条路。这篇不是官方文档翻译,是我从零上手 LangChain4j 的真实笔记,包含版本选型、可直接跑的代码、以及我踩进去又爬出来的五个坑。适合刚接触 LLM 应用开发、想在自己的 Spring 项目里加 AI 能力、或者单纯不想写一堆 Python 胶水代码的 Java 工程师。

1. Java 开发者做 LLM 应用,为什么绕不开 LangChain4j

1.1 Python 圈的繁荣与 Java 圈的窘境

我见过太多团队做 AI 功能时的路线争执:后端明明是 Java 技术栈,一碰到大模型就默认要开一个 Python 微服务,把 Prompt 拼接、文档切分、向量检索全塞进去,Java 这边只负责转发 HTTP 请求。这么做不是不行,但代价是两套语言、两套部署流程、两批维护人员,中间只要业务一改,沟通成本立刻翻倍。

Python 生态的优势是框架多、更新快,但它的繁荣有一部分是靠“每天冒出新框架”堆出来的,对 Java 团队来说,学习与迁移成本被放大了。Java 这边虽然起步晚,却一直缺一个真正理解 Java 开发习惯的 LLM 框架。

后来我找到 LangChain4j,试用第一个星期就意识到:这才是 Java 里该有的样子。它把模型调用、消息历史、结构化输出、工具调用、文档检索这些能力,全部封装成 Java 工程师熟悉的接口、注解和 Builder 模式。对于一个写过多年 Spring 的人来说,这个东西的学习曲线比想象中平滑得多。

1.2 LangChain4j 到底解决了什么问题

LangChain4j 解决的核心问题不是“帮你调一个模型”,而是“帮你把模型集成进 Java 业务代码”。举个例子,普通方式调用大模型就是发一个 HTTP 请求,拿回一段文本;但真实业务需要的是:把用户输入、历史对话、检索到的资料、当前想调用的业务方法组合起来,让模型理解上下文,再把它输出的文本转成 Java 对象,必要时还要触发你系统里的某个方法。

这些事如果全自己做,我要写很多模板代码:维护消息列表、处理工具调用的循环、解析模型输出的 JSON、处理重试…… LangChain4j 把这些流程抽象成了几个核心组件,用 Java 的方式组织起来,我可以专注于业务本身。

1.3 什么样的人最需要它

我自己判断,下面三类团队最适合作技术选型时考虑 LangChain4j:

  • 已有成熟 Java 后端,想快速给现有业务加上问答、总结、抽取类 AI 能力,不想引入第二套技术栈。
  • 团队里 Python 经验薄弱,但 Java 功底扎实,希望 LLM 应用的代码能被现有体系完成 Code Review 和测试。
  • 需要把模型能力和业务逻辑紧密耦合,比如让模型调用内部服务、查询数据库、调用领域方法,而不是简单地“问一句答一句”。

反过来,如果项目只是做一个纯实验性 Demo,或者团队本身 Python 很强,那用什么都行,不必迁就 Java。选型这件事,核心还是看现有资产。

2. 环境准备与第一个能对话的 main 方法

2.1 依赖版本选择的一个大坑

LangChain4j 的版本演进速度很快,0.31 到 0.35 之间就有多处破坏性变更,类被重命名、模块被拆分都是常事。我见过不少人在 Maven 里直接写最新版本,第二天一编译突然报一堆找不到类,最后只能整个工程回滚。

我的建议是:优先选择稳定的 release 版本,不要追最新快照。初始引入时参考官方文档当前主推的版本号,并在 pom 里显式锁定。

<properties> <langchain4j.version>0.35.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>

这里说句实话,langchain4j-open-ai这个名字虽然带了某家服务商的标签,但它对接的是业内通用的聊天补全协议,国内很多模型服务商都提供兼容接口,所以这一套依赖关系适配性很广。如果你用的是别家的模型服务商,找到对应模块即可,思考方式完全一致。

另外要注意,某些模块是单独发布的,比如langchain4j-easy-rag、langchain4j-embeddings,需要按需引入,别指望核心包把所有能力都带进来。

2.2 API Key 与基础地址的配置细节

很多新手上来就把 API Key 写死在代码里,这绝对是要改掉的坏习惯。不要因为本地测试图省事而硬编码密钥,否则代码一旦被提交到仓库,泄露只是时间问题。

我习惯用环境变量注入:

LLM_API_KEY=你的key LLM_BASE_URL=https://你的服务商地址 LLM_MODEL_NAME=你购买的模型标识

代码里这样读取:

String apiKey = System.getenv("LLM_API_KEY"); String baseUrl = System.getenv("LLM_BASE_URL"); String modelName = System.getenv("LLM_MODEL_NAME");

还有一点容易被忽略:不同服务商的兼容层并不保证所有参数都生效。比如有些平台不认temperature以外的采样参数,传了也不报错,但实际没起作用。先保持默认值跑通链路,再逐步调参数,这样排障范围会小很多。

2.3 第一个能对话的 main 方法

引入依赖、配好环境变量后,可以直接写一个最简入口验证连通性:

import dev.langchain4j.model.chat.ChatLanguageModel; public class FirstDemo { public static void main(String[] args) { String apiKey = System.getenv("LLM_API_KEY"); String baseUrl = System.getenv("LLM_BASE_URL"); String modelName = System.getenv("LLM_MODEL_NAME"); // 实际类型取决于你选择的模型插件,Builder 的写法大方向一致 ChatLanguageModel model = ChatLanguageModel.builder() .apiKey(apiKey) .baseUrl(baseUrl) .modelName(modelName) .temperature(0.7) .build(); String answer = model.generate("用一句话介绍你自己,并说明你的知识截止时间。"); System.out.println(answer); } }

这段代码跑通,说明依赖、密钥、网络链路都没问题。第一次调用通常会比后续慢一些,这是正常的,因为存在连接建立和鉴权的过程。

2.4 超时与重试:第一个容易被忽略的配置

对话类接口的延迟波动非常大,高峰期可能从几百毫秒飙到几十秒。LangChain4j 默认超时往往不够用,业务场景下建议主动配置:

ChatLanguageModel model = ChatLanguageModel.builder() .apiKey(apiKey) .baseUrl(baseUrl) .modelName(modelName) .timeout(Duration.ofSeconds(60)) .maxRetries(2) .build();

我的建议是超时不要低于 30 秒,重试次数控制在 1 到 2 次。重试逻辑只适用于“连接类错误”和“限流类错误”,对于模型正常返回但因内容太长被截断的情况,重试没有意义。

3. 把模型输出的“乱话”变成能入库的数据:结构化输出实战

3.1 为什么不能直接让模型返回 JSON 然后硬解析

我刚开始做 LLM 应用时,犯过一个典型错误:让模型“返回一个 JSON”,然后在 Java 里用 Jackson 硬解析。上线前测试一切正常,上线后才发现,模型偶尔会在 JSON 前面加一句“好的,这是你要的结果:”,后面再跟 JSON,甚至中途插入注释。硬解析当场失败,用户收到一片错误提示。

这不是模型笨,而是自然语言输出天然不稳定。指望模型输出严格遵守格式,不如在框架层面做约束。

LangChain4j 的解法是通过消息类型和输出格式配置,把“结构化返回”做成模型侧的协议约束,再由框架把返回内容映射成 Java 对象。实践下来,成功率比“直接在 Prompt 里说请返回 JSON”高不少,而且代码更干净。

3.2 Java 里结构化输出的正确打开方式

核心思路是定义好 Java POJO,然后让框架根据 POJO 的结构去约束模型输出,而不是自己拼 JSON Schema。

假设业务场景是从用户反馈中抽取信息,定义一个数据类:

import dev.langchain4j.model.output.structured.StructuredOutput; public class FeedbackItem { private String productName; private Integer rating; private String problemType; private String comment; // getter / setter 必须齐全,否则 JSON 映射会失败 }

调用侧把输出格式告诉模型:

String userText = "京东买的机械键盘,用了三天回车键就失灵了,售后倒是挺好,但质量真的不咋地。"; FeedbackItem item = model.generate( userText, StructuredOutput.forType(FeedbackItem.class) ); System.out.println(item.getProductName()); System.out.println(item.getRating()); System.out.println(item.getProblemType());

这里有一个容易忽略的细节:FeedbackItem必须提供完整的空构造器和字段对应的 getter/setter。Java 的反射机制对字段访问方式很敏感,字段是 private 且没有公开读方法,结果是静默返回 null,而且不会报错。

3.3 抽取结果不达标时的兜底策略

即使框架做了约束,模型仍可能在极端情况下返回不符合预期的内容。我的项目里一般叠加三层兜底:

  • 基础校验:对关键字段做空值判断和长度判断,凡是缺少必填字段的,直接标记为解析失败,不能放行。
  • 单次重试:把上一轮的结果作为“错误示例”反馈给模型,让它重新生成。重试一次的性价比最高,三次以上边际收益很低。
  • 业务降级:如果重试仍失败,进入人工处理队列或返回模糊结果,而不是给用户一个崩溃异常。

结构化输出是 LLM 应用从“玩具”走向“生产资料”的关键一步,但它不是银弹。模型依然可能“编造”字段,尤其是 rating 这类数字字段,所以重要数据必须做范围校验。

4. 让 Java 方法变成模型能调用的“工具”:AiServices 与 Tool 实战

4.1 AiServices 是什么

AiServices 是 LangChain4j 里一个很核心的抽象。它允许你定义一个普通的 Java 接口,框架自动帮你生成代理实现,把模型的对话能力与你的业务方法绑定。

打个比方,普通的方式是你和模型一问一答;而有了 AiServices,你的系统里就像多了一个“会调用内部系统的实习生”。用户说“帮我查一下订单 S10023 的物流状态”,模型读到这句话,自己决定调用你的logisticsQuery()方法,拿到结果后再组织语言回答用户。

它的价值在于:工具调用、消息组装、结果整理这些脏活全部由框架完成,你只需要写业务方法。

4.2 定义工具类的三个硬性要求

想让 Java 方法被模型识别并调用,工具类必须满足三个条件,少一个模型就“看不见”你的方法:

  • 方法必须是public。
  • 方法必须用工具描述注解说明用途,描述越清晰,模型越容易在恰当场景下调用它。
  • 每个参数都必须有明确的说明注解,模型需要知道每个参数代表什么。

一个最简示例:

import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.ToolParameter; public class OrderTools { @Tool("根据订单号查询物流状态") public String queryLogistics( @ToolParameter("订单号,格式为 S 加数字") String orderId) { // 这里可以是 HTTP 调用、数据库查询或本地缓存 return "订单 " + orderId + " 已发货,当前到达中转站。"; } }

工具类本身不一定要注册成 Spring Bean,但如果你想让它被自动扫描,推荐配合组件扫描注解一起使用,否则手动装配时很容易漏掉。

4.3 一个完整的智能助手雏形

定义一个服务接口:

import dev.langchain4j.service.AiServices; public interface Assistant { String chat(String userMessage); }

在代码中组装:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .build(); String answer = assistant.chat("帮我查一下订单 S10023 到哪里了?"); System.out.println(answer);

运行后你会发现,模型先解析出意图,选择调用queryLogistics,拿到结果再生成回答。整个过程不需要你自己写“判断该调哪个方法”的逻辑。

4.4 工具调用的死循环问题

工具调用最常见的坑是死循环:模型不断调用同一个工具,或者两个工具互相调用,直到资源耗尽。

原因是模型在特定场景下会陷入“反复确认”的状态,比如一次查询返回结果不明确,它就试第二次、第三次。LangChain4j 提供了最大迭代次数控制:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .maxIterations(3) .build();

实际经验是 3 到 5 次比较合适。小于 3 次可能导致正常的多步骤任务做不完,大于 5 次则在异常情况下拖慢接口响应。同时建议在工具方法的返回值里包含“查询完成”这类明确状态词,帮助模型判断任务是否已经结束。

5. 给模型装上专属知识库:RAG 流程的最小实现

5.1 RAG 的本质:开卷考试带书进考场

RAG(检索增强生成)这个词听起来很高深,本质却很简单:模型本身的知识有截止时间,回答不了你私有文档里的内容。RAG 的做法是,先把你的文档切块、向量化并存储,用户提问时,把与问题最相关的几段内容检索出来,拼进 Prompt,再让模型阅读这些资料后回答。

它相当于一场开卷考试:模型不再凭空背答案,而是翻着指定参考书作答。这样既能回答私有知识,又能在一定程度上减少“胡编乱造”。

5.2 最小视觉下的组件选型

LangChain4j 的 RAG 流程涉及几个组件,逐个说明:

  • 文档加载器:把 txt、md、pdf 等文件读成文本。
  • 文档分割器:把长文档切成有语义边界的小块,官方内置了按字符数、按段落切分的实现。
  • 嵌入模型:把文本转成向量。它决定了“语义相似”判断的质量。
  • 向量存储:保存向量并支持相似度检索。测试阶段可以用内存实现,生产环境再换成独立的向量数据库。

选型的优先级是:先跑通内存版流程,再考虑生产部署。不要在第一步就引入分布式向量数据库,学习成本会淹没你对核心流程的理解。

5.3 最少代码的“文档问答”实现

先把一份文档灌入向量存储:

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.TextFileDocumentLoader; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import dev.langchain4j.model.embedding.EmbeddingModel; Path path = Path.of("company_manual.txt"); Document doc = TextFileDocumentLoader.load(path); EmbeddingModel embeddingModel = ...; // 根据你的模型插件初始化 EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); // 切分、向量化、入库 for (TextSegment segment : DocumentSplitter.paragraphs(300).split(doc)) { var vector = embeddingModel.embed(segment).content(); store.add(vector, segment); }

然后创建带检索能力的 AiServices:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(ContentRetriever.from(embeddingModel, store)) .build(); String answer = assistant.chat("公司请假流程是什么?"); System.out.println(answer);

注意,这里DocumentSplitter.paragraphs(300)指的是切块粒度,300 个字符左右,在检索精度和上下文完整度之间比较平衡。切太细,语义碎片化;切太粗,检索命中不精准。

5.4 查不到相关文档时,代码永远比资料诚实

RAG 效果差,绝大多数问题出在召回环节,而不是模型本身。症状是模型回答得冠冕堂皇,但内容其实是泛泛而谈,根本没引用你的文档。

排查思路比较朴素:把最终发给模型的 Prompt 打印出来,看看检索出来的片段到底是哪几段。如果片段相关度低,优先调整切分策略和召回数量;如果片段本身是对的但模型没用好,再考虑调整 Prompt 指令。

RAG 是一个工程链路,真正排障时不能只看最终输出,要一层层看数据流转。

6. 我把新手期最容易踩的五个坑都踩了一遍

6.1 Java 泛型擦除让结构化输出静默失败

泛型擦除是 Java 老话题,在 LangChain4j 里却特别阴险。如果你用List<FeedbackItem>作为结构化输出类型,因为泛型擦除,框架拿不到FeedbackItem的类型信息,最终返回的可能是一堆 Map,或者直接解析失败。

解决方法是避免在顶层类使用泛型字段。非要返回列表时,定义一个包装类:

public class FeedbackListResponse { private List<FeedbackItem> items; // getter / setter }

这样反射可以拿到完整类型。这个坑的麻烦之处在于它不一定报错,而是静默返回错误结构,只能靠断点排查。

6.2 HttpClient 复用问题导致连接耗尽

早期我图省事,每次调用模型都重新创建 HTTP 客户端,低并发时没感觉,稍微上点压力就报连接池耗尽。

LangChain4j 的模型对象本身就持有 HTTP 客户端,正确做法是把它设计成单例重用,不要每次请求都新建。Spring 场景下,把模型声明为 Bean 就不容易踩这个坑。连接池参数也要关注,尤其在网关型应用中,默认连接数可能远远不够。

6.3 把计费 Token 和上下文窗口混为一谈

这是成本失控最常见的原因。计费 Token 是模型实际处理的 token 总量,包括输入和输出;上下文窗口是模型最多能处理的 token 数量。两者不同:你可以在窗口内塞很多东西,但成本按实际传入量计算。

很多人做 RAG 时,为了“多给一点资料”,把窗口塞得满满当当,结果发现成本飙升,响应速度还变慢。建议对检索片段做数量限制,比如默认检索 3 到 5 段,而不是一股脑全塞进去。日志中可以记录每次请求的 token 用量,定期检查成本分布,数据会告诉你哪些 Prompt 塞得太多。

6.4 日志与异常信息里的敏感内容泄露

LLM 应用的日志比普通应用更敏感,因为用户输入的内容会被原样发送给模型服务商,日志里如果打印请求体,等于把所有原始输入记录下来。

更危险的是把 API Key 打出来。某种默认配置下,异常堆栈可能包含完整请求头,里面就有鉴权信息。我给项目定的规矩是:

  • 日志屏蔽所有鉴权字段,统一输出脱敏标记。
  • 只打印 token 用量、耗时、模型名称等元信息,不打印输入原文。
  • 测试环境可以开完整日志,生产环境强制关闭请求体打印。

这类问题不出事则已,出事就是合规事故。

6.5 小版本升级后的破坏性变更

LangChain4j 的快速迭代既是优点也是风险。我经历过一次升级:模块名变了,类名变了,连 Builder 的字段名都变了,几十个文件的编译错误排了一整天。

给所有团队的升级建议是:

  • 升级前先读发布说明和迁移指南。
  • 把项目分为“依赖升级”和“代码适配”两步,不要混在一起改。
  • 保留一个只做冒烟测试的回归用例集,覆盖对话、结构化输出、工具调用、RAG 四条主链路。
  • 生产环境不要追求最新版,选择已经稳定运行两周以上的版本。

框架的新功能确实吸引人,但在生产项目里,稳定压倒一切。

最后说一点个人的体会

把 LangChain4j 这些核心能力跑通之后,我发现它的学习路径其实很清晰:先跑通一次普通对话,理解模型对象怎么配置;再做一次结构化输出,体会约束和类型转换的价值;接着引入工具调用,让模型真正触达业务代码;最后按需叠加 RAG。四步走完,绝大多数日常 AI 功能都能覆盖了。

我给新手唯一的建议是:不要一上来就搭完整架构,先用最小的 main 方法跑起来,再逐步加组件。每个环节都亲手打印输出、观察数据流转,比读十遍文档都有用。Java 生态做大模型应用开发虽然比 Python 冷清一些,但 LangChain4j 提供的这些抽象恰恰贴合 Java 工程化的习惯。工具链成熟度在快速追赶,现在入场正是时候。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进