LLMの回答を画面に少しずつ表示したいとき、SSE(Server-Sent Events)とWebSocketのどちらを使うか迷うことがあります。
私が実際にHonoで簡単なストリーミングAPIを作って確認したところ、LLMの回答をサーバーからブラウザへ一方向に流すだけならSSE、双方向のリアルタイム通信が必要ならWebSocketという結論になりました。
この記事では、両方の実装を比較し、接続の扱いや再接続、エラー処理の違いを整理します。
SSEの基本
SSEは、HTTP接続を維持したままサーバーからイベントを送信する仕組みです。ブラウザ側ではEventSourceを使って受信できます。
LLMのストリーミングでは、ユーザーがプロンプトを送り、サーバーが生成されたトークンを順番に返す構成がよくあります。この場合、通信の大部分はサーバーからクライアントへの一方向です。
まず、Honoでトークンを少しずつ返すAPIを作りました。
import { Hono } from 'hono'
const app = new Hono()
async function* fakeLLM() {
const tokens = ['こんにちは', '。', 'これは', 'SSE', 'の', 'テストです', '。']
for (const token of tokens) {
await new Promise((resolve) => setTimeout(resolve, 300))
yield token
}
}
app.get('/api/stream', (c) => {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
for await (const token of fakeLLM()) {
const message = `data: ${JSON.stringify({ token })}\n\n`
controller.enqueue(encoder.encode(message))
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
} catch (error) {
controller.enqueue(
encoder.encode(`event: error\ndata: ${JSON.stringify({
message: '生成に失敗しました',
})}\n\n`)
)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
})
export default app
SSEのデータは、data:で始まる行と空行を1イベントとして扱います。JSON.stringifyしたデータを送っておくと、クライアント側で扱いやすくなります。
確認にはcurlの-Nオプションが便利です。
curl -N http://localhost:8787/api/stream
ブラウザからは次のように受信できます。
const source = new EventSource('/api/stream')
source.onmessage = (event) => {
if (event.data === '[DONE]') {
source.close()
return
}
const { token } = JSON.parse(event.data)
document.querySelector('#answer')!.textContent += token
}
source.onerror = () => {
source.close()
console.error('SSE connection error')
}
WebSocketの基本
WebSocketは、接続後にクライアントとサーバーの双方が自由にメッセージを送信できます。
Honoでは実行環境によってWebSocketの設定が異なります。以下はCloudflare Workersを想定した例です。
import { Hono } from 'hono'
import { upgradeWebSocket, websocket } from 'hono/cloudflare-workers'
const app = new Hono()
async function* fakeLLM(prompt: string) {
const tokens = [`「${prompt}」への回答です`, '。', 'WebSocket', 'で', '送信します', '。']
for (const token of tokens) {
await new Promise((resolve) => setTimeout(resolve, 300))
yield token
}
}
app.get(
'/api/ws',
upgradeWebSocket(() => {
return {
onMessage: async (event, ws) => {
const message = JSON.parse(event.data.toString())
if (message.type !== 'prompt') {
return
}
try {
for await (const token of fakeLLM(message.prompt)) {
ws.send(JSON.stringify({ type: 'token', token }))
}
ws.send(JSON.stringify({ type: 'done' }))
} catch {
ws.send(JSON.stringify({
type: 'error',
message: '生成に失敗しました',
}))
}
},
}
})
)
export default websocket(app)
クライアント側は、接続後にプロンプトを送信します。
const socket = new WebSocket('wss://example.com/api/ws')
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
type: 'prompt',
prompt: 'WebSocketの特徴を説明してください',
}))
})
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.type === 'token') {
document.querySelector('#answer')!.textContent += message.token
}
if (message.type === 'done') {
console.log('completed')
}
if (message.type === 'error') {
console.error(message.message)
}
})
SSEと違い、WebSocketではサーバーからのイベント形式が決まっていません。そのため、typeを持つメッセージ形式を自分で設計する必要があります。
実際に比較して分かったこと
SSEとWebSocketの通信の流れを比較すると、次のようになります。
手元で同じようなモックLLMを使って比較した結果、最も大きな違いは「通信方向」と「接続管理」でした。
| 比較項目 | SSE | WebSocket |
|---|---|---|
| 通信方向 | サーバー → クライアント | 双方向 |
| ブラウザAPI | EventSource | WebSocket |
| 実装量 | 少ない | やや多い |
| 再接続 | EventSourceが比較的扱いやすい | 自前実装が必要 |
| HTTPとの親和性 | 高い | 専用接続へ切り替え |
| 適した用途 | LLM回答、通知、ログ | チャット、共同編集、音声対話 |
LLMの通常のチャットでは、最初にHTTPでプロンプトを送信し、そのレスポンスをSSEで受け取る設計もよく使います。ただし、EventSourceはGET中心なので、プロンプトをPOSTのボディで送りたい場合には工夫が必要です。
その場合は、fetchとReadableStreamを使ってSSEを受信できます。
const response = await fetch('/api/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'ストリーミングで回答してください',
}),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { value, done } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
console.log(chunk)
}
実運用ではSSEのイベント境界がネットワーク上で分割されることがあるため、単純にチャンクごとにJSON.parseしてはいけません。バッファに追加し、\n\n単位でイベントを切り出す処理が必要です。
認証・切断・エラー処理
SSEで特に注意したのは認証です。EventSourceは任意のAuthorizationヘッダーを付けにくいため、Cookie認証、短時間だけ有効なチケット、またはfetchによるストリーム受信を検討します。
アクセストークンをURLのクエリに直接付ける方法もありますが、アクセスログや履歴に残る可能性があるため避けたほうが安全です。
また、LLM生成中にブラウザを閉じたり、ネットワークが切れたりすることもあります。サーバー側では切断を検知して、不要になったLLM生成をキャンセルできるようにすると、無駄な処理を減らせます。
WebSocketでは、少なくとも次の状態をクライアント側で管理しました。
let reconnectTimer: number | undefined
function connect() {
const socket = new WebSocket('wss://example.com/api/ws')
socket.onopen = () => {
console.log('connected')
}
socket.onclose = () => {
reconnectTimer = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket.close()
}
}
connect()
ただし、再接続時に同じプロンプトを再送すると回答が重複する可能性があります。リクエストID、生成済みトークン数、サーバー側のキャンセル処理などを設計しておく必要があります。
どちらを選ぶべきか
私の選び方は次の通りです。
- LLMの回答を一方向に表示するだけならSSE
- サーバーからの通知や進捗表示にも使いたいならSSE
- クライアントから途中停止・再指示を頻繁に送りたいならWebSocket
- 音声入力、音声出力、リアルタイムツール実行を統合するならWebSocket
- まず小さく実装して運用したいならSSE
LLMチャットだから必ずWebSocket、というわけではありません。通常のテキスト生成では、SSEのほうがプロトコルの意味が分かりやすく、HTTPサーバーやプロキシとの相性も確認しやすいと感じました。
一方で、生成停止、再生成、ツール実行の承認、サーバーからのイベント通知など、通信が双方向になるほどWebSocketのメリットが大きくなります。
まとめ
SSEとWebSocketは、どちらもLLMストリーミングに利用できます。
SSEは実装がシンプルで、サーバーからクライアントへトークンを流す用途に向いています。WebSocketは双方向通信に強く、複雑なリアルタイムチャットや音声対話に向いています。
まずは「通信が一方向か、双方向か」を基準に選ぶのがおすすめです。私の場合、テキスト中心のLLMチャットはSSE、将来的に中断やイベント連携が増える機能はWebSocketとして設計を分けると、実装と運用の見通しがよくなりました。