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 对接
环境 版本
-
Springboot3.2.0 (Spring 6)
-
JDK17
-
Spring-ai 1.0.0-M1
-
支持文本/音频/视频/图片模型
-
2.1.1 创建SpringBoot项目
-
-
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>
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); } }
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>
运行结果
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)
-
3. 总结
-
本次Demo的实践, 为了让自己突破舒适圈,不断探索未知领域, 保持一个好奇心,做自己感兴趣的事情, 完成了某个阶段也挺有成就感的。 其他的音频/视频/图片模型 下次有时间再探索一下。
-
底层调用HTTP调用方式 Spring 6.1 RestClient /Webclient
4. 周末啦, 开启上分之路
5. 参考
- juejin.cn/post/685457…
- 服务端实时推送技术之SSE(Server-Send Events)-CSDN博客
- juejin.cn/post/712907…
- GitHub - spring-projects/spring-ai: An Application Framework for AI Engineering
- SpringBoot整合Spring AI实现项目访问chatgpt
- juejin.cn/post/735875…
- OpenAI Chat :: Spring AI Reference
- docs.spring.io/spring-ai/r…
- # Spring Boot 3.2 新特性之 RestClient