天机学堂AI助手智能体 - 基本对话与课程咨询

参考视频:黑马程序员SpringAI+DeepSeek大模型应用开发实战视频教程

1. 熟悉项目

天机学堂项目是一个已经开发完成的项目,我们要做的是在原有项目基础上,增加天机AI助手智能体模块,所以,对于原有系统的代码我们不用过多的研究,只需要有基本的了解即可,但是,需要知道,原有的业务是怎么样的,这样我们才能去开发新的功能。

接下来,我们需要把运行环境部署好、代码跑起来,了解一下他的业务功能。

1.1. 部署环境

1.1.1. 导入虚拟机

第一步:参考文档,将虚拟机导入到自己电脑的VMware中。 👉 虚拟机导入手册(密码:nxyt)

第二步:为了模拟使用域名访问,我们需要在本地配置hosts:

shell
192.168.150.101 git.tianji.com
192.168.150.101 jenkins.tianji.com
192.168.150.101 mq.tianji.com
192.168.150.101 nacos.tianji.com
192.168.150.101 xxljob.tianji.com
192.168.150.101 es.tianji.com
192.168.150.101 api.tianji.com
192.168.150.101 www.tianji.com
192.168.150.101 manage.tianji.com
192.168.150.101 cpolar.tianji.com

每个域名对应的服务列表如下:

名称域名账号端口
Git私服git.tianji.comtjxt/12332110880
Jenkins持续集成jenkins.tianji.comroot/12318080
RabbitMQmq.tianji.comtjxt/12332115672
Nacos控制台nacos.tianji.comnacos/nacos8848
xxl-job控制台xxljob.tianji.comadmin/1234568880
ES的Kibana控制台es.tianji.com-5601
微服务网关api.tianji.com-10010
用户端入口www.tianji.com-18081
管理端入口manage.tianji.com-18082

当我们访问上述域名时,请求实际是发送到了虚拟机,而虚拟机中的Nginx会对这些域名做反向代理,这样我们就能请求到对应的组件了:

img

我们访问用户端或者管理端页面时,也会被Nginx反向代理:

img
  • nginx的配置文件路径: /usr/local/src/nginx/conf
  • 当访问 www.tianji.com 时,请求会被代理到虚拟机中的/usr/local/src/tj-portal目录中的静态资源
  • 当访问 manage.tianji.com 时,请求会被代理到虚拟机中的/usr/local/src/tj-admin目录中的静态资源
  • 当访问 api.tianji.com 时,请求会被代理到虚拟机中的网关服务。
  • 现在访问,虽然可以看到页面,但是不能正常登录使用,因为我们还没启动网关和微服务。

1.1.2. 运行代码

第一步,在idea中通过git拉取代码,地址:https://gitee.com/zhijun.zhang/tjxt.git

📚 说明:

  • 这里拉取的是后端代码,前端是在虚拟机中,通过编译后的前端代码+nginx运行的,本地无需拉取和运行前端代码
  • 如果自己需要学习前端代码,可以到这里拉取前端源码。👉 https://gitee.com/zhijun.zhang/tj-portal-src.git
img

拉取完成后,检查分支是否是 stu-1.0,如果不是,切换到stu-1.0分支:

img

第二步,设置项目的jdk版本为17:

img

警告

🚀 注意:没有安装jdk17的同学,在资料中找到jdk17的压缩包,解压后,配置到idea中即可。

第三步:刷新maven依赖,确保所有的依赖都下载完成,不报错。

img

第四步:启动项目测试(全选 → 启动)

img

要确保所有的服务都能正常启动:

img

登录到虚拟机中的nacos,查看下微服务是否已经注册成功:

img

1.1.3. 微服务说明

微服务名称功能描述完成状态
tj-parent父工程
tj-common通用工程
tj-message消息中心
tj-gateway网关
tj-auth权限服务
tj-user用户服务
tj-pay支付服务
tj-course课程服务
tj-exam考试服务
tj-search搜索服务
tj-trade交易服务
tj-learning学习服务
tj-promotion促销服务
tj-media媒资服务
tj-data数据服务
tj-remark评价服务

1.1.4. 功能测试

1.1.4.1. 用户端

访问 http://www.tianji.com/ 可以看到用户端页面,使用 jack / 123456 账号登录:

img
img

登录成功:

img
1.1.4.2. 管理端

访问 http://manage.tianji.com/ 可以看到管理端页面,使用 13500010002 / 123456 账号登录:

img

1.2. 熟悉业务

天机学堂的业务分为两部分:

  • 学生端:其核心业务主体就是学员,所有业务围绕着学员的展开
  • 管理端:其核心业务主体包括老师、管理员、其他员工,核心业务围绕着老师展开

1.2.1. 功能演示

下面我们通过一个视频,快速的了解下天机学堂项目的业务:

此处为语雀视频卡片,点击链接查看:02.项目介绍-功能演示.mp4

1.2.2. 老师核心业务

老师的核心业务流程有:

img

📚 虽然流程并不复杂,但其中包含的业务繁多,例如:

  • 课程分类管理:课程分类的增删改查
  • 媒资管理:媒资的增删改查、媒资审核
  • 题目管理:试题的增删改查、试题批阅、审核
  • 课程管理:课程增删改查、课程上下架、课程审核、发布等等

1.2.3. 学员核心业务

学员的核心业务就是买课、学习,基本流程如下:

img

2. AI功能需求

2.1. 需求文档

需求文档:https://rp-java.itheima.net/tianji-agent-AI-V0.3/rp/index.html

2.2. 天机AI助手

在页面左下角,点击 img 唤起AI助手,进行聊天操作。如下:

img
img
img
img

2.3. 自动回复

学生在课程中可以进行提问,天机AI助手会进行自动回复。

img
img

2.4. AI文本处理

img
img
img

3. 技术架构

3.1. 整体架构

img

3.2. 实现流程

img

4. 项目集成SpringAI

4.1. 创建tj-aigc微服务

天机学堂项目采用的是微服务架构,我们要加入AI模块,是需要添加AI微服务,名为tj-aigc

