1. 从零认识 LangChain4j:Java 开发者的大模型接入起点
很多 Java 后端同学最近都在琢磨同一件事:怎么把大模型能力接进现有的 Spring Boot 项目里。Python 那边生态确实热闹,但咱们手里的系统大多是 Java 写的,总不能为了调个模型就把整个技术栈换掉。LangChain4j 就是在这个背景下进入视野的——它是 LangChain 理念在 Java 世界的落地实现,专门解决 Java 应用与大语言模型之间的对接问题。
说白了,LangChain4j 干的事情就是帮你把"调用大模型"这件事标准化。没有它的时候,你得自己封装 HTTP 请求、自己处理流式返回、自己管理对话历史、自己拼 Prompt 模板,每个模型厂商的接口格式还不一样,换一个模型就得改一遍代码。有了它之后,这些脏活累活都被抽象成了统一的 API,你只需要面向接口编程,底层换模型对业务代码几乎透明。
这个教程适合谁看?如果你是有 Java 基础、想快速把大模型能力集成到项目里的后端开发,或者你之前用过 Spring AI 但觉得生态还不够成熟想换个方案试试,再或者你是技术负责人正在做技术选型评估,那这篇内容都能给你一个可落地的参考。我下面会从环境搭建一路讲到实战踩坑,尽量把每个"为什么这么选"都讲清楚,而不是只丢一堆代码让你抄。
需要提前说明的是,LangChain4j 本身迭代很快,不同版本之间 API 有变动。我写这篇内容时参考的是相对稳定的版本区间,你在实操时如果发现某个方法签名对不上,先去官方仓库的 Release Notes 里确认一下版本差异,这是使用快速演进类库的基本习惯。
2. 动手之前:环境准备与依赖选型的那些门道
2.1 JDK 版本与构建工具的选择逻辑
LangChain4j 对 JDK 的最低要求是 17,这一点没有太多商量余地。为什么是 17 而不是 8 或 11?因为它的很多实现用到了 record、sealed interface、模式匹配这些较新的语言特性,这些在 17 之前要么没有要么是预览状态。你如果手上还有跑在 JDK 8 上的老项目,想接入 LangChain4j 的话,要么单独起一个服务做模型网关,要么就得推动升级——后者在真实项目里往往比技术本身更难。
构建工具方面,Maven 和 Gradle 都支持,我个人更推荐 Maven,原因是 LangChain4j 的模块拆分比较细,Maven 的依赖树排查起来更直观,遇到版本冲突时mvn dependency:tree一眼就能看出问题。Gradle 当然也能用,只是团队协作时如果成员水平参差,Maven 的约定优于配置反而省心。
依赖引入有个关键点:LangChain4j 是模块化设计的,你不要一上来就把所有模块都引进来。核心模块langchain4j-core提供了基础抽象,但具体对接哪个模型,需要引入对应的集成模块。比如对接 OpenAI 风格的接口用langchain4j-open-ai,对接国内某些兼容 OpenAI 协议的模型服务也用这个。这种设计的好处是按需引入,不会把一堆用不上的东西打进包里。
2.2 模型服务接入方式的对比与取舍
接入大模型有几种常见路径,我列个表帮你快速判断:
| 接入方式 | 适用场景 | 优点 | 注意事项 |
|---|---|---|---|
| 官方云服务 API | 快速验证、中小流量 | 开箱即用,无需运维 | 按量计费,注意配额和延迟 |
| 兼容 OpenAI 协议的第三方服务 | 成本敏感、需要特定模型 | 切换成本低,协议统一 | 确认协议兼容程度 |
| 本地部署模型 | 数据敏感、离线场景 | 数据不出内网 | 硬件成本高,需要运维能力 |
| 自建模型网关 | 多模型统一管理 | 灵活可控,便于治理 | 开发维护成本高 |
对于新手入门,我建议先用云服务 API 把链路跑通,别一上来就折腾本地部署。本地部署涉及显存分配、量化精度、推理框架选型一大堆事,很容易在环境问题上卡住,还没摸到 LangChain4j 的门就放弃了。等链路跑通、对 API 有感觉了,再根据实际需求决定要不要往本地部署迁移。
配置 API 密钥时有个安全习惯必须养成:绝对不要把密钥硬编码在代码里,也不要在测试类里写死。正确做法是通过环境变量或者配置中心注入,本地开发用.env文件配合读取工具,生产环境走配置管理。我见过太多项目把密钥提交到代码仓库的案例,一旦仓库权限管理有疏漏就是事故。
2.3 第一个可运行的最小示例
环境准备好之后,先跑一个最小闭环。核心步骤就三步:构建模型实例、构建对话链、发起调用。
// 构建模型实例,baseUrl 和 apiKey 从配置读取 ChatLanguageModel model = OpenAiChatModel.builder() .baseUrl(System.getenv("MODEL_BASE_URL")) .apiKey(System.getenv("MODEL_API_KEY")) .modelName("gpt-3.5-turbo") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); // 直接调用 String answer = model.generate("用一句话解释什么是依赖注入"); System.out.println(answer);这段代码里几个参数值得说道。temperature控制输出的随机性,0 到 2 之间,值越低输出越确定,做代码生成、数据抽取这类任务时建议调到 0.2 以下,做创意文案可以调到 0.8 以上。timeout一定要设,大模型响应时间波动很大,不设超时的话遇到服务端卡顿你的线程就挂在那里了,生产环境这是大忌。
跑通这个示例之后,你会对 LangChain4j 的基本调用模式有个直观感受。接下来才是真正有意思的部分——怎么把这种调用能力组织成可维护、可扩展的工程结构。
3. 核心抽象拆解:ChatModel、Prompt 与 Chain 的协作方式
3.1 ChatLanguageModel 与 StreamingChatLanguageModel 的区别
LangChain4j 里最基础的两个接口是ChatLanguageModel和StreamingChatLanguageModel。前者是同步阻塞式调用,你发一个请求,线程等着,拿到完整结果再返回。后者是流式调用,模型生成一个字就推一个字,适合做打字机效果的对话界面。
什么时候用哪个?如果你的场景是后台批处理,比如批量给商品生成描述文案,用同步的就行,逻辑简单。如果是面向用户的实时对话,强烈建议用流式,因为大模型生成一段几百字的回复可能要好几秒,同步调用用户会盯着空白屏幕等,体验很差;流式的话用户能看到文字一个个蹦出来,感知延迟低很多。
流式调用的代码结构不太一样,它用的是回调或者响应式流的方式:
StreamingChatLanguageModel streamingModel = OpenAiStreamingChatModel.builder() .baseUrl(System.getenv("MODEL_BASE_URL")) .apiKey(System.getenv("MODEL_API_KEY")) .modelName("gpt-3.5-turbo") .build(); streamingModel.generate("写一段关于春天的散文", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { // 每生成一个片段就推给前端 System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println("\n--- 生成完毕 ---"); } @Override public void onError(Throwable error) { // 错误处理不能省 error.printStackTrace(); } });这里有个容易忽略的点:onError回调必须认真处理。流式调用过程中网络抖动、服务端限流都可能触发错误,如果你不处理,用户看到的就是文字生成到一半突然没了,也不知道发生了什么。生产环境里我通常会在onError里记录日志、给前端推一个错误事件、并且考虑是否要重试。
3.2 Prompt 模板:把变量和指令分离
直接拼字符串写 Prompt 是新手最容易犯的错。比如你要做一个翻译功能,可能会写成"把下面这句话翻译成英文:" + userInput。这样写短期没问题,但当你需要调整指令、增加 few-shot 示例、或者做多语言支持时,代码里到处散落的字符串拼接就会变成维护噩梦。
LangChain4j 提供了 Prompt 模板机制,核心思路是把固定指令和动态变量分离:
PromptTemplate template = PromptTemplate.from( "你是一名专业的{{role}},请将以下内容翻译成{{targetLanguage}}:\n{{content}}" ); Map<String, Object> variables = new HashMap<>(); variables.put("role", "技术文档翻译"); variables.put("targetLanguage", "英文"); variables.put("content", "这个接口用于查询用户订单状态"); Prompt prompt = template.apply(variables); String result = model.generate(prompt.text());模板的好处在于,指令的调整集中在一个地方,改一处全局生效。而且模板可以外部化配置,比如放到数据库或者配置文件中,运营人员不用改代码就能调整 Prompt,这在需要频繁调优 Prompt 的场景下非常实用。
3.3 Chain 的组装思路与适用边界
Chain 是 LangChain 体系里的核心概念,本质是把多个处理步骤串起来,前一步的输出作为后一步的输入。LangChain4j 里虽然没有 Python 版那么丰富的 Chain 类型,但基础的顺序链、条件链是支持的。
举个实际场景:用户输入一段产品反馈,你需要先做情感分析,如果是负面情绪就生成安抚话术,如果是正面就生成感谢话术。这个逻辑用 Chain 表达就很清晰:
// 伪代码示意链式组装思路 Chain<String, String> sentimentChain = Chain.of(model) .map(input -> "判断以下文本的情感倾向,只回答positive或negative:" + input); Chain<String, String> responseChain = Chain.of(model) .map(sentiment -> { if ("negative".equals(sentiment.trim())) { return "用户情绪负面,生成一段诚恳的安抚和解决方案说明"; } else { return "用户情绪正面,生成一段感谢和邀请继续反馈的话术"; } });不过我要泼一盆冷水:不是所有场景都适合用 Chain。如果你的逻辑就是"调一次模型拿结果",那直接调model.generate()最清晰,硬套 Chain 反而增加理解成本。Chain 的价值在于多步骤、有依赖关系的流程编排,步骤少于两步的基本没必要用。我见过一些项目为了"看起来架构先进"把简单调用包装成好几层 Chain,结果调试的时候链路追踪都费劲,这是典型的过度设计。
4. 对话记忆与上下文管理:让模型记住聊过什么
4.1 为什么大模型本身没有记忆
这是新手最容易困惑的地方:我上一轮告诉模型我叫什么,下一轮问"我叫什么"它就不记得了。原因在于大模型的 API 调用是无状态的,每次请求都是独立的,模型不会自动保存你之前的对话。所谓"记忆",其实是每次请求时把历史对话一起发给模型,让它基于完整上下文来回答。
理解这一点非常关键,因为它直接决定了你的架构设计。对话历史存在哪里、存多长、怎么裁剪,这些都是你要做的决策,而不是模型帮你搞定的。
4.2 ChatMemory 的几种实现与选择
LangChain4j 提供了ChatMemory抽象,常见实现有基于内存的MessageWindowChatMemory和基于持久化的实现。内存版适合开发测试和单机小流量场景,它用一个滑动窗口保存最近 N 条消息,超出窗口的自动丢弃。
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(20); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); // 第一轮 assistant.chat("我叫张三,在做电商系统"); // 第二轮,模型能记住上面的信息 String reply = assistant.chat("我叫什么?做什么的?");withMaxMessages(20)这个参数需要根据实际情况调。窗口太小,模型记不住稍早的信息;窗口太大,每次请求携带的 token 数量暴涨,成本和延迟都上去了。我的经验值是:客服类对话 10 到 20 条够用,长文档分析类可能需要 50 条以上,但这时候更推荐用摘要压缩而不是单纯扩大窗口。
4.3 持久化记忆的工程实现要点
生产环境的对话记忆肯定不能只放内存,服务重启就丢了。持久化方案通常是存数据库,每次对话前加载历史,对话后追加保存。这里有几个工程细节值得注意。
第一是存储结构设计。不要只存一个拼接好的大字符串,要按消息维度存,每条记录包含角色(user/assistant/system)、内容、时间戳、会话 ID。这样你才能做灵活的查询和裁剪。
第二是并发问题。同一个用户可能在多个设备上同时对话,如果两个请求同时读写同一个会话的历史,可能出现覆盖。简单做法是给会话加锁,或者用乐观锁版本号控制。高并发场景下更推荐把同一会话的请求路由到同一节点处理。
第三是历史裁剪策略。除了按条数裁剪,还可以按 token 数裁剪,因为不同消息长度差异很大,按条数裁可能裁完还是超长。LangChain4j 提供了 token 计数相关的工具,可以估算消息的 token 消耗,据此做更精确的裁剪。
// 按 token 数控制记忆窗口的思路 ChatMemory memory = TokenWindowChatMemory.withMaxTokens(2000, new OpenAiTokenizer());4.4 多轮对话中的常见陷阱
有个坑我踩过:把系统提示词(System Message)也放进了滑动窗口。系统提示词通常很长,而且每轮都需要,如果它被窗口挤出去了,模型的行为就会突然变得不符合预期。正确做法是把系统提示词单独管理,每次请求都固定带上,不参与窗口裁剪。
另一个坑是历史消息里的敏感信息。用户可能在对话里输入了手机号、身份证号,这些如果原样存进数据库,就涉及数据安全问题。稳妥的做法是在存储前做脱敏处理,或者在系统提示词里明确要求模型不要复述敏感信息,同时后端做一层过滤。
5. AiServices 声明式编程:把接口变成 AI 能力
5.1 声明式接口的设计哲学
前面我们都是手动构建模型、手动拼 Prompt、手动管理记忆,代码写起来还是有点繁琐。LangChain4j 提供了一个更优雅的方式:AiServices。你只需要定义一个 Java 接口,用注解描述每个方法要做什么,框架会自动帮你生成实现。
这种声明式风格的好处是业务代码极其干净,你面向的就是普通的 Java 接口,调用方完全感知不到背后是大模型。这对于把 AI 能力嵌入现有业务系统特别友好,因为业务代码不需要引入任何 LangChain4j 的类型。
interface CustomerServiceAssistant { @SystemMessage("你是一名电商客服,语气友好专业,回答简洁") String answer(@UserMessage String question); }5.2 注解体系详解与参数绑定
@SystemMessage定义系统角色,@UserMessage标记用户输入,这两个是最基础的。除此之外还有@MemoryId用于区分不同会话,@V用于模板变量绑定。
interface OrderAssistant { @SystemMessage("你是订单查询助手,根据订单号回答用户问题") String queryOrder(@MemoryId String userId, @UserMessage String question); }@MemoryId的作用是让框架知道这条消息属于哪个会话,从而加载对应的历史记忆。如果你不做多用户隔离,所有对话混在一起,那模型就会串台。这个注解配合ChatMemoryProvider使用,可以为每个用户创建独立的记忆实例。
参数绑定方面,@V注解可以把方法参数注入到 Prompt 模板里:
interface Translator { @SystemMessage("你是翻译助手") @UserMessage("把 {{text}} 翻译成 {{language}}") String translate(@V("text") String text, @V("language") String language); }5.3 结构化输出:让模型返回对象而不是字符串
大模型默认返回的是自然语言文本,但业务代码往往需要结构化数据。比如你要从用户的一段话里抽取订单号、商品名、数量,返回一个 Java 对象比返回一段文字好用得多。
LangChain4j 支持让模型输出 JSON 并自动映射到 Java 对象:
interface OrderExtractor { @UserMessage("从以下文本中提取订单信息:{{text}}") OrderInfo extract(@V("text") String text); } class OrderInfo { String orderId; String productName; int quantity; }这里的关键是模型要能稳定输出符合格式的 JSON。实测下来,指令里明确给出 JSON schema 示例、把 temperature 调低、必要时用 few-shot 示例,都能显著提升结构化输出的成功率。但即便如此,也要做好解析失败的兜底——模型偶尔会加一些解释性文字导致 JSON 解析失败,这时候要么重试,要么用更宽松的解析策略。
5.4 工具调用:让模型能执行你的代码
工具调用(Function Calling)是让模型从"只会说"变成"能做事"的关键能力。你定义一些工具方法,模型在需要的时候会主动调用它们。
class WeatherTools { @Tool("查询指定城市的天气") String getWeather(@P("城市名称") String city) { // 实际调用天气 API return "晴,25度"; } } interface WeatherAssistant { String chat(String question); } WeatherAssistant assistant = AiServices.builder(WeatherAssistant.class) .chatLanguageModel(model) .tools(new WeatherTools()) .build(); // 模型会自动判断是否需要调用 getWeather assistant.chat("北京今天天气怎么样?");工具调用的价值在于打通了模型和真实系统的边界。模型可以查数据库、调接口、执行计算,而不只是基于训练数据回答。但要注意,工具方法的描述(@Tool里的文字)非常关键,模型是根据描述来判断什么时候调用哪个工具的。描述写得含糊,模型就可能该调不调、不该调乱调。
6. 实战踩坑记录:那些文档里不会写的问题
6.1 超时与重试:别让一次抖动拖垮整个请求
大模型服务的响应时间波动很大,同样的请求可能这次 2 秒返回,下次 20 秒还没动静。如果不设超时,你的线程池很快就会被占满。我的做法是设置一个合理的超时(一般 30 到 60 秒),超时后触发重试,但重试次数要限制,通常 2 次就够了,再多就是浪费资源。
重试还有个细节:不是所有错误都值得重试。网络超时、服务端 5xx 可以重试,但如果是 4xx 比如参数错误、鉴权失败,重试多少次都没用,反而增加无效请求。所以重试策略要区分错误类型。
// 配置超时和重试的思路 OpenAiChatModel model = OpenAiChatModel.builder() .baseUrl(...) .apiKey(...) .timeout(Duration.ofSeconds(45)) .maxRetries(2) .build();6.2 Token 超限:长文本处理的正确姿势
每个模型都有上下文长度限制,超出限制的请求会直接报错。处理长文档时这个问题特别突出。常见的应对策略有几种:分段处理再汇总、用摘要压缩历史、或者选用上下文更长的模型。
分段处理听起来简单,但实际做的时候要考虑分段边界。按固定字数切可能把一句话切断,影响语义。更好的做法是按段落或按语义单元切分。汇总阶段也要注意,如果分段结果太多,汇总本身又可能超限,这时候需要做多级汇总。
6.3 流式输出的前端对接问题
流式输出在后端是回调,但前端通常用 SSE(Server-Sent Events)或 WebSocket 接收。这里有个常见问题:代理服务器或网关可能会缓冲响应,导致前端收不到实时推送,而是等全部生成完才一次性收到。排查这类问题时,先确认网关的缓冲配置,再看后端是否正确 flush。
另外,流式输出和对话记忆的配合也要注意。流式过程中如果用户刷新页面或者断开连接,这次对话的内容要不要存进记忆?我的做法是只有onComplete回调触发后才保存,中途断开的对话不保存,避免记忆里出现半截内容。
6.4 成本控制:别让账单吓到你
大模型调用是按 token 计费的,输入和输出都算钱。新手容易忽略的是,对话历史每次都全量发送,轮次多了之后输入 token 会快速增长。一个 20 轮的对话,第 20 轮可能要发送前面 19 轮的全部内容,输入 token 是累加的。
控制成本的手段有几个:合理设置记忆窗口、对历史做摘要压缩、把不必要的信息从 Prompt 里去掉、对简单任务用小模型。我一般会在项目里加一个 token 消耗的监控,按用户或按接口维度统计,发现异常增长能及时定位。
7. 从 Demo 到生产:还需要补齐哪些能力
7.1 可观测性建设
Demo 阶段你只关心能不能跑通,生产阶段你必须知道每次调用花了多少 token、耗时多久、成功率多少。这些指标要接入现有的监控体系。LangChain4j 提供了监听器接口,可以在请求前后插入埋点逻辑。
ChatModelListener listener = new ChatModelListener() { @Override public void onRequest(ChatModelRequestContext context) { // 记录请求开始时间、模型名称 } @Override public void onResponse(ChatModelResponseContext context) { // 记录耗时、token 消耗 } @Override public void onError(ChatModelErrorContext context) { // 记录错误类型、错误信息 } };7.2 降级与熔断
大模型服务不是 100% 可用的,可能限流、可能故障。生产系统必须有降级方案。简单场景可以返回预设的兜底话术,复杂场景可能需要切换到备用模型。熔断机制也要有,当错误率超过阈值时自动切断请求,避免雪崩。
7.3 Prompt 版本管理
Prompt 是 AI 应用的核心资产之一,但很多团队把它散落在代码里,改了就改了,没有版本记录。我建议把 Prompt 当作配置来管理,每次修改都有记录,方便回溯和 A/B 测试。LangChain4j 的模板机制支持从外部加载,配合配置中心就能实现动态调整。
8. 一些个人体会
LangChain4j 这个库我用了有一段时间,最大的感受是它把 Java 接入大模型的门槛确实降下来了。以前要写一堆胶水代码,现在用声明式接口几行就能搞定。但它也不是银弹,很多工程问题——记忆管理、成本控制、错误处理——框架只能帮你一部分,剩下的还得靠自己对业务的理解来设计。
新手最容易犯的错是贪多求全,一上来就想把 RAG、Agent、工具调用全用上,结果每个都半生不熟。我的建议是先跑通最简单的对话,把模型调用、记忆管理、错误处理这几个基础环节吃透,再逐步往上叠加能力。基础打牢了,后面加什么都是水到渠成的事。
还有一点,别迷信 Prompt 能解决所有问题。有些需求用传统代码实现更可靠、更便宜,比如格式校验、数值计算,这些交给代码做,把模型的精力留给真正需要语义理解的任务。模型和代码各司其职,系统才稳。