Administrator
Administrator
发布于 2025-08-31 / 102 阅读
0
0

【三】springAi聊天记忆功能(本地内存和数据库存储)

调用过大模型api的都知道,维护一个聊天记录还是代码比较繁琐的,用springAi怎么使用记忆功能呢

@RestController
public class ChatController {
    private static final ConcurrentHashMap < String, MessageWindowChatMemory > memoryMap = new ConcurrentHashMap < > ();
    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
    @Resource(name = "openAiChatClient")
    private ChatClient openAiChatClient;

    /**
     * OpenAI:带记忆上下文
     */
    @GetMapping(value = "/openAi", produces = "text/stream;charset=UTF-8")
    public Flux<String> openAiChat(@RequestParam("userPrompt") String userPrompt,
                                   @RequestParam("userId") String userId) {

        MessageWindowChatMemory chatMemory = memoryMap.computeIfAbsent(userId,
                id -> MessageWindowChatMemory.builder().build());
        chatMemory.add(userId, new UserMessage(userPrompt));

        // 用于拼接完整输出
        StringBuilder fullContent = new StringBuilder();

        return openAiChatClient.prompt()
                .user(userPrompt)
                //这一步是把历史记录堆积到上下文聊天中
                .messages(memoryMap.get(userId).get(userId))
                .stream()
                .content()
                .doOnNext(fullContent::append)
                .doOnComplete(() -> {
                    String answer = fullContent.toString();
                    log.info("完整返回结果:" + answer);
                    chatMemory.add(userId, new AssistantMessage(answer));
                });
    }
}

实现效果

现在聊天记录是保存在jvm本地中的,项目重新启动就没有数据了,所以我们需要将记忆存储在数据库或者redis中

聊天记录存储数据库中(未完待续)

springAi有一个很坑爹的点就是他集成的太完善了,我如果直接用他封装的mysql存储数据的话,他会很贴心的用滑动窗口给我保存最近n条【1.0.0的版本,截止目前是最新版本】,也就是说他能直接删我数据库!所以我们需要自己封装一个mysql查询,删除,新增聊天记录的接口,具体方法如下

  1. 先把必要的配置给加上,包括依赖和配置文件

   <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
   </dependency>
   <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
   </dependency>
   <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
   </dependency>

spring:
    # mysql 配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/memory?rewriteBatchedStatements=true&useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
    username: root
    password: 12345678
    type: com.zaxxer.hikari.HikariDataSource
    hikari:
      connection-timeout: 30000         # 等待连接池获取连接的最大时间(ms)
      initialization-fail-timeout: -1   # 不在启动时报错,而是延迟容忍连接失败
      validation-timeout: 5000          # 校验连接有效性的最大等待时间
      max-lifetime: 1800000             # 连接最大生命周期(ms)
      maximum-pool-size: 15
      minimum-idle: 5
      idle-timeout: 30000

