☰
LangChain4j实战:Java开发者如何搞定大模型应用开发
2026/10/20 17:36:58 网站建设 项目流程

这两年我在Java服务端里做大模型能力的落地,被问得最多的一个问题就是:“你是用Python写的还是Java写的?”问的人默认了一个前提——搞大模型应用,似乎就得拥抱Python生态。其实不然。LangChain4j出现之后,Java开发者终于有了一个符合自己工程习惯的LLM应用开发框架,而且它把“接入大模型、管理对话记忆、调用工具、对接私有知识库”这些高频需求全部收拢成类型安全、结构清晰的API。这篇文章我想顺着一条完整的上手路径,把LangChain4j从依赖配置到实战组装讲一遍,适合两类读者:一类是已经在技术团队里做Java开发、正准备把AI能力集成进现有服务的人;另一类是看着各种AI应用层出不穷、但不想随大流去学Python的纯Java工程师。你会发现,用Java写AI应用,没有想象中那么别扭,甚至可能在可维护性上比脚本式demo舒服得多。

1. 为什么Java开发者要盯上LangChain4j:AI应用开发里的“Java缺席”

最初我在现有服务里接大模型能力,用的方式很原始:拿HTTP客户端去调模型接口、用字符串拼接提示词、再把返回的JSON手工解析成对象。单个接口还好,一旦涉及多轮对话、格式化输出、外部工具调用,代码就开始失控——提示词一改,拼接逻辑就要跟着动;会话上下文要自己维护一个List往请求里塞;模型返回的格式稍微变一点,解析层就报错。这种状态持续一段时间后,我几乎要以为“Java做AI应用就是得这么痛苦”。

后来接触到LangChain4j,第一感受是“这块拼图终于有人补上了”。它不是一个把Python的LangChain做简单翻译的移植版,而是从Java开发者的习惯出发重新设计的一套LLM应用开发框架。它把所有和模型打交道的脏活累活都抽象成了几组干净的接口,你不需要关心HTTP细节、不需要手动拼Prompt、不需要自己维护对话记忆的数据结构,只需要面对Java对象和方法签名。

具体来说,LangChain4j帮你解决了这么几类问题:

  • 统一模型接入:聊天、流式对话、向量嵌入分别对应不同的模型接口,后端的差异被封装在实现类里,换模型不换业务代码。
  • 提示词工程化:用模板和注解管理System Prompt、User Message,不再靠字符串拼接。
  • 会话记忆托管:一个ChatMemory对象维护多轮上下文,窗口大小、清理策略都可以配置。
  • 工具调用:在方法上标一个@Tool,模型就能自己决定什么时候调、传什么参数,框架负责把调用结果送回模型。
  • RAG知识库:文档加载、切分、向量化、检索、再生成,整条链路都提供了现成组件。
  • 类型安全的AI服务编排:这也是我认为最“Java”的地方——你定义一个带注解的接口,框架就在运行时生成实现,像调用本地方法一样去调大模型。

对Java团队来说,LangChain4j的另外一个价值是它没有引入一套新的运行时,就是一个普通依赖库,可以无缝嵌进现有的Web服务、批处理任务或者微服务模块。如果你的项目用的是Java生态最常见的Web快速开发框架,官方还提供了对应的starter,配置好连接参数之后,直接把模型对象注入到Service里就能用。相比在Python环境里单独维护一套服务再和Java应用做网络通信,这种“在原来工程里直接加能力”的方式,部署和运维成本低了一个量级。

2. 上手前的认知清单:LangChain4j帮你搭好了哪几个积木

写代码之前,我建议你先在脑子里搭一张地图:一个大模型应用看起来千变万化,本质上就四件事——把需求写清楚(提示词)、把上下文留住(记忆)、让模型真正干活(工具调用)、让模型回答自己不知道的信息(RAG)。LangChain4j的所有模块,都是围绕这四件事展开的。

先看一张总表,把核心抽象和它们解决的问题对上号。

