LLMチャットでは、回答がすべて生成されるまで待つよりも、生成された文字から順番に表示したほうが操作感がよくなります。
この記事では、FastAPIをストリーミングAPI、ReactをチャットUIとして使い、LLMの回答を少しずつ画面へ表示する構成を実装します。今回は動作確認しやすいように、バックエンド側で疑似的なトークン生成処理を用意しました。実際のLLM SDKに置き換える場合も、生成部分を差し替えるだけで利用できます。
全体構成
Reactから質問を送信して回答が画面に追記されるまでの流れは次のとおりです。
今回のデータの流れは次のとおりです。
- Reactから質問をPOSTする
- FastAPIがStreamingResponseを返す
- サーバーが生成した文字列をSSE形式で送信する
- ReactがReadableStreamを読み取り、回答欄へ追記する
SSE(Server-Sent Events)は、HTTP接続を維持したままサーバーからイベントを送信できる仕組みです。LLMのストリーミング出力とは相性がよく、今回は以下のような形式でデータを送ります。
data: "こんにちは"
data: "。これは"
data: "ストリーミング"
イベントの終端には[DONE]を送信します。
FastAPIでストリーミングAPIを作る
まずバックエンドを作成します。
mkdir llm-chat
cd llm-chat
python -m venv .venv
.venv\Scripts\activate
pip install fastapi uvicorn
main.pyを作成し、次のコードを書きます。
import asyncio
import json
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
async def generate_answer(message: str) -> AsyncGenerator[str, None]:
# 実際にはここをLLM SDKのストリーミング処理に置き換える
answer = (
f"「{message}」について回答します。"
"これはFastAPIから少しずつ送信される文章です。"
"React側では受信した文字列を順番に画面へ追加します。"
)
for token in answer:
payload = json.dumps(token, ensure_ascii=False)
yield f"data: {payload}\n\n"
await asyncio.sleep(0.04)
yield "data: [DONE]\n\n"
@app.post("/chat")
async def chat(request: ChatRequest):
return StreamingResponse(
generate_answer(request.message),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
StreamingResponseには、非同期ジェネレーターを渡しています。yieldされるたびにレスポンスの一部が送信されるため、回答全体をメモリ上にためてから返す必要がありません。
await asyncio.sleep()は疑似的な遅延を作るためのものです。実際のLLMを利用する場合は、SDKが返すチャンクを受け取るたびにyieldします。
起動してAPIを確認します。
uvicorn main:app --reload --port 8000
React側でレスポンスを読み取る
次にフロントエンドを作成します。
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm run dev
src/App.jsxを次の内容に置き換えます。
import { useState } from "react";
function App() {
const [input, setInput] = useState("");
const [answer, setAnswer] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
if (!input.trim() || loading) {
return;
}
setAnswer("");
setLoading(true);
try {
const response = await fetch("http://localhost:8000/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: input }),
});
if (!response.ok || !response.body) {
throw new Error("APIリクエストに失敗しました");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const eventText of events) {
const dataLine = eventText
.split("\n")
.find((line) => line.startsWith("data:"));
if (!dataLine) {
continue;
}
const data = dataLine.slice(5).trim();
if (data === "[DONE]") {
return;
}
const token = JSON.parse(data);
setAnswer((current) => current + token);
}
}
} catch (error) {
setAnswer(`エラー: ${error.message}`);
} finally {
setLoading(false);
}
}
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: 16 }}>
<h1>Streaming LLM Chat</h1>
<div
style={{
minHeight: 180,
padding: 16,
border: "1px solid #ddd",
whiteSpace: "pre-wrap",
}}
>
{answer || "ここに回答が表示されます"}
</div>
<form onSubmit={handleSubmit} style={{ marginTop: 16 }}>
<textarea
rows="4"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="質問を入力してください"
style={{ width: "100%", boxSizing: "border-box" }}
/>
<button type="submit" disabled={loading} style={{ marginTop: 8 }}>
{loading ? "生成中..." : "送信"}
</button>
</form>
</main>
);
}
export default App;
ここで重要なのは、reader.read()が1イベント単位でデータを返すとは限らない点です。ネットワーク状況によっては、1つのイベントが複数チャンクに分割されたり、複数イベントが一度に届いたりします。
そのため、受信したデータをbufferに追加し、空行区切りでイベントを分割しています。読み取り処理でバッファリングを行わないと、JSONが途中で分割されたときにパースエラーになります。
実際のLLM呼び出しに置き換える
疑似生成部分は、実際のLLM SDKが提供するストリーミングAPIに置き換えます。重要なのは、SDKから受け取ったテキストをそのままyieldすることです。
例えば、LLMクライアントが非同期イテレーターを返す場合は、次のような形になります。
async def generate_answer(message: str):
stream = llm_client.chat_stream(
messages=[
{"role": "user", "content": message}
]
)
async for chunk in stream:
text = chunk.text
if text:
payload = json.dumps(text, ensure_ascii=False)
yield f"data: {payload}\n\n"
yield "data: [DONE]\n\n"
SDKによってチャンクの属性名や呼び出し方は異なりますが、FastAPIのエンドポイントとReact側の受信処理はそのまま使えます。
本番運用では、さらに以下も確認します。
- クライアント切断時にLLM生成をキャンセルする
- タイムアウトを設定する
- 認証とレート制限を追加する
- リバースプロキシのバッファリングを無効にする
- エラーをSSEイベントとして通知する
特にプロキシがレスポンスをバッファリングすると、バックエンドが逐次送信していてもブラウザにはまとめて届くことがあります。今回のX-Accel-Buffering: noは、Nginxなどでのバッファリングを抑制するためのヘッダーです。
動作確認で気づいたこと
実際に動かして確認したところ、ブラウザには文字が少しずつ追加され、回答生成中もUIが固まらないことを確認できました。
一方で、短い回答やローカル環境ではチャンク到着が速く、見た目には一括表示に近くなる場合があります。これはUIの問題ではなく、ネットワークやLLM側のチャンク分割の影響です。
また、setAnswer(answer + token)のように現在の値を直接参照すると、非同期更新のタイミングによって文字が欠落する可能性があります。今回のように、
setAnswer((current) => current + token);
という関数形式を使うことで、Reactの最新状態を基準に追記できます。
まとめ
FastAPIのStreamingResponseとReactのReadableStreamを組み合わせることで、LLMの回答を生成された順に表示するチャットUIを実装できます。
ポイントは次の3つです。
- FastAPIでは非同期ジェネレーターでチャンクを
yieldする - SSEではイベントの区切りを正しく処理する
- Reactでは分割されたネットワークチャンクをバッファリングする
今回のサンプルは疑似回答を使っていますが、生成部分をLLM SDKに置き換えれば、実際のチャットアプリケーションへ発展させられます。まずはローカルでストリーミングの流れを確認し、その後に会話履歴、Markdown表示、キャンセル処理などを追加していくのがおすすめです。