参考视频:黑马程序员SpringAI+DeepSeek大模型应用开发实战视频教程
1. 熟悉项目
天机学堂项目是一个已经开发完成的项目,我们要做的是在原有项目基础上,增加天机AI助手智能体模块,所以,对于原有系统的代码我们不用过多的研究,只需要有基本的了解即可,但是,需要知道,原有的业务是怎么样的,这样我们才能去开发新的功能。
接下来,我们需要把运行环境部署好、代码跑起来,了解一下他的业务功能。
1.1. 部署环境
1.1.1. 导入虚拟机
第一步:参考文档,将虚拟机导入到自己电脑的VMware中。 👉 虚拟机导入手册(密码:nxyt)
第二步:为了模拟使用域名访问,我们需要在本地配置hosts:
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.tianji.com | tjxt/123321 | 10880 | |
| jenkins.tianji.com | root/123 | 18080 | |
| RabbitMQ | mq.tianji.com | tjxt/123321 | 15672 |
| Nacos控制台 | nacos.tianji.com | nacos/nacos | 8848 |
| xxl-job控制台 | xxljob.tianji.com | admin/123456 | 8880 |
| es.tianji.com | - | 5601 | |
| 微服务网关 | api.tianji.com | - | 10010 |
| 用户端入口 | www.tianji.com | - | 18081 |
| 管理端入口 | manage.tianji.com | - | 18082 |
当我们访问上述域名时,请求实际是发送到了虚拟机,而虚拟机中的Nginx会对这些域名做反向代理,这样我们就能请求到对应的组件了:

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

- 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

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

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

警告
🚀 注意:没有安装jdk17的同学,在资料中找到jdk17的压缩包,解压后,配置到idea中即可。
第三步:刷新maven依赖,确保所有的依赖都下载完成,不报错。

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

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

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

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 账号登录:


登录成功:

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

1.2. 熟悉业务
天机学堂的业务分为两部分:
- 学生端:其核心业务主体就是学员,所有业务围绕着学员的展开
- 管理端:其核心业务主体包括老师、管理员、其他员工,核心业务围绕着老师展开
1.2.1. 功能演示
下面我们通过一个视频,快速的了解下天机学堂项目的业务:
此处为语雀视频卡片,点击链接查看:02.项目介绍-功能演示.mp4
1.2.2. 老师核心业务
老师的核心业务流程有:

📚 虽然流程并不复杂,但其中包含的业务繁多,例如:
- 课程分类管理:课程分类的增删改查
- 媒资管理:媒资的增删改查、媒资审核
- 题目管理:试题的增删改查、试题批阅、审核
- 课程管理:课程增删改查、课程上下架、课程审核、发布等等
1.2.3. 学员核心业务
学员的核心业务就是买课、学习,基本流程如下:

2. AI功能需求
2.1. 需求文档
需求文档:https://rp-java.itheima.net/tianji-agent-AI-V0.3/rp/index.html
2.2. 天机AI助手
在页面左下角,点击
唤起AI助手,进行聊天操作。如下:




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


2.4. AI文本处理



3. 技术架构
3.1. 整体架构

3.2. 实现流程

4. 项目集成SpringAI
4.1. 创建tj-aigc微服务
天机学堂项目采用的是微服务架构,我们要加入AI模块,是需要添加AI微服务,名为tj-aigc。
AIGC 是指人工智能生成内容(Artificial Intelligence Generated Content)
首先,代码不要在stu分支直接修改,创建自己的分支,比如叫作my分支。


创建tj-aigc微服务:


4.2. 导入依赖
<?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:
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个配置文件:

📚 说明:
- application.yml 主配置文件,配置了端口号、服务名称、swagger接口等信息
- application-dev.yml 运行在虚拟机环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息
- application-local.yml 运行在本地环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息
- application-test.yml 运行在测试环境的配置文件,配置了nacos注册中心、配置中心、日志级别等信息
👉 我们只需要关注 application.yml 和 application-local.yml,这两个配置文件即可。
具体的配置内容如下:
application.yml
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
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: debugapplication-local.yml
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: debugapplication-test.yml
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
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 #向量维度维度
启动项目进行测试:


