参考视频:黑马程序员SpringAI+DeepSeek大模型应用开发实战视频教程
1. 查询课程
1.1. 需求分析
在天机AI助手中,学生可以查询课程信息,比如:提供了课程id进行查询:

1.2. 课程微服务
在原有系统中,有课程微服务,提供了课程相关的接口服务:

其中,提供了通过课程id查询课程信息的接口,我不需要关心具体的实现,只需要知道有这样的接口就行:

他所对应的Feign client是在这里:

有了FeignClient就可以访问微服务了。
1.3. 实现流程
1.4. 代码实现
1.4.1. 定义结果类
下面定义,Tools返回的结果数据:
package com.tianji.aigc.tools.result;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.NumberUtil;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.tianji.api.dto.course.CourseBaseInfoDTO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Optional;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CourseInfo {
@JsonPropertyDescription("课程id")
private Long id;
@JsonPropertyDescription("课程名称")
private String name;
@JsonPropertyDescription("课程价格,单位为元,货币为人民币")
private double price;
@JsonPropertyDescription("课程学习有效期,单位:月")
private Integer validDuration;
@JsonPropertyDescription("适用人群,例如:初学者")
private String usePeople;
@JsonPropertyDescription("课程详细介绍")
private String detail;
/**
* 将CourseBaseInfoDTO转换为CourseInfo对象
*
* @param courseBaseInfoDTO 课程基础信息数据传输对象(包含原始课程数据)
* @return 转换后的课程信息实体对象(包含格式化后的价格和详情页URL)
*/
public static CourseInfo of(CourseBaseInfoDTO courseBaseInfoDTO) {
if (null == courseBaseInfoDTO) {
return null;
}
// 基础对象属性拷贝(忽略转换错误)
CourseInfo courseInfo = BeanUtil.toBeanIgnoreError(courseBaseInfoDTO, CourseInfo.class);
// 价格格式化处理:分转元 -> 四舍五入保留两位小数 -> 默认值0.0
courseInfo.setPrice(Optional.ofNullable(courseBaseInfoDTO.getPrice())
.map(num -> num.doubleValue() / 100d)
.map(num -> NumberUtil.round(num, 2).doubleValue())
.orElse(0.0d));
return courseInfo;
}
}1.4.2. 定义常量类
package com.tianji.aigc.constants;
public interface Constant {
interface Tools {
String QUERY_COURSE_BY_ID = "根据课程id查询课程详细信息";
}
interface ToolParams {
String COURSE_ID = "课程id";
}
}1.4.3. 编写Tool代码
package com.tianji.aigc.tools;
import com.tianji.aigc.constants.Constant;
import com.tianji.aigc.tools.result.CourseInfo;
import com.tianji.api.client.course.CourseClient;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class CourseTools {
private final CourseClient courseClient;
/**
* 根据课程id查询课程信息
*
* @param courseId 课程id
* @return 课程信息
*/
@Tool(description = Constant.Tools.QUERY_COURSE_BY_ID)
public CourseInfo queryCourseById(@ToolParam(description = Constant.ToolParams.COURSE_ID) Long courseId) {
return Optional.ofNullable(courseId)
.map(id -> CourseInfo.of(this.courseClient.baseInfo(id, true)))
.orElse(null);
}
}1.4.4. 注册Tool
/**
* 配置 ChatClient
*/
@Bean
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
Advisor loggerAdvisor,
Advisor messageChatMemoryAdvisor,
CourseTools courseTools // 课程工具
) { // 日志记录器
return chatClientBuilder
.defaultAdvisors(loggerAdvisor, messageChatMemoryAdvisor) //添加 Advisor 功能增强
.defaultTools(courseTools) //添加默认工具
.build();
}1.4.5. 测试
输入内容:查询课程,课程id为:1589905661084430337, 可以看到,已经调用工具查询到课程数据了。
在tool代码处添加断点:

可以看到,传入课程参数了:

效果:

1.5. 课程卡片
1.5.1. 功能说明

需要在AI助手中,如果是查询课程,或推荐课程,需要展示出课程的卡片信息,其中,要显示课程的价格、名称等信息,还可以,点击卡片,跳转到课程详情页面。
1.5.2. 实现分析
如何实现展示卡片需求呢?
分析:
要想实现这个效果,就必须给前端返回相应的参数数据,前端才能展示卡片,但是,上述的内容,都是大模型返回的,都是些文字数据,而我们需要给前端的是格式化的数据,例如json数据,该怎么做呢?
实际上,就是在Flux输出流的最后,做判断,如果调用了工具,拿到工具的结果,追加到输出流的结束标签之前即可。像这样:

这个参数数据结构是这样的:(这个是和前端约定好的结构)
{
"eventData": {
"courseInfo_1589905661084430337": {
"id": "1589905661084430337",
"name": "可能是史上最全的微服务技术栈课程",
"price": 199.0,
"validDuration": 9999,
"usePeople": "有一定的Java开发基础,熟练使用了SpringBoot、MyBatis等基础框架",
"detail": "可能是史上最全的微服务技术栈课程,由黑马名师授课,你值的拥有"
}
},
"eventType": 1003
}这里有一件很重要的事情还没搞清楚,就是,Tool执行的结果已经给了大模型,我们在Flux输出时如何获取到呢?(这个问题很重要)
要想解决这个问题,就必须在全局有个容器,工具执行完后,将结果放入容器,流输出的最后进行判断,判断这个容器中是否有数据,如果有,就添加到流中,反之,就不需要添加。这样就可以解决问题了。
仔细想想,其实还有一个问题,就是存入这个容器的数据,怎么确保是这次请求的结果数据呢?能不能和sessionId关联?这其实是不可以的,因为同一个sessionid也可能有并发的情况,所以不能使用sessionId,那就需要重新生成一个requestId,这个请求id,每次发起大模型时都会生成一个新的id,用这个请求id和容器的数据关联起来,问题就解决了。
基本的流程如下:

1.5.3. 代码实现
1.5.3.1. 生成请求id与传递
在com.tianji.aigc.service.impl.ChatServiceImpl#chat方法中,需要生成requestId,并且传递到工具中,这里就需要使用SpringAI的toolContext进行传递了。
package com.tianji.aigc.constants;
public interface Constant {
String REQUEST_ID = "requestId";
interface Tools {
String QUERY_COURSE_BY_ID = "根据课程id查询课程详细信息";
}
interface ToolParams {
String COURSE_ID = "课程id";
}
} @Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
var conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
// 生成请求id
var requestId = IdUtil.fastSimpleUUID();
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))
.toolContext(Map.of(Constant.REQUEST_ID, requestId)) //通过工具上下文传递参数
.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())
);
}1.5.3.2. 工具结果保持器
package com.tianji.aigc.config;
import cn.hutool.core.lang.Assert;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 工具结果保持器,用来存储tools中得到的结果,请求id 作为key, value为键值对数据
*
* @author zzj
* @version 1.0
*/
public class ToolResultHolder {
private static final Map<String, Map<String, Object>> HANDLER_MAP = new ConcurrentHashMap<>();
/**
* 工具类,禁止实例化
*/
private ToolResultHolder() {
}
public static void put(String key, String field, Object result) {
Assert.notNull(key, "key is not null!");
Assert.notNull(field, "field is not null!");
HANDLER_MAP.computeIfAbsent(key, k -> new HashMap<>()).put(field, result);
}
public static Map<String, Object> get(String key) {
return key == null ? null : HANDLER_MAP.get(key);
}
public static Object get(String key, String field) {
Assert.notNull(key, "key is not null!");
Assert.notNull(field, "field is not null!");
return Optional.ofNullable(HANDLER_MAP.get(key))
.map(map -> map.get(field))
.orElse(null);
}
public static void remove(String key) {
Assert.notNull(key, "key is not null!");
HANDLER_MAP.remove(key);
}
}1.5.3.3. 工具保存数据
在CourseTools中,需要将查询的课程数据存储到ToolResultHolder中,并且要获取ToolContext到传来的requestId。
package com.tianji.aigc.tools;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import com.tianji.aigc.config.ToolResultHolder;
import com.tianji.aigc.constants.Constant;
import com.tianji.aigc.tools.result.CourseInfo;
import com.tianji.api.client.course.CourseClient;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class CourseTools {
private final CourseClient courseClient;
private static final String FIELD_NAME_FORMAT = "{}_{}"; // 提取格式字符串常量
/**
* 根据课程id查询课程信息
*
* @param courseId 课程id
* @return 课程信息
*/
@Tool(description = Constant.Tools.QUERY_COURSE_BY_ID)
public CourseInfo queryCourseById(@ToolParam(description = Constant.ToolParams.COURSE_ID) Long courseId, ToolContext toolContext) {
return Optional.ofNullable(courseId)
.map(id -> CourseInfo.of(this.courseClient.baseInfo(id, true)))
.map(courseInfo -> {
// 存储数据的字段名
String field = StrUtil.format(FIELD_NAME_FORMAT,
StrUtil.lowerFirst(CourseInfo.class.getSimpleName()),
courseInfo.getId());
// 存储的key
var requestId = Convert.toStr(toolContext.getContext().get(Constant.REQUEST_ID));
ToolResultHolder.put(requestId, field, courseInfo);
return courseInfo;
})
.orElse(null);
}
}1.5.3.4. 输出流中添加结果
在com.tianji.aigc.service.impl.ChatServiceImpl#chat方法中,需要进行判断,如果当前请求中有工具生成的数据(也就是工具被调用了),就加到流中,反之不添加。
// 输出结束的标记
private static final ChatEventVO STOP_EVENT = ChatEventVO.builder().eventType(ChatEventTypeEnum.STOP.getValue()).build();
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
var conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
// 生成请求id
var requestId = IdUtil.fastSimpleUUID();
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))
.toolContext(Map.of(Constant.REQUEST_ID, requestId)) //通过工具上下文传递参数
.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.defer(() -> {
// 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
var map = ToolResultHolder.get(requestId);
if (CollUtil.isNotEmpty(map)) {
ToolResultHolder.remove(requestId); // 清除参数列表
// 响应给前端的参数数据
var chatEventVO = ChatEventVO.builder()
.eventData(map)
.eventType(ChatEventTypeEnum.PARAM.getValue())
.build();
return Flux.just(chatEventVO, STOP_EVENT);
}
return Flux.just(STOP_EVENT);
}));
}1.5.4. 功能测试
输入内容进行测试:查询课程,Id为:1880529463279169537

