一、前言
目前市面上接入ai的方式主要有三种:
- 调用api接口。
缺点:功能简陋,如果需要实现更多的功能需要自主封装。 - 使用各产商提供的sdk。
优点:开箱即用,实现简单,功能性强。
缺点:各产商都有自己的一套规范,如果需要接入多家的ai,得阅读多家的文档。 - 使用springAi。
优点:完美契合Spring生态,实现简单,功能性强。
缺点:某些ai未做适配。
推荐优先级:springAi>sdk>api。
本文介绍了使用Spring AI框架深度集成业务系统的方法,实现本地化AI问答功能。主要内容包括:
- 技术选型:基于SpringBoot框架,使用Spring AI接入阿里云百炼deepseek-r1文本模型,配合Elasticsearch向量数据库和Redis缓存。
- 实现功能:
流式输出响应
多轮对话上下文管理
对话中断处理
完整的对话日志记录 - 系统特点:
通过向量数据库和业务数据增强AI知识库
提供配置化的相似度阈值和上下文窗口大小
实现Redis缓存的对话记忆持久化 - 实施建议:采用Spring AI优先的接入方案,相比直接调用API或SDK更易于与Spring生态集成。
文章包含完整的代码示例和配置说明(基本每一行代码和配置都有说明,作用是什么,为什么要这样写),适合开发者快速实现业务场景的AI对话功能。
二、使用技术
基础框架:springboot
ai框架:springAi
jdk:17
文本模型:阿里云百炼deepseek-r1
向量模型:text-embedding-v4
向量数据库:elasticsearch
缓存数据库:redis
三、代码实现
1、导入maven坐标
<dependencies>
<!-- 使用的ruoyi框架,其它模块依赖,使用到租户模块、鉴权模块、web模块、mybatis模块,可以改造或删除 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-tenant</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-satoken</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mybatis</artifactId>
</dependency>
<!-- 工具依赖 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
<version>5.8.35</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.12</version>
</dependency>
<!-- spring-ai 核心依赖 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-elasticsearch-store-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M6.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.17.0</version>
</dependency>
</dependencies>
2、yml配置
spring:
#elasticsearch向量数据库配置
elasticsearch:
uris: http://127.0.0.1:9200
# 本地elasticsearch未开启认证,生产环境必须开启
username: root
password: famsakfdsfx
spring:
ai:
dashscope:
api-key: sk-c7********************* #从阿里云百炼平台获取
model: deepseek-r1 #文本模型名称
embedding:
options:
# 嵌入的向量模型
model: text-embedding-v4
vectorstore: #向量相关配置
elasticsearch: #使用elasticsearch作为向量数据库,也可以使用其他的,如redis向量数据库
initialize-schema: true #如果不存在该索引则创建
index-name: spring-ai-rag #索引名称(这里的索引并不是mysql那种索引)
# elasticsearch的向量维度(默认1536),需要和向量模型的维度一致。因为text-embedding-v4向量模型配置维度不生效,只能配置默认的1024
dimensions: 1024
similarity: cosine #相似配置枚举类型
#以上是springAi自身的配置,以下是自定义配置
# ai初始化配置
client:
enabled: false #是否开启初始化
# 系统的默认行为和风格
defaultSystem: 你是一个电子产品回收系统专业的助手,遇到不确定或不明确的信息时,会主动询问用户以获取更多信息。在回答问题时,你倾向于使用条理清晰的格式,例如分点列举的方式,以便用户更容易理解和参考。同时,你拒绝回答与本系统无关的问题。
# 相似度阈值,取值区间:(0,1]
similarity-threshold: 0.3
# 返回匹配的向量文档数量,可以按每个向量文档的token大小来设置,token大,topK则设置小一些,否则会超出ai对话的token长度限制
topK: 50
# 对话上下文记忆条数
chatHistoryWindowSize: 20
3、ai初始化及配置类
3.1、ai对话配置信息
读取yml配置
/**
* ai对话配置信息
*/
@Data
@Configuration
@ConfigurationProperties("spring.ai.client")
public class ChatClientConfigInfo {
/**
* 是否开启ai知识库初始化(原本是设计成是否开启ai的,后续会更新)
*/
private Boolean enabled;
/**
* 系统的默认行为和风格
*/
private String defaultSystem;
/**
* 相似度阈值,取值区间:(0,1]
*/
private Double similarityThreshold;
/**
* 返回匹配的向量文档数量,并不是越多越好,ai对话的token是有上限的
* 可以按每个向量文档的token大小来设置,token大,topK则设置小一些,否则会超出ai对话的token长度限制
*/
private Integer topK;
/**
* 对话上下文记忆条数
*/
private Integer chatHistoryWindowSize;
/**
* 提示词(问题引导,引导用户怎么提问。这个可以作为ai初始界面的问题引导或用户提问与系统业务无关时候返回的引导)
*/
private List<String> issues;
}
3.2、对话上下文缓存(实现ChatMemory)
为什么要缓存对话记录?因为ai是没有记忆的,想要实现多轮对话,需要将之前的对话信息一并告诉它。
实现该功能的核心是实现ChatMemory接口并注册成Spring Bean。以下是以redis作为缓存,同时将对话信息持久化。
@Slf4j
@Component
@RequiredArgsConstructor
public class ChatRedisMemory implements ChatMemory {
private static final String KEY_PREFIX = "chat:history:";
private final IChatAiLogService chatAiLogService;
//conversationId:缓存标识,本文使用的是用户ID
@Override
public void add(String conversationId, Message message) {
ChatMemory.super.add(conversationId, message);
}
@Override
public void add(String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
List<ChatEntity> listIn = new ArrayList<>();
List<ChatAiLog> listLog = new ArrayList<>();
for (Message msg : messages) {
//去除think过程
String[] strs = msg.getText().split("</think>");
String text = strs.length == 2 ? strs[1] : strs[0];
// 构建缓存实体
ChatEntity ent = new ChatEntity();
ent.setChatId(conversationId);
ent.setType(msg.getMessageType().getValue());
ent.setText(text);
listIn.add(ent);
// 构建持久化实体
//缓存实体和持久化实体就相差一个字段,之所以要分开,是因为缓存信息的要作为上下文提供给ai的,越简练越好
ChatAiLog chatAiLog = new ChatAiLog();
chatAiLog.setChatId(conversationId);
chatAiLog.setType(msg.getMessageType().getValue());
chatAiLog.setText(text);
chatAiLog.setChatTime(DateUtils.getNowDate());
listLog.add(chatAiLog);
}
//redis的工具类,可以使用自身框架的,或自主实现
// 缓存对话记录并设置超时时间
RedisUtils.setCacheList(key, listIn);
RedisUtils.expire(key, Duration.ofMinutes(30));
// 对话日志持久化
chatAiLogService.saveBatch(listLog);
}
// lastN:获取取上下文的条数,怎么传进来的后面代码中有使用
@Override
public List<Message> get(String conversationId, int lastN) {
String key = KEY_PREFIX + conversationId;
List<Message> listOut = new ArrayList<>();
// 校验缓存是否存在
if (!RedisUtils.hasKey(key)) {
return listOut;
}
long size = RedisUtils.getCacheList(key).size();
if (size == 0) {
return listOut;
}
int start = Math.max(0, (int) (size - lastN));
List<Object> listTmp = RedisUtils.getCacheListRange(key, start, -1);
ObjectMapper objectMapper = new ObjectMapper();
for (Object obj : listTmp) {
ChatEntity chat = objectMapper.convertValue(obj, ChatEntity.class);
//构建ai需要的数据格式,MessageType是对话角色:user是用户,system是ai,还有两个特殊的对话角色:assistant(对话组手,如提示语)、ai工具(tool)本文未记录该角色的对话信息
if (MessageType.USER.getValue().equals(chat.getType())) {
listOut.add(new UserMessage(chat.getText()));
} else if (MessageType.ASSISTANT.getValue().equals(chat.getType())) {
listOut.add(new AssistantMessage(chat.getText()));
} else if (MessageType.SYSTEM.getValue().equals(chat.getType())) {
listOut.add(new SystemMessage(chat.getText()));
}
}
return listOut;
}
@Override
public void clear(String conversationId) {
RedisUtils.deleteObject(KEY_PREFIX + conversationId);
}
}
对话实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatEntity implements Serializable {
/**
* 对话ID
*/
private String chatId;
/**
* 类型(system、user、assistant、tool)
*/
private String type;
/**
* 对话消息
*/
private String text;
}
3.3、ai初始化
@Slf4j
@Configuration
@RequiredArgsConstructor
public class ChatClientConfig {
private final ChatClientConfigInfo chatClientConfigInfo;
private final VectorStore vectorStore;
private final ChatModel chatModel;
private final IChatAiLogService chatAiLogService;
/**
* 初始化AI
*/
@Bean
public ChatClient chatClient() {
return ChatClient.builder(chatModel)
// 系统的默认行为和风格,怎么描述需要自主调试,才能达到想要的效果
.defaultSystem(chatClientConfigInfo.getDefaultSystem())
// 默认的顾问(增强ai的一种手段,这里默认只配置向量检索,还可以配置日志等等,想要增强哪方面,可以在springAi官网文档中查看。)
//本文还使用了对话信息顾问,即上下文检索,在调用时使用。
.defaultAdvisors(
// 注册向量检索顾问,用于从向量存储中检索相关信息
new QuestionAnswerAdvisor(
//向量存储器,有4个继承类,本文使用elasticsearch作为向量数据库,所以默认是使用elasticsearch的实现
vectorStore,
//向量文档的检索配置,需要自主微调
SearchRequest.builder()
//相似度阈值
.similarityThreshold(chatClientConfigInfo.getSimilarityThreshold())
//返回匹配的向量文档数量
.topK(chatClientConfigInfo.getTopK())
.build()
)
)
.build();
}
/**
* 注册对话上下文缓存
*/
@Bean
public ChatMemory chatMemory() {
return new ChatRedisMemory(chatAiLogService);
}
}
3.4、初始化知识库
将准备好的基本信息文件转化成向量文档存储到向量数据库中,在使用ai时检索与问题相似数据提供给ai。初始化信息可以是:系统介绍、公司介绍、操作手册、业务名称说明、业务表结构说明等等,文件类型推荐文本和word文档。
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SpringAiRunner implements ApplicationRunner {
private final static String knowledgeBasePath = "chatai/knowledgeBase";
private final static String issuePath = "chatai/问题提示语.txt";
private final ChatClientConfigInfo chatClientConfigInfo;
private final VectorStoreService vectorStoreService;
@Override
public void run(ApplicationArguments args) {
if (chatClientConfigInfo.getEnabled()){
knowledgeBaseInit();
}
readIssue();
}
/**
* 初始化知识库
*/
public void knowledgeBaseInit() {
List<String> fileNames = new ArrayList<>();
try {
fileNames = FileUtils.getClassPathResourceFileName(knowledgeBasePath);
} catch (IOException e) {
log.info("无初始化知识库文件!");
}
// 删除旧文档和添加新文档
vectorStoreService.deleteIn(VectorKey.FILE,fileNames);
for (String fileName : fileNames) {
String filePath = knowledgeBasePath + "/" + fileName;
vectorStoreService.saveFilePath(fileName,filePath);
}
log.info("初始化 向量知识库 成功!");
}
/**
* 读取【问题提示词.txt】文件添加到chatClientConfigInfo
*/
public void readIssue(){
try {
List<String> issues = FileUtils.readLinesFromFile(issuePath);
List<String> infoIssues = chatClientConfigInfo.getIssues();
if (infoIssues != null){
issues.addAll(infoIssues);
}
chatClientConfigInfo.setIssues(issues);
} catch (IOException e) {
log.info("读取【{}】文件失败",issuePath);
}
}
}
例:
4、对话实现
由于ai功能模块化,为了避免耦合,由ai模块提供接口给业务模块,业务模块实现接口,传入ai工具(tool)到ai模块,由ai模块实现对话的核心逻辑。这里是唯一与业务模块关联的地方。
4.1、控制层(ai模块)
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/chat/v1")
public class ChatController {
private final ChatService chatService;
private final ChatMemory chatMemory;
/**
* deepSeek
*/
@GetMapping(value = "/deepseek/rag")
public Flux<String> ragChat(String message) {
return chatService.ragChat(message);
}
/**
* 清空上下文
*/
@GetMapping(value = "/deepseek/clearAway")
public R<Void> clearAway() {
chatMemory.clear(LoginHelper.getUserId().toString());
return R.ok();
}
/**
* 中断对话(后续实现)
*/
}
4.2、ai服务接口(ai模块)
提供给外部的接口,业务模块实现该接口,本文是单例实现,可以改造成策略模式达到多实现的目的,使ai更加专注于某一块业务的处理。
/**
* ai服务
*/
public interface ChatService {
/**
* 多轮对话
*/
Flux<String> ragChat(String message);
}
4.3、实现ai服务接口(业务模块)
/**
* 回收报价ai服务
*/
@Service
@RequiredArgsConstructor
public class EsChatService implements ChatService {
//ai工具调用类
private final EquipmentTool equipmentTool;
private final OrderTool orderTool;
private final ChatClientService chatClientService;
@Override
public Flux<String> ragChat(String message) {
//主要目的:根据业务需要选择性传入ai工具(tool)。
return chatClientService.send(message, equipmentTool, orderTool);
}
}
4.4、ai工具(tool)(业务模块)
核心内容:@Tool和@ToolParam注解,其他可有可无,看业务需要。
只要在方法上加了@Tool注解,那该方法即是ai工具(tool)。
@Tool:使用自然语言描述标记的方法的用处,当ai根据对话内容匹配到时候就会执行该方法。
@ToolParam:使用自然语言描述标记的参数,由ai根据对话内容自动传入。
**作用:**作为给ai提供数据的一种方法,或是由ai操作业务的方法,如在订票系统中:帮我订一张xxx的票。(让ai操作业务需要谨慎,ai并不是百分百准确的)
以下为@Tool的使用示例,与ai模块无关。
@Slf4j
@Component
@RequiredArgsConstructor
@Description("查询回收设备及价格服务")
public class EquipmentTool {
private final EsEquipmentMapper equipmentMapper;
private final EsPriceMapper priceMapper;
@Tool(description = "查询可回收的产品、设备或型号")
public List<Map<String, String>> productName(@ToolParam(description = "类型") String equipmentType,
@ToolParam(description = "品牌") String brand,
@ToolParam(description = "系列", required = false) String series,
@ToolParam(description = "型号", required = false) String model,
@ToolParam(description = "内存", required = false) String internalStorage) {
return equipmentMapper.getProductNameList(equipmentType, brand, series, model, internalStorage);
}
@Tool(description = "查询价格、回收价格")
public List<Map<String, String>> selectPrice(@ToolParam(description = "类型", required = false) String equipmentType,
@ToolParam(description = "品牌", required = false) String brand,
@ToolParam(description = "系列", required = false) String series,
@ToolParam(description = "型号") String model,
@ToolParam(description = "内存", required = false) String internalStorage,
@ToolParam(description = "成色", required = false) String fineness) {
return priceMapper.selectPriceListByTableName("es_price",equipmentType, brand, series, model, internalStorage, fineness);
}
@Tool(description = "查询最新价格、回收价格")
public List<Map<String, String>> selectNewPrice(@ToolParam(description = "类型", required = false) String equipmentType,
@ToolParam(description = "品牌", required = false) String brand,
@ToolParam(description = "系列", required = false) String series,
@ToolParam(description = "型号") String model,
@ToolParam(description = "内存", required = false) String internalStorage,
@ToolParam(description = "成色", required = false) String fineness) {
return priceMapper.selectPriceListByTableName("es_price",equipmentType, brand, series, model, internalStorage, fineness);
}
@Tool(description = "查询历史价格、回收价格")
public List<Map<String, String>> selectHistoryPrice(@ToolParam(description = "类型", required = false) String equipmentType,
@ToolParam(description = "品牌", required = false) String brand,
@ToolParam(description = "系列", required = false) String series,
@ToolParam(description = "型号") String model,
@ToolParam(description = "内存", required = false) String internalStorage,
@ToolParam(description = "成色", required = false) String fineness,
@ToolParam(description = "日期", required = false) Date date) {
String dateToStr = DateUtils.parseDateToStr(FormatsType.YYYY_MM_DD_, date);
String tableName = String.format("%s_%s", "es_price", dateToStr);
return priceMapper.selectPriceListByTableName(tableName,equipmentType, brand, series, model, internalStorage, fineness);
}
}
4.5、对话核心实现(ai模块)
@Service
@RequiredArgsConstructor
public class ChatClientService {
private final ChatClientConfigInfo chatClientConfigInfo;
private final VectorStoreService vectorStoreService;
private final ChatClient chatClient;
private final ChatMemory chatMemory;
/**
* 发送对话
*
* @param message 对话信息
* @param toolObjects ai工具(tool)
*/
public Flux<String> send(String message, Object... toolObjects) {
String userId;
try {
userId = LoginHelper.getLoginUser().getUserId().toString();
} catch (Exception e) {
return Flux.just("请先登录!");
}
//先进行数据检索,在知识库中未检索到信息则认为是与本系统无关的问题
List<Document> documentList = vectorStoreService.search(message);
if (documentList.isEmpty()) {
// 这里是获取3.4初始化知识库中的问题提示词,引导用户提问方式。
List<String> issues = chatClientConfigInfo.getIssues();
Collections.shuffle(issues);
return Flux.just("我无法回答与系统无关的问题,请换一个问题吧。" +
"\n" + issues.get(0) + "\n" + issues.get(1) + "\n" + issues.get(2)
);
}
return chatClient.prompt()
// 设置用户对话
.user(message)
// 上下文顾问
.advisors(new MessageChatMemoryAdvisor(
// 添加3.2中实现的redis上下文缓存
chatMemory,
// 标识,这里使用的是用户ID,对用户的对话进行隔离
// 如有多人追问ai的场景需求的,可以使用组ID、部门ID等等
userId,
//对话上下文记忆条数
chatClientConfigInfo.getChatHistoryWindowSize()))
// 添加ai工具(toolObjects)
.tools(toolObjects)
// 使用流式输出
.stream().content();
}
}
5、向量数据的存储与检索(核心:VectorStore类)
VectorStore类是没有修改方法的,只有新增和删除。
目前在做多租户数据隔离改造,后续更新。
功能有:向量数据库的查询、新增、批量新增(需要额外编写配置类)、删除、条件过滤。
6、ai对话日志
简单日志信息的存储和查询,常用的三层架构,可以改成其他的方式。
ai对话日志是为了通过分析更好的调整ai,如用户高频问题,可以编写专门的ai工具调用方法。
6.1、实体类
/**
* ai对话日志
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("chat_ai_log")
public class ChatAiLog extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id",type = IdType.AUTO)
private Long id;
/**
* 对话ID(用户ID)
*/
private String chatId;
/**
* 类型(system、user、assistant)
*/
private String type;
/**
* 对话消息
*/
private String text;
/**
* 对话时间
*/
private Date chatTime;
}
6.2、Mapper及xml文件
public interface ChatAiLogMapper extends BaseMapper<ChatAiLog> {
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.chatai.mapper.ChatAiLogMapper">
</mapper>
6.3、service及impl
public interface IChatAiLogService extends IService<ChatAiLog> {
TableDataInfo<ChatAiLog> queryPageList(ChatAiLog bo, PageQuery pageQuery);
}
@Slf4j
@RequiredArgsConstructor
@Service
public class IChatAiLogServiceImpl extends ServiceImpl<ChatAiLogMapper, ChatAiLog> implements IChatAiLogService {
@Override
public TableDataInfo<ChatAiLog> queryPageList(ChatAiLog bo, PageQuery pageQuery) {
LambdaQueryWrapper<ChatAiLog> lqw = buildQueryWrapper(bo);
Page<ChatAiLog> result = baseMapper.selectPage(pageQuery.build(), lqw);
return TableDataInfo.build(result);
}
private LambdaQueryWrapper<ChatAiLog> buildQueryWrapper(ChatAiLog bo) {
LambdaQueryWrapper<ChatAiLog> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(ChatAiLog::getId);
lqw.like(StringUtils.isNotBlank(bo.getType()), ChatAiLog::getType, bo.getType());
lqw.eq(StringUtils.isNotBlank(bo.getText()), ChatAiLog::getText, bo.getText());
return lqw;
}
}
6.4、controller
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/chat/log")
public class ChatAiLogController {
private final IChatAiLogService chatAiLogService;
/**
* 查询ai对话日志列表
*/
@SaCheckPermission("chat:log:list")
@GetMapping("/list")
public TableDataInfo<ChatAiLog> list(ChatAiLog bo, PageQuery pageQuery) {
return chatAiLogService.queryPageList(bo, pageQuery);
}
/**
* 获取ai对话日志详细信息
*
* @param id 主键
*/
@SaCheckPermission("chat:log:query")
@GetMapping("/{id}")
public R<ChatAiLog> getInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) {
return R.ok(chatAiLogService.getById(id));
}
}