ストリーミング

生成途中のトークンを1 文字ずつブラウザに届ける仕組み。 体感応答性が桁違いに上がる。SSE / Server Streams / Anthropic SDK の流儀を網羅。

なぜストリーミング

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_deltastop_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)
  }
}

キャンセル

const ac = new AbortController()
const stream = await client.messages.stream(
  { ... },
  { signal: ac.signal }
)
// 中断
ac.abort()

バッファリング問題

UI 表示のコツ

マルチプレックス(複数クライアントへ配信)

同じ会話を複数タブで見るなら、サーバーで1 つのストリームを fan-outする。 BroadcastChannel / Redis Pub/Sub / WebSocket で実現。

レイテンシー指標

失敗パターン

症状対処
ストリームが流れず一気に来るnginx / CDN のバッファ無効化
切断されるidle timeout を延長、ping 送信
UI が固まるsetState 頻度を下げる、虚 DOM 最適化
JSON が壊れるtool_use は完成後に parse
料金が膨らむmax_tokens の上限を必ず設定
体感品質はストリーミングで決まる

同じ性能の LLM でもストリーミング有無で体感は雲泥の差。 チャット UI を作るなら最初から組み込むこと。