ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java开发者上手LangChain4j:从环境搭建到RAG实战

Java开发者上手LangChain4j:从环境搭建到RAG实战

作为一个常年写 Java 后端的人,我第一次接触 LangChain4j 的时候其实挺抗拒的。当时团队里只要涉及大模型,默认解法就是起一个 Python 小服务,Java 这边用 HTTP 去调,prompt 稍微改一版就要跨项目走一次联调,链路拉得又长又脆。后来在一次内部工具改造里,我狠下心把整块逻辑用 LangChain4j 搬进了 Java 工程,才发现这条路其实早就是通的。这篇教程就是写给想用 Java 直接接入大模型的应用开发者,内容包括环境搭建、基础会话、结构化输出、工具调用、记忆管理,再到 RAG 本地知识库问答,最后是一份我自己踩出来的避坑清单。整个项目以 Java 17 为基础,适合从零开始跟着敲,也适合想在现有 Spring Boot 工程里集成 AI 能力的人参考。

1. Java项目接大模型的老路,我为什么最终选了LangChain4j

1.1 没有LangChain4j之前,Java是这样接大模型的

如果你所在团队既有 Java 又有 Python,早期接大模型的典型姿势是:Python 同学写一个 FastAPI 或 Flask 服务,把某个 prompt 模板固定成接口参数,Java 这边用 WebClient、RestTemplate 甚至 OkHttp 去调,然后对返回的 JSON 字符串做切割和解析。

这套模式跑通一个 Demo 一点问题没有,可一旦业务复杂起来,痛点会非常密集。刚换了一个新模型、接口路径变了,改的是 Python 那边;想要修改 system prompt,还是 Python 那边;需要把多轮对话的上下文在请求里拼起来,依然是 Python 那边。Java 工程师想自己调一遍 prompt 看效果,得先去装 Python 环境、拉依赖、起服务,这套动作看着不重,次数多了就会让人烦躁。

更麻烦的是返回解析。模型返回的内容本质是一个文本流,你需要自己设计 JSON 格式的 prompt,再靠 Jackson 去反序列化。模型一旦不听话,回复里混进一句解释性文字,解析直接抛异常或者静默失败。多轮对话的记忆状态没人管理,工具调用的循环逻辑也要自己写。我见过不少 Java 项目所谓的“AI 能力”,最后变成了 Python 服务里几百行拼 prompt 的胶水代码,维护成本比业务代码还高。

1.2 LangChain4j 与 LangChain 的关系,以及它填补的空缺

LangChain4j 的命名很明显,就是 LangChain 的 Java 版本。但要澄清一点,它并不是把 Python 的 LangChain 逐个类翻译过来,而是借鉴了 LangChain 里被验证过的核心概念,比如 ChatModel、ChatMemory、Tool、Retriever,再用贴近 Java 开发习惯的方式重新组织了一遍。

它在工程上的优势,我认为体现在三点。第一,所有东西都是类型安全的,模型的输入输出可以落到 Java 的 class、enum、record 上,不再靠字符串正则去抠。第二,AiServices这个组件让我可以用接口定义 AI 能力,框架在运行时通过动态代理生成实现,调用模型、解析结果、维护会话这些事情都被底层处理掉了。第三,模型供应商的接入层做得很薄,OpenAI、Azure OpenAI、Ollama、Google Gemini、Anthropic 这些都有对应模块,业务代码只需要面向统一的ChatLanguageModel接口,以后换模型厂商不需要大面积改代码。

语言层面也不必担心,JDK 8 的老项目也能跑,但我个人建议直接用 JDK 17。一方面新版本框架在 Java 17 上的表现更稳定,另一方面像 record、switch 表达式这类语法在写结构化输出时确实省事。

1.3 什么场景其实不需要它

不是说所有 Java 项目都必须引入 LangChain4j。如果你的需求就是拿着一个写死的 prompt 去调一次大模型,拿到结果就完事,那直接用 HTTP 客户端调官方接口反而更轻,没有必要为一次调用引入一个框架。

另外,如果公司已经有一个成熟的 AI 网关,统一封装了模型调用、限流和计费,下游业务只需要通过网关 SDK 发请求,那也可以不引入。LangChain4j 更适合的场景是需要在 Java 工程内部完成 prompt 编排、多轮对话、工具调度、文档检索这些组合逻辑时,它能省掉大量自己造轮子的工作。