4.4. 配置网关
在天机项目中,所有服务的请求都要经过网关,aigc服务也不例外,在tj-gateway项目的配置文件中,添加请求映射:
application.yml
- id: ais
uri: lb://aigc-service
predicates:
- Path=/ais/**
📚
这个配置意思是,所有以 /ais/** 开头的请求,都会转发到aigc-service。
修改完配置后,记得要重启tj-gateway服务。
4.5. 导入apifox项目
我们已经将天机AI项目的api接口发布到apifox中,现在需要将他克隆到你自己的apifox账号中,这样,就可以在apifox中进行测试接口了。
第一步,打开 链接: https://tianji-ai.apifox.cn 访问密码: itheima 




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

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

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

token值在 全局变量中设置:

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

打开浏览器开发者工具:

查看请求头:

5. 新建会话
我们知道,在与AI对话时,需要有不同的会话,每个会话都有自己的id,叫作:sessionId,所以,我先开发新建会话功能。
5.1. 需求分析
打开天机AI助手时,就需要创建新的会话:



5.2. 实现分析
实现新建会话功能,需要解决3个问题:
- 如何生成sessionId?
- 生成的sessionId是否需要保存到数据库?
- 热门问题,该怎么做?
我们逐一进行分析:
问题一:如何生成sessionId?
解决:生成sessionId的要求是字符串的,不重复的,所以,可以使用uuid来生成,确保唯一性。
问题二:sessionId是否需要保存到数据库?
解决:是需要的,因为后续需要有查询历史对话的功能,如果不存储到数据库,是无法查询的。

问题三:热门问题,该怎么做?
解决:热门问题一般都是固定的问题,所以在后台中存储,随机的返回3个即可,但是,要做成可配置的,不能硬编码到代码中。所以,我们会想到把他写到nacos的配置中心中,这样就会比较灵活一些。
5.3. 接口分析
在apifox中,已经定义好了接口:

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

接口响应数据分析:

5.4. 数据库设计
数据库表已经设计好了,接下来,连接到数据库,查看表结构,数据库的连接信息如下:
ip:192.168.150.101
端口:3306
用户名:root
密码:itcast321bca

表结构:

创建表语句:
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表结构一致。
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接口
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类:
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
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类,来映射这个配置:
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
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接口:
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);
}编写实现类:
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. 测试

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

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

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

是因为请求头中没有设置token或token已经过期,需要重新设置,方法参考 导入apifox项目
5.6. 练习:热门问题
这个功能的开发,由学员自己来完成。
5.6.1. 功能说明
点击【换一换】切换热门问题,像这样:

对应的接口:

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

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

请求参数有2个,分别是question和sessionId,请求方式是body方式:

响应是流式输出:

响应结构说明:
1、每行数据,都是一个json数据
2、json中有eventData和eventType,两个属性
3、eventData:文本内容
4、eventType:事件类型,1001-数据事件,1002-停止事件,1003-参数事件
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. 定义响应对象
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. 定义事件枚举
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对象
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
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返回的数据不会被包装,如果不标记就会进行包装,类似:

6.6. 创建Service
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配置
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实现类
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()));
}
}测试:

输出结果如下:
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}基于前端进行测试:

到这里,基本的聊天对话功能就做好了。
7. system提示词
7.1. 功能分析
开发AI聊天功能,首先得设计系统提示词,所以,需要先搞清楚AI能够完成的功能有哪些,这样才能编写提示词。
功能1:课程推荐


功能二:课程购买

功能三:课程咨询

功能四:知识讲解

7.2. 系统提示词
根据上面功能描述,我们来设计提示词,如下:
角色
你作为在线教育平台资深客服代表兼讲师。你的任务根据学员的需求,调用知识库中的课程信息,为学员推荐合适的课程,同时解答学员对课程内容和知识点的疑问。
技能 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,引导学员咨询其他的问题。📚 说明:
queryCourseById 和 prePlaceOrder 是我们后面要定义的Tool,现在先忽略它。
7.3. 存储到nacos
为了方便修改提示词,所以选择将提示词内容存储到nacos的配置中心,名为:system-chat-message.txt:

7.4. 读取配置
接下来,我们需要读取配置文件,并且支持热更新。
首先,需要在application.yml文件中指定nacos中的配置名:
tj:
ai:
prompt:
system:
chat:
data-id: system-chat-message.txt
group: DEFAULT_GROUP
timeout-ms: 20000编写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; // 读取的超时时间,单位毫秒
}
}
}通过上面的配置,只是读取到了配置文件的名称、分组、超时时间,但是,还没读取文件内容,接下来就需要读取内容了:
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. 应用提示词
接下来,需要将系统提示词应用到上面写的对话聊天中了,改造下前面写的代码:
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()));
}
}测试:

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

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

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

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

可以看到,该接口只需要一个参数,那就是sessionId。
8.3. 编写Controller
在ChatController中定义stop方法:
@PostMapping("/stop")
public void stop(@RequestParam("sessionId") String sessionId) {
this.chatService.stop(sessionId);
}在ChatService接口中定义stop方法:
/**
* 停止生成
*
* @param sessionId 会话id
*/
void stop(String sessionId);8.4. 编写Service实现
Service的实现,核心思想是在大模型输出内容时,进行标记正在输出,如果stop的话,需要将这个标记删除,Flux是否继续输出,取决于这个标记。
改造后的代码:
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. 测试


