Spring-AI (v1.0.0-M1 版本) +SSE 实践ChatGPT🤖️单向推送聊天

1,154 阅读4分钟

1. 背景/历程/收获/展望

  • 背景/历程:

    • 最近没什么事干, 为了我的周报多几行字😏, 想着使用Netty(太想落地使用了,全是理论/源码)实现websocket双向通信和客户端优化现在的Chat功能, 一直在调研和写Demo,在今天周会上提到了这一点, 讨论出可以看下单向通信,不用双向通信,当时不了解单向通信的实现没有概念,开完会就回去看相关知识点了。
    • 这段时间业务上面对接了ChatGPT model="gpt-3.5-turbo-0125", 3.5-turbo-0125这个模型支持json输出(这个点帮我解决了一个业务问题),但是我们业务上的用户体验感并不是很好,都是一次性返回,并且都是短连接 客户端轮训服务器接口获取结果(客户端、服务端的资源损耗,每隔3秒调用 全是无效http请求),技术方案非常low,我现在就想把它优化为双向连接Webscoket+Stream形式, 考虑到我们业务并不是用户与用户聊天/群聊, 仅仅是机器人与用户聊天,可以使用单向连接SSE(服务器推送事件)。
    • 前段时间2月27日Spring发布了与AI功能整合的starter第一个版本 v0.8.0, 在5月28号发布了新的版本 v1.0.0-M1, 加上需要去优化Chat功能的架构设计, 现在都是使用webclient进行调用chatGPT拿到数据,代码也是一坨屎💩(我写的),没有抽象封装化,自己都看不下去了, 这不Spring-AI推出的Starter看看能不能救我一命, 使用完是真舒服啊, 只需要配置一个key和url, 直接new Client来使用, 直接call/stream/, 反正都帮你封装好了所有的东西, 还能和Spring无缝衔接, stream返回的功能就是我所想要的东西。
  • 收获:

    • 接触了解到 Server-Send Events(服务器单向推送)和 WebFlux异步非阻塞的响应式编程模型相关技术并实践。
    • Netty实现Websocket 相关知识点和实践项目 (上一次写netty还是大四的时候 写rpc轮子)。😭
    • 学习Spring-AI源码中正常请求/流式请求ChatGPT各种设计和封装, 对比人家Chat的代码和自己写的代码差别在哪里。
  • 展望:

    • 开放出来的接口不支持代理添加,看了一眼底层也是使用的Webclient,只能整个服务设置代理, 不能只对webclient进行代理, 我服务中还有其他业务不需要使用代理, 确实是一个麻烦点,暂时提了issue。github.com/spring-proj…, 看官方怎么回复吧(问题已解决 一个Github的大佬提供一个解决方案的代码 太帅了)。
    • 深入学习SSE/Netty/WebFlux 知识点,从理论完全理解到Demo/实践, 到最后的架构设计/源码阅读/开源项目社区贡献🎉。

2. 实践ChatGPT🤖️聊天

实践ChatGPT🤖️单向推送聊天 先看Spring-AI (v1.0.0-M1 版本) +SSE 实现最终成果。

2.1. 官方Spring-Ai 对接

环境 版本

2.1.1 创建SpringBoot项目

  • docs.spring.io/spring-ai/r…

    image.png

    image.png

    image.png

    image.png

    image.png

  • pom.xml文件 添加依赖

    <properties>
      <java.version>17</java.version>
      <spring-ai.version>**1.0.0-M1**</spring-ai.version>
    </properties>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
      </dependency>
    
    </dependencies>
    <dependencyManagement>
      <dependencies>
        <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-bom</artifactId>
          <version>${spring-ai.version}</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
      </dependencies>
    </dependencyManagement>
    

    image.png