2. 环境准备与第一个可运行程序:Maven、JDK 和 API Key

2.1 依赖怎么选,以及各模块对应什么能力

第一步先把依赖加上。我建议用 Maven,虽然 Gradle 也没问题,但 Maven 的中央仓库信息更全,搜版本号方便。核心依赖只需要两个,一个是langchain4j主模块,另一个是你实际要用的模型供应商模块。

我以 OpenAI 兼容接口和本地 Ollama 两个方向做示例,pom.xml里的依赖大概是这样的:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.36.2</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.36.2</version> </dependency> <!-- 如果要用本地 Ollama,把上面那个 open-ai 替换成这个 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>0.36.2</version> </dependency>

真实项目的版本号建议以上 Maven 仓库里的最新稳定版为准,不要盲目追最新,尤其是beta和alpha的版本,API 变动会比较频繁。我的习惯是每半年左右升级一次,升级前先看 GitHub 上的CHANGELOG。

下表是我实际用过或调研过的模块,按场景整理给你参考:

Maven 模块适用场景常见模型示例
langchain4j-open-aiOpenAI 官方接口、以及兼容 OpenAI 协议的模型gpt-4o-mini、qwen、deepseek 等
langchain4j-ollama本地部署或内网环境,无需 API Keyllama3.1、qwen2.5
langchain4j-azure-open-aiAzure 企业环境,走微软云发布GPT-4o 系列部署名
langchain4j-google-ai-geminiGoogle Gemini 系列gemini-1.5-flash

有一点要提醒:并不是每个模型供应商都有自己的模块,LangChain4j 社区有一个langchain4j-community组织,很多第三方模型集成都放在那里。如果官方模块里找不到你要的厂商,先去社区仓库搜,而不是自己手写 HTTP 接入。

2.2 API Key 管理:不要在代码里硬编码

接入 OpenAI 兼容接口最常见的问题是 Key 的管理。初学者通常会在代码里直接写apiKey("sk-xxxx"),这在本地 Demo 无所谓,但只要代码一提交到 Git 仓库,这个 Key 就泄露了。我见过不止一次因为误提交 Key 导致账号被盗刷的案例,那账单真的能让人血压飙升。

正确做法是用环境变量或配置文件注入。比如:

export OPENAI_API_KEY=sk-your-key-here

代码里通过System.getenv("OPENAI_API_KEY")读取。如果用的是 Spring Boot,可以用@ConfigurationProperties把 Key 放到application.yml里,并在.gitignore中排除。不管哪种方式,基本原则就一条:任何环境相关的凭证都不进代码库。

如果你用的是本地 Ollama,就没有 Key 的问题,这也是我推荐新手先跑本地模型的原因之一。没有网络请求,没有额度限制,随便调试 prompt 不心疼。

2.3 第一个 Hello World:验证整条链路

环境搭好后,先写一个最小程序,验证 Maven 依赖、Key、网络、模型服务四件事。代码如下:

import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; public class HelloLangChain4j { public static void main(String[] args) { ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); String answer = model.generate("用一句话解释什么是大模型"); System.out.println(answer); } }

如果你用的是 Ollama,只需要把模型构造换成:

import dev.langchain4j.model.ollama.OllamaChatModel; ChatLanguageModel model = OllamaChatModel.builder() .baseUrl("http://localhost:11434") .modelName("qwen2.5:7b") .build();

第一次跑大概率会遇到几个常见错误,我按排查顺序整理成一张表:

报错现象常见原因处理方式
401 UnauthorizedAPI Key 错误,或环境变量没读到检查环境变量是否注入,Key 是否有多余空格
404 model not foundmodelName拼写错误,或该模型不在账号权限内去模型平台控制台确认可用的模型名
ConnectException网络不通,或 Ollama 服务没启动本地 Ollama 先执行ollama serve确认 11434 端口
超时异常首次请求冷启动慢,或配置的 timeout 太短把.timeout(Duration.ofSeconds(60))放宽一点

跑通这第一个 Hello World,后面所有内容都只是在这个基础上做叠加,所以这一步值得慢一点,把环境彻底弄明白。

