ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LangChain4j从入门到实战:Java大模型应用开发指南

LangChain4j从入门到实战:Java大模型应用开发指南

LangChain4j 这个名字,最近在 Java 技术社区里出现的频率明显高了起来。很多后端工程师应该都有同样的困惑:Python 生态里大模型开发框架已经满天飞,Java 这边却一直没什么趁手的工具,直到 LangChain4j 出现,才算是补齐了这块拼图。它不是一个简单的 API 封装,而是把大模型接入、提示词管理、上下文记忆、工具调用、知识库检索这些能力,以 Java 工程师熟悉的方式全部工程化了。如果你正在用 Spring Boot 做业务系统,想把大语言模型的能力真正落到订单处理、客服问答、文档分析这些场景里,这篇实战教程就是给你准备的。我会从一个最小可运行的 Demo 开始,带你梳理清楚 AiServices、ChatMemory、@Tool 注解这些核心概念,最后用一个多路召回的知识库问答案例收尾。

1. LangChain4j 为什么是Java工程师入坑大模型应用的第一站

1.1 Java生态和LLM之间缺的那块拼图

先说一个很现实的问题:过去两年里,Java 工程师想接入大模型,最常见的做法是什么?直接引入 HTTP 客户端,对着 OpenAI 兼容接口拼 JSON 请求。项目规模小的时候还好,一旦涉及多轮对话、工具调用、知识库召回,你很快就会发现自己写了一大堆重复代码来维护消息列表、解析 JSON、处理超时和重试,而且每换一家模型厂商就要重写一遍。

Python 那边有 LangChain、LlamaIndex 这样的框架,把这些问题都抽象好了。Java 这边长期处于裸调 SDK 的状态,直到 LangChain4j 出现。它并不是把 Python 的 LangChain 机械翻译成 Java,而是按照 Java 社区的工程习惯重新设计了一整套 API。核心目标只有一个:让开发者用声明式的方式接入大模型,而不是自己维护那些繁琐的底层逻辑。

我在实际项目里最大的感受是,LangChain4j 解决的不只是“调通 API”的问题,而是“如何把大模型变成 Java 系统里一个标准组件”的问题。它提供了统一的模型接口、提示词模板、多轮记忆、工具调用、向量检索这些能力,并且能和 Spring Boot 无缝整合。你完全可以把一个聊天助手、一个信息抽取服务、一个知识库问答接口,当作普通的 Java Bean 去编写和注入。

1.2 LangChain4j的核心组成与运行逻辑

LangChain4j 的整体设计遵循一种模块化结构。langchain4j-core提供核心抽象接口,langchain4j是聚合包,方便一键引入。针对不同模型厂商,它又提供了独立模块,比如langchain4j-open-ai、langchain4j-ollama、langchain4j-azure-open-ai,还包括国产模型厂商的适配模块。这种设计与 JDBC 的思路非常像:上层业务只面向ChatLanguageModel这个统一接口编程,底层实现由不同模块提供,切换模型厂商时不需要改动业务代码。

运行逻辑也不复杂。一次完整的大模型交互大致是这样的:你构建一个包含系统提示词、历史消息、用户当前输入的消息列表,交给ChatLanguageModel发送给模型;模型返回文本响应;如果涉及工具调用,框架会解析模型输出的工具调用指令,执行对应的 Java 方法,把执行结果再作为一条消息发回给模型,直到模型给出最终回答。

理解这条链路,后面看代码就不会觉得神奇了。LangChain4j 的所有高级功能,比如记忆、工具、检索增强,本质上都是在给这条消息链路增加“上下文”和“能力扩展”。它没有改变大模型本身的工作方式,而是帮你管理了模型之外的所有工程细节。

1.3 版本选择:别再盯着旧教程抄了

LangChain4j 的版本迭代速度相当快,早期 0.x 版本和 1.x 版本之间的 API 差异不小。很多网上的旧教程还在用 0.3x 的写法,如果你直接把代码抄到新版本项目里,大概率会碰到编译错误。我的建议是:新建项目时直接用 Maven Central 上的最新稳定版,不要拘泥于某一个具体版本号。

在 JDK 版本上,LangChain4j 的 1.x 版本普遍要求 JDK 17 及以上。如果你们公司还在用 JDK 8,也不是完全不能用,但会遇到更多兼容性问题。我自己的习惯是:本地开发用 JDK 17,Spring Boot 项目保持 3.x 版本,这样整合起来最顺滑。另外需要注意,OpenAiChatModel这个核心类在旧版本里叫OpenAiChatModel,但在更早的版本中有过构造方式的变化,碰到编译报错时优先去官方 GitHub 仓库看最新的 README,而不是去搜索引擎找那些两年前的帖子。