你要解决的事LangChain4j的核心抽象一句话理解
发起一次对话ChatLanguageModel聊天模型的统一入口,输入字符串返回字符串
流式输出StreamingChatLanguageModel一边生成一边回调,适合打字机效果
文本向量化EmbeddingModel把文本转成向量,供RAG检索使用
提示词管理PromptTemplate/ 注解比字符串拼接更安全、更易维护
多轮记忆ChatMemory框架帮你保管历史对话
让模型调外部方法@Tool注解把Java方法暴露给模型
知识库问答Document/Splitter/EmbeddingStore/ContentRetriever完整RAG组件链路
组装一切AiServices用接口声明AI能力,自动生成实现

ChatLanguageModel是使用频率最高的接口。你引入对应的模型适配依赖后,会得到一个实现了这个接口的具体类,构建时通常需要配置三个参数:模型服务地址、访问密钥、模型名称。设计上它刻意保持极简,核心方法就一个generate,输入处理好的提示词,返回文本结果。这样的好处是业务代码根本不关心你后面接的是商用模型API还是本地推理服务,哪天切换后端,换一个构建参数就行。

ChatMemory是另一个容易被低估的组件。模型本身没有记忆,所谓多轮对话,本质上是每次请求都把历史消息重新发给模型。自己维护这个列表也能做,但很快会遇到三个问题:消息多了Token爆炸、要自己决定保留多少轮、不同会话要隔离。MessageWindowChatMemory这类实现帮你把这三个问题一次性解决了,你只需要告诉它最多保留多少条消息。

AiServices则是最有LangChain4j特色的设计。它允许你定义一个普通Java接口,方法上加上提示词相关的注解,然后通过构建器生成代理对象。调用接口方法时,框架自动完成提示词渲染、记忆注入、工具注册、结构化输出转换这一整套流程。从使用者视角看,大模型被包装成了一个可测试、可Mock的本地组件。

还有一组容易被忽略的接口是Document、DocumentSplitter、EmbeddingStore和ContentRetriever,它们组成了RAG的官方推荐链路。文档先加载成Document,再切分成小块,每块向量化后存入EmbeddingStore,查询时通过ContentRetriever找回相关片段,最后拼进提示词让模型回答。后面实战部分我会完整演示这条链路。

3. 十分钟跑通第一个对话示例:依赖、配置与最小代码

先把环境准备好。JDK建议用17及以上版本,构建工具用你熟悉的Maven或Gradle都行。新建一个空白Java工程,引入LangChain4j核心依赖,再加一个模型后端的适配依赖。核心依赖的坐标长这样:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>选择你当前可用的稳定版本</version> </dependency>

适配依赖取决于你打算接哪种模型服务。如果走商用API,就引入对应的官方适配模块;如果走本地推理服务,就引入本地模型服务对应的适配模块。选依赖的时候注意两点:一是核心库和适配库的版本要一致,混用不同版本的API经常出怪问题;二是有些适配模块还需要额外的日志库,没配置的话控制台一片安静,排查起来很头疼。

模型后端有两条路线。商用API体验最省事,去服务商平台申请一个访问密钥,填进去就能跑。如果不想申请密钥或者担心成本,可以本机起一个推理服务,加载一个开源模型,然后在代码里把地址指向本机。本地路线的优势是免费、数据不出内网、延迟也可控,缺点是模型能力上限取决于你的机器配置。

接下来是最小可运行代码。注意这里XxxChatModel是对你接入的模型后端实现类的一个占位写法,你引入对应的适配依赖后,把Xxx换成实际的类名即可。构建参数里,baseUrl是模型服务的地址,apiKey是访问密钥(本地服务常常不需要真实密钥,随便填一串能过校验就行),modelName是你想用的模型名称。

String apiKey = System.getenv("LLM_API_KEY"); String baseUrl = System.getenv("LLM_BASE_URL"); String modelName = System.getenv("LLM_MODEL_NAME"); // XxxChatModel 为你的模型后端对应的实现类 ChatLanguageModel model = XxxChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .timeout(Duration.ofSeconds(60)) .build(); String answer = model.generate("用一句话解释什么是依赖注入"); System.out.println(answer);

