Java开发者LLM实战——LangChain4j最新版教学知识库实战
介绍
官网:https://docs.langchain4j.dev/
LangChain4j 的目标是简化与 Java 应用程序 集成大模型。
特性:
- 统一 API: LLM提供程序(如 OpenAI 或 阿里百炼)和嵌入(向量)存储(如 redis 或 ES) 使用专有 API。LangChain4j 提供了一个统一的 API,以避免为每个 API 学习和实现特定的 API。 要试验不同的LLMs存储或嵌入的存储,您可以在它们之间轻松切换,而无需重新编写代码。 LangChain4j 目前支持 15+ 热门LLM 和 20+ 嵌入模型。
- jdk版本:v0.35.0可以在jdk1.8, v0.36.0+迁移到jdk17 , 所以最新版本必须jdk17+
langchain4j vs springAI
维度 | Spring AI | LangChain4j |
技术栈绑定 | 强依赖 Spring 生态 | 无框架依赖,可独立使用 |
适用场景 | SpringBoot应用快速接入单模型 | 多模型(动态模型)平台 |
初识LangChain4j(纯java)
接下来,让我们与LangChain4j初识一下,新建一个Maven工程,然后添加以下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.xs</groupId><artifactId>langchain4j-demo</artifactId><version>1.0-SNAPSHOT</version><properties><java.version>17</java.version><langchain4j.version>1.4.0</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></project>
引入了langchain4j的核心依赖、langchain4j集成OpenAi各个模型的依赖。和OpenAi的第一次对话
@Testpublic void test1() {OpenAiChatModel model = OpenAiChatModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").modelName("gpt-4o-mini").build();String answer = model.chat("Say '圆形的面积怎么计算?'");System.out.println(answer);}
你会发现, LangChain4j 对于初次接入大模型的开发者来说十分友好,不需要指定模型,不需要指定apikey, 即可对接大模型进行对话,这是怎么做到的呢?
其实我们对ApiKey为"demo" , 底层会做这些事情:
public OpenAiChatModel(String baseUrl, String apiKey, String organizationId, String modelName, Double temperature, Double topP, List<String> stop, Integer maxTokens, Double presencePenalty, Double frequencyPenalty, Map<String, Integer> logitBias, String responseFormat, Integer seed, String user, Duration timeout, Integer maxRetries, Proxy proxy, Boolean logRequests, Boolean logResponses, Tokenizer tokenizer) {baseUrl = (String)Utils.getOrDefault(baseUrl, "https://api.openai.com/v1");if ("demo".equals(apiKey)) {baseUrl = "http://langchain4j.dev/demo/openai/v1";}//其他代码
}
在底层在构造OpenAiChatModel时,会判断传入的ApiKey是否等于"demo",如果等于会将OpenAi的原始API地址"https://api.openai.com/v1"改为"http://langchain4j.dev/demo/openai/v1",这个地址是langchain4j专门为我们准备的一个体验地址,实际上这个地址相当于是"https://api.openai.com/v1"的代理,我们请求代理时,代理会去调用真正的OpenAi接口,只不过代理会将自己的ApiKey传过去,从而拿到结果返回给我们。
所以,真正开发时,需要大家设置自己的apiKey或baseUrl,可以这么设置:
ChatLanguageModel model = OpenAiChatModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").build();
接入deepseek
@Testpublic void test3() {OpenAiChatModel model = OpenAiChatModel.builder().baseUrl("https://api.deepseek.com").apiKey("sk-6c2bdbc74").modelName("deepseek-chat").build();String answer = model.chat("Say '圆形的面积怎么计算?'");System.out.println(answer);}
文生图WanxImageModel
@Test
public void test() {WanxImageModel wanxImageModel = WanxImageModel.builder().modelName("wanx2.1-t2i-plus").apiKey(System.getenv("ALI_AI_KEY")).build();Response<Image> response = wanxImageModel.generate("美女");System.out.println(response.content().url());
}
文生语音
package com.xs.langchain4j_demos;import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesisParam;
import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesizer;import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;public class AudioTest {private static String model = "cosyvoice-v1";private static String voice = "longxiaochun";public static void streamAuidoDataToSpeaker() {SpeechSynthesisParam param =SpeechSynthesisParam.builder()// 若没有将API Key配置到环境变量中,需将下面这行代码注释放开,并将your-api-key替换为自己的API Key.apiKey(System.getenv("ALI_AI_KEY")).model(model).voice(voice).build();SpeechSynthesizer synthesizer = new SpeechSynthesizer(param, null);ByteBuffer audio = synthesizer.call("大家好我是徐庶?");File file = new File("output.mp3");try (FileOutputStream fos = new FileOutputStream(file)) {fos.write(audio.array());} catch (IOException e) {throw new RuntimeException(e);}}public static void main(String[] args) {streamAuidoDataToSpeaker();System.exit(0);}
}
整合SpringBoot
先引入SpringBoot:
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.4.9</version><relativePath/> </parent>
接入百炼官网:
DashScope (Qwen) | LangChain4j
<!--百炼--><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependencyManagement><dependencies><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-community-bom</artifactId><version>${langchain4j.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
Controller:
package com.xs.langchain4j_demos.controller;import dev.langchain4j.model.chat.ChatLanguageModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/ai")
public class AiController {@AutowiredChatLanguageModel qwenChatModel;@RequestMapping("/chat")public String test(@RequestParam(defaultValue="你是谁") String message) {String chat = qwenChatModel.chat(message);return chat;}
}
配置通义千问-Max模型:
langchain4j.community.dashscope.chatModel.apiKey=${ALI_AI_KEY}
langchain4j.community.dashscope.chatModel.modelName=qwen-plus
访问http://localhost:8080/ai/chat:
配置deepseek模型
langchain4j.community.dashscope.chatModel.apiKey=${ALI_AI_KEY}
langchain4j.community.dashscope.chatModel.modelName=deepseek-r1
访问http://localhost:8080/ai/chat:
接入Ollama
关于Ollama的本地部署: DeepSeek本地部署教程
官网:Ollama | LangChain4j
<!--Ollama--><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-ollama-spring-boot-starter</artifactId><version>${langchain4j.version}</version></dependency>
Controller:
package com.xs.langchain4j_demos.controller;import dev.langchain4j.model.chat.ChatLanguageModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/ai")
public class AiController {@AutowiredChatLanguageModel ollamaChatModel;@RequestMapping("/chat_ollama")public String chatOllama(@RequestParam(defaultValue="你是谁") String message) {String chat = ollamaChatModel.chat(message);return chat;}
}
配置通Deepseek模型:
langchain4j.community.dashscope.chatModel.apiKey=${ALI_AI_KEY}
langchain4j.community.dashscope.chatModel.modelName=qwen-plus
访问http://localhost:8080/ai/chat:
流式输出
因为langchain4j不是spring家族, 所以我们在wen应用中需要引入webflux
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
通过Flux进行流式响应
@RestController
@RequestMapping("/ai_other")
public class OtherAIController {@AutowiredStreamingChatLanguageModel qwenStreamingChatModel;@RequestMapping(value = "/stream_chat",produces ="text/stream;charset=UTF-8")public Flux<String> test(@RequestParam(defaultValue="你是谁") String message) {return Flux.create(sink -> {qwenStreamingChatModel.chat(message, new StreamingChatResponseHandler() {@Overridepublic void onPartialResponse(String partialResponse) {sink.next(partialResponse); // 逐次返回部分响应}@Overridepublic void onCompleteResponse(ChatResponse completeResponse) {sink.complete(); // 完成整个响应流}@Overridepublic void onError(Throwable error) {sink.error(error); // 异常处理}});});}
}
到这里你会发现, langchain4j毕竟不是spring家族, 和spring生态一起用真蹩脚。 还是springai舒服
记忆对话(多轮对话)
原生方式
大模型并不会把我们每次的对话存在服务端, 所以他记不住我们说的话
/*** 测试多轮对话——错误用法*/@Testvoid test03_bad() {ChatLanguageModel model = OpenAiChatModel.builder().apiKey("demo").modelName("gpt-4o-mini").build();System.out.println(model.chat("你好,我是徐庶老师"));System.out.println("----");System.out.println(model.chat("我叫什么"));}
响应:
所以每次对话都需要将之前的对话记录,都发给大模型, 这样才能知道我们之前说了什么:
/*** 测试多轮对话——正确用法*/@Testvoid test03_good() {ChatLanguageModel model = OpenAiChatModel.builder().apiKey("demo").modelName("gpt-4o-mini").build();UserMessage userMessage1 = UserMessage.userMessage("你好,我是徐庶");ChatResponse response1 = model.chat(userMessage1);AiMessage aiMessage1 = response1.aiMessage(); // 大模型的第一次响应System.out.println(aiMessage1.text());System.out.println("----");// 下面一行代码是重点ChatResponse response2 = model.chat(userMessage1, aiMessage1, UserMessage.userMessage("我叫什么"));AiMessage aiMessage2 = response2.aiMessage(); // 大模型的第二次响应System.out.println(aiMessage2.text());}
但是如果要我们每次把之前的记录自己去维护, 未免太麻烦, 所以提供了ChatMemory
但是他这个ChatMemory没有SpringAi好用、易用, 十分麻烦! 所以说谁在跟我说Langchain4j比SpringAi好我跟谁急!。
通过ChatMemory
在SpringBoot中他要这么用:
@Configuration
public class AiConfig {public interface Assistant {String chat(String message);// 流式响应TokenStream stream(String message);}@Beanpublic Assistant assistant(ChatLanguageModel qwenChatModel,StreamingChatLanguageModel qwenStreamingChatModel) {ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);Assistant assistant = AiServices.builder(Assistant.class).chatLanguageModel(qwenChatModel).streamingChatLanguageModel(qwenStreamingChatModel).chatMemory(chatMemory).build();return assistant;}
}
原理:
0. 通过AiService创建的代理对象(
)调用chat方法
- 代理对象会去ChatMemory中获取之前的对话记录(获取记忆)
- 将获取到的对话记录合并到当前对话中(此时大模型根据之前的聊天记录肯定就拥有了“记忆”)
- 将当前的对话内容存入ChatMemory(保存记忆)
Controller:
@RestController
@RequestMapping("/ai_other")
public class OtherAIController { @AutowiredAiConfig.Assistant assistant;@RequestMapping(value = "/memory_chat")public String memoryChat(@RequestParam(defaultValue="我叫徐庶") String message) {return assistant.chat(message);}}@RequestMapping(value = "/memory_stream_chat",produces ="text/stream;charset=UTF-8")public Flux<String> memoryStreamChat(@RequestParam(defaultValue="我是谁") String message, HttpServletResponse response) {TokenStream stream = assistant.stream(message);return Flux.create(sink -> {stream.onPartialResponse(s -> sink.next(s)).onCompleteResponse(c -> sink.complete()).onError(sink::error).start();});}
这封装得也太不优雅了! 看看人家Spring-AI 封装得那叫一个优雅, 行吧不吐槽了,我们继续往下面看吧。
我们通过2种接口体验记忆对话, (当然也可以通过同一个接口)
访问:/memory_chat
访问:/memory_stream_chat
记忆分离
现在我们再来想另一种情况: 如果不同的用户或者不同的对话肯定不能用同一个记忆,要不然对话肯定会混淆,此时就需要进行区分:
可以通过memoryId进行区分,
public interface AssistantUnique {String chat(@MemoryId int memoryId, @UserMessage String userMessage);// 流式响应TokenStream stream(@MemoryId int memoryId, @UserMessage String userMessage);}@Beanpublic AssistantUnique assistantUnique(ChatLanguageModel qwenChatModel,StreamingChatLanguageModel qwenStreamingChatModel) {AssistantUnique assistant = AiServices.builder(AssistantUnique.class).chatLanguageModel(qwenChatModel).streamingChatLanguageModel(qwenStreamingChatModel).chatMemoryProvider(memoryId ->MessageWindowChatMemory.builder().maxMessages(10).id(memoryId).build()).build();return assistant;}
原理:
0. 通过AiService创建的代理对象(
)调用chat方法
传入id
- 代理对象会去ChatMemory中根据id获取之前的对话记录(获取记忆)
- 将获取到的对话记录合并到当前对话中(此时大模型根据之前的聊天记录肯定就拥有了“记忆”)
- 将当前的对话内容根据id存入ChatMemory(保存记忆)
memoryId可以设置为用户Id, 或者对话Id 进行区分即可:
@AutowiredAiConfig.AssistantUnique assistantUnique;@RequestMapping(value = "/memoryId_chat")public String memoryChat(@RequestParam(defaultValue="我是谁") String message, Integer userId) {return assistantUnique.chat(userId,message);}
看效果:
userId=1,我叫徐庶
userId=2, 我叫什么
userId=1 ,我叫什么
持久化对话
OK, 完成! 如果要对记忆的数据进行持久化呢? 因为现在的数据其实是存在内存中, 重启就丢了
可以配置一个ChatMemoryStore
默认是InMemoryChatMemoryStore
——通过一个map进行存储,
所以如果需要持久化到第三方存储, 可以重新配置ChatMemoryStore
:
自定义ChatMemoryStore实现类: 假设持久化到数据库 , 具体代码我不演示了
public class PersistentChatMemoryStore implements ChatMemoryStore {private final Map<Integer, List<ChatMessage>> map =new HashMap<>();@Overridepublic List<ChatMessage> getMessages(Object memoryId) {// todo 根据memoryId从数据库获取}@Overridepublic void updateMessages(Object memoryId, List<ChatMessage> messages) {// todo 根据memoryId修改、新增记录}@Overridepublic void deleteMessages(Object memoryId) {// todo 根据memoryId删除}}
- 然后配置ChatMemoryStore
@Beanpublic AssistantUnique assistantUniqueStore(ChatLanguageModel qwenChatModel,StreamingChatLanguageModel qwenStreamingChatModel) {PersistentChatMemoryStore store = new PersistentChatMemoryStore();ChatMemoryProvider chatMemoryProvider = memoryId -> MessageWindowChatMemory.builder().id(memoryId).maxMessages(10).chatMemoryStore(store).build();AssistantUnique assistant = AiServices.builder(AssistantUnique.class).chatLanguageModel(qwenChatModel).streamingChatLanguageModel(qwenStreamingChatModel).chatMemoryProvider(memoryId ->MessageWindowChatMemory.builder().maxMessages(10).id(memoryId).build()).chatMemoryProvider(chatMemoryProvider).build();return assistant;
Function-call(Tools)
对于基础大模型来说, 他只具备通用信息,他的参数都是拿公网进行训练,并且有一定的时间延迟, 无法得知一些具体业务数据和实时数据, 这些数据往往被各软件系统存储在自己数据库中:
比如我问大模型:“中国有多少个叫徐庶的” 他肯定不知道, 我们就需要去调用政务系统的接口。
比如我现在开发一个智能票务助手, 我现在跟AI说需要退票, AI怎么做到呢? 就需要让AI调用我们自己系统的退票业务方法,进行操作数据库。
那这些都可以通过function-call进行完成,更多的用于实现类似智能客服场景,因为客服需要帮用户解决业务问题(就需要调用业务方法)。
function-call的流程:
比如: 我现在需要当对话中用户问的是“长沙有多少个叫什么名字”的对话, 我需要去我程序中获取
- 问大模型 长沙有多少个叫徐庶的
- 大模型在识别到你的问题是: “长沙有多少个叫什么名字”
- 大模型提取“徐庶”
- 调用
changshaNameCount
方法 - 通过返回的结果再结合上下文再次请求大模型
- 响应“长沙有xx个叫徐庶的”
实现:
- 加入回调方法:
@Service
public class ToolsService {@Tool("长沙有多少个名字的")public Integer changshaNameCount(@P("姓名")String name){System.out.println(name);return 10;}
}
- ToolsService配置为了一个bean
- @Tool 用于告诉AI什么对话调用这个方法
- @P("姓名") 用于告诉AI ,调用方法的时候需要提取对话中的什么信息, 这里提取的是姓名
- 结合通过AiService配置tools , 这里用的是前面记忆对话时配置的Assistant
public interface Assistant {String chat(String message);// 流式响应TokenStream stream(String message);}@Beanpublic Assistant assistant(ChatLanguageModel qwenChatModel,StreamingChatLanguageModel qwenStreamingChatModel,ToolsService toolsService) {ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);Assistant assistant = AiServices.builder(Assistant.class).chatLanguageModel(qwenChatModel).streamingChatLanguageModel(qwenStreamingChatModel).tools(toolsService).chatMemory(chatMemory).build();return assistant;}
所以, 你如果需要加更多的tool. 只需要在TollsService中加, 比如:
这个langchan4j封装得倒是挺易用的👍
@Tool("长沙的天气")public String changshaWeather( ){System.out.println("长沙的天气");return "下雪";}
预设角色(系统消息SystemMessage):
基础大模型是没有目的性的, 你聊什么给什么, 但是如果我们开发的事一个智能票务助手, 我需要他以一个票务助手的角色跟我对话, 并且在我跟他说"退票"的时候, 让大模型一定要告诉我“车次”和"姓名" ,这样我才能去调用业务方法(假设有一个业务方法,需要根据车子和姓名才能查询具体车票),进行退票。
在langchain4j中实现也非常简单 @SystemMessage 系统消息, 一般做一些预设角色的提示词,设置大模型的基本职责可以通过{{current_date}} 传入参数, 因为预设词中的文本可能需要实时变化@V("current_date"), 通过@V传入{{}}中的参数一旦参数不止一个, 就需要通过@UserMessage设置用户信息
public interface Assistant {String chat(String message);// 流式响应TokenStream stream(String message);@SystemMessage("""您是“Tuling”航空公司的客户聊天支持代理。请以友好、乐于助人且愉快的方式来回复。您正在通过在线聊天系统与客户互动。 在提供有关预订或取消预订的信息之前,您必须始终从用户处获取以下信息:预订号、客户姓名。请讲中文。今天的日期是 {{current_date}}.""")TokenStream stream(@UserMessage String message,@V("current_date") String currentDate);}@RequestMapping(value = "/memory_stream_chat",produces ="text/stream;charset=UTF-8")public Flux<String> memoryStreamChat(@RequestParam(defaultValue="我是谁") String message, HttpServletResponse response) {TokenStream stream = assistant.stream(message, LocalDate.now().toString());return Flux.create(sink -> {stream.onPartialResponse(s -> sink.next(s)).onCompleteResponse(c -> sink.complete()).onError(sink::error).start();});}
另外:假设大模型不支持系统消息(一般都支持),可以用@UserMessage代替@SystemMessage
interface Friend {@UserMessage("你是一个航空智能助手,你需要帮助用户进行服务: {{it}}")String chat(String userMessage);
}
RAG:
检索增强生成(Retrieval-augmented Generation)
对于基础大模型来说, 他只具备通用信息,他的参数都是拿公网进行训练,并且有一定的时间延迟, 无法得知一些具体业务数据和实时数据, 这些数据往往在各种文件中(比如txt、word、html、数据库...)
虽然function-call、SystemMessage可以用来解决一部分问题
但是它只能少量, 如果你要提供大量的业务领域信息, 就需要给他外接一个知识库:
比如
- 我问他退订要多少费用
- 这些资料可能都由产品或者需求编写在了文档中: 📎terms-of-service.txt
-
- 所以需要现在需求信息存到向量数据库(这个过程叫Embedding, 涉及到文档读取、分词、向量化存入)
- 去向量数据库中查询“退订费用相关信息”
- 将查询到的数据和对话信息再请求大模型
- 此时会响应退订需要多少费用
概念
向量:
向量通常用来做相似性搜索,比如语义的一维向量,可以表示词语或短语的语义相似性。例如,“你好”、“hello”和“见到你很高兴”可以通过一维向量来表示它们的语义接近程度。
然而,对于更复杂的对象,比如小狗,无法仅通过一个维度来进行相似性搜索。这时,我们需要提取多个特征,如颜色、大小、品种等,将每个特征表示为向量的一个维度,从而形成一个多维向量。例如,一只棕色的小型泰迪犬可以表示为一个多维向量 [棕色, 小型, 泰迪犬]。
如果需要检索见过更加精准, 我们肯定还需要更多维度的向量, 组成更多维度的空间,在多维向量空间中,相似性检索变得更加复杂。我们需要使用一些算法,如余弦相似度或欧几里得距离,来计算向量之间的相似性。向量数据库会帮我实现。
文本向量化
LangChain4j中来调用向量模型来对一句话进行向量化体验:
package com.xs;import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.model.output.Response;/*** wx:程序员徐庶*/
public class _05_Vector {public static void main(String[] args) {QwenEmbeddingModel embeddingModel= QwenEmbeddingModel.builder().apiKey(System.getenv("ALI_AI_KEY")).build();Response<Embedding> embed = embeddingModel.embed("你好,我叫徐庶");System.out.println(embed.content().toString());System.out.println(embed.content().vector().length);}
}
代码执行结果为:
mbedding { vector = [0.014577684, 0.007282357, 0.030037291, -0.02028425, ...
1536
从结果可以知道"你好,我叫徐庶"这句话经过OpenAiEmbeddingModel向量化之后得到的一个长度为1536的float数组。注意,1536是固定的,不会随着句子长度而变化。
那么,我们通过这种向量模型得到一句话对应的向量有什么作用呢?非常有用,因为我们可以基于向量来判断两句话之间的相似度,举个例子:
查询跟秋田犬类似的狗, 在向量数据库中根据每个狗的特点进行多维向量, 你会发现秋田犬的向量数值和柴犬的向量数值最接近, 就可以查到类似的狗。 (当然我这里只是举例,让你对向量数据库有一个印象)
向量数据库
对于向量模型生成出来的向量,我们可以持久化到向量数据库,并且能利用向量数据库来计算两个向量之间的相似度,或者根据一个向量查找跟这个向量最相似的向量。
在LangChain4j中,EmbeddingStore表示向量数据库,它有支持20+ 嵌入模型:
Embedding Store | Storing Metadata | Filtering by Metadata | Removing Embeddings |
In-memory | ✅ | ✅ | ✅ |
Astra DB | ✅ | ||
Azure AI Search | ✅ | ✅ | ✅ |
Azure CosmosDB Mongo vCore | ✅ | ||
Azure CosmosDB NoSQL | ✅ | ||
Cassandra | ✅ | ||
Chroma | ✅ | ✅ | ✅ |
ClickHouse | ✅ | ✅ | ✅ |
Coherence | ✅ | ✅ | ✅ |
Couchbase | ✅ | ✅ | |
DuckDB | ✅ | ✅ | ✅ |
Elasticsearch | ✅ | ✅ | ✅ |
Infinispan | ✅ | ||
Milvus | ✅ | ✅ | ✅ |
MongoDB Atlas | ✅ | ✅ | ✅ |
Neo4j | ✅ | ||
OpenSearch | ✅ | ||
Oracle | ✅ | ✅ | ✅ |
PGVector | ✅ | ✅ | ✅ |
Pinecone | ✅ | ✅ | ✅ |
Qdrant | ✅ | ✅ | ✅ |
Redis | ✅ | ||
Tablestore | ✅ | ✅ | ✅ |
Vearch | ✅ | ||
Vespa | |||
Weaviate | ✅ | ✅ |
其中有我们熟悉的几个数据库都可以用来存储向量,比如Elasticsearch、MongoDb、Neo4j、Pg、Redis。
视频主要通过In-memory方式演示完整使用流程
Redis也很简单, 你需要先安装redis7.0+的版本:其他的向量数据库不做介绍
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-redis</artifactId><version>${langchain4j.version}</version>
</dependency>
然后需要注意的是,普通的Redis是不支持向量存储和查询的,需要额外的redisearch模块,我这边是直接使用docker来运行一个带有redisearch模块的redis容器的,命令为:
docker run -p 6379:6379 redis/redis-stack-server:latest
注意端口6379不要和你现有的Redis冲突了。
然后就可以使用以下代码把向量存到redis中了:
RedisEmbeddingStore embeddingStore = RedisEmbeddingStore.builder().host("127.0.0.1").port(6379).dimension(1536).build();// 生成向量
Response<Embedding> embed = embeddingModel.embed("我是徐庶");// 存储向量
embeddingStore.add(embed.content());
dimension表示要存储的向量的维度,所以为1536,如果你不是使用OpenAiEmbeddingModel得到的向量,那么维度可能会不一样。
可以使用以下命令来清空:
redis-cli FT.DROPINDEX embedding-index DD
匹配向量
在这个示例中, 我分别存储了预订航班
和取消预订
2段说明到向量数据库中
然后通过"退票要多少钱" 进行查询
@Testpublic void test02() {InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();QwenEmbeddingModel embeddingModel= QwenEmbeddingModel.builder().apiKey(System.getenv("ALI_AI_KEY")).build();// 利用向量模型进行向量化, 然后存储向量到向量数据库TextSegment segment1 = TextSegment.from("""预订航班:- 通过我们的网站或移动应用程序预订。- 预订时需要全额付款。- 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25 的费用。""");Embedding embedding1 = embeddingModel.embed(segment1).content();embeddingStore.add(embedding1, segment1);// 利用向量模型进行向量化, 然后存储向量到向量数据库TextSegment segment2 = TextSegment.from("""取消预订:- 最晚在航班起飞前 48 小时取消。- 取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。- 退款将在 7 个工作日内处理。""");Embedding embedding2 = embeddingModel.embed(segment2).content();embeddingStore.add(embedding2, segment2);// 需要查询的内容 向量化Embedding queryEmbedding = embeddingModel.embed("退票要多少钱").content();// 去向量数据库查询// 构建查询条件EmbeddingSearchRequest build = EmbeddingSearchRequest.builder().queryEmbedding(queryEmbedding).maxResults(1).build();// 查询EmbeddingSearchResult<TextSegment> segmentEmbeddingSearchResult = embeddingStore.search(build);segmentEmbeddingSearchResult.matches().forEach(embeddingMatch -> {System.out.println(embeddingMatch.score()); // 0.8144288515898701System.out.println(embeddingMatch.embedded().text()); // I like football.});}
代码执行结果为:
0.7319455553039915
取消预订:
- 最晚在航班起飞前 48 小时取消。
- 取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。
- 退款将在 7 个工作日内处理。
知识库RAG演练
Document Loaders 文档读取器
读取为文档
Document document = ClassPathDocumentLoader.loadDocument("rag/terms-of-service.txt", new TextDocumentParser());
1.1. Document Parser 文档解析器
如果要开发一个知识库系统, 这些资料可能在各种文件中, 比如word、txt、pdf、image、html等等, 所以langchain4j也提供了不同的文档解析器:
TextDocumentParser
来自langchain4j
模块的TextDocumentParser
,它可以解析纯文本格式(e.g. TXT、HTML、MD 等)的文件。ApachePdfBoxDocumentParser
来自langchain4j-document-parser-apache-pdfbox
,它可以解析 PDF 文件ApachePoiDocumentParser
来自langchain4j-document-parser-apache-poi
,可以解析 MS Office 文件格式(e.g. DOC、DOCX、PPT、PPTX、XLS、XLSX 等)ApacheTikaDocumentParser
来自langchain4j-document-parser-apache-tika
模块中,可以自动检测和解析几乎所有现有的文件格式
在这里我来解析一份这个txt文件📎terms-of-service.txt, 所以我们用TextDocumentParser
随便放这里吧
代码:
// 读取
Path documentPath = Paths.get(VectorTest.class.getClassLoader().getResource("rag/terms-of-service.txt").toURI());
DocumentParser documentParser = new TextDocumentParser();
Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);
2. DocumentSplitter 文档拆分器
由于文本读取过来后, 还需要分成一段一段的片段(分块chunk), 分块是为了更好地拆分语义单元,这样在后面可以更精确地进行语义相似性检索,也可以避免LLM的Token限制。
langchain4j也提供了不同的文档拆分器:
分词器类型 | 匹配能力 | 适用场景 |
DocumentByCharacterSplitter | 无符号分割 | 就是严格根据字数分隔(不推荐,会出现断句) |
DocumentByRegexSplitter | 正则表达式分隔 | 根据自定义正则分隔 |
DocumentByParagraphSplitter | 删除大段空白内容 | 处理连续换行符(如段落分隔)( |
DocumentByLineSplitter | 删除单个换行符周围的空白, 替换一个换行 | (
|
DocumentByWordSplitter | 删除连续的空白字符。 |
|
DocumentBySentenceSplitter | 按句子分割 | 该分割器使用Apache OpenNLP 库中的一个类,用于检测文本中的句子边界。它能够识别标点符号(如句号、问号、感叹号等)是否标记着句子的末尾,从而将一个较长的文本字符串分割成多个句子。 |
这里我们选DocumentByLineSplitter吧, 因为内容不多, 所以其实没有特别大的关系, 后面如果大家有兴趣我详细讲解每一种的应用场景。
代码:
将第1步读取到的文档进行分割
DocumentByCharacterSplitter splitter = new DocumentByCharacterSplitter(20, // 每段最长字数10 // 自然语言最大重叠字数
);
List<TextSegment> segments = splitter.split(document);
chunk_size(块大小)指的就是我们分割的字符块的大小;chunk_overlap(块间重叠大小)就是下图中加深的部分,上一个字符块和下一个字符块重叠的部分,即上一个字符块的末尾是下一个字符块的开始。
在使用按字符切分时,需要指定分割符,另外需要指定块的大小以及块之间重叠的大小(允许重叠是为了尽可能地避免按照字符进行分割造成的语义损失)。
比如
- 最晚在航班起飞前 48 小时取消。取消费用:经济舱 75 美元,豪华经济舱 50 美元,
商务舱 25 美元。退款将在 7 个工作日内处理。按照chunksize可能会分隔成:
最晚在航班起飞前 48 小时取消。取消费用:经济舱 7
如果设置了重叠可能会:
-最晚在航班起飞前 48 小时取消。取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。-取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。退款将在 7 个工作日内处理。
整个流程如下:
先按照指定的分割符进行切分,切分过之后,如果块的长度小于 chunk_size 的大小,则进行块之间的合并。在进行合并时,遵循下面的规则:
1. 如果相邻块加在一起的长度小于或等于chunk_size,则进行合并;否则看你有没有子分割器,如果没有报错。
2. 在进行合并时,如果块的大小小于或等于chunk_overlap,并且和前后两个相邻块合并后,两个合并后的块均不超过chunk_size,则两个合并后的块允许有重叠
在RAG系统中,文本分块的粒度需要平衡语义完整性与计算效率,并非越细越好。以下是关键考量点:
2.1. 分隔经验:
2.1.1. 过细分块的潜在问题
- 语义割裂: 破坏上下文连贯性,影响模型理解 。
- 计算成本增加:分块过细会导致向量嵌入和检索次数增多,增加时间和算力开销。
- 信息冗余与干扰:碎片化的文本块可能引入无关内容,干扰检索结果的质量,降低生成答案的准确性。
2.1.2. 分块过大的弊端
- 信息丢失风险:过大的文本块可能超出嵌入模型的输入限制,导致关键信息未被有效编码。
- 检索精度下降:大块内容可能包含多主题混合,与用户查询的相关性降低,影响模型反馈效果。
场景 | 分块策略 | 参数参考 |
微博/短文本 | 句子级分块,保留完整语义 | 每块100-200字符 |
学术论文 | 段落级分块,叠加10%重叠 | 每块300-500字符 |
法律合同 | 条款级分块,严格按条款分隔 | 每块200-400字符 |
长篇小说 | 章节级分块,过长段落递归拆分为段落 | 每块500-1000字符 |
- 固定长度分块
-
- 字符数范围:通常建议每块控制在 100-500字符(约20-100词),以平衡上下文完整性与检索效率12。
- 重叠比例:相邻块间保留 10-20%的重叠内容(如块长500字符时重叠50-100字符),减少语义断层34。
- 语义分块
-
- 段落或章节:优先按自然段落、章节标题划分,保持逻辑单元完整14。
- 动态调整:对于长段落,可递归分割为更小单元(如先按段落分块,过长时再按句子拆分)46。
- 专业领域调整
-
- 高信息密度文本(如科研论文、法律文件):采用更细粒度分块(100-200字符),保留专业术语细节14。
- 通用文本(如新闻、社交媒体):适当放宽分块大小(300-500字符)12
3. 文本向量化
向量化存储之前在“文本向量化”介绍了, 就是通过向量模型库进行向量化
代码:
依然通过Qwen向量模型进行向量化: 将第2步分割的chunk进行向量化
QwenEmbeddingModel embeddingModel= QwenEmbeddingModel.builder().apiKey(System.getenv("ALI_AI_KEY")).build();// 向量化List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
4. 存储向量
选择向量数据库进行存储即可
代码:
// 存入
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
embeddingStore.addAll(embeddings,segments);
5. 向量数据库检索
代码:
需要先将文本进行向量化, 然后去向量数据库查询,
// 生成向量Response<Embedding> embed = embeddingModel.embed("退费费用");EmbeddingSearchRequest build = EmbeddingSearchRequest.builder().queryEmbedding(embed.content()).build();// 查询EmbeddingSearchResult<TextSegment> results = embeddingStore.search(build);for (EmbeddingMatch<TextSegment> match : results.matches()) {System.out.println(match.embedded().text() + ",分数为:" + match.score());}
完整代码:
@Testpublic void test03() throws URISyntaxException {// 读取Path documentPath = Paths.get(VectorTest.class.getClassLoader().getResource("rag/terms-of-service.txt").toURI());DocumentParser documentParser = new TextDocumentParser();Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);DocumentByLineSplitter splitter = new DocumentByLineSplitter(20, // 每段最长字数10 // 自然语言最大重叠字数);List<TextSegment> segments = splitter.split(document);QwenEmbeddingModel embeddingModel= QwenEmbeddingModel.builder().apiKey(System.getenv("ALI_AI_KEY")).build();// 向量化List<Embedding> embeddings = embeddingModel.embedAll(segments).content();// 存入InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();embeddingStore.addAll(embeddings,segments);// 生成向量Response<Embedding> embed = embeddingModel.embed("退费费用");EmbeddingSearchRequest build = EmbeddingSearchRequest.builder().queryEmbedding(embed.content()).build();// 查询EmbeddingSearchResult<TextSegment> results = embeddingStore.search(build);for (EmbeddingMatch<TextSegment> match : results.matches()) {System.out.println(match.embedded().text() + ",分数为:" + match.score());}}
6. 对话阶段
ChatLanguageModel model = QwenChatModel.builder().apiKey(System.getenv("ALI_AI_KEY")).modelName("qwen-max").build();ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder().embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) // 最相似的5个结果.minScore(0.6) // 只找相似度在0.6以上的内容.build();// 为Assistant动态代理对象 chat ---> 对话内容存储ChatMemory----> 聊天记录ChatMemory取出来 ---->放入到当前对话中Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .contentRetriever(contentRetriever) .build();System.out.println(assistant.chat("退费费用"));public interface Assistant { String chat(String message);}
AiService向量检索原理:
7. 整合SpringBoot
最终其实还会将查询到的内容, 和对话上下文组合起来, 发给LLM为我们组织语言进行回答。
这一步我们直接整合进SpringBoot进行实战:
- 配置一个Content Retriever 内容检索器
-
- 提供向量数据库和向量模型及其他参数
- 将内容检索器绑定到AiServices
- 当我们进行LLM对话时, 底层会自动为我们检索向量数据库进行回答。
public interface Assistant {String chat(String message);// 流式响应TokenStream stream(String message);}@Beanpublic Assistant assistant(ChatLanguageModel qwenChatModel,StreamingChatLanguageModel qwenStreamingChatModel,ToolsService toolsService,EmbeddingStore embeddingStore,QwenEmbeddingModel qwenEmbeddingModel) {ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder().embeddingStore(embeddingStore).embeddingModel(qwenEmbeddingModel).maxResults(5) // 最相似的5个结果.minScore(0.6) // 只找相似度在0.6以上的内容.build();Assistant assistant = AiServices.builder(Assistant.class).chatLanguageModel(qwenChatModel).streamingChatLanguageModel(qwenStreamingChatModel).tools(toolsService).contentRetriever(contentRetriever).chatMemory(chatMemory).build();return assistant;}
当然我们还需要提前存储向量数据到向量数据库
@BeanCommandLineRunner ingestTermOfServiceToVectorStore(QwenEmbeddingModel qwenEmbeddingModel,EmbeddingStore embeddingStore) throws URISyntaxException {// 读取Path documentPath = Paths.get(Langchain4jDemosApplication.class.getClassLoader().getResource("rag/terms-of-service.txt").toURI());return args -> {DocumentParser documentParser = new TextDocumentParser();Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);DocumentByLineSplitter splitter = new DocumentByLineSplitter(500,200);List<TextSegment> segments = splitter.split(document);// 向量化List<Embedding> embeddings = qwenEmbeddingModel.embedAll(segments).content();// 存入embeddingStore.addAll(embeddings,segments);};}
我们依然利用之前的/memory_stream_chat进行测试: 不需要改任何代码
@RequestMapping(value = "/memory_stream_chat",produces ="text/stream;charset=UTF-8")public Flux<String> memoryStreamChat(@RequestParam(defaultValue="我是谁") String message, HttpServletResponse response) {TokenStream stream = assistant.stream(message);return Flux.create(sink -> {stream.onPartialResponse(s -> sink.next(s)).onCompleteResponse(c -> sink.complete()).onError(sink::error).start();});}
完成:✿✿ヽ(°▽°)ノ✿
希望大家能掌握利用langchan4j进行function-call和RAG开发。
Chain多个ServiceAI
在一个应用中, 可能需要多个模型共同一起协作完成一个任务。
为什么要这样:
您的LLM可能不需要始终了解您拥有的每个tools。例如,当用户只是向LLM打招呼或说再见时,让 LLM 访问数十或数百个tools的成本很高,有时甚至很危险(LLM 调用中包含的每个tools都会消耗大量token),并且可能会导致意想不到的结果(LLM 可能会产生幻觉或被操纵以使用非预期的输入来调用tools)。
关于 RAG:同样,有时需要为 LLM 提供一些上下文,但并非总是如此,因为它会产生额外的成本(更多上下文 = 更多token)并增加响应时间(更多上下文 = 更高的延迟)。
关于模型参数:在某些情况下,您可能想不通的对话使用不同的 LLM ,以利用不同LLM的最佳特性。
- 您可以一个接一个地调用 AI 服务(又称链接-chain)。
- 您可以使用确定性和 LLM 支持的
if
/else
语句(AI 服务可以返回boolean
)。 - 您可以使用确定性和 LLM 支持的
switch
语句(AI 服务可以返回enum
)。 - 您可以使用确定性和 LLM 驱动的
for
/while
循环(AI 服务可以返回int
和其他数字类型)。 - 您可以在单元测试中模拟 AI 服务(因为它是一个接口)。
- 您可以单独地对每个 AI 服务进行集成测试。
并且我们可以自由的进行任务编排:
大家平常应该见过一些AI智能体, 由多个(LLM)任务组合编排为一个智能体,
其实利用langchain4j的chain特性, 也可以完成这种类似的效果, 不过你需要自己完成前端编排以及不同任务的初始化和具体实现。 这不是一两句话能讲清楚的。
这里我们利用langchain4j的chain特性你让你得到更多的灵感。 其实langchain4j也可以实现类似智能体,只不过目前没有特别优秀的开源项目,期待你去实现它!:
代码
以下演示2个模型协调合作,但是实际非常灵活, 甚至可以手搓一个langchain4j版的manus(无非就是多个模型相互协作, 1个负责任务拆分, 1个模型负责任务tools回调执行,但是需要大量提示词不是一句话说清楚的, 有机会带大家手写一个manus)。
import dev.langchain4j.community.model.dashscope.QwenChatModel;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;/*** @author wx:程序员徐庶* @desc 测试多模型智能体**/
public class _04TestAgent {interface GreetingExpert {@UserMessage("以下文本是什么任务: {{it}}")TASKTYPE isTask(String text);}interface ChatBot {@SystemMessage("你是一名航空公司客服代理,请为客户服务:")String reply(String userMessage);}class MilesOfSmiles {private GreetingExpert greetingExpert;private ChatBot chatBot;public MilesOfSmiles(GreetingExpert greetingExpert, ChatBot chatBot) {this.greetingExpert = greetingExpert;this.chatBot = chatBot;}public String handle(String userMessage) {TASKTYPE task = greetingExpert.isTask(userMessage);switch (task) {case MODIFY_TICKET:case QUERY_TICKET:case CANCEL_TICKET:return task.getName() + "调用service方法处理";case OTHER:return chatBot.reply(userMessage);}return null;}}ChatLanguageModel qwen;ChatLanguageModel deepseek;@BeforeEachpublic void init() {qwen = QwenChatModel.builder().apiKey(System.getenv("ALI_AI_KEY")).modelName("qwen-max").build();deepseek = OpenAiChatModel.builder().baseUrl("https://api.deepseek.com").apiKey(System.getenv("DEEP_SEEK_KEY")).modelName("deepseek-reasoner").build();}@Testvoid test() {GreetingExpert greetingExpert = AiServices.create(GreetingExpert.class, deepseek);ChatBot chatBot = AiServices.create(ChatBot.class, qwen);MilesOfSmiles milesOfSmiles = new MilesOfSmiles(greetingExpert, chatBot);String greeting = milesOfSmiles.handle("我要退票!");System.out.println(greeting);}
}
MCP
mcp其实很简单, 就是tools的一种外部调用的方式(既然要外部调用,肯定就需要遵循一种通信协议, 这里的协议就MCP,利用一种json-rpc2.0的json格式告知用有哪些tools什么参数, 调用哪个tool, 返回什么数据)。之前我们在自己程序中实现了tools, 但是这种tools无法提供给其他应用调用, 形成了应用孤岛, 无法提供外部共享。
图像演示:
代码:
langchain4j 没有提供mcp server的实现, 但是提供的mcp client的实现:
当然mcpserver哪怕纯java也可以单独实现,下次有时间单独给大家讲解(如果有兴趣的话)
<!--mcp-->
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-mcp</artifactId><version>${langchain4j.version}</version>
</dependency>
public class _05TestMCP {// 测试npx 方式百度地图@Testpublic void test() throws Exception {// 1.构建模型ChatLanguageModel model = QwenChatModel.builder().apiKey(System.getenv("ALI_AI_KEY")).modelName("qwen-max").build();// 2.构建MCP服务传输方式 有sse和stdio两种, 这里演示的是stdioMcpTransport transport = new StdioMcpTransport.Builder().command(List.of("cmd","/c","npx","-y","@baidumap/mcp-server-baidu-map","mcp/github")).environment(Map.of("BAIDU_MAP_API_KEY",System.getenv("BAIDU_MAP_API_KEY"))).logEvents(true).build();// 3.构建MCP客户端, 指定传输方式McpClient mcpClient = new DefaultMcpClient.Builder().transport(transport).build();// 4.构建MCP工具提供者, 指定MCP客户端ToolProvider toolProvider = McpToolProvider.builder().mcpClients(List.of(mcpClient)).build();// 5.构建服务代理, 指定模型和工具提供者Bot bot = AiServices.builder(Bot.class).chatLanguageModel(model).toolProvider(toolProvider).build();try {// 对话请求String response = bot.chat("规划长沙到武汉骑行路线");System.out.println("RESPONSE: " + response);} finally {mcpClient.close();}}interface Bot {String chat(String userMessage);}
}