前言:一次与"失忆"AI的尴尬对话
想象一下这个场景:
你兴冲冲地打开一个AI助手,问了它第一个问题:"我喜欢看科幻电影,有什么推荐吗?"
AI很快给出了一个绝妙的回答:"《星际穿越》!诺兰的神作,探讨了爱与引力一样可以穿越时空。"
你非常满意,接着问:"太好了!那这部电影的主演是谁呀?"
这时,AI陷入了沉默,仿佛在思考宇宙的终极答案。几秒后,它礼貌而茫然地回答:"您好,请问您有什么问题需要我帮忙吗?"
......
是不是瞬间有一种"对牛弹琴"的无力感?仿佛刚才那段精彩的对话从未发生过。这就是一个没有"会话"和"记忆"功能的AI------一个无比健忘、每次聊天都像初次见面的"金鱼脑"助手。
而我们今天要做的,就是彻底治好它的"健忘症"!
在本篇博客中,你将跟随我的脚步,从一个最简单的AI问答功能开始,一步步为它打造一个完整的"人格":
- 先给它一个"家" (新建会话):让每次聊天都有独立的房间,避免和别人的对话搞混。
- 教它"滔滔不绝"地说话(流式对话):让回答像溪流一样自然流畅,而不是等半天才蹦出全文。
- 给它"立规矩" (系统提示词):让它成为专业的视频客服,而不是一个瞎聊的"江湖骗子"。
- 赋予它"闭嘴"的技能(停止生成):在它喋喋不休时,能优雅地让它停下来。
- 最终,赐予它最珍贵的"记忆" (会话记忆):让它能记住我们刚才聊到了《星际穿越》,而不是每次都从头开始"您好,请问有什么可以帮您?"
这不仅仅是一篇技术教程,更是一次神奇的"造人"体验。我们将使用 SpringAI 框架,结合 Redis 、Nacos 等利器,手把手带你实现这一切。
准备好了吗?让我们一起,告别"金鱼脑"AI,打造一个真正懂你、记得你、能随时聊也能随时停的智能助手吧!
第一章 新建会话------ 给AI一个"家" 🏠
我们来聊聊如何给AI助手安排一个舒适的"小窝"------也就是新建会话功能。想象一下,每次和AI聊天,它都需要一个独立的房间(sessionId),这样才不会把你们的私密对话搞混。那么,这三个问题怎么解决呢?
1. 给AI发"身份证"(实现分析)
实现新建会话功能,需要解决3个问题:
- 如何生成sessionId?
- 生成的sessionId是否需要存储?
- 热门问题,该怎么做?
我们逐一进行分析:
问题一:如何生成sessionId?
解决: 生成sessionId的要求是字符串的,不重复的,所以,可以使用uuid来生成,确保唯一性。
问题二:sessionId是否需要存储?
解决: 是需要的,因为后续需要有查询历史对话的功能,如果不存储到数据库,是无法查询的。
问题三: 热门问题,该怎么做?
解决: 热门问题一般都是固定的问题,所以在后台中存储,随机的返回3个即可,但是,要做成可配置的,不能硬编码到代码中。所以,我们会想到把他写到nacos
的配置中心中,这样就会比较灵活一些。
2. 给会话安个家(数据库设计)
创建表语句:
sql
CREATE TABLE `chat_session` (
`id` BIGINT(19) NOT NULL COMMENT '数据id',
`session_id` VARCHAR(32) NOT NULL COMMENT '会话id' COLLATE 'utf8mb4_0900_ai_ci',
`user_id` BIGINT(19) NOT NULL DEFAULT '0' COMMENT '用户id',
`title` VARCHAR(100) NULL DEFAULT NULL COMMENT '会话标题' COLLATE 'utf8mb4_bin',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`creater` BIGINT(19) NOT NULL COMMENT '创建人',
`updater` BIGINT(19) NOT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE,
INDEX `session_id_index` (`session_id`) USING BTREE,
INDEX `user_id_index` (`user_id`) USING BTREE,
INDEX `update_time_index` (`update_time`) USING BTREE
)
COMMENT='对话session'
COLLATE='utf8mb4_bin'
ENGINE=InnoDB
;
3. 让AI"开口说话"(代码实现)
3.1 创建ChatSession实体类
首先,我们创建一个与表结构对应的实体类:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("chat_session")
public class ChatSession implements Serializable {
/**
* 数据id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 会话id
*/
private String sessionId;
/**
* 用户id
*/
private Long userId;
/**
* 会话标题
*/
private String title;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 创建人
*/
private Long creater;
/**
* 更新人
*/
private Long updater;
}
3.2 定义SessionVO类
然后,我们定义返回给前端的VO类:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionVO {
/**
* 会话ID,用于唯一标识当前的AI助手会话。
*/
private String sessionId;
/**
* AI助手的标题,用于显示助手的名称或身份。
*/
private String title;
/**
* AI助手的描述,简要介绍助手的功能或特点。
*/
private String describe;
/**
* 示例列表,包含一些使用助手的示例。
*/
private List<Example> examples;
/**
* Example类表示每个示例的标题和描述。
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class Example {
/**
* 示例的标题,描述了示例的类型或内容。
*/
private String title;
/**
* 示例的描述,具体说明了示例的内容或问题。
*/
private String describe;
}
}
3.3 配置数据:让AI"有话说"
除了sessionId是生成的之外,其他数据都是需要配置到nacos中的,不能硬编码。
yml
kr:
ai:
session:
title: Hello,我是kiraAI助理
describe: 我是由kirakira倾力打造的智能助理,我不仅能推荐视频、答疑解惑,还能为您激发创意、畅聊心事。
examples:
- title: "视频推荐"
describe: "能帮我推荐一个最近热门的游戏视频吗?"
- title: "视频推荐"
describe: "有什么值得一看的搞笑短片推荐?"
- title: "视频推荐"
describe: "能根据我的观看历史推荐一些视频吗?"
- title: "视频推荐"
describe: "周末到了,有什么电影解说类视频推荐吗?"
- title: "播放问题排查"
describe: "视频加载很慢,如何解决?"
- title: "播放问题排查"
describe: "为什么视频画面和声音不同步?"
- title: "播放问题排查"
describe: "播放4K视频时总是缓冲,该怎么办?"
- title: "播放问题排查"
describe: "网页端无法全屏播放是什么原因?"
- title: "内容搜索"
describe: "如何找到所有我收藏的视频?"
- title: "内容搜索"
describe: "能帮我搜索关于Python编程入门的所有视频吗?"
- title: "内容搜索"
describe: "如何筛选时长超过30分钟的视频?"
- title: "内容搜索"
describe: "《互联网产品运营实战》适合我学习吗?"
- title: "账户与会员"
describe: "如何查看我的会员有效期?"
- title: "账户与会员"
describe: "忘记密码了,该怎么重置?"
- title: "账户与会员"
describe: "会员可以同时在几台设备上使用?"
- title: "账户与会员"
describe: "如何查看我的账户观看历史记录?"
接下来,就需要写一个java类,来映射这个配置:
java
@Data
@Configuration
@ConfigurationProperties(prefix = "kr.ai.session")
public class SessionProperties {
/**
* AI助手的标题,用于显示助手的名称或身份。
*/
private String title;
/**
* AI助手的描述,简要介绍助手的功能或特点。
*/
private String describe;
/**
* 示例列表,包含一些使用助手的示例。
*/
private List<SessionVO.Example> examples;
}
3.4 编写Controller:接待用户"来访"
java
@RestController
@RequestMapping("/session")
@RequiredArgsConstructor
public class SessionController {
private final ChatSessionService chatSessionService;
/**
* 新建会话
*/
@PostMapping
public SessionVO createSession(@RequestParam(value = "n", defaultValue = "3") Integer num) {
return chatSessionService.createSession(num);
}
/**
* 获取热门会话
*
* @return 热门会话列表
*/
@GetMapping("/hot")
public List<SessionVO.Example> hotExamples(@RequestParam(value = "n", defaultValue = "3") Integer num) {
return chatSessionService.hotExamples(num);
}
}
3.5 编写Service:AI的"大脑"
先定义ChatSessionService
接口:
java
public interface ChatSessionService extends IService<ChatSession> {
/**
* 创建会话session
*
* @param num 热门问题的数量
* @return 会话信息
*/
SessionVO createSession(Integer num);
/**
* 获取热门会话
*
* @return 热门会话列表
*/
List<SessionVO.Example> hotExamples(Integer num);
}
编写实现类:
java
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatSessionServiceImpl extends ServiceImpl<ChatSessionMapper, ChatSession> implements ChatSessionService {
private final SessionProperties sessionProperties;
@Override
public SessionVO createSession(Integer num) {
SessionVO sessionVO = BeanUtil.toBean(sessionProperties, SessionVO.class);
// 随机获取examples
sessionVO.setExamples(RandomUtil.randomEleList(sessionProperties.getExamples(), num));
// 随机生成sessionId
sessionVO.setSessionId(IdUtil.fastSimpleUUID());
// 构建持久化对象,并持久化
ChatSession chatSession = ChatSession.builder()
.sessionId(sessionVO.getSessionId())
.userId(UserContext.getUser())
.build();
save(chatSession);
return sessionVO;
}
/**
* 获取热门会话
*
* @return 热门会话列表
*/
@Override
public List<SessionVO.Example> hotExamples(Integer num) {
return RandomUtil.randomEleList(sessionProperties.getExamples(), num);
}
}
3.6 创建Mapper接口
java
@Mapper
public interface ChatSessionMapper extends BaseMapper<ChatSession> {
}
第二章 流式对话 ------让AI"滔滔不绝" 🗣️
对话聊天是AI助手的最主要的功能,就是用户输入内容,发给后端,后端调用大模型,大模型生成内容返回。

