ChatGPT 打字機效果原理

MarsZuo發表於2023-05-13

一、背景

在初次使用 ChatGPT 時,我就被打字機的視覺效果吸引。總是感覺似曾相識,因為經常在一些科幻電影中看到,高階文明回傳的資訊在通訊裝置的螢幕上以打字機效果逐步出現,在緊張的氛圍下,輸出人類可讀的內容,拉動著觀眾的神經,一步步將故事情節拉向高潮。

ChatGPT 打字機效果原理

在很早之前我就瞭解過 Server-Sent Events 這門服務端推送技術,當時看過很多部落格介紹其原理和使用場景,最後也沒有留下深刻的印象。這一次 ChatGPT 的使用感受帶給我一些觸動,也激發了對技術的思考,究竟什麼樣的技術是一門好的技術 ”需要一個殺手級的應用,現實應用會促進技術發展“,技術不是冰冷無情的,貼近生活挖掘其實用價值,一樣可以表現出感性的藝術效果。

二、SSE 工作原理

Server-Sent Events(SSE)是一種允許伺服器單向推送資訊到客戶端的技術,與傳統的請求/響應模式相比,這種模式更加適合處理實時資料。以下是一些常見的 Server-Sent Events 應用場景:

  • ChatGPT 大型語言模型處理自然語言需要大量的計算資源和時間,響應速度肯定比普通的 HTTP 請求要慢的多。對於這種單項對話場景,ChagtGPT 將先計算出的資料 “推送” 給使用者,邊計算邊返回,提升使用者體驗。
  • 實時通知:SSE 非常適合於實時通知的場景,例如電子郵件或社交媒體通知。一旦有新訊息,伺服器可以立即將其推送給客戶端,而無需客戶端定時輪詢檢查新訊息。
  • 實時資料流:在金融服務、股票市場、體育比賽等場景中,SSE 可以用於實時推送資料流,如股票價格等。

2.1 SSE 工作原理

SSE 的基本工作原理是客戶端首先向伺服器傳送一個 HTTP 請求,然後伺服器保持這個連線開啟,並週期性地透過這個連線向客戶端傳送資料。每個資料塊都是一個獨立的訊息,每個訊息都以一個空行結束。

使用 SSE 的主要步驟如下:

  1. 客戶端建立一個新的EventSource物件,引數是伺服器的URL。
let source = new EventSource("http://xxx/chat/completions");
  1. 伺服器返回一個 HTTP 響應,Content-Type 為 "text/event-stream",並保持連線開啟。
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
Cache-Control: no-cache
  1. 伺服器透過開啟的連線向客戶端傳送訊息。每個訊息都包含一些資料,資料可以是任何格式的文字,比如 JSON。訊息以兩個連續的換行符結束。
data: This is a message\n\n
  1. 客戶端監聽 "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>

五、參考資料

相關文章