2. 环境搭建与第一个能跑的Demo

2.1 引入依赖:最少需要哪几个包

先用 Maven 项目演示。创建一个最基础的 Java 项目,在pom.xml里加上 LangChain4j 的核心依赖和一个厂商适配模块:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.0.0</version> </dependency>

这里的langchain4j-open-ai模块提供的其实是一个 OpenAI 兼容协议的客户端。现在很多本地模型服务,比如 Ollama、vLLM、FastGPT,都支持 OpenAI 兼容接口。也就是说,你并不一定需要真的注册 OpenAI 的付费服务,只要有一个兼容协议的 HTTP 端点,LangChain4j 就能正常工作。

如果要用本地模型,还可以引入langchain4j-ollama模块。但我的建议是:入门阶段先用 OpenAI 兼容协议把你的模型服务包一层,这样后续切换非常灵活,不需要改代码。等真正理解了核心概念,再去看厂商定制模块。

2.2 模型配置:OpenAI兼容接口与本地模型

配置模型的方式非常直观,使用 Builder 模式:

ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("demo") .baseUrl("http://localhost:11434/v1") .modelName("qwen2.5:7b") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build();

这段配置的意思是:模型服务跑在本机的 11434 端口,也就是 Ollama 的默认端口;/v1路径让 Ollama 暴露 OpenAI 兼容接口;modelName是本地已经拉取好的模型名称。

这里有两个细节值得展开。第一,apiKey在本地服务场景下可以随便填,因为服务端不校验。如果你用的是云厂商服务,这里必须正确填写你的密钥,并且要保证你的运行环境网络策略允许访问该服务。第二,temperature控制随机性,值越大回答越发散,值越小越确定。做知识库问答、信息抽取这类任务时我习惯调到 0.1 到 0.3,做文案生成再调高一些。

2.3 第一段聊天代码:从字符串到多消息

配置好模型对象后,最简单的调用方式是这样:

String answer = model.chat("用一句话解释什么是AOP"); System.out.println(answer);

一行代码就能得到模型的文本回答。源码层面,这个重载内部会把纯字符串包装成UserMessage,再调用完整的消息接口。

真正生产级的调用方式,应该从消息列表开始:

List<ChatMessage> messages = List.of( SystemMessage.from("你是资深Java架构师,回答要简洁、专业,优先给出代码示例。"), UserMessage.from("什么是面向切面编程?") ); ChatResponse response = model.chat(messages); System.out.println(response.aiMessage().text());

SystemMessage用来设置系统级指令,UserMessage代表用户输入,AiMessage代表模型输出。这其实就是大模型对话的本质:一个不断扩张的消息数组。LangChain4j 的ChatLanguageModel接口把这套过程规范化了,你不需要手动拼接 JSON 请求体。

2.4 为什么要做这一层抽象

有同学可能会问:我都已经会写 HTTP 客户端了,直接用 RestTemplate 调用模型接口不就行了吗?为什么要引入框架?

核心原因是可维护性。裸调接口时,模型返回的内容你需要手动解析,错误码要自己处理,多轮消息要自己维护,切换模型厂商要重写整个调用层。而ChatLanguageModel把这一切统一到了一个接口后面。你今天用 Ollama 本地模型做开发,上线前换成一款商业模型服务,只需要改配置,业务代码一行都不用动。对于要长期维护的企业级系统来说,这个价值怎么强调都不过分。

3. AiServices:把大模型改造成业务接口

3.1 定义一个“会聊天”的Java接口

LangChain4j 里最精彩的设计就是这个AiServices。它的思路是:把大模型能力封装成一个 Java 接口,你在接口上定义方法并标注提示词,框架会在运行时生成代理实现。底层所有细节都被隐藏起来了,调用方拿到的只是一个普通的 Java Bean。

看一个最简单的例子:

interface Assistant { String chat(String message); } ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("demo") .baseUrl("http://localhost:11434/v1") .modelName("qwen2.5:7b") .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); String answer = assistant.chat("帮我写一个冒泡排序"); System.out.println(answer);

这里AiServices.builder()会检查接口的方法签名,生成一个实现了Assistant接口的代理对象。当你调用assistant.chat(...)时,框架自动把方法参数包装成用户消息、发送给模型、把模型输出转换成字符串返回。

