SpringAI官方文档: https://docs.spring.io/spring-ai/reference/index.html
SpringAI中文官方文档: Spring AI 中文文档

整体架构:

新建会话及热门问题:

生成sessionId:

可以使用uuid来生成,确保唯一性。

sessionId保存到数据库:

页面原型:

配置项:

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限制?"

配置类:

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

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

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

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

}

实现类:

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

}

热门问题:

热门问题一般都是固定的问题,所以在后台中存储,随机的返回3个即可,写到nacos的配置中心中,不硬编码到代码。

    @Override
    public List<SessionVO.Example> hotExamples(Integer num) {
        return RandomUtil.randomEleList(sessionProperties.getExamples(), num);
    }

流式对话:

响应结构说明:

Spring AI 内部执行工具时会切换线程,ThreadLocal 的值传不过去

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}

创建配置类SpringAIConfig:

引入依赖中的ChatClient.Builder创建ChatClient对象:

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

编写Service实现类

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

    private final ChatClient chatClient;


    @Override
    public Flux<ChatEventVO> chat(String question, String sessionId) {
        return chatClient.prompt()
                .user(question)
                .stream()//返回的类型:Flux<chatResponse>
                .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()));
    }
}

Spring AI ChatClient 常见API总结:

system提示词:

功能:

  • 功能一:课程推荐
  • 功能二:课程购买
  • 功能三:课程咨询
  • 功能四:知识讲解

提示词工程构建:

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

技能 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,引导学员咨询其他的问题。

可以直接用 @RefreshScope + @Value 自动注入和热更新

配置类进行配置:将提示词放入到对应的配置文件中

@Slf4j
@Getter
@RefreshScope  // Nacos 配置变更时自动刷新
@Configuration
public class SystemPromptConfig {

      @Value("${tj.ai.system}")
      private String chatSystemMessage;
  }

实现类:

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()) // 设置系统提示语
                        .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()));
    }
}

停止生成:

基于Redis容器进行控制输出流的中断:

核心思想:

在大模型输出内容时,进行标记正在输出,如果stop的话,需要将这个标记删除,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) {
         BoundHashOperations<String, Object, Object> hashOps = stringRedisTemplate.boundHashOps(GENSTATU);
        return this.chatClient.prompt()
                .system(promptSystem -> promptSystem
                        .text(this.systemPromptConfig.getChatSystemMessage()) // 设置系统提示语
                        .param("now", DateUtil.now()) // 设置当前时间的参数
                )
                .user(question)
                .stream()
                .chatResponse()
                .doFirst(()-> hashOps.put(sessionId,"true"))
                .doOnError(throwable -> {hashOps.delete(sessionId);})
                .doOnComplete(() -> {hashOps.delete(sessionId);})
                .doOnCancel(()->saveStopHistoryRecord(conversationId,                                            outputBuilder.toString()))
                .filter(chatResponse -> chatResponse.getResult() != null)
                .takeWhile(chatResponse -> {return hashOps.get(sessionId) != null;)
                .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) {
      stringRedisTemplate.delete(sessionId);
}

会话记忆:

SpringAI官方没有提供redis存储的实现:

实现自定义的Redia存储:

就需要实现SpringAI中的org.springframework.ai.chat.memory.ChatMemoryRepository接口

package com.tianji.aigc.memory;

import cn.hutool.core.collection.CollStreamUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.stream.StreamUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import jakarta.annotation.Resource;
import org.jetbrains.annotations.NotNull;
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;
import java.util.Set;

/**
 * 基于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(DEFAULT_PREFIX + "*");
        if (null == keys) {
            return List.of();
        }
        return StreamUtil.of(keys)
                .map(key -> StrUtil.replace(key, DEFAULT_PREFIX, ""))
                .toList();
    }



    @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);
    }
    //全量的传入数据。是整个会话。
    @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)));
    }

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

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

基于 chatMemoryRepository 对象构建 chatMemory 对象:

@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 #存储最大的聊天记录数

自定义对象对Message对象序列化和反序列化:

自定义类:

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

}

自定义工具包:

/**
 * 消息转换工具类,提供消息对象与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.");
    }

}

停止输出bug:

停止是通过中断Flux流程完成的,Flux中断了,SpringAI就不会触发ChatMemoryadd方法,也就不会调用ChatMemoryRepository#saveAll方法了,所以就保存数据了。

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

在ChatServiceImpl实现类中进行迭代更新:

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

实现自定义的Mysql存储:


/**
 * 基于MYSQL实现的ChatMemoryRepository
 */

public class JDBCChatMemoryRepository implements ChatMemoryRepository {

    @Autowired
    private IConversationService conversationService;

    @Override
    public List<String> findConversationIds() {
        List<Conversation> list = conversationService.lambdaQuery().select(Conversation::getConversationId).list();
        return CollStreamUtil.toList(list, Conversation::getConversationId);
    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        List<Conversation> list = conversationService.lambdaQuery().eq(Conversation::getConversationId, conversationId).list();
        List<Message> list1 = CollStreamUtil.toList(list, conversation -> MessageUtil.toMessage(conversation.getData()));
        return list1;
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        deleteByConversationId(conversationId);
        Long userId = UserContext.getUser();
        long uid = userId == null ? 0L : userId;

        List<Conversation> list = CollStreamUtil.toList(messages, message ->
                new Conversation()
                        .setConversationId(conversationId)
                        .setData(MessageUtil.toJson(message))
                        .setCreator(uid)
                        .setUpdater(uid)
        );
        conversationService.saveBatch(list);
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        LambdaQueryWrapper<Conversation> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Conversation::getConversationId, conversationId);
        conversationService.remove(wrapper);
    }
}
spring:
  ai:
    memory:
      max: 100 #存储最大的聊天记录数
      type: Redis

