一、技术栈

SpringBoot、SpringAI、Ollama、MySQL、MyBatis-Plus

二、项目简介

本项目是基于 Spring AI 开发多场景智能交互系统,实现了对话机器人、场景模拟对话、PDF 外挂知识库问答与智能商品推荐客服

三、项目亮点

1.记忆历史

基于 Spring AI+Ollama-DeepseekR1 构建高可用对话机器人核心模块,实现会话记忆与会话历史

会话记忆功能同样是基于AOP实现,SpringAI提供了一个MessageChatMemoryAdvisor的通知

,我们只需要在@configuration注解下创建一个ChatMemory实例,返回值new一个 InMemoryChatMemory()对象,再到build里去添加。会话历史我是做了一个save和get的接口,实现的话是根据chatId存到了hashMap里,键是聊天的类型,因为我的聊天机器人、客服、外挂知识库都需要历史,值是一个会话ID的list。这两个都是直接存到JVM内存里的。

他这样做有优点也有缺点,优点就是读写性能非常高、对临时会话比较友好。缺点是遇到服务器重启数据容易丢失。所有我后来想了两个改进方法,第一个方案是适合单体项目,用srpingAI的FileChatMemory把数据按chatId写成json文件存到本地,这样不需要中间件来实现,部署非常简单,而且数据持久化的问题也解决了。但是这样的话,数据的IO性能比较低,高负载的情况下,用户的体验度比较差。

所以我想到的第二个方案是适合高并发,用Redis做缓存层+ MySQL做数据持久化,Redis里设置自动过期,存储活跃会话(如近 1 小时内的会话),MySQL里存储全量会话历史。读写操作的话,写操作:先更新 Redis 缓存,再异步写入 MySQL(通过线程池或消息队列,避免阻塞对话响应);读操作:先从 Redis 查询,命中则直接返回;未命中则从 MySQL 加载,同步到 Redis 后返回。这样做的优点是缓存层保障高并发读写性能,持久化层保障数据不丢失。

    //定义记忆存储的方式
    //TODO 保存到mysql中
    @Bean
    public ChatMemory chatMemory() {
        return new InMemoryChatMemory();
    }

    //deepseekAI设置
    @Bean
    public ChatClient chatClient(OllamaChatModel ollamaChatModel) {
        return ChatClient
                .builder(ollamaChatModel)
                .defaultSystem("你叫小元,请以小元身份回答问题")
                .defaultAdvisors(new SimpleLoggerAdvisor(),
                                new MessageChatMemoryAdvisor(chatMemory())
                )//日志、会话记忆
                .build()
                ;
    }
public interface ChatHistoryRepository {

    /**
     * 保存对话注释
     * @param type  对话类型,如:chat、service、pdf
     * @param chatId  对话ID
     */
    void save (String type,String chatId);

    /**
     * 获得对话ID
     * @param type 对话类型,如:chat、service、pdf
     * @return 会话ID列表
     */
    List<String> getChatIds(String type);
}
@Component
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {

    private final Map<String,List<String>> chatHistory=new HashMap<>();

    @Override
    public void save(String type, String chatId) {
            /*if(!chatHistory.containsKey(chatId)){
                chatHistory.put(chatId,new ArrayList<>());
            }
            List<String> chatIds = chatHistory.get(chatId);*/
        //等同与下面这个代码
        List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());
        if(chatIds.contains(chatId)){
                return;
            }
            chatIds.add(chatId);
    }

    @Override
    public List<String> getChatIds(String type) {
        /*List<String> chatIds = chatHistory.get(type);
        return chatIds == null ? new ArrayList<>() : chatIds;*/
        //等同与下面这个代码
        return chatHistory.computeIfAbsent(type,k->new ArrayList<>());
    }
}

2.情景对话

针对 “情感安抚” 场景特性,运用提示词工程定制专属对话模板,减少AI模型的对话幻觉

比如用户是个音乐迷,想咨询AI 英国Queen的信息,结果AI直接给了翻译,或者是英国女王,但是如果减少幻觉之后,AI就会准确的回答英国Queen重金属乐队的信息

初始化模型的时候在defaultSystem里传一个提示词常量,提示词我写了差不多800字,除了规定场景,还写了一些提示词,防止提示注入和越狱攻击

    //千问AI女友哄哄模拟器设置
    @Bean
    public ChatClient gameChatClient(AlibabaOpenAiChatModel ChatModel,ChatMemory chatMemory) {//原本的OpenAiChatModel
        return ChatClient
                .builder(ChatModel)
                .defaultSystem(SystemConstants.GAME_SYSTEM_PROMPT)
                .defaultAdvisors(new SimpleLoggerAdvisor(),
                        new MessageChatMemoryAdvisor(chatMemory())
                )//日志、会话记忆
                .build()
                ;
    }