这段代码的背后,框架替你做了好几件事:构造请求体、把字符串包装成用户消息、发起调用、处理超时和鉴权、解析返回内容。如果手写HTTP调用,这些逻辑每一个都要自己处理一遍,而且每换一个模型服务商就要重新写一套。我建议第一次跑通之后,故意把apiKey改成错的试一试,你会看到框架抛出异常的位置非常精准,这比对着响应文本猜问题舒服太多了。

如果你的项目用的是Web快速开发脚手架,还可以直接用官方提供的自动配置功能。在配置文件里写上模型服务的地址、密钥、模型名,然后在代码里直接注入ChatLanguageModel。这个方式把底层的生命周期管理都交给容器,日常开发时基本感觉不到“我在调一个外部模型”,和注入一个普通业务Service没有区别。

4. 拆解五个基础积木:从提示词模板到AI服务接口

4.1 用PromptTemplate告别字符串拼接

直接往generate里传字符串,短期内没问题,场景一复杂就难受了。假设你要构造这样一个提示词:要求模型扮演某个角色,用特定语气回答用户问题,还要附带几条参考资料。用字符串拼接,代码长、易出错、难复用。用PromptTemplate,模式就变成了“模板 + 参数”。

PromptTemplate template = PromptTemplate.from(""" 你是{role},请始终用{style}的语气回答用户的问题。 如果提供了参考资料,请优先依据资料作答。 用户问题:{question} """); Prompt prompt = template.apply(Map.of( "role", "资深技术顾问", "style", "简洁专业", "question", "如何设计一个高可用的消息队列?" )); String response = model.generate(prompt.text());

这个组件给我的感觉就像把提示词当代码来管理:变量一目了然,改提示词内容不需要动Java逻辑,模板可以放在配置文件或专门的文件里。团队里非开发同学也能参与提示词优化,这是字符串拼接方案做不到的。

4.2 用ChatMemory接管多轮上下文

我第一次做多轮对话时犯过错误:自己维护一个消息列表,每次请求前把历史消息拼进去,结果对话轮数一多,消息长度失控,模型开始答非所问。换成ChatMemory之后,代码简洁了很多:

ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build();

maxMessages(20)表示最多保留20条历史消息,超出后最老的消息会被自动丢弃。这个窗口值需要根据你的实际场景调整:窗口越大,模型可参考的上下文越多,Token消耗也越大;窗口太小,用户多聊几句就“失忆”了。我一般从20到30条起步,单轮修改建议类场景可以适当缩小。真正的多用户场景,还要按会话ID做记忆隔离,不能所有用户共用一个ChatMemory,否则你的用户会互相“串台”。

4.3 用结构化输出拿到强类型结果

大模型默认返回自由文本,如果要拿字段去填业务表单、存数据库,就得自己解析。LangChain4j的结构化输出能力,可以定义一个Java类型,让模型直接返回这个类型的实例。

record WeatherResponse(String city, int temperature, String suggestion) {} interface WeatherAssistant { WeatherResponse getWeather(@UserMessage String query); } WeatherAssistant assistant = AiServices.builder(WeatherAssistant.class) .chatLanguageModel(model) .build(); WeatherResponse weather = assistant.getWeather("北京明天适合出门吗?"); System.out.println(weather.city() + " / " + weather.temperature() + "度");

这里的感觉非常奇妙:你在调用一个Java方法,返回的是一个强类型对象,但背后其实是模型在干活。框架把接口返回值类型告诉了模型,模型按要求输出,框架再完成反序列化。这份“强类型”的价值在编译期就能体现——字段名拼错了,IDE直接报错,而不是等到运行期发现JSON字段对不上。要注意的是,结构化输出的稳定性依赖模型能力,级别较高的大模型普遍遵守格式指令,小参数模型偶尔会漏字段或输出额外内容,上线前需要加一层校验兜底。

4.4 用AiServices把接口变成AI能力

