最近两年,AI 应用开发的话题几乎被 Python 系框架霸屏。如果你一直深耕 Java/JVM 技术栈,面对团队里“接一个大模型能力”的需求时,多少会有点尴尬:改用 Python 重写一套?成本太高;直接用 HTTP 调接口拼 Prompt?又太原始,重复劳动多。LangChain4j 就是冲着这个缺口来的,它把 Python 生态里那套“大模型应用开发”的理念搬到了 Java 世界。这篇文章我会以新手视角,从环境搭建、对话接入、接口化封装、工具调用、RAG 知识库到常见问题排查,完整走一遍实战流程,让没接触过 LangChain4j 的 Java 开发者也能照着落地一个可用项目。
这篇文章适合三类人:一是想在 Spring Boot 存量系统里快速引入 AI 能力的后端工程师;二是公司内需要做客服机器人、文档问答、内容摘要等场景但不想跨语言折腾的团队;三是对 LangChain4j 只听说过名字、看了官方文档又觉得零散的初学者。我尽量少讲虚的,多给能直接用的思路、代码和参数经验。
1. 为什么 Java 开发者应该关注 LangChain4j
1.1 它解决的问题:Python 生态与 JVM 之间的落差
先说个大背景。Python 生态里搞大模型应用已经非常成熟,很多人习惯用一段 Python 脚本就完成对话、知识库、智能体这套流程。但现实是,大量企业的核心业务系统跑在 Java 技术栈上,订单、用户、支付、权限都在 JVM 里。你想让大模型帮忙查一个订单,最简单也最安全的做法,是让 Java 代码去调大模型,而不是把业务数据拉出来再交给另一套 Python 服务。
这时候最原始的做法是什么?用 HTTP 客户端直接请求模型接口,自己拼接消息历史,自己处理返回格式,自己做重试和超时,再自己写一套“工具调用”的逻辑。说实话,这些活不难,但是非常琐碎,而且每个模型服务商的接口风格还不完全一样,换一家就要改一层代码。LangChain4j 的核心价值,就是把“对话管理、模型切换、消息历史、工具调用、知识检索”这些通用能力封装成可复用的 Java API,让你专注在业务逻辑上。
我在实际使用中的体会是,它更像是一个“大模型应用开发工具包”,而不是一个完整产品。它不限制你必须用哪种模型、必须部署在哪里,而是提供了一套统一抽象,模型供应商只是其中可替换的组件。
1.2 核心设计理念:把大模型调用做成像 Spring 一样的东西
LangChain4j 给我的第一感觉是:设计者应该对 Spring 生态非常熟悉。它大量使用 Builder 模式、注解驱动、接口代理,写起来不像是在拼 OpenAI 的 REST 接口,更像是在配置一个 Spring Bean。
它有几个核心抽象:
ChatLanguageModel:统一封装对话模型,不管背后是哪个服务商,你都是调用chat或者generate这类方法。ChatMemory:管理多轮对话的上下文,避免无状态调用导致的“失忆”。AiServices:它最强的功能之一,允许你定义一个 Java 接口,框架自动生成实现。类比 MyBatis 的 Mapper 接口,你只声明方法,框架帮你完成底层调用。EmbeddingStore与ContentRetriever:用于 RAG 知识库场景,让模型在回答前先检索相关资料。@Tool注解:把普通 Java 方法暴露给模型,让模型在需要时主动调用你的业务代码。
这些抽象合在一起,就形成了下面这条工作链路:用户输入 → 由 AiServices 或 ChatLanguageModel 统一接收 → 结合消息历史 / 工具 / 检索结果 → 发送给模型服务商 → 返回结果给业务层。你不需要关心每家模型服务商的协议差异,只需要在项目里引入一个对应的适配模块。
1.3 模块化设计:不用的功能就不引入
LangChain4j 的依赖是模块化的。核心包langchain4j只包含基础抽象和工具类,真正的模型接入能力分散在各类适配模块里。这个设计很符合 Java 项目的洁癖,不会因为你只做对话,被迫引入一整套 RAG 相关依赖。
我用一个表格来展示它的模块划分逻辑:
| 分层 | 作用 | 包或依赖特征 |
|---|---|---|
| 核心 API | 对话接口、消息类型、AI Services、记忆管理 | dev.langchain4j核心包 |
| 模型适配层 | 对接不同模型服务商 | 对应服务商适配模块 |
| 数据处理层 | 文档解析、切分、嵌入、向量存储 | dev.langchain4j.data.*相关模块 |
| 集成层 | Spring Boot 快速接入 | 独立集成模块 |
新手入门时不需要把全部模块都搞清楚,先跑通核心对话链路,再按需加工具和 RAG 功能就够了。我见过不少新手一上来就想搭一个“全家桶”,最后被各种配置和数据格式问题淹没,反而把最基础的对话流程忽略掉了。
2. 从零搭建项目:环境准备与依赖引入
2.1 基础环境清单
在动手之前,建议先把下面这些准备好:
- JDK 17 及以上:LangChain4j 对 JDK 版本有要求,我用 JDK 17 和 JDK 21 都跑过,建议至少 17。
- Maven 3.8+ 或 Gradle 7.5+:下面以 Maven 为例。
- 一个可用的模型服务商 API Key:你手上有什么就用什么,关键是拿到 API Key、接口地址和模型名称。
- IDE:这个看个人习惯,IDEA 或者 Eclipse 都行。
- 可选:Spring Boot 3.x 工程。如果你是在已有 Spring Boot 系统上接入,那就直接复用现有工程。
有一个容易忽略的点是网络环境。模型服务通常是外部的 HTTP 接口,你需要确认所在服务器或本地开发机可以正常访问对应的接口域名。如果公司有网络白名单,记得提前把域名加进去,否则后面排查半天会发现是网络拦截问题。
2.2 最小依赖配置
新建一个普通 Maven 工程,在pom.xml里加入核心依赖:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <langchain4j.version>0.36.2</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>这只是核心包,只包含基础抽象。要真正发起模型调用,还需要引入一个“模型服务商适配模块”。那家服务商的适配模块怎么找?去 LangChain4j 官方仓库的模块列表里找,命名规则通常是langchain4j-服务商标识。我在这里特意不写具体厂商名,是因为这个框架支持的服务商很多,而且更新很快,你直接用你选的那家去搜相应模块会更准确。
引入适配模块后,pom.xml大概是这样的:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-你的服务商适配模块</artifactId> <version>${langchain4j.version}</version> </dependency>版本号记得和核心包保持一致,避免出现依赖冲突。
2.3 配置文件与密钥管理建议
API Key 是敏感信息,我强烈不建议硬编码在代码里。最简单的做法是把它放到环境变量中,运行时读取。这样即使代码提交到仓库,也不会泄露密钥。
# 示例:application.properties 或环境变量 LLM_API_KEY=你的模型服务商密钥 LLM_BASE_URL=模型服务商接口地址 LLM_MODEL_NAME=模型名称如果你用的是 Spring Boot,可以在application.properties中做一层中转:
langchain4j.model.api-key=${LLM_API_KEY} langchain4j.model.base-url=${LLM_BASE_URL} langchain4j.model.model-name=${LLM_MODEL_NAME}需要提醒的是,不同服务商可能对环境变量名称有自己约定,但思路都是一样的:密钥不入库,配置集中管理。我见过身边的同事把 Key 直接写死在测试代码里提交到 GitLab,第二天就收到服务商的告警邮件说密钥异常调用,这个坑没必要用真金白银去踩。
3. 第一个对话式实战:让程序开口说话
3.1 构建模型对象
依赖配好之后,最核心的一步就是创建一个ChatLanguageModel对象。不同服务商的适配类命名不同,我这里用一个占位符YourProviderChatModel来演示,你实际使用时替换成你所选服务商对应的类即可。
import dev.langchain4j.model.chat.ChatLanguageModel; public class ChatDemo { public static void main(String[] args) { ChatLanguageModel model = YourProviderChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .temperature(0.7) .logRequests(true) .logResponses(true) .build(); String answer = model.chat("用一句话介绍你自己"); System.out.println(answer); } }这里有几个参数值得解释一下:
temperature:控制输出的随机性,取值通常是 0 到 2。数值越低,输出越稳定和保守;数值越高越有创造性。做客服、查订单这类对准确性要求高的场景,建议调到 0.2~0.5;做文案创意,可以调到 0.8 以上。logRequests和logResponses:调试阶段建议开成true,你能在控制台看到实际发给模型的请求体和模型回复的完整内容,排查问题非常有用。上生产环境再关掉,避免日志刷屏。
我第一次跑这段代码时,其实没有一次成功。最典型的问题就是把baseUrl配错了,多了一个斜杠,导致接口路径拼接错误。这类问题尤其隐蔽,因为报错信息往往只说“连接失败”,你要是不开日志,很难定位到是地址问题。
3.2 同步调用与流式输出
上面chat方法返回的是完整字符串,适合后端逻辑处理。但如果你要做的是机器人对话或者实时展示逐字输出的场景,等待完整回复会非常吃力。这时应该使用流式输出。
LangChain4j 对流式的支持大体上可以这样理解:调用模型后,不等待完整结果,而是通过回调不断接收部分内容。示意代码如下:
import dev.langchain4j.model.StreamingChatLanguageModel; StreamingChatLanguageModel streamingModel = YourProviderStreamingChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .build(); streamingModel.chat("写一个关于猫的短故事") .onPartialResponse(System.out::print) .start();你会在控制台看到文字一个一个地被打印出来,体验很接近ChatGPT 网页端的逐字显示效果。这里不同的版本回调 API 会有一点差异,但核心思路不变:注册一个部分响应回调,拿到内容就立刻推给前端或者写入缓冲区。
流式输出虽然体验好,但要注意一个问题:如果同时有大量用户请求,每个请求都保持长连接,对后端线程和网络连接都是压力。生产环境需要做好连接数限制和并发控制,不能无脑全开。
3.3 消息历史与多轮对话状态维护
直接调用model.chat(userMessage)是“无状态”的,模型不记得你之前说过什么。比如你第一句说“我叫小王”,第二句问“我叫什么?”,模型大概率会回答不知道。解决办法就是把历史消息一起传过去。
用ChatMemory管理消息历史是一个标准做法。MessageWindowChatMemory可以控制最多保留多少条历史消息:
import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.message.AiMessage; MessageWindowChatMemory memory = MessageWindowChatMemory.withMaxMessages(10); memory.add(UserMessage.from("你好,我叫小王")); memory.add(AiMessage.from("你好,小王,很高兴认识你!")); memory.add(UserMessage.from("我叫什么名字?")); String answer = model.generate(memory.messages()).aiMessage().text(); System.out.println(answer);这里有几个细节要注意:
- 消息窗口不是越长越好。每多一轮历史,请求消耗的 token 就更多,成本和时间都会上升。
MessageWindowChatMemory.withMaxMessages(10)是“按条数”限制,不是按 token 数量限制。如果单条消息本身很长,10 条也可能撑爆上下文。后续版本中也有基于 token 数量的记忆窗口,需要的话可以换用。- 多轮对话场景下,我建议直接在 AI Services 层配置记忆,比手动拼消息要优雅得多,下一节就会讲到。
4. 接口化编程:AI Services 让业务代码大幅简化
4.1 核心思路:定义一个接口,就是一个 AI 助手
LangChain4j 最吸引我的一点,是AiServices这套接口化编程方式。你不需要手写model.chat(...)、拼接 Prompt、处理消息历史,只需要定义一个接口,然后声明方法签名,框架在运行时会帮你生成一个实现。
import dev.langchain4j.service.AiServices; public interface Assistant { String chat(String userMessage); } ChatLanguageModel model = YourProviderChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); System.out.println(assistant.chat("给我讲个笑话"));是不是有点眼熟?这就像 MyBatis 的Mapper接口,开发者只写接口,框架生成代理实现。对应的web层、service层代码会变得非常干净,你甚至不需要把模型相关的调用逻辑散落在业务代码里。
我个人建议项目里所有和大模型的交互都通过 AI Services 接口走,不要直接散着调model.chat。原因很简单:集中管理 Prompt、模拟数据、测试和替换都会方便很多。比如测试阶段,你可以给接口配一个假的实现,返回固定文案,完全不影响业务链路。
4.2 结构化输出与 Prompt 模板
接口方法不一定只能返回String,你可以让它返回一个 DTO。比如我想做一个天气查询助手,希望模型输出的内容是结构化数据,而不是一大段自然语言:
public record WeatherInfo(String city, String temperature, String advice) {} public interface WeatherAssistant { @SystemMessage("你是一个专业的天气预报助手,请用简洁中文回答。") @UserMessage("查询这个城市的天气:{{it}}") WeatherInfo queryWeather(String city); }创建代理时,方法声明的返回类型会被框架用于引导模型输出 JSON,并把 JSON 转换成WeatherInfo对象。这比你让模型返回一段 JSON 字符串,再手动解析要可靠得多。
使用注解还有一个好处:Prompt 模板可以复用。@SystemMessage用来放系统人设和约束,@UserMessage用来放用户输入,还可以用{{it}}把方法参数嵌入到模板里。如果有多参数,还可以用类似{{city}}的占位符,并在参数上标注变量名。需要注意的是,不同版本的注解处理细节略有变化,但思路是一致的。
4.3 在 Spring Boot 中集成
在 Spring Boot 项目里,把 model 和 assistant 声明为@Bean,就可以在业务代码里直接注入引用了。
@Configuration public class LlmConfig { @Bean public ChatLanguageModel chatLanguageModel() { return YourProviderChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .logRequests(true) .build(); } @Bean public Assistant assistant(ChatLanguageModel model) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); } }Controller 里直接注入Assistant来用:
@RestController public class ChatController { private final Assistant assistant; public ChatController(Assistant assistant) { this.assistant = assistant; } @PostMapping("/chat") public String chat(@RequestBody String message) { return assistant.chat(message); } }到这里,你已经可以对外提供一个简单的 AI 聊天接口了。但这个版本还有个问题:每次请求都是独立的,没有对话记忆。要加记忆很简单,在构建Assistant时给它挂一个ChatMemory:
@Bean public Assistant assistant(ChatLanguageModel model) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); }注意,MessageWindowChatMemory是进程内记忆。如果服务重启或者部署了多个实例,记忆就会丢失或相互不共享。分布式环境中你需要换成 Redis 之类的持久化记忆方案,这属于进阶话题,新手阶段先不用折腾。
5. 工具调用实战:让大模型能查库、能算数
5.1 什么是 Function Calling,为什么它很关键
模型本质上是一个文本生成器,它不知道你数据库里有哪条订单记录,也不会真的去执行计算。Function Calling 做的事是:给模型提供一组“工具清单”,模型在需要的时候主动发起调用请求,然后你的 Java 代码执行实际逻辑,再把结果以文本形式交回给模型,由模型组织最终回复。
你可以把它想象成给一个实习生一张“求助清单”,清单上写着“遇到订单问题找订单系统,遇到天气问题查天气接口”。实习生本身不会写代码,但他知道该找谁。这个机制非常有用,因为它把模型的“语言能力”和你系统的“真实数据能力”结合在了一起,而且访问权限完全由你的工具代码控制。
5.2 注册一个可执行工具
在 LangChain4j 中,给普通方法加上@Tool注解,就能把它暴露给模型。先看一个最简单的工具:
import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.P; public class OrderQueryTool { @Tool("根据订单号查询订单状态") public String queryOrderStatus(@P("订单号") String orderId) { if ("80001".equals(orderId)) { return "订单80001当前状态:已发货,预计3天内送达"; } return "订单80001未查询到记录"; } }这里有三个关键点:
@Tool注解中的描述文字非常重要。模型是靠这段描述来判断“什么时候应该调用这个方法”的,描述越清楚,模型就越不会乱调。- 方法参数的
@P注解也是描述参数含义,模型需要从用户的话中提取参数值,描述不清就会传递错值。 - 工具方法的返回值最终会被拼进提示词里再次发给模型,所以尽量返回简洁、明确、结构稳定的文本。如果返回的是一个复杂对象,建议先转成可读性良好的字符串再返回。
然后把工具挂到 AI Services 上:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new OrderQueryTool()) .build();调用端代码不需要任何变化,用户还是会发一句:我的订单80001什么时候到?。模型看到这句话,根据工具描述判断需要查订单状态,于是调用queryOrderStatus,拿到结果后组织成自然语言回答用户。
5.3 完整案例:订单查询机器人
结合前面说的,我给你一个可参考的完整示例流程。
先定义 Assistant 接口:
public interface OrderAssistant { String chat(String userMessage); }再定义一个工具类,真正访问订单数据。这里为了演示我把数据写死,实际项目中你可以注入 Mapper 或 Repository:
public class OrderQueryTool { @Tool("根据订单号查询订单状态和预计送达时间") public String queryOrderStatus(@P("订单号") String orderId) { // 这里调用你的订单服务 return "订单%s当前状态:已发货,预计3天内送达".formatted(orderId); } @Tool("根据订单号查询订单金额") public String queryOrderAmount(@P("订单号") String orderId) { // 这里查询订单金额 return "订单%s的金额为259.00元".formatted(orderId); } }然后构建服务:
OrderAssistant assistant = AiServices.builder(OrderAssistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new OrderQueryTool()) .build();当用户发送“帮我查一下订单80001的金额”,模型的内部处理流程大致是:
- 读取工具清单,发现有
queryOrderAmount方法。 - 从用户话中抽出订单号
80001。 - 调用你的 Java 方法,拿到金额文本。
- 生成最终回复:“订单80001的金额为259.00元。”
这个能力在实际项目里非常实用。我团队后来做客服机器人时,就是用这套模式把“查订单、查物流、查退换货进度”等几个查询能力暴露给模型,后端完全不需要写关键词匹配逻辑,模型的意图理解能力天然承担了这部分工作。
关于工具调用,有几个提醒:
- 工具数量不要一开始就放太多,模型会“选择困难”。我测试过,工具描述模糊、数量超过十几个时,误调用率明显上升。优先给每个工具写清楚“何时用、何时不用”。
- 工具方法参数类型要简单。尽量用字符串、数字、布尔这类基础类型,复杂对象容易让模型理解出错。
- 对工具的执行结果一定要做脱敏处理,不要把内部字段原样返回给模型。比如内部有用户身份证号,工具返回时只给脱敏后的字符串。
6. RAG 实战:把专属知识交底给模型
6.1 破解幻觉:让模型回答前先“翻资料”
大模型训练数据是有截止时间的,而且对内部业务知识一无所知。直接问它“我们公司考勤制度是什么”,它大概率会编造一份漏洞百出的制度出来。RAG(Retrieval-Augmented Generation,检索增强生成)的思路很简单:让模型在回答问题之前,先从你的知识库里检索相关内容,把它放在上下文里,再由模型基于这些资料组织答案。
这就是“开卷考试”和“闭卷考试”的区别。你不指望模型把公司制度背下来,你给它一本“参考书”,让它先查再答。这个方案不要求你微调模型,也不需要昂贵的训练成本,效果却立竿见影。做企业知识库问答时,RAG 几乎是第一选择。
6.2 最小 RAG 实现步骤拆解
LangChain4j 做 RAG 大致分这几步:
- 加载文档。可以是文本、Markdown、PDF、Word 等。
- 文档切分。把长文档切成适合检索的小块。
- 嵌入。把每一块文本用 Embedding 模型转成向量。
- 存取向量。把向量和原文存到向量存储中。
- 检索。用户提问时,把问题转成向量,在库中找最相似的片段。
- 回答。把检索到的片段和用户问题拼在一起,发给对话模型。
示意代码如下:
import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; // 1. 加载你的知识文档 List<Document> documents = List.of( Document.from("公司加班申请流程:员工需提前一天提交加班申请...") ); // 2. 构建嵌入模型,这里用你要接入的 Embedding 模型适配类 EmbeddingModel embeddingModel = YourProviderEmbeddingModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .build(); // 3. 创建内存向量存储 EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); // 4. 用 Ingestor 统一完成切分、嵌入、存储 EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(500, 100)) .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(documents);这个示例用的是内存向量存储,重启数据就会丢,适合演示和小规模尝试。真实项目里可以换成支持持久化的向量数据库。值得注意的是,Embedding 模型和对话模型可以是同一家,也可以是不同的,关键是它们产生的向量空间要一致;如果你切换了 Embedding 模型,历史向量最好重新生成。
检索结果要挂到 AI Services 上,让回答助手在对话前自动检索:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .contentRetriever(EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .build()) .build();这样用户问“加班申请怎么提”,系统会先去向量库里找出与问题相似的几段制度文本,再让模型基于这些文本回答。
6.3 切分、向量化与检索的关键参数经验
RAG 的效果,很大程度上取决于“资料能不能被正确找出来”。这块我踩过不少坑,分享几个重要参数:
- 切分大小:我一般用 500 到 800 个字符左右一块。太小,检索出来的片段信息不完整;太大,塞进上下文的 token 成本高,而且模型可能被无关内容带偏。
- 切分重叠:每块之间保留 100 到 150 个字符的重叠,避免一个完整知识点刚好被拦腰切断,导致两边都检索不到。
- 相似度阈值:检索接口通常会返回相似度分数。低于 0.6 的片段基本可以认为不相关,不建议拼进上下文。具体阈值可以根据你的知识库测试调整,先从一个偏保守的 0.7 开始,再上下微调。
- 检索数量:返回给模型的相关片段数量一般控制在 3 到 5 个。太多了模型处理不过来,太少则信息不足。
关于 RAG,我还想多说一句:不是所有内容都适合丢进知识库。涉及个人隐私、公司机密的数据,要么脱敏,要么做好访问控制,否则等于把敏感信息通过模型接口泄露给外部,这个风险在真实业务中必须重视。
7. 常见报错与排查技巧实录
7.1 接入失败与密钥配置问题
新手最容易遇到的问题就集中在接入阶段。我把典型的几个场景整理成一张表,方便你直接对照:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 请求返回 401 或 403 | API Key 无效,或没有权限访问该模型 | 检查密钥是否复制完整、是否过期,确认模型访问权限 |
| 请求返回 404 | 接口地址或模型名称错误 | 打开logRequests日志,对比实际请求的路径和模型名 |
| 连接超时 | 网络不通,或模型服务较慢 | 先确认网络能否访问接口域名,再调整超时时间 |
| 中文乱码 | 控制台或项目文件的编码不是 UTF-8 | 编译和运行参数都加上-Dfile.encoding=UTF-8 |
| 依赖冲突 | 版本不一致,或传递依赖冲突 | 统一 LangChain4j 各模块版本,必要时用dependency:tree排查 |
我之前遇到一个特别隐蔽的问题:某个服务商接口要求请求头里带一个额外的项目标识,文档里写得很模糊,我只配了 Key 就一直 401。最后是抓了官方示例的请求日志,逐字段对比才发现。所以遇到 401,不要只怀疑 Key,还要看请求头、请求体是不是和服务商的要求完全一致。
7.2 响应不完整、超时与异常中断
对话模型生成的内容如果特别长,同步调用可能等很长时间。遇到这种情况,优先做两件事:
第一,调整超时时间。LangChain4j 的模型适配 builder 里通常有timeout参数,可以设置成 60 秒或更长。不同服务商对超时的处理方式不一样,有的连接超时和读取超时是分开的,需要分别配置。
第二,改用流式输出。响应逐字到达时,用户的等待感受会好很多,而且后端不必长时间占住一个线程等完整结果。
还有一个我自己踩过的坑:对流式输出的回调里直接操作共享变量,结果并发一上来就出现数据错乱。流式回调是在不同线程上执行的,不要在回调里无脑修改共享状态,要么用线程安全的容器,要么把内容收集到局部缓冲区后统一提交。
7.3 结构化输出不稳定与工具调用不生效
用 AI Services 返回 DTO 时,偶尔会遇到模型输出格式不匹配导致解析报错。这类问题的排查方向有几个:
- 返回类型不要太复杂。嵌套过深的对象,模型转错 JSON 结构的概率明显变大。
- 系统提示里可以补充一句“不要输出多余说明”,但不要指望这招每次都有效。结构稳定性本质依赖底层模型能力。
- 如果模型服务商支持 JSON 模式或者响应格式约束参数,优先在构建模型时打开。
工具调用不生效是另一个高频问题。模型没有调用你的工具,最常见的原因就是工具描述不够清晰。比如你把工具描述写成了“订单查询”,模型就不太确定是查状态还是查金额。我会把描述改写成“当用户询问订单物流进度时,使用该工具查询最新物流状态”,模型几乎不会误判。另外,工具参数如果填不出来,模型也不会硬调;这时候检查@P描述,确保每个参数都能从用户问题里提取到。比如订单号,你需要告诉模型“必须包含数字或字母的订单编号”。
7.4 成本失控与上下文膨胀
模型按 token 计费,上下文越长成本越高。只要用户多聊几轮,历史消息累积起来,看着不多,实际一算很吓人。
我的建议是:
- 用
MessageWindowChatMemory.withMaxMessages(10)这类限制,控制历史数量。 - 对超长文档,尽量走 RAG 检索片段,而不是全部塞进上下文。
- 定期观察日志中每次请求的 token 消耗。一般模型适配器的日志里能看到总 token 数,如果发现某个功能平均消耗异常高,要检查是不是有重复文本被反复加入上下文。
成本问题不是上线以后才考虑,在开发阶段就要有意识。否则功能测试时你开心地聊了几百轮,月底账单下来就笑不出来了。
最后再分享一点个人经验
如果你是从零开始接触 LangChain4j,我建议你严格按这个顺序推进:先跑通单轮对话,再叠加多轮记忆,然后尝试 AI Services 接口化封装,之后才是工具调用和 RAG。前两步一天就能完成,第三四步是真正提升开发效率的地方,RAG 则是把模型能力落到企业场景的关键。
我在实际项目里最大的体会是,LangChain4j 的抽象层能帮你省掉大量重复代码,但“提示词设计”和“数据准备”还是要靠自己对业务的理解。工具描述写得好不好、知识库资料清理得干不干净,直接决定最终效果的上限。框架本身只是把这条路铺平了,至于跑多远,还是看你对业务场景的把握。
这个项目后续还能做的扩展很多:比如把 Assistant 接口接入企业IM机器人,定时任务跑批量文本摘要,或者把 RAG 的向量库换成持久化方案。希望这篇实战笔记能帮你少走一些弯路,至少把第一公里的坑都避掉。