3. 从单向问答到连续对话:消息类型、上下文与流式输出

3.1 一次对话在LangChain4j里的消息骨架

大模型的聊天接口本质上接收的是消息列表,而不是单个字符串。LangChain4j 把消息抽象成四种类型:SystemMessage(系统指令)、UserMessage(用户输入)、AiMessage(模型回复)、ToolMessage(工具调用结果)。一次完整的会话就是这四种消息按顺序排列的产物。

SystemMessage 很关键,它是你控制模型行为最重要的手段。同样的模型,加上“你是一个严谨的 Java 代码评审专家”,和加上“你是一个口语化的编程助手”,输出风格完全不同。UserMessage 和 AiMessage 很好理解,就是来回的对话记录。ToolMessage 只在模型调用工具时出现,后面工具调用那一节会专门展开。

明白这个消息结构,你就能理解为什么 LangChain4j 的多轮会话不是靠魔法实现的,它只是替你把历史消息一条条保存下来,再在每次请求时重新拼进列表里发给模型。

3.2 同步调用和 ChatResponse 的解析

如果只想拿最终回答,用上一节的model.generate(String)就足够了。但真实业务里往往需要更多信息,比如模型用了多少个 token、为什么截断、以及完整的消息内容,这时候就要走更完整的调用方式:

ChatResponse response = model.chat("介绍一下 Java 17 的 record 特性"); AiMessage aiMessage = response.aiMessage(); System.out.println(aiMessage.text()); TokenUsage usage = response.tokenUsage(); System.out.println("输入token: " + usage.inputTokenCount()); System.out.println("输出token: " + usage.outputTokenCount());

这里我用的model.chat()是较新版本的 API,早期版本里这套逻辑叫generate(),现在generate依然保留但更推荐chat。如果 IDE 提示方法已废弃,不用慌,看它推荐的新方法签名照抄即可。

有一点容易被忽略:model.chat()接收的不只是单个字符串,也可以传ChatMessage或消息列表。这意味着你可以自己控制发送窗口,比如只把最近 5 轮对话发给模型,而不是全量发送,这在控制 token 成本时非常实用。

3.3 流式输出:做一个像 ChatGPT 一样的打字机效果

同步调用有个问题:模型生成几百字可能要等两三秒,页面一直空白,用户很容易以为程序卡死了。解决办法是流式输出,把模型生成的 token 一个个或一小批一小批推给前端,还原 ChatGPT 那种打字机效果。

LangChain4j 中流式模型的接口是StreamingChatLanguageModel:

import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.model.output.Response; import dev.langchain4j.message.AiMessage; import dev.langchain4j.model.StreamingResponseHandler; StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); model.chat("讲一个程序员冷笑话", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { System.out.println("\n--- 生成完成 ---"); } @Override public void onError(Throwable error) { error.printStackTrace(); } });

回调方法也不难理解:onNext每来一个片段就触发一次,onComplete在全部生成完成后触发,onError处理异常。Web 场景下,通常会把onNext里的内容通过 WebSocket 或 SSE 推给前端。我自己的经验是,在 Spring Boot 里用SseEmitter和它配合非常顺手,前端用EventSource接收,基本零成本实现打字机效果。

需要注意,不同版本的 LangChain4j 对StreamingResponseHandler的方法签名有过调整,有些版本onNext的参数是ChatMessage而不是String。遇到编译报错时先看 IDE 里提示的抽象方法是什么,照着实现即可,思路完全一样。

3.4 两个最常被忽略的配置项:temperature 和 timeout

temperature控制随机性,数值越低越保守,适合事实问答类任务;数值高一点更发散,适合头脑风暴。我在实际项目中默认给 0.3,只有在生成营销文案这类偏创意的场景才调到 0.8。这个参数放进 builder 链式调用就行:

OpenAiChatModel.builder() .apiKey(key) .modelName("gpt-4o-mini") .temperature(0.3) .timeout(Duration.ofSeconds(60)) .build();

timeout很容易被忽略。模型推理高峰期响应慢是很常见的事,如果你不显式设置,某些 HTTP 客户端的默认读超时只有几秒,稍微一慢就直接报超时。我建议至少给到 30 秒,长文本生成场景给 60 秒更稳妥。