这个方法的好处是:业务代码完全不感知“对面是一个大模型”。你今天可以把Assistant替换成另一个实现类,明天也可以切换底层模型,对上层调用方透明。

3.2 SystemMessage与UserMessage模板分离

真实场景里,系统提示词通常很长,用户输入格式也不是固定的一句话。LangChain4j 提供了@SystemMessage和@UserMessage注解来管理这两部分。

interface Assistant { @SystemMessage("你是订单客服助手,只能回答与订单查询、退换货、物流相关的问题。") String chat(String userMessage); @UserMessage("请从下面这段用户评价中提取商品名称、购买数量和对物流的满意度。评价内容:{{text}}") String extractCommentInfo(String text); }

{{text}}是模板变量,会自动替换成方法参数的值。如果方法有多个参数,可以给参数注解@V("参数名"),在模板中按名字引用。把系统提示词和用户提示词分开管理后,后续调整提示词就不用改动 Java 代码里的业务逻辑,也方便把模板内容挪到配置中心。

一个小经验:提示词模板不要写太长。一旦超过几百字,模型的注意力可能被引导词干扰,输出不稳定。保持指令明确、示例精简,是最稳妥的做法。

3.3 结构化输出的正确姿势

调用大模型时最烦的一件事就是输出格式不可控。明明要求返回 JSON,模型偶尔会在前后多输出几句解释。LangChain4j 对这件事的处理是:让方法返回类型直接声明为 POJO 或List<POJO>,框架会强制模型按 JSON 输出,然后自动反序列化。

record OrderInfo(String customerName, String productName, int quantity, String totalAmount) {} interface Assistant { @UserMessage("从用户话术中抽取订单信息:{{text}}") OrderInfo extractOrder(String text); } OrderInfo info = assistant.extractOrder("帮我看看张三昨天买的那个机械键盘,买了两个,一共598元"); System.out.println(info.productName());

这段代码背后,框架会给模型发送“你必须输出 JSON,并且匹配给定 schema”的指令,然后把模型返回的文本用 Jackson 解析成OrderInfo对象。如果解析失败,框架还会做一次自动重试。字段命名越简单越好,别用中文名,也别塞复杂的嵌套结构,复杂 JSON 不仅容易触发表字段歧义,还会增加模型输出格式错误率。

3.4 与Spring Boot结合使用的骨架

在 Spring Boot 项目里,你只需要把ChatLanguageModel和Assistant都声明成 Bean:

@Configuration public class LlmConfig { @Bean public ChatLanguageModel chatLanguageModel( @Value("${llm.base-url}") String baseUrl, @Value("${llm.model-name}") String modelName, @Value("${llm.api-key:demo}") String apiKey) { return OpenAiChatModel.builder() .baseUrl(baseUrl) .modelName(modelName) .apiKey(apiKey) .temperature(0.2) .timeout(Duration.ofSeconds(60)) .build(); } @Bean public Assistant assistant(ChatLanguageModel model) { return AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); } }

配置写在application.yml里,不同环境切换模型服务时只改配置,不重新打包。这是最基础也是最实用的整合姿势。

4. 让AI记住上下文,并学会使用工具

4.1 ChatMemory:从单轮到多轮

默认情况下,Assistant接口的每次调用都是独立无状态的,模型不会记得上一轮说了什么。要想支持多轮对话,必须引入ChatMemory。

ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(20); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build();

MessageWindowChatMemory的含义是:只保留最近 N 条消息,超出范围的老消息自动丢弃。这个窗口大小很有讲究。设得太小,模型记不住关键信息;设得太大,每次请求都会携带大量历史消息,消耗更多 Token,还可能撞上模型的上下文长度上限。我实测下来,20 到 30 条是一个比较均衡的范围。

如果系统里有多个用户,就不能用单例的ChatMemory,因为会话之间会互相串记忆。正确做法是使用ChatMemoryProvider,按会话 ID 分别维护记忆:

ChatMemoryProvider provider = chatMemoryId -> MessageWindowChatMemory.withMaxMessages(20);

这样一个用户一个记忆空间,互不干扰。

4.2 @Tool:给模型一把查询数据库的钥匙

大模型最擅长的是语言理解,但它没有访问你数据库的能力。LangChain4j 的做法是提供一个@Tool注解,你写好一个普通 Java 方法,模型在回答过程中如果觉得需要外部信息,可以主动“调用”这个方法。