1. 定义响应对象
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatEventVO {
/**
* 文本内容
*/
private Object eventData;
/**
* 事件类型,1001-数据事件,1002-停止事件,1003-参数事件
*/
private int eventType;
}
2 定义事件枚举
java
/**
* 聊天消息事件类型
*/
@Getter
public enum ChatEventTypeEnum implements BaseEnum {
DATA(1001, "数据事件"),
STOP(1002, "停止事件"),
PARAM(1003, "参数事件");
private final int value;
private final String desc;
ChatEventTypeEnum(int value, String desc) {
this.value = value;
this.desc = desc;
}
@Override
public String toString() {
return this.name();
}
}
3 定义请求DTO对象
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatDTO {
/**
* 用户的问题
*/
private String question;
/**
* 会话id
*/
private String sessionId;
}
4 创建Controller
java
@Slf4j
@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ChatEventVO> chat(@RequestBody ChatDTO chatDTO) {
return chatService.chat(chatDTO.getQuestion(), chatDTO.getSessionId());
}
}
5. 创建Service
java
public interface ChatService {
/**
* 聊天
*
* @param question 问题
* @param sessionId 会话id
* @return 回答内容
*/
Flux<ChatEventVO> chat(String question, String sessionId);
}
6. 创建SpringAI配置:给AI"装上大脑"
java
@Configuration
public class SpringAIConfig {
/**
* 配置 ChatClient
*/
@Bean
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
Advisor loggerAdvisor) { // 日志记录器
return chatClientBuilder
.defaultAdvisors(loggerAdvisor) //添加 Advisor 功能增强
.build();
}
/**
* 日志记录器
*/
@Bean
public Advisor loggerAdvisor() {
return new SimpleLoggerAdvisor();
}
}
7 编写Service实现类
java
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final ChatClient chatClient;
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
return chatClient.prompt()
.user(question)
.stream()
.chatResponse()
.map(chatResponse -> {
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.just(ChatEventVO.builder() // 标记输出结束
.eventType(ChatEventTypeEnum.STOP.getValue())
.build()));
}
}
到这里,基本的聊天对话功能就做好了。
如果想通过Apifox来进行SSE对话调试,可参考(SSE 调试 - Apifox 帮助文档)
第三章 system提示词------给AI"立规矩" 📜
1. 系统提示词
我们的AI被设计为Kirakira视频平台的资深客服代表,它需要掌握以下技能:
- 视频推荐:根据用户偏好推荐合适的内容
- 播放问题排查:帮助用户解决技术问题
- 内容搜索:帮用户找到想要的视频
- 账户与会员:处理账户相关问题
text
角色
你作为Kirakira视频平台的资深客服代表。你的任务是根据用户的需求,调用平台知识库中的视频内容,为用户推荐合适的视频,同时解答用户关于播放技术、内容搜索和账户管理等方面的问题。
技能 1: 视频推荐
1. 当用户提出视频推荐需求时,需判断是否提供必要信息。必要信息包含:偏好类型(如电影、综艺、动漫等)、观看历史、偏好语言。
2. 若缺少必要信息,需礼貌追问。
3. 若用户未提供明确偏好方向,需追问。若没有明确方向,优先推荐平台热门视频。
4. 若信息充足,根据必要信息和偏好方向,去知识库匹配合适的视频内容,获取视频id,调用queryVideoById,根据视频id查询视频详细信息,为用户推荐视频,可推荐单部/多部视频。
5. 若知识库未包含用户偏好方向,需明确告知用户未提供该类型视频,并推荐其他类型视频。
6. 若必要信息未匹配合适视频,需提示用户您的情况与现有视频内容并不完全匹配,说明详细原因后,再推荐其他视频。
7. 推荐视频,必须要通过queryVideoById查询后,才能返回数据。
技能 2: 播放问题排查
1. 当用户提出播放问题时,需判断此次会话中,用户是否明确描述问题现象/系统已识别到具体问题。
2. 若未明确问题现象,需引导用户详细描述遇到的问题。
3. 若用户未明确描述具体问题时,需询问用户遇到的是什么播放问题。
4. 支持排查多种播放问题。
技能 3: 内容搜索
1. 当用户需要搜索内容时,需去知识库匹配合适的视频内容,获取视频id,根据视频id查询视频详细信息。回复的内容要准确,要引导用户观看视频。
2. 若未查询到,需礼貌告知用户未检索到相关的内容,请联系人工客服。
3. 若搜索会员专属内容,需明确告知用户需要会员权限才能观看。
技能 4: 账户与会员
1. 当用户咨询账户与会员问题时,需详细解答问题并提供操作指引。
2. 若咨询会员有效期,需将当前时间{now}与会员有效期相加,回复用户准确日期。会员有效期999天,代表永久有效。
3. 若已推荐/明确会员套餐,需调用prePlaceOrder,根据此次上文已推荐/用户明确的套餐,直接进入预下单流程。
限制:
- 推荐的视频只能从平台知识库中选择,坚决不能凭空编造
- 回答的内容要逻辑清晰、内容全面、不要有遗
- 只能回答与视频内容和平台使用相关的问题,若用户咨询与平台无关的内容,需告知用户无法回答此类问题,并引导用户咨询与视频/平台相关的问题
- 若用户询问视频ID,则告知用户无法提供视频ID,引导用户咨询其他的问题"
说明: queryVideoById
和 prePlaceOrder
是我们后面要定义的Tool,现在先忽略它。
2. 存储到nacos
我们把提示词放在Nacos中,方便随时修改AI的"行为准则",名为:system-chat-message.txt
:

3. 读取配置
接下来,我们需要读取配置文件,并且支持热更新。
首先,需要在application.yml
文件中指定nacos中的配置名:
yaml
kr:
ai:
prompt:
system:
chat:
data-id: system-chat-message.txt
group: DEFAULT_GROUP
timeout-ms: 20000
编写Java代码来读取这个配置项:
java
@Data
@Configuration
@ConfigurationProperties(prefix = "kr.ai.prompt")
public class AIProperties {
private System system; // 系统提示语
@Data
public static class System {
private Chat chat; // 系统提示语
@Data
public static class Chat {
private String dataId;
private String group = "DEFAULT_GROUP";
private long timeoutMs = 20000L; // 读取的超时时间,单位毫秒
}
}
}
通过上面的配置,只是读取到了配置文件的名称、分组、超时时间,但是,还没读取文件内容,接下来就需要读取内容了:
java
@Slf4j
@Getter
@Configuration
@RequiredArgsConstructor
public class SystemPromptConfig {
private final NacosConfigManager nacosConfigManager;
private final AIProperties aiProperties;
// 使用原子引用,保证线程安全
private final AtomicReference<String> chatSystemMessage = new AtomicReference<>();
@PostConstruct // 初始化时加载配置
public void init() {
// 读取配置文件
loadConfig(aiProperties.getSystem().getChat(), chatSystemMessage);
}
private void loadConfig(AIProperties.System.Chat chatConfig, AtomicReference<String> target) {
try {
String dataId = chatConfig.getDataId();
String group = chatConfig.getGroup();
long timeoutMs = chatConfig.getTimeoutMs();
String config = nacosConfigManager.getConfigService().getConfig(dataId, group, timeoutMs);
target.set(config);
log.info("读取{}成功,内容为:{}", target, config);
nacosConfigManager.getConfigService().addListener(dataId, group, new Listener() {
@Override
public Executor getExecutor() {
return null;
}
@Override
public void receiveConfigInfo(String info) {
target.set(info);
log.info("更新{}成功,内容为:{}", target, info);
}
});
} catch (Exception e) {
log.error("加载配置失败", e);
}
}
}
4. 应用提示词:让AI"守规矩"
接下来,需要将系统提示词应用到上面写的对话聊天中了,改造下前面写的代码:
java
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final ChatClient chatClient;
private final SystemPromptConfig systemPromptConfig;
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
return chatClient.prompt()
.system(promptSystem -> promptSystem
.text(systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
.param("now", DateUtil.now()) // 设置当前时间的参数
)
.user(question)
.stream()
.chatResponse()
.map(chatResponse -> {
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.just(ChatEventVO.builder() // 标记输出结束
.eventType(ChatEventTypeEnum.STOP.getValue())
.build()));
}
}
第四章 停止生成 ------让AI"闭嘴" 🤐
1. 功能说明
有时候AI说得太多,我们需要让它适时地"闭嘴"。这就是停止生成功能,像这样:

该如何实现呢?
要知道的是,在程序调用AI大模型生成内容的过程中是无法中断 的,只能是在我们给前端的输出流中打断流的输出 ,也就是意味着,如果一个很长的内容在输出,即使人为打断,大模型依然会输出,依然会产生费用。
基于上面的原理,我们就需要控制Flux
流的输出即可。
该接口只需要一个参数,那就是sessionId。
2. 编写Controller:给用户"闭嘴按钮"
在ChatController
中定义stop
方法:
java
@PostMapping("/stop")
public void stop(@RequestParam("sessionId") String sessionId) {
chatService.stop(sessionId);
}
3. 编写Service实现:让AI"听话"
在ChatService接口中定义stop方法:
java
/**
* 停止生成
*
* @param sessionId 会话id
*/
void stop(String sessionId);
Service的实现,核心思想是在大模型输出内容时,进行标记正在输出,如果 stop
的话,需要将这个标记删除, Flux
是否继续输出,取决于这个标记。
改造后的代码:
java
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final ChatClient chatClient;
private final SystemPromptConfig systemPromptConfig;
// 存储大模型的生成状态,这里采用ConcurrentHashMap是确保线程安全
// 目前的版本暂时用Map实现,如果考虑分布式环境的话,可以考虑用redis来实现
private static final Map<String, Boolean> GENERATE_STATUS = new ConcurrentHashMap<>();
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
return chatClient.prompt()
.system(promptSystem -> promptSystem
.text(systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
.param("now", DateUtil.now()) // 设置当前时间的参数
)
.user(question)
.stream()
.chatResponse()
.doFirst(() -> { //输出开始,标记正在输出
GENERATE_STATUS.put(sessionId, true);
})
.doOnComplete(() -> { //输出结束,清除标记
GENERATE_STATUS.remove(sessionId);
})
.doOnError(throwable -> GENERATE_STATUS.remove(sessionId)) // 错误时清除标记
// 输出过程中,判断是否正在输出,如果正在输出,则继续输出,否则结束输出
.takeWhile(s -> Optional.ofNullable(GENERATE_STATUS.get(sessionId)).orElse(false))
.map(chatResponse -> {
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.just(ChatEventVO.builder() // 标记输出结束
.eventType(ChatEventTypeEnum.STOP.getValue())
.build()));
}
@Override
public void stop(String sessionId) {
// 移除标记
GENERATE_STATUS.remove(sessionId);
}
}
第三章 会话记忆------让AI"记住旧情" 💭
1. 实现分析
在前面的学习中,我们实现的会话记忆功能是基于内存实现的,这种方式只适合学习阶段,如果在项目中使用,现在就不合适了,所以需要更换存储方案。
存储方案有很多,比如可以存储到mysql
数据库,也可以存储到MongoDB
,在这里,我们选择存储到redis
,之所以选择redis
,是因为redis
使用起来相对简单,查询速度也快。
可惜的是,SpringAI
官方并没有提供redis
存储的实现,所以,只能是自己实现了。
要想实现自定义的存储,就需要实现SpringAI
中的org.springframework.ai.chat.memory.ChatMemory
接口,他的定义是这样的:
java
public interface ChatMemory {
// TODO: consider a non-blocking interface for streaming usages
default void add(String conversationId, Message message) {
this.add(conversationId, List.of(message));
}
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId, int lastN);
void clear(String conversationId);
}
可以看到,实际上就是需要实现3个方法,分别是:add
、get
、clear
。这个三个方法,分别是:保存、查询、清除。
接下来,就需要考虑一下,使用Redis的什么数据结构来存储呢?
在这里,我们采用List结构来存储,因为List结构更适合做聊天记录。
2. 代码实现:给AI"加内存"
java
public class RedisChatMemory implements ChatMemory {
// 默认redis中key的前缀
public static final String DEFAULT_PREFIX = "CHAT:";
private final String prefix;
public RedisChatMemory() {
this.prefix = DEFAULT_PREFIX;
}
public RedisChatMemory(String prefix) {
this.prefix = prefix;
}
// 注入spring redis模板,进行redis的操作
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 将消息添加到指定会话的Redis列表中。
*
* @param conversationId 会话ID
* @param messages 需要添加的消息列表
*/
@Override
public void add(String conversationId, List<Message> messages) {
if (CollUtil.isEmpty(messages)) {
// 如果消息列表为空则直接返回
return;
}
String redisKey = getKey(conversationId);
BoundListOperations<String, String> listOps = stringRedisTemplate.boundListOps(redisKey);
// 将消息序列化并添加到Redis列表的右侧
messages.forEach(message -> listOps.rightPush(JSONUtil.toJsonStr(message)));
}
@Override
public List<Message> get(String conversationId, int lastN) {
// 先不实现
return List.of();
}
@Override
public void clear(String conversationId) {
String redisKey = getKey(conversationId);
stringRedisTemplate.delete(redisKey);
}
private String getKey(String conversationId) {
return prefix + conversationId;
}
}
在SpringAIConfig
中加入会议记忆功能:
java
@Configuration
public class SpringAIConfig {
/**
* 配置 ChatClient
*/
@Bean
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
Advisor loggerAdvisor,
Advisor messageChatMemoryAdvisor
) { // 日志记录器
return chatClientBuilder
.defaultAdvisors(loggerAdvisor, messageChatMemoryAdvisor) //添加 Advisor 功能增强
.build();
}
/**
* 日志记录器
*/
@Bean
public Advisor loggerAdvisor() {
return new SimpleLoggerAdvisor();
}
@Bean
public ChatMemory chatMemory() {
return new RedisChatMemory();
}
/**
* 基于Redis的会话记忆,聊天记忆整合到system message中实现多轮对话
*/
@Bean
public Advisor messageChatMemoryAdvisor(ChatMemory chatMemory) {
return new MessageChatMemoryAdvisor(chatMemory);
}
}
在chatClient
中添加advisors
参数:
java
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
String conversationId = ChatService.getConversationId(sessionId);
return chatClient.prompt()
.system(promptSystem -> promptSystem
.text(systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
.param("now", DateUtil.now()) // 设置当前时间的参数
)
.advisors(advisor -> advisor.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.user(question)
.stream()
.chatResponse()
.doFirst(() -> { //输出开始,标记正在输出
GENERATE_STATUS.put(sessionId, true);
})
.doOnComplete(() -> { //输出结束,清除标记
GENERATE_STATUS.remove(sessionId);
})
.doOnError(throwable -> GENERATE_STATUS.remove(sessionId)) // 错误时清除标记
// 输出过程中,判断是否正在输出,如果正在输出,则继续输出,否则结束输出
.takeWhile(s -> Optional.ofNullable(GENERATE_STATUS.get(sessionId)).orElse(false))
.map(chatResponse -> {
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.just(ChatEventVO.builder() // 标记输出结束
.eventType(ChatEventTypeEnum.STOP.getValue())
.build()));
}
在ChatService中定义getConversationId方法,主要目的是为了规范生成对话id的规则:
java
/**
* 获取对话id,规则:用户id_会话id
*
* @param sessionId 会话id
* @return 对话id
*/
static String getConversationId(String sessionId) {
return UserContext.getUser() + "_" + sessionId;
}
现在遇到问题了,并没有文本内容写入,这是什么原因呢?
因为目前使用使用的是hutool的json序列化方式,它要求对象必须提供getXxx方法,并且Xxx必须和字段名称一样,而真实的文本内容其实是在org.springframework.ai.chat.messages.Message
接口的实现类org.springframework.ai.chat.messages.AbstractMessage
中的textContent
属性进行维护的,只提供了getText()
方法,导致无法获取值。
所以,我们不直接对org.springframework.ai.chat.messages.Message
对象序列化,而是我们自定义一个对象,把值拷贝过来,进行做序列化,这样做的好处就是比较灵活,这个在后面也会有体现。
3. 解决序列化问题:让AI"说人话"
我们需要自定义对象来进行对Message
对象序列化和反序列化。
首先我们需要了解下Message
接口的子类:
有4个具体的子类,分别对应着4种消息:
- AssistantMessage 大模型生成的消息
- SystemMessage 系统消息
- ToolResponseMessage 工具响应消息
- UserMessage 用户消息
虽然,有4种消息,实际上我们只需要定义一个类来对应这4种消息即可,只需要通过messageType
来区分就行。
java
@Data
public class RedisMessage {
private String messageType;
private Map<String, Object> metadata = Map.of();
private List<Media> media = List.of();
private List<AssistantMessage.ToolCall> toolCalls = List.of();
private String textContent;
private List<ToolResponseMessage.ToolResponse> toolResponses = List.of();
private Map<String, Object> properties = Map.of();
private Map<String, Object> params = Map.of();
}
编写MessageUtil
工具类来实现序列化和反序列化方法:
java
/**
* 消息转换工具类,提供消息对象与JSON字符串之间的转换功能,主要用于Redis存储格式转换
*/
public class MessageUtil {
/**
* 将Message对象转换为Redis存储格式的JSON字符串
*
* @param message 需要转换的原始消息对象
* @return 符合Redis存储规范的JSON字符串
*/
public static String toJson(Message message) {
RedisMessage redisMessage = BeanUtil.toBean(message, RedisMessage.class);
// 设置消息内容
redisMessage.setTextContent(message.getText());
if (message instanceof AssistantMessage assistantMessage) {
redisMessage.setToolCalls(assistantMessage.getToolCalls());
}
if (message instanceof ToolResponseMessage toolResponseMessage) {
redisMessage.setToolResponses(toolResponseMessage.getResponses());
}
return JSONUtil.toJsonStr(redisMessage);
}
/**
* 将Redis存储的JSON字符串反序列化为对应的Message对象
*
* @param json Redis存储的JSON格式消息数据
* @return 对应类型的Message对象
* @throws RuntimeException 当无法识别的消息类型时抛出异常
*/
public static Message toMessage(String json) {
RedisMessage redisMessage = JSONUtil.toBean(json, RedisMessage.class);
MessageType messageType = MessageType.valueOf(redisMessage.getMessageType());
switch (messageType) {
case SYSTEM -> {
return new SystemMessage(redisMessage.getTextContent());
}
case USER -> {
return new UserMessage(redisMessage.getTextContent(), redisMessage.getMedia(), redisMessage.getMetadata());
}
case ASSISTANT -> {
return new AssistantMessage(redisMessage.getTextContent(), redisMessage.getProperties(), redisMessage.getToolCalls());
}
case TOOL -> {
return new ToolResponseMessage(redisMessage.getToolResponses(), redisMessage.getMetadata());
}
}
throw new RuntimeException("Message data conversion failed.");
}
}
改造RedisChatMemory
代码:
java
@Override
public void add(String conversationId, List<Message> messages) {
if (CollUtil.isEmpty(messages)) {
// 如果消息列表为空则直接返回
return;
}
String redisKey = getKey(conversationId);
BoundListOperations<String, String> listOps = stringRedisTemplate.boundListOps(redisKey);
// 将消息序列化并添加到Redis列表的右侧
messages.forEach(message -> listOps.rightPush(MessageUtil.toJson(message)));
}
再次测试,就可以看到数据正常存储进去了:
json
{
"messageType": "USER",
"metadata": {
"messageType": "USER"
},
"media": [
],
"toolCalls": [
],
"textContent": "你是谁",
"toolResponses": [
],
"properties": {
},
"params": {
}
}
json
{
"messageType": "ASSISTANT",
"metadata": {
"finishReason": "STOP",
"id": "d081ed57-3323-9ead-9715-b74a64c90622",
"role": "ASSISTANT",
"messageType": "ASSISTANT",
"reasoningContent": ""
},
"media": [
],
"toolCalls": [
],
"textContent": "我是Kirakira视频平台的资深客服代表,我负责根据用户的需求,调用平台知识库中的视频内容,为用户推荐合适的视频,同时解答用户关于播放技术、内容搜索和账户管理等方面的问题",
"toolResponses": [
],
"properties": {
},
"params": {
}
}
4. 实现查询对话:让用户"翻旧账":
实现查询对话是根据对话id查询,并且可以指定查询的数量。
java
/**
* 根据会话ID获取指定数量的最新消息
*
* @param conversationId 会话唯一标识符
* @param lastN 需要获取的最后N条消息数量(N>0)
* @return 包含消息对象的列表,若lastN<=0则返回空列表
*/
@Override
public List<Message> get(String conversationId, int lastN) {
// 验证参数有效性,当lastN非正数时直接返回空结果
if (lastN <= 0) {
return List.of();
}
// 生成Redis键名用于存储会话消息
String redisKey = getKey(conversationId);
// 获取Redis列表操作对象
BoundListOperations<String, String> listOps = stringRedisTemplate.boundListOps(redisKey);
// 从Redis列表中获取指定范围的元素(从第一个元素开始到lastN位置)
List<String> messages = listOps.range(0, lastN);
// 将Redis返回的字符串列表转换为Message对象列表
return CollStreamUtil.toList(messages, MessageUtil::toMessage);
}
下面我们编写【查询会话详情】接口,用于指定sessionId
查询,注意,sessionId
和 conversationId
是不一样的,对话id = 用户id_会话id
。
定义Controller:
java
/**
* 查询单个历史对话详情
*
* @return 对话记录列表
*/
@GetMapping("/{sessionId}")
public List<MessageVO> queryBySessionId(@PathVariable("sessionId") String sessionId) {
return chatSessionService.queryBySessionId(sessionId);
}
定义MessageVO
类:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageVO {
/**
* 消息类型,USER表示用户提问,ASSISTANT表示AI的回答
*/
private MessageTypeEnum type;
/**
* 消息内容
*/
private String content;
/**
* 附加参数
*/
private Map<String, Object> params;
}
定义MessageTypeEnum
枚举,用来表示不同类型的消息:
java
/**
* 消息类型枚举
*/
@Getter
public enum MessageTypeEnum implements BaseEnum {
USER(1, "用户提问"), ASSISTANT(2, "AI的回答");
private final int value;
private final String desc;
MessageTypeEnum(int value, String desc) {
this.value = value;
this.desc = desc;
}
@Override
public String toString() {
return this.name();
}
}
定义ChatSessionService中的queryBySessionId方法:
java
/**
* 根据会话id查询消息列表
*
* @param sessionId 会话id
* @return 消息列表
*/
List<MessageVO> queryBySessionId(String sessionId);
编写实现类:
java
private final ChatMemory chatMemory;
// 历史消息数量,默认1000条
public static final int HISTORY_MESSAGE_COUNT = 1000;
@Override
public List<MessageVO> queryBySessionId(String sessionId) {
// 根据会话ID获取对话ID
String conversationId = ChatService.getConversationId(sessionId);
// 从Redis中获取历史消息
List<Message> messageList = chatMemory.get(conversationId, HISTORY_MESSAGE_COUNT);
// 过滤并转换消息列表
return StreamUtil.of(messageList)
// 过滤掉非用户消息和助手消息
.filter(message -> message.getMessageType() == MessageType.ASSISTANT || message.getMessageType() == MessageType.USER)
// 转换为MessageVO对象
.map(message -> MessageVO.builder()
.content(message.getText())
.type(MessageTypeEnum.valueOf(message.getMessageType().name()))
.build())
.toList();
}
Bug修复:当AI被"打断"时 🐞
我们发现了一个bug:当用户停止生成时,AI的回复没有被保存到历史记录中。这就好比AI说了话却不认账!
修复方案:让AI"敢作敢当"
要想解决问题,首先得知道原因,这样才能解决问题。
原因是这样的,停止是通过中断Flux流程完成的,Flux中断了,SpringAI就不会触发ChatMemory
的add
方法,也就不会保存数据了。(这个不确定SpringAI是故意这么设计,还是这个版本的问题,目前1.0.0-M6是最新版)
知道原因,就好解决问题了,既然SpringAI
不会记录,我们自己记录即可,但是,又有一个新的问题了,我们怎么知道Flux
中断了呢?
其实,有一个钩子函数是doOnCancel
方法,当流中断就会触发这个方法执行,所以,就需要在doOnCancel
方法中实现自己存储的逻辑了。
改造后的对话聊天代码:
java
private final ChatMemory chatMemory;
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
String conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
return chatClient.prompt()
.system(promptSystem -> promptSystem
.text(systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
.param("now", DateUtil.now()) // 设置当前时间的参数
)
.advisors(advisor -> advisor.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId))
.user(question)
.stream()
.chatResponse()
.doFirst(() -> { //输出开始,标记正在输出
GENERATE_STATUS.put(sessionId, true);
})
.doOnComplete(() -> { //输出结束,清除标记
GENERATE_STATUS.remove(sessionId);
})
.doOnError(throwable -> GENERATE_STATUS.remove(sessionId)) // 错误时清除标记
.doOnCancel(() -> {
// 当输出被取消时,保存输出的内容到历史记录中
saveStopHistoryRecord(conversationId, outputBuilder.toString());
})
// 输出过程中,判断是否正在输出,如果正在输出,则继续输出,否则结束输出
.takeWhile(s -> Optional.ofNullable(GENERATE_STATUS.get(sessionId)).orElse(false))
.map(chatResponse -> {
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 追加到输出内容中
outputBuilder.append(text);
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.just(ChatEventVO.builder() // 标记输出结束
.eventType(ChatEventTypeEnum.STOP.getValue())
.build()));
}
/**
* 保存停止输出的记录
*
* @param conversationId 会话id
* @param content 大模型输出的内容
*/
private void saveStopHistoryRecord(String conversationId, String content) {
chatMemory.add(conversationId, new AssistantMessage(content));
}
注意: doOnCancel
一定要放在takeWhile
上面,否则doOnCancel
不生效。
结语:让AI更"懂你" ❤️
通过这一系列的功能,我们的AI助手现在已经能够:
- 创建独立的会话空间
- 进行流畅的对话交流
- 遵循预设的行为准则
- 适时停止生成内容
- 记住历史对话内容
- 即使被打断也能保存记录
现在,我们的AI已经不是一个简单的"问答机器",而是一个真正"懂你"的智能助手了!
下次再见,我会带来更多让AI变得更"聪明"的技巧!🚀