AiServices是LangChain4j的编排核心。它接受一个接口,为你生成一个代理实现,把提示词、记忆、工具、检索这些东西全部融合到接口调用里。刚才的结构化输出就是最简单的一种用法。它带来的好处不只是少写代码,更重要的是把“AI能力”和“业务逻辑”解耦了——上层代码面对的是一个普通接口,你甚至可以在测试环境用Mock对象替换掉AI实现,这让单元测试变得非常干净。

如果你愿意,还可以在接口方法上组合使用@SystemMessage和@UserMessage注解。@SystemMessage设置模型的角色和行为约束,@UserMessage声明哪些参数会填入用户消息。这样一来,一次AI调用的完整输入输出契约,全部写在接口定义里,维护起来非常直观。

4.5 用流式接口实现打字机效果

聊天场景如果等模型全部生成完再一次性返回,用户体验会差很多。LangChain4j提供了流式模型接口StreamingChatLanguageModel,它把生成过程拆成一段一段往回传,配合WebSocket或SSE,前端就能看到打字机式的逐字输出。

StreamingChatLanguageModel streamingModel = XxxStreamingChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .build(); streamingModel.generate("请写一段关于微服务的介绍,大约200字", new StreamingResponseHandler<>() { @Override public void onPartialResponse(String partialResponse) { // 把 partialResponse 逐段推给前端 } @Override public void onComplete(ChatResponse completeResponse) { // 生成结束,关闭连接 } });

流式接口在Web场景下的体感差异是决定性的,但实现时要注意两件事:一是回调线程是框架内部线程池派发的,往前端推数据时别直接用这个线程做耗时操作;二是要处理用户中断,前端断开连接后,后端要能及时取消生成任务,否则资源一直在空转。这两个细节我在最初实现时都没留意,直到线上出现连接数堆积才回头修的。

5. 让模型长出手脚:工具调用、记忆与RAG的协同实战

单靠提示词,模型能聊、能写、能总结,但它“不知道实时数据,也做不了外部操作”。解决方式就是工具调用(Tool Calling)。我用一个客服助手的场景来串讲。

先定义一个查询库存的工具方法:

class StockQuery { @Tool("查询商品实时库存") public int queryStock(String skuId) { // 这里调用真实的库存服务 return 42; } }

然后在构建AI服务时把这个工具注册进去:

interface CustomerServiceAssistant { String chat(@UserMessage String message); } CustomerServiceAssistant assistant = AiServices.builder(CustomerServiceAssistant.class) .chatLanguageModel(model) .tools(new StockQuery()) .chatMemory(MessageWindowChatMemory.builder().maxMessages(20).build()) .build(); String answer = assistant.chat("帮我看看SKU-10086还有货吗?");

当用户问库存时,模型的思考过程大致是这样:先判断这个问题需要调用工具,然后输出一个“我要调用queryStock方法,参数是SKU-10086”的结构化指令;框架收到指令后,反射调用你的Java方法,拿到返回值42;框架把“工具调用结果:42”作为新的消息送回给模型;模型基于这个结果生成最终答复。整个过程对开发者透明,你看到的只是接口方法的返回值,但背后模型已经完成了一次“自己决定要不要动手、动什么手”的推理。

需要提醒的是,工具方法一定不能有副作用隐患。模型可能因为用户话术变化而意外触发某个工具,所以方法内部要有参数校验和权限控制,关键操作最好加确认步骤。我在生产环境里给工具方法统一加了审计日志,谁在什么时候调了什么工具、参数是什么、结果是什么,全部留痕,方便回溯问题。

同样在这次构建里,chatMemory让模型记住了前文对话。用户先说“我之前问过一个商品”,然后再问“它现在有货吗”,模型能关联上历史话题。这里要特别说明:记忆和工具并不是互斥的,它们是同一套AiServices协调的不同模块,模型在一次回答中既可以参考历史上下文,也可以决定查询工具,还能结合RAG检索到的知识库内容。

再引入RAG。假设客服助手需要回答“你们的退换货政策是什么”这类问题,这些内容在实时库存工具里是查不到的,但政策文档是现成的。做法是把政策文档切块、向量化、存入向量库,构建一个检索器:

// 1. 加载文档并切分 Document document = loadDocument("退货政策.md"); DocumentSplitter splitter = new ParagraphSplitter(300, 50); List<TextSegment> segments = splitter.split(document); // 2. 向量化并存入向量库 EmbeddingModel embeddingModel = new XxxEmbeddingModel(...); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); embeddingStore.addAll(embeddings, segments); // 3. 把检索器接到AI服务上 ContentRetriever retriever = new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel); CustomerServiceAssistant assistant = AiServices.builder(CustomerServiceAssistant.class) .chatLanguageModel(model) .chatMemory(...) .tools(new StockQuery()) .contentRetriever(retriever) .build();

这样组合出来的客服助手,面对三类问题各有办法:闲聊直接回答;查库存调用工具;问政策检索文档。你可能已经发现,工具调用和RAG解决的是不同的问题:RAG给模型喂的是“参考文本”,模型不能修改这些内容,适合回答知识型问题;工具调用让模型执行外部动作,适合实时查询和写操作。实际项目里这两种能力经常同时出现,用AiServices把它们组装到同一个接口上,是最顺手的做法。

6. 组装一个文档问答助手:从需求到可运行代码

前面讲了单个能力,这一节把它们串成一个完整Demo。目标很明确:读入本地若干文档,构建一个问答助手,用户提问后返回基于文档内容的答案。这是RAG应用最常见的形态,也是我在团队内部落地知识库问答时的最小骨架。

先定需求:

  • 自动加载某个目录下的所有文本或Markdown文档
  • 对文档执行切分、向量化、入库
  • 接收用户问题,检索相关片段,生成回答
  • 在回答中标注引用了哪个文档片段,便于人工核验

接入模型和向量模型的部分和前面一致,核心区别在文档处理管线。我把完整代码拆成几段来写。

第一段,加载文档并切分。这里我使用ParagraphSplitter,两个参数分别表示分段大小(这里300个字符)和相邻段之间的重叠(50个字符)。重叠段的作用是避免一个完整语义被拦腰切断,比如一段话跨在两个相邻切块边界上,有重叠就能保证检索时至少有一块包含完整意思。这个参数值得多调试几次,太小了检索噪声多,太大了Token浪费多。

DocumentSplitter splitter = new ParagraphSplitter(300, 50); List<TextSegment> segments = new ArrayList<>(); for (String filePath : filePaths) { Document document = DocumentLoader.load(filePath); segments.addAll(splitter.split(document)); }

第二段,向量化并入库。向量模型的选择直接影响检索质量,同一个模型服务商一般会同时提供对话模型和向量模型,选embedding能力时关注两个维度:文本长度上限和向量维度。注意更换向量模型时,历史已经入库的向量就失效了,必须全量重嵌入,这是我踩过的一个实打实的坑。

List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); embeddingStore.addAll(embeddings, segments);

第三段,构建检索器和AI服务。检索器负责在查询时把用户问题向量化,然后在向量库里找最相似的片段。maxResults参数控制取回几段,我通常设3到5段;还可以设置相似度阈值,低于阈值的片段直接丢弃,避免模型被不相关内容干扰。

EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.7) .build(); interface DocAssistant { String answer(@UserMessage String question); } DocAssistant docAssistant = AiServices.builder(DocAssistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build(); String answer = docAssistant.answer("合同审批的流程是什么?");

第四段,代码里加上引用来源验证。因为EmbeddingStore里存的是TextSegment,检索到结果后可以进一步取出片段文本和它对应的原文档元数据,拼进提示词的同时也输出到日志里。这样如果模型答错了,你可以立刻判断是“检索到的内容本身不对”,还是“模型没有准确依据生成回答”,排查效率高得多。

这个Demo跑通后,有几个明显要往生产方向优化的点:InMemoryEmbeddingStore存的是内存数据,重启就丢,生产环境要换成持久化向量库;文档更新时,需要做增量索引,而不是每次全量重建;检索结果可以加一层重排序,让模型先对候选片段做相关性打分,再进入生成阶段。这些都是后话,第一版先跑通链路,你已经掌握了RAG应用的主干。

7. 避坑红黑榜:版本、成本、模型选型与并发场景的真实经验

最后分享几条来自真实项目的经验,这些内容文档里未必会写,但一旦踩中往往要花掉一两天去排查。

版本更新速度比想象中快,先锁定版本再看API。LangChain4j迭代很积极,一些API在版本升级后会发生参数变化。我遇到过的情况是:网上教程用的是旧版写法,官方文档已经是新接口,照着写根本编译不过。建议代码里显式指定版本号,升级时先看官方变更说明,再决定要不要跟随。生产项目没必要追最新版,稳定跑着比什么都重要。

API密钥管理别走捷径。密钥放在配置文件里已经是坏习惯了,更危险的是不小心提交到代码仓库。泄漏之后,别人可以用你的密钥消耗大量Token,月底账单会教你做人。请一律使用环境变量或专门的密钥管理服务,并定期轮换。如果是本地模型服务不需要真实密钥,代码里也应该用占位符,避免养成把密钥写死的肌肉记忆。

Token成本会被记忆和RAG双重放大。很多人在原型阶段没有成本概念,上了生产才发现账单涨得飞快。多轮对话的记忆窗口越大,每轮请求消耗的Token就越多;RAG检索回来的片段越冗长,预测成本也越高。建议做一个简单的监控:记录每次调用的输入Token数和输出Token数,按天汇总,这样能很清楚看到是哪个环节在烧钱。设计上可以给记忆窗口设置上限,RAG片段只截取最相关的一两段,这通常能省下不少费用。

小参数模型做复杂工具调用容易翻车。工具调用看起来简单,其实非常考验模型的指令跟随能力。模型要正确判断该不该调工具、调用哪个工具、参数类型是否匹配。参数量偏小的模型偶尔会把参数类型传错,或者编造一个不存在的工具名。如果你的场景依赖工具调用,务必在小模型上做多轮回归测试,不行就换更大的模型,或者做一个工具调用的结果校验层。

大模型的非确定性会影响测试。同样的输入,两次运行结果可能不完全一样,这让自动化测试变得很棘手。我的经验是:不要断言模型返回的完整文本,而是断言结果里是否包含关键实体信息,比如订单号、金额、政策名称;或者用结构化的方式返回,只针对结构化字段做断言。对稳定性要求极高的场景,可以降低模型温度参数,但这只能缓解,不能根除。

并发场景要注意限流和重试。商用模型API通常有速率限制,并发一大就会报错。框架自身不提供重试策略,需要在业务侧实现指数退避重试。我踩过的教训是:某次活动流量上来后,大量请求同时打到模型接口,直接触发限流,然后排队重试又放大了压力。后来在接入层加了一个带有信号量的限流器,再配合合理的超时时间,整个服务才稳定下来。

嵌入模型切换必须重算历史向量。我在前面提过一次,这里再强调一遍:向量模型的升级或更换,会导致新旧文本向量不在同一语义空间里,新旧向量混着存,检索质量会明显下降。如果你要升级向量模型,请设计一个全量重建索引的流程,宁可停机几分钟,也不要新旧混合。

最后说几句

我从“手动调大模型API”到“用框架组装AI应用”,最大的体会是:工具链成熟之后,Java开发者做AI应用的门槛已经大幅降低,真正的难点不再是接口怎么调,而是怎么设计提示词、怎么组织工具、怎么治理检索质量——这些恰恰是普通Java工程师本来就擅长的工程化能力。如果你正在犹豫要不要把LangChain4j引入项目,我的建议是先别急着上生产,拿一个内部知识库问答的Demo跑通整条链路,再评估效果和成本。后续如果想深入,可以往这几个方向探索:多工具组合成复杂Agent流程,让模型自主决定先调哪个工具;引入更完整的RAG评估手段,用一组标准问题持续度量检索和回答质量;以及多模态能力,把图片、音频理解也纳入现有服务。先把基础打扎实,剩下的都是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询