0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMのストリーミング応答をNDJSONで実装した話。SSEからHTTP POSTまで

0
Posted at

LLMを活用したチャットボットでは、回答が完了するまで数秒〜十数秒待つ間、ユーザーにリアルタイムでテキストを表示したい situations があります。このストリーミング応答を、WebSocketではなくHTTP POSTで実現する方法を紹介します。

使用したプロトコルはNDJSON(Newline-Delimited JSON)です。SSE(Server-Sent Events)の代わりにNDJSONを使う理由は、HTTP POSTリクエストで送信できるため、プロキシやCDNの制約を気にせず、すべてのデプロイ環境で動作するからです。

アーキテクチャの全体像

システムは3つの層で構成されています。サーバー側では、LLMプロバイダからSSEでトークンを受け取り、内部的なNDJSONストリームに変換します。HTTPレスポンスとしてクライアントに返す際は、TransformStreamを使って ReadableStream を生成します。クライアント側では、ReadableStreamのリーダーで受信したNDJSONをパースし、トークンごとにUIを更新します。

サーバー側:SSEトークンの取り出し

LLMプロバイダのSSEレスポンスを処理する部分です。バッファ管理が重要なポイントです。TCPチャンクは不完全な行を含むことがあるため、バッファに蓄積して改行で分割し、最後の不完全な行は次の読み込みに持ち越す必要があります。

SSEのデータ行には「data: {トークン}」という形式が使われます。データ行からトークン部分だけを抽出し、完了マーカーである「[DONE]」が来たらイテレータを終了します。不正な形式の行はスキップし、プロバイダごとの微妙な仕様の違いに耐性を持たせています。

サーバー側:NDJSONストリームの構築

ここがこのアーキテクチャで一番のポイントです。TransformStreamを使ってReadableStreamを生成し、その中で非同期処理を実行します。

ポイントは、HTTPレスポンスヘッダーを送信した後に非同期処理を開始するという順序です。即座にレスポンスを返し、バックグラウンドでベクトル検索やLLMストリーミングを実行します。この「即レスポンス+非同期処理」のパターンが、HTTPストリーミングの基本形です。

ストリーム内のイベントには3つのステータスがあります。ユーザーの質問を処理中(analyzing)、サイト情報を検索中(searching)、回答を生成中(generating)の3段階です。クライアントはこのステータスに応じて、メッセージバブルにプログレスインジケータを表示できます。

トークンの転送は1トークンずつ行います。LLMから受信したトークンを、クライアントへの送信用イベントと、ログ用のバッファに同時に書き込みます。最後に完了イベントとして、完成した回答と参照ソースを含むresultイベントを送信します。

クライアント側:NDJSONストリームの消費

クライアント側でもサーバー側と同じバッファ管理パターンを使います。ReadableStreamのリーダーでチャンクを受信し、バッファに蓄積して改行で分割します。

トークンイベントが来たら、受信したテキストをメッセージテキストに追加し、Reactのステート更新をトリガーしてUIを再描画します。これにより、ユーザーはトークンが届くたびに文字が増えていく様子を確認できます。

完了イベントが来たら、タイピング状態をクリアして、 permanent メッセージリストに最終的な回答を追加します。AbortControllerを使ってリクエストのキャンセルにも対応し、コンポーネントのアンマウント時に inflight リクエストを中断します。

ストリーミング中のMarkdownレンダリング

ストリーミング中にMarkdownをリアルタイムでレンダリングするには、トークンが届るたびにMarkdownをパースし直す必要があります。これは一見非効率に見えますが、最近のブラウザでは十分なパフォーマンスが得られます。

セキュリティ面では、DOMPurifyを使ってLLM生成のMarkdownをサニタイズします。iframeのsrc属性はYouTubeとGoogle Mapsのみ許可し、その他のiframeはすべて除去します。これにより、LLMが生成したMarkdownに悪意のあるiframeが含まれても、XSS攻撃を防げます。

スクロール動作の制御

ストリーミング中に新しいトークンが届くと、自動スクロールで最新のテキストが見えるようにします。ユーザーが手動でスクロールアップした場合は自動スクロールを停止し、「一番下へ」ボタンを表示します。

スクロール位置の判定は、scrollHeightからscrollTopとclientHeightを引いた値が60px未満かどうかで判断します。この余裕(60px)があることで、ユーザーが少しスクロールアップしただけでも自動スクロールが止まり、快適に閲覧できます。

まとめ

NDJSON over HTTP POSTは、WebSocketやSSEに比べてデプロイが簡単で、プロキシやCDNの問題が少ないのが最大のメリットです。バッファ管理のパターンはサーバー側とクライアント側で共通化でき、コードの重複を減らせます。

今回紹介した実装はHelpy AI ChatというAIチャットボットサービスの技術基盤の一部です。自社サービスとして公開しており、無料トライアルで試せます。 → https://www.helpy-ai-chat.com/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?