4. 让接口变成AI能力:AiServices的结构化输出、工具调用与记忆

4.1 结构化输出:让模型直接返回POJO和枚举,而不是JSON字符串

很多 Java 开发者第一次接大模型都会被一个问题搞疯:怎么让模型稳定返回 JSON?常见做法是在 prompt 里写“请以 JSON 格式返回”,然后在代码里解析,再写一堆 try-catch 处理模型偶尔抽风的情况。

LangChain4j 的AiServices提供了一种更优雅的方式。你定义一个接口,用注解声明规则,框架自动帮你完成提示词构造和结果解析。比如做一个情感分析:

import dev.langchain4j.service.AiServices; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE } interface SentimentAnalyzer { @SystemMessage("你是情感分析专家,只能返回POSITIVE、NEUTRAL或NEGATIVE。") Sentiment analyze(@UserMessage String text); } // 使用 SentimentAnalyzer analyzer = AiServices.builder(SentimentAnalyzer.class) .chatLanguageModel(model) .build(); Sentiment result = analyzer.analyze("这个产品太好用了,强烈推荐!"); System.out.println(result); // POSITIVE

我当初第一次跑通这段代码时,最震撼的点在于:框架真的能解析枚举,完全不用我手写正则。它内部其实就是帮你构造了一个带格式要求的高质量 prompt,再用模型的 JSON 模式约束输出,最后反序列化成目标类型。你可以把返回类型换成自定义 record,比如:

record ProductInfo(String name, BigDecimal price, String brand) {} interface ProductExtractor { @SystemMessage("从用户描述中提取商品信息,只输出事实,不要猜测。") ProductInfo extract(@UserMessage String text); }

如果模型偶尔解析失败,框架会抛出OutputParsingException,你可以在调用处捕获并降级重试。结构化输出实际是“提示词工程 + 数据解析”的组合,不是模型保证万能,这一点后面避坑部分我还会展开。

4.2 工具调用:让模型具备计算、查询和执行能力

大模型本身不会做数学计算,也不会查数据库,但你可以给它“工具”。LangChain4j 里在方法上标一个@Tool注解,该方法就会暴露给模型,模型在需要时发起调用,框架负责把调用结果回传给模型,形成一次完整循环。

举个最经典的例子——给模型一个加法计算器:

import dev.langchain4j.agent.tool.Tool; public class CalculatorTools { @Tool("计算两个整数相加") public int add(int a, int b) { return a + b; } } interface MathAssistant { String chat(@UserMessage String message); } MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .build(); String answer = assistant.chat("请帮我计算 123 + 456 等于多少"); System.out.println(answer);

你不会真的看到“模型去调函数”这个动作,实际过程是:模型收到问题后,根据@Tool注解里的描述决定调用add,框架执行方法得到579,构造一条ToolMessage再发回模型,模型看到结果后才给出最终回答。整个循环是自动的,用户无感知。

一个有用的经验:@Tool的描述写得越具体,模型越知道什么时候该用。比如只写“计算两个整数相加”就比“加法”好很多。如果工具参数要表达复杂语义,可以直接用 record 作为入参类型,LangChain4j 会把它序列化成模型需要的 JSON Schema。实际项目里,我给这个模式接通过库存查询、订单状态查询、权限校验,相当于把一个标准的“函数输出”变成了模型可用的“能力”。

4.3 会话记忆:用ChatMemory解决多轮对话失忆问题

如果你只用上一节的接口,每调一次就是一次独立会话,模型不会记得你上一句说了什么。要让 AI 有“记忆”,就需要引入ChatMemory。LangChain4j 提供了MessageWindowChatMemory,它就像一个滑动窗口,只保留最近 N 条消息:

import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; interface ChatAssistant { String chat(@UserMessage String message); } ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); ChatAssistant assistant = AiServices.builder(ChatAssistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build(); System.out.println(assistant.chat("我叫小明,喜欢喝美式咖啡")); System.out.println(assistant.chat("我叫什么?爱喝什么?"));

第二次调用时,模型能够正确回答“你叫小明,喜欢喝美式咖啡”。maxMessages(20)表示窗口内最多保留 20 条消息,超出后最老的消息会被丢弃。这个机制也暗示了一个事实:记忆只是“把历史消息重新发给模型”,所以窗口越大越贵,也越容易超长。如果你的对话很长,建议按业务需求动态调整窗口大小,而不是无脑设置一个很大的数。

多用户场景还要注意:如果ChatMemory是全局单例,所有用户会共享上下文,互相污染。正确做法是按用户或会话维度创建独立的 memory 实例,并定期清理不活跃会话。

5. 给模型装一个内部知识库:RAG的最小落地与参数调优

5.1 RAG是什么,什么时候必须用它

大模型的知识截止于训练数据,公司内部的制度文档、产品手册、私有代码库,它一概不知道。想要让模型基于这些资料回答问题,有两个思路:一是微调,成本高、周期长,只适合大规模固定知识;二是 RAG(Retrieval-Augmented Generation,检索增强生成),先检索出相关文档片段,把片段拼到 prompt 里再让模型回答。

大多数业务场景选 RAG 就对了。它不需要训练模型,新增文档随时入库,来源可追溯,模型答错了还能排查是检索问题还是生成问题。我做的第一个 RAG 项目就是一个内部客服问答机器人,把几百份常见问题文档切块后向量化,用户提问时先召回最相关的几个片段,再由模型组织回答,准确率比纯靠 prompt 高了一大截。

5.2 Embedding 模型与向量存储是 RAG 的两个轮子

RAG 依赖两个核心组件:EmbeddingModel负责把文本转成向量,EmbeddingStore负责存向量并做相似度检索。

LangChain4j 中,OpenAI 的text-embedding-3-small是目前性价比很高的选择:

import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("text-embedding-3-small") .build();

向量存储方面,InMemoryEmbeddingStore适合中小规模场景,数据量不大时直接用这个就够了。生产环境可以换成 Redis、PGvector、Elasticsearch 这类支持向量索引的存储,LangChain4j 都有对应的模块,接口保持一致。

5.3 一个最小可用的文档问答流程

完整流程分五步:读取文档、切分文本、向量化、存入向量库、检索并回答。我写一个最精简的版本:

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.service.AiServices; import dev.langchain4j.service.UserMessage; // 1. 准备文档 Document doc = Document.from("LangChain4j是一个Java生态的大模型应用框架..." + "它支持OpenAI、Ollama、Gemini等多个模型供应商..." + "通过AiServices可以快速实现结构化输出和工具调用。"); // 2. 切分:每500字符一块,前后重叠50字符 List<TextSegment> segments = DocumentSplitters.recursive(500, 50) .split(doc) .stream() .map(d -> TextSegment.from(d.text())) .toList(); // 3. 向量化 List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); // 4. 存向量库 EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); embeddingStore.addAll(embeddings, segments); // 5. 构建检索器并组装问答接口 EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.65) .build(); interface KnowledgeAssistant { String answer(@UserMessage String question); } KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class) .chatLanguageModel(model) .contentRetriever(retriever) .build(); String answer = assistant.answer("LangChain4j支持哪些模型供应商?"); System.out.println(answer);

走完这五步,你就拥有一个能回答私有知识问题的机器人了。实际项目中,文档来源可能是 PDF、Word 或数据库记录,需要按数据源写不同的 loader。LangChain4j 的langchain4j-document-parser系列模块对常见格式已经很友好,能省不少事。

5.4 召回参数怎么调到靠谱的水平

RAG 效果差,十有八九不是模型问题,而是检索参数没配好。下面这几个参数是我反复调过之后总结的初始值和调优方向:

参数作用建议初始值调优经验
切块大小单段文本长度,决定向量化的粒度500 字符太短容易把一句话拆断,太长检索噪音大
重叠字符数前后两块的重复字符50防止关键信息恰好在切缝处被拆散
maxResults召回片段数量3-5太少可能漏信息,太多会撑爆上下文还引入噪音
minScore相似度阈值,低于该值的片段不召回0.6-0.7阈值越高越精准,但召回量越少

一个我特别想强调的细节:RAG 的 prompt 设计也很重要。不要只把文档片段原样拼进去,最好在系统提示词里明确写“只能根据提供的资料回答,资料中没有的信息要如实说明不知道”。否则模型一旦自信地编造,RAG 的溯源优势就全没了。

6. 我踩过的坑和排查思路:LangChain4j 开发中的五个真实问题

6.1 Maven 依赖冲突导致 NoSuchMethodError

现象是代码编译没问题,一运行就抛NoSuchMethodError,指向某个 LangChain4j 内部类的方法。这种错误八成是多个 LangChain4j 模块版本不一致导致的,比如核心模块是 0.34,OpenAI 模块却是 0.35,两个版本的内部类不兼容。

排查思路很简单:在pom.xml里运行mvn dependency:tree,把所有dev.langchain4j开头的依赖版本对齐。我是用一个properties变量统一管理版本号的:

<properties> <langchain4j.version>0.36.2</langchain4j.version> </properties>

所有 LangChain4j 模块都引用${langchain4j.version},从根上避免版本漂移。

6.2 上下文超长,模型直接报 400 或“maximum context length”错误

当聊天窗口maxMessages设得过大,或者某个文档被整段塞进 prompt,token 数会超过模型上限,报错信息通常很明确,一眼就能定位。但更难查的是文档切片没生效,把整本文档当成一个DocumentSegment提交了。我遇到过同事把 PDF 全文直接作为 prompt 传给模型,10 万 token 直接打爆。

预防办法有两个:一是所有待输入文本必须经过切分,二是对单次请求增加 token 计数保护。LangChain4j 的TokenCountEstimator接口可以在请求前估算 token 数,超过阈值就直接走降级策略,而不是硬着头皮发请求。

6.3 结构化输出不稳定,模型偶尔返回废话

用AiServices定义枚举或 POJO 返回时,绝大多数请求都能正确解析,但偶尔模型会不按规则来,比如在 JSON 前后加一句解释。框架会抛出OutputParsingException,如果你没捕获,整个请求就失败了。

我的应对方案是三层兜底。第一层,在@SystemMessage里写得足够强硬,明确“只输出JSON,不要任何解释”。第二层,调用处捕获OutputParsingException,用稍低的 temperature 重试一次。第三层,如果结构化输出是核心链路,返回类型尽量用包含String rawOutput的 record 作为兜底,把原始文本也保留下来,宁可业务侧自行处理,也不要让数据丢。

6.4 工具对象本身要考虑线程安全和无状态

在用AiServices.builder().tools(new CalculatorTools())时,如果这个Assistant是全局单例,那么CalculatorTools实例也会被多线程共享。如果你的工具方法里有内部可变状态,比如一个累加计数器,会在并发场景下出现各种诡异结果。

经验之谈:工具类务必保持无状态,所有的入参通过方法参数传进来,方法内部不要依赖成员变量。如果工具必须访问外部服务,把外部服务作为依赖注入,确保它本身是线程安全的。

6.5 不同模型供应商之间的参数差异

LangChain4j 抽象了供应商差异,但没完全抹平全部差异。比如 OpenAI 的gpt-4o-mini支持 JSON 输出模式,但某些开源本地模型在这方面的表现就不稳定;minScore阈值对不同的 embedding 模型也不能用同一个经验值。

我踩过最实际的坑是:同一套 RAG 代码,从 OpenAI 切到 Ollama 本地模型后,召回质量明显下降。最后发现不是因为代码逻辑变了,而是本地模型的 embedding 向量维度和语义能力弱于商用模型。解决方式也很朴素——针对不同模型单独调召回参数,并建立一份参数对比表,切换模型时直接查表,而不是凭感觉改。

一些收尾的私人建议

如果让我给刚入门的人提一条最实在的建议,就是不要一上来就追着 LangChain4j 的高级功能跑。先把一个最基础的model.chat()跑通,再去折腾流式输出和结构化输出,然后才是工具调用和 RAG。这个顺序不是按难度排的,而是按依赖关系排的,每一步踩的坑都会成为下一步的认知基础。

还有一个很实际的小提醒:在你的日志里把每次调用的模型名、输入 token 数、输出 token 数、耗时全打出来。有了这些日志,你才能在线上环境快速判断问题到底出在模型、检索还是你自己的代码。我靠这套日志排查过不少线上问题,也靠着它说服过同事不要盲目加大模型参数。LangChain4j 的模型供应商模块里大多提供.logRequests(true)和.logResponses(true)这类开关,本地调试时打开,开发环境部署前记得关掉,避免敏感信息泄露。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进