注意:使用注解:@ConditionalOnProperty

    @Bean
    @ConditionalOnProperty(prefix = "tj.ai.memory", value = "type" ,havingValue = "Redis")
    public ChatMemoryRepository redisChatMemoryRepository() {
        return new RedisChatMemoryRepository();
    }

    @Bean
    @ConditionalOnProperty(prefix = "tj.ai.memory", value = "type" ,havingValue = "MYSQL")
    public ChatMemoryRepository jdbcChatMemoryRepository() {
        return new JDBCChatMemoryRepository();
    }

Tool调用:

查询课程:

定义Tool类:

package com.tianji.aigc.constants;

public interface Constant {

    interface Tools {
        String QUERY_COURSE_BY_ID = "根据课程id查询课程详细信息";
    }

    interface ToolParams {
        String COURSE_ID = "课程id";
    }

}
@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 courseId == null ? null : CourseInfo.of(this.courseClient.baseInfo(courseId, true));
    }
}

注册tool:

    /**
     * 配置 ChatClient
     */
    @Bean
    public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
                                 Advisor loggerAdvisor,
                                 Advisor messageChatMemoryAdvisor,
                                 CourseTools courseTools // 课程工具
    ) {  // 日志记录器
        return chatClientBuilder
                .defaultAdvisors(loggerAdvisor, messageChatMemoryAdvisor) //添加 Advisor 功能增强
                .defaultTools(courseTools) //添加默认工具
                .build();
    }

预下单:

定义Tool类:

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列表";
    }

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

