
说实话这两年做后端的老哥焦虑感一半来自大模型另一半来自满屏的 Python 代码。AI 应用火起来之后想做个内部知识库问答、自动工单分类、智能客服这类功能翻开教程和示例基本都是 Python。可公司技术栈是 Java总不能为了几个 AI Demo 把团队语言全换了。所以我第一次看到 LangChain4j 的时候确实眼前一亮——这就是 Java 生态里对标 LangChain 的 LLM 应用框架把大模型对话、上下文记忆、工具调用、文档问答这些能力用 Java 的接口和注解重新封装了一遍。这篇文章是一个纯 Java 后端从零上手 LangChain4j 的完整总结里面有我跑通的代码、踩过的坑以及现在一直在用的工程化套路适合还没入坑、或者刚开头还没理清头绪的同学。1. 先搞清楚LangChain4j是个什么东西1.1 它解决了Java后端接入大模型时最烦的事Java 直接调用大模型接口本来不难难的是接口之外的一堆重复劳动。你想想每次对话要维护消息列表区分 system、user、assistant 三种角色多轮对话要把历史消息一次次拼进 prompt想让模型查数据库得处理“模型说要调函数再自己写代码去调最后把结果塞回去”的循环。这些逻辑如果每个项目都自己写一遍最后一定是一团乱麻。LangChain4j 的做法是给 Java 开发人员一套统一抽象。核心模型接口叫 ChatLanguageModel你只需要拿到一个实现类调用chat()或generate()剩下的消息组装、角色标识、返回解析都交给框架。更高阶的 AiServices 还能把记忆、工具、检索这些能力组合成一个接口你定义一个 Java 接口用框架生成一个代理对象调用方式跟在本地调 Service 一样。这个设计思路很像我们在 Java 里用 ORM 框架。你不需要自己写 JDBC 模板的重复逻辑框架帮你把表结构映射成对象。LangChain4j 就是把“和大模型对话”这件事也做成了这种模板化的东西。1.2 核心组件有哪些我把 LangChain4j 里最常用的几个组件按“干什么用的”列了一下组件作用我类比成什么ChatLanguageModel统一的模型调用入口负责发送消息、拿回复一个远程 Service 的客户端ChatMemory维护多轮对话历史自动管理消息窗口客服手里的对话工单Tool / Tool把 Java 方法注册成模型可以调用的工具给实习生一张“可求助清单”EmbeddingModel把文本转成向量用于语义搜索给每段文字生成一个指纹EmbeddingStore存储和检索向量按指纹找相似内容的资料库AiServices把记忆、工具、检索、模型编排成一个业务服务业务门面 FacadeContentRetriever从向量库检索相关资料喂给模型做 RAG自动查参考书的图书管理员这里面最容易忽略的是 AiServices。新手一开始只想调通对话往往跳过它直接拼消息。但当你开始做真实业务需要同时处理记忆、工具和知识库时AiServices 是你能稳定落地的关键。1.3 和Python生态的LangChain有什么区别很多教程会把 LangChain4j 直接说成“Java 版 LangChain”这话对也不对。设计理念确实很像但它不是 Python 代码的逐行翻译而是按照 Java 的强类型和工程习惯重新设计过的。举个例子在 Python 里你经常要自己处理动态字典、链路回调结构比较灵活但 Java 里你靠接口定义行为靠注解声明元数据。比如定义一个工具方法Python 生态可能要写装饰器和复杂的回调LangChain4j 里就是给方法加一个Tool注解方法签名是强类型的参数说明写在注解里IDE 能帮你检查类型问题。再加上 Java 后端天生在意依赖管理和模块化。LangChain4j 被拆成核心包、厂商适配包、Spring 生态集成包、向量库适配包你可以按需引入不会因为做了一个简单对话就拖进一整套重量级依赖。不过也要认清差距。Python 生态的 AI 组件数量多社区活跃度高你可以找到各种冷门场景的开源方案。Java 这边相对少有些个性化需求可能要自己读源码去扩展。但核心对话、记忆、工具、RAG 这些高频场景它已经覆盖得很好了。2. 环境准备与第一个对话Demo2.1 依赖怎么引我建议先准备 JDK 17 和 Maven 3.8。JDK 8 不是不能用但 LangChain4j 的不少特性和虚拟线程、HTTP 客户端都依赖新版本用 JDK 17 能少折腾很多。依赖分两块一个是 langchain4j 核心包一个是具体模型服务商对应的适配包。核心包里包含了对话、记忆、工具调用等基础 API适配包解决的是“怎么把你选定的那家模型 API 封装成 ChatLanguageModel”。下面是一个最小化 pom 片段properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target langchain4j.version0.35.0/langchain4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-xxx/artifactId version${langchain4j.version}/version /dependency /dependencies那个xxx就是你在用的模型供应商适配模块。不同厂商的依赖坐标不一样但格式基本一致langchain4j-厂商名。建议去官方示例页面对应模块找最新坐标不要直接抄我这里的版本号因为框架更新很快跨一个大版本时 API 可能有调整。密钥管理上不要把 key 写到代码里。我习惯用系统环境变量LLM_API_KEY本地 dev 环境用.env文件生产环境走密钥中心。这样即使代码截图流传出去也不会泄露敏感信息。2.2 最小可运行代码第一次跑通对话最重要的不是背 API而是理解“ChatLanguageModel 是用来隔离不同厂商差异的”。我代码里故意用一个占位类MyChatModelProvider实际项目里替换成你用的厂商 Builder 就行。import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.model.chat.ChatLanguageModel; public class ChatDemo { public static void main(String[] args) { // 注意MyChatModelProvider 只是占位符 // 实际替换成你选的厂商适配类例如 XxxChatModel ChatLanguageModel model MyChatModelProvider.builder() .apiKey(System.getenv(LLM_API_KEY)) .modelName(your-model-name) .temperature(0.7) .build(); AiMessage answer model.chat(请用一句话介绍你自己); System.out.println(answer.text()); } }model.chat(String)是简化方法底层会构造一个用户消息并调用模型。返回的AiMessage里有两个重要字段text()是模型返回的文本内容toolCalls()是模型想调用的工具列表。只做对话时看text()就够了工具调用后面细说。如果你跑完这个 Demo发现控制台打印了内容恭喜你说明依赖引入、API Key、网络链路都通了。2.3 参数调优与日志查看第一次跑通后会忍不住调各种参数。我实际用得最多的是这几个temperature控制随机性。做信息抽取、分类这类任务调到 0 或者 0.1输出更稳定做文案创作、头脑风暴调到 0.7 以上结果更有发散性。maxTokens限制最大返回 token 数。不设的话有些模型会啰嗦半天成本也会上去。timeout网络超时。默认值在一些慢模型上不够用我一般调成 60 秒左右。还有一个很容易漏掉的调试技巧开启 HTTP 请求日志。很多模型适配模块都支持底层 HTTP 日志可以把真正发送给模型的 prompt 打印出来。你会在日志里看到框架给你拼了哪些 system 指令、哪些历史消息、哪些工具定义。很多“模型回答不对”的问题打开日志后马上就能定位到是 prompt 不对。3. 让对话拥有记忆ChatMemory实战3.1 为什么不能自己拼历史消息不写框架的话大家最自然的做法是用一个 List 存历史消息每次调用前把整个 List 塞进请求。这个方案在只测一两个来回时没问题但一旦对话轮数多起来你会遇到三件事第一token 数量膨胀。几十轮对话全部交给模型单次请求就可能超限成本和延迟都不可控。第二上下文容易被无关内容干扰。模型会从很早期的对话里找蛛丝马迹导致最近的真实意图反而被冲淡。第三窗口管理逻辑很容易写错。比如什么时候保留 system 指令、什么时候丢弃最早的消息、按消息数截断还是按 token 截断这些都是自己实现时的细节坑。ChatMemory 就是来解决这个问题的。它帮你维护一个消息窗口只保留最近 N 轮或最多保留 N 个 token同时处理好 system 消息的优先级。3.2 AiServices 记忆的实现最省心的用法不是直接操作 ChatMemory而是配合 AiServices。你先定义一个接口interface Assistant { String chat(String userMessage); }然后用 AiServices 生成实现类import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer assistant.chat(你好我叫小明); System.out.println(answer);这个接口的每个方法调用框架会自动管理上下文。默认策略是 MessageWindow 滑动窗口最多保留最近 20 条消息。具体数字可以按业务场景调智能客服希望记住更久可以调大做普通问答调小一点既省钱又减少干扰。有一点要注意AiServices 生成的代理对象内部使用的 ChatMemory 如果是无状态的实现那多用户同时访问时可能会串话。所以接着看多用户会话隔离。3.3 多用户会话隔离与持久化我一开始踩过这个坑把所有用户共用一个 Assistant 实例结果用户 A 说的话用户 B 都看在眼里上下文完全乱套。解决办法很简单会话维度单独维护 ChatMemory。import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ChatSessionService { private final MapString, ChatMemory memoryStore new ConcurrentHashMap(); public String chat(String sessionId, String userMessage) { ChatMemory memory memoryStore.computeIfAbsent( sessionId, id - MessageWindowChatMemory.withMaxMessages(20) ); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); return assistant.chat(userMessage); } }这段代码里用sessionId区分不同用户每个会话拥有独立记忆窗口。如果服务是单机部署ConcurrentHashMap够用但如果有多实例部署就需要把记忆放到 Redis 这类外部存储里。框架提供了持久化扩展点你可以把记忆读写抽象成接口在实现里用 Redis 或数据库保存这样重启后用户的上下文也不会丢。4. 让模型学会调用工具Tool调用实战4.1 工具调用到底是什么很多刚接触的同学会把工具调用理解成“模型自己上网查数据”这是不对的。模型本质上不会执行代码它只是根据你的指令输出“我想调用哪一个方法参数是什么”。真正执行方法的是你本地服务。用人话打个比方你是个领导模型是实习生。实习生不知道内部系统的下单逻辑但他知道有个“下单工具”。当用户说“帮我订一杯美式”实习生不会自己去下单而是告诉你“我要调用下单工具参数是美式大杯”。然后真正的下单动作由你来做做完之后把结果告诉实习生他再组织成自然语言回复用户。LangChain4j 里的 Tool 就是这整套流程的封装。你只要把一个 Java 方法标记成工具框架负责把方法签名转换成模型能理解的 JSON Schema在模型返回工具调用后自动执行方法再把执行结果发回给模型。4.2 用Tool注册一个真实方法我举一个业务里很常见的例子用户问“现在几点”模型可能不知道准确时间但我们本地方法能拿到。import dev.langchain4j.agent.tool.Tool; import java.time.LocalDateTime; import java.time.ZoneId; public class TimeTools { Tool(获取中国标准时间当前的日期时间) public String currentTime() { return LocalDateTime.now(ZoneId.of(Asia/Shanghai)).toString(); } }然后在构建 Assistant 时注入Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new TimeTools()) .build();这样当用户问“现在北京时间几点”时模型会先输出工具调用框架自动执行currentTime()再把结果返回给模型生成回答。你基本不用写任何工具调用的“粘合代码”。如果你想实现更复杂的工具比如查订单状态只需在方法里调用对应的业务 Service参数通过方法入参传入。模型会自动解析用户表达里的订单号映射到方法的参数上。4.3 工具调用链路的关键细节工具调用看起来简单但有几个点特别影响稳定性。第一工具的描述要具体。Tool(获取当前时间)和Tool(获取中国标准时间当前的日期时间格式yyyy-MM-dd HH:mm:ss)后者被模型正确选择的概率高得多。工具描述就是给模型看的说明越清晰越好。第二参数类型尽量简单。字符串、数字、布尔值最稳定。如果你用复杂对象模型输出 JSON 时很容易少字段或类型不对。遇到复杂参数我一般拆成多个简单参数宁可方法签名长一点。第三工具方法里的异常一定要处理干净。工具方法一旦抛异常框架可能会把错误消息作为工具结果返回给模型模型可能基于异常信息开始“编”一个答案非常危险。我在方法体里做了 try-catch返回“查询失败请联系系统管理员”之类的固定文案。第四防止无限循环。模型可能会反复调用同一个失败工具或者在工具之间来回跳。生产环境里我给工具调用次数设置了上限达到上限直接中断。5. RAG实战给模型塞一份私有知识库5.1 RAG的核心链路RAG 的全称是 Retrieval-Augmented Generation检索增强生成。它解决的是模型不知道我们内部资料的问题。模型的训练数据再新也不可能知道你们公司的内部制度、项目文档、产品手册你也不想把这些资料全部塞进 prompt那既不现实也不安全。RAG 的思路很简单模型回答之前先根据用户问题从你的私有文档库里检索最相关的几段内容把这几段内容拼进 prompt让模型基于这些内容回答。相当于给模型配了一个“带目录的参考资料书架”他遇到问题先翻书再回答。LangChain4j 的 RAG 链路分为四步加载文档、切分文本、向量化存储、检索并生成。5.2 文本切分、向量化和存储加载文档这一步比较直接你可以从文件、数据库、网页抓取。重点是切分。如果把一整篇几万字的文档直接向量化检索效果会很差因为向量只能表达整段文字的整体语义用户问其中一个小细节时相似度会被其他内容稀释。我自己一般用“定长 重叠”的切分方式。每段 500 到 800 个字符段与段之间重叠 100 个字符左右这样能保住句子上下文的连贯性。下面是一个简单的切分示例import dev.langchain4j.data.document.Document; import dev.langchain4j.data.segment.TextSegment; import java.util.ArrayList; import java.util.List; public class TextSplitter { public static ListTextSegment split(Document document, int chunkSize, int overlap) { String content document.text(); ListTextSegment segments new ArrayList(); for (int i 0; i content.length(); i chunkSize - overlap) { int end Math.min(i chunkSize, content.length()); segments.add(TextSegment.from(content.substring(i, end))); if (end content.length()) { break; } } return segments; } }切分完成后用 EmbeddingModel 把每一段转成一个向量再存进 EmbeddingStore。EmbeddingModel embeddingModel MyEmbeddingModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .build(); EmbeddingStoreTextSegment embeddingStore MyEmbeddingStore.builder() .build(); ListTextSegment segments TextSplitter.split(document, 600, 100); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);这里的MyEmbeddingModel和MyEmbeddingStore依旧是占位类。实际使用中EmbeddingModel 是模型服务商提供的向量模型接口EmbeddingStore 可以选内存版也可以接专门的向量数据库。新手先跑通内存版再考虑接外部存储。5.3 检索增强问答的完整实现向量化存储做好之后真正写问答就变得非常简单。LangChain4j 提供了一个 ContentRetriever 的抽象你只要把 Store 和 Model 配置进去。ContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.7) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build(); String answer assistant.chat(离职流程是什么);框架做了三件事把问题转成向量在向量库里检索最相似的文档片段把片段和问题一起送给模型。你不用担心 prompt 拼错。minScore是相似度阈值具体数值取决于 Embedding 模型。我建议初次调试时先设低一点比如 0.5把命中的片段打印出来看看相关性再逐步调高。5.4 关于RAG效果的一些心里话RAG 的效果一半在算法一半在数据准备。很多同学向量库也建了prompt 也写了但回答还是不对查来查去发现是文档本身太乱。我这里有几个调优经验切分块太大模型会把无关内容也吸收进去切分块太小每段信息不完整。600 到 800 字符是我常用的起始值。文档清洗很重要。PDF 转出来的文本经常有乱码、多行表头、页眉页脚这些噪声不清理检索质量会直线下降。调试时一定要打印检索结果。不要只看最终答案我习惯把所有命中的片段打印出来确认“模型看到的资料”真的是用户问题对应的内容。有些用户问题表述模糊比如“提交流程”这时直接检索效果不好。可以先用模型把问题改写成更明确的query再做检索。这是 RAG 进阶优化点。6. 工程落地时最想提醒你的几件事6.1 常见问题排查速查表我把这段时间遇到的典型问题整理成了速查表希望对你有帮助现象可能原因处理办法模型答非所问说无关内容系统提示词太弱或多轮历史被截断优化 system 指令检查历史消息是否完整工具方法一直不被调用工具描述不清楚或参数类型太复杂重写 Tool 描述拆解复杂参数对话历史不生效没有注入 ChatMemory或每次构建新 Assistant用 AiServices 注入同一个 ChatMemory多个用户互相串话共用了一个 ChatMemory按 sessionId 隔离工具调用后死循环工具异常导致模型反复尝试加工具调用上限工具内部兜底返回结构化输出经常失败返回类型太复杂或 prompt 未给格式示例简化返回对象给一个 JSON 示例RAG 检索不到内容阈值设太高或者文档没切分好降低 minScore打印检索片段定位返回结果超长/超时模型未设置 maxTokens 或网络慢设置 maxTokens调大 timeout6.2 生产环境必须盯住的点第一个是密钥管理。模型 API Key 绝对不能出现在代码仓库里也不能被前端直接拿走。我通过环境变量注入服务器上再用密钥管理服务挂载这个看起来笨但非常稳妥。第二个是可观测性。写到生产环境后你会发现“模型返回了什么”和“为什么这么返回”同样重要。我在调用前后加了日志记录入参和出参并给自己加的每一次 LLM 请求打上一个 traceId这样用户投诉时我能快速从日志里捞到完整的 prompt 和模型回复链路。第三个是降级方案。模型服务再稳定也会有抖动接口超时、限流、临时不可用都可能发生。我在业务侧做了兜底如果大模型调用失败或者超时返回一个预设的提示文案同时记录失败次数告警通知值班人。这比用户看到一串报错信息要强太多。6.3 性能与成本优化最后聊钱和速度。模型调用按 token 计费每多传一段历史成本就多一点。优化思路无非四个方向精简 prompt。能不说废话就不说废话把 system 指令控制到最精炼。压缩历史。MessageWindowChatMemory 限制轮数也可以按 token 窗口截断。结果缓存。高频问题直接缓存答案相同问题不重复调用模型。流式输出。对话场景用流式接口能大幅提升用户首字感受到的速度体验比干等更友好。做了一段时间之后我发现真正有价值的不是把单次响应调到多快而是把整个应用的上下文管理、工具调用、数据检索这些基础设施做稳。模型本身会越来越聪明但这些工程底座始终得自己一条一条搭起来。最后再分享一个我调了很久才明白的细节做工具调用和结构化输出同时出现的场景时返回对象别设计得太花哨尽量用扁平字段并在系统提示词里给出一个明确的 JSON 示例。很多“模型偶发格式错乱”的问题都是因为给模型发挥的空间太大了。你把它当成一个比较容易跑偏的新人指令给细一点输出的稳定性就会好很多。