# mybatis 配置内容
mybatis:
  # 配置 Mapper XML 地址
  mapper-locations: classpath:/mapper/*Mapper.xml
  configuration:
    # 使用驼峰命名法转换字段
    map-underscore-to-camel-case: true
    # 配置数据库日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

我自己创建了一个数据库,并且只放了一张聊天记录表

CREATE TABLE `chat_message` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` int DEFAULT NULL COMMENT '用户id',
  `message` varchar(255) DEFAULT NULL COMMENT '聊天消息',
  `type` varchar(255) DEFAULT NULL COMMENT 'user,assistant,system,tool',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `modify_time` datetime DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3 COMMENT='聊天消息表';
  1. 继承ChatMemory接口,重写里面方法(我写的稍微有点繁琐,定义了业务层,仓储层,mapper层,sql层,大家想测试怎么简单怎么来就行)
    这里有一点要注意,要写一个类型转换工具,就是将自己数据库的聊天消息类转换成springAi的Message类,就不会有问题了,我自己写了一个简单的MessageUtil

package com.gyq;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.gyq.entity.ChatMessage;
import org.springframework.ai.chat.messages.*;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;


public class MessageUtil implements Serializable {
    /**
     * 将自定义 ChatMessage列表 转为 Spring AI Message列表
     */
    public static List<Message> toAiMessageList(List<ChatMessage> list) {
        List<Message> result = new ArrayList<>();
        for (ChatMessage chatMessage : list) {
            Message aiMessage = toAiMessage(chatMessage);
            result.add(aiMessage);
        }
        return result;
    }

    /**
     * 将自定义 ChatMessage 转为 Spring AI Message
     */
    public static Message toAiMessage(ChatMessage cm) {
        if (cm == null) throw new IllegalArgumentException("ChatMessage cannot be null");

        String type = cm.getType() == null ? "" : cm.getType().trim().toLowerCase(Locale.ROOT);
        String content = cm.getMessage();

        return switch (type) {
            case "system" -> new SystemMessage(content);
            case "user" -> new UserMessage(content);
            case "assistant" -> new AssistantMessage(content);
            case "tool" -> new ToolResponseMessage(toToolResponses(content));
            default -> throw new IllegalArgumentException("Unsupported message type: " + cm.getType());
        };
    }

    /**
     * 解析 tool 类型的 message 字符串为 ToolResponse 列表
     */
    private static List<ToolResponseMessage.ToolResponse> toToolResponses(String content) {
        if (content == null || content.isBlank()) {
            return List.of(new ToolResponseMessage.ToolResponse(UUID.randomUUID().toString(), null, ""));
        }

        // JSON 数组
        if (JSON.isValidArray(content)) {
            JSONArray arr = JSON.parseArray(content);
            List<ToolResponseMessage.ToolResponse> list = new ArrayList<>();
            for (int i = 0; i < arr.size(); i++) {
                JSONObject obj = arr.getJSONObject(i);
                list.add(new ToolResponseMessage.ToolResponse(
                        obj.getString("id"),
                        obj.getString("name"),
                        obj.getString("responseData")
                ));
            }
            return list;
        }

        // JSON 对象
        if (JSON.isValidObject(content)) {
            return List.of(new ToolResponseMessage.ToolResponse(
                    UUID.randomUUID().toString(), null, content
            ));
        }

        // 普通文本
        return List.of(new ToolResponseMessage.ToolResponse(
                UUID.randomUUID().toString(), null, content
        ));
    }

}
/** 
 * MysqlChatMemory类,继承ChatMemory,重写了新增,获取,删除方法
 */

package com.gyq.config;

import com.gyq.MessageUtil;
import com.gyq.entity.ChatMessage;
import com.gyq.repository.MysqlChatMemoryRepository;
import jakarta.annotation.Resource;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;

import java.util.List;

@Component
public class MysqlChatMemory implements ChatMemory {
    private final static Integer max_memory = 10;
    @Resource
    private MysqlChatMemoryRepository mysqlChatMemoryRepository;

    @Override
    public void add(@NotNull String conversationId, @NotNull List<Message> messages) {
        Assert.notNull(conversationId, "conversationId must not be null");
        Assert.notNull(messages, "messages must not be null");
        mysqlChatMemoryRepository.insert(conversationId, messages.get(0));
    }


    @NotNull
    @Override
    public List<Message> get(@NotNull String conversationId) {
        List<ChatMessage> list = mysqlChatMemoryRepository.queryByConversationIdAndMaxMemory(conversationId, max_memory);
        return MessageUtil.toAiMessageList(list);
    }

    @Override
    public void clear(@NotNull String conversationId) {
        mysqlChatMemoryRepository.deleteByConversationId(conversationId);
    }
}
package com.gyq.repository;
import com.gyq.entity.ChatMessage;
import com.gyq.mapper.ChatMessageMapper;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;

@Repository
public class MysqlChatMemoryRepository {
    @Resource
    private ChatMessageMapper chatMessageMapper;