class OrderTools { @Tool("根据订单号查询订单的状态,返回订单状态文本") public String getOrderStatus(String orderId) { // 这里可以查数据库、调外部接口 return "订单已发货,预计2天后送达"; } }

使用方式很简单,构建代理时把工具对象传进去:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderTools()) .build();

当用户问“我的订单 od12345 现在什么状态”,模型不会自己编造答案,而是生成一个调用getOrderStatus的指令,框架执行这个方法,拿到返回值后再拼接到对话上下文中,让模型基于真实数据组织回答。

这个机制有个很形象的说法:模型不直接干活的,它是“项目经理”,你写好的工具方法是“执行员工”。模型负责拆解任务、判断该调用哪个工具,Java 代码负责真正执行操作。把数据库查询、API 请求、业务计算都封装成工具,就等于无限扩展了模型的能力边界。

顺着这个思路,你可以把 MyBatis-Plus 的 Mapper 注入到工具类里,让模型通过工具方法完成真实的数据查询。比如定义一个searchProductByKeyword方法,内部执行productMapper.selectList(...),返回 JSON 字符串给模型。ToStringCalculator。整体效果比起裸调接口,稳定性和可维护性都有了质的提升。

4.3 工具调用的几个经验教训

工具调用听起来简单,实际踩坑的地方不少。我先说三个最常见的。

第一,工具描述必须非常具体。@Tool注解里的描述是模型判断“何时调用这个工具”的唯一依据。你写“查询库存”太模糊,模型可能在整个对话中根本不触发。写成“根据商品SKU编码查询该商品当前剩余库存数量,返回数字字符串”这样带条件和返回格式的描述,触发率会高很多。

第二,方法参数类型尽量简单。模型输出工具参数时本质是 JSON,它会把自然语言转成匹配的字段。如果你的方法签名是复杂对象,解析失败的概率会明显上升。字符串、整数、布尔值是最稳妥的选择。

第三,工具方法的返回值不能太长。如果你把整个数据库表记录都序列化成 JSON 返回给模型,在线Token 限制会导致模型“记不住”信息。返回前先截断、聚合摘要,只要保留模型组织回答所需的关键字段就行。比如查询商品列表时,返回商品名、价格、库存三个字段,而不是把所有明细字段全塞回去。

5. 实战:做一个多路召回的知识库问答

5.1 多路召回到底在解决什么问题

知识库问答是目前大模型落地最成熟的场景之一。用户提问后,系统从知识库中检索出相关片段,把片段作为参考上下文发给大模型,让它基于这些内容作答。这里的核心难点在检索环节。

单一路径的检索都有明显短板。纯向量检索用 Embedding 模型计算语义相似度,优势是能处理“没提关键词但意思相近”的查询,但对精确匹配很弱,比如产品或合同编号这种字符串,语义向量反而容易把相似的编号弄混。纯关键词检索精确匹配很强,但遇到同义词、口语化表达就完全失效。

多路召回的思路就是:同一条用户问题,同时走向量检索、关键词检索等多个通道,每个通道返回一批候选文档,然后用一个合并策略把这些结果综合排序,取出最终的前 N 条。虽然实现上多写了一些代码,但在真实业务里的命中率提升非常明显。这也是最近很多资深工程师面试里常被问到的一个设计题。

5.2 文档切分与向量化

做检索之前,先把原始文档切成更小的片段。切分粒度决定了检索的精度:切得太粗,一个片段里有多个主题,检索命中后噪音很大;切得太细,上下文碎片化,模型理解困难。我常用的策略是按固定大小切分,每个片段 500 到 800 个字符,片段之间重叠 100 个字符,保证跨边界的信息不丢失。

Document document = Document.from(articleText); DocumentSplitter splitter = DocumentSplitters.recursive(800, 100); List<TextSegment> segments = splitter.split(document);

向量化这一步需要选择一个 Embedding 模型。入门阶段可以用本地模型AllMiniLmL6V2EmbeddingModel,它不需要联网,语言学名,兼容性也不错。如果是中文场景,生产环境我更建议使用专门的中文 Embedding 模型,比如 BGE 系列,中文语义效果会明显更好。

向量化后的结果要存到向量数据库里。最简单的实验方案是InMemoryEmbeddingStore,重启数据就没了,但用来验证流程很方便。生产环境有更多选择,Qdrant、Milvus、Pinecone,都可以通过 LangChain4j 的模块接进来。

EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); EmbeddingStore<TextSegment> vectorStore = new InMemoryEmbeddingStore<>(); // 入库 for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment).content(); vectorStore.add(embedding, segment); } // 向量召回 Embedding queryEmbedding = embeddingModel.embed(question).content(); List<EmbeddingMatch<TextSegment>> relevant = vectorStore.findRelevant(queryEmbedding, 5);