AIGC 是指人工智能生成内容(Artificial Intelligence Generated Content)

首先,代码不要在stu分支直接修改,创建自己的分支,比如叫作my分支。

img
img

创建tj-aigc微服务:

img
img

4.2. 导入依赖

xml
<?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>
    <parent>
        <groupId>com.tianji</groupId>
        <artifactId>tjxt</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>tj-aigc</artifactId>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- Spring AI BOM -->
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Alibaba AI BOM -->
            <dependency>
                <groupId>com.alibaba.cloud.ai</groupId>
                <artifactId>spring-ai-alibaba-bom</artifactId>
                <version>1.0.0.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <!--api-->
        <dependency>
            <groupId>com.tianji</groupId>
            <artifactId>tj-api</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--auth-->
        <dependency>
            <groupId>com.tianji</groupId>
            <artifactId>tj-auth-resource-sdk</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--swagger依赖-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
        </dependency>
        <!--Redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--discovery-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--config-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>
        <!--loadbalancer-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <!-- spring-ai-alibaba-->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
        </dependency>

        <!--数据库相关-->
        <!-- mybatis plus 管理 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>build-info</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <mainClass>com.tianji.AIGCApplication</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

创建启动类com.tianji.AIGCApplication

java
package com.tianji; // 注意:包不要写错了

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableAsync;

import java.net.InetAddress;
import java.net.UnknownHostException;

@Slf4j
@EnableAsync
@SpringBootApplication
public class AIGCApplication {

    public static void main(String[] args) throws UnknownHostException {
        SpringApplication app = new SpringApplicationBuilder(AIGCApplication.class).build(args);
        Environment env = app.run(args).getEnvironment();
        String protocol = "http";
        if (env.getProperty("server.ssl.key-store") != null) {
            protocol = "https";
        }
        log.info("--/\n---------------------------------------------------------------------------------------\n\t" +
                        "Application '{}' is running! Access URLs:\n\t" +
                        "Local: \t\t{}://localhost:{}\n\t" +
                        "External: \t{}://{}:{}\n\t" +
                        "Profile(s): \t{}" +
                        "\n---------------------------------------------------------------------------------------",
                env.getProperty("spring.application.name"),
                protocol,
                env.getProperty("server.port"),
                protocol,
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"),
                env.getActiveProfiles());
    }
}

4.3. 配置文件

每个微服务都会有4个配置文件:

img

📚 说明:

  • application.yml 主配置文件,配置了端口号、服务名称、swagger接口等信息
  • application-dev.yml 运行在虚拟机环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息
  • application-local.yml 运行在本地环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息
  • application-test.yml 运行在测试环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息

👉 我们只需要关注 application.yml 和 application-local.yml,这两个配置文件即可。

具体的配置内容如下:

application.yml

yaml
server:
  port: 8094  #端口
  tomcat:
    uri-encoding: UTF-8   #服务编码
spring:
  profiles:
    active: local
  application:
    name: aigc-service
  main:
    allow-circular-references: true # 允许循环引用
tj:
  swagger:
    enable: true
    package-path: com.tianji.aigc.controller
    title: 天机学堂 - AIGC接口文档
    description: 该服务包含AI相关功能
    contact-name: 传智教育·研究院
    contact-url: http://www.itcast.cn/
    contact-email: zhangzhijun@itcast.cn
    version: v1.0
    enableResponseWrap: true
  auth:
    resource:
      enable: true
  jdbc:
    database: tj_aigc #指定使用的数据库名

application-dev.yml

yaml
spring:
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos注册中心
      username: nacos
      password: nacos
      discovery:
        namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0
        group: DEFAULT_GROUP
        ip: 192.168.150.101
  config:
    import:
      - nacos:${spring.application.name}.yaml # 导入本服务的配置
      - nacos:shared-spring.yaml # 共享spring配置
      - nacos:shared-redis.yaml # 共享redis配置
      - nacos:shared-logs.yaml # 共享日志配置
      - nacos:shared-feign.yaml # 共享feign配置
      - nacos:shared-mybatis.yaml # 共享mybatis配置
#logging:
#  level:
##    com.tianji: debug
#    com:
#      alibaba: debug
#    org:
#      springframework:
#        ai:
#          chat:
#            client:
#              advisor: DEBUG
#      apache:
#        hc:
#          client5:
#            http:
#              wire: debug

application-local.yml

yaml
spring:
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos注册中心
      username: nacos
      password: nacos
      discovery:
        namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0
        group: DEFAULT_GROUP
        ip: 192.168.150.1
  config:
    import:
      - nacos:${spring.application.name}.yaml # 导入本服务的配置
      - nacos:shared-spring.yaml # 共享spring配置
      - nacos:shared-redis.yaml # 共享redis配置
      - nacos:shared-logs.yaml # 共享日志配置
      - nacos:shared-feign.yaml # 共享feign配置
      - nacos:shared-mybatis.yaml # 共享mybatis配置
logging:
  level:
    com:
#      alibaba: debug
      tianji: debug
#    org:
#      springframework:
#        ai:
#          chat:
#            client:
#              advisor: DEBUG
#      apache:
#        hc:
#          client5:
#            http:
#              wire: debug

application-test.yml

yaml
spring:
  cloud:
    nacos:
      server-addr: 172.17.2.76:8848 # nacos注册中心
      username: nacos
      password: nacos
      discovery:
        namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0
        group: DEFAULT_GROUP
        ip: 172.17.2.76
  config:
    import:
      - nacos:${spring.application.name}.yaml # 导入本服务的配置
      - nacos:shared-spring.yaml # 共享spring配置
      - nacos:shared-redis.yaml # 共享redis配置
      - nacos:shared-logs.yaml # 共享日志配置
      - nacos:shared-feign.yaml # 共享feign配置
      - nacos:shared-mybatis.yaml # 共享mybatis配置
#logging:
#  level:
##    com.tianji: debug
#    com:
#      alibaba: debug
#    org:
#      springframework:
#        ai:
#          chat:
#            client:
#              advisor: DEBUG
#      apache:
#        hc:
#          client5:
#            http:
#              wire: debug

