
做 Java 后端快十年了去年开始发现身边越来越多同事在研究怎么把大模型接进业务系统结果一搜资料教程几乎全是 Python 的。LangChain4j 这个名字我一开始也没太在意直到真正上手用它做了一版带记忆的 AI 客服助手又跑通了多路召回才确认这就是 Java 生态里那个能把大模型能力和工程化落地衔接起来的框架。这篇文章就是我基于 LangChain4jJava 版从零实操的完整记录包括依赖怎么引、核心抽象怎么理解、带记忆客服怎么写、多路召回和 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.version1.0.0-beta1/langchain4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-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和modelNameimport 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 StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { // 这里会不断收到增量片段 System.out.print(token); } Override public void onComplete(ResponseAiMessage 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); }然后构建带记忆的 agentChatModel 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(); EmbeddingStoreTextSegment 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 ListRetrievedRecord multiRecall(String question) { // 路一向量召回 ListEmbeddingMatchTextSegment vectorHits embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(embeddingModel.embed(question).content()) .maxResults(5) .minScore(0.5) .build()); // 路二关键词召回 ListTextSegment keywordHits invertedIndex.search(question); // 路三结构化精确查询比如用户在问题里带了工单号 ListOrderDoc 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 服务都配置成 BeanConfiguration 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 应用的标准套路。这篇记录里的代码和思路基本就是我现在做新项目时最常用的底座照着搭一遍再往里面加自己的业务规则会顺畅很多。