【ChatGPT API / stream】chatGPTのツールstream対応してみた javascript / typescript

今話題のChatGPTですが、apiはstreamオプションを使用することで、リアルタイムに対話を行うことができます。
現代人は待てない人が増えている（ってのは感想ですが）、streamすることでストレスの少ないUXを提供できるのも確かです。
本記事では、TypeScriptでChat Completion APIのストリームを実装する方法を紹介します。

なお、この記事の内容で先日作ったchatgptのツールをstream対応させています！

https://prompt-studio.vercel.app/

https://qiita.com/ruiiixiii/items/6a21eb83ab397ade4a16

## streamをcurlで投げてみる
なにもともあれ、streamオプションを付けたときにどんな感じでレスポンスがかえってくるのか確認しましょう。

```bash
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "stream": true,
    "messages": [{"role": "user", "content": "Hello!"}]
}'
```

```txt
data: {"id":"chatcmpl-798yxjuKJ40j94poEGypdvY1vmNQF","object":"chat.completion.chunk","created":1682413835,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}

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

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

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

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

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

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

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

data: {"id":"chatcmpl-798yxjuKJ40j94poEGypdvY1vmNQF","object":"chat.completion.chunk","created":1682413835,"model":"gpt-3.5-turbo-0301","choices":[{"delta"hoices":[{"delta":{"content":" today"},"index":0,"finish_reason":null}]}

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

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

data: [DONE]
```

さて何やらたくさん返ってきました。
どうなっているか見ていきましょう

### streamオプションでのresponse

responseは`data: {...}`というデータが何行も来ていることがわかります。
3パターンに分かれています

1つ目は最初のdataで`"role": "assistant"`が返ってきています
本当は改行ありませんが、見やすくすると以下のような内容です。
```txt
data: {
  "id":"chatcmpl-798yxjuKJ40j94poEGypdvY1vmNQF",
  "object":"chat.completion.chunk",
  "created":1682413835,
  "model":"gpt-3.5-turbo-0301",
  "choices":[
    {
      "delta":{"role":"assistant"},
      "index":0,
      "finish_reason":null
    }
  ]
}
```

2つ目は最初以降のdataで、contentがおそらくトークンごとに入ってきています。
roleはassistantと決まりきっているので、このcontentをうまく取り出すのが目標になってきます。
```txt
data: {
  "id":"chatcmpl-798yxjuKJ40j94poEGypdvY1vmNQF",
  "object":"chat.completion.chunk",
  "created":1682413835,
  "model":"gpt-3.5-turbo-0301",
  "choices":[
    {
      "delta":{"content":"Hello"},
      "index":0,
      "finish_reason":null
    }
  ]
}
```

3つ目は最後のdataで`[DONE]`とだけ入っています。
これが来たら処理終了という認識でいいでしょう。
```txt
data: [DONE]
```

なので方針としては、
- responseを行ごとに処理して
- `data.choices[0].delta.content`を取り出す
- `[DONE]`で終了する

ことができればよさそうです

## Chat Completion APIのstream実装
以上を踏まえて、Chat Completion APIのストリームを実装するには、以下のような TypeScript コードを使用することができます。

```typescript
// 型定義
type ChatCompletionMessage = {
  role: "system" | "user" | "assistant";
  content: string
}

const getChatCompletionStreaming = async (
  sendMessages: ChatCompletionMessage[]
): Promise<ChatCompletionMessage | void> => {
  try {
    let content = ''

    // APIにリクエストを送信する
    const res = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${openAIApiKey}`,
      },
      body: JSON.stringify({
        model: "gpt-3.5-turbo",
        stream: true, // streamオプションを付ける
        messages: sendMessages,
      }),
    });
    if (!res.ok || !res.body) throw new Error();

    // レスポンスの本文を読み取るための準備をする
    const reader = res.body.getReader();
    const textDecoder = new TextDecoder();
    let buffer = "";
    let end = false;

    // ストリームからデータを読み取り、処理を行う
    while (!end) {
      const { value, done } = await reader.read();
      if (done) break;

      buffer += textDecoder.decode(value, { stream: true });
      while (true) {
        // bufferからlineをpopしてlineごとに処理していく
        const newlineIndex = buffer.indexOf("\n");
        if (newlineIndex === -1) break;
        const line = buffer.slice(0, newlineIndex);
        buffer = buffer.slice(newlineIndex + 1);

        // データに"[DONE]"が含まれていれば終了
        if (line.includes("[DONE]")) {
          end = true;
          break;
        }

        // 受信したデータが"data:"で始まっているかどうかを確認する
        if (!line.startsWith("data:")) continue;
        // lineは`data: {}`の形状をしているため5文字目以降をparseする
        const jsonData = JSON.parse(line.slice(5));
        if (!jsonData.choices[0].delta.content) continue;

        // contentを取り出す
        const newContent = jsonData.choices[0].delta.content;
        content += newContent
      }
    }
    return {role: "assistant", content}
  } catch (e) {
    // エラー処理をする
    // ...
  }
};
```

実際にこれを使ってみるとこんな感じで、chatGPTのUIのように徐々に表示することを実現できました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612494/5abc6b67-41ca-a29e-9437-7342708fc716.gif" width="50%" />

## 最後に

chatGPTに限らずstreamingは待てない現代人にとってUX上大切でしょう。
今回その一端に触れることができたので、今後streamingの知識をよりつけていきたいと思います。

今回の記事が皆さんに参考になれば幸いです！

なお、冒頭に書いた通り、この記事を書くにあたって学んだ内容で、先日作ったchatGPT関連のツールをstream対応してみたので、よかったら見てください！
AI同士で会話させることで、プロンプトエンジニアリングするときなどに入力を考えなくてよくなるツールです！

https://prompt-studio.vercel.app/