前言
前几篇文章已经已经完成了一些主流的大模型能力的接入,目前我们需要体验一些 LangChain4j 的高级功能,本篇文章我们先来实践【聊天记忆】功能,这个功能对于开发一个智能体来说是必要的,我们必须记忆用户的聊天历史,结合上下文给出越来越精准的回答。
一、AiServices简介
LangChain4j 的 AiServices 提供了一种声明式的方式,将 Java 接口(定义 AI 调用契约)映射到底层的 LLM(Large Language Model)组件,实现了对模型调用、输入输出转换、记忆管理与流式响应的高度抽象与自动化。通过简单的接口和构建器模式,开发者无需关心底层细节,即可快速构建具备对话、检索增强生成(RAG)、工具调用等能力的 AI 服务。
二、记忆组件选择
LangChain4j 提供了两种会话记忆的实现方案,先梳理一下各自的原理之后,我们选择一种更加适合我们的方式来实现。
① chatMemory
查看 AiServices 的源码我们可以看到 chatMemory 方法,调用的是 this.context.initChatMemories(chatMemory)。
点进去看到就是初始化 chatMemoryService。
再点进去看看到底是怎么初始化的,原来就是将传入的记忆实例直接传入默认的 defaultChatMemory 中,可以看到 defaultChatMemory 只有一个对象,所以使用该方法的话,所有的会话都会公用这个记忆实例,简单来说所有人都公用同一个会话。
② chatMemoryProvider
查看 AiServices 的源码我们可以看到 chatMemoryProvider 方法,调用的是 this.context.initChatMemories(chatMemoryProvider),并且参数也不是简单的 ChatMemory 而是函数式接口 ChatMemoryProvider。
点进去看到也是是初始化 chatMemoryService。
再点进去看看到底是怎么初始化的,原来是归根结底是将记忆数据放到了 Map<Object, ChatMemory>中,使用了 ConcurrentHashMap 键值对的方式保存多组记忆数据,那么就可以通过key的维度来区分不同用户的数据,只有这个map的数据怎么维护进入的,等下面具体实现的时候再展示源码分析。
③ 初步总结
情形 | 选择 |
---|---|
单一对话/单用户 | chatMemory(...) |
多用户/多会话 | chatMemoryProvider(...) |
无状态调用(完全不需要记忆) | 不配置任何记忆,或使用 NoChatMemoryProviderSupplier |
三、导入POM依赖
<!-- langchain4j 高级功能-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>
四、
实现记忆聊天
① 代码展示
(1) 自定义 AIService接口
package com.ceair.aiService;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
/**
* @author wangbaohai
* @ClassName MyClassicAssistant
* @description: LangChain4j 样例 AIService
* @date 2025年05月16日
* @version: 1.0.0
*/
public interface MyClassicAssistant {
/**
* 根据给定的记忆ID和用户消息进行聊天
*
* @param memoryId 一个整数类型的记忆ID,用于标识特定的记忆或对话状态
* @param message 用户输入的消息字符串,用于进行对话
* <p>
* 此方法旨在模拟与用户基于特定记忆的对话过程,通过用户消息进行交互
*/
String chat(@MemoryId int memoryId, @UserMessage String message);
}
(2) AiServices构建实例
/**
* 测试LangChain4j的MyClassicAssistant功能
* 向MyClassicAssistant模型发送预设问题并获取回答
*
* @return Result<String> 包含成功状态和模型回答结果的封装对象
* 当调用成功时返回模型回答文本,失败时返回错误信息
*/
@Operation(summary = "测试LangChain4j功能-MyClassicAssistant")
@PostMapping("/MyClassicAssistant")
public Result<String> MyClassicAssistant() {
// 定义一个 ChatMemoryProvider 函数式接口实例,用于根据 memoryId 创建聊天记忆窗口
ChatMemoryProvider chatMemoryProvider = memoryId -> MessageWindowChatMemory.builder()
// 设置记忆窗口的 ID(即会话 ID),用于区分不同用户的对话历史
.id(memoryId)
// 设置最大保存的消息数量为 10 条,超出后会自动移除最早的消息
.maxMessages(10)
// 构建 MessageWindowChatMemory 实例
.build();
// 使用 AiServices 构建器构建 AI 助手服务实例的代理
MyClassicAssistant proxyMyClassicAssistant = AiServices
// 指定 AI 助手的接口类型为 MyClassicAssistant
.builder(MyClassicAssistant.class)
// 设置使用的聊天模型为 qwenChatModel(通义千问模型)
.chatModel(qwenChatModel)
// 设置聊天记忆提供者,用于根据 memoryId 提供对应的聊天记忆
.chatMemoryProvider(chatMemoryProvider)
// 完成构建并返回 AI 助手实例
.build();
// 调用 AI 助手的 chat 方法发起对话
// 第一个参数 1 是 memoryId,表示本次对话的记忆 ID(可用于后续对话状态保持)
// 第二个参数是用户输入的提示信息,询问模型名称及其特长
String answer1 = proxyMyClassicAssistant.chat(1, "记住我是王保海");
String answer2 = proxyMyClassicAssistant.chat(1, "我是谁?");
String answer3 = proxyMyClassicAssistant.chat(2, "我是谁?");
// 将模型的回答封装到 Result 对象中并返回成功响应
return Result.success(answer1 + "||" + answer2 + "||" + answer3);
}
(3) 流程简要分析
运行时,LangChain4j 为 MyClassicAssistant 创建了一个动态代理。代理执行时的关键步骤:
方法和注解扫描:找到 @MemoryId 注解所在的参数索引。
提取实参值:从调用 chat(1, …) 中拿到实参 1。
调用 ChatMemoryProvider:执行 chatMemoryProvider.get(1),创建或获取 ID 为 1 的 MessageWindowChatMemory。
存入上下文:将该记忆实例缓存到 AiServiceContext.chatMemories,键即为 1。
触发 LLM 调用:最后,使用这个 memory 和你的用户消息一起,构造聊天上下文,发给语言模型。
② AiServices的build源码解析
首先我们先从 build() 方法进去
我们会看到一个抽象方法 build(),不要慌直接到具体层。
在这一层我们就可以看到 LangChain4j 是怎么为我们创建动态代理类的。
我们真实接口的方法名称是 【chat】,所以我们直接看反射 invoke 的最后一个 else 分支即可,可以看到核心的是这两行代码,第一行是为了找到 @MemoryId 注解的参数,第二行则是根据拿到第一行的参数即【memoryId】,通过我们已经使用 【ChatMemoryProvider】 方式注册到 【ChatMemoryService】 的【getOrCreateChatMemory】方法获取实际的记忆数据。
第一行的核心源码:
private static Optional<Object> findMemoryId(Method method, Object[] args) {
Parameter[] parameters = method.getParameters();
for(int i = 0; i < parameters.length; ++i) {
if (parameters[i].isAnnotationPresent(MemoryId.class)) {
Object memoryId = args[i];
if (memoryId == null) {
throw Exceptions.illegalArgument("The value of parameter '%s' annotated with @MemoryId in method '%s' must not be null", new Object[]{parameters[i].getName(), method.getName()});
}
return Optional.of(memoryId);
}
}
return Optional.empty();
}
第二行的核心源码:
public ChatMemory getOrCreateChatMemory(Object memoryId) {
if (memoryId == "default") {
if (this.defaultChatMemory == null) {
this.defaultChatMemory = this.chatMemoryProvider.get("default");
}
return this.defaultChatMemory;
} else {
Map var10000 = this.chatMemories;
ChatMemoryProvider var10002 = this.chatMemoryProvider;
Objects.requireNonNull(var10002);
return (ChatMemory)var10000.computeIfAbsent(memoryId, var10002::get);
}
}
③ ChatMemoryProvider自定义实例
ChatMemoryProvider 是一个函数式接口。
(1) 函数式接口简介
根据 Java 语言规范,函数式接口是指只声明了一个抽象方法的接口,其它方法都必须有默认(default
)实现或是静态方法,或者是从 java.lang.Object
继承的方法,这些都不计入抽象方法数。
(2) 函数式接口基本使用方法
在接口上添加注解
@FunctionalInterface
public interface GreetingService {
void sayMessage(String message);
}
在代码中使用 Lambda 表达式或方法引用接口实例
public class App {
public static void main(String[] args) {
GreetingService greeter = message -> System.out.println("Hello, " + message);
greeter.sayMessage("World"); // 输出:Hello, World
}
}
这样一来,Lambda 表达式 message -> ...
自动对应 sayMessage
方法,简洁直观。
(3) 创建接口实例
ChatMemoryProvider 里的抽象方式是 ChatMemory get(Object var1),所以我们要收集参数并且返回一个 ChatMemory 的实现类,这个实现类指定我们具体的数据存储方式,LangChain4j为我们提供了默认的实现类,名字叫: MessageWindowChatMemory,这个实现类默认是保存在内存中的。
// 定义一个 ChatMemoryProvider 函数式接口实例,用于根据 memoryId 创建聊天记忆窗口
ChatMemoryProvider chatMemoryProvider = memoryId -> MessageWindowChatMemory.builder()
// 设置记忆窗口的 ID(即会话 ID),用于区分不同用户的对话历史
.id(memoryId)
// 设置最大保存的消息数量为 10 条,超出后会自动移除最早的消息
.maxMessages(10)
// 构建 MessageWindowChatMemory 实例
.build();
(4) MessageWindowChatMemory 源码解析
查看源码,可以看到 MessageWindowChatMemory 保存数据是在 ChatMemoryStore 中的。
进入其中接口发现有两个实现类
我们分别打开看看,其实没什么大的区别,一个以List形式保存到内存,一个是Map的形式保存到内存,默认使用的是 【SingleSlotChatMemoryStore】 这一种实现。
五、验证功能
此步骤直接展示接口调用结果,测试具体步骤请查看专栏第一篇文章的测试章节。
我们打好断点,观察一下,第一个我先使用id=1的身份告诉大模型我的信息,那么我用id=1的身份去问是可以拿到我们的信息的,但是我们用id=2的身份再去拿id=1的身份信息是不可以的。
后记
按照一篇文章一个代码分支,本文的后端工程的分支都是 LangChain4j-4,前端工程的分支是 LangChain4j-1。
后端工程仓库:后端工程
前端工程仓库:前端工程