注册tool:

 原因很明确:UserContext 用的是 ThreadLocal。

  请求线程:UserContext.setUser(123) → ThreadLocal 存入当前线程
      ↓
  ChatClient 流式处理(可能切换线程)
      ↓
  工具方法在另一个线程执行 → UserContext.getUser() → null(拿不到)

  Spring AI 内部执行工具时会切换线程,ThreadLocal 的值传不过去。所以需要通过 ToolContext 手动把 userId带过去,在工具方法里重新 UserContext.setUser() 来恢复上下文。

    /**
     * 配置 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();
    }

课程卡片:

需要在AI助手中,如果是查询课程,或推荐课程,需要展示出课程的卡片信息,其中,要显示课程的价格、名称等信息,还可以,点击卡片,跳转到课程详情页面。

实际上,就是在Flux输出流的最后,做判断,如果调用了工具,拿到工具的结果,追加到输出流的结束标签之前即可。

输出流结构:

{
    "eventData": {
        "courseInfo_1589905661084430337": {
            "id": "1589905661084430337",
            "name": "可能是史上最全的微服务技术栈课程",
            "price": 199.0,
            "validDuration": 9999,
            "usePeople": "有一定的Java开发基础,熟练使用了SpringBoot、MyBatis等基础框架",
            "detail": "可能是史上最全的微服务技术栈课程,由黑马名师授课,你值的拥有"
        }
    },
    "eventType": 1003
}

Redis全局容器

解决方案:Redis全局容器,工具执行完后,将结果放入容器,流输出的最后进行判断,判断这个容器中是否有数据,如果有,就添加到流中,反之,就不需要添加。

同一个sessionid也可能有并发的情况,所以不能使用sessionId,那就需要重新生成一个requestId,这个请求id,每次发起大模型时都会生成一个新的id,用这个请求id和容器的数据关联起来。

保存工具数据:

@Component
@RequiredArgsConstructor
public class CourseTools {

    private final CourseClient courseClient;

    private final static String FIELD = "courseinfo_";

    private final StringRedisTemplate  stringRedisTemplate;

    /**
     * 根据课程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) {
        CourseInfo courseInfo = courseId == null ? null : CourseInfo.of(this.courseClient.baseInfo(courseId, true));
        String requestId = (String) toolContext.getContext().get(Constant.REQUEST_ID);
        stringRedisTemplate.opsForHash().put(requestId,FIELD+courseInfo.getId(), courseInfo);
        return courseInfo;
    }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements IChatService {

    private final ChatClient chatClient;

    private final SystemPromptConfig systemPromptConfig;

//    private final Map<String,Boolean> GENSTATU = new ConcurrentHashMap<>();
    private static final ChatEventVO STOP_EVENT = ChatEventVO.builder().eventType(ChatEventTypeEnum.STOP.getValue()).build();

    private final ChatMemory  chatMemory;

    private static final String GENSTATU = "GENSTATU";

    private final StringRedisTemplate stringRedisTemplate;

    private final VectorStore vectorStore;

    @Override
    public Flux<ChatEventVO> chat(String question, String sessionId) {
        StringBuilder outputBuilder = new StringBuilder();
        String conversationId = UserContext.getUser() + sessionId;
        BoundHashOperations<String, Object, Object> hashOps = stringRedisTemplate.boundHashOps(GENSTATU);
        // 生成请求id
        var userId = UserContext.getUser();
        var requestId = IdUtil.fastSimpleUUID();
        var qaAdvisor = QuestionAnswerAdvisor.builder(this.vectorStore)
                .searchRequest(SearchRequest.builder().similarityThreshold(0.6d).topK(6).build())
                .build();
        return chatClient.prompt()
                .system(s -> s
                        .text(systemPromptConfig.getChatSystemMessage())  // 去掉 .get()
                        .param("now", DateUtil.now())
                )
                .advisors(advisor -> advisor.advisors(qaAdvisor).param(ChatMemory.CONVERSATION_ID, conversationId))
                .user(question)
                .toolContext(Map.of(Constant.REQUEST_ID, requestId, Constant.USER_ID, userId)) //通过工具上下文传递参数
                .stream()
                .chatResponse()
                .doFirst(()-> hashOps.put(sessionId,"true"))
                .doOnError(throwable -> {hashOps.delete(sessionId);})
                .doOnComplete(() -> {hashOps.delete(sessionId);})
                .doOnCancel(()->saveStopHistoryRecord(conversationId, outputBuilder.toString()))
                .filter(chatResponse -> chatResponse.getResult() != null)
                .takeWhile(chatResponse -> {
                   return hashOps.get(sessionId) != null;
                })
                .map(chatResponse -> {
                    String text = chatResponse.getResult().getOutput().getText();
                    outputBuilder.append(text);
                    var finishReason = chatResponse.getResult().getMetadata().getFinishReason();
                    if (StrUtil.equals(Constant.STOP, finishReason)) {
                        var messageId = chatResponse.getMetadata().getId();
                        stringRedisTemplate.opsForHash().put(messageId, Constant.REQUEST_ID, requestId);
                    }
                    return ChatEventVO.builder()
                            .eventData(text)
                            .eventType(ChatEventTypeEnum.DATA.getValue())
                            .build();
                })
                .filter(vo -> vo.getEventData() != null)
                .concatWith(Flux.defer(() -> {
                    // 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
                    Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(requestId);
                    if (CollUtil.isNotEmpty(map)) {
                        // 注意:不在这里删除requestId,由MessageUtil.toJson()读取后再清理
                        // 响应给前端的参数数据
                        var chatEventVO = ChatEventVO.builder()
                                .eventData(map)
                                .eventType(ChatEventTypeEnum.PARAM.getValue())
                                .build();
                        return Flux.just(chatEventVO, STOP_EVENT);
                    }
                    return Flux.just(STOP_EVENT);
                }));
    }

Redis存储工具调用数据(bug修复):

课程查询和预下单功能,给前端返回的数据中,包含了eventType1003的数据,这个叫作额外数据,给前端提供,前端是不会显示到页面的,正常对话是没问题的,但是,数据存储到Redis是没有保存进去的。

Redis中的数据,是与requestId关联的,requestId是我们自己生成的,在RedisChatMemory中是没有的,所以,这个问题的关键就是如何获取到requestId了,只要有了requestId就可以获取到数据,进行保存了。

如何传递requestId

其实,同样也是可以借助于Redis来完成,通用容器,可以放Tool的结果,也可以放其他的内容,只要及时的删除即可。

Redis通用容器数据结构:messageId, Constant.REQUEST_ID, requestId

@Override
public Flux<ChatEventVO> chat(String question, String sessionId) {
    StringBuilder outputBuilder = new StringBuilder();
    String conversationId = UserContext.getUser() + sessionId;
    BoundHashOperations<String, Object, Object> hashOps = stringRedisTemplate.boundHashOps(GENSTATU);
    // 生成请求id
    var userId = UserContext.getUser();
    var requestId = IdUtil.fastSimpleUUID();
    var qaAdvisor = QuestionAnswerAdvisor.builder(this.vectorStore)
            .searchRequest(SearchRequest.builder().similarityThreshold(0.6d).topK(6).build())
            .build();
    return chatClient.prompt()
            .system(s -> s
                    .text(systemPromptConfig.getChatSystemMessage())  // 去掉 .get()
                    .param("now", DateUtil.now())
            )
            .advisors(advisor -> advisor.advisors(qaAdvisor).param(ChatMemory.CONVERSATION_ID, conversationId))
            .user(question)
            .toolContext(Map.of(Constant.REQUEST_ID, requestId, Constant.USER_ID, userId)) //通过工具上下文传递参数
            .stream()
            .chatResponse()
            .doFirst(()-> hashOps.put(sessionId,"true"))
            .doOnError(throwable -> {hashOps.delete(sessionId);})
            .doOnComplete(() -> {hashOps.delete(sessionId);})
            .doOnCancel(()->saveStopHistoryRecord(conversationId, outputBuilder.toString()))
            .filter(chatResponse -> chatResponse.getResult() != null)
            .takeWhile(chatResponse -> {
               return hashOps.get(sessionId) != null;
            })
            .map(chatResponse -> {
                String text = chatResponse.getResult().getOutput().getText();
                outputBuilder.append(text);
                var finishReason = chatResponse.getResult().getMetadata().getFinishReason();
                if (StrUtil.equals(Constant.STOP, finishReason)) {
                    var messageId = chatResponse.getMetadata().getId();
                    stringRedisTemplate.opsForHash().put(messageId, Constant.REQUEST_ID, requestId);
                }
                return ChatEventVO.builder()
                        .eventData(text)
                        .eventType(ChatEventTypeEnum.DATA.getValue())
                        .build();
            })
            .filter(vo -> vo.getEventData() != null)
            .concatWith(Flux.defer(() -> {
                // 通过请求id获取到参数列表,如果不为空,就将其追加到返回结果中
                Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(requestId);
                if (CollUtil.isNotEmpty(map)) {
                    // 注意:不在这里删除requestId,由MessageUtil.toJson()读取后再清理
                    // 响应给前端的参数数据
                    var chatEventVO = ChatEventVO.builder()
                            .eventData(map)
                            .eventType(ChatEventTypeEnum.PARAM.getValue())
                            .build();
                    return Flux.just(chatEventVO, STOP_EVENT);
                }
                return Flux.just(STOP_EVENT);
            }));
}
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());
        // ★ 通过 messageId 中转,从Redis获取工具参数
        if (assistantMessage.getMetadata() != null
                && assistantMessage.getMetadata().containsKey(Constant.ID)
                && staticRedisTemplate != null) {
            String messageId = assistantMessage.getMetadata().get(Constant.ID).toString();
            Object requestIdObj = staticRedisTemplate.opsForHash().get(messageId, Constant.REQUEST_ID);
            if (requestIdObj != null) {
                String reqId = requestIdObj.toString();
                Map<Object, Object> params = staticRedisTemplate.opsForHash().entries(reqId);
                if (ObjectUtil.isNotEmpty(params)) {
                    myMessage.setParams((Map) params);
                }
                staticRedisTemplate.delete(messageId);
                staticRedisTemplate.delete(reqId);
    }}}

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

    return JSONUtil.toJsonStr(myMessage);
}

自定义AssistantMessage:

在toMessage时使用:

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

}

构建知识库:

RAG:

  1. 文档摄取(ETL)流程(离线处理)
    1. 数据读取:从数据源(如文档、数据库)读取原始文档。
    2. 分割文档:通过分割模块(<<Split>>)将文档切分为更小的数据块(chunks)。
    3. 转换数据:通过转换模块(Transformer)处理数据块(如向量化、添加元数据)。
    4. 写入存储:将处理后的数据块写入向量数据库(Vector Store),为后续检索做准备。
    5. 核心目标:将非结构化文档转化为结构化、可检索的向量数据。
  1. 检索增强生成(RAG)流程(实时处理)
    1. 用户查询:接收用户提问(Chat Request)。
    2. 检索相关块:从向量库中检索与查询最相关(相似度高)的数据块(<<Retrieve>>)。
    3. 增强查询:将检索到的上下文信息(Context information)与用户问题结合,生成增强后的提示(<<Augment>>)。
    4. 生成响应:通过聊天模型(Chat Model)生成回答。
    5. 核心目标:通过外部知识库提升生成结果的准确性,解决了大模型信息缺失或滞后的问题。

准备数据存入数据库:

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

}

集成到chatClient:

        // 创建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)
                )........................................................................................

增删改查:

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

}

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