こんにちは、かつコーチです。
ChatGPTを使っていると、文字が一気に表示されるのではなく、じわじわと出てくるのに気づいた方も多いと思います。
あれを自分のLaravelアプリで再現したいという相談を、最近よく受けます。
この記事では、ストリーミングレスポンス(AIの応答を少しずつリアルタイムに送る仕組み)の実装方法を、実際に手を動かした手順そのままで解説します。
読み終える頃には、SSEの基本からLaravel側・フロントエンド側のコードまで一通り書けるようになります。
ストリーミングとは?
なぜ一気に返さないのか
LLM(大規模言語モデル:大量のテキストで学習し、文章生成や対話ができるAI)は、実は文章を一文字ずつ生成しています。
内部的には「トークン」という単位で、次に来る言葉を予測しながら生成しているのです。
すべて生成し終えてからまとめて返すと、長い回答ほどユーザーは待たされます。
だからこそ、生成された分から順次画面に表示する「ストリーミング」が定番になっています。
Server-Sent Events(SSE)の基本
SSE(Server-Sent Events)とは、サーバーからクライアントへ一方向にデータを流し続けられる仕組みです。
WebSocketのように双方向通信は必要ないけれど、サーバーからどんどんデータを押し出したい、というケースにぴったりです。
HTTPの上に乗っているので、特別なプロトコルやライブラリがなくても、Content-Type: text/event-stream を指定するだけで実現できます。
AIチャットの応答表示は、まさにこのSSEが定番の実装方法です。
基本の書き方 / 実装手順
ルーティングとコントローラー
まずはコントローラーで、OpenAIのストリーミングAPIを呼び出しつつ、逐次クライアントに書き出す処理を作ります。
PrismPHP(LaravelでLLMを扱いやすくするパッケージ)を使うと、ストリーミング用のメソッドが用意されているので、生のcURLを書くより格段に楽です。
// routes/web.php
use App\Http\Controllers\ChatStreamController;
Route::get('/chat/stream', [ChatStreamController::class, 'stream']);
// app/Http/Controllers/ChatStreamController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Prism\Prism\Prism;
use Prism\Prism\Enums\Provider;
class ChatStreamController extends Controller
{
public function stream(Request $request): StreamedResponse
{
$prompt = $request->query('prompt', 'こんにちは');
return response()->stream(function () use ($prompt) {
$response = Prism::text()
->using(Provider::OpenAI, 'gpt-4o-mini')
->withPrompt($prompt)
->asStream();
foreach ($response as $chunk) {
echo "data: " . json_encode(['text' => $chunk->text]) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
echo "data: [DONE]\n\n";
flush();
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
}
ポイントは response()->stream() を使い、コールバックの中で echo した内容を flush() で即座に送り出すことです。
SSEの仕様上、各メッセージは data: で始まり、空行(\n\n)で区切る必要があります。
フロントエンド側(JavaScript)での受信処理
ブラウザ側は EventSource を使うと、SSEの受信がとても簡単に書けます。
// resources/js/chat-stream.js
const output = document.getElementById('chat-output');
const prompt = encodeURIComponent('Laravelの魅力を教えて');
const source = new EventSource(`/chat/stream?prompt=${prompt}`);
source.onmessage = (event) => {
if (event.data === '[DONE]') {
source.close();
return;
}
const payload = JSON.parse(event.data);
output.textContent += payload.text;
};
source.onerror = () => {
console.error('ストリーミング接続でエラーが発生しました');
source.close();
};
このコードだけで、文字がじわじわ表示される、あのChatGPT風のUIが完成します。
よくあるつまずきポイント・エラー対処
Nginx環境でストリーミングが効かない
実際にこの機能を本番のNginx環境にデプロイしたとき、ローカルでは動いていたのに文字が一切表示されず、応答が終わってから一気にドンと出てくるという現象にハマりました。
原因を調べると、NginxのプロキシバッファリングがPHPからの出力を溜め込んでしまい、SSEの効果が消えていたのです。
コントローラー側で X-Accel-Buffering: no ヘッダーを付けていたのに効かず、しばらく首をひねっていました。
結局、Nginxの設定ファイル(proxy_buffering off;)を追記して解決しました。
アプリ側のヘッダーだけでなく、インフラ側の設定も合わせて確認する必要があると痛感した一件です。
❌Before → ✅After
❌Before:コントローラーのヘッダーだけ設定して「動くはず」と思い込み、本番で詰まった。
✅After:Nginxの proxy_buffering off; をロケーション単位で追加し、ローカルと本番で挙動を揃えてから公開した。
PHPのタイムアウトで途中で切れる
もう一つよくあるのが、max_execution_time によって長い応答の途中でストリームが強制終了されるケースです。
public function stream(Request $request): StreamedResponse
{
set_time_limit(0); // ストリーミング処理はタイムアウトを無効化する
// ...
}
ストリーミングのように長時間コネクションを張り続ける処理では、この設定を忘れずに入れておきましょう。
応用・一歩先の使い方
Laravel AI SDKでの記述
PrismPHP以外にも、Laravel AI SDK系のパッケージにはストリーミング専用のメソッドが用意されていることが多いです。
基本的な考え方は同じで、「ストリームを受け取り、チャンクごとにSSE形式で書き出す」という流れは変わりません。
パッケージのバージョンによってメソッド名が変わることがあるので、公式ドキュメントで stream や asStream に類するメソッドを探してみてください。
Vue/Reactでの受信
生のJavaScriptだけでなく、Vue.jsやReactのコンポーネント内でEventSourceを使えば、状態管理と組み合わせたリアクティブなチャットUIも簡単に作れます。
次の記事では、この受信部分をベースに会話履歴を管理するチャットUIを作っていきます。
まとめ
この記事のポイント
- ストリーミングはLLMがトークン単位で生成する仕組みを活かし、待ち時間の体感を減らす技術
- SSEは
text/event-streamとデータ区切りのルールさえ守れば、シンプルに実装できる - 本番環境ではNginxのバッファリング設定など、インフラ側の確認も忘れずに行う
次に読むべき記事
- 次は「会話履歴を管理するチャットUIを実装する」で、このストリーミングを使ったチャット機能を完成させましょう
コメント