一、背景
在初次使用 ChatGPT 時,我就被打字機的視覺效果吸引。總是感覺似曾相識,因為經常在一些科幻電影中看到,高階文明回傳的資訊在通訊裝置的螢幕上以打字機效果逐步出現,在緊張的氛圍下,輸出人類可讀的內容,拉動著觀眾的神經,一步步將故事情節拉向高潮。
在很早之前我就瞭解過 Server-Sent Events 這門服務端推送技術,當時看過很多部落格介紹其原理和使用場景,最後也沒有留下深刻的印象。這一次 ChatGPT 的使用感受帶給我一些觸動,也激發了對技術的思考,究竟什麼樣的技術是一門好的技術 ”需要一個殺手級的應用,現實應用會促進技術發展“,技術不是冰冷無情的,貼近生活挖掘其實用價值,一樣可以表現出感性的藝術效果。
二、SSE 工作原理
Server-Sent Events(SSE)是一種允許伺服器單向推送資訊到客戶端的技術,與傳統的請求/響應模式相比,這種模式更加適合處理實時資料。以下是一些常見的 Server-Sent Events 應用場景:
- ChatGPT 大型語言模型處理自然語言需要大量的計算資源和時間,響應速度肯定比普通的 HTTP 請求要慢的多。對於這種單項對話場景,ChagtGPT 將先計算出的資料 “推送” 給使用者,邊計算邊返回,提升使用者體驗。
- 實時通知:SSE 非常適合於實時通知的場景,例如電子郵件或社交媒體通知。一旦有新訊息,伺服器可以立即將其推送給客戶端,而無需客戶端定時輪詢檢查新訊息。
- 實時資料流:在金融服務、股票市場、體育比賽等場景中,SSE 可以用於實時推送資料流,如股票價格等。
2.1 SSE 工作原理
SSE 的基本工作原理是客戶端首先向伺服器傳送一個 HTTP 請求,然後伺服器保持這個連線開啟,並週期性地透過這個連線向客戶端傳送資料。每個資料塊都是一個獨立的訊息,每個訊息都以一個空行結束。
使用 SSE 的主要步驟如下:
- 客戶端建立一個新的EventSource物件,引數是伺服器的URL。
let source = new EventSource("http://xxx/chat/completions");
- 伺服器返回一個 HTTP 響應,Content-Type 為 "text/event-stream",並保持連線開啟。
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
Cache-Control: no-cache
- 伺服器透過開啟的連線向客戶端傳送訊息。每個訊息都包含一些資料,資料可以是任何格式的文字,比如 JSON。訊息以兩個連續的換行符結束。
data: This is a message\n\n
- 客戶端監聽 "message" 事件,當收到新的訊息時,這個事件會被觸發。
source.onmessage = function(event) {
console.log(event.data);
};
注意,由於 SSE 是基於 HTTP 的,因此它受到同源策略的限制。如果你需要進行跨域 SSE,你需要在伺服器端設定適當的 CORS 頭部資訊。另外,SSE 只支援文字資料,不支援二進位制資料。如果你需要傳送二進位制資料,你可能需要考慮使用 WebSockets。
2.2 Fetch API 模擬 SSE
Fetch API 是一種通用的 HTTP 請求和響應模型,它可以用於傳送和接收任何型別的 HTTP 請求,支援文字和二進位制資料。由於其對流(Stream)的支援,可以模擬 Server-Sent Events (SSE),需要手動處理重連和流式資料。
在某些情況下,你可能會選擇使用 Fetch API 模擬 SSE,而不是直接使用 SSE:
- 傳送二進位制資料:如果你需要傳送或接收二進位制資料,你必須使用 Fetch API 或其他技術,因為 SSE 只支援文字資料。
- 雙向通訊:如果你需要進行雙向通訊,你必須使用 Fetch API 或其他技術,因為 SSE 只支援單向通訊。
- 更大的靈活性:Fetch API 提供了更大的靈活性,例如,你可以控制請求頭、請求方法、響應處理等。
const url = 'https://your-server.com/events';
fetch(url)
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
// done 為資料是否接收完成 boolean 值
// value 為接收到的資料, Uint8Array 格式
return reader.read().then(function processMessage({ done, value }) {
if (done) {
return;
}
console.log(decoder.decode(value));
return reader.read().then(processMessage);
});
});
在這個示例中,我們使用 fetch() 函式發起 HTTP 請求。然後,使用 response.body.getReader() 獲取一個可讀流的 reader,用來讀取資料。還建立了一個 TextDecoder 物件,用來將二進位制資料解碼為文字,然後列印出來。然後,再次呼叫 reader.read() 方法,等待下一批資料。
這樣,就可以使用 Fetch API 來接收伺服器推送的實時更新,就像使用 SSE 一樣,ChatGPT 採用的就是這種實現。
三、SSE 服務端
Server-Sent Events (SSE) 是一種伺服器推送技術,允許伺服器向客戶端傳送實時更新。在伺服器端,我們需要建立一個 endpoint,傳送正確的 HTTP 頭部並持續推送資料。
func main() {
http.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
// 事件流媒體 (MIME 型別)
w.Header().Set("Content-Type", "text/event-stream")
// 阻止快取
w.Header().Set("Cache-Control", "no-cache")
// 保持長連線
w.Header().Set("Connection", "keep-alive")
// 跨域支援
w.Header().Set("Access-Control-Allow-Origin", "*")
phrase := []string{"dolor ", "sit amet", ", consectetur", " adipiscing elit. ", "Ut consequat", " diam at ", "justo efficitur", " mattis."}
for _, delta := range phrase {
// 資料內容用 data 表示, 如果資料很長, 可以分成多行用 \n 結尾,
fmt.Fprintf(w, "data: %s\n", delta)
flusher.Flush()
time.Sleep(200 * time.Millisecond)
}
// 最後一行使用 \n\n 結尾
fmt.Fprintf(w, "data: %s\n\n", "[DONE]")
})
if err := http.ListenAndServe("127.0.0.1:8080", nil); err != nil {
panic(err)
}
}
在 Go 語言中,http.Flusher 是一個介面,它允許 HTTP 響應資料在寫入後立即傳送到客戶端,而不是等待所有響應資料都寫入後再一次性傳送。這對於長連線和伺服器推送的場景非常有用。
// Flush 將使用者層的資料寫入到 TCP 緩衝區,核心會盡快將 TCP 快取區資料傳送出去
type Flusher interface {
Flush()
}
擴充套件:每個 TCP socket 連線在核心中都有一個傳送快取區和接收緩衝區
傳送緩衝區用於暫存應用程式寫入的資料,直到資料被髮送出去並得到對方的確認。接收緩衝區用於暫存收到的資料,直到應用程式讀取這些資料。
當應用程式呼叫傳送資料的系統呼叫(如 write 或 send)時,資料會被複制到傳送緩衝區。然後,核心會盡快將這些資料傳送出去。但具體傳送的時機取決於許多因素,包括但不限於以下幾點:
- Nagle 演算法:為了減少小包在網路上的傳輸,Nagle 演算法規定,除非上一個傳送的資料包已經得到確認,否則不能傳送新的資料包。所以,如果傳送緩衝區中的資料量較小,並且上一個資料包還未得到確認,資料可能會在緩衝區中等待。
- TCP 擁塞控制:TCP 協議透過擁塞控制演算法,動態地調整傳送速率,以避免網路擁塞。如果網路擁塞,資料可能會在傳送緩衝區中等待,直到網路狀況改善。
- 接收方的接收視窗:接收方透過 TCP 的滑動視窗機制,告訴傳送方它的接收緩衝區還有多少空間。如果接收方的接收視窗滿了,資料必須在傳送緩衝區等待,直到接收方的接收視窗有空間。
當資料成功傳送並得到確認後,核心會從傳送緩衝區中刪除這些資料,釋放緩衝區空間。
四、實現一個打字機效果
上面我們討論下 SSE 的工作原理,也知道由於 Web API EventSource 的侷限性,ChatGPT 採用了 Fetch API 來手動處理和解析 SSE 服務端端點接收的資料流。那麼接下來透過一個簡單的打字機案例,加深對所學內容的理解。
這裡借鑑了 《ChatGPT 打字機訊息回覆實現原理》 文章中的前端程式碼,在其基礎上增加了訊息處理邏輯,用於適配上面的 SSE 服務端。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat Completion</title>
</head>
<body>
<button onclick="connectFetch()">建立 fetchSSE 連線</button>
<button onclick="closeSSE()">斷開 fetchSSE 連線</button>
<br/>
<br/>
<div id="text"></div>
<script>
const divTyping = document.getElementById('text')
let ctrl
const connectFetch = () => {
ctrl = new AbortController()
fetchEventSource('http://127.0.0.1:8080/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
prompt: 'Lorem ipsum',
max_tokens: 20,
stream: true,
}),
signal: ctrl.signal,
onopen: () => {
console.log('Connection successful.')
},
onclose: () => {
console.log('Connection closed.')
},
onmessage: (delta) => {
let prefix = 'data: '
if (!delta.startsWith(prefix)) {
return
}
delta = delta.slice(prefix.length)
delta = delta.replace(/\n$/, '')
if (delta === '[DONE]\n') {
return
}
divTyping.innerText += delta
}
})
}
const closeSSE = () => {
if (ctrl) {
ctrl.abort()
ctrl = null
}
}
const fetchEventSource = (url, options) => {
fetch(url, options).then(resp => {
if (resp.status === 200) {
options.onopen && options.onopen()
return resp.body
}
}).then(rb => {
const reader = rb.getReader()
const push = () => {
// done 為資料是否接收完成 boolean 值
// value 為接收到的資料, Uint8Array 格式
return reader.read().then(({done, value}) => {
if (done) {
options.onclose && options.onclose()
return
}
options.onmessage && options.onmessage(new TextDecoder().decode(value))
return push()
});
}
// 開始讀取流資訊
return push()
}).catch((e) => {
options.error && options.error(e)
})
}
</script>
</body>
</html>
五、參考資料
- MDN - EventSource https://developer.mozilla.org/en-US/docs/Web/API/EventSource
- MDN - Server-sent events https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- Server-Sent Events 教程 https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html
- Go 實現 SSE 服務端 https://learnku.com/articles/75392
- ChatGPT 打字機訊息回覆實現原理 https://juejin.cn/post/7229632570374783034
- Create chat completion https://platform.openai.com/docs/api-reference/chat/create
- ChatGPT Web 開源專案 https://github.com/Chanzhaoyu/chatgpt-web
- Go clients for OpenAI API https://github.com/sashabaranov/go-openai