做 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 |
|---|---|---|
| 多轮对话 | 手动拼接消息列表,自己管理 token | ChatMemory自动维护,可设窗口条数 |
| 工具/函数调用 | 手写 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 应用的标准套路。这篇记录里的代码和思路,基本就是我现在做新项目时最常用的底座,照着搭一遍再往里面加自己的业务规则,会顺畅很多。