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/