🐌

ChatGPTをぬるぬるにする🐌Server-Sent Eventsの基礎知識

2023/04/21に公開
2

はじめに

ChatGPT を使っている時に、下記の gif の左側のような感じで文字がどんどん出てくる表示に馴染みがあると思います。
右側の例と比べると文章が全て表示されるまでの時間は同じなのですが、ユーザー体験が全然違うと思います。

今回はこの表示を支える、Server-Sent Events(以下SSE)を紹介していきます。

SSE ってどんな技術?

SSEは、日本語で表現するとサーバ送信イベントと表現されるもので、サーバからクライアントに対してリアルタイムでイベントを送信することができる機能です。

コネクションを張っておいて、サーバからイベントを好きなタイミングで送れるイメージです!

引用: https://ably.com/blog/websockets-vs-sse

ChatGPT では、1 token 生成するごとに、サーバから生成した文字列を含んだイベントを送信することで、文章がどんどん表示されるような表示をしています。

他のリアルタイム通信技術とは何が違うの?

ブラウザでのリアルタイム通信を支える技術として、WebSocket が思いついたので比較してみます。

SSE WebSocket
通信方式 サーバからクライアントに対しての単方向通信 サーバとクライアント間で双方向通信
データ形式 テキストのみ テキストとバイナリデータ
実装の簡単さ HTTP/1.1 上で動作するので簡単 独自プロトコルなので実装が難しい
再接続 基本自動でしてくれる 自分で実装しないとダメ

単方向通信であるということと、HTTP/1.1上で動作しているのが大きな特徴です。
また、HTTP上で動作することから、通信の互換性が高く、セキュリティモデルも使いまわせるので安心です。

どんな用途と相性がいいの?

双方向通信がしたいわけでなければ、相性の幅がとても広いです。

今回の ChatGPT のような、GPT がトークンを生成するごとに送るケースはもちろん、通知の未読件数バッジの更新、ニュース速報の表示など、サーバからイベントを送りたい時ならなんでも使えます。

HTTP/1.1で動くカラクリ

SSEHTTPのレスポンスヘッダにContent-Type: text/event-streamを指定した上で動作します。

SSEが動く流れ

  1. クライアントがサーバーに HTTP/1.1 リクエストを送信し、イベントストリームに接続します。
  2. サーバーは、Keep-Alive 接続を使用して、TCP 接続を維持します。
  3. サーバーは、チャンク転送エンコーディングを使用して、イベントデータをクライアントに逐次的に送信します。
  4. クライアントは、イベントデータを受信して処理します。この間、TCP 接続は維持され続けます。
  5. サーバーは、新しいイベントデータが利用可能になるたびに、そのデータをチャンクとして送信し続けます。これにより、リアルタイムでデータをクライアントにプッシュすることができます。
  6. クライアントが切断されるか、サーバーが明示的に接続を閉じるまで、イベントストリームは維持されます。

こんな流れで動作していて、チャンク転送エンコーディングKeep-Alive 接続が今回のミソです。

チャンク転送エンコーディングと Keep-Alive

通常HTTP通信では、サーバから応答を返すときに、HTTP ヘッダContent-Lengthという送信するデータの大きさの情報を含めています。
SSEを使う時は、HTTP ヘッダContent-Lengthを記載せずに、代わりにTransfer-Encoding: chunkedを指定して、コネクションを貼ったままにすることでデータをチャンク形式で送ることができるようになっています。

HTTP/1.0の時には、Content-Lengthを記載しなかった場合、TCP コネクションの終了時にチャンクの転送が終了した扱いになってしまい、実際には通信が意図せず切断されても転送が完了したと扱われてしまいました。
HTTP/1.1Transfer-Encoding: chunkedでは、最後に空のチャンクと改行コードを送ることで、正常に転送が完了したか判別できるようになりました。
この仕様によって、Keep-Aliveでコネクションを維持したまま、データをリアルタイムで送ることができるようになっています。

どんなデータをやり取りしてる??

SSEで送信するイベントストリームは、単純なテキスト形式で、UTF-8 でエンコードされています。
各メッセージは改行文字で区切られていて、フィールド名と値のペアになります。

データフィールド

データ本文を含めるためのフィールドです。
ただデータを流したいだけの場合はこのようなイベントストリームで実現できます。

data: ここに文字列形式のデータが入ります;

イベントフィールド

指定したイベント名のイベントを送ることができます。これを指定した場合は、addEventListener(イベント名)で指定したリスナーにイベントが送られます。

event: aweasome - event;
data: aweasome - eventのデータ;

ID フィールド

ID フィールドに一意な ID を与えることで、再接続時にどこまでイベントを受信できていたかをサーバに知らせることができます。

id: 123;
data: ID123のデータ;

Retry フィールド

接続が切れた場合に、再接続を試みるまでの時間です。

retry: 5000
data: 5秒後に再接続をしようとします。

GPT からイベントストリームを受信してみる 🤖

GPT の API を cURL で叩いてレスポンスを見てみます。

まずは以下のコマンドで API を叩きます、stream: trueとすると、SSEを使ってくれます。

コマンド
curl 'https://api.openai.com/v1/chat/completions' \
  -H 'accept: text/event-stream' \
  -H 'authorization: Bearer sk-xxxx' \
  -H 'content-type: application/json' \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{"role": "user", "content": "こんにちは"}],
    "stream": true
  }'\
  --verbose

