最近在给团队内部工具加智能问答能力,翻遍Java生态,发现LangChain4j是接入大模型最顺手的一个框架,搭配Qwen问答场景,5分钟就能跑通一个最小demo。这篇文章就是那5分钟的完整记录——从开通模型服务、配置Maven依赖,到写出第一行可运行的问答代码,再把过程中遇到的环境变量、超时、依赖冲突这些坑一并讲清楚。如果你是Java开发者,想在项目里接大模型但还没动手,这篇可以直接当操作手册用。就算你之前完全没接触过LangChain4j,只要照着操作,大概率也能一次跑通。
1. 为什么在Java项目里选择 LangChain4j + Qwen
1.1 LangChain4j填补了Java生态的“LLM应用层”空缺
Python圈子里搞LLM应用开发,随手就是LangChain、LlamaIndex,各种模型调用、链式编排、工具调用、RAG组件一抓一大把。但Java圈长期处于一种非常原始的状态:想接大模型,要么直接在业务代码里拼HTTP请求,要么去写某家模型厂商SDK的方法调用。这么搞当然也能跑,但问题很快就来了。
第一个问题是耦合。今天接的是A模型的SDK,明天想换B模型,业务代码就要跟着改一大片。第二个问题是重复造轮子。多轮对话要自己维护历史消息,流式输出要自己处理SSE事件流,工具调用要自己做JSON参数解析和拼接,模型名、温度、最大token这些参数散落在各处。这些事跟业务逻辑一点关系都没有,但不做还不行。
LangChain4j最核心的价值,是它把这层脏活累活抽象掉了。它在模型层定义了ChatLanguageModel这样一个统一接口,你面向接口编程,具体用哪家模型,用哪个builder去构造,都只是启动时的配置。这个设计跟JDBC的思路很像:应用层写SQL,至于背后是MySQL还是PostgreSQL,只要换一个驱动就行。我项目里最开始用的是Qwen,后面如果要切到其他兼容OpenAI协议的服务,Core代码几乎不用动,换个BaseUrl和模型名就完事了。
除了模型抽象,LangChain4j还提供了提示词模板、聊天记忆(ChatMemory)、工具调用(Tool)、向量检索(Embedding)这一整套东西。对于要做知识库问答、企业内部助手这类场景,这些能力不是锦上添花,而是刚需。
1.2 问答场景选Qwen,本质选的是“省心”
模型服务这块,我最初在几个主流选项之间犹豫过,最后选了Qwen,核心原因是三件事:中文效果、成本、访问路径。
先说中文效果。问答类场景对中文理解和生成质量要求很高,Qwen系模型在中文语料上的表现是有明显优势的。团队内部工具里问的都是带业务语境的中文问题,拿qwen-plus实测下来的回答质量,放在大多数业务问答场景里都够用。
再说成本。DashScope平台对新用户有免费额度,个人学习、写demo阶段几乎不花钱。到了生产环境,qwen-turbo价格非常低,qwen-plus也远低于一些国际模型。对于想快速验证“大模型问答能不能解决业务问题”的团队来说,这个试错成本非常友好。
最后是访问路径。Qwen通过DashScope的OpenAI兼容模式对外开放,使用的正是LangChain4j里langchain4j-open-ai模块所实现的那套协议。不需要单独找适配器,不需要写专用客户端,一个OpenAiChatModel就把模型服务拉起来了。这一点很重要:如果团队后续自己在内网用vLLM部署了一个开源模型,只要那个服务也暴露了OpenAI兼容接口,同一套LangChain4j代码可以直接切过去。模型层做了完全隔离,这才是靠谱的架构。
2. 跑通前的准备:开通DashScope与项目搭建
2.1 开通DashScope并创建API Key
第一步是去模型服务平台创建API Key。这里直接说步骤,任何人都能照做:
- 打开DashScope控制台,用账号登录。新账号需要完成实名认证,不做完这一步,后面没法开通模型服务。
- 在控制台首页找到“模型服务”相关入口,进入后开通服务。Qwen系列默认就能用,不需要单独申请白名单。
- 进入API-KEY管理页面,点击创建API Key。创建的时候会生成一串以
sk-开头的密钥。这里有个细节,Key创建完成后列表里只会显示一次完整值,之后只能看到掩码。所以建议创建完立刻复制到本地密码管理器里保存。
拿到Key之后,先别急着写代码。我的建议是立刻把它配成环境变量,而不是直接贴在代码里。命令行执行:
export DASHSCOPE_API_KEY="sk-xxx"如果你用的是IDEA这类IDE,直接运行的话环境变量还需要在Run Configuration里配。可以在Environment variables一栏追加DASHSCOPE_API_KEY=sk-xxx。这个细节很多人会踩坑,明明在终端里export了,一从IDE跑就报401,查半天才发现是IDE拿到的环境变量是空的。
Key到手之后记得两件事:第一,永远不要提交到Git仓库,哪怕只是demo,也建议用环境变量或本地配置文件的忽略机制;第二,别把Key贴到任何公开群聊里。这类密钥泄露以后很麻烦,轻则被薅额度,重则账户欠费。
2.2 用一个Maven项目把依赖拉起来
5分钟跑通,不需要Spring Boot,不需要复杂工程结构,一个纯Maven项目就够。JDK版本建议用17或者更高,写records、var、switch表达式都方便,而且LangChain4j对Java 17的支持非常稳。
pom.xml里加这些依赖:
<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-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.13</version> </dependency> </dependencies>三个依赖分别干什么,说清楚:
langchain4j是核心库,包含ChatLanguageModel接口、ChatRequest、ChatResponse这些核心抽象,以及AiServices、ChatMemory等高级封装。langchain4j-open-ai是OpenAI协议模型的适配实现,OpenAiChatModel就在这里。Qwen走DashScope兼容模式,用的就是这一层。slf4j-simple是一个日志实现,不加它的话,跑起来控制台会刷一行SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder"的警告。加上之后日志信息会正常打印,排查问题会舒服很多。
版本号0.35.0是我在写这篇时验证过的版本。LangChain4j迭代速度很快,建议你在实际使用时去官方仓库看一眼最新稳定版,版本号直接用最新即可。另外,如果后续项目里要集成Spring Boot,langchain4j-spring-boot-starter会提供自动配置,但5分钟跑通这个阶段完全用不上,加了反而多一层配置干扰。
首次执行mvn compile会拉取依赖,网络好的话一两分钟就完成了。如果你用的是Gradle,依赖坐标可以等价替换。
3. 最小可运行代码:用OpenAI兼容协议调通Qwen
3.1 核心调用代码就这么几行
写一个QwenQuickstart类,主方法里构造模型对象,然后调一次问答:
import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; public class QwenQuickstart { public static void main(String[] args) { ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .modelName("qwen-plus") .build(); String answer = model.generate("请用一句话介绍Java编程语言"); System.out.println(answer); } }第一次看到OpenAiChatModel这个名字,很多人会问:我要接的是Qwen,为什么用的是OpenAI的类?其实这里的OpenAi指的是协议,而不是具体厂商。DashScope给Qwen模型开放了一个OpenAI兼容端点,也就是说,它把Qwen模型的服务包装成了标准的OpenAI风格API。所以LangChain4j里凡是实现了OpenAI协议适配的类,都能直接对接。这也意味着,如果哪天你想换成其他也支持这种协议的服务,这个类不需要换。
代码里baseUrl指向https://dashscope.aliyuncs.com/compatible-mode/v1,这个路径是固定的,/compatible-mode/v1一个字符都不能少。modelName填的是模型名,这里用的是qwen-plus,想换qwen-turbo或者qwen-max直接把字符串改掉就行。
model.generate("...")是最简单的一次性问答方法,传入字符串,返回模型的回答字符串。很适合快速验证链路是否通。
跑起来之后,控制台如果打印出一句关于Java的语言介绍,说明你已经成功走完了整个链路:Java代码 -> LangChain4j -> DashScope兼容端点 -> Qwen模型 -> 返回回答。
3.2 常用参数与第一次调优
generate虽然简单,但真实场景里经常要指定系统提示词、控制回复长度、调整超时时间。先用这张表把最常用的几个参数说清楚:
| 参数 | 作用 | 推荐值 |
|---|---|---|
| temperature | 控制回复随机性,范围0~2。值越高,回答越发散 | 问答场景0.2~0.5 |
| maxTokens | 限制回复的最大token数 | 默认值即可,长文输出场景按需调大 |
| timeout | 每次请求的超时时间 | 30秒 |
| maxRetries | 请求失败后的自动重试次数 | 3 |
temperature是第一个值得反复实验的参数。在问答场景,用户要的是准确、稳定的事实性回答,所以建议调低,处在0.2~0.5之间比较合适。如果是做创意文案、头脑风暴,才需要拉高到0.7以上。
除了直接generate,还有一种更常用的写法,就是构建一个ChatRequest传入完整的消息列表:
import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.output.Response; import java.util.ArrayList; import java.util.List; public class QwenWithSystemPrompt { public static void main(String[] args) { ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .modelName("qwen-plus") .temperature(0.3) .build(); List<ChatMessage> messages = new ArrayList<>(); messages.add(SystemMessage.from("你是一位资深的Java技术顾问,回答问题要简洁、准确,不要输出与问题无关的内容。")); messages.add(UserMessage.from("LangChain4j是什么?")); Response<dev.langchain4j.data.message.AiMessage> response = model.chat(dev.langchain4j.model.chat.request.ChatRequest.builder() .messages(messages) .build()); System.out.println(response.content().text()); } }这段代码里SystemMessage用来设定角色和行为边界。比如我要求“简洁、准确”,模型就会明显比不加约束的时候收敛很多。UserMessage是用户输入。多轮对话场景下,往这个messages列表里继续追加历史消息即可。
这里要说明一下generate和chat的区别:generate只接收一个字符串,适合快速验证链路;chat接收完整的消息列表,是真正写业务时该用的入口。
4. 实际跑通过的几个坑与排查心得
4.1 401 Unauthorized:API Key的问题
最常见,也最容易排查。现象是请求发出后,服务端返回401,日志里能看到AuthenticationError或者401状态码。原因一般就这几类:
第一,环境变量没有传到运行进程里。刚才说过,IDE里跑代码需要额外配置环境变量,尤其是从IDEA启动时,终端里export的变量默认是拿不到的。先在代码里加一行System.out.println(System.getenv("DASHSCOPE_API_KEY")),看打印出来的值是不是空,这个动作能帮你快速定位问题。
第二,Key过期或者账户欠费。DashScope的Key有过期策略,如果之前创建的Key过期了,去控制台重新生成一个就好。
第三,复制的时候把空格也带进去了。粘贴到IDE配置框里的时候,首尾多一个空格就会导致鉴权失败,而且肉眼很难看出来。遇到401先检查Key两边是不是有不可见字符。
4.2 404 / Invalid URL:baseUrl写错了
如果你把baseUrl写成了https://dashscope.aliyuncs.com,或者漏掉了/compatible-mode/v1,服务端很可能返回404。这类错误在日志里通常能看到明确的HTTP状态码,但有个坑是,LangChain4j在发请求前可能会把URL规范化,导致错误路径被合并,表现变成让人摸不着头脑的Invalid URL。
我的排查建议是:如果报错涉及URL,先回看一遍baseUrl的完整字符串,和官方文档逐字符对照。注意末尾不要有斜杠,也不要有多余的空格。
4.3 SLF4J警告和依赖冲突
刚搭建项目的时候,如果没加日志实现依赖,控制台会看到一行黄色的SLF4J: Failed to load class警告。这不影响功能,但会让新手误以为哪里挂了。解决办法就是pom里加一个slf4j-simple,或者如果你项目已经有logback了,直接什么都不用加,日志框架会自动绑定。
如果是一个已有的大型项目,引入LangChain4j后可能会出现NoSuchMethodError或者奇怪的Jackson反序列化异常。这种大概率是项目里已有的gson、guava、okhttp跟langchain4j-open-ai传递依赖的版本冲突了。遇到这种情况,用mvn dependency:tree -Dincludes=dev.langchain4j看一下依赖树,把冲突的版本调整一致。
4.4 超时、限流和重试策略
调用qwen-plus时,我遇到过一次SocketTimeoutException。原因是问题比较复杂,模型生成的回答较长,而我把timeout设置得比较短。解决办法是在builder里显式设置超时时间:
OpenAiChatModel.builder() .timeout(java.time.Duration.ofSeconds(30)) ...另外,如果请求过于频繁,服务端会返回429 Too Many Requests。这时候靠maxRetries重试自动处理即可:
OpenAiChatModel.builder() .maxRetries(3) ...重试虽然方便,但生产环境建议自己实现带指数退避的重试,不要完全依赖框架默认策略,否则限流时可能出现重试风暴。
4.5 回答质量不稳定,怎么“教育”模型
有时候同一道题,模型两次回答方向不一样,或者输出了一段跟业务无关的内容。这种问题不属于bug,而是模型参数和提示词的问题。
我的处理思路是三步:第一,加SystemMessage明确角色和回答边界;第二,把temperature降到0.2甚至更低;第三,对关键答案做程序化校验,做一个简单的关键字断言,保证输出格式符合预期。如果试了这三步还不稳定,那就考虑换更强的qwen-max,或者把问题拆得更细。
5. 从Demo到工程落地:三个进阶能力
5.1 流式输出:让回答变成“打字机”
跑通同步接口只是第一步。你要真是给用户做一个问答机器人,没人愿意盯着屏幕等一个3秒后才一次性出来的长回答。流式输出能把耗时从“等待”变成“持续输出”,用户第一反应就会觉得系统“活”了。
LangChain4j里有专门的StreamingChatLanguageModel接口,同样属于langchain4j-open-ai模块。构造方式类似:
import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.model.output.Response; import dev.langchain4j.data.message.AiMessage; StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .modelName("qwen-plus") .build(); model.generate("请解释微服务架构", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println(); } @Override public void onError(Throwable error) { error.printStackTrace(); } });onNext回调里拿到的token就是模型每次返回的增量片段,把它们拼起来就是完整回答。前端接SSE或者WebSocket,把这些增量实时推给浏览器,打字机效果就出来了。这里注意,流式接口的语义是边生成边返回,所以你的业务代码如果需要等完整结果再处理,就别用流式,或者把流式结果先聚合到内存里再统一处理。
5.2 多轮对话记忆:别再手动拼历史消息了
用chat接口做多轮对话,你得自己维护一个messages列表,每轮追加用户消息和AI消息,token数还要自己算。这种手写方案很容易在消息列表无限增长时把上下文撑爆。
LangChain4j提供了ChatMemory来管理对话记忆,最常用的实现是MessageWindowChatMemory,它只保留最近N条消息,超出窗口的旧消息会被自动丢弃。然后借助AiServices,你只需要定义一个接口,框架自动把“带记忆的对话”变成一个普通Java方法调用:
import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; interface Assistant { String chat(String userMessage); } ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("DASHSCOPE_API_KEY")) .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .modelName("qwen-plus") .build(); ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build(); String answer1 = assistant.chat("你好,我叫小张"); String answer2 = assistant.chat("我叫什么名字?");第二次调用时,模型能记得用户叫小张,这就是记忆窗口在起作用。MessageWindowChatMemory.withMaxMessages(10)表示最多保留10条消息,超过之后自动淘汰最旧的消息。窗口越大,模型可参考的上下文越多,但每次请求的token成本也会随之上升,这个需要根据业务按需调整。
5.3 工具调用:问答助手变“数字化员工”
只做问答不够,问答只是入口,真正的价值在于模型能帮你做事。LangChain4j支持把Java方法注册成工具,模型在回答过程中如果发现需要调用某个工具,会主动发起调用,并把返回值整合进最终回答。
举个例子,给Assistant接口之外定义一个工具类:
import dev.langchain4j.agent.tool.Tool; class OrderTool { @Tool("根据订单号查询订单状态") public String queryOrderStatus(String orderNo) { // 这里查数据库、调内部接口都行 return "订单 " + orderNo + " 当前状态为已发货"; } }然后在构建AiServices时把这个工具注册进去:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .tools(new OrderTool()) .build(); String answer = assistant.chat("帮我查一下订单2024001的状态");当模型发现你的问题涉及订单查询,它会根据@Tool里的描述生成调用参数,框架自动反序列化并执行你的queryOrderStatus方法,再把结果回传给模型,模型组织成自然语言回答。这就是工具调用的完整链路。
这个能力一下子把“问答”升级成了“能执行任务的助手”。查库存、生成工单、拉报表、提单审批,只要把方法暴露成@Tool,模型就能“指挥”代码干活。对Java后端来说,这是我觉得LangChain4j最有价值的一块能力,远比单纯把大模型接入聊天窗有价值。
这里再说一个实际体验
跑通demo本身不难,难的是从demo到生产之间那段路。我自己的体会是:API Key一定要尽早纳入配置中心统一管理,不要散落在各个环境变量里;每次调用最好记录一下模型名、token数、耗时,方便后续对账和成本优化;如果业务存在高频调用,限流和重试策略要提前设计,别让模型供应商的429限制突然打崩你的接口。
最后再分享一个小技巧:刚开始调试LangChain4j时,把日志级别开到DEBUG,看LangChain4j实际发出的HTTP请求体。你会发现它把messages列表、temperature、maxTokens这些参数完整地拼进了请求。看到这个真实的请求结构之后,你对这套框架的理解会立刻深一层——原来它不过是个帮你把模型调用封装成普通Java接口的“翻译官”。想清楚这一点,后面无论换模型还是排查问题,你心里都会更有底。