5.3 向量召回通道

向量召回本身就是一条标准的检索通道。上面的代码已经完成了基本流程:把文档片段向量化入库,查询时把用户问题也转成向量,然后用余弦相似度找最相近的 5 条记录。

LangChain4j 还提供了一个更高级的封装,叫ContentRetriever,你可以把向量召回包装成一个检索器,直接塞给AiServices。这样知识库问答的整体链路就更清晰了:用户问题进来,自动触发检索器,把召回结果拼接到系统提示词里,再交给模型回答。

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(vectorStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build();

minScore是个容易踩坑的参数。官方默认值有时对某些模型太苛刻,导致什么都召不回来。它的语义是相似度阈值,但不同 Embedding 模型的分数分布差异很大,我习惯先把日志打开看一批分数,再设置合理的阈值,而不是拍脑袋填一个数。

5.4 关键词召回通道与评分合并

关键词通道在生产环境通常用 Elasticsearch 的 BM25 评分,或者 PostgreSQL、MySQL 的全文索引。为了演示方便,这里用最简单的关键词命中计数来说明:

List<TextSegment> keywordRetrieval(String question, List<TextSegment> segments, int topK) { String[] keywords = question.split("[\\s,。、?,.?!]+"); return segments.stream() .sorted(Comparator.comparingInt((TextSegment s) -> (int) Arrays.stream(keywords).filter(k -> s.text().contains(k)).count()).reversed()) .limit(topK) .collect(Collectors.toList()); }

这段代码虽然粗糙,但能说明关键词召回通道的本质:按文本命中情况给候选片段打分排序。真实项目里直接换成搜索引擎或数据库全文索引就可以了。

多路召回以后,最关键的步骤是合并两个通道的结果。直接对比向量相似度和关键词命中次数是不公平的,因为两个通道的分数量纲完全不同。我推荐用 RRF(Reciprocal Rank Fusion)方法,它不看具体分数,只看每条候选在各自通道里的排名:

对每条文档,计算其排名倒数的和,排名第 1 贡献 1 分,第 2 贡献 0.5 分,以此类推,然后按总分重新排序。

score(d) = sum(1 / (k + rank_i(d))) 其中 k 取 60

这个公式的巧妙之处在于既不用考虑分数归一化,也不用调权重,只要多个通道的排名可靠,融合效果就非常稳定。手动算一个例子:某文档在向量通道排第 1,在关键词通道排第 3,则它的得分为1/61 + 1/63 ≈ 0.0323。另一文档在两个通道都排第 2,得分为1/62 + 1/62 ≈ 0.0323,两边基本持平。排名越靠前,贡献越大,并且不再受原始分数分布影响。

5.5 在Spring Boot里跑通完整流程

到这里,整条多路召回链路已经清晰了。在 Spring Boot 中组装成服务,大概长这样:

@Service public class KnowledgeBaseService { private final Assistant assistant; public KnowledgeBaseService( ChatLanguageModel model, EmbeddingStore<TextSegment> vectorStore, EmbeddingModel embeddingModel, List<TextSegment> corpus) { ContentRetriever vectorRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(vectorStore) .embeddingModel(embeddingModel) .maxResults(5) .build(); // 两个召回通道的结果合并策略 ContentRetriever hybridRetriever = (question) -> { List<Content> vectorResults = vectorRetriever.retrieve(question); List<TextSegment> keywordResults = keywordRetrieval(question, corpus, 5); return mergeByRrf(vectorResults, keywordResults, 4); }; Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(hybridRetriever) .build(); this.assistant = assistant; } public String answer(String question) { return assistant.chat(question); } }

mergeByRrf方法按照上一节讲的 RRF 思路实现:把两路结果的排名合并,取前 4 条,再拼接到提示词里。系统提示词里建议加上一段约束:“请仅根据提供的参考资料回答,如果参考资料中没有答案,请直接告诉用户不知道”。这样能明显降低模型编造答案的概率。

我自己的经验是,这个案例做成以后,最好不要停在 Demo 层面。把文档库、索引构建、检索参数都从配置中心管理起来,后续调整切分大小、召回数量、RRF 的参数,就不用改代码重新发版了。这一步做到位,才真正具备上线运维的价值。

6. 常见问题与排查实录

6.1 依赖冲突与版本坑

Spring Boot 项目里引入 LangChain4j,最常碰到的问题就是传递依赖冲突。尤其容易出在guava、jackson、reactor这几个库上。Spring Boot 自带了一套版本管理,LangChain4j 的传递依赖可能和它不一致,轻则运行时报错,重则启动直接失败。

遇到这类问题,第一件事就是在项目根目录执行mvn dependency:tree,把依赖树拉出来,找到版本冲突的具体位置。然后根据冲突情况决定是排除传递依赖,还是统一用更高版本。

版本差异引起的 API 变化也要留个心眼。网上老教程里的model.chat(messages)用法,在新版本里可能已经废弃,换成了model.chat(ChatRequest.builder()...)这样的新写法。编译报错时先去官方仓库看最新文档,不要盲目搜索历史帖子。

6.2 上下文超长和幻觉

模型服务报错里出现 “maximum context length” 之类的提示,含义是发送给模型的消息总长度超过了模型支持的上下文窗口。解决办法从两个方向入手:一是减少历史消息条数,把MessageWindowChatMemory.withMaxMessages(20)改成更小的值;二是缩短每条消息的内容,比如工具返回的 JSON 做截断,召回片段按数量限制。

幻觉问题更隐蔽,它不报错,但回答是错的。我的排查顺序是:先看提示词有没有强调“不知道就直说”,再看召回结果里是否包含正确答案。很多时候模型产生幻觉,是因为召回片段里根本没有相关信息,模型被“逼”着编了一个答案。这种情况下调检索参数比调提示词更有效。

6.3 工具调用“不听话”或者触发失败

工具不触发的排查顺序,我一般是这样:

第一步,检查@Tool描述是否足够具体。描述里包含“什么时候该调用”和“方法返回什么”,比只写方法名称效果好得多。第二步,确认使用的模型版本能力是否够强。某些小参数的模型在工具调用上表现很差,换一个能力更强的模型往往立刻见效。第三步,临时在系统提示词里明确要求“如果需要查询订单,请调用 getOrderStatus 工具”,先验证工具链路本身没问题,再去掉提示词做真实测试。

参数解析失败是另一个常见问题。模型生成的 JSON 参数和你的方法签名不匹配时,框架会抛出解析错误。解决办法是把工具方法参数类型简化,尽量用字符串接收,在方法内部再做类型转换。

6.4 召回结果融合:分数不可比

多路召回合并时,如果直接用原始分数相加,结果基本是错的。向量相似度可能是 0.7 到 0.9 的区间,BM25 分数可能是几十,关键词命中数可能是 0 到 10,三个分数直接相加,量纲大的通道会完全主导结果。我之前就踩过这个坑,把余弦相似度和两路结果加权相加,结果关键词通道排名前几的永远稳居前列,向量通道根本插不进来。

换用 RRF 只看排名,就不会有这个问题。如果还是要用加权分数,先做 Min-Max 归一化把每路分数压到 0 到 1 之间,再给不同通道赋予权重。权重不要拍脑袋定,拿一批真实问题的命中日志做实验,对比不同权重下的 Top 挺准率,选表现最好的配置。

6.5 网络与超时配置

模型服务第一次请求通常都比较慢,因为需要加载模型、建立连接。timeout设置太短,比如 10 秒,经常会在模型还没返回时就被客户端判定超时。我建议把超时时间至少设到 60 秒,然后再根据实际接口耗时调整。在 Spring Boot 里还可以配一个简单的重试策略,遇到 5xx 或连接超时最多重试两三次,但要注意别在业务高峰期造成模型服务的重复压力。

如果调用云厂商的服务总是连接失败,先检查运行环境的网络策略、出口防火墙和 SSL 证书配置,这些环境问题排查起来往往比代码问题更耗时。本地模型服务就省心很多,把baseUrl指向本机服务,通网不通网都可以开发调试。

我个人在实际摸索中的体会是,LangChain4j 这把工具,最大的价值不是省掉几行代码,而是把大模型应用开发这件事重新拉回到了 Java 工程师熟悉的轨道上。先把最小链路跑通,再把工具调用和多路召回这两个能力吃透,就已经跨过入门到实战之间最大的坎了。最后分享一个小技巧:给项目加一个--llm.model-name之类的启动参数,或者直接用 Spring Boot 的 profile 区分本地模型和云端模型,开发期用本地小模型调试逻辑,上线切换时成本几乎为零。这一步看着不起眼,长期调试下来能帮你省下大量时间和钱。

RELATED READING

延伸阅读

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