论如何优雅地让AI“闭嘴”:深入SpringAI的流式停止与记忆难题

前言:一次与"失忆"AI的尴尬对话

想象一下这个场景:

你兴冲冲地打开一个AI助手,问了它第一个问题:"我喜欢看科幻电影,有什么推荐吗?"

AI很快给出了一个绝妙的回答:"《星际穿越》!诺兰的神作,探讨了爱与引力一样可以穿越时空。"

你非常满意,接着问:"太好了!那这部电影的主演是谁呀?"

这时,AI陷入了沉默,仿佛在思考宇宙的终极答案。几秒后,它礼貌而茫然地回答:"您好,请问您有什么问题需要我帮忙吗?"

......

是不是瞬间有一种"对牛弹琴"的无力感?仿佛刚才那段精彩的对话从未发生过。这就是一个没有"会话"和"记忆"功能的AI------一个无比健忘、每次聊天都像初次见面的"金鱼脑"助手

而我们今天要做的,就是彻底治好它的"健忘症"!

在本篇博客中,你将跟随我的脚步,从一个最简单的AI问答功能开始,一步步为它打造一个完整的"人格":

  1. 先给它一个"家" (新建会话):让每次聊天都有独立的房间,避免和别人的对话搞混。
  2. 教它"滔滔不绝"地说话(流式对话):让回答像溪流一样自然流畅,而不是等半天才蹦出全文。
  3. 给它"立规矩" (系统提示词):让它成为专业的视频客服,而不是一个瞎聊的"江湖骗子"。
  4. 赋予它"闭嘴"的技能(停止生成):在它喋喋不休时,能优雅地让它停下来。
  5. 最终,赐予它最珍贵的"记忆" (会话记忆):让它能记住我们刚才聊到了《星际穿越》,而不是每次都从头开始"您好,请问有什么可以帮您?"

这不仅仅是一篇技术教程,更是一次神奇的"造人"体验。我们将使用 SpringAI 框架,结合 RedisNacos 等利器,手把手带你实现这一切。

准备好了吗?让我们一起,告别"金鱼脑"AI,打造一个真正懂你、记得你、能随时聊也能随时停的智能助手吧!

第一章 新建会话------ 给AI一个"家" 🏠

我们来聊聊如何给AI助手安排一个舒适的"小窝"------也就是新建会话功能。想象一下,每次和AI聊天,它都需要一个独立的房间(sessionId),这样才不会把你们的私密对话搞混。那么,这三个问题怎么解决呢?

1. 给AI发"身份证"(实现分析)

实现新建会话功能,需要解决3个问题:

  1. 如何生成sessionId?
  2. 生成的sessionId是否需要存储?
  3. 热门问题,该怎么做?

我们逐一进行分析:

问题一:如何生成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,引导用户咨询其他的问题"

说明: queryVideoByIdprePlaceOrder 是我们后面要定义的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个方法,分别是:addgetclear。这个三个方法,分别是:保存、查询、清除。

接下来,就需要考虑一下,使用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查询,注意,sessionIdconversationId是不一样的,对话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就不会触发ChatMemoryadd方法,也就不会保存数据了。(这个不确定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助手现在已经能够:

  1. 创建独立的会话空间
  2. 进行流畅的对话交流
  3. 遵循预设的行为准则
  4. 适时停止生成内容
  5. 记住历史对话内容
  6. 即使被打断也能保存记录

现在,我们的AI已经不是一个简单的"问答机器",而是一个真正"懂你"的智能助手了!

下次再见,我会带来更多让AI变得更"聪明"的技巧!🚀

相关推荐
计算机编程小咖5 分钟前
《基于大数据的农产品交易数据分析与可视化系统》选题不当,毕业答辩可能直接挂科
java·大数据·hadoop·python·数据挖掘·数据分析·spark
艾莉丝努力练剑6 分钟前
【C语言16天强化训练】从基础入门到进阶:Day 7
java·c语言·学习·算法
老华带你飞22 分钟前
校园交友|基于SprinBoot+vue的校园交友网站(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·校园交友网站
自强的小白1 小时前
学习Java24天
java·学习
Ashlee_code2 小时前
香港券商櫃台系統跨境金融研究
java·python·科技·金融·架构·系统架构·区块链
还梦呦2 小时前
2025年09月计算机二级Java选择题每日一练——第五期
java·开发语言·计算机二级
2501_924890522 小时前
商超场景徘徊识别误报率↓79%!陌讯多模态时序融合算法落地优化
java·大数据·人工智能·深度学习·算法·目标检测·计算机视觉
從南走到北3 小时前
JAVA国际版东郊到家同城按摩服务美容美发私教到店服务系统源码支持Android+IOS+H5
android·java·开发语言·ios·微信·微信小程序·小程序
毅航4 小时前
从原理到实践,讲透 MyBatis 内部池化思想的核心逻辑
后端·面试·mybatis
qianmoq4 小时前
第04章:数字流专题:IntStream让数学计算更简单
java