ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java后端集成大模型:LangChain4j实践指南

Java后端集成大模型:LangChain4j实践指南

说实话,这两年做后端的老哥,焦虑感一半来自大模型,另一半来自满屏的 Python 代码。AI 应用火起来之后,想做个内部知识库问答、自动工单分类、智能客服这类功能,翻开教程和示例基本都是 Python。可公司技术栈是 Java,总不能为了几个 AI Demo 把团队语言全换了。所以我第一次看到 LangChain4j 的时候,确实眼前一亮——这就是 Java 生态里对标 LangChain 的 LLM 应用框架,把大模型对话、上下文记忆、工具调用、文档问答这些能力,用 Java 的接口和注解重新封装了一遍。这篇文章是一个纯 Java 后端从零上手 LangChain4j 的完整总结,里面有我跑通的代码、踩过的坑,以及现在一直在用的工程化套路,适合还没入坑、或者刚开头还没理清头绪的同学。

1. 先搞清楚LangChain4j是个什么东西

1.1 它解决了Java后端接入大模型时最烦的事

Java 直接调用大模型接口本来不难,难的是接口之外的一堆重复劳动。你想想,每次对话要维护消息列表,区分 system、user、assistant 三种角色;多轮对话要把历史消息一次次拼进 prompt;想让模型查数据库,得处理“模型说要调函数,再自己写代码去调,最后把结果塞回去”的循环。这些逻辑如果每个项目都自己写一遍,最后一定是一团乱麻。

LangChain4j 的做法是给 Java 开发人员一套统一抽象。核心模型接口叫 ChatLanguageModel,你只需要拿到一个实现类,调用chat()或generate(),剩下的消息组装、角色标识、返回解析都交给框架。更高阶的 AiServices 还能把记忆、工具、检索这些能力组合成一个接口,你定义一个 Java 接口,用框架生成一个代理对象,调用方式跟在本地调 Service 一样。

这个设计思路很像我们在 Java 里用 ORM 框架。你不需要自己写 JDBC 模板的重复逻辑,框架帮你把表结构映射成对象。LangChain4j 就是把“和大模型对话”这件事也做成了这种模板化的东西。

1.2 核心组件有哪些

我把 LangChain4j 里最常用的几个组件按“干什么用的”列了一下:

组件作用我类比成什么
ChatLanguageModel统一的模型调用入口,负责发送消息、拿回复一个远程 Service 的客户端
ChatMemory维护多轮对话历史,自动管理消息窗口客服手里的对话工单
Tool / @Tool把 Java 方法注册成模型可以调用的工具给实习生一张“可求助清单”
EmbeddingModel把文本转成向量,用于语义搜索给每段文字生成一个指纹
EmbeddingStore存储和检索向量按指纹找相似内容的资料库
AiServices把记忆、工具、检索、模型编排成一个业务服务业务门面 Facade
ContentRetriever从向量库检索相关资料,喂给模型做 RAG自动查参考书的图书管理员

这里面最容易忽略的是 AiServices。新手一开始只想调通对话,往往跳过它,直接拼消息。但当你开始做真实业务,需要同时处理记忆、工具和知识库时,AiServices 是你能稳定落地的关键。

1.3 和Python生态的LangChain有什么区别

很多教程会把 LangChain4j 直接说成“Java 版 LangChain”,这话对也不对。设计理念确实很像,但它不是 Python 代码的逐行翻译,而是按照 Java 的强类型和工程习惯重新设计过的。

举个例子,在 Python 里你经常要自己处理动态字典、链路回调,结构比较灵活;但 Java 里你靠接口定义行为,靠注解声明元数据。比如定义一个工具方法,Python 生态可能要写装饰器和复杂的回调,LangChain4j 里就是给方法加一个@Tool注解,方法签名是强类型的,参数说明写在注解里,IDE 能帮你检查类型问题。

再加上 Java 后端天生在意依赖管理和模块化。LangChain4j 被拆成核心包、厂商适配包、Spring 生态集成包、向量库适配包,你可以按需引入,不会因为做了一个简单对话就拖进一整套重量级依赖。

