做了多年 Java 后端,前几年看各种 AI 项目基本都是 Python 的天下,说不羡慕是假的。可真轮到自己动手,才发现最大的问题不是看不懂那些概念,而是想把大模型的能力接进现有 Java 系统里,每一步都要自己硬啃。LangChain4j 这个名字,我最初是在一个后端技术社群里看到的,当时的第一反应是:终于有人把 AI 应用编排这件事做到 Java 生态里了。这篇文章我想用一次完整的新手实战过程,从零开始搭一个带上下文记忆、能调用工具、还能检索私有文档的 LLM 应用,把我实际踩过的坑和觉得值得注意的细节一起交代清楚。如果你是 Java 后端,正打算把手头项目接上大模型能力,这篇文章的思路可以直接照着落地。
1. 为什么 Java 团队应该关注 LangChain4j
1.1 一个头疼的接入问题
技术选型先看痛点。大多数 Java 开发者在接入大模型时,面对的不是模型能力不够,而是工程化成本太高。我第一次接大模型时,需求是做一个带知识库的问答机器人。模型本身不需要训练,但需要处理一长串实际问题:HTTP 请求怎么拼、流式响应怎么在 Java 里按 token 回调、多轮对话的上下文放哪里、怎么让模型去查数据库而不是乱编、文档怎么切片和召回。这些问题单独看都不难,串在一起却很消磨精力。
跟 Python 社区不同,Java 这边在 LangChain4j 出现之前,几乎没有一套被广泛接受的 LLM 应用框架。能找到的参考项目大多是 Python 的,Java 开发者要么自己去封装模型厂商 SDK,要么把 Python 那套推理逻辑用 Java 重写一遍。重写意味着反复拉扯。我后来理解到,这种痛苦的本质是缺少一个“LLM 应用和 Java 业务代码之间的粘合层”。LangChain4j 做的就是这个粘合层,而且补得相当克制,没有把业务逻辑绑进框架里。
1.2 LangChain4j 到底解决了什么
LangChain4j 的定位是 LLM 编排框架,它帮你处理跟模型对话的底层细节,同时把业务逻辑留在 Java 代码里。举个具体场景,你想让模型查一下订单状态。如果裸调模型接口,你得自己拼函数调用协议的 JSON 结构,还要处理模型返回的不可控参数。用 LangChain4j 只需要写一个带 @Tool 注解的 Java 方法,框架负责生成协议、解析模型的选择、在本地执行方法并把结果回传给模型,你看到的调用过程就是一次普通 Java 方法调用。
它还解决了日常开发里的常见问题:多轮记忆、提示词模板、结构化输出、文档嵌入与检索、不同模型供应商之间的切换。模块化做得很好,核心是一个薄薄的 API 层,具体模型通过独立依赖接入。不用 LangChain4j 当然也能把所有这些做出来,但用上之后,省下的是大量重复的“接模型”代码,可以把精力放到真正的业务设计上。
1.3 它和 Python 版 LangChain 的关系与差异
很多人会问,已经有 Python 的 LangChain,Java 直接用不就行了?且不说跨语言调用的部署复杂度和额外网络开销,单从开发效率看,Java 项目的工程规范、配置管理、类型系统,都不是 Python 版能直接套用的。LangChain4j 虽然名字里带 LangChain,但它不是简单的 Java 翻译版。API 设计更贴合 Java 习惯,比如用接口加注解、用 Builder 模式配置模型、支持 Spring Boot 和 Quarkus 生态,整体比 Python 版的魔法感少很多,读源码时也更舒服。
两者也有取舍。Python 版生态更庞大,社区插件多;Java 版胜在类型安全和框架整合,适合企业级系统。对大多数后端团队来说,选 LangChain4j 不是因为它比 Python 版更强,而是因为它能融进现有的 Java 技术栈。我身边不少 Java 团队都是这个思路:先在一个边缘场景试点,跑通后再逐步扩大范围。
2. 动手前的基础准备
2.1 依赖引入的核心姿势
先聊版本。LangChain4j 迭代速度不慢,新版本号要求各个模型模块的版本保持兼容,所以我在项目里习惯用一个 properties 把所有 langchain4j 相关依赖的版本统一管理,避免出现“核心 API 是 1.x 但模型模块还停在 0.x”这种诡异问题。以 Maven 项目为例,最小依赖长这样:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <!-- 以 Maven Central 上的最新稳定版为准 --> <langchain4j.version>填写你查到的稳定版</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>如果项目用了 Spring Boot,官方还提供了一个 langchain4j-spring-boot-starter,支持在 application.yml 里直接配置模型参数。不过新手阶段我建议先不接 Spring Boot,用最朴素的 main 方法把全流程跑通。零框架依赖,出问题也容易隔离。
2.2 模型服务从哪里来
入门阶段最难的不是写代码,而是找到一只能稳定连上的模型。本地模型是新手最舒服的起点,没有 API Key 门槛,也不会因为网络问题卡住。我推荐用 Ollama,它是目前比较省心的本地模型运行工具,装好后执行 ollama pull qwen2.5 就能拉一个中文表现很不错的模型。LangChain4j 对 Ollama 的支持很完善,baseUrl 指向本地端口即可。
如果需要对接各厂商云端模型的兼容接口,LangChain4j 也有对应模块,把 endpoint 和 apiKey 配好就能切换。我的建议是:学习期模型不要追大,3B 到 8B 参数规模足够跑通所有流程。模型不是越强越好,接入成本低、迭代快,才是新手时期最关键的。
2.3 第一个对话程序:Hello LLM
理论讲再多,不如跑一句话。下面这段代码创建了一个 ChatLanguageModel 实例,向本地 Ollama 发一句话,然后把回复打印出来:
import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.ollama.OllamaChatModel; public class HelloLlm { public static void main(String[] args) { ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:3b") .temperature(0.7) .build(); String answer = model.generate("请用一句话介绍你自己。"); System.out.println(answer); } }注意 temperature 是控制随机性的参数,0.7 是比较稳妥的对话数值。如果做信息抽取这类确定性任务,我通常会降到 0.1 以下,尽量让模型少一点自由发挥。运行后如果看到控制台输出了模型生成的文本,说明最难的连接部分已经通了。后面所有复杂功能,都是在这个基础上一步步长出来的。
3. 理解四个核心抽象
3.1 ChatLanguageModel:所有能力的地基
新手的误区是上来就看各种高级能力,忽略最基础的对象。LangChain4j 里打交道最多的接口是 ChatLanguageModel,所有和模型的对话几乎都从它开始。它的核心方法只有一个 generate,输入文本输出文本,但背后实现类五花八门:OllamaChatModel、OpenAiChatModel,以及各家云端模型的封装。切换模型供应商时,你换的只是这个对象的构建方式,业务代码基本不用动。
如果你需要流式输出,比如让回复一个字一个字蹦出来,LangChain4j 提供 StreamingChatLanguageModel。用法和普通模型略有区别,返回一个 TokenStream,注册回调处理增量结果:
StreamingChatLanguageModel streamingModel = OllamaStreamingChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:3b") .build(); streamingModel.generate("用三句话讲一个幽默的程序员故事") .onPartialResponse(System.out::print) .onCompleteResponse(response -> System.out.println("\n生成完毕")) .start();流式输出最大的意义是用户体验。后端接口如果一次性要等几十秒才返回,前端用户早就关页面了;改成 token 流式,感知上的等待时间会短很多。做聊天类应用时这个能力几乎必须用到。
3.2 Message 体系:模型聊天的输入输出格式
当需求从“发一句话”变成“聊一个有来有回的天”,数据结构就得升级。LangChain4j 把对话过程抽象成 ChatMessage 体系:SystemMessage 是系统提示词,用来设定角色和行为边界;UserMessage 是用户输入;AiMessage 是历史回复;ToolMessage 是工具执行结果。模型本质上不理解“记忆”,它只看到按顺序排列的消息列表,上下文就是靠不断追加消息维持的。
这里有个新手容易忽略的点:SystemMessage 不是摆设。我见过有人把角色设定塞进每个用户问题里,既浪费 token,上下文逻辑也乱。正确做法是把稳定不变的指令放系统消息,把每次动态输入放用户消息。项目复杂以后,甚至可以单独维护系统提示词的版本,方便同一套逻辑在不同模型之间做对比。
3.3 ChatMemory:让 AI 记住上下文
上一轮对话怎么下一轮还能记得?答案是把消息历史存在某个地方,每次请求前重新拼进消息列表。LangChain4j 提供了 ChatMemory 抽象,最常用的是 MessageWindowChatMemory,它固定保留最近 N 条消息,超出部分自动丢弃,避免上下文爆掉:
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(20);窗口大小怎么定?跟模型上下文长度有关。我自己的经验是:窗口太大,每次请求的 token 成本线性上升;窗口太小,用户聊到第五轮就“失忆”了。20 条起步,在 16K 上下文窗口的模型上压力不大,等真正不够了再动态调。生产环境建议把记忆存储替换成持久化实现,把历史消息存进数据库或 Redis,否则应用一重启记忆全丢。
提示:窗口大小不是越大越好,要综合模型上下文长度与每次请求的 token 成本做取舍。
3.4 AiServices:应用级的高级封装
直接手动拼接 Message 列表,虽然能跑,但代码会越来越散。LangChain4j 真正好用的是一个叫 AiServices 的门面类,它允许把 AI 能力声明成普通 Java 接口,由框架自动生成实现类。举个例子,定义一个问答接口:
interface Assistant { String chat(String userMessage); }然后通过 AiServices 构建实例,框架会在 chat 方法内部完成记忆检索、消息拼装、调用模型等一连串工作。对业务方来说,只需要注入 Assistant 并调用 chat,跟调用普通方法没有任何区别。AiServices 最大的价值不只是省几行代码,而是把“模型请求”装进了一个可测试、可替换的结构里。后面讲记忆、工具调用、RAG,都推荐在 AiServices 的体系里做,而不是继续手动裸调。
4. 实战:做一个带记忆的问答助手
4.1 定义接口和实现
前面介绍的抽象,如果用一句话浓缩,就是 AiServices + ChatMemory + ChatLanguageModel 三个组件组合拼装。先定义一个带记忆的问答接口,再通过 AiServices 装起来:
interface Assistant { String chat(String userMessage); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String first = assistant.chat("我叫小贾,想了解 LangChain4j"); String second = assistant.chat("你还记得我叫什么吗?");实测时,第一次回答和第二次回答应该是连贯的,second 里模型能准确说出“小贾”。这说明记忆已经生效了。从代码角度看,你没有做任何“记忆管理”,框架在 chat 方法内部把历史消息拼进了上下文。将来无论把这个 Assistant 接入 Spring 的 Service 层,还是暴露成 REST 接口,接口定义都不用改。
4.2 配置 ChatMemory 的策略
MessageWindowChatMemory 看似简单,真正调整时有几个参数值得琢磨。withMaxMessages 决定保留最近多少条消息,数字太大容易超过模型上下文窗口,太小则对话不连贯。除了按条数窗口,LangChain4j 还提供按 token 数量裁剪的 TokenWindowChatMemory,但使用前提是模型实现支持估算 token 数,不是所有模型都会如实上报。
我的建议是项目初期不要过度设计,用 MessageWindow 就行。等实际流量起来,再根据接口延迟和 token 消耗统计收紧窗口。每次请求固定携带一个窗口,成本是可以算清楚的,这也是做预算控制的基础。真到了要持久化的阶段,再考虑把 ChatMemoryStore 换成数据库实现。
4.3 从裸调 API 到 AiServices 的演进对比
如果没有 AiServices,带记忆的对话要怎么写?大致逻辑是:维护一个消息列表,每次请求前追加用户消息,调用模型,再把模型回复追加进去。逻辑不复杂,但一旦要加工具、加记忆持久化、加多轮分支,这个列表就会慢慢变成没人敢碰的万金油类。AiServices 的意义在于接管这些重复流程,你只需要声明接口、组装配置。
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .tools(new DateTimeTool()) .build();换个能力只需要改一行配置,这就是封装带来的长期价值。对团队来说,代码可维护性比“少写两行”重要得多。新手很容易被“我自己拼请求也行”的想法带走,但真实项目里,可维护性才是硬指标。
5. 进阶:让模型会使用工具
5.1 什么是函数调用
大模型有个天生的弱点:它的知识和现实世界是脱节的。它不知道今天的日期,查不了数据库,也算不了复杂数值。函数调用(Function Calling)是应对这个问题的主流方案:模型在生成回复前,先判断“要回答这个问题,我需要调用哪个函数”,然后把决定返回给应用;应用执行这个函数,把结果塞回上下文;模型再基于结果生成最终回复。整个过程,模型只是决策者,真正执行的是你的 Java 代码。
这个能力很强大,但它不是模型凭空学会的。模型需要知道每个函数是干什么的、参数是什么含义。LangChain4j 所做的,是把 Java 方法的签名和注解描述转换成模型需要的协议描述。你写的代码越清晰,模型“想到要调用它”的概率越高,这跟两个人协作是一个道理。
5.2 用 @Tool 注解暴露 Java 方法
具体实现上,写一个普通 Java 类,在方法上标 @Tool,然后把它传给 AiServices:
public class DateTimeTool { @Tool("返回当前日期和时间的字符串,格式为 yyyy-MM-dd HH:mm:ss") public String currentDateTime() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } @Tool("根据日期判断是星期几,参数 date 格式为 yyyy-MM-dd") public String weekDay(String date) { return LocalDate.parse(date).getDayOfWeek().toString(); } } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new DateTimeTool()) .build(); String answer = assistant.chat("现在是几点?");运行之后会发现,模型输出的不再是模棱两可的猜测,而是带上准确时刻的完整回答。这里有两个细节值得注意:第一,@Tool 注解里的描述要写清楚,模型不会自作主张猜测用途;第二,方法参数尽量用基本类型或简单对象,少用复杂嵌套结构,否则模型传参容易出错。
5.3 工具调用的坑
我刚开始做工具调用时,遇到最多的问题是模型明明有工具却不用。排查下来,多数情况是工具描述太模糊。比如只写“获取时间”,模型不确定这是什么时间、返回格式是什么,干脆猜一个答案。改成“返回当前日期和时间的字符串,格式为 yyyy-MM-dd HH:mm:ss”之后,调用率明显提升。写工具描述就像写接口文档,信息越明确,调用方越不容易出错。
另一个坑是循环调用。某些配置下,模型会反复要求同一个工具,形成死循环。LangChain4j 对单次请求内工具调用次数有默认限制,但自己的业务方法里还是应该加防御,比如同一个工具连续被调用多次时要做缓存或终止。这种东西文档里不会写,属于上线前要主动补的细节。
6. 再进阶:检索增强生成(RAG)
6.1 没有 RAG 时模型有多无力
下一个高频需求,是让模型回答私有知识。比如公司内部有一份产品操作手册,用户问“退款流程是什么”,模型不知道,因为它没读过这份文档。有人会想,把文档全文塞进提示词不就行了?这个方案在文档很小时确实可行,但一旦文档有几十万字,一方面会超过上下文上限,另一方面每次请求都携带全文,成本也扛不住。
RAG(检索增强生成)的思路是:平时把文本切块、转成向量存进向量库;提问时,把用户的问题也转成向量,在库里找出最相关的几个文本块;最后只把这几块文本和问题一起交给模型生成答案。你可以把向量理解成“语义坐标”,相似内容坐标接近,所以能检索到关联片段。这套机制既不要求训练模型,也不需要手工维护问答对,是接私域知识最实用的路径。
6.2 落地流程:切块、嵌入、检索、生成
LangChain4j 里做 RAG 有对应的模块,流程比大多数人想象中简单。先准备一个 EmbeddingModel,它专门把文本转成向量,Ollama 下的 nomic-embed-text 就是一个常用的嵌入模型。然后创建 EmbeddingStore 存向量和原始片段,先用内存版验证流程:
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("nomic-embed-text") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); List<TextSegment> segments = List.of( TextSegment.from("LangChain4j 是 Java 生态的 LLM 编排框架。"), TextSegment.from("RAG 全称是 Retrieval-Augmented Generation。") ); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); store.addAll(embeddings, segments);用户提问时,把问题向量化后在 store 里查询相似片段,再把命中的 TextSegment 拼进提示词。推荐先用内存向量库跑通,再根据数据量决定要不要引入正式的向量数据库。存储时记得把原始文本一起保存,不然只存向量没有意义,还得额外回表查。
6.3 RAG 的工程细节
用 InMemoryEmbeddingStore 验证流程没问题,但生产环境要按数据量和并发量选存储方案。LangChain4j 的 EmbeddingStore 接口有多个实现,既有轻松持久化到本地文件的方式,也有对接专用向量库的接入模块。选型时重点看两点:一是数据量,几千条和几百万条的检索性能差很多;二是运营成本,维护一套独立向量库是否值得。很多团队一开始只为了验证效果,就搭了一套重型中间件,这是容易过度设计的地方。
切分策略也值得认真对待。切得太碎,单块信息不完整,模型拼不出完整答案;切得太大,命中块占用的 token 太多。我习惯先按段落切,再按固定大小补充,具体数值视文档类型调整。这一步没有标准答案,唯一有效的验证方式是把一批真实问题跑一遍,人工看回答质量。不要只看单条效果,要按真实查询分布抽样评估。
7. 常见问题与排查清单
7.1 典型报错与解法
我在本地和线上环境都遇到过不少容易劝退新手的报错,整理成一张速查表,大概能覆盖一半以上的问题。最常见的类型集中在版本匹配、模型服务状态、上下文窗口、工具描述和编码环境这几类。
| 现象 | 最可能原因 | 快速解法 |
|---|---|---|
| 连接超时 | 本地模型服务没启动 | 启动 Ollama 后确认服务可访问 |
| 抽象方法签名错误 | 核心包与模型模块版本不一致 | 统一 langchain4j 版本号再重新构建 |
| 上下文长度超限 | 记忆窗口开太大 | 调小 MessageWindowChatMemory 的值 |
| 工具一直不生效 | 工具描述不够明确 | 在 @Tool 里写清楚用途与参数格式 |
| 中文输出乱码 | 控制台或环境编码问题 | 检查 UTF-8 设置,优先调整终端编码 |
这些现象背后往往是同一类根因:版本不统一或者模型配置和代码预期不一致。排查时先对照版本,再看配置,最后才怀疑框架本身。LangChain4j 的抽象设计相对稳定,大多数问题都出在接入层。
7.2 日志调试经验
LangChain4j 内置了完善的日志输出。出现问题先别猜,把日志级别打开,重点看每次请求最终发给模型的 prompt 长什么样。我遇到很多奇怪问题,比如“模型在回答里自己编了一个工具名”,打开日志一看,原来是系统消息里的说明和工具描述存在冲突。日志里能看到完整拼装的消息序列,比用眼睛脑补可靠得多。
<logger name="dev.langchain4j" level="DEBUG"/>调试流式输出时有个技巧:先不用流式,改调非流式的 generate,用同步结果看完整响应。流式模式下异常往往是断断续续的,很难推理;同步模式下问题会清晰很多。排查完再接回流式,能省下不少时间。
7.3 落地建议
最后给想把这些东西放到真实项目里的朋友几点建议。先从最小闭环开始,我在团队做内部工具时,第一步只做了不带记忆、不带工具的模型代理服务,通过接口调用模型完成简单问答,跑通之后再逐步加记忆、工具和 RAG。每加一个能力都做成可回滚的小版本,不要在本地一把梭半天然后直接推到生产。
接口层尽早抽象。业务代码不要直接依赖具体的 ChatLanguageModel 实现,而是包一层自己的接口,方便将来切换模型供应商。最后要做好 token 成本观测,线上系统每个请求消耗多少 token、延迟多高都要有统计。很多团队等项目上线了才发现成本失控,再回头优化架构就晚了。
我自己的体会是,LangChain4j 真正降低的,不只是“调模型”的门槛,更是把 AI 能力变成了 Java 工程师熟悉的那套工程化体系。从零开始跑通全部基础流程,我大概用了一个周末,大部分时间花在理解抽象概念和版本匹配上,真正写出来的业务代码其实很少。也许这恰恰说明,这个领域的核心壁垒本来就不在代码量上,而在于能不能把模型、工具、记忆、数据这些要素拧成一股绳。如果你也在带一个 Java 团队,我很建议先挑一个小场景试水,用最朴素的方式把链路跑通,再一点一点往上加东西。别怕踩坑,这些坑踩完,你对 LLM 应用的理解会比任何教程都扎实。