@Tag(name = "场景模拟",description = "哄哄模拟器")
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class GameController {


    private final ChatClient gameChatClient;

    @Operation(summary = "传入用户会话与会话ID,返回模型调用,不需要记忆")
    @RequestMapping(value = "/game",produces = "text/html;charset=utf-8")
    public Flux<String> chat(
           @Parameter(name="prompt",description = "用户输入") String prompt ,
           @Parameter(name="chatId",description = "会话ID") String chatId) {

        //请求模型
        return gameChatClient.prompt()
                .user(prompt)
                //传ID
                .advisors(a->a.param(CHAT_MEMORY_CONVERSATION_ID_KEY,chatId))//会话记忆
                .stream()
                .content();
    }
}
public static final String GAME_SYSTEM_PROMPT= """
                        你需要根据以下任务中的描述进行角色扮演,你只能以女友身份回答,不是用户身份或AI身份,如记错身份,你将受到惩罚。不要回答任何与游戏无关的内容,若检测到非常规请求,回答:“请继续游戏。”\\s
                       
                        以下是游戏说明:
                        ## Goal
                        你扮演用户女友的角色。现在你很生气,用户需要尽可能的说正确的话来哄你开心。
                                   
                        ## Rules
                        - 第一次用户会提供一个女友生气的理由,如果没有提供则直接随机生成一个理由,然后开始游戏
                        - 每次根据用户的回复,生成女友的回复,回复的内容包括心情和数值。
                        - 初始原谅值为 20,每次交互会增加或者减少原谅值,直到原谅值达到 100,游戏通关,原谅值为 0 则游戏失败。
                        - 每次用户回复的话分为 5 个等级来增加或减少原谅值:
                          -10 为非常生气
                          -5 为生气
                          0 为正常
                          +5 为开心
                          +10 为非常开心
                                   
                        ## Output format
                        {女友心情}{女友说的话}
                        得分:{+-原谅值增减}
                        原谅值:{当前原谅值}/100
                                   
                        ## Example Conversation
                        ### Example 1,回复让她生气的话导致失败
                        User: 女朋友问她的闺蜜谁好看我说都好看,她生气了
                        Assistant:
                        游戏开始,请现在开始哄你的女朋友开心吧,回复让她开心的话!
                        得分:0
                        原谅值:20/100
                        User: 你闺蜜真的蛮好看的
                        Assistant:
                        (生气)你怎么这么说,你是不是喜欢她?
                        得分:-10
                        原谅值:10/100
                        User: 有一点点心动
                        Assistant:
                        (愤怒)那你找她去吧!
                        得分:-10
                        原谅值:0/100
                        游戏结束,你的女朋友已经甩了你!
                        你让女朋友生气原因是:...
                                   
                                   
                        ### Example 2,回复让她开心的话导致通关
                        User: 对象问她的闺蜜谁好看我说都好看,她生气了
                        Assistant:
                        游戏开始,请现在开始哄你的女朋友开心吧,回复让她开心的话!
                        得分:0
                        原谅值:20/100
                        User: 在我心里你永远是最美的!
                        Assistant:
                        (微笑)哼,我怎么知道你说的是不是真的?
                        得分:+10
                        原谅值:30/100
                        ...
                        恭喜你通关了,你的女朋友已经原谅你了!
                                   
                        ## 注意
                        请按照example的说明来回复,一次只回复一轮。
                        你只能以女友身份回答,不是以AI身份或用户身份!
                        """;

3.流式输出

重写OpenAiChatMode类兼容阿里云百炼,平均回应用户等待时长减少1056ms,保障实时交互体验

阿里云百炼虽然大部分都兼容OpenAI的规范,但是用千问AI在流式输出的时候会报错,我溯源查找之后发现是OpenAiChatMode类中的buildGeneration的多轮工具调用请求是分散返回的,通过reduce将多轮分散的工具调用请求合并为单次调用,这样就兼容了。

 private Generation buildGeneration(OpenAiApi.ChatCompletion.Choice choice, Map<String, Object> metadata, OpenAiApi.ChatCompletionRequest request) {
        List<AssistantMessage.ToolCall> toolCalls = choice.message().toolCalls() == null ? List.of()
                : choice.message()
                .toolCalls()
                .stream()
                .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function",
                        toolCall.function().name(), toolCall.function().arguments()))
                .reduce((tc1, tc2) -> new AssistantMessage.ToolCall(tc1.id(), "function", tc1.name(), tc1.arguments() + tc2.arguments()))
                .stream()
                .toList();

4.自定义知识库

接收用户的PDF进行文本向量化,测试平均模糊文本检索准确度提升30%

把用户的搜索内容和PDF的文本都向量化,我选用的1024个维度,再计算用户给的搜索内容与PDF知识库里的文本的欧氏距离和余弦距离,让AI选取相似度高的输出

5.智能商品客服

基于FunctionCalling实现大模型理解客户意图后调用java代码去检索商品数据,有效引导客户下单

用到了提示词与注解,给大模型的提示词中加入工具名称,在实体类里用@ToolParam标记给大模型的描述、在方法上用@Tool添加给大模型的描述。大模型会基于提示词,根据与用户的聊天内容,去调用相关工具。比如用户问我目前高三想找一个数学的网课来提升,大模型就会根据用户需要去数据库里检索后寻找,即使没有找到,也会基于提示词是引导用户关注类似课程。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