2.1.2. OpenAiChatConfig 注册OpenAiChatModel Bean

  • import org.springframework.ai.openai.OpenAiAudioSpeechModel;
    import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
    import org.springframework.ai.openai.OpenAiChatModel;
    import org.springframework.ai.openai.OpenAiEmbeddingModel;
    import org.springframework.ai.openai.OpenAiImageModel;
    import org.springframework.ai.openai.api.OpenAiApi;
    import org.springframework.ai.openai.api.OpenAiAudioApi;
    import org.springframework.ai.openai.api.OpenAiImageApi;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class OpenAiChatConfig {
      @Value("${spring.ai.openai.api-key}")
      private String apiKey;
      @Value("${spring.ai.openai.base-url}")
      private String baseUrl;
    
      @Bean
      public OpenAiChatModel myOpenAiEmbeddingClient() {
        OpenAiApi openAiApi = new OpenAiApi(baseUrl, apiKey);
        return new OpenAiChatModel(openAiApi);
      }
    }
    

    2.1.3. application.properties 配置文件添加

    server.port=9876
    spring.ai.openai.api-key=sk-xxxxxxxxxxxxxx
    spring.ai.openai.base-url=https://api.openai.com
    

2.1.4. AP调用 单元测试 (call/stream) 直接使用刚才注入的OpenAiChatModel

  • 对比以前的方式,需要自己写request/response的model, 自己写webclient请求处理和异常处理。

    package com.chat.websocket_chatgpt;
    
    import java.util.Objects;
    
    import org.junit.jupiter.api.Test;
    import org.springframework.ai.chat.messages.AssistantMessage;
    import org.springframework.ai.chat.model.Generation;
    import org.springframework.ai.chat.prompt.Prompt;
    import org.springframework.ai.openai.OpenAiChatModel;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.http.codec.ServerSentEvent;
    import reactor.core.publisher.Flux;
    
    @SpringBootTest
    class WebsocketChatgptApplicationTests {
    
      @Autowired
      private OpenAiChatModel chatClient;
    
      @Test
      public void call() {
        Prompt prompt = new Prompt("hello");
        String content = chatClient.call(prompt).getResult().getOutput().getContent();
        System.out.println(content);
      }
    
      @Test
      public void stream() throws InterruptedException {
        Prompt prompt = new Prompt("Golang实现二分算法");
        chatClient.stream(prompt)
            .filter(Objects::nonNull)
            .filter(chatResponse -> chatResponse.getResults() != null)
            .flatMap(chatResponse -> Flux.fromIterable(chatResponse.getResults()))
            .filter(Objects::nonNull)
            .map(Generation::getOutput)
            .filter(Objects::nonNull)
            .filter(content -> Objects.nonNull(content.getContent()))
            .map(AssistantMessage::getContent)
            .filter(Objects::nonNull)
            .doOnNext(System.out::println)
            .subscribe();
        Thread.sleep(50000);
      }
    
    }
    

    image.png