不过也要认清差距。Python 生态的 AI 组件数量多,社区活跃度高,你可以找到各种冷门场景的开源方案。Java 这边相对少,有些个性化需求可能要自己读源码去扩展。但核心对话、记忆、工具、RAG 这些高频场景,它已经覆盖得很好了。

2. 环境准备与第一个对话Demo

2.1 依赖怎么引

我建议先准备 JDK 17 和 Maven 3.8+。JDK 8 不是不能用,但 LangChain4j 的不少特性和虚拟线程、HTTP 客户端都依赖新版本,用 JDK 17 能少折腾很多。

依赖分两块:一个是 langchain4j 核心包,一个是具体模型服务商对应的适配包。核心包里包含了对话、记忆、工具调用等基础 API;适配包解决的是“怎么把你选定的那家模型 API 封装成 ChatLanguageModel”。

下面是一个最小化 pom 片段:

<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <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-xxx</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>

那个xxx就是你在用的模型供应商适配模块。不同厂商的依赖坐标不一样,但格式基本一致:langchain4j-厂商名。建议去官方示例页面对应模块找最新坐标,不要直接抄我这里的版本号,因为框架更新很快,跨一个大版本时 API 可能有调整。

密钥管理上,不要把 key 写到代码里。我习惯用系统环境变量LLM_API_KEY,本地 dev 环境用.env文件,生产环境走密钥中心。这样即使代码截图流传出去,也不会泄露敏感信息。

2.2 最小可运行代码

第一次跑通对话,最重要的不是背 API,而是理解“ChatLanguageModel 是用来隔离不同厂商差异的”。我代码里故意用一个占位类MyChatModelProvider,实际项目里替换成你用的厂商 Builder 就行。

