🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

OpenAI APIで思い出す、Server-Sent Events

Server-Sent Events・・・お前・・・生きていたのか・・・!

っていう気持ちになったので、ちょっとだけまとめておく。

OpenAI API

話題のChatGPTはAPIが公開されていて、それぞれの言語のライブラリだったりREST APIだったりから利用できる。

それを使ってチャットを実装する場合に、本家GUIみたく、レスポンスを一気にまとめてではなくちょっとずつ返ってくるようにしたいとする。
そこで、あの挙動はどうやって実現するのか?ってなった人も多いはず。

あのレスポンスをちょっとずつ、ストリーミングで返してもらう挙動を実現するためには、`stream: true`というオプションを指定する。

これはREST APIJavaScriptから利用する場合の指定。

const res = await fetch("https://api.openai.com/v1/chat/completions", {
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${API_KEY_HERE}`,
  },
  method: "POST",
  body: JSON.stringify({
    messages: [{ role: "user", content: "はろー" }],
    model: "gpt-3.5-turbo",
    stream: true, // 👈
  }),
  signal: ac.signal,
});

ライブラリを使うにしても、同様の指定があるはず。

https://platform.openai.com/docs/api-reference/chat/create#chat/create-stream

Server-Sent Events

で、この指定をすると使われるようになるのが、Server-Sent Eventsというもの。

HTML Standard

具体的には、レスポンスヘッダに`Content-Type: text/event-stream`がつくようになって、レスポンスボディが次のようなフォーマットになる。

data: {"id":"chatcmpl-74OwM9uEZ2aMRnI3YCFauaIuGZ4BP","object":"chat.completion.chunk","created":1681283538,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}

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

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

data: [DONE]

というように、`data: {JSON_OR_DONE_MARKER}`という感じのストリームになる。

  • DevToolsで見えるHTTPのコネクションは1つ
  • その中で、この空行で区切られたそれぞれが、よしななタイミングでレスポンスされてくる
    • これはSSEの仕様
  • 最後に`[DONE]`とだけ書かれたものが届く
    • これはOpenAI APIの便宜上のもの

というわけで、これをUIに反映してやればよい。

DOMに表示する

細かいところは置いといて、雑にやるとこんな感じに。

const res = await fetch(/* さっきのやつ */);

const decoder = new TextDecoder();
const reader = res.body.getReader();

while (true) {
  const { done, value } = await reader.read();

  const events = decoder
    .decode(value)
    // 複数の`data: `行がまとまって落ちてくることもある
    .split("\n\n")
    .map((line) => {
      try {
        return JSON.parse(line.split("data: ")[1]);
      } catch {
        return null;
      }
    })
    .filter(Boolean);

  for (const json of events) {
    const content = json.choices[0].delta?.content ?? "";

    // ここで徐々にrenderすればよい
    // まとめたものが欲しいなら変数にでも入れておく
    content;
  }

  // `[DONE]`行か見て判断してもいいけど
  if (done) break;
}

これだけ。

`EventSource`というAPI

SSEには`EventSource`という専用のAPIがあって、これを使えば↑みたいなコードがシュッと書けるよっていう触れ込みになってる。

const es = new EventSource(url, { withCredentials: true });

es.onmessage = ({ data }) => {
  console.log(data);
};
es.onerror = () => es.close();

ただ、URLと`withCredentials`しかサーバー側に情報を伝える術がなかったり、勝手に再接続してくれたり(余計なお世話)、惜しいAPIくんって印象やったけど、2023年になっても変わってなかった。

`POST`のAPIを自分でプロキシして、あえて`GET`で返すように書けば使えるとは思うけど、そこまでして`EventSource`使いたいか?って言われると・・。