まずはリクエスト送信部分のログです、ここは本題ではないので流します。

リクエスト部分のログ
*   Trying 104.18.6.192:443...
* Connected to api.openai.com (104.18.6.192) port 443 (#0)
* ALPN: offers h2
* ALPN: offers http/1.1
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: C=US; ST=California; L=San Francisco; O=Cloudflare, Inc.; CN=sni.cloudflaressl.com
*  start date: Mar 28 00:00:00 2023 GMT
*  expire date: Mar 26 23:59:59 2024 GMT
*  subjectAltName: host "api.openai.com" matched cert's "api.openai.com"
*  issuer: C=US; O=Cloudflare, Inc.; CN=Cloudflare Inc ECC CA-3
*  SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: POST]
* h2h3 [:path: /v1/chat/completions]
* h2h3 [:scheme: https]
* h2h3 [:authority: api.openai.com]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: text/event-stream]
* h2h3 [authorization: Bearer sk-xxxx]
* h2h3 [content-type: application/json]
* h2h3 [content-length: 120]
* Using Stream ID: 1 (easy handle 0x15280a800)
> POST /v1/chat/completions HTTP/2
> Host: api.openai.com
> user-agent: curl/7.86.0
> accept: text/event-stream
> authorization: Bearer sk-xxxx
> content-type: application/json
> content-length: 120
>

ここからがレスポンス部分のログです。
リクエストの送信が完了して、レスポンスが返ってきました。
レスポンスヘッダに content-type: text/event-streamのみ含まれていて、Content-Lengthに関する情報はなさそうです。

レスポンス部分のログ
* We are completely uploaded and fine
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 200
< date: Thu, 20 Apr 2023 05:37:26 GMT
< content-type: text/event-stream
< access-control-allow-origin: *
< cache-control: no-cache, must-revalidate
< openai-model: gpt-3.5-turbo-0301
< openai-organization: user-49wr72zyvpsevffsfnrcxvyd
< openai-processing-ms: 188
< openai-version: 2020-10-01
< strict-transport-security: max-age=15724800; includeSubDomains
< x-ratelimit-limit-requests: 3500
< x-ratelimit-remaining-requests: 3499
< x-ratelimit-reset-requests: 17ms
< x-request-id: a6656d804f2df32b5d33165cf079b10b
< cf-cache-status: DYNAMIC
< server: cloudflare
< cf-ray: 7bab090d09d18a98-NRT
< alt-svc: h3=":443"; ma=86400, h3-29=":443"; ma=86400
<

ここからイベントストリームにイベントが流れてきてどんどんログが出てきます。
GPT の API ではデータフィールドのみのチャンクに JSON データが流れてきていて、最後に[DONE]と生成が完了したことを示すイベントが流れていました。

イベントストリームのログ
data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"こんにちは"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"、"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"私"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"は"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"AI"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ア"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"シ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ス"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"タ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ント"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"です"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"。"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ど"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"の"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"よ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"う"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"に"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"お"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"手"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"伝"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"い"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"で"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"き"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ます"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"か"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"?"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-77HGwVFc0IIclL2KCx51ic6bTG8Iv","object":"chat.completion.chunk","created":1681969046,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}

data: [DONE]

この後に、サーバからの切断によってTLSのコネクションも終了しました。

コネクションの終了ログ
* Connection #0 to host api.openai.com left intact

これで無事にストリーミングをして、流れてきたデータを確認できました 🙌🙌🙌🙌

ブラウザで受信してみる

ブラウザで受信するには、EventSourceを用いることでデータストリームに接続することができます。
が、今回は GPT の API を使いたいので、FetchAPIを使います。

まずは、いつも通りPOSTで API を叩きます。

const res = await fetch("https://api.openai.com/v1/chat/completions", {
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer sk-xxxx`,
  },
  method: "POST",
  body: JSON.stringify({
    messages: [
      {
        role: "user",
        content: "こんにちは",
      },
    ],
    model: "gpt-3.5-turbo",
    stream: true,
  }),
});

res.bodyReadableStreamなので、このストリームからデータを読み取るためのリーダーを取得します。

const reader = res.body.getReader();

リーダーのreadメソッドを呼ぶと、次のイベントが流れてきた時に非同期処理が解決されるので、完了になるまで読み取りを続けます。

const decoder = new TextDecoder();

const read = async () => {
  const { done, value } = await reader.read();
  if (done) {
    return;
  }
  console.log(decoder.decode(value));
  return read();
};

await read();

最後に、リーダーを取得すると、そのReadableStreamはロック状態になって、他のリーダを取得することができないので解放しておきます。

reader.releaseLock();

これでブラウザ上でも、cURLで利用したときと同様にイベントストリームを受信できたと思います!

最後に

GPTの体験を支えるSSEについて調べてみて、そもそも HTTP 通信について何も知らなかったので勉強になりました。
まだまだわからないことだらけなので、勉強しつつ記事アップデートしていけたらなと思います。

参考記事

https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events
https://lonesec.com/2019/12/01/transfer-encoding/
https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events/Using_server-sent_events
https://qiita.com/toshihirock/items/8d9d1cce4c04284be4c4
https://ably.com/blog/websockets-vs-sse

Discussion