    /**
     * 批量存储到数据库
     *
     * @param conversationId 用户唯一标识
     * @param messages       消息列表
     */
    public void insert(String conversationId, Message messages) {
        ChatMessage chatMessage = new ChatMessage();
        chatMessage.setUserId(Integer.parseInt(conversationId));
        chatMessage.setMessage(messages.getText());
        chatMessage.setType(messages.getMessageType().getValue());
        chatMessage.setCreateTime(LocalDateTime.now());
        chatMessage.setModifyTime(LocalDateTime.now());
        chatMessageMapper.insert(chatMessage);
    }

    /**
     * 查询最新10条
     *
     * @param conversationId 用户id
     * @param maxMemory      limit
     * @return 聊天记录列表
     */
    public List<ChatMessage> queryByConversationIdAndMaxMemory(String conversationId, Integer maxMemory) {
        return chatMessageMapper.queryByConversationIdAndMaxMemory(conversationId,maxMemory);

    }

    /**
     * 删除某个用户的全部聊天记录
     * @param conversationId 用户id
     */
    public void deleteByConversationId(String conversationId) {
        chatMessageMapper.deleteByUserId(conversationId);
    }
}
package com.gyq.mapper;

import com.gyq.entity.ChatMessage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;


/**
 * 聊天消息表(ChatMessage)表数据库访问层
 *
 * @author makejava
 * @since 2025-09-07 22:02:41
 */
 @Mapper
public interface ChatMessageMapper {
  
 void insert(ChatMessage chatMessage);

 List<ChatMessage> queryByConversationIdAndMaxMemory(@Param("userId") String conversationId, @Param("size") Integer maxMemory);

 void deleteByUserId(@Param("userId") String conversationId);

}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gyq.mapper.ChatMessageMapper">

    <resultMap type="com.gyq.entity.ChatMessage" id="ChatMessageMap">
        <result property="id" column="id" jdbcType="INTEGER"/>
        <result property="userId" column="user_id" jdbcType="INTEGER"/>
        <result property="message" column="message" jdbcType="VARCHAR"/>
        <result property="type" column="type" jdbcType="VARCHAR"/>
        <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
        <result property="modifyTime" column="modify_time" jdbcType="TIMESTAMP"/>
    </resultMap>

    <select id="queryByConversationIdAndMaxMemory" resultType="com.gyq.entity.ChatMessage">
        select *
        from chat_message where user_id = #{userId}
        order by create_time desc limit 0,#{size}
    </select>

    <insert id="insert" keyProperty="id" useGeneratedKeys="true">
        insert into chat_message(id, user_id, message, type, create_time, modify_time)
        values (#{id}, #{userId}, #{message}, #{type}, #{createTime}, #{modifyTime})
    </insert>

    <delete id="deleteByUserId">
        delete
        from chat_message
        where user_id = #{userId}
    </delete>

</mapper>
  1. 所有的准备都是为了调用的时候优雅一点


    /**
     * OpenAI:带记忆上下文
     */
    @GetMapping(value = "/openAi", produces = "text/stream;charset=UTF-8")
    public Flux<String> openAiChat(@RequestParam("userPrompt") String userPrompt,
                                   @RequestParam("userId") String userId) {

        mysqlChatMemory.add(userId,new UserMessage(userPrompt));
        List<Message> list = mysqlChatMemory.get(userId);
        // 用于拼接完整输出
        StringBuilder fullContent = new StringBuilder();

        return openAiChatClient.prompt()
                .user(userPrompt)
                //这一步是把历史记录堆积到上下文聊天中
                .messages(list)
                .stream()
                .content()
                .doOnNext(fullContent::append)
                .doOnComplete(() -> {
                    String answer = fullContent.toString();
                    log.info("完整返回结果:" + answer);
                    mysqlChatMemory.add(userId,new AssistantMessage(answer));
                });
    }

实际测试是可以上下文关联的,同时也是保存到数据库的,如下图


评论