在nacos中创建配置文件aigc-service.yaml:(微服务的名字,主要是Spring AI相关的配置项)

nacos地址:http://nacos.tianji.com/

aigc-service.yaml

yaml
tj:
  ai:
    dashscope:
      key: ${ALIYUN_API_KEY}  #换成你自己的阿里百炼key 或 读取环境变量中的配置
spring:
  ai:
    dashscope:
      api-key: ${tj.ai.dashscope.key}
      chat:
        enabled: true
        options:
          model: qwen-plus
          # model: qwen2.5-1.5b-instruct  免费模型
      embedding:
        enabled: true
        options:
          model: text-embedding-v3 #向量模型
          dimensions: 1024 #向量维度维度
img

启动项目进行测试:

img
img

4.4. 配置网关

在天机项目中,所有服务的请求都要经过网关,aigc服务也不例外,在tj-gateway项目的配置文件中,添加请求映射:

application.yml

yaml
        - id: ais
          uri: lb://aigc-service
          predicates:
            - Path=/ais/**
img

📚

这个配置意思是,所有以 /ais/** 开头的请求,都会转发到aigc-service

修改完配置后,记得要重启tj-gateway服务。

4.5. 导入apifox项目

我们已经将天机AI项目的api接口发布到apifox中,现在需要将他克隆到你自己的apifox账号中,这样,就可以在apifox中进行测试接口了。

第一步,打开 链接: https://tianji-ai.apifox.cn 访问密码: itheima img

img
img
img
img

就可以在本地apifox中看到克隆的项目了:

img

可以在项目设置中,修改项目的名称、图标等信息:

img

添加全局请求参数,也就是每个请求都需要加请求token:(Authorization

img

token值在 全局变量中设置:

img

🚨 具体的值,需要到用户端获取,步骤:在用户端登录后,刷新页面,打开浏览器开发者工具,查看请求,就可以看到请求头中的token值了。

首先,确保是登录状态:

img

打开浏览器开发者工具:

img

查看请求头:

img

5. 新建会话

我们知道,在与AI对话时,需要有不同的会话,每个会话都有自己的id,叫作:sessionId,所以,我先开发新建会话功能。

5.1. 需求分析

打开天机AI助手时,就需要创建新的会话:

img
img
img

5.2. 实现分析

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

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

我们逐一进行分析:

问题一:如何生成sessionId?

解决:生成sessionId的要求是字符串的,不重复的,所以,可以使用uuid来生成,确保唯一性。

问题二:sessionId是否需要保存到数据库?

解决:是需要的,因为后续需要有查询历史对话的功能,如果不存储到数据库,是无法查询的。

img

问题三:热门问题,该怎么做?

解决:热门问题一般都是固定的问题,所以在后台中存储,随机的返回3个即可,但是,要做成可配置的,不能硬编码到代码中。所以,我们会想到把他写到nacos的配置中心中,这样就会比较灵活一些。

5.3. 接口分析

在apifox中,已经定义好了接口:

img

请求参数,只有一个,就是生成热门问题的数量,默认为3:

img

接口响应数据分析:

img

5.4. 数据库设计

数据库表已经设计好了,接下来,连接到数据库,查看表结构,数据库的连接信息如下:

ip:192.168.150.101

端口:3306

用户名:root

密码:itcast321bca

img

表结构:

img

创建表语句:

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
;

5.5. 代码实现

5.5.1. 创建ChatSession实体类

ChatSession实体类的结构要与chat_session表结构一致。

java
package com.tianji.aigc.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@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;
}

5.5.2. 创建Mapper接口

java
package com.tianji.aigc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tianji.aigc.entity.ChatSession;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ChatSessionMapper extends BaseMapper<ChatSession> {

}

5.5.3. 定义SessionVO类

根据接口的响应数据的结构,定义对应的VO类:

java
package com.tianji.aigc.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@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;
    }

}

5.5.4. 配置数据

上述接口中的数据,除了sessionId是生成的之外,其他数据都是需要配置到nacos中的,不能硬编码。

可以是这样配置:

aigc-service.yaml

yaml
tj:
  ai:
    session:
      title: Hello,我是天机AI助理
      describe: 我是由天机学堂倾力打造的智能助理,我不仅能推荐课程、答疑解惑,还能为您激发创意、畅聊心事。
      examples:
        - title: "课程推荐"
          describe: "能帮我推荐一个合适的课吗?"
        - title: "课程推荐"
          describe: "最近有什么新课吗?"
        - title: "课程推荐"
          describe: "如何选择适合我的课程?"
        - title: "课程推荐"
          describe: "有哪些热门课程推荐?"
        - title: "课程购买"
          describe: "我想要购买《Java进阶编程实战》"
        - title: "课程购买"
          describe: "我想要报名《Java高级架构与微服务》"
        - title: "课程购买"
          describe: "《Java进阶编程实战》的课程价格是多少?"
        - title: "课程购买"
          describe: "《Java高级架构与微服务》的课程价格是多少?"
        - title: "课程介绍"
          describe: "请帮我介绍一下《互联网产品运营实战》"
        - title: "课程介绍"
          describe: "《Java大数据处理与分析》这门课一共有多少节?"
        - title: "课程介绍"
          describe: "《Java高级架构与微服务》能学到什么内容?"
        - title: "课程介绍"
          describe: "《互联网产品运营实战》适合我学习吗?"
        - title: "知识讲解"
          describe: "面向对象的三大特征是什么?"
        - title: "知识讲解"
          describe: "ArrayList和LinkedList有何区别?"
        - title: "知识讲解"
          describe: "Java多线程怎么实现?"
        - title: "知识讲解"
          describe: "Spring Boot和SSM有什么区别?"
        - title: "知识讲解"
          describe: "列表和元组有什么区别?"
        - title: "知识讲解"
          describe: " Python如何管理内存?"
        - title: "知识讲解"
          describe: " 装饰器的作用是什么?"
        - title: "知识讲解"
          describe: "多线程为什么有GIL限制?"

接下来,就需要写一个java类,来映射这个配置:

java
package com.tianji.aigc.config;

import com.tianji.aigc.vo.SessionVO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Data
@Configuration
@ConfigurationProperties(prefix = "tj.ai.session")
public class SessionProperties {

    /**
     * AI助手的标题,用于显示助手的名称或身份。
     */
    private String title;

    /**
     * AI助手的描述,简要介绍助手的功能或特点。
     */
    private String describe;

    /**
     * 示例列表,包含一些使用助手的示例。
     */
    private List<SessionVO.Example> examples;

}

