news 2026/10/27 22:53:44

LangChain4j 实战:Java 后端构建带记忆 AI 客服与 RAG 应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangChain4j 实战:Java 后端构建带记忆 AI 客服与 RAG 应用

做 Java 后端快十年了,去年开始发现身边越来越多同事在研究怎么把大模型接进业务系统,结果一搜资料,教程几乎全是 Python 的。LangChain4j 这个名字我一开始也没太在意,直到真正上手用它做了一版带记忆的 AI 客服助手,又跑通了多路召回,才确认这就是 Java 生态里那个能把"大模型能力"和"工程化落地"衔接起来的框架。这篇文章就是我基于 LangChain4j(Java 版)从零实操的完整记录,包括依赖怎么引、核心抽象怎么理解、带记忆客服怎么写、多路召回和 RAG 怎么在 Spring Boot 服务里落地,以及那些常规文档不会告诉你的坑。

1. 为什么 Java 团队会需要 LangChain4j:从生态缺位到正式补位

1.1 我是在什么场景下开始用它的

之前接大模型的方式非常原始:用RestTemplate或OkHttp去调模型厂商的chat/completions接口。这个方案在 demo 阶段没问题,一旦进入真实业务就立刻暴露问题。比如我要做一个工单系统的 AI 客服,光是"上下文管理"就够喝一壶的——用户每说一句话,我得手动把之前的对话历史拼进请求体,还得算 token 数,超了就要截断;用户问的问题如果涉及公司内部的售后政策,我得先把知识库切片查出来,再把检索结果拼进 system prompt;模型返回的内容有时候是一段 JSON,但偶尔会在外面包一圈废话,我需要正则去剥,剥完再解析。

这些东西每次都要重写一遍,而且每换一个模型厂商,请求体格式、鉴权方式、流式响应的解析逻辑全不一样。所以当我看到 LangChain4j 的时候,第一反应是:这不就是 Java 版的大模型应用脚手架吗?它把对话、记忆、工具调用、向量检索、输出解析这些高频能力统一抽象成了接口,底层换模型厂商只需要换依赖和配置。

1.2 它比裸调 API 到底多出来了什么

我用一张表来说明裸调 API 和 LangChain4j 在典型场景下的差异,这是我在做选型评估时实际列的对比:

维度裸调模型 API使用 LangChain4j
多轮对话手动拼接消息列表,自己管理 tokenChatMemory自动维护,可设窗口条数
工具/函数调用手写 JSON Schema,解析参数叫回@Tool注解标记方法,框架自动生成参数
结构化输出靠 prompt 说"请返回 JSON",再手写容错解析声明接口返回 POJO,框架走 Function Calling 拿结果
向量检索 + RAG自己调 embedding 接口、自己算相似度EmbeddingStore统一读写,检索器可插拔
流式输出手写 SSE 解析,按 chunk 拼接StreamingChatModel回调onNext自动收
模型厂商切换每个厂商一套实现换 artifact 和 builder 配置,业务代码不动

当然,LangChain4j 不是没有代价。它多了一层抽象,也就意味着排查问题时你得知道框架在背后做了什么;它的版本迭代非常快,部分接口在 1.0 之前还有过调整。这些都是后话,但整体判断非常明确:只要你的项目是 Java 技术栈,又不想把大模型接入做成"一次性脚本",用它是当前最稳的选择。

2. 从 Maven 依赖到第一句对话:环境搭建中真正会卡住你的几个点

2.1 依赖引入的正确姿势

LangChain4j 的 Maven 坐标是dev.langchain4j,不是com.langchain4j,这一点我第一次就搞错过。它在中央仓库的主模块叫langchain4j,同时按模型厂商拆分了很多集成模块,比如langchain4j-open-ai、langchain4j-ollama、langchain4j-azure-open-ai等等。

我建议最小项目的 pom.xml 这样写:

<properties> <langchain4j.version>1.0.0-beta1</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> </dependencies>

这里有个非常容易踩的问题:langchain4j核心包和厂商集成包必须使用完全一致的版本号。如果你核心包用0.35.0,集成包用0.36.0,启动时大概率会报NoSuchMethodError或者ClassNotFoundException,因为框架内部的 SPI 约定变了。所以我的习惯是统一用一个langchain4j.version属性管理,强烈不建议手动画版本。

2.2 一个能对话的 Demo 怎么写

依赖引好后,写一个最简对话其实只需要十几行代码。我以 OpenAI 兼容接口为例,因为在 LangChain4j 里,这类接口的接入方式完全一致,你换成任何厂商都只改baseUrl和modelName:

import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; public class QuickStart { public static void main(String[] args) { ChatModel model = OpenAiChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .temperature(0.7) .build(); String answer = model.generate("用一句话向 Java 开发者介绍什么是 LangChain4j"); System.out.println(answer); } }

你如果直接跑这个类大概率已经能拿到输出,但我还是建议从环境变量读 API Key,而不是硬编码在代码里。尤其是多人协作的项目,Key 一旦被提交进 git 仓库,后面清理起来非常麻烦。

2.3 配置上常踩的坑

baseUrl的结尾斜杠是我反复中招的地方。很多兼容接口的地址必须以/v1结尾,而且baseUrl后面不要再拼路径,框架会自己补/chat/completions。如果你在配置里写了https://api.example.com/v1/,某些版本会拼出双斜杠导致 404,某些网关能忍,但线上环境我建议统一写成不带尾斜杠的格式。

另一个是超时问题。大模型服务本身的响应时间波动很大,框架默认超时在使用非流式接口时通常够用,但如果你的 prompt 很长或者模型较慢,经常会在 60 秒附近被切断。我建议在 builder 里显式加大超时:

OpenAiChatModel.builder() .timeout(Duration.ofSeconds(120)) .maxRetries(3) ...

maxRetries也值得说明:它只在网络错误(比如连接超时、5xx)时重试,业务层面的 4xx 不会重试,所以不要指望它帮你兜住参数错误。

最后,如果你发现请求没反应又不知道发生了什么,把 HTTP 日志打开。OpenAiChatModel.builder().logRequests(true).logResponses(true)能帮你看到完整请求体,在做 prompt 调试时几乎是必备操作。

3. 核心抽象不是黑话:ChatModel、ChatMessage 和 ChatMemory 怎么配合

3.1 一条消息在框架里的流转

LangChain4j 的模型和市面上所有大模型 API 一样,本质是"消息进、消息出"。它把消息分成三种主要类型:

  • SystemMessage:系统指令,相当于给模型定的角色和规则;
  • UserMessage:用户输入,可以是纯文本,也可以带图片等多模态内容;
  • AiMessage:模型生成的回复,除了文本还包含函数调用结果等信息。

你不需要手动构造这些类的实例,框架在你调用model.generate("...")时,会自动把字符串包装成UserMessage;如果你传了多轮对话,它会在内部组织成消息列表。这个小细节很重要,因为它解释了为什么框架能统一处理各家模型的上下文格式,你只需要关心业务逻辑。

3.2 ChatMemory 到底干了什么

无状态的 AI 客服是没有意义的。用户上一句说"我的订单号是 12345",下一句问"它到哪了",你不可能要求用户再报一遍订单号。所以我们需要记忆。

LangChain4j 对记忆的抽象是ChatMemory,接口方法就两类:add()加消息、messages()取消息。最常见实现是MessageWindowChatMemory,它只保留最近 N 条消息:

ChatMemory memory = MessageWindowChatMemory.builder() .maxMessages(10) .build();

这里要注意一个很容易让人误会的点:maxMessages(10)限制的是消息条数,不是 token 数。如果这 10 条消息里有两三条特别长,token 依然可能打爆模型上下文窗口。所以我的经验是,在接口层提前限制用户单次输入长度,再把maxMessages设小一点,比如 6 到 8 条,比单纯堆大窗口更稳妥。

还有一个点,ChatMemory默认是存 JVM 内存里的。单机应用没问题,多实例部署时每个节点的记忆互不相通,用户请求被负载均衡到不同节点就"失忆"了。后面我会讲怎么在 Spring Boot 里扩展这一步。

3.3 ChatModel 的流式生成

非流式接口在 AI 客服场景里体验很差:用户要等 3 到 8 秒才能看到完整回复。LangChain4j 的StreamingChatModel用回调来处理流式输出,代码结构比裸调 SSE 清爽很多:

StreamingChatModel model = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .build(); model.generate("帮我写一段 Java 的 Stream 用法示例", 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) { // 处理异常 } });

实际写到 Spring Boot 里,我通常会配合SseEmitter转发给前端,让后端接口变成"打字机模式"。但注意流式接口一般要用独立的超时设置,因为整段响应时间可能很长,按常规 HTTP 超时去设会一直断流。

4. 实战:为工单系统做带记忆的 AI 客服助手

4.1 需求与设计

我拿真实场景举例:公司内部的工单系统,用户提交问题前,希望 AI 客服先通过多轮对话收集信息,包括问题分类、问题描述和期望优先级,然后自动生成工单草稿。这个需求下,AI 不是简单地你问我答,而是要在对话中记住用户已经提供的信息,再引导用户补全缺失项。

我的设计分三层。第一层是ChatModel,负责语言理解和生成;第二层是ChatMemory,用id区分不同用户;第三层是AiServices,这是 LangChain4j 里比直接调model.generate()更高级的用法,它能把你定义的接口方法自动变成"用大模型去实现"的调用。

4.2 完整代码实现

先定义一个 AI 服务接口,方法上写清楚系统提示词和用户提示词模板:

interface CustomerSupportAgent { @SystemMessage(""" 你是工单系统的智能客服。你的任务是通过对话收集用户的问题。 你已经了解的信息: 分类:未知 描述:未知 优先级:未知 如果用户还没说清楚某个字段,请主动询问。当四个字段都明确后,请输出:感谢你的描述,我已为你生成工单草稿。 只围绕工单收集信息,不要回答与工单无关的问题。 """) String chat(@V("question") String question); }

然后构建带记忆的 agent:

ChatModel model = OpenAiChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .build(); CustomerSupportAgent agent = AiServices.builder(CustomerSupportAgent.class) .chatModel(model) .chatMemory(MessageWindowChatMemory.builder() .maxMessages(8) .build()) .build(); // 模拟不同用户:这里如果是真实系统,通常按用户 id 来做 memoryId String answer = agent.chat("人工客服不在线,我想问一下我买的东西为什么 10 天还没发货");

在多用户场景下,MessageWindowChatMemory默认是全局共享一份。更好的做法是按用户隔离,LangChain4j 的ChatMemoryProvider可以按memoryId返回独立记忆实例。我在代码里简化了,但真实项目这一步不能省。

4.3 实测效果与调参记录

我实测下来有几点体会。

一是temperature要调低。客服场景最优值在我这里是0.2到0.4之间。太高的温度会让 AI 偶尔"发挥过度",把规则说得花里胡哨,甚至自己编造售后条款 —— 这在客服系统里是致命问题。

二是@SystemMessage里列出的字段状态看起来有点"笨",但确实有效。你把已知信息写出来,模型会照着做状态跟踪,而不是每次重新猜测用户意图。如果你想更精确地拿到结构化状态,就比较适合用下一节说的"结构化输出"改造成把每轮收集到的字段映射成对象。

三是 prompt 模板变量必须用{{it}}或者{{question}}这样的占位符,不要用${}。原因很简单,${}在模板引擎里可能有冲突,而 LangChain4j 自己的模板约定就是双大括号。我第一次写顺手用了{}导致变量没被替换,模型直接把我模板里的占位符当成了文本,调试了快半小时才发现是占位符写错了。

5. 进阶:多路召回 + RAG 在 Java 服务里的落地过程

5.1 为什么单路向量检索不够用

做企业知识库问答,最经典的实现是 RAG:把文档切块 -> 向量化 -> 存向量库 -> 用户提问时检索相似片段 -> 拼进 prompt。这个流程 LangChain4j 支持得很好。但我在真实业务里发现,如果只靠"向量召回"一条路,结果经常不理想。

向量检索擅长语义相似,比如用户问"退款要多久",它能召回文档里写"退货款通常在3-5个工作日到账"的段落。但向量检索对精确信息不敏感:用户问"订单号 10086 是什么状态",向量检索几乎不可能精确命中该订单。工单编号、型号、日期、具体金额这些关键词,用倒排索引或数据库条件查询反而更准。所以我采用了"多路召回",也就是同时走多条检索路径,再把结果融合排序,那段时间搜索 LangChain4j 多路召回的资料时,相关讨论也印证了这个思路:单路方案简单,但生产环境最多只能叫"能跑",离"靠谱"还有距离。

5.2 嵌入模型与文本切分

先解决文档入库问题。LangChain4j 的EmbeddingModel负责把文本变成向量,EmbeddingStore负责存储和检索:

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(DocumentSplitters.recursive(300, 50)) .embeddingModel(embeddingModel) .embeddingStore(store) .build(); ingestor.ingest(Document.from("你的知识库文本内容..."));

切分参数recursive(300, 50)的意思是每段最多 300 字符,段与段之间重叠 50 字符。重叠非常重要,否则一个完整语义被切在两段中间,检索时两边都召不回完整的答案。300 这个数字不是拍脑袋,它需要结合你用的 embedding 模型的单次输入上限和实际知识条目的长度来定,原则是"一段能自洽地表达一个完整信息"。

5.3 多路召回的路由与融合

多路召回拆开看其实很简单:

  • 路一:向量召回,负责语义匹配;
  • 路二:关键词召回,我用的是简单倒排索引,把所有文档切成词,用户问题里的词去命中;
  • 路三:精确匹配,用户问题匹配到订单号、工单号等结构化字段时走数据库查询。

融合阶段我用最朴素的加权分数。把每路召回的相似度归一化到 0 到 1,然后按权重相加,权重根据场景凭经验调:

public List<RetrievedRecord> multiRecall(String question) { // 路一:向量召回 List<EmbeddingMatch<TextSegment>> vectorHits = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(embeddingModel.embed(question).content()) .maxResults(5) .minScore(0.5) .build()); // 路二:关键词召回 List<TextSegment> keywordHits = invertedIndex.search(question); // 路三:结构化精确查询,比如用户在问题里带了工单号 List<OrderDoc> dbHits = orderRepository.searchByOrderId(question); // 统一 Score 模型,按加权融合后取 TopN return fusionEngine.topN(vectorHits, keywordHits, dbHits, 5); }

这段代码简化了融合细节,但核心思路是:不要把多路结果原样拼给模型。我在最初版本里把每路召回到的 TOP 5 全部塞进 prompt,很快发现 token 爆炸且模型被无关信息干扰。融合排序是必需的,甚至可以在融合后再加一道"重排",用一个小模型判断候选片段是否真的回答用户问题。不过重排会带来额外成本,量小的时候可以缓一缓。

5.4 RAG 落地的真实坑

我在这块踩的坑大概可以列三条。

第一,向量库选型。InMemoryEmbeddingStore只能用于 demo,项目重启数据就没了。生产环境我建议至少用RedisEmbeddingStore,或者专门的向量数据库,LangChain4j 都做好了适配,切换成本不高。

第二,召回为空的问题。如果设置minScore(0.5)而用户问题表述太偏,可能一个片段都召不回。我的处理方式是把 minScore 调低到 0.3,并在融合阶段如果分数都很低时,返回一条兜底文本"我暂时没找到相关政策,已为你转接人工"。

第三,同步检索导致接口延迟叠加。用户在客服窗口输入一句话,后端要先做多路召回,再调用大模型,整体耗时可能从 2 秒变成 6 秒。解决思路是尽早接流式输出:先让模型把开场白流出来,检索结果边查边补充,体验会好很多。

6. Spring Boot 集成与结构化输出:让模型返回值直接变成业务对象

6.1 结构化输出是 AI 进业务链路的最后一道门槛

客服助手聊完天,最终要落库生成工单。工单有明确的字段,比如category、description、priority。如果靠模型在对话文本里"夹带私货",再写正则抽取,生产环境维护起来非常痛苦。LangChain4j 最优雅的用法,是直接定义一个 POJO,让接口方法返回它:

record OrderIntent(String category, String description, int priority) { } interface CustomerSupportAgent { @UserMessage(""" 用户的话:{{it}} 请提取工单信息,并分类到:退款、物流、商品咨询、售后维修。 """) OrderIntent extractIntent(String userMessage); }

然后这样调用:

CustomerSupportAgent agent = AiServices.builder(CustomerSupportAgent.class) .chatModel(model) .build(); OrderIntent intent = agent.extractIntent("我的东西都十天了还没到,到底怎么回事?");

框架会引导模型以符合 Java 类型的方式返回结果,底层通常走的是模型的函数调用能力,而不是让模型输出一段不可控的 JSON 文本再解析。这个设计一举解决了两个问题:第一,返回值天然就是一个强类型对象;第二,因为走的是结构化通道,模型"多说废话"的情况大幅减少。

6.2 Spring Boot 里注入 Bean 和异步处理

放到 Spring Boot 里,我更倾向于把模型和 AI 服务都配置成 Bean:

@Configuration public class LangChain4jConfig { @Bean public ChatModel chatModel() { return OpenAiChatModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .baseUrl(System.getenv("LLM_BASE_URL")) .modelName(System.getenv("LLM_MODEL_NAME")) .temperature(0.2) .timeout(Duration.ofSeconds(120)) .build(); } @Bean public CustomerSupportAgent customerSupportAgent(ChatModel chatModel) { return AiServices.builder(CustomerSupportAgent.class) .chatModel(chatModel) .chatMemory(MessageWindowChatMemory.builder() .maxMessages(8) .build()) .build(); } }

要注意的是,ChatModel的调用是阻塞的,而且单次耗时可能几秒甚至十几秒。在 Spring MVC 里直接同步调用会占满 Tomcat 线程。我实际项目里会把它丢到单独的线程池去执行,配合CompletableFuture,或者直接用前面说的StreamingChatModel做 SSE 输出,避免大模型拖垮接口整体的吞吐。

6.3 生产环境必须做的三件小事

最后分享三个我在生产环境总结出来、且不再想踩第二次的坑。

第一,API Key 一定要通过环境变量或者配置中心下发,不要写死在配置文件里提交仓库。不仅是安全原因,还因为一旦要切换模型厂商或账号,写死在代码里的 Key 会让你改代码发版,而动辄几十个微服务都依赖同一个 Key,过试用期之后你会非常痛苦。

第二,给自己加一层"成本护栏"。大模型按 token 计费,prompt 越长成本越高。我在开发环境统一使用较便宜的模型,在代码里限制单次maxMessages,在网关层面限制用户单日调用次数。如果哪天别人发现你的 AI 客服可以被脚本刷一晚上,账单会教做人。

第三,AI 服务的测试不能只看"有输出"。我有限的实践里,最可靠的方式是把核心 prompt 和召回结果导出出来,人工标注一部分 case,每次改 prompt 后回归对比。模型输出天然有随机性,不要迷信某一次跑通就是没问题。要允许接口在"无法提供答案"时明确说不知道,不要强行编造,这点必须在@SystemMessage里写死。

LangChain4j 的迭代速度很快,接口还在持续演进,但这套从 ChatModel 到 AiServices、从 ChatMemory 到多路召回的组合,已经成为我在 Java 里做 AI 应用的标准套路。这篇记录里的代码和思路,基本就是我现在做新项目时最常用的底座,照着搭一遍再往里面加自己的业务规则,会顺畅很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 22:52:29

Windows 本地部署 MinerU 4.0:构建 RAG 知识库的 PDF 解析利器

字符串解析这关&#xff0c;真的是做 RAG 知识库最容易翻车、又最容易被忽视的环节。我见过太多人把精力全砸在 embedding 选型和向量库调参上&#xff0c;结果检索不准&#xff0c;回头排查才发现源头文档压根没解析干净。MinerU 4.0 就是来解决这个问题的&#xff1a;开源、可…

作者头像 李华
网站建设 2026/10/27 22:53:11

Java多商户电商平台源码实战:从架构拆解到核心链路避坑指南

简介&#xff1a;这是一套基于Java的创创猫多商户电商平台消费端前端源码&#xff0c;定位为B2B2C电商解决方案&#xff0c;适合Java电商开发学习者、前端工程师以及需要搭建多商户商城的中小团队参考。资源共342个文件&#xff0c;压缩包约2.85MB&#xff0c;其中以197个Vue组…

作者头像 李华
网站建设 2026/10/27 22:52:31

Spring源码怎么读?模块划分与面试高频追问的全景解析

“看过源码吗&#xff1f;说一下Spring有哪些模块&#xff1f;”这道题我面试别人时问过很多次&#xff0c;自己也被人问过无数次。说实话&#xff0c;绝大多数候选人听到这个问题都会愣一下&#xff0c;然后开始背“Spring由IOC容器、AOP、事务、MVC组成”这种教科书答案。这种…

作者头像 李华
网站建设 2026/10/27 10:23:36

HRTOS 4.0 将长期维护:为什么版本号不再频繁变化

随着 HRTOS 4.0 核心逐步稳定&#xff0c;后续项目的发展方向也会发生一些变化。过去&#xff0c;版本号更多代表一个阶段性的开发成果&#xff1b;而进入 4.0 之后&#xff0c;我希望它逐渐成为一个长期稳定的基础版本。因此&#xff0c;未来 HRTOS 将尽量长期保持 4.0 这个版…

作者头像 李华
网站建设 2026/10/18 19:44:26

浏览器渲染性能优化:从渲染管线到JS引擎的完整实战指南

1. 浏览器渲染管线&#xff1a;理解从URL到像素的必经之路很多人写前端写了一两年&#xff0c;调试CSS靠猜&#xff0c;调性能靠试&#xff0c;为什么&#xff1f;本质上是没建立起“浏览器到底怎么把我写的代码变成屏幕上的像素”这个全局观。这一节我们先把这个链路彻底打通&…

作者头像 李华
网站建设 2026/10/22 7:16:04

第111篇 Kotlin Multiplatform 初识:跨端共享业务逻辑

前面几节都在 Android / JVM 范围内,这一节把视野打开到 Kotlin Multiplatform(KMP)。这是近年 Android 面试里的新兴高频题——不是因为它有多难,而是因为很多人只知道"跨平台"四个字,答不出边界在哪里、什么时候不该用。这一节的判据很清楚:KMP 的价值在&quo…

作者头像 李华