WebアプリにAIチャットのストリーミング表示(SSE)を実装する方法|fetch ReadableStream と Edge Worker の連携
AI チャットのストリーミング表示は、Edge サーバーでの text/event-stream 返却と、クライアント側での TextDecoder({ stream: true }) によるチャンクデコードを組み合わせることで実現できます。
ChatGPT や多くの Web AI チャットでは、回答が完成するのを待つのではなく、生成された文字から順に 1 文字ずつぬるぬる表示されるインターフェースが採用されています。
このストリーミング表示(Server-Sent Events / ReadableStream)を JavaScript / TypeScript で実装する手順と、日本語特有の文字化けを防ぐ注意点を解説します。
ストリーミングと一括受信の比較
ユーザー体験と技術的特性の比較表です。
| 方式 | 最初の1文字表示(TTFB) | 通信方式 | 特徴・メリット | 課題・ハマりどころ |
|---|---|---|---|---|
| 一括受信(JSON) | 数秒〜10数秒(遅い) | 通常の HTTP POST | 実装がシンプル | 長文回答の際にユーザーが離脱しやすい |
| ストリーミング(SSE) | 数百ミリ秒(非常に速い) | Chunked Stream | 体感速度が大幅に向上 | チャンク境界での日本語文字化け対策が必要 |
ユーザーに「レスポンスが早い」と感じさせるためには、実際の全文生成完了時間よりも最初の 1 文字目が表示されるまでの時間(TTFT: Time To First Token)を短縮することが極めて重要です。
ストリーミングの実装 4 手順
Edge サーバーとブラウザクライアントでストリーミングを実現する手順です。
-
サーバー側のストリーム中継 Gemini API や Workers AI の呼び出し時に
stream: trueを指定し、返ってきた ReadableStream をそのままクライアントへ返却します。 -
適切なレスポンスヘッダーの設定
Content-Type: text/event-streamおよびCache-Control: no-cacheを設定します。 -
クライアントでの Reader 取得
const response = await fetch('/api/chat', ...)の後、response.body.getReader()でストリームリーダーを取得します。 -
チャンクのループ読み取りとデコード
while (true)ループでreader.read()を実行し、受信したバイナリをTextDecoder({ stream: true })でテキスト化して UI に反映します。
最小コード例(クライアント側ストリーム受信)
async function sendChatMessage(message: string, onChunk: (text: string) => void) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: message }] }),
});
if (!response.body) return;
const reader = response.body.getReader();
// 必須: { stream: true } を指定してマルチバイト文字の分割に対応
const decoder = new TextDecoder('utf-8');
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// バイト配列を文字列にデコード(途中切れのバイトは内部バッファに保持される)
const chunkText = decoder.decode(value, { stream: true });
fullText += chunkText;
// UI 更新コールバックを実行
onChunk(fullText);
}
}
現場で起こる障害と対策(Failure Boundary)
ストリーミング実装時に遭遇しやすい障害と対策です。
1. 日本語マルチバイト文字の途切れ・文字化け()
- 発生現象: ひらがなや漢字が表示される際、文字が「」に化けたり不自然に分割されたりする。
- 原因と対策: UTF-8 の日本語文字は 3 バイトで構成されています。ネットワークチャンクの切れ目がたまたま 3 バイトの途中に来ると単体ではデコードできません。
new TextDecoder()の.decode(value, { stream: true })オプションを渡すことで、不完全なバイト列を自動的に次のチャンクまでバッファリングして解決します。
2. CDN やプロキシによるレスポンスのバッファリング
- 発生現象: サーバー側はストリーミング返却しているのに、ブラウザ側では全文章が一括で届いてしまう。
- 原因と対策: 途中の Cloudflare や Nginx 等のプロキシがレスポンスをまとめてキャッシュ・バッファリングしている可能性があります。レスポンスヘッダーに
X-Accel-Buffering: noやCache-Control: no-cacheを明示的に追加します。
当サイトの /chat/(加賀チャット)でも、この TextDecoder({ stream: true }) と Cloudflare Edge のストリーム中継を用いたリアルタイム表示を採用しています。