5.5.5. 编写Controller

java
package com.tianji.aigc.controller;

import com.tianji.aigc.service.ChatSessionService;
import com.tianji.aigc.vo.SessionVO;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/session")
@RequiredArgsConstructor
public class SessionController {

    private final ChatSessionService chatSessionService;

    /**
     * 新建会话
     */
    @PostMapping
    public SessionVO createSession(@RequestParam(value = "n", defaultValue = "3") Integer num) {
        return this.chatSessionService.createSession(num);
    }

}

5.5.6. 编写Service

先定义ChatSessionService接口:

java
package com.tianji.aigc.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.tianji.aigc.entity.ChatSession;
import com.tianji.aigc.vo.SessionVO;

public interface ChatSessionService extends IService<ChatSession> {

    /**
     * 创建会话session
     *
     * @param num 热门问题的数量
     * @return 会话信息
     */
    SessionVO createSession(Integer num);

}

编写实现类:

java
package com.tianji.aigc.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.aigc.config.SessionProperties;
import com.tianji.aigc.entity.ChatSession;
import com.tianji.aigc.mapper.ChatSessionMapper;
import com.tianji.aigc.service.ChatSessionService;
import com.tianji.aigc.vo.SessionVO;
import com.tianji.common.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatSessionServiceImpl extends ServiceImpl<ChatSessionMapper, ChatSession> implements ChatSessionService {

    private final SessionProperties sessionProperties;

    @Override
    public SessionVO createSession(Integer num) {
        var sessionVO = BeanUtil.toBean(sessionProperties, SessionVO.class);
        // 随机获取examples
        sessionVO.setExamples(RandomUtil.randomEleList(sessionProperties.getExamples(), num));

        // 随机生成sessionId
        sessionVO.setSessionId(IdUtil.fastSimpleUUID());

        // 构建持久化对象,并持久化
        var chatSession = ChatSession.builder()
                .sessionId(sessionVO.getSessionId())
                .userId(UserContext.getUser())
                .build();
        super.save(chatSession);

        return sessionVO;
    }

}

5.5.7. 测试

img

对应的数据表也会有会话数据:

img

同样,也是可以基于前端页面测试的:

img

⛔ 如果测试出现401情况,像这样:

img

是因为请求头中没有设置token或token已经过期,需要重新设置,方法参考 导入apifox项目

5.6. 练习:热门问题

这个功能的开发,由学员自己来完成。

5.6.1. 功能说明

点击【换一换】切换热门问题,像这样:

img

对应的接口:

img

5.6.2. 参考代码

java
    /**
     * 获取热门会话
     *
     * @return 热门会话列表
     */
    @GetMapping("/hot")
    public List<SessionVO.Example> hotExamples(@RequestParam(value = "n", defaultValue = "3") Integer num) {
        return this.chatSessionService.hotExamples(num);
    }
java
/**
 * 获取热门会话
 *
 * @return 热门会话列表
 */
List<SessionVO.Example> hotExamples(Integer num);
java
    /**
     * 获取热门会话
     *
     * @return 热门会话列表
     */
    @Override
    public List<SessionVO.Example> hotExamples(Integer num) {
        return RandomUtil.randomEleList(sessionProperties.getExamples(), num);
    }

测试:

img

6. 流式对话

对话聊天是天机AI助手的最主要的功能,就是用户输入内容,发给后端,后端调用大模型,大模型生成内容返回。

6.1. 接口分析

img

请求参数有2个,分别是questionsessionId,请求方式是body方式:

img

响应是流式输出:

img

响应结构说明:

1、每行数据,都是一个json数据

2、json中有eventData和eventType,两个属性

3、eventData:文本内容

4、eventType:事件类型,1001-数据事件,1002-停止事件,1003-参数事件

plain
data:{"eventData":"课程","eventType":1001}

data:{"eventData":"id","eventType":1001}

data:{"eventData":":","eventType":1001}

data:{"eventData":"1880","eventType":1001}

data:{"eventData":"5332","eventType":1001}

data:{"eventData":"5357","eventType":1001}

data:{"eventData":"5225","eventType":1001}

data:{"eventData":"346\n\n","eventType":1001}

data:{"eventData":"发展。","eventType":1001}

data:{"eventData":"","eventType":1001}

data:{"eventType":1002}

6.2. 定义响应对象

java
package com.tianji.aigc.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatEventVO {

    /**
     * 文本内容
     */
    private Object eventData;

    /**
     * 事件类型,1001-数据事件,1002-停止事件,1003-参数事件
     */
    private int eventType;

}

6.3. 定义事件枚举

java
package com.tianji.aigc.enums;

import com.tianji.common.enums.BaseEnum;
import lombok.Getter;

/**
 * 聊天消息事件类型
 */
@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();
    }
}

6.4. 定义请求DTO对象

java
package com.tianji.aigc.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatDTO {

    /**
     * 用户的问题
     */
    private String question;
    /**
     * 会话id
     */
    private String sessionId;
}

6.5. 创建Controller

java
package com.tianji.aigc.controller;

import com.tianji.aigc.service.ChatService;
import com.tianji.aigc.vo.ChatDTO;
import com.tianji.aigc.vo.ChatEventVO;
import com.tianji.common.annotations.NoWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@Slf4j
@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    @NoWrapper // 标记结果不进行包装
    @PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ChatEventVO> chat(@RequestBody ChatDTO chatDTO) {
        return this.chatService.chat(chatDTO.getQuestion(), chatDTO.getSessionId());
    }
}

说明:

@NoWrapper: 这个注解是天机学堂项目原有的一个注解,用来标记这个Controller返回的数据不会被包装,如果不标记就会进行包装,类似:

img

6.6. 创建Service

java
package com.tianji.aigc.service;

import com.tianji.aigc.vo.ChatEventVO;
import reactor.core.publisher.Flux;

public interface ChatService {

    /**
     * 聊天
     *
     * @param question  问题
     * @param sessionId 会话id
     * @return 回答内容
     */
    Flux<ChatEventVO> chat(String question, String sessionId);

}

6.7. 创建SpringAI配置

java
package com.tianji.aigc.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@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();
    }
}

6.8. 编写Service实现类

java
package com.tianji.aigc.service.impl;

import com.tianji.aigc.enums.ChatEventTypeEnum;
import com.tianji.aigc.service.ChatService;
import com.tianji.aigc.vo.ChatEventVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {

    private final ChatClient chatClient;


    @Override
    public Flux<ChatEventVO> chat(String question, String sessionId) {
        return this.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()));
    }
}

测试:

img

输出结果如下:

plain
data:{"eventData":"Java","eventType":1001}

data:{"eventData":"是一种","eventType":1001}

data:{"eventData":"广泛","eventType":1001}

data:{"eventData":"使用的面向对象的","eventType":1001}

data:{"eventData":"编程语言,由","eventType":1001}

data:{"eventData":"Sun Microsystems(后","eventType":1001}

data:{"eventData":"被Oracle收购)","eventType":1001}

data:{"eventData":"在199","eventType":1001}

-------------省略中间内容----------------

data:{"eventData":"开发的,因为它","eventType":1001}

data:{"eventData":"提供了良好的性能、","eventType":1001}

data:{"eventData":"安全性、可扩展","eventType":1001}

data:{"eventData":"性和社区支持。","eventType":1001}

data:{"eventData":"","eventType":1001}

data:{"eventType":1002}

基于前端进行测试:

img

到这里,基本的聊天对话功能就做好了。

7. system提示词

7.1. 功能分析

开发AI聊天功能,首先得设计系统提示词,所以,需要先搞清楚AI能够完成的功能有哪些,这样才能编写提示词。

功能1:课程推荐

img
img

功能二:课程购买

img

功能三:课程咨询

img

功能四:知识讲解

img

7.2. 系统提示词

根据上面功能描述,我们来设计提示词,如下:

markdown
角色
你作为在线教育平台资深客服代表兼讲师。你的任务根据学员的需求,调用知识库中的课程信息,为学员推荐合适的课程,同时解答学员对课程内容和知识点的疑问。

技能 1: 课程推荐
1. 当学员提出课程推荐需求时,需判断是否提供必要信息。必要信息包含年龄、学历、是否有编程基础。
2. 若缺少必要信息,需礼貌追问。
3. 若学员未提供感兴趣的方向,需追问。若没有明确方向,优先推荐学习人数多的课程。
4. 若信息充足,根据必要信息和感兴趣的课程方向,去知识库匹配合适的课程,获取课程id,调用queryCourseById,根据课程id查询课程详细信息,为学员推荐课程,可推荐单门/多门课程。
5. 若知识库未包含学员感兴趣方向,需明确告知学员未提供该方向课程,并推荐其他课程。
6. 若必要信息未匹配合适课程,需提示学员您的情况与现有课程要求并不完全匹配,说明详细原因后,再推荐其他课程。
7. 推荐课程,必须要通过queryCourseById查询后,才能返回数据。

技能 2: 课程购买
1. 当学员提出购买课程时,需判断此次会话中,学员是否明确提出购买xx课/系统已为学员推荐课程。
2. 若已推荐/明确课程名称,需调用prePlaceOrder,根据此次上文已推荐/学员明确的课程,直接进入预下单流程。
3. 若未推荐课程,需引导学员进入到课程推荐流程。
4. 若学员未明确提出购买某门课程时,需询问用户购买哪门课程。
5. 支持购买一门/多门课程。

技能 3: 课程咨询
1. 当学员咨询课程内容时,需去知识库匹配合适的课程,获取课程id,根据课程id查询课程详细信息。回复的内容要全面,要引导学员报名购买。
2. 若未查询到,需礼貌告知学员未检索到相关的内容,请联系人工客服010-12345678。
3. 若咨询课程有效期,需将当前时间{now} 与 课程有效期相加,回复学员准确日期。课程有效期999天,代表永久有效。

技能 4: 知识讲解
1. 当学员咨询与IT相关的知识点内容时,需详细讲解知识点并提供示例。

限制:
- 推荐的课程只能从知识库中选择,坚决不能凭空编造
- 回答的内容要逻辑清晰、内容全面、不要有遗漏。
- 只能回答与课程和IT知识点相关的内容,若学员咨询与课程无关的内容,你需告知学员不能回答与课程和IT知识点无关的问题,并引导学员咨询与课程/IT知识点相关的问题。
- 若学员询问课程ID,则告知学员无法提供课程ID,引导学员咨询其他的问题。

📚 说明:

queryCourseByIdprePlaceOrder 是我们后面要定义的Tool,现在先忽略它。

7.3. 存储到nacos

为了方便修改提示词,所以选择将提示词内容存储到nacos的配置中心,名为:system-chat-message.txt

img

7.4. 读取配置

接下来,我们需要读取配置文件,并且支持热更新。

首先,需要在application.yml文件中指定nacos中的配置名:

yaml
tj:
  ai:
    prompt:
      system:
        chat:
          data-id: system-chat-message.txt
          group: DEFAULT_GROUP
          timeout-ms: 20000

编写Java代码来读取这个配置项:

java
package com.tianji.aigc.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration
@ConfigurationProperties(prefix = "tj.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
package com.tianji.aigc.config;

import com.alibaba.cloud.nacos.NacosConfigManager;
import com.alibaba.nacos.api.config.listener.Listener;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;

@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 {
            var dataId = chatConfig.getDataId();
            var group = chatConfig.getGroup();
            var timeoutMs = chatConfig.getTimeoutMs();

            var 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);
        }
    }

}