2.1.4. ChatController 接口

  • 
    import java.util.Objects;
    
    import com.chat.websocket_chatgpt.service.OpenAiChatService;
    import org.springframework.ai.chat.messages.AssistantMessage;
    import org.springframework.ai.chat.model.Generation;
    import org.springframework.ai.chat.prompt.Prompt;
    import org.springframework.ai.openai.OpenAiChatModel;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.MediaType;
    import org.springframework.http.codec.ServerSentEvent;
    import org.springframework.web.bind.annotation.CrossOrigin;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    import reactor.core.publisher.Flux;
    
    @RestController
    @RequestMapping("/ai")
    public class ChatController {
    
      @Autowired
      private OpenAiChatModel chatClient;
    
      @CrossOrigin(origins = "http://localhost:63342/")
      @GetMapping(value = "/easyChat", params = "message", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
      public Flux<ServerSentEvent<String>> easyChat(@RequestParam String message) {
        System.out.println(1111);
        Prompt prompt = new Prompt(message);
        return chatClient.stream(prompt)
            .filter(Objects::nonNull)
            .filter(chatResponse -> chatResponse.getResults() != null)
            .flatMap(chatResponse -> Flux.fromIterable(chatResponse.getResults()))
            .filter(Objects::nonNull)
            .map(Generation::getOutput)
            .filter(Objects::nonNull)
            .filter(content -> Objects.nonNull(content.getContent()))
            .map(AssistantMessage::getContent)
            .filter(Objects::nonNull)
            .map(content -> ServerSentEvent.builder(content).build())
            .doOnNext(System.out::println)
            .concatWith(Flux.just(ServerSentEvent.builder("complete").build())); // Optionally, you can add a completion signal
      }
    }
    

2.1.5 Caht.html 前端JS 创建EventSource 监听text/event-stream 数据返回 绚烂到页面上

  • <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>EasyChat SSE Example</title>
      <style>
        body {
          font-family: Arial, sans-serif;
          margin: 20px;
          background-color: #f3f4f6;
          color: #333;
          display: flex;
          flex-direction: column;
          align-items: center;
        }
        h1 {
          color: #333;
          margin-bottom: 20px;
        }
        #chatContainer {
          display: flex;
          flex-direction: column;
          align-items: center;
          width: 100%;
          max-width: 600px;
        }
        #messageInput {
          padding: 10px;
          width: calc(100% - 22px);
          margin-bottom: 10px;
          border: 1px solid #ccc;
          border-radius: 4px;
          font-size: 16px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        button {
          padding: 10px 20px;
          font-size: 16px;
          background-color: #007BFF;
          color: white;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
          transition: background-color 0.3s ease;
          margin-right: 10px;
        }
        button:hover {
          background-color: #0056b3;
        }
        #messages {
          border: 1px solid #ccc;
          padding: 10px;
          width: calc(100% - 22px);
          height: 500px;
          overflow-y: auto;
          background-color: #fff;
          border-radius: 4px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
          white-space: pre-wrap; /* Ensure spaces are preserved */
        }
        .complete {
          font-weight: bold;
          color: green;
        }
        .message {
          padding: 5px 0;
        }
        .message.complete::after {
          content: " [Complete]";
          font-weight: bold;
          color: green;
        }
      </style>
    </head>
    <body>
    
    <h1>EasyChat</h1>
    <div id="chatContainer">
      <input type="text" id="messageInput" placeholder="Type a message" onkeypress="handleKeyPress(event)" />
      <div>
        <button onclick="startChat()">Send</button>
        <button onclick="clearChat()">Clear</button>
      </div>
    </div>
    
    <h2>Messages</h2>
    <div id="messages"></div>
    
    <script>
      let eventSource;
      let messageContent = '';
    
      function startChat() {
        const messageInput = document.getElementById('messageInput');
        const message = messageInput.value.trim();
        if (!message) {
          alert("Please enter a message");
          return;
        }
    
        if (eventSource) {
          eventSource.close();
        }
    
        eventSource = new EventSource(`http://localhost:9876/ai/easyChat?message=${encodeURIComponent(message)}`);
    
        eventSource.onmessage = function(event) {
          const messagesDiv = document.getElementById('messages');
          if (event.data === 'complete') {
            messageContent += ' [Complete]';
            eventSource.close();
          } else {
            messageContent += `${event.data}`;
          }
          messagesDiv.innerHTML = messageContent;
          messagesDiv.scrollTop = messagesDiv.scrollHeight;
        };
    
        eventSource.onerror = function(event) {
          console.error("Error: ", event);
          eventSource.close();
        };
    
        messageInput.value = '';
      }
    
      function handleKeyPress(event) {
        if (event.key === 'Enter') {
          startChat();
        }
      }
    
      function clearChat() {
        const messagesDiv = document.getElementById('messages');
        messagesDiv.innerHTML = '';
        messageContent = '';
      }
    </script>
    
    </body>
    </html>
    

运行结果

  • image.png

  • image.png

2.1.6 python 对接SSE 实现Stream效果

  • import sseclient
    import requests
    
    if __name__ == '__main__':
        url = "http://127.0.0.1:9876/ai/easyChat"
        params = {
            'message': 'Swift'
        }
    
        headers = {
            'Accept': 'text/event-stream',
            'Accept-Encoding': 'gzip, deflate',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Host': '127.0.0.1:9876',
            'Pragma': 'no-cache',
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
        }
    
        response = requests.get(url, headers=headers, params=params, stream=True)
        client = sseclient.SSEClient(response)
    
        for event in client.events():
            print(event.data)
    
  • image.png

3. 总结

  • 本次Demo的实践, 为了让自己突破舒适圈,不断探索未知领域, 保持一个好奇心,做自己感兴趣的事情, 完成了某个阶段也挺有成就感的。 其他的音频/视频/图片模型 下次有时间再探索一下。

  • 底层调用HTTP调用方式 Spring 6.1 RestClient /Webclient 762aada8-4129-442f-bb8d-450073215b89.png img_v3_02bk_a937c80b-41c3-469c-a455-87fc99d09deg.png

4. 周末啦, 开启上分之路

image.png

5. 参考