import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.model.chat.ChatLanguageModel; public class ChatDemo { public static void main(String[] args) { // 注意:MyChatModelProvider 只是占位符 // 实际替换成你选的厂商适配类,例如 XxxChatModel ChatLanguageModel model = MyChatModelProvider.builder() .apiKey(System.getenv("LLM_API_KEY")) .modelName("your-model-name") .temperature(0.7) .build(); AiMessage answer = model.chat("请用一句话介绍你自己"); System.out.println(answer.text()); } }

model.chat(String)是简化方法,底层会构造一个用户消息并调用模型。返回的AiMessage里有两个重要字段:text()是模型返回的文本内容,toolCalls()是模型想调用的工具列表。只做对话时看text()就够了,工具调用后面细说。

如果你跑完这个 Demo,发现控制台打印了内容,恭喜你,说明依赖引入、API Key、网络链路都通了。

2.3 参数调优与日志查看

第一次跑通后会忍不住调各种参数。我实际用得最多的是这几个:

  • temperature:控制随机性。做信息抽取、分类这类任务,调到 0 或者 0.1,输出更稳定;做文案创作、头脑风暴,调到 0.7 以上,结果更有发散性。
  • maxTokens:限制最大返回 token 数。不设的话,有些模型会啰嗦半天,成本也会上去。
  • timeout:网络超时。默认值在一些慢模型上不够用,我一般调成 60 秒左右。

还有一个很容易漏掉的调试技巧:开启 HTTP 请求日志。很多模型适配模块都支持底层 HTTP 日志,可以把真正发送给模型的 prompt 打印出来。你会在日志里看到框架给你拼了哪些 system 指令、哪些历史消息、哪些工具定义。很多“模型回答不对”的问题,打开日志后马上就能定位到是 prompt 不对。

3. 让对话拥有记忆:ChatMemory实战

3.1 为什么不能自己拼历史消息

不写框架的话,大家最自然的做法是用一个 List 存历史消息,每次调用前把整个 List 塞进请求。这个方案在只测一两个来回时没问题,但一旦对话轮数多起来,你会遇到三件事:

第一,token 数量膨胀。几十轮对话全部交给模型,单次请求就可能超限,成本和延迟都不可控。第二,上下文容易被无关内容干扰。模型会从很早期的对话里找蛛丝马迹,导致最近的真实意图反而被冲淡。第三,窗口管理逻辑很容易写错。比如什么时候保留 system 指令、什么时候丢弃最早的消息、按消息数截断还是按 token 截断,这些都是自己实现时的细节坑。

ChatMemory 就是来解决这个问题的。它帮你维护一个消息窗口,只保留最近 N 轮,或最多保留 N 个 token,同时处理好 system 消息的优先级。

3.2 AiServices + 记忆的实现

最省心的用法不是直接操作 ChatMemory,而是配合 AiServices。你先定义一个接口:

interface Assistant { String chat(String userMessage); }

然后用 AiServices 生成实现类:

import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer = assistant.chat("你好,我叫小明"); System.out.println(answer);

这个接口的每个方法调用,框架会自动管理上下文。默认策略是 MessageWindow 滑动窗口,最多保留最近 20 条消息。具体数字可以按业务场景调,智能客服希望记住更久,可以调大;做普通问答,调小一点既省钱又减少干扰。

有一点要注意:AiServices 生成的代理对象,内部使用的 ChatMemory 如果是无状态的实现,那多用户同时访问时可能会串话。所以接着看多用户会话隔离。

3.3 多用户会话隔离与持久化

我一开始踩过这个坑:把所有用户共用一个 Assistant 实例,结果用户 A 说的话,用户 B 都看在眼里,上下文完全乱套。解决办法很简单,会话维度单独维护 ChatMemory。

import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ChatSessionService { private final Map<String, ChatMemory> memoryStore = new ConcurrentHashMap<>(); public String chat(String sessionId, String userMessage) { ChatMemory memory = memoryStore.computeIfAbsent( sessionId, id -> MessageWindowChatMemory.withMaxMessages(20) ); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); return assistant.chat(userMessage); } }

这段代码里用sessionId区分不同用户,每个会话拥有独立记忆窗口。如果服务是单机部署,ConcurrentHashMap够用;但如果有多实例部署,就需要把记忆放到 Redis 这类外部存储里。框架提供了持久化扩展点,你可以把记忆读写抽象成接口,在实现里用 Redis 或数据库保存,这样重启后用户的上下文也不会丢。

4. 让模型学会调用工具:Tool调用实战

4.1 工具调用到底是什么

很多刚接触的同学会把工具调用理解成“模型自己上网查数据”,这是不对的。模型本质上不会执行代码,它只是根据你的指令输出“我想调用哪一个方法,参数是什么”。真正执行方法的是你本地服务。

用人话打个比方:你是个领导,模型是实习生。实习生不知道内部系统的下单逻辑,但他知道有个“下单工具”。当用户说“帮我订一杯美式”,实习生不会自己去下单,而是告诉你“我要调用下单工具,参数是美式大杯”。然后真正的下单动作由你来做,做完之后把结果告诉实习生,他再组织成自然语言回复用户。

LangChain4j 里的 Tool 就是这整套流程的封装。你只要把一个 Java 方法标记成工具,框架负责把方法签名转换成模型能理解的 JSON Schema,在模型返回工具调用后,自动执行方法,再把执行结果发回给模型。

4.2 用@Tool注册一个真实方法

我举一个业务里很常见的例子:用户问“现在几点”,模型可能不知道准确时间,但我们本地方法能拿到。

import dev.langchain4j.agent.tool.Tool; import java.time.LocalDateTime; import java.time.ZoneId; public class TimeTools { @Tool("获取中国标准时间当前的日期时间") public String currentTime() { return LocalDateTime.now(ZoneId.of("Asia/Shanghai")).toString(); } }

然后在构建 Assistant 时注入:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new TimeTools()) .build();

这样当用户问“现在北京时间几点”时,模型会先输出工具调用,框架自动执行currentTime(),再把结果返回给模型生成回答。你基本不用写任何工具调用的“粘合代码”。

如果你想实现更复杂的工具,比如查订单状态,只需在方法里调用对应的业务 Service,参数通过方法入参传入。模型会自动解析用户表达里的订单号,映射到方法的参数上。