7.5. 应用提示词

接下来,需要将系统提示词应用到上面写的对话聊天中了,改造下前面写的代码:

java
package com.tianji.aigc.service.impl;

import cn.hutool.core.date.DateUtil;
import com.tianji.aigc.config.SystemPromptConfig;
import com.tianji.aigc.enums.ChatEventTypeEnum;
import com.tianji.aigc.service.ChatService;
import com.tianji.aigc.vo.ChatEventVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@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 this.chatClient.prompt()
                .system(promptSystem -> promptSystem
                        .text(this.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()));
    }
}

测试:

img

可以看到,系统提示词生效了。

img

通过浏览器开发者工具查询请求:

img

8. 停止生成

8.1. 功能说明

停止生成这个功能,是在AI大模型生成的过程中,人为的打断输出,像这样:

img

该如何实现呢?

📚

要知道的是,在程序调用AI大模型生成内容的过程中是无法中断的,只能是在输出流中打断流的输出,也就是意味着,如果一个很长的内容在输出,即使人为打断,大模型依然会输出,依然会产生费用。基于上面的原理,我们就需要控制Flux流的输出即可。

8.2. 接口分析

img

可以看到,该接口只需要一个参数,那就是sessionId。

8.3. 编写Controller

ChatController中定义stop方法:

java
    @PostMapping("/stop")
    public void stop(@RequestParam("sessionId") String sessionId) {
        this.chatService.stop(sessionId);
    }

ChatService接口中定义stop方法:

java
    /**
     * 停止生成
     *
     * @param sessionId 会话id
     */
    void stop(String sessionId);

8.4. 编写Service实现

Service的实现,核心思想是在大模型输出内容时,进行标记正在输出,如果stop的话,需要将这个标记删除,Flux是否继续输出,取决于这个标记。

改造后的代码:

java
package com.tianji.aigc.service.impl;

import cn.hutool.core.date.DateUtil;
import com.tianji.aigc.config.SystemPromptConfig;
import com.tianji.aigc.enums.ChatEventTypeEnum;
import com.tianji.aigc.service.ChatService;
import com.tianji.aigc.vo.ChatEventVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@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 this.chatClient.prompt()
                .system(promptSystem -> promptSystem
                        .text(this.systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
                        .param("now", DateUtil.now()) // 设置当前时间的参数
                )
                .user(question)
                .stream()
                .chatResponse()
                .doFirst(() -> GENERATE_STATUS.put(sessionId, true)) // 第一次输出内容时执行
                .doOnError(throwable -> GENERATE_STATUS.remove(sessionId)) // 出现异常时,删除标识
                .doOnComplete(() -> GENERATE_STATUS.remove(sessionId)) // 完成时执行,删除标识
                .takeWhile(response -> { // 通过返回值来控制Flux流是否继续,true:继续,false:终止
                    return GENERATE_STATUS.getOrDefault(sessionId, 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);
    }
}

8.5. 测试

img
img

9. 会话记忆

9.1. 实现分析

在前面的学习中,我们实现的会话记忆功能是基于内存实现的,这种方式只适合学习阶段,如果在项目中使用,现在就不合适了,所以需要更换存储方案。

存储方案有很多,比如可以存储到mysql数据库,也可以存储到MongoDB,在这里,我们选择存储到redis,之所以选择redis,是因为redis使用起来相对简单,查询速度也快。

可惜的是,SpringAI官方并没有提供redis存储的实现,所以,只能是自己实现了。

要想实现自定义的存储,就需要实现SpringAI中的org.springframework.ai.chat.memory.ChatMemoryRepository接口,他的定义是这样的:

java
/*
 * Copyright 2023-2025 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.ai.chat.memory;

import java.util.List;

import org.springframework.ai.chat.messages.Message;

/**
 * A repository for storing and retrieving chat messages.
 *
 * @author Thomas Vitale
 * @since 1.0.0
 */
public interface ChatMemoryRepository {

	List<String> findConversationIds(); // 查询所有的对话id

	List<Message> findByConversationId(String conversationId); // 根据对话id查询Message列表

	/**
	 * Replaces all the existing messages for the given conversation ID with the provided
	 * messages.
	 */
	void saveAll(String conversationId, List<Message> messages); // 保存Message列表数据

	void deleteByConversationId(String conversationId); // 根据对话id删除数据

}

接下来,就需要考虑一下,使用Redis的什么数据结构来存储呢?

在这里,我们采用List结构来存储,因为List结构更适合做聊天记录,像这样:

img

9.2. 代码实现

编写代码实现:

java
package com.tianji.aigc.memory;

import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.json.JSONUtil;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.List;

/**
 * 基于Redis实现的ChatMemoryRepository
 */
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    // 默认redis中key的前缀
    public static final String DEFAULT_PREFIX = "CHAT:";

    private final String prefix;

    // 注入spring redis模板,进行redis的操作
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public RedisChatMemoryRepository() {
        this.prefix = DEFAULT_PREFIX;
    }

    public RedisChatMemoryRepository(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public List<String> findConversationIds() {
        Set<String> keys = this.stringRedisTemplate.keys(this.prefix + "*");
        if (null == keys) {
            return List.of();
        }
        return StreamUtil.of(keys)
                .map(key -> StrUtil.replace(key, this.prefix, ""))
                .toList();
    }


    @Override
    public List<Message> findByConversationId(String conversationId) {
        // 先不实现
        return List.of();
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        Assert.notEmpty(messages, "消息列表不能为空");
        var redisKey = this.getKey(conversationId);
        var listOps = this.stringRedisTemplate.boundListOps(redisKey);
        // 保存数据时,会传入全部的消息数据,包括之前的数据,所以需要先删除之前的数据,再添加新的数据
        this.deleteByConversationId(conversationId);
        // 将消息序列化并添加到Redis列表的右侧
        messages.forEach(message -> listOps.rightPush(JSONUtil.toJsonStr(message)));
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        var redisKey = this.getKey(conversationId);
        this.stringRedisTemplate.delete(redisKey);
    }

    private String getKey(String conversationId) {
        return prefix + conversationId;
    }
}

增加package-info.java文件:

java
@NonNullApi
@NonNullFields
package com.tianji.aigc.memory;

import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

SpringAIConfig中加入会议记忆功能:

java
package com.tianji.aigc.config;

import com.tianji.aigc.memory.RedisChatMemoryRepository;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringAIConfig {

    @Value("${tj.ai.memory.max:100}")
    private Integer maxMessages;

    /**
     * 配置 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 ChatMemoryRepository redisChatMemoryRepository() {
        return new RedisChatMemoryRepository();
    }

    @Bean
    public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        // 基于 chatMemoryRepository 对象构建 chatMemory 对象
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)
                .maxMessages(this.maxMessages) // 最多保存 100 条对话, 如果超出的话,会自动删除最旧的对话
                .build();
    }

    /**
     * 基于Redis的会话记忆,聊天记忆整合到message列表中实现多轮对话
     */
    @Bean
    public Advisor messageChatMemoryAdvisor(ChatMemory chatMemory) {
        // 创建基于 chatMemory 的 Advisor 对象
        return MessageChatMemoryAdvisor.builder(chatMemory).build();
    }
}

application.yml中添加配置:

yaml
tj:
  ai:
    memory:
      max: 100 #存储最大的聊天记录数

chatClient中添加advisors参数:

java
    @Override
    public Flux<ChatEventVO> chat(String question, String sessionId) {
        // 获取对话id
        var conversationId = ChatService.getConversationId(sessionId);

        return this.chatClient.prompt()
                .system(promptSystem -> promptSystem
                        .text(this.systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
                        .param("now", DateUtil.now()) // 设置当前时间的参数
                )
                .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, 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;
    }

重启,进行测试:

img

测试时会发现,会存储到redis中1条数据:

img
img

看到,可以看到只存入了1条数据,存在2个问题:

  • 为什么只有一条大模型返回的数据?用户输入的问题数据去哪里了?

    • 原因:我们现在还没实现根据对话id查询消息的方法,导致了插入第二条数据时,删除了之前的数据。
  • 数据中并没有真正的文字内容 img

📚

没有文本内容写入,这是什么原因呢?那是因为,org.springframework.ai.chat.messages.Message 接口的实现类org.springframework.ai.chat.messages.AbstractMessage中的textContent属性没有提供get方法,只提供了getText()方法,导致无法获取值。当然了,不使用cn.hutool.json.JSONUtil,而是采用com.fasterxml.jackson.databind.ObjectMapper进行序列化,也是可以获取到值的,但是,在后面反序列化时也是会有问题的。所以,我们不直接对org.springframework.ai.chat.messages.Message对象序列化,而是我们自定义一个对象,把值拷贝过来,进行做序列化,这样做的好处就是比较灵活,这个在后面也会有体现。

9.3. 解决序列化问题

前面分析过,我们需要自定义对象来进行对Message对象序列化和反序列化。

首先我们需要了解下Message接口的子类:

img

可以看到,有4个具体的子类,分别对应着4种消息:

  • AssistantMessage 大模型生成的消息
  • SystemMessage 系统消息
  • ToolResponseMessage 工具响应消息
  • UserMessage 用户消息

虽然,有4种消息,实际上我们只需要定义一个类来对应这4种消息即可,只需要通过messageType就行。

java
package com.tianji.aigc.memory;

import lombok.Data;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.content.Media;

import java.util.List;
import java.util.Map;

@Data
public class MyMessage {

    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> params = Map.of();

}

编写MessageUtil工具类来实现序列化和反序列化方法:

java
package com.tianji.aigc.memory;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONUtil;
import org.springframework.ai.chat.messages.*;

/**
 * 消息转换工具类,提供消息对象与JSON字符串之间的转换功能,主要用于Redis存储格式转换
 */
public class MessageUtil {

    /**
     * 将Message对象转换为Redis存储格式的JSON字符串
     *
     * @param message 需要转换的原始消息对象
     * @return 符合Redis存储规范的JSON字符串
     */
    public static String toJson(Message message) {
        var myMessage = BeanUtil.toBean(message, MyMessage.class);
        // 设置消息内容
        myMessage.setTextContent(message.getText());
        if (message instanceof AssistantMessage assistantMessage) {
            myMessage.setToolCalls(assistantMessage.getToolCalls());
        }

        if (message instanceof ToolResponseMessage toolResponseMessage) {
            myMessage.setToolResponses(toolResponseMessage.getResponses());
        }

        return JSONUtil.toJsonStr(myMessage);
    }

    /**
     * 将Redis存储的JSON字符串反序列化为对应的Message对象
     *
     * @param json Redis存储的JSON格式消息数据
     * @return 对应类型的Message对象
     * @throws RuntimeException 当无法识别的消息类型时抛出异常
     */
    public static Message toMessage(String json) {
        var myMessage = JSONUtil.toBean(json, MyMessage.class);
        var messageType = MessageType.valueOf(myMessage.getMessageType());
        switch (messageType) {
            case SYSTEM -> {
                return new SystemMessage(myMessage.getTextContent());
            }
            case USER -> {
                return UserMessage.builder()
                        .text(myMessage.getTextContent())
                        .metadata(myMessage.getMetadata())
                        .media(myMessage.getMedia())
                        .build();
            }
            case ASSISTANT -> {
                return new AssistantMessage(myMessage.getTextContent(), myMessage.getMetadata(), myMessage.getToolCalls());
            }
            case TOOL -> {
                return new ToolResponseMessage(myMessage.getToolResponses(), myMessage.getMetadata());
            }
        }

        throw new RuntimeException("Message data conversion failed.");
    }

}

改造com.tianji.aigc.memory.RedisChatMemory代码:

java
    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        Assert.notEmpty(messages, "消息列表不能为空");
        var redisKey = this.getKey(conversationId);
        var listOps = this.stringRedisTemplate.boundListOps(redisKey);
        // 保存数据时,会传入全部的消息数据,包括之前的数据,所以需要先删除之前的数据,再添加新的数据
        this.deleteByConversationId(conversationId);
        // 将消息序列化并添加到Redis列表的右侧
        messages.forEach(message -> listOps.rightPush(MessageUtil.toJson(message)));
    }

再次测试,就可以看到数据正常存储进去了:

json
{
  "messageType": "ASSISTANT",
  "metadata": {
    "finishReason": "STOP",
    "role": "ASSISTANT",
    "id": "d799db48-6376-9264-8565-50b3e161a69f",
    "messageType": "ASSISTANT",
    "reasoningContent": ""
  },
  "media": [

  ],
  "toolCalls": [

  ],
  "textContent": "您好,我是在线教育平台的客服代表兼讲师。我可以为您推荐合适的课程、解答课程内容相关的疑问,还可以讲解IT技术知识点哦。有什么可以帮助您的吗?",
  "toolResponses": [

  ],
  "params": {

  }
}

9.4. 实现查询对话

实现查询对话是根据对话id查询。

java
    @Override
    public List<Message> findByConversationId(String conversationId) {
        // 生成Redis键名用于存储会话消息
        var redisKey = this.getKey(conversationId);
        // 获取Redis列表操作对象
        var listOps = this.stringRedisTemplate.boundListOps(redisKey);

        // 从Redis列表中获取所有的数据
        var messages = listOps.range(0, -1);
        // 将Redis返回的字符串列表转换为Message对象列表
        return CollStreamUtil.toList(messages, MessageUtil::toMessage);
    }

下面我们编写【查询会话详情】接口,用于指定sessionId查询,注意,sessionIdconversationId是不一样的,对话id = 用户id_会话id

img

定义Controller:

java
    /**
     * 查询单个历史对话详情
     *
     * @return 对话记录列表
     */
    @GetMapping("/{sessionId}")
    public List<MessageVO> queryBySessionId(@PathVariable("sessionId") String sessionId) {
        return this.chatSessionService.queryBySessionId(sessionId);
    }

定义MessageVO类:

java
package com.tianji.aigc.vo;

import com.tianji.aigc.enums.MessageTypeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Map;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageVO {

    /**
     * 消息类型,USER表示用户提问,ASSISTANT表示AI的回答
     */
    private MessageTypeEnum type;
    /**
     * 消息内容
     */
    private String content;

    /**
     * 附加参数
     */
    private Map<String, Object> params;

}

定义MessageTypeEnum枚举,用来表示不同类型的消息:

java
package com.tianji.aigc.enums;

import com.tianji.common.enums.BaseEnum;
import lombok.Getter;

/**
 * 消息类型枚举
 */
@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;

    @Override
    public List<MessageVO> queryBySessionId(String sessionId) {
        // 根据会话ID获取对话ID
        String conversationId = ChatService.getConversationId(sessionId);
        // 从Redis中获取历史消息
        List<Message> messageList = this.chatMemory.get(conversationId);
        // 过滤并转换消息列表
        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();
    }

测试,可以看到,通过sessionId已经查询到了对话数据:

img

9.5. 练习

前面我们已经基于Redis完成了存储,现在需要类似Redis的实现方式来完成Mysql和MongoDB的实现,并且通过配置文件可以指定使用哪种方式。

类似下面的配置:

img

说明:

虽然SpringAI官方也提供看jdbc的存储方式,但是我们不能使用,因为后面我们会对Message进行扩展字段,而官方的实现已经固定死了字段无法扩展。

img

10. 解决bug

前面我们已经实现了【停止生成】和【会话记忆】的功能,这两个功能单独测试起来没问题,但是结合起来测试就会有问题。

10.1. bug重现

com.tianji.aigc.memory.RedisChatMemoryRepository#saveAll方法上打断点:

img

com.tianji.aigc.service.impl.ChatServiceImpl#chat打断点:

img

进行聊天测试:

img

可以看到,断点进入了,这是第一次调用saveAll方法,用来保存用户输入的内容:

img

放行断点,可以看到redis中存入了数据:

img

这时,断点来到了ChatServiceImpl中,说明了,大模型已经返回了数据:

img

放行2-3次断点,可以看到有内容输出了:

img

此时,调用stop接口:

img
img

再放行断点,可以看到数据不再输出了,并且redis中也没有存入数据。

img
img

也就是说,只要是进行了停止操作,大模型返回的数据就不会被记录到redis中了。bug重现了。

10.2. bug解决

要想解决问题,首先得知道原因,这样才能解决问题。

📚

原因是这样的,停止是通过中断Flux流程完成的,Flux中断了,SpringAI就不会触发ChatMemoryadd方法,也就不会调用ChatMemoryRepository#saveAll方法了,所以就保存数据了。(这个不确定SpringAI是故意这么设计,还是这个版本的问题,目前1.0.0是最新版)。

知道原因,就好解决问题了,既然SpringAI不会记录,我们自己记录即可,但是,又有一个新的问题了,我们怎么知道Flux中断了呢?

其实,FluxdoOnCancel方法的,当流中断就会触发这个方法执行,所以,就需要在doOnCancel方法中实现自己存储的逻辑了。

改造后的对话聊天代码:

java
    private final ChatMemory chatMemory;

    @Override
    public Flux<ChatEventVO> chat(String question, String sessionId) {
        // 获取对话id
        var conversationId = ChatService.getConversationId(sessionId);
        // 大模型输出内容的缓存器,用于在输出中断后的数据存储
        var outputBuilder = new StringBuilder();

        return this.chatClient.prompt()
                .system(promptSystem -> promptSystem
                        .text(this.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(() -> {
                    // 当输出被取消时,保存输出的内容到历史记录中
                    this.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) {
        this.chatMemory.add(conversationId, new AssistantMessage(content));
    }

警告

注意:doOnCancel一定要放在takeWhile上面,否则doOnCancel不生效。

自己按照上面的【bug重现】的方法测试下,看是否能够在停止生成后,依然会记录数据到redis中。像这样:

img
img

Q.E.D.
天机学堂AI助手智能体 - 购买课程与知识库
📗RAG Embedding