【AI Shift Advent Calendar 2023】ユーザー体験を向上させるOpenAI(Compatible) Streaming APIのレスポンス速度計測

こんにちは、AIチームの杉山です。本記事はAI Shift Advent Calendar 2023の2日目の記事になります。
今回の記事では、OpenAI社のChatGPT APIやその互換的なLarge Language Model(大規模言語モデル, 以降LLM) 推論APIにおけるストリーミングを用いるユースケースとその速度の計測について紹介します。

別府弁天池

LLMプロダクトにおけるUX

OpenAI社のChatGPTに代表されるLLMは、その性能の高さが広く知られるようになりました。その結果、LLM単体で使用するだけでなくLLMを組み込んだプロダクトも開発されるようになってきています。

しかしLLMはその性能の反面、GPUを用いても推論に時間がかかります。夜間バッチなどで非同期的に処理を行うプロダクトであれば推論速度はそれほど気にならないかもしれませんが、例えばAI Messenger Chatbotのようなリアルタイム質問応答プロダクトにLLMを組み込もうとした場合、質問をしてから応答が返ってくるまで数秒~かかってしまうとユーザーの体験は悪化してしまいます。

体験悪化を防ぐ一つの策として、LLMの推論が完全に終わるのを待ってから結果を取得する同期的リクエストではなく、順次出力されるトークンを逐次的に取得するストリーミングでのリクエストを用いる方法があります。実例としては、ChatGPTをブラウザから使ったことのある方でしたらテキスト入力に対して返答が先頭から流れていく様子を見たことがあると思いますが、あのような体験になります。

待ち時間を考えると、例えば出力トークン数が101トークンで、1つ目のトークンが出力されるまでに3秒、以降1トークンあたりの出力に0.1秒かかるとした場合、それを用いたチャットボットでは、同期的では3 + 100 * 0.1 = 13秒(いわゆるLatency)待って一気に結果が表示されてから読み始めるのに対し、ストリーミング形式では3秒待てば1トークン目が得られ、以降は10トークン/秒で流れてくるテキストを読み進める、という体験になりユーザーが読み始められるまでの時間が10秒縮まることになります。

このユースケースの場合、速度的なUXの検証のために計測すべき対象は1つ目のトークンが出力されるまでの時間(Time To First Token, 以降TTFT)と、各トークンが出力される時間(Time Per Output Token, 以降TPOT)になります。[1]

そこで今回の記事ではOpenAI ChatGPT(互換)のAPIにストリーミングのリクエストを行った際の上記指標の計測方法を紹介します。

Streaming Response

OpenAI ChatGPT APIのストリーミングはServer-Sent Events(サーバー送信イベント, 以降SSE)で行われます。
SSEの解説はすでに多くの記事で紹介されているため割愛しますが、計測にあたりレスポンス形式を確認します。

公式ドキュメントによるとストリーミングのレスポンスは以下の形式となっています。

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}

....

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":" today"},"finish_reason":null}]}

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"content":"?"},"finish_reason":null}]}

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo-0613", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

このように出力トークンごとに結果が得られます。(最初と最後のレスポンスはシステム的なものでいわゆる出力トークンとは異なるので厳密な計測では除外すべきですが、簡単のために今回の記事では計測対象に含めます)

createdがトークンごとの生成時刻であればそこから所要時間を計測すればよかったのですが、createdには同一の値が含まれているため、リクエスト側で時刻を計測して計算することにします。

計測方法

ということで、curlでOpenAIにストリーミングのリクエストを送り、そのTTFTとTPOTを計測するスクリプトをGPT-4に書いてもらいました。(Macで小数点まで秒数を計算できるように多少修正は入れましたが、大枠はほぼGPT-4の出力そのままです。)

#!/bin/bash

event_count=0
start_time=$(perl -MTime::HiRes -le 'print int(Time::HiRes::time() * 1000)')
first_event_time=
end_time=

# Handle each line (event) from the server
while IFS= read -r line; do
  # Update event count
  ((event_count++))

  # Capture the time of the first event
  if [ -z "$first_event_time" ]; then
    first_event_time=$(perl -MTime::HiRes -le 'print int(Time::HiRes::time() * 1000)')
    time_to_first_event=$(echo "scale=3; $((first_event_time - start_time)) / 1000" | bc)
    echo "Time To First Token: $time_to_first_event sec"
  fi

  # Continuously capture the latest event time as potential end time
  end_time=$(perl -MTime::HiRes -le 'print int(Time::HiRes::time() * 1000)')

done < <(curl -s -X POST https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR OPENAI KEY>" \
  -d @chat_stream_input.json)

time_from_first_to_last_event=$(echo "scale=3; $((end_time - first_event_time)) / 1000" | bc)
sec_per_events=$(echo "scale=3; 1000 * $time_from_first_to_last_event / $event_count" | bc -l)
latency=$(echo "scale=3; $((end_time - start_time)) / 1000" | bc -l)

echo "Elapsed time: $time_from_first_to_last_event sec" # 1つ目のトークンが出力されてから最後のトークンが出力されるまでの時間
echo "Latency: $latency sec" # リクエストを送信してから最後のトークンが出力されるまでの時間
echo "Time Per Output Tokens: $sec_per_events millisec/token"

Elapsed timeとLatencyはそれぞれ、1つ目のトークンが出力されてから最後のトークンが出力されるまでの時間とリクエストを送信してから最後のトークンが出力されるまでの時間を表します。

また、リクエストパラメーターのjsonファイルの一例を示します。違うモデルを使いたい場合はmodel の値を変更してください。

{
  "model": "gpt-3.5-turbo",
  "max_tokens": 100,
  "stream": true,
  "messages": [
      {
          "role": "system",
          "content": "あなたは株式会社AI Shiftのカスタマーサポートです。"
      },
      {
          "role": "user",
          "content": "株式会社AI Shiftについて教えてください。"
      }
  ]
}

OpenAIのGPT-3.5-turbo, GPT-4, 先日のDevDayで発表されたGPT-4-turboそれぞれに対して上記の内容で実行した結果は以下のとおりです。

Time to First Token: 2.964 sec
Elapsed time: 4.534 sec
Latency: 7.498 sec
Time Per Output Tokens: 27.646 millisec/token
Time to First Token: 6.090 sec
Elapsed time: 5.193 sec
Latency: 11.283 sec
Time Per Output Tokens: 28.532 millisec/token
Time to First Token: 4.931 sec
Elapsed time: 5.196 sec
Latency: 10.127 sec
Time Per Output Tokens: 27.935 millisec/token

GPT-4-turboは、APIが公開された日に計測した際はGPT-3.5-turboより少し遅いくらいの速度だったのですが、利用者が増えたためか記事執筆時に再度計測すると少し差のある結果となっていました。(他ユーザーの利用状況にもよると思うため、計測値自体は参考程度に留めてください。)

また、OpenAI互換のAPIインタフェースであれば上記のスクリプトがそのまま使えるため、FastChatvLLMといったLLMの効率的な推論を行うためのライブラリでOpenAI互換のエンドポイントを提供しているものを用いたり、自前で互換性のあるLLM推論サーバーを立てたりした環境に対しても同様に計測することができます。
参考までに、vLLMを用いてA100 80GB 1台でcyberagent/calm2-7b-chatをサービングしている環境で計測した結果を紹介します。
(上記スクリプトのcurlの投げ先を自身の作成したエンドポイントに、jsonファイルのmodelをcyberagent/calm2-7b-chatに変更すればokです。)

Time to First Token: 0.412 sec
Elapsed time: 1.692 sec
Latency: 2.104 sec
Time Per Output Tokens: 30.712 millisec/token

モデルサイズに比べGPUのメモリに余裕があり(他にユーザーがいないこともあり)事前にKV Cacheをメモリ上に多く展開できるなどあり、TTFTがかなり短くなり実際にチャットボットに組み込んでも待ち時間はほぼ感じないのではと考えられます。

ちなみに、こちらのサイトによると社会人の読む速度は平均約600文字/分 とあります。
こちらの記事を参考に、日本語テキストではGPT-3.5/GPT-4で使われているTokenizerのTiktoken cl100k-baseでおよそ1文字=1.09トークンと仮定すると、文字を読む速度と同等の速度になるTPOTは
600char/min = 10char/sec = 10.9token/sec ≒ 92 millisec/token
となります。今回計測したケースではどれもその約1/3の時間で出力できているため、ストリームの出力が遅くてユーザーが読んでいる間に追いついてしまう、といったことはなさそうです。別のユースケースでTPOTや全体のレイテンシを高めたい場合には量子化などの高速化手法を選択肢として考えていくことになると思います。

おわりに

ここまで読んでいただきありがとうございました。今回の記事のように、AI ShiftのAIチームではLLMを活用したプロダクト開発に取り組んでおり、cyberagent/calm2-7b-chatを開発しているCyberAgent AI Lab NLPチームと研究開発を行ったり、専用のデータセンターで豊富なGPUを使ったLLMの学習やvLLMなどを用いた効率的な推論サーバーの構築などに取り組んでいます。

AI Shiftではエンジニアの採用に力を入れています!この分野に少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?(オンライン・19時以降の面談も可能です!)
面談フォームはこちら

明日のAdvent Calendar 3日目の記事は、エンジニアの由利によるインフラ関連の記事の予定です。こちらもよろしくお願いいたします。

参考

[1] https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices

PICK UP

TAG