ai大模型流式輸出------基於SSE協議的長連線實現

王若伊_恩赐解脱發表於2024-12-02

傳統的http1.0請求開發,已經滿足了我們日常的web開發。
一般請求就像下圖這樣子,客服端發起一個請求(觸發),服務端做出一個響應(動作):

有時會有諸如實時重新整理,實時顯示的場景,我們往往是客戶端定時發起請求,不斷的嘗試獲取最新的資料。
但是每次請求都會建立並釋放一個新的連線,這樣對於需要頻繁請求的場景,效能損耗太大,此外對於實時性響應的場景也很難評估輪詢週期。輪詢的週期短,很多查詢結果其實並沒有變化,增加了成本開銷。輪詢週期長,又不能實時的展示資料,週期值變成了一個經驗值,而且不同場景都需要不斷的調整。這屬實不夠友好。
於是http1.1協議對此進行了擴充套件,允許長連線的存在。今天要介紹的SSE協議,就屬於http1.1下的新協議。
SSE全稱為 Sever-Sent Event
指伺服器端事件傳送。當客戶端請求成功後,服務端會依次將事件(其實就是響應資訊),分多次傳送到客戶端。客戶端只要接收事件(響應資訊),做出相應的處理即可。
就像下圖的樣子:

比如K線增長圖,實時熱力圖,各種增長曲線等等,都可以實時的,由後端主動將事件推送到前端,不再需要前端每次建立一個新的連線來請求。這種方式也稱之為長連線。
除了SSE,像websocket 、TCP等都屬於長連線的型別。依次連線可以多次互動。
SSE其實最初並不受重視,甚至很多人都不知道這個協議。如果是簡單一點的話,通常直接多輪詢幾遍就解決問題了,如果是複雜一點的話,直接就使用websocket這樣的重協議來處理了,功能也相對來說比較強大。但是自從互動大模型問世以後,大模型的流式對話往往能更高效的輸出,這種流式輸出的使用者體驗也更好。這種主要是側重大模型響應的互動模式,(防盜連線:本文首發自http://www.cnblogs.com/jilodream/ )反而使得SSE的優勢又體現出來了。
下面我們看下如何在springboot中使用sse來開發:
由於springboot的封裝,我們使用SSE開發變得異常簡單,
核心思路是:
建立一個 SseEmitter 物件,返回給前端
這個SseEmitter類似於一個socket,我們只管向裡邊塞資料即可,
而前端在收到SseEmitter物件後,則只管從sseEmitter中取資料即可。(注意此處一般採用註冊響應方式)

後端程式碼如下:
pom檔案新增依賴:

1         <dependency>
2             <groupId>org.springframework.boot</groupId>
3             <artifactId>spring-boot-starter-web</artifactId>
4         </dependency>

controller類:

 1 package com.example.demo.learnsse;
 2 
 3 import lombok.extern.slf4j.Slf4j;
 4 import org.springframework.http.MediaType;
 5 import org.springframework.web.bind.annotation.CrossOrigin;
 6 import org.springframework.web.bind.annotation.GetMapping;
 7 import org.springframework.web.bind.annotation.RequestParam;
 8 import org.springframework.web.bind.annotation.RestController;
 9 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
10 
11 import java.io.IOException;
12 import java.util.concurrent.TimeUnit;
13 
14 /**
15  * @discription
16  */
17 @Slf4j
18 @RestController
19 @CrossOrigin(origins = "*")
20 public class SseController {
21 
22 
23     @GetMapping(value = "/learn/sseChat" , produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
24     public SseEmitter chat(@RequestParam String name) throws IOException {
25         SseEmitter sseEmitter = new SseEmitter(360000L);
26         sseEmitter.onCompletion(() -> log.warn("sse complete!!!" + Thread.currentThread().getName()));
27         sseEmitter.onError(throwable -> {
28             log.warn("sse error  " + Thread.currentThread().getName(), throwable);
29         });
30         sseEmitter.send("start");
31         Runnable r = () -> {
32             int i = 1;
33             try {
34                 while (i <= 10) {
35                     sseEmitter.send(Thread.currentThread().getName()+": the next index:" + i);
36                     log.warn(Thread.currentThread().getName() + ":" + i);
37                     i++;
38                     TimeUnit.SECONDS.sleep(3);
39                 }
40                 sseEmitter.complete();
41             } catch (Exception e) {
42                 log.warn("catch a ex", e);
43                 sseEmitter.completeWithError(e);
44             }
45         };
46         Thread t = new Thread(r);
47         t.start();
48         log.warn("start return sse");
49         return sseEmitter;
50     }
51 }

我們可以不寫前端,直接用瀏覽器或者命令列訪問,

瀏覽器效果如下:

真實效果是一行行輸出的

data:start

data:Thread-2: the next index:1

data:Thread-2: the next index:2

data:Thread-2: the next index:3

data:Thread-2: the next index:4

data:Thread-2: the next index:5

data:Thread-2: the next index:6

data:Thread-2: the next index:7

data:Thread-2: the next index:8

data:Thread-2: the next index:9

data:Thread-2: the next index:10

日誌輸出如下:

2024-12-02 11:06:36.267  WARN 2032 --- [nio-8081-exec-4] com.example.demo.learnsse.SseController  : sse complete!!!http-nio-8081-exec-4
2024-12-02 11:06:38.440  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:2
2024-12-02 11:06:41.442  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:3
2024-12-02 11:06:44.450  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:4
2024-12-02 11:06:47.458  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:5
2024-12-02 11:06:50.468  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:6
2024-12-02 11:06:53.471  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:7
2024-12-02 11:06:56.475  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:8
2024-12-02 11:06:59.483  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:9
2024-12-02 11:07:02.495  WARN 2032 --- [       Thread-2] com.example.demo.learnsse.SseController  : Thread-2:10
2024-12-02 11:07:05.508  WARN 2032 --- [nio-8081-exec-5] com.example.demo.learnsse.SseController  : sse complete!!!http-nio-8081-exec-5

這樣一個簡單的單次連線,伺服器多次推送的示例就寫完了。

當然你也可以寫一個簡短的前端程式碼,檢視效果,注意此時涉及到跨域了,因此我們的java程式碼要使用註解@CrossOrigin(origins = "*") 來解決跨域,請看controller程式碼中紅色字型

 1 <!DOCTYPE html>
 2 <html>
 3 <head>
 4     <title>SSE Example</title>
 5 </head>
 6 <body>
 7     <div id="events"></div>
 8     <script>
 9         const eventSource = new EventSource('http://127.0.0.1:8081/learn/sseChat?name=xx');
10 
11         eventSource.onmessage = function(event) {
12             const newElement = document.createElement("div");
13             newElement.textContent = "New message: " + event.data;
14             document.getElementById("events").appendChild(newElement);
15         };
16 
17         eventSource.onerror = function(error) {
18             console.error("Error:", error);
19             const newElement = document.createElement("div");
20             newElement.textContent = "error message: " + error;
21             document.getElementById("events").appendChild(newElement);
22             eventSource.close();
23         };
24         
25         eventSource.onclose = function(event) {
26             const newElement = document.createElement("div");
27             newElement.textContent = "close message: " + event.data;
28             document.getElementById("events").appendChild(newElement);
29             eventSource.close();
30         };
31     </script>
32 </body>
33 </html>

我們在建立好SSE示例時,一般會設定以下幾個回撥方法:

onCompletion(Runnable callback):當非同步請求完成時,我們會呼叫此方法註冊的回撥函式。
onError(Consumer<Throwable> callback) 當非同步處理期間發生錯誤時,會呼叫該方法設定的回撥函式

服務端發現任務結束時,主動知會客戶端關閉連線:
complete():表示已經完成推送,通知客戶端不再有新的事件傳送。
completeWithError(Throwable ex) 表示由於發生了某個異常而結束推送。springmvc將透過異常處理機制傳遞該異常。
一般在對接大模型時,(防盜連線:本文首發自http://www.cnblogs.com/jilodream/ )我們除了完成SSE相關的註冊,還會設定與大模型的連線,
一般的思路是這樣的:
1、當前端傳送請求提問來後端時,
2、我們首先建立一個SseEmitter,作為未來傳送的套接字,
3、接著啟動一個http連線,來請求大模型,
4、此時我們會使用Reactor-Mono之類的響應式程式設計框架,來回撥處理大模型推送回來的資料。(其中Reactor部分的程式碼實現,由於篇幅有限,我會在後邊的文章中講解)
5、在Mono的每次回撥到大模型推送回來的資料時,我們透過SseEmitter傳送給前端
6、將第二步建立好的SseEmitter,返回給前端。
注意3/4/5步都是作為非同步回撥註冊到mono中的。整體的結構圖如下:

相關文章