可以看到,课程卡片的效果实现了。
1.6. 小结
在查询课程这个功能中,主要学习了:
理解项目中原有的课程微服务,要知道怎么调用它
Tool的定义、注册方法,以及代码优化手段,比如:字符串要写到常量类中
在课程卡片功能中的知识点
- 给前端传递结构化的数据,不是大模型返回的
- 通过toolContext传递数据到tool中
- ToolResultHolder结构的设计
- tool中的结果数据,存储到容器,再传回输出流中
2. 预下单
预下单,这个功能的实现与前面的课程查询实现思路基本一致。
2.1. 需求分析
在推荐课程的结果中,或者直接指定课程id,进行下单操作,同样要显示下单的卡片,点击【立即下单】即可完成下单功能:


2.2. 实现说明
- 定义
OrderTools工具,调用TradeClient中的prePlaceOrder方法进行预下单操作 - 调用
TradeClient中的prePlaceOrder方法时,需要在UserContext中设置当前用户id,这个用户id,需要通过ToolContext传递过来 - 需要在
SpringAIConfig中注册OrderTools工具到ChatClient中 - 工具名、参数描述,需要定义在常量类中的
响应给前端的数据结构:
{
"eventData": {
"prePlaceOrder": {
"count": 1,
"totalAmount": 199.0,
"discountAmount": 6.0,
"couponName": "叠加6券:【优惠6.0元】",
"payAmount": 193.0,
"courseIds": [
"1589905661084430337"
],
"orderId": "1904459544722419714",
"couponId": "1901825343409999874"
}
},
"eventType": 1003
}
结果对象不需要自己编写,直接使用给出的代码即可:
package com.tianji.aigc.tools.result;
import cn.hutool.core.collection.CollStreamUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.tianji.api.dto.promotion.CouponDiscountDTO;
import com.tianji.api.dto.promotion.OrderCourseDTO;
import com.tianji.api.dto.trade.OrderConfirmVO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Optional;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PrePlaceOrder {
@JsonPropertyDescription("课程数量")
private int count;
@JsonPropertyDescription("订单总金额")
private double totalAmount;
@JsonPropertyDescription("最大优惠金额")
private double discountAmount;
@JsonPropertyDescription("优惠券名称")
private String couponName;
@JsonPropertyDescription("实付金额")
private double payAmount;
@JsonPropertyDescription("课程id列表")
private List<Long> courseIds;
@JsonPropertyDescription("订单id")
private Long orderId;
@JsonPropertyDescription("优惠券id")
private Long couponId;
public static PrePlaceOrder of(OrderConfirmVO orderConfirmVO) {
// 订单总金额
double totalAmount = Optional.ofNullable(orderConfirmVO.getTotalAmount())
.map(num -> num.doubleValue() / 100d)
.map(num -> NumberUtil.round(num, 2).doubleValue())
.orElse(0.0d);
//最大优惠金额
double discountAmount = Optional.ofNullable(CollUtil.getFirst(orderConfirmVO.getDiscounts()))
.map(CouponDiscountDTO::getDiscountAmount)
.map(num -> num.doubleValue() / 100d)
.map(num -> NumberUtil.round(num, 2).doubleValue())
.orElse(0.0d);
//优惠券名称
String couponName = Optional.ofNullable(CollUtil.getFirst(orderConfirmVO.getDiscounts()))
.map(couponDiscountDTO -> {
List<String> rules = couponDiscountDTO.getRules();
int size = CollUtil.size(rules);
return size >= 2
? StrUtil.format("叠加{}券:【优惠{}元】", size, discountAmount)
: StrUtil.format("单券:【{}】", CollUtil.getFirst(rules));
})
.orElse("");
// 实付金额,保留小数点后两位
double payAmount = NumberUtil.round(totalAmount - discountAmount, 2).doubleValue();
// 课程id列表
List<Long> courseIds = CollStreamUtil.toList(orderConfirmVO.getCourses(), OrderCourseDTO::getId);
// 优惠券id
Long couponId = Optional.ofNullable(CollUtil.getFirst(orderConfirmVO.getDiscounts()))
.map(CouponDiscountDTO::getIds)
.map(CollUtil::getFirst)
.orElse(null);
return PrePlaceOrder.builder()
.count(CollUtil.size(orderConfirmVO.getCourses()))
.totalAmount(totalAmount)
.discountAmount(discountAmount)
.couponName(couponName)
.payAmount(payAmount)
.courseIds(courseIds)
.orderId(orderConfirmVO.getOrderId())
.couponId(couponId)
.build();
}
}2.3. 代码实现
2.3.1. OrderTools
package com.tianji.aigc.tools;
import cn.hutool.core.collection.CollStreamUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import com.tianji.aigc.config.ToolResultHolder;
import com.tianji.aigc.constants.Constant;
import com.tianji.aigc.tools.result.PrePlaceOrder;
import com.tianji.api.client.trade.TradeClient;
import com.tianji.common.utils.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class OrderTools {
private final TradeClient tradeClient;
@Tool(description = Constant.Tools.PRE_PLACE_ORDER)
public PrePlaceOrder prePlaceOrder(@ToolParam(description = Constant.ToolParams.COURSE_IDS) List<Number> ids,
ToolContext toolContext) {
// 设置用户ID,用于身份验证,否在在Feign调用时会出现401错误
UserContext.setUser(Convert.toLong(toolContext.getContext().get(Constant.USER_ID)));
// 大模型传入的ids,可能是int类型,所以转化为long类型,再调用Feign
var orderConfirmVO = this.tradeClient.prePlaceOrder(CollStreamUtil.toList(ids, Number::longValue));
return Optional.ofNullable(orderConfirmVO)
.map(PrePlaceOrder::of)
.map(prePlaceOrder -> {
var field = StrUtil.lowerFirst(prePlaceOrder.getClass().getSimpleName());
var requestId = Convert.toStr(toolContext.getContext().get(Constant.REQUEST_ID));
ToolResultHolder.put(requestId, field, prePlaceOrder);
return prePlaceOrder;
})
.orElse(null);
}
}2.3.2. Constant
package com.tianji.aigc.constants;
public interface Constant {
String USER_ID = "userId";
String REQUEST_ID = "requestId";
interface Tools {
String QUERY_COURSE_BY_ID = "根据课程id查询课程详细信息";
String PRE_PLACE_ORDER = "购买课程预下单操作";
}
interface ToolParams {
String COURSE_ID = "课程id";
String COURSE_IDS = "课程id列表";
}
}2.3.3. SpringAIConfig
/**
* 配置 ChatClient
*/
@Bean
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
Advisor loggerAdvisor,
Advisor messageChatMemoryAdvisor,
CourseTools courseTools, // 课程工具
OrderTools orderTools // 预下单工具
) { // 日志记录器
return chatClientBuilder
.defaultAdvisors(loggerAdvisor, messageChatMemoryAdvisor) //添加 Advisor 功能增强
.defaultTools(courseTools, orderTools) //添加默认工具
.build();
}2.3.4. ChatServiceImpl
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
var conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
// 生成请求id
var requestId = IdUtil.fastSimpleUUID();
// 获取用户id
var userId = UserContext.getUser();
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))
.toolContext(Map.of(Constant.REQUEST_ID, requestId, Constant.USER_ID, userId)) //通过工具上下文传递参数
.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.defer(() -> {
// 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
var map = ToolResultHolder.get(requestId);
if (CollUtil.isNotEmpty(map)) {
ToolResultHolder.remove(requestId); // 清除参数列表
// 响应给前端的参数数据
ChatEventVO chatEventVO = ChatEventVO.builder()
.eventData(map)
.eventType(ChatEventTypeEnum.PARAM.getValue())
.build();
return Flux.just(chatEventVO, STOP_EVENT);
}
return Flux.just(STOP_EVENT);
}));
}3. bug解决
3.1. bug描述
课程查询和预下单功能,给前端返回的数据中,包含了eventType为1003的数据,这个叫作额外数据,给前端提供,前端是不会显示到页面的,正常对话是没问题的,但是,数据存储到Redis是没有保存进去的,如下:


所以,我们就需要来解决这个bug,在保存聊天记录到redis的同时,也将params保存进去。
3.2. bug解决
这个bug的解决思路就是,在RedisChatMemoryRepository中保存数据时,获取到ToolResultHolder中的数据,将数据保存到params中即可。
但是,ToolResultHolder中的数据,是与requestId关联的,requestId是我们自己生成的,在RedisChatMemory中是没有的,所以,这个问题的关键就是如何获取到requestId了,只要有了requestId就可以获取到数据,进行保存了。
如何传递requestId?
其实,同样也是可以借助于ToolResultHolder来完成,我们可以把ToolResultHolder看作是一个通用的容器,可以放Tool的结果,也可以放其他的内容,只要及时的删除即可。
有了这个思路,问题就好解决了,解决代码如下:
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
var conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
// 生成请求id
var requestId = IdUtil.fastSimpleUUID();
// 获取用户id
var userId = UserContext.getUser();
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))
.toolContext(MapUtil.<String, Object>builder() // 设置tool列表
.put(Constant.REQUEST_ID, requestId) // 设置请求id参数
.put(Constant.USER_ID, userId) // 设置用户id参数
.build()
)
.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 -> {
// 对于响应结果进行处理,如果是最后一条数据,就把此次消息id放到内存中
// 主要用于存储消息数据到 redis中,可以根据消息di获取的请求id,再通过请求id就可以获取到参数列表了
// 从而解决,在历史聊天记录中没有外参数的问题
var finishReason = chatResponse.getResult().getMetadata().getFinishReason();
if (StrUtil.equals(Constant.STOP, finishReason)) {
var messageId = response.getMetadata().getId();
ToolResultHolder.put(messageId, Constant.REQUEST_ID, requestId);
}
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 追加到输出内容中
outputBuilder.append(text);
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.defer(() -> {
// 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
var map = ToolResultHolder.get(requestId);
if (CollUtil.isNotEmpty(map)) {
ToolResultHolder.remove(requestId); // 清除参数列表
// 响应给前端的参数数据
ChatEventVO chatEventVO = ChatEventVO.builder()
.eventData(map)
.eventType(ChatEventTypeEnum.PARAM.getValue())
.build();
return Flux.just(chatEventVO, STOP_EVENT);
}
return Flux.just(STOP_EVENT);
}));
}常量类中增加常量值:
package com.tianji.aigc.constants;
public interface Constant {
String USER_ID = "userId";
String REQUEST_ID = "requestId";
String STOP = "STOP";
String ID = "id";
interface Tools {
String QUERY_COURSE_BY_ID = "根据课程id查询课程详细信息";
String PRE_PLACE_ORDER = "购买课程预下单操作";
}
interface ToolParams {
String COURSE_ID = "课程id";
String COURSE_IDS = "课程id列表";
}
}在MessageUtil中增加params参数的处理:
/**
* 将Message对象转换为Redis存储格式的JSON字符串
*
* @param message 需要转换的原始消息对象
* @return 符合Redis存储规范的JSON字符串
*/
public static String toJson(Message message) {
var redisMessage = BeanUtil.toBean(message, RedisMessage.class);
// 设置消息内容
redisMessage.setTextContent(message.getText());
if (message instanceof AssistantMessage assistantMessage) {
redisMessage.setToolCalls(assistantMessage.getToolCalls());
// 通过 messageId 获取 requestId,再通过 requestId 获取参数列表,如果有,就存储起来
// 最后,删除 messageId 对应的数据
var messageId = Convert.toStr(assistantMessage.getMetadata().get(Constant.ID));
var requestId = Convert.toStr(ToolResultHolder.get(messageId, Constant.REQUEST_ID));
var params = ToolResultHolder.get(requestId);
if (ObjectUtil.isNotEmpty(params)) {
myMessage.setParams(params);
}
ToolResultHolder.remove(messageId);
}
if (message instanceof ToolResponseMessage toolResponseMessage) {
redisMessage.setToolResponses(toolResponseMessage.getResponses());
}
return JSONUtil.toJsonStr(redisMessage);
}在反序列化Message对象时,无法给AssistantMessage设置params属性,所以需要继承AssistantMessage来扩展params属性:
package com.tianji.aigc.memory;
import lombok.Getter;
import lombok.Setter;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.content.Media;
import java.util.List;
import java.util.Map;
@Setter
@Getter
public class MyAssistantMessage extends AssistantMessage {
private Map<String, Object> params;
public MyAssistantMessage(String content, Map<String, Object> properties, List<ToolCall> toolCalls, List<Media> media, Map<String, Object> params) {
super(content, properties, toolCalls, media);
this.params = params;
}
} /**
* 将存储的JSON字符串反序列化为对应的Message对象
*
* @param json 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 new UserMessage(myMessage.getTextContent(), myMessage.getMedia(), myMessage.getMetadata());
}
case ASSISTANT -> {
return new MyAssistantMessage(myMessage.getTextContent(), myMessage.getMetadata(), myMessage.getToolCalls(), myMessage.getMedia(), myMessage.getParams());
// 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.");
}根据sessionId查询对话详情时,也需要对params参数做处理:
@Override
public List<MessageVO> queryBySessionId(String sessionId) {
// 根据会话ID获取对话ID
String conversationId = ChatService.getConversationId(sessionId);
// 从Redis中获取历史消息
List<Message> messageList = this.chatMemory.get(conversationId, HISTORY_MESSAGE_COUNT);
// 过滤并转换消息列表
return StreamUtil.of(messageList)
// 过滤掉非用户消息和助手消息
.filter(message -> message.getMessageType() == MessageType.ASSISTANT || message.getMessageType() == MessageType.USER)
// 转换为MessageVO对象
.map(message -> {
if (message instanceof MyAssistantMessage) {
return MessageVO.builder()
.content(message.getText())
.type(MessageTypeEnum.valueOf(message.getMessageType().name()))
.params(((MyAssistantMessage) message).getParams())
.build();
}
return MessageVO.builder()
.content(message.getText())
.type(MessageTypeEnum.valueOf(message.getMessageType().name()))
.build();
})
.toList();
}3.3. 测试


可以看到,问题已经解决了。
4. 知识库
4.1. 为什么要用知识库
之所以要使用知识库,是因为我们在做课程推荐时,需要先从知识库匹配到课程,再通过课程id查询课程信息进行推荐,如果没有知识库,就无法根据学生的需求进行推荐,所以必须要用到知识库了。
实现流程如下:

前面我们学习过SpringAI的RAG,前面是使用内存向量库,实际上,在项目中是不可能用内存存储的,所以,在我们项目中,选择使用ElasticSearch作为向量库存储数据。
https://docs.spring.io/spring-ai/reference/1.0/api/vectordbs/elasticsearch.html
4.2. RAG基本原理
下面这张图是来源于Spring AI官网文档,说明了RAG整体实现流程。👉 RAG :: Spring AI Reference

📚 这张图展示了文档摄取(ETL)和检索增强生成(RAG)两个核心流程,具体可分为以下两部分:
文档摄取(ETL)流程(离线处理)
a. 数据读取:从数据源(如文档、数据库)读取原始文档。
b. 分割文档:通过分割模块(
<<Split>>)将文档切分为更小的数据块(chunks)。c. 转换数据:通过转换模块(
Transformer)处理数据块(如向量化、添加元数据)。d. 写入存储:将处理后的数据块写入向量数据库(
Vector Store),为后续检索做准备。e. 核心目标:将非结构化文档转化为结构化、可检索的向量数据。
检索增强生成(RAG)流程(实时处理)
a. 用户查询:接收用户提问(
Chat Request)。b. 检索相关块:从向量库中检索与查询最相关(相似度高)的数据块(
<<Retrieve>>)。c. 增强查询:将检索到的上下文信息(
Context information)与用户问题结合,生成增强后的提示(<<Augment>>)。d. 生成响应:通过聊天模型(
Chat Model)生成回答。e. 核心目标:通过外部知识库提升生成结果的准确性,解决了大模型信息缺失或滞后的问题。
4.3. 部署ES
在我们的虚拟机中,已经有一个ES服务了,这个ES是用作课程搜索的,并且版本也比较低,所以我们不直接使用这个,而是重新搭建一个ES服务,用来做向量库。
#win平台
docker run -d \
--name es2 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-v es2-data:/usr/share/elasticsearch/data \
-v es2-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es2-net \
--restart=always \
-p 19200:9200 \
-p 19300:9300 \
registry.cn-beijing.aliyuncs.com/itcast/elasticsearch:8.13.4
---------------------------------------------------------------------------------
#M系列MAC虚拟机
docker run -d \
--name es2 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-v es2-data:/usr/share/elasticsearch/data \
-v es2-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es2-net \
--restart=always \
-p 19200:9200 \
-p 19300:9300 \
docker.1ms.run/elasticsearch:8.13.4
#如果容器已经存在,可以先删除,再创建
#删除
docker rm -f es2
#清理挂载目录中无用的数据
docker volume prune测试:http://192.168.150.101:19200/

说明,ES已经部署好了。
4.4. 项目集成ES
在tj-aigc项目中,导入SpringAI集成ES的依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-elasticsearch</artifactId>
<exclusions>
<exclusion>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.15.5</version>
</dependency>在nacos的配置中心的aigc-service.yaml文件中进行配置:
spring:
elasticsearch:
uris: http://192.168.150.101:19200
ai:
dashscope:
api-key: ${tj.ai.dashscope.key}
chat:
enabled: true
options:
model: qwen-plus
# model: qwen-plus
# model: qwen2.5-1.5b-instruct 免费模型
embedding:
enabled: true
options:
model: text-embedding-v3 #向量模型
dimensions: 1024 #向量维度维度
vectorstore: #向量库配置
elasticsearch: #使用ES作为向量库存储
initialize-schema: true #开启初始化向量库结构
dimensions: 1024 #向量维度维度重启启动服务。
4.5. 部署kibana
部署kibana的目的是用于查看ES中是否已经创建了索引库。
#win平台
docker run -d \
--name kibana2 \
-e ELASTICSEARCH_HOSTS=http://192.168.150.101:19200 \
-p 15601:5601 \
docker.elastic.co/kibana/kibana:8.13.4
#M系列MAC虚拟机
docker run -d \
--name kibana2 \
-e ELASTICSEARCH_HOSTS=http://192.168.150.101:19200 \
-p 15601:5601 \
docker.1ms.run/kibana:8.13.4等待kibana启动好之后,访问地址:http://192.168.150.101:15601/app/dev_tools#/console

spring-ai-document-index这个是默认的索引库的名称,在官方文档中已经有说明: 👉 Elasticsearch

索引库相关操作:
# 查询索引库
GET /spring-ai-document-index
# 搜索数据
GET /spring-ai-document-index/_search
#删除索引库
DELETE /spring-ai-document-index4.6. 写入数据
4.6.1. 准备数据
在知识库中,需要写入一些数据,以供推荐课程使用。具体数据查看:知识库-课程数据
4.6.2. 编写代码
package com.tianji.aigc.controller;
import cn.hutool.core.collection.CollStreamUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
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;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/embedding")
@RequiredArgsConstructor
public class EmbeddingController {
private final VectorStore vectorStore;
@PostMapping
public void saveVectorStore(@RequestParam("messages") List<String> messages) {
log.info("保存到向量数据库中,消息数据:{}", messages);
//构建文档
List<Document> documents = CollStreamUtil.toList(messages, message -> Document.builder()
.text(message)
.build());
//存储到向量数据库中
this.vectorStore.add(documents);
log.info("保存到向量数据库成功, 数量:{}", messages.size());
}
}测试:

验证是否写入成功:

可以看到已经有数据写入成功了。
接下来就可以把全部的数据导进去了。
4.7. 集成到chatClient
private final VectorStore vectorStore;
@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
// 获取对话id
var conversationId = ChatService.getConversationId(sessionId);
// 大模型输出内容的缓存器,用于在输出中断后的数据存储
StringBuilder outputBuilder = new StringBuilder();
// 生成请求id
var requestId = IdUtil.fastSimpleUUID();
// 获取用户id
var userId = UserContext.getUser();
// 创建RAG增强
var qaAdvisor = QuestionAnswerAdvisor.builder(this.vectorStore)
.searchRequest(SearchRequest.builder().similarityThreshold(0.6d).topK(6).build())
.build();
return this.chatClient.prompt()
.system(promptSystem -> promptSystem
.text(this.systemPromptConfig.getChatSystemMessage().get()) // 设置系统提示语
.param("now", DateUtil.now()) // 设置当前时间的参数
)
.advisors(advisor -> advisor
// 设置RAG增强
.advisors(qaAdvisor)
.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId)
)
.toolContext(MapUtil.<String, Object>builder() // 设置tool列表
.put(Constant.REQUEST_ID, requestId) // 设置请求id参数
.put(Constant.USER_ID, userId) // 设置用户id参数
.build()
)
.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 -> {
// 对于响应结果进行处理,如果是最后一条数据,就把此次消息id放到内存中
// 主要用于存储消息数据到 redis中,可以根据消息di获取的请求id,再通过请求id就可以获取到参数列表了
// 从而解决,在历史聊天记录中没有外参数的问题
var finishReason = chatResponse.getResult().getMetadata().getFinishReason();
if (StrUtil.equals(Constant.STOP, finishReason)) {
var messageId = ((ChatResponseMetadata) ReflectUtil.getFieldValue(chatResponse, Constant.Chats.CHAT_RESPONSE_METADATA)).getId();
ToolResultHolder.put(messageId, Constant.REQUEST_ID, requestId);
}
// 获取大模型的输出的内容
String text = chatResponse.getResult().getOutput().getText();
// 追加到输出内容中
outputBuilder.append(text);
// 封装响应对象
return ChatEventVO.builder()
.eventData(text)
.eventType(ChatEventTypeEnum.DATA.getValue())
.build();
})
.concatWith(Flux.defer(() -> {
// 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
var map = ToolResultHolder.get(requestId);
if (CollUtil.isNotEmpty(map)) {
ToolResultHolder.remove(requestId); // 清除参数列表
// 响应给前端的参数数据
ChatEventVO chatEventVO = ChatEventVO.builder()
.eventData(map)
.eventType(ChatEventTypeEnum.PARAM.getValue())
.build();
return Flux.just(chatEventVO, STOP_EVENT);
}
return Flux.just(STOP_EVENT);
}));
}4.8. 测试课程推荐





4.9. 练习1
4.9.1. 说明
前面我们只实现了向量库的新增操作,除了新增操作外,我们还可以实现如下的几个接口:

实现了这几个接口后,我们就不需要在kibana中操作了。这几个作为练习部分由学员自主完成。
4.9.2. 参考代码
package com.tianji.aigc.controller;
import cn.hutool.core.collection.CollStreamUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/embedding")
@RequiredArgsConstructor
public class EmbeddingController {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
@PostMapping
public void saveVectorStore(@RequestParam("messages") List<String> messages) {
log.info("保存到向量数据库中,消息数据:{}", messages);
//构建文档
List<Document> documents = CollStreamUtil.toList(messages, message -> Document.builder()
.text(message)
.build());
//存储到向量数据库中
this.vectorStore.add(documents);
log.info("保存到向量数据库成功, 数量:{}", messages.size());
}
@GetMapping
public EmbeddingResponse embed(@RequestParam("message") String message) {
return this.embeddingModel.embedForResponse(List.of(message));
}
@DeleteMapping
public void deleteVectorStore(@RequestParam("ids") List<String> ids) {
// 删除向量数据库中的数据
this.vectorStore.delete(ids);
}
@GetMapping("/search")
public List<Document> search(@RequestParam("message") String message) {
return this.vectorStore.similaritySearch(SearchRequest.builder().query(message).topK(5).build());
}
@GetMapping("/search/all")
public List<Document> searchAll() {
// 搜索全部数据
return this.vectorStore.similaritySearch(SearchRequest.builder().query("").topK(999).build());
}
}4.10. 练习2
把所有提供的测试数据导入到向量库中,进行推荐课程的测试。知识库-课程数据
按照SpringAI官方文档,实现使用Redis作为向量库存储,替换课程中的ES部分。👉 Redis :: Spring AI Reference
实现提示:使用Redis实现向量库,需要使用到redis-stack。
📚 Redis Stack 是 Redis 官方推出的一个扩展工具集,基于 Redis 构建,整合了多个高性能模块和工具,旨在简化开发并增强 Redis 的核心功能。它特别适合需要多模型数据处理的场景,如搜索、时间序列、图数据等。
Q.E.D.