9. 会话记忆
9.1. 实现分析
在前面的学习中,我们实现的会话记忆功能是基于内存实现的,这种方式只适合学习阶段,如果在项目中使用,现在就不合适了,所以需要更换存储方案。
存储方案有很多,比如可以存储到mysql数据库,也可以存储到MongoDB,在这里,我们选择存储到redis,之所以选择redis,是因为redis使用起来相对简单,查询速度也快。
可惜的是,SpringAI官方并没有提供redis存储的实现,所以,只能是自己实现了。
要想实现自定义的存储,就需要实现SpringAI中的org.springframework.ai.chat.memory.ChatMemoryRepository接口,他的定义是这样的:
/*
* 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结构更适合做聊天记录,像这样:

9.2. 代码实现
编写代码实现:
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文件:
@NonNullApi
@NonNullFields
package com.tianji.aigc.memory;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;在SpringAIConfig中加入会议记忆功能:
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中添加配置:
tj:
ai:
memory:
max: 100 #存储最大的聊天记录数在chatClient中添加advisors参数:
@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的规则:
/**
* 获取对话id,规则:用户id_会话id
*
* @param sessionId 会话id
* @return 对话id
*/
static String getConversationId(String sessionId) {
return UserContext.getUser() + "_" + sessionId;
}重启,进行测试:

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


看到,可以看到只存入了1条数据,存在2个问题:
为什么只有一条大模型返回的数据?用户输入的问题数据去哪里了?
- 原因:我们现在还没实现根据对话id查询消息的方法,导致了插入第二条数据时,删除了之前的数据。
数据中并没有真正的文字内容

📚
没有文本内容写入,这是什么原因呢?那是因为,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接口的子类:

可以看到,有4个具体的子类,分别对应着4种消息:
- AssistantMessage 大模型生成的消息
- SystemMessage 系统消息
- ToolResponseMessage 工具响应消息
- UserMessage 用户消息
虽然,有4种消息,实际上我们只需要定义一个类来对应这4种消息即可,只需要通过messageType就行。
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工具类来实现序列化和反序列化方法:
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代码:
@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)));
}再次测试,就可以看到数据正常存储进去了:
{
"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查询。
@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查询,注意,sessionId和 conversationId是不一样的,对话id = 用户id_会话id。

定义Controller:
/**
* 查询单个历史对话详情
*
* @return 对话记录列表
*/
@GetMapping("/{sessionId}")
public List<MessageVO> queryBySessionId(@PathVariable("sessionId") String sessionId) {
return this.chatSessionService.queryBySessionId(sessionId);
}定义MessageVO类:
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枚举,用来表示不同类型的消息:
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方法:
/**
* 根据会话id查询消息列表
*
* @param sessionId 会话id
* @return 消息列表
*/
List<MessageVO> queryBySessionId(String sessionId);编写实现类:
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已经查询到了对话数据:

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

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

10. 解决bug
前面我们已经实现了【停止生成】和【会话记忆】的功能,这两个功能单独测试起来没问题,但是结合起来测试就会有问题。
10.1. bug重现
在com.tianji.aigc.memory.RedisChatMemoryRepository#saveAll方法上打断点:

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

进行聊天测试:

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

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

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

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

此时,调用stop接口:


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


也就是说,只要是进行了停止操作,大模型返回的数据就不会被记录到redis中了。bug重现了。
10.2. bug解决
要想解决问题,首先得知道原因,这样才能解决问题。
📚
原因是这样的,停止是通过中断Flux流程完成的,Flux中断了,SpringAI就不会触发ChatMemory的add方法,也就不会调用ChatMemoryRepository#saveAll方法了,所以就保存数据了。(这个不确定SpringAI是故意这么设计,还是这个版本的问题,目前1.0.0是最新版)。
知道原因,就好解决问题了,既然SpringAI不会记录,我们自己记录即可,但是,又有一个新的问题了,我们怎么知道Flux中断了呢?
其实,Flux是doOnCancel方法的,当流中断就会触发这个方法执行,所以,就需要在doOnCancel方法中实现自己存储的逻辑了。
改造后的对话聊天代码:
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中。像这样:


Q.E.D.

