ストリーミング
生成途中のトークンを1 文字ずつブラウザに届ける仕組み。 体感応答性が桁違いに上がる。SSE / Server Streams / Anthropic SDK の流儀を網羅。
なぜストリーミング
- 長文応答は完成まで10 秒以上かかることもある
- 1 文字ずつ表示することで「動いている」感がでる
- ユーザーが途中で読み始められる(実時間が短く感じる)
- 途中キャンセルが容易(不要な続きを生成しなくて済む)
Anthropic SDK でストリーミング
const stream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [{ role: "user", content: "こんにちは" }]
})
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text)
}
}
const final = await stream.finalMessage()
console.log(final.usage)
イベントの種類
| イベント | 意味 |
|---|---|
message_start | メッセージ開始(usage の input_tokens) |
content_block_start | ブロック開始(text / tool_use / thinking) |
content_block_delta | 差分(text_delta / input_json_delta など) |
content_block_stop | ブロック終了 |
message_delta | stop_reason / output_tokens など |
message_stop | メッセージ完了 |
ping | 接続維持 |
error | エラー |
サーバーからブラウザへ流す
バックエンドで Claude をストリーミング呼出し → SSE / ReadableStream でフロントへ。
SSE で配信
// Hono などで
app.post("/chat", async (c) => {
return c.streamSSE(async (sse) => {
const stream = await client.messages.stream({...})
for await (const event of stream) {
if (event.type === "content_block_delta") {
await sse.writeSSE({ data: event.delta.text })
}
}
await sse.writeSSE({ event: "done", data: "" })
})
})
クライアント側
const es = new EventSource("/chat")
es.onmessage = (e) => {
document.getElementById("out").textContent += e.data
}
es.addEventListener("done", () => es.close())
fetch + ReadableStream
SSE は GET 限定なので、POST でストリームするなら fetch + ReadableStream。
サーバー
app.post("/chat", async (c) => {
const stream = new ReadableStream({
async start(controller) {
const llm = await client.messages.stream({...})
for await (const ev of llm) {
if (ev.type === "content_block_delta") {
controller.enqueue(new TextEncoder().encode(ev.delta.text))
}
}
controller.close()
}
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
})
})
クライアント
const res = await fetch("/chat", { method: "POST", body: JSON.stringify(data) })
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { value, done } = await reader.read()
if (done) break
setText(prev => prev + decoder.decode(value))
}
Vercel AI SDK の streamText
SSE / Stream の取り回しを抽象化。React の useChat / useCompletion と組合せて即動く。
import { streamText } from "ai"
import { anthropic } from "@ai-sdk/anthropic"
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages
})
return result.toDataStreamResponse()
}
"use client"
import { useChat } from "ai/react"
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat()
return (
<form onSubmit={handleSubmit}>
{messages.map(m => <p key={m.id}>{m.content}</p>)}
<input value={input} onChange={handleInputChange} />
</form>
)
}
ツール呼び出し中のストリーム
Claude のストリームでは input_json_delta イベントが流れる(ツール引数の構築途中)。
for await (const ev of stream) {
if (ev.type === "content_block_start" && ev.content_block.type === "tool_use") {
console.log("ツール呼び出し開始:", ev.content_block.name)
}
if (ev.type === "content_block_delta" && ev.delta.type === "input_json_delta") {
process.stdout.write(ev.delta.partial_json)
}
}
キャンセル
- クライアントが画面を閉じる / リロードしたらfetch を AbortController で abort
- サーバー側はreq.signal.aborted を見て LLM ストリームを中断
- 料金はそこまでに生成した分のみ発生
const ac = new AbortController()
const stream = await client.messages.stream(
{ ... },
{ signal: ac.signal }
)
// 中断
ac.abort()
バッファリング問題
- nginx / CDN / プラットフォームがレスポンスをバッファするとストリームが届かない
- nginx:
proxy_buffering off; - Cloudflare: Streaming は標準対応(chunked transfer-encoding 維持)
- Vercel: Edge Runtime ならストリーミング可
UI 表示のコツ
- カーソル点滅を末尾に表示するとそれっぽい
- requestAnimationFrame で再描画を絞る(毎トークン setState すると重い)
- 大量トークン受信時は仮想スクロール
- マークダウンを差分レンダリング(react-markdown + memo)
- コードブロックは完成してからシンタックスハイライト(途中だと崩れる)
マルチプレックス(複数クライアントへ配信)
同じ会話を複数タブで見るなら、サーバーで1 つのストリームを fan-outする。 BroadcastChannel / Redis Pub/Sub / WebSocket で実現。
レイテンシー指標
- TTFT (Time to First Token): 最初のトークンが届くまで。重要
- TPS (Tokens per Second): 出力速度
- Total time: 全部出るまで
失敗パターン
| 症状 | 対処 |
|---|---|
| ストリームが流れず一気に来る | nginx / CDN のバッファ無効化 |
| 切断される | idle timeout を延長、ping 送信 |
| UI が固まる | setState 頻度を下げる、虚 DOM 最適化 |
| JSON が壊れる | tool_use は完成後に parse |
| 料金が膨らむ | max_tokens の上限を必ず設定 |
体感品質はストリーミングで決まる
同じ性能の LLM でもストリーミング有無で体感は雲泥の差。 チャット UI を作るなら最初から組み込むこと。