4.3 工具调用链路的关键细节

工具调用看起来简单,但有几个点特别影响稳定性。

第一,工具的描述要具体。@Tool("获取当前时间")和@Tool("获取中国标准时间当前的日期时间,格式yyyy-MM-dd HH:mm:ss"),后者被模型正确选择的概率高得多。工具描述就是给模型看的说明,越清晰越好。

第二,参数类型尽量简单。字符串、数字、布尔值最稳定。如果你用复杂对象,模型输出 JSON 时很容易少字段或类型不对。遇到复杂参数,我一般拆成多个简单参数,宁可方法签名长一点。

第三,工具方法里的异常一定要处理干净。工具方法一旦抛异常,框架可能会把错误消息作为工具结果返回给模型,模型可能基于异常信息开始“编”一个答案,非常危险。我在方法体里做了 try-catch,返回“查询失败,请联系系统管理员”之类的固定文案。

第四,防止无限循环。模型可能会反复调用同一个失败工具,或者在工具之间来回跳。生产环境里,我给工具调用次数设置了上限,达到上限直接中断。

5. RAG实战:给模型塞一份私有知识库

5.1 RAG的核心链路

RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。它解决的是模型不知道我们内部资料的问题。模型的训练数据再新,也不可能知道你们公司的内部制度、项目文档、产品手册,你也不想把这些资料全部塞进 prompt,那既不现实也不安全。

RAG 的思路很简单:模型回答之前,先根据用户问题从你的私有文档库里检索最相关的几段内容,把这几段内容拼进 prompt,让模型基于这些内容回答。相当于给模型配了一个“带目录的参考资料书架”,他遇到问题先翻书,再回答。

LangChain4j 的 RAG 链路分为四步:加载文档、切分文本、向量化存储、检索并生成。

5.2 文本切分、向量化和存储

加载文档这一步比较直接,你可以从文件、数据库、网页抓取。重点是切分。如果把一整篇几万字的文档直接向量化,检索效果会很差,因为向量只能表达整段文字的整体语义,用户问其中一个小细节时,相似度会被其他内容稀释。

我自己一般用“定长 + 重叠”的切分方式。每段 500 到 800 个字符,段与段之间重叠 100 个字符左右,这样能保住句子上下文的连贯性。下面是一个简单的切分示例:

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.segment.TextSegment; import java.util.ArrayList; import java.util.List; public class TextSplitter { public static List<TextSegment> split(Document document, int chunkSize, int overlap) { String content = document.text(); List<TextSegment> segments = new ArrayList<>(); for (int i = 0; i < content.length(); i += chunkSize - overlap) { int end = Math.min(i + chunkSize, content.length()); segments.add(TextSegment.from(content.substring(i, end))); if (end == content.length()) { break; } } return segments; } }

切分完成后,用 EmbeddingModel 把每一段转成一个向量,再存进 EmbeddingStore。

EmbeddingModel embeddingModel = MyEmbeddingModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .build(); EmbeddingStore<TextSegment> embeddingStore = MyEmbeddingStore.builder() .build(); List<TextSegment> segments = TextSplitter.split(document, 600, 100); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);

这里的MyEmbeddingModel和MyEmbeddingStore依旧是占位类。实际使用中,EmbeddingModel 是模型服务商提供的向量模型接口,EmbeddingStore 可以选内存版,也可以接专门的向量数据库。新手先跑通内存版,再考虑接外部存储。

5.3 检索增强问答的完整实现

向量化存储做好之后,真正写问答就变得非常简单。LangChain4j 提供了一个 ContentRetriever 的抽象,你只要把 Store 和 Model 配置进去。

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build(); String answer = assistant.chat("离职流程是什么?");

框架做了三件事:把问题转成向量,在向量库里检索最相似的文档片段,把片段和问题一起送给模型。你不用担心 prompt 拼错。

minScore是相似度阈值,具体数值取决于 Embedding 模型。我建议初次调试时先设低一点,比如 0.5,把命中的片段打印出来看看相关性,再逐步调高。

5.4 关于RAG效果的一些心里话

RAG 的效果一半在算法,一半在数据准备。很多同学向量库也建了,prompt 也写了,但回答还是不对,查来查去发现是文档本身太乱。我这里有几个调优经验:

  • 切分块太大,模型会把无关内容也吸收进去;切分块太小,每段信息不完整。600 到 800 字符是我常用的起始值。
  • 文档清洗很重要。PDF 转出来的文本经常有乱码、多行表头、页眉页脚,这些噪声不清理,检索质量会直线下降。
  • 调试时一定要打印检索结果。不要只看最终答案,我习惯把所有命中的片段打印出来,确认“模型看到的资料”真的是用户问题对应的内容。
  • 有些用户问题表述模糊,比如“提交流程”,这时直接检索效果不好。可以先用模型把问题改写成更明确的query,再做检索。这是 RAG 进阶优化点。

6. 工程落地时最想提醒你的几件事

6.1 常见问题排查速查表

我把这段时间遇到的典型问题整理成了速查表,希望对你有帮助:

现象可能原因处理办法
模型答非所问,说无关内容系统提示词太弱,或多轮历史被截断优化 system 指令,检查历史消息是否完整
工具方法一直不被调用工具描述不清楚,或参数类型太复杂重写 @Tool 描述,拆解复杂参数
对话历史不生效没有注入 ChatMemory,或每次构建新 Assistant用 AiServices 注入同一个 ChatMemory
多个用户互相串话共用了一个 ChatMemory按 sessionId 隔离
工具调用后死循环工具异常导致模型反复尝试加工具调用上限,工具内部兜底返回
结构化输出经常失败返回类型太复杂,或 prompt 未给格式示例简化返回对象,给一个 JSON 示例
RAG 检索不到内容阈值设太高,或者文档没切分好降低 minScore,打印检索片段定位
返回结果超长/超时模型未设置 maxTokens 或网络慢设置 maxTokens,调大 timeout

6.2 生产环境必须盯住的点

第一个是密钥管理。模型 API Key 绝对不能出现在代码仓库里,也不能被前端直接拿走。我通过环境变量注入,服务器上再用密钥管理服务挂载,这个看起来笨但非常稳妥。

第二个是可观测性。写到生产环境后,你会发现“模型返回了什么”和“为什么这么返回”同样重要。我在调用前后加了日志,记录入参和出参,并给自己加的每一次 LLM 请求打上一个 traceId,这样用户投诉时,我能快速从日志里捞到完整的 prompt 和模型回复链路。

第三个是降级方案。模型服务再稳定也会有抖动,接口超时、限流、临时不可用都可能发生。我在业务侧做了兜底:如果大模型调用失败或者超时,返回一个预设的提示文案,同时记录失败次数,告警通知值班人。这比用户看到一串报错信息要强太多。

6.3 性能与成本优化

最后聊钱和速度。模型调用按 token 计费,每多传一段历史,成本就多一点。优化思路无非四个方向:

  • 精简 prompt。能不说废话就不说废话,把 system 指令控制到最精炼。
  • 压缩历史。MessageWindowChatMemory 限制轮数,也可以按 token 窗口截断。
  • 结果缓存。高频问题直接缓存答案,相同问题不重复调用模型。
  • 流式输出。对话场景用流式接口,能大幅提升用户首字感受到的速度,体验比干等更友好。

做了一段时间之后,我发现真正有价值的不是把单次响应调到多快,而是把整个应用的上下文管理、工具调用、数据检索这些基础设施做稳。模型本身会越来越聪明,但这些工程底座,始终得自己一条一条搭起来。

最后再分享一个我调了很久才明白的细节:做工具调用和结构化输出同时出现的场景时,返回对象别设计得太花哨,尽量用扁平字段,并在系统提示词里给出一个明确的 JSON 示例。很多“模型偶发格式错乱”的问题,都是因为给模型发挥的空间太大了。你把它当成一个比较容易跑偏的新人,指令给细一点,输出的稳定性就会好很多。

RELATED READING

延伸阅读

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