結論

LLM の応答のストリーミングは、要求に stream: true を付け、サーバーが返す SSE(text/event-stream)を「空行で区切られたイベント」として1つずつ読み、data の JSON の差分(delta)をつなげて表示する実装です。受け方そのものより、切断で生成を止めること・プロキシのバッファを切ること・終わりの印を確かめることが、本番で差が出るところです。

この記事でわかること

  • OpenAI 互換(Chat Completions)と Anthropic(Messages API)の SSE の形式を公式ドキュメントで確かめ、その形で1トークンずつ返す偽の LLM サーバーを作って、Python(httpx・公式 SDK)・Node(fetch)・ブラウザ(EventSource と fetch)で受けた原文と TTFT の実測
  • クライアントが途中で切ったときにサーバー側の生成を止める書き方と止まらない書き方の差(11トークンで停止と、87トークン全部の生成)、nginx の proxy_buffering で最初の文字が 1.2 秒遅れる現象と X-Accel-Buffering での直し方
  • EventSource の自動再接続と Last-Event-ID による途中再開、ツール呼び出しの引数の差分の組み立て、よくあるエラー原文と判断表

2026年11月4日更新|Python 3.13.18・FastAPI 0.162.0・httpx 0.28.1・openai 3.45.0・anthropic 2.4.0・Node.js 22.23.4・Chromium 153・nginx 1.24.0 で実行確認

この記事で実際に動かしたもの:決まった文章を2文字ずつ「トークン」として 40 ミリ秒おきに返す偽の LLM サーバー(FastAPI。LLM 本体は使いません)を 127.0.0.1 の空きポートで起動し、その前に自分の設定ファイルで起動した nginx を置いて、4種類のクライアントから受けました。

flowchart TB
    L["LLM(偽サーバー)<br>1トークンずつ生成"] -->|"data: 差分(SSE)"| N["nginx<br>バッファを切る"]
    N -->|"届いた順にそのまま"| B["ブラウザ・CLI<br>差分をつなげて表示"]
    B -.->|"停止・切断"| N
    N -.->|"切断を伝える→生成を止める"| L

図は「差分が届いたらすぐ次へ渡し、停止は逆向きに伝える」理想の流れです。途中のどこかが溜め込むと、利用者には最後にまとめて届き、停止の伝わりも遅れます(後の節で実測します)。よく調べられることの答えを先にまとめます。

知りたいこと答え詳しい節
llm ストリーミングとは生成済みの部分を順に送る返し方。全体の時間は同じでも、最初の文字(TTFT)が早く見えるLLMのストリーミングとは
stream: true で何が返るContent-Type が text/event-stream の応答。data: {JSON} と空行の繰り返しSSE の形式の基本
OpenAI 互換の終わりの印Chat Completions は data: [DONE]。Responses は response.completed のイベントOpenAI 互換の SSE の形式
Anthropic の終わりの印message_delta(stop_reason)の後の message_stop。[DONE] は無いAnthropic の SSE の形式
sse ストリーミング 実装(Python)httpx の client.stream() と iter_lines()。空行で1イベントPython で受けて TTFT を測る
ブラウザで受けるにはPOST と認証ヘッダーが要るので fetch の ReadableStream。EventSource は GET のみブラウザで受ける
途中で止めたいクライアントは AbortController。サーバーは切断で生成を止める書き方にする切断を検知して生成を止める
nginx で一気に表示されるproxy_buffering が既定で on。アプリから X-Accel-Buffering: no か、location で offnginx でバッファされる問題
server-sent events llm の再接続id と Last-Event-ID で続きから送れるのは自作サーバーだけ。LLM の API は続きを頼み直す再接続と途中再開

確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB の共有マシン)。作業用フォルダーの venv に PyPI の fastapi 0.162.0(starlette 1.10.0)・uvicorn 0.54.0・httpx 0.28.1・openai 3.45.0・anthropic 2.4.0 を入れ、偽の LLM サーバーを 127.0.0.1 の空きポートで起動しました。nginx は Ubuntu の nginx 1.24.0 の .deb を作業場所に展開し、自分の設定ファイル(127.0.0.1 の空きポート)で起動しています。ホストの nginx の設定は変えていません。クライアントは Python 3.13.18(httpx と公式 SDK)、Node.js 22.23.4 の fetch、Playwright 1.63.0 の Chromium 153.0.8010.12 で、curl 8.5.0 で原文を取りました。LLM の API やモデルの重みには接続していないため、文章は固定で、LLM の答えの質については何も主張しません。各社の SSE の形式(イベント名・data の中身・終わりの印)は、OpenAI の API リファレンスと Streaming のガイド(developers.openai.com)、Anthropic の Streaming Messages のページ(platform.claude.com)で確かめた範囲だけを書き、SDK の挙動はインストールした SDK のソースで確かめました。SSE の仕様は MDN、nginx の指令は nginx.org の公式ドキュメントで確かめています。起動したプロセスは PID を控えて止めました(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「LLMのストリーミング実装|SSEの形式・TTFT・切断・nginxを実測」

LLMのストリーミングとは(stream: true と SSE の形式の基本)

この節では、LLM の応答をストリーミングで返すと何が速くなり何が変わらないのかと、SSE の形式の決まりが分かります。

LLM は文章を1トークンずつ順に作るので、作った分から送れば、利用者は全体が終わる前に読み始められます。全体の生成時間は同じで、早くなるのは「最初の文字が見えるまでの時間」です。これを TTFT(Time To First Token)と呼びます。手元の偽サーバー(考える時間 400 ミリ秒、29トークンを 40 ミリ秒おき)で、同じ文章をストリーミングあり・なしで受けた中央値(5回)です。

490msstream: true の TTFT全体は 1,732 ms(httpx・直接)
1,681msstream: false で最初に読めるまで全部そろってから1回で届く
3.4倍最初の文字が見えるまでの差全体の時間はほぼ同じ(+51 ms)

応答が長いほど差は大きくなります。逆に、全体を受け取ってから JSON として検証する処理(構造化出力をそのまま保存するなど)では、ストリーミングにしても待ち時間は縮みません。

SSE(Server-Sent Events)の形式

LLM の API のストリーミングは、ほとんどが SSE で返ります。SSE は HTTP の応答本文を UTF-8 のテキストで流し続ける決まりで、MDN の説明の要点は次のとおりです。

  • 応答の MIME タイプは text/event-stream。本文は UTF-8 でなければならない。
  • メッセージは空行(改行2つ)で区切る。1つのメッセージは event:・data:・id:・retry: の行でできていて、それ以外のフィールド名は無視される。
  • 連続する data: 行は改行でつながれて1つのデータになる。
  • コロンで始まる行はコメントとして無視される。接続が時間切れにならないよう、定期的にコメントを送る使い方が書かれている。
  • retry: は再接続までの待ち時間(ミリ秒の整数)。id: は EventSource が覚える「最後のイベント ID」になる。

偽サーバーに curl で要求したときの、応答の頭の原文です。-N は curl の出力バッファを切るオプションで、これが無いと届いた順に表示されません。

$ curl --noproxy '*' -sS -N -i -X POST "http://127.0.0.1:46463/v1/chat/completions" \
    -H 'Content-Type: application/json' \
    -d '{"model":"fake-1","stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":"こんにちは"}]}'
HTTP/1.1 200 OK
server: uvicorn
cache-control: no-cache
content-type: text/event-stream; charset=utf-8
Transfer-Encoding: chunked

data: {"id": "chatcmpl-8d4b376db9ce", "object": "chat.completion.chunk", "created": 1793784519, "model": "fake-1", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}

data: {"id": "chatcmpl-8d4b376db9ce", "object": "chat.completion.chunk", "created": 1793784519, "model": "fake-1", "choices": [{"index": 0, "delta": {"content": "スト"}, "finish_reason": null}]}

data: {"id": "chatcmpl-8d4b376db9ce", "object": "chat.completion.chunk", "created": 1793784519, "model": "fake-1", "choices": [{"index": 0, "delta": {"content": "リー"}, "finish_reason": null}]}

HTTP の層で見えるもの

1回の要求に対する1つの長い応答

Transfer-Encoding: chunked(HTTP/1.1)

TCP の読み込み単位はイベントの区切りと無関係

SSE の層で見えるもの

空行で区切られたイベントの列

event 名と data の文字列

data の中身(JSON)の意味は各社が決める

大事なのは、SSE が決めているのは「区切り方」までで、data に何を入れ、何を終わりの印にするかは API ごとに違うことです。次の2節で、OpenAI 互換と Anthropic の形を公式ドキュメントで確かめた範囲で並べます。

OpenAI 互換(Chat Completions・Responses)の SSE の形式

この節では、OpenAI の Chat Completions と Responses の2つの API で、ストリーミングの data の中身と終わりの印がどう違うかが分かります。

Chat Completions は、名前の無いイベント(event: 行なし)の data に chat.completion.chunk の JSON を入れ、最後に data: [DONE] を送ります。API リファレンスの stream の説明は「If set to true, the model response data will be streamed to the client as it is generated using server-sent events.」です。[DONE] は stream_options.include_usage の説明に「If set, an additional chunk will be streamed before the data: [DONE] message.」として出てきます。リファレンスで確かめたチャンクの主な項目は次のとおりです。

項目中身(公式の説明の要点)
idチャット補完の ID。「Each chunk has the same ID.」
choices[].index選択肢の番号。n が 2 以上なら複数入る
choices[].delta.role発話者の役割(最初のチャンクに assistant)
choices[].delta.contentこのチャンクで増えた本文
choices[].delta.tool_callsindex・id・function(name・arguments)・type の配列。arguments は JSON の断片
choices[].finish_reason止まった理由。途中は null
usage(最後のチャンク)include_usage を付けたとき、[DONE] の前に1つ追加。choices は空の配列、ほかのチャンクの usage は null

include_usage の説明には、ストリームが途中で切れると最後の usage のチャンクを受け取れない場合があることも書かれています。トークン数を課金や上限の管理に使うなら、切断時の扱いを決めておきます。なお偽サーバーのチャンクには object・created・model も入れました。インストールした openai 3.45.0 の型 ChatCompletionChunk がこの3つを持ち、object は「always chat.completion.chunk」と書かれているためです。

openai 3.45.0 の SDK のソース(openai/_streaming.py)では、受け取った data が [DONE] で始まるとループを抜け、data の JSON に error キーがあれば APIError を投げる作りになっていました。自作のクライアントでも、[DONE] を JSON として読もうとして例外にしないよう、先に比べます。

Responses API はイベントの type で見分ける

Responses API のストリーミングは、型の決まったイベントが順に届き、data の JSON の type で種類を見分けます。Streaming のガイドは「Each event is typed with a predefined schema」と書き、よく使うイベントとして次を挙げています。API リファレンスでは、どのイベントにも sequence_number が付きます。

  • response.created:応答が作られた。response に status が in_progress のオブジェクト
  • response.output_text.delta:本文の差分。item_id・output_index・content_index・delta
  • response.output_text.done:その部分の本文が確定。text に全文
  • response.function_call_arguments.delta と .done:ツール呼び出しの引数の差分と確定値
  • response.completed:応答の完了。これが終わりの印になる
  • error:code・message・param

同じガイドには、ストリーミングでは出力の途中で内容を確かめにくくなること(モデレーションの点数は部分的な差分には付かず、全体がそろってから届く)も書かれています。途中の文字を表示するのは「未確定の下書き」を見せることだ、と考えて設計します。

この記事の偽サーバーは、広く「OpenAI 互換」として実装されている Chat Completions の形だけを再現しています。vLLM・Ollama などのローカル推論サーバーが持つ OpenAI 互換の API の細部は、各製品の公式ドキュメントで確かめます。

Anthropic(Messages API)の SSE の形式

この節では、Anthropic の Messages API のストリーミングで届くイベントの順番と中身、終わりの印、途中のエラーの形が分かります。

Anthropic の Messages API は、すべてのイベントに event: 行の名前を付け、data の type にも同じ名前を入れます。公式ページの説明は「Each event uses an SSE event name (for example, event: message_stop), and includes the matching event type in its data.」です。流れは次の順に決まっています。

1

message_start

content が空の Message オブジェクト。id・model・usage(input_tokens など)が入る。

最初に1回

2

content_block_start → content_block_delta … → content_block_stop

本文・tool_use・thinking などのブロックごとに繰り返す。index は最終的な content 配列の位置。差分は text_delta・input_json_delta・thinking_delta・signature_delta。

ブロックの数だけ

3

message_delta

stop_reason(end_turn・tool_use など)と usage。公式は「The token counts shown in the usage field of the message_delta event are cumulative.」(累計)と書いている。

1回以上

4

message_stop

これが終わりの印。OpenAI の Chat Completions のような [DONE] は無い。

最後に1回

このほか、ping がストリームのどこにでも何回でも入り得ること、混雑時には error イベントで overloaded_error が届くこと(ストリーミングでない場合の HTTP 529 にあたる)、版の方針に従って新しいイベントの種類が増え得るので「your code should handle unknown event types gracefully」であることが公式に書かれています。tool_use の引数は input_json_delta の partial_json(JSON の文字列の断片)で届き、content_block_stop を受けてから JSON として読むよう案内されています。

この形で返す偽サーバーに、ツール付きの要求を送った原文の一部です。

event: message_start
data: {"type": "message_start", "message": {"id": "msg_077b33ef5f334983a038", "type": "message", "role": "assistant", "content": [], "model": "fake-claude", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 12, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: ping
data: {"type": "ping"}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "東京"}}
(中略)
event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "s\"}"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 1}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null}, "usage": {"output_tokens": 13}}

event: message_stop
data: {"type": "message_stop"}

3社目以降の API を足すときも、見るべきは「イベント名を使うか」「差分はどこに入るか」「終わりの印は何か」「途中のエラーはどう届くか」の4点です。Gemini などほかの API の形は、この記事では扱っていません。

比べる点Chat CompletionsResponsesAnthropic Messages
種類の見分け方無し(全部同じ chunk)data の typeevent 名と data の type
本文の差分choices[].delta.contentoutput_text.delta の deltacontent_block_delta の text_delta
ツールの引数delta.tool_calls[].function.argumentsfunction_call_arguments.deltainput_json_delta の partial_json
終わりの印data: [DONE]response.completedmessage_stop
途中のエラー(SDK は data の error キーで判断)error イベントerror イベント(overloaded_error 等)

偽の LLM サーバーを 127.0.0.1 に立てる(FastAPI の StreamingResponse)

この節では、LLM 本体なしでストリーミングの実装と試験ができる「1トークンずつ返す偽サーバー」の作り方が分かります。

LLM の API を呼ばずに試せる偽サーバーがあると、TTFT・切断・プロキシ・再接続の試験を、課金も待ち時間のぶれも無しに繰り返せます。FastAPI では、非同期ジェネレーターを StreamingResponse に渡し、media_type を text/event-stream にするだけで SSE を返せます。要点だけを抜き出したコードです(全体はツール呼び出し・Anthropic 形式・エラーの再現なども含みます)。

import asyncio, json, time, uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

ANSWER = "ストリーミングでは、生成された部分から順に画面へ届きます。…"
app = FastAPI()

def sse(data, event=None, id=None):
    s = f"event: {event}\n" if event else ""
    if id is not None: s += f"id: {id}\n"
    body = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
    return s + f"data: {body}\n\n"          # 空行で1イベントが終わる

@app.post("/v1/chat/completions")
async def chat(req: Request):
    body = await req.json()
    cid = "chatcmpl-" + uuid.uuid4().hex[:12]
    async def gen():
        base = {"id": cid, "object": "chat.completion.chunk",
                "created": int(time.time()), "model": body.get("model")}
        sent = 0
        try:
            await asyncio.sleep(0.4)                       # 考える時間(TTFT の元)
            yield sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]})
            for i in range(0, len(ANSWER), 2):             # 2文字を1トークンの代わりに
                yield sse({**base, "choices": [{"index": 0, "delta": {"content": ANSWER[i:i+2]}, "finish_reason": None}]})
                sent += 1
                await asyncio.sleep(0.04)
            yield sse({**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
            yield sse("[DONE]")
        except asyncio.CancelledError:                     # クライアントが切った
            print("CANCELLED at token", sent)
            raise
    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache"})

起動は uvicorn fake_llm:app --host 127.0.0.1 --port 空きポート です。0.0.0.0 にしないのは、試験用のサーバーを同じネットワークのほかの機械から見えなくするためです。偽サーバーに持たせた「つまみ」は次の4つです。

1考える時間と間隔

最初のトークンまでの待ち(既定 400 ms)とトークンの間隔(既定 40 ms)をクエリで変える。TTFT の測り方の試験に使う。

2途中のエラー

Anthropic 形式で、n 個目の差分の代わりに error イベント(overloaded_error)を送る。

3ツール呼び出し

tools を付けた要求には、引数の JSON を5文字ずつに切った差分を返す。

4X-Accel-Buffering

クエリで指定すると応答ヘッダーに X-Accel-Buffering: no を付ける。nginx の試験に使う。

公式 SDK が偽サーバーの応答をそのまま読めるかも確かめました。openai 3.45.0 は OpenAI(base_url="http://127.0.0.1:ポート/v1", api_key="dummy")、anthropic 2.4.0 は Anthropic(base_url="http://127.0.0.1:ポート", api_key="dummy") で、どちらも外部には接続しません。結果は次の節に載せます。

Python(httpx・公式 SDK)でストリーミングを受けて TTFT を測る

この節では、Python で SSE を正しく区切って読む最小のコードと、TTFT・全体時間の測り方、公式 SDK で受けたときの結果が分かります。

httpx では client.stream() で応答を開き、iter_lines() の行を「空行で1イベント」にまとめます。1行ずつ data: を探すだけのコードは手軽ですが、複数行の data やコメント行、event 名を取りこぼします。SSE の規則の要点だけを守ったパーサーです。

import httpx, json, time

def iter_sse(lines):
    ev, data = None, []
    for line in lines:
        if line == "":                       # 空行で1イベントが確定
            if data: yield ev, "\n".join(data)
            ev, data = None, []
        elif line.startswith(":"):           # コメント(ハートビート)は捨てる
            continue
        else:
            k, _, v = line.partition(":")
            v = v[1:] if v.startswith(" ") else v
            if k == "event": ev = v
            elif k == "data": data.append(v)

t0 = time.perf_counter(); ttft = None; text = ""
with httpx.Client(timeout=30, trust_env=False) as c:
    with c.stream("POST", f"{BASE}/v1/chat/completions",
                  json={"model": "fake-1", "stream": True, "messages": MSG}) as r:
        for ev, data in iter_sse(r.iter_lines()):
            if data == "[DONE]": break       # JSON として読む前に比べる
            d = json.loads(data)["choices"][0]["delta"].get("content")
            if d:
                if ttft is None: ttft = time.perf_counter() - t0
                text += d
print(f"TTFT {ttft*1000:.0f} ms / 全体 {(time.perf_counter()-t0)*1000:.0f} ms")

TTFT は「最初の空でない本文の差分」が届いた時刻で測ります。最初のチャンクは role だけで content が空なので、これを最初のトークンに数えると TTFT を短く見誤ります。5回ずつの中央値です。

$ python client_py.py bench
OpenAI形式 stream:true: TTFT 490 ms / 全体 1732 ms / 57文字
OpenAI形式 stream:false: TTFT 1681 ms / 全体 1681 ms / 57文字
Anthropic形式 stream:true: TTFT 542 ms / 全体 1760 ms / 57文字
{'message_start': 1, 'content_block_start': 1, 'ping': 1, 'content_block_delta': 29, 'content_block_stop': 1, 'message_delta': 1, 'message_stop': 1}

設定した考える時間は 400 ms なので、残りの約 90 ms は接続の確立と最初の読み込みの分です。Anthropic 形式は最初の差分の前に 40 ms 待つ作りにしたので、その分だけ遅くなっています。最後の行は、受け取ったイベントの種類ごとの数です。

公式 SDK で受けた結果

同じ偽サーバーを、openai と anthropic の公式 SDK で受けた出力です。

=== openai 3.45.0(stream=True, stream_options={"include_usage": True})
最後のチャンク: choices = [] usage = {'completion_tokens': 29, 'prompt_tokens': 12, 'total_tokens': 41, ...}
ChatCompletionChunk ストリーミングでは、生成された部分から順 ... 1801 ms

=== anthropic 2.4.0(client.messages.stream(...) と get_final_message())
text_stream の断片数 6
text 東京の天気を調べます。
tool_use {'city': '東京', 'unit': 'celsius'}
stop_reason tool_use usage.output_tokens 13

=== anthropic 2.4.0(5個目の差分の後に error イベント)
APIStatusError | {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}}
それまでに受け取った文字: ストリーミングでは、

anthropic の SDK は、受け取った差分を組み立てて最終の Message(本文のブロックと tool_use のブロック、引数は dict)を返してくれます。途中の error イベントは例外になり、それまでの文字は手元に残ります。エラーを画面に出すときは、届いた文字を消すのか「ここで止まりました」と添えるのかを決めておきます。

偽サーバーで実際に起きた食い違い

やってはいけないAnthropic 形式なのに event: 行を省いて data だけ送る。anthropic 2.4.0 の SDK は受け取った文字数 0、get_final_message() は AssertionError になった
→
代わりに公式の形どおり event 名と data の type を両方送る。SDK のソースでは、event 名の無いイベントは読み飛ばす作りになっている

互換サーバーを自作するときは、公式 SDK で受けて確かめる

Node.js(fetch の ReadableStream)でストリーミングを受ける

この節では、Node.js の標準の fetch で SSE を受けるコードと、日本語が文字化けする落とし穴が分かります。

Node.js 22 では fetch が標準で使え、res.body を for await で読めます。受け取るのはバイト列なので、TextDecoder で文字にしてから SSE のパーサーに渡します。

const res = await fetch(`${B}/v1/chat/completions`, {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ model: "fake-1", stream: true, messages }),
  signal: ac.signal,                       // AbortController で途中で止められる
});
const dec = new TextDecoder();
for await (const chunk of res.body) {
  feed(dec.decode(chunk, { stream: true })); // stream: true が要
  if (done) break;
}
$ node client_node.mjs chat
status 200 text/event-stream; charset=utf-8
TTFT 674 ms / 全体 1867 ms / 読んだチャンク 30 / 57文字

この1回は TTFT が 674 ms と長めでした。同じプロセスで3回続けて測ると 483・447・449 ms で、最初の1回だけが長くなりました。プロセスで最初の fetch には準備の時間が乗るので、TTFT を比べるときは最初の1回を捨てるか、同じプロセスで複数回測ります。

UTF-8 の途中で切れたチャンクを再現する

TCP や HTTP の読み込みの単位は、文字の区切りともイベントの区切りとも関係ありません。偽サーバーに「本」の1バイト目の直後でわざと切って送る口を作り、デコードの仕方で結果を比べました。

$ node client_node.mjs split
届いたチャンクのバイト数: [ 17, 9 ]
チャンクごとに decode(): "data: {\"t\": \"日���語\"}"
stream: true で decode(): "data: {\"t\": \"日本語\"}"

チャンクごとに新しく decode すると、3バイトの「本」が2つに割れて置換文字(U+FFFD)になります。同じ TextDecoder を使い回して { stream: true } を付けると、足りないバイトを次のチャンクまで持ち越してくれます。ブラウザなら res.body.pipeThrough(new TextDecoderStream()) でも同じ効果です。同じ理由で、イベントの区切り(空行)もチャンクをまたぎ得るので、受け取った文字列はバッファに足してから区切ります。

ブラウザで受ける(EventSource と fetch の違い)

この節では、ブラウザで LLM の応答を受けるときに EventSource と fetch のどちらを使うかと、それぞれの挙動を Chromium で測った結果が分かります。

LLM のチャット画面では、POST で会話の本文を送り、認証のヘッダーを付けたいので、多くの場合 fetch の ReadableStream を使います。EventSource は GET しか送れず、独自のヘッダーも付けられません。手元の Chromium で new EventSource(url, {method: "POST", headers: {Authorization: "Bearer x"}}) と書いても例外は出ず、サーバーに届いたのは Authorization の無い GET でした。その代わり EventSource には、切れたら自動でつなぎ直し、最後の id を Last-Event-ID ヘッダーで送る仕組みがあります。

EventSource

GET のみ・独自ヘッダーは付かない

自動再接続と Last-Event-ID が標準

event 名ごとに addEventListener で受ける

fetch + ReadableStream

POST・Authorization ヘッダーを送れる

再接続は自分で書く

AbortController で途中で止められる

127.0.0.1 の偽サーバーが返すテスト用のページを Playwright の Chromium 153 で開き、fetch で3回ずつ受けた結果です(単位はミリ秒。nginx の2つの経路は次の次の節で説明します)。

経路TTFT(3回)全体(3回)
偽サーバーに直接412・414・4211,689・1,687・1,702
nginx 経由(既定のバッファあり)1,176・1,185・1,1611,652・1,676・1,643
nginx 経由(バッファを切った設定)410・415・4041,649・1,627・1,619

表示には textContent を使いました。LLM の出力をそのまま innerHTML に入れると、出力に含まれた HTML が解釈されます。Markdown を整形して表示するなら、整形した後の HTML を無害化してから入れます。

EventSource は終わっても閉じないとつなぎ直し続ける

EventSource は、サーバーが応答を閉じると「切れた」とみなして再接続します。done イベントを受けたら es.close() を呼ぶ版と、呼ばない版を 4 秒ずつ動かした記録です(サーバーは最初に retry: 500 を送っています)。

A done で close():  ["open 7ms", "first token 51ms id=0", "done 1230ms"]
B close() しない:   ["open 4ms", "first token 50ms id=0", "done 1255ms",
                     "error readyState=0 lastText=57chars 1265ms", "open 1770ms", "done 1770ms",
                     "error readyState=0 lastText=57chars 1770ms", "open 2294ms", "done 2295ms",
                     … 4 秒までに合計 6 回の再接続]
サーバーのログ(B):
09:33:46 sse_answer sid b Last-Event-ID None start 0
09:33:47 sse_answer sid b Last-Event-ID 28 start 29
09:33:48 sse_answer sid b Last-Event-ID 28 start 29

偽サーバーは Last-Event-ID を見て「29番目から」送るので、再接続しても done だけが返りました。Last-Event-ID を見ないサーバーなら、つなぎ直すたびに生成を最初からやり直し、そのたびにトークンを消費します。EventSource を使うなら、終わりのイベントで必ず close() し、サーバーも Last-Event-ID に対応させます。

もう1つ、HTTP/1.1 では同じ相手への同時接続数に上限があります。MDN は、HTTP/2 を使わない場合の SSE について、上限がブラウザごとに 6 と低いことを警告しています。手元で同じオリジンに EventSource を8本開くと、2秒の間に開いたのは 0〜5 番の 6 本だけで、残り2本は待たされました。タブを複数開くチャット画面では、HTTP/2 で配信するか、1本の接続にまとめます。

切断を検知して生成を止める(クライアントが途中で切ったとき)

この節では、利用者が「停止」を押したりタブを閉じたりしたときに、サーバー側の生成まで止まる書き方と止まらない書き方の違いが分かります。

生成をレスポンスのジェネレーターの中で行えば、クライアントが切ったときに Starlette がジェネレーターを取り消し、生成も止まります。生成を別のタスクに切り離して、キュー経由で応答に流す書き方では、応答は止まっても生成は最後まで続きます。どちらも 87 トークンを返すエンドポイントにして、クライアントが 10 イベント受けたところで接続を閉じました。

# 悪い例:生成が応答と無関係に走り続ける
@app.post("/v1/detached")
async def detached(req: Request):
    queue = asyncio.Queue()
    async def producer():
        for t in tokens() * 3:
            await asyncio.sleep(0.04); await queue.put(t)
        await queue.put(None)
    asyncio.create_task(producer())
    async def gen():
        while (t := await queue.get()) is not None:
            yield sse({"t": t})
    return StreamingResponse(gen(), media_type="text/event-stream")

# 良い例:生成をジェネレーターの中で行う(切断で CancelledError)
@app.post("/v1/attached")
async def attached(req: Request):
    async def gen():
        try:
            for t in tokens() * 3:
                await asyncio.sleep(0.04)
                yield sse({"t": t})
        except asyncio.CancelledError:
            log("CANCELLED")   # 上流の LLM への要求もここで閉じる
            raise
    return StreamingResponse(gen(), media_type="text/event-stream")
attached: client closed after 10 events 09:31:09
detached: client closed after 10 events 09:31:11
09:31:09 attached d1ffb0 CANCELLED after 11 tokens
09:31:11 detached 81431d generated 20
09:31:12 detached 81431d generated 40
09:31:13 detached 81431d generated 60
09:31:13 detached 81431d generated 80
09:31:14 detached 81431d generated ALL 87
11トークンジェネレーターの中で生成切断の直後に CancelledError で停止
87トークン別タスクで生成利用者が去った後も最後まで生成
20トークンnginx のバッファ越しに切った場合3イベント目で切っても 20 まで進んだ

本物の構成では、自分のサーバーの先に LLM の API があります。CancelledError を受けたら、上流への HTTP 接続(SDK のストリーム)も閉じます。公式の Anthropic のページも OpenAI のページも、切断した要求の課金がどうなるかをこのページでは書いていないため、上流を閉じれば費用が止まると決めつけず、各社の料金の説明で確かめます。

クライアント側は AbortController で止めます。手元での例外の原文は、Node.js 22 が「AbortError This operation was aborted」、Chromium 153 が「AbortError: BodyStreamBuffer was aborted」でした。Chromium で5トークン目に abort したとき、偽サーバーのログには「chat CANCELLED … at token 5」と出て、ブラウザの停止がサーバーの生成の停止までつながったことを確かめられました。

nginx でバッファされて一括で届く・途中で切れる問題(proxy_buffering・タイムアウト)

この節では、nginx などのリバースプロキシの後ろに置いたとき、ストリーミングが「最後にまとめて届く」「途中で切れる」原因と直し方を、自分の nginx で再現した結果で説明します。

nginx の proxy_buffering は既定で on で、上流の応答をバッファに溜めてからクライアントに送ります。公式ドキュメントには「Buffering can also be enabled or disabled by passing “yes” or “no” in the “X-Accel-Buffering” response header field.」とあり、アプリが応答ヘッダーで切り替えられます。作業場所に展開した nginx 1.24.0 で、location ごとに設定を変えて httpx で受け、イベントが届いた時刻を記録しました(「届いた塊」は 20 ms 以上の間を区切りに数えたもの)。

upstream llm { server 127.0.0.1:46463; }
server {
  listen 127.0.0.1:36369;
  location /buf/   { proxy_pass http://llm/; }                         # 既定のまま
  location /nobuf/ { proxy_pass http://llm/; proxy_buffering off; }
  location /gz/    { proxy_pass http://llm/; proxy_buffering off;
                     gzip on; gzip_types text/event-stream; gzip_min_length 1; }
  location /short/ { proxy_pass http://llm/; proxy_buffering off; proxy_read_timeout 3s; }
  location /good/  { proxy_pass http://llm/; proxy_http_version 1.1; proxy_set_header Connection "";
                     proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; }
}
v1/chat/completions(直接)               最初   559 ms  最後  1760 ms  イベント 31  届いた塊 30  gzip=None
buf/v1/chat/completions                  最初  1310 ms  最後  1768 ms  イベント 31  届いた塊 2  gzip=None
buf/v1/chat/completions?xab=no           最初   550 ms  最後  1747 ms  イベント 31  届いた塊 30  gzip=None
nobuf/v1/chat/completions                最初   637 ms  最後  1854 ms  イベント 31  届いた塊 30  gzip=None
gz/v1/chat/completions                   最初   592 ms  最後  1817 ms  イベント 31  届いた塊 30  gzip=gzip
good/v1/chat/completions                 最初   612 ms  最後  1842 ms  イベント 31  届いた塊 30  gzip=None

既定のままの /buf/ では、最初のイベントが 1,310 ms まで届かず、全体が2つの塊で届きました。バッファが一杯になった時点で1回、応答の終わりで1回送られたと読めます。全体の時間は直接とほぼ同じで、失われたのは TTFT だけです。応答に X-Accel-Buffering: no を付けた要求(?xab=no)は、同じ /buf/ でも直接と同じ届き方になりました。このヘッダーは nginx が受け取って消費するため、クライアントに届いた応答ヘッダーには残っていませんでした。

gzip を on にした /gz/ では、この構成では圧縮されたまま 30 の塊で届き、遅れは出ませんでした。ただし圧縮の扱いはプロキシや CDN ごとに違うため、ストリーミングの経路では圧縮を切るか、実際の経路で届き方を測って確かめます。

1

アプリから切る

SSE の応答に X-Accel-Buffering: no を付ける。nginx の設定を触れない環境でも効く。

proxy_ignore_headers で無効化され得る

2

location で切る

ストリーミングのパスだけ proxy_buffering off。ほかのパスのバッファは残せる。

proxy_cache も off に

3

上流は HTTP/1.1

nginx.org は、1.29.7 から proxy_http_version の既定が 1.1、それより前は 1.0 と書いている。1.24.0 の既定では偽サーバーのログに HTTP/1.0 で届いた。

Connection "" と組で

4

読み込みの待ち時間

proxy_read_timeout(既定 60s)は「2回の読み込みの間」の時間。考える時間が長い要求で切れる。

ハートビートと組で

バッファは切断の検知にも影響しました。同じ要求をクライアントが3イベント目で閉じると、直接と /good/ では偽サーバーの生成が2トークン目で止まりましたが、/buf/ では 20 トークン目まで進んでから止まりました。クライアントに届くのが遅れるぶん、切断に気づくのも遅れます。

proxy_read_timeout で切れる様子とハートビート

最初の差分を送った後に 5 秒黙る口を作り、proxy_read_timeout を 3s にした /short/ を通しました。

$ time curl --noproxy '*' -sS -N "http://127.0.0.1:36369/short/slow?pause=5"
data: {"t": "考え中…"}

curl: (18) transfer closed with outstanding read data remaining
real	0m3.053s

(nginx の error.log)
[error] 3076#3076: *11 upstream timed out (110: Connection timed out) while reading upstream, client: 127.0.0.1, server: , request: "GET /short/slow?pause=5 HTTP/1.1", upstream: "http://127.0.0.1:46463/slow?pause=5", host: "127.0.0.1:36369"

$ curl --noproxy '*' -sS -N "http://127.0.0.1:36369/short/slow?pause=5&hb=1"
data: {"t": "考え中…"}

: keep-alive
(1秒おきに5回)
data: {"t": "答え"}

data: [DONE]

応答のヘッダーはもう 200 で送ってしまっているため、プロキシが途中で切ると、クライアントには HTTP のエラーではなく「本文が途中で終わった」としか見えません。1秒おきにコメント行(: keep-alive)を送ると、同じ 3s の設定でも最後まで届きました。コメント行は SSE の規則でクライアントに無視されるので、本文を汚しません。

クライアント側の時間切れも同じ形で起きます。httpx の既定のタイムアウトは 5 秒で、6 秒黙る応答を既定のまま読むと httpx.ReadTimeout | timed out で止まりました。2 秒おきのハートビートがあれば既定のままでも最後まで読めます。LLM のストリーミングでは「全体の時間」ではなく「差分と差分の間の時間」に上限を掛ける、と考えると設定を決めやすくなります。

再接続と途中再開の考え方(Last-Event-ID)

この節では、ストリーミングが途中で切れたときに続きから受け取れる場合と、頼み直すしかない場合の違いが分かります。

SSE の仕組みとしては、イベントに id を付けておけば、EventSource が再接続のときに最後の id を Last-Event-ID ヘッダーで送り、サーバーは続きから送れます。ただし続きを送れるのは、サーバーが生成済みの内容を覚えている場合だけです。偽サーバーは生成したトークンを stream の ID ごとにメモリに持たせ、10個目でわざと接続を閉じました。

C サーバーが10個目で切る: ["open 10ms", "first token 45ms id=0",
                          "error readyState=0 lastText=20chars 434ms", "open 939ms", "done 1731ms"]
サーバーのログ:
09:33:50 sse_answer sid c Last-Event-ID None start 0
09:33:50 sse_answer server closes at 10
09:33:51 sse_answer sid c Last-Event-ID 9 start 10

ブラウザは切断から約 500 ms 後(retry: 500)に自動で再接続し、Last-Event-ID: 9 を送りました。サーバーは 10 番目から送り、画面の文章は欠けも重なりもなく 57 文字そろいました。

LLM の API そのものについては、確かめた公式ページに「Last-Event-ID で続きから」という説明は見当たりませんでした。Anthropic のページは、ネットワークの問題などで切れたストリーミングの要求を「回復」する方法として、受け取れた部分を保存し、それを使った新しい要求を送る手順を示しています(Claude 4.5 以前は受け取った部分をアシスタントの発話の先頭に置き、Claude 4.6 以降は受け取った部分と続きを頼む指示をユーザーの発話に入れる)。また tool_use と thinking のブロックは部分的には回復できず、再開できるのは最後のテキストのブロックからだと書いています。

  1. 1ブラウザ ⇔ 自分のサーバー → id と Last-Event-ID で続きから生成済みの差分を短い時間だけ保存しておく。fetch で受けるなら、再接続とヘッダーは自分で書く
  2. 2自分のサーバー ⇔ LLM の API → 受け取れた部分を使って頼み直す各社の公式の方法に従う。同じ要求を最初から出し直すと、別の文章になり得る
  3. 3画面 → 差分の番号で重複を捨てる再開した境目で文が重なったり欠けたりしないようにする

生成の途中経過を自分のサーバーで保存しておくと、ブラウザの再接続に強くなるうえ、上流が切れても「どこまで届いたか」が分かります。保存先はメモリなら1台の中だけで有効なので、複数台に振り分けるなら Redis などの共有の置き場に出すか、同じ台に戻す設定にします。

ツール呼び出し(function calling)の差分(delta)を組み立てる

この節では、ストリーミング中に届くツール呼び出しの引数の断片を、正しく組み立てて JSON として読むタイミングが分かります。

ツールの引数は JSON の文字列が細切れで届くので、文字列のまま足し合わせ、確定の合図を受けてから JSON として読みます。Chat Completions では delta.tool_calls[] の index ごとに function.arguments を足し、finish_reason が tool_calls になったら確定です。id と name は最初の断片にだけ入ります。openai 3.45.0 で受け、断片を足すたびに json.loads を試した記録です。

  +'{"cit'      -> '{"cit'                         JSONDecodeError: Unterminated string starting at: line 1 column 2 (char 1)
  +'y": "'      -> '{"city": "'                    JSONDecodeError: Unterminated string starting at: line 1 column 10 (char 9)
  +'東京", '      -> '{"city": "東京", '               JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 16 (char 15)
  +'"unit'      -> '{"city": "東京", "unit'          JSONDecodeError: Unterminated string starting at: line 1 column 16 (char 15)
  +'": "c'      -> '{"city": "東京", "unit": "c'     JSONDecodeError: Unterminated string starting at: line 1 column 24 (char 23)
  +'elsiu'      -> '{"city": "東京", "unit": "celsiu' JSONDecodeError: Unterminated string starting at: line 1 column 24 (char 23)
  +'s"}'        -> '{"city": "東京", "unit": "celsius"}' JSONとして完成
finish_reason = tool_calls
0 get_weather {'city': '東京', 'unit': 'celsius'}
calls = {}
for ch in stream:
    c0 = ch.choices[0]
    for tc in c0.delta.tool_calls or []:
        slot = calls.setdefault(tc.index, {"id": None, "name": None, "arguments": ""})
        if tc.id: slot["id"] = tc.id
        if tc.function and tc.function.name: slot["name"] = tc.function.name
        if tc.function and tc.function.arguments: slot["arguments"] += tc.function.arguments
    if c0.finish_reason == "tool_calls":
        for i, s in calls.items():
            args = json.loads(s["arguments"])   # 確定してから読む

Anthropic では、content_block_start の tool_use(id・name)から始まり、input_json_delta の partial_json を足して、content_block_stop で JSON として読みます。公式ページは、現在のモデルは input のキーと値を1組ずつ出すので、ツールを使うときはイベントの間に間が空くことがある、とも書いています。この間がハートビートの無い区間になるので、前の節のタイムアウトの話とつながります。途中の引数を画面に出したいときは、部分的な JSON を読めるライブラリか SDK のヘルパーを使う、というのが公式の案内です。

index を見ずに1つの文字列に足すと、並列で複数のツールが呼ばれたときに引数が混ざります。偽サーバーの試験では1つだけでしたが、実装は最初から index ごとに分けておきます。

よくあるエラー・つまずき(原文つき)

この節では、LLM のストリーミングの実装で出やすいエラーや症状と、その原因・直し方が分かります。原文はすべて手元で起こしたものです。

症状・原文原因直し方
最後に一気に表示される(最初のイベントが 1,310 ms)nginx の proxy_buffering が onX-Accel-Buffering: no か proxy_buffering off
curl: (18) transfer closed with outstanding read data remainingプロキシの読み込みタイムアウトで途中で切られたproxy_read_timeout を延ばし、コメント行のハートビート
upstream timed out (110: Connection timed out) while reading upstream上の症状の nginx 側のログ同上
httpx.ReadTimeout | timed outhttpx の既定のタイムアウト 5 秒を沈黙が超えたtimeout を明示し、ハートビート
日本語が「日���語」になるチャンクごとに decode し、UTF-8 の途中で割れたTextDecoder を使い回し stream: true
JSONDecodeError: Unterminated string …ツールの引数の断片を途中で JSON として読んだ確定の合図の後で読む
受け取った文字数 0・AssertionErrorAnthropic 形式で event: 行を省いたevent 名と type の両方を送る
APIStatusError(overloaded_error)ストリームの途中の error イベント届いた文字の扱いを決め、再試行は上限つき
AbortError: BodyStreamBuffer was abortedブラウザで AbortController を呼んだ(正常)停止ボタンの結果として扱う
JSONDecodeError Expecting value: line 1 column 2[DONE] を JSON として読んだ文字列のまま先に比べる
終わったのに同じ要求が何度も来るEventSource を close() していない終わりのイベントで close()

「[DONE] を JSON として読んで例外になる」もよくある形です。偽サーバーで試すと、Python では json.loads("[DONE]") が「JSONDecodeError Expecting value: line 1 column 2 (char 1)」になります。終わりの印は data の文字列のまま比べてから JSON に進みます。

判断表:どの受け方・どの設定を選ぶか

この節では、LLM のストリーミングを組み込むときの選択を、場面ごとにまとめて分かります。

場面選ぶもの理由
サーバーから LLM の API を呼ぶ公式 SDK のストリーミング形式の違い・エラー・最終メッセージの組み立てを任せられる
互換サーバーや独自の中継を作る自作のパーサー+公式 SDK で受ける試験event 名の有無など、SDK が読み飛ばす違いを見つけられる
ブラウザのチャット画面fetch + ReadableStreamPOST と認証ヘッダーが要る。停止ボタンは AbortController
通知のような GET で足りる配信EventSource自動再接続と Last-Event-ID が標準。終わりで close()
nginx・ロードバランサーの後ろX-Accel-Buffering: no とハートビートバッファとアイドルタイムアウトの両方を避ける
全体を検証してから使う出力ストリーミングしないTTFT は縮んでも、使えるようになる時刻は変わらない
双方向にやり取りする(音声など)WebSocket なども検討SSE はサーバーからの一方向

SSE と WebSocket の比較や WebSocket の実装は、関連記事「Node.js WebSocket実装|ws・再接続・認証・nginxまで」で扱っています。Java で SSE を返す場合は、関連記事「Spring Boot SSE|SseEmitterの使い方と再接続・nginx設定」で扱っています。AWS Lambda からストリーミングで返す場合は、関連記事「Lambda関数URLの使い方|認証・CORS・ストリーミング」で扱っています。MCP のリモートサーバーでの SSE の使われ方は、別記事「MCP Streamable HTTPとは|リモートサーバーの作り方と通信の中身」で通信の原文を載せています。

よくある質問

Q. ストリーミングにすると LLM の生成は速くなりますか?
生成そのものは速くなりません。手元の測定では全体の時間は stream: true が 1,732 ms、false が 1,681 ms でほぼ同じで、変わったのは最初の文字が見えるまでの時間(490 ms と 1,681 ms)です。

Q. SSE と WebSocket のどちらを使うべきですか?
LLM の応答を受けるだけなら SSE が素直です。ふつうの HTTP の応答なので、認証・ログ・プロキシの仕組みをそのまま使えます。音声のように双方向で常時やり取りするなら WebSocket なども候補になります。

Q. OpenAI 互換のサーバーなら [DONE] は必ず来ますか?
OpenAI の Chat Completions の公式の説明は [DONE] を前提にしています。互換をうたうほかの製品が同じかは、その製品の公式ドキュメントで確かめます。どちらにしても、終わりの印が来ないまま接続が閉じたら途中で切れたとみなす作りにしておくと安全です。

Q. Anthropic のストリームの ping は何に使いますか?
公式ページは、ping がストリームのどこにでも何回でも入り得るとだけ書いています。受け取ったら読み飛ばします。anthropic 2.4.0 の SDK のソースでも、ping は読み飛ばす作りでした。

Q. TTFT は何を測ればよいですか?
要求を送った時刻から、最初の「空でない本文の差分」を受け取った時刻までです。role だけのチャンクや ping は数えません。本番の経路(プロキシや CDN を通した状態)で測ると、バッファの問題も同時に見つかります。

Q. 利用者が停止を押したら、LLM の課金も止まりますか?
この記事で確かめた公式ページには書かれていなかったため、各社の料金の説明で確かめます。少なくとも、自分のサーバーが切断を検知して上流の接続を閉じなければ、生成は最後まで続きます(手元の実測で 87 トークン全部)。

Q. EventSource で Authorization ヘッダーを送れますか?
送れません。EventSource は GET で、独自のヘッダーを付けられません。Cookie での認証にするか、fetch の ReadableStream で受けます。トークンを URL のクエリに入れると、アクセスログなどに残る点に注意します。

Q. nginx の X-Accel-Buffering は、どこで付けますか?
上流のアプリが返す応答ヘッダーに付けます。nginx はこのヘッダーを見てその応答のバッファを切り、クライアントへは転送しません(手元の応答ヘッダーにも残っていませんでした)。proxy_ignore_headers で X-Accel-Buffering を無視する設定になっていると効きません。

Q. ストリーミング中にエラーが起きたら、HTTP のステータスコードはどうなりますか?
ヘッダーはもう 200 で送っているので変えられません。Anthropic は error イベントをストリームの中で送ります。自作のサーバーでも、エラーをイベントとして送り、クライアントはそれを受けたら失敗として扱います。

Q. 途中までの文章を保存しておくべきですか?
再接続や上流の切断に備えるなら、差分の番号つきで短い時間保存しておくと役に立ちます。Last-Event-ID で続きを送れるうえ、上流が切れたときに「どこまで届いたか」を使って続きを頼めます。

まとめ

  • LLM のストリーミングは、stream: true で返る SSE を「空行で1イベント」に区切って読み、差分をつなげる実装です。速くなるのは TTFT(手元で 1,681 ms → 490 ms)で、全体の時間は変わりません。
  • 終わりの印は API ごとに違います。Chat Completions は data: [DONE]、Responses は response.completed、Anthropic は message_stop。印が来ないまま閉じたら途中で切れたとみなします。
  • 生成はレスポンスのジェネレーターの中で行い、切断で止めます。別タスクに切り離すと、利用者が去った後も最後まで生成しました(11 トークンで停止と 87 トークン全部)。
  • nginx の既定のバッファで、最初の文字が 1,310 ms まで遅れ、2つの塊で届きました。X-Accel-Buffering: no か proxy_buffering off で直り、長い沈黙にはコメント行のハートビートで各段のタイムアウトを避けます。
  • ブラウザのチャットは fetch の ReadableStream と AbortController、EventSource を使うなら終わりで close() し、Last-Event-ID で続きから送れるようにします。ツールの引数は断片を足し、確定してから JSON として読みます。

参考・出典

確認日はいずれも 2026年11月4日です。

  • OpenAI API Reference「Create chat completion」(stream・stream_options・include_usage):https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
  • OpenAI API Reference「Chat Completions streaming events」(chat.completion.chunk):https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events
  • OpenAI「Streaming API responses」:https://developers.openai.com/api/docs/guides/streaming-responses
  • OpenAI API Reference「Responses streaming events」:https://developers.openai.com/api/reference/resources/responses/streaming-events
  • Anthropic「Streaming Messages」:https://platform.claude.com/docs/en/build-with-claude/streaming
  • MDN「Using server-sent events」:https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
  • nginx「Module ngx_http_proxy_module」(proxy_buffering・proxy_read_timeout・proxy_http_version・proxy_ignore_headers):https://nginx.org/en/docs/http/ngx_http_proxy_module.html
  • PyPI「openai」3.45.0・「anthropic」2.4.0・「httpx」0.28.1・「fastapi」0.162.0(各 SDK の _streaming.py を読んで挙動を確かめた):https://pypi.org/

LLM の API そのものの使い方は、関連記事「OpenAI API Python入門|Responses APIのコピペ集」と関連記事「Claude API Python入門|Anthropic SDKコピペレシピ集」で扱っています。ストリーミング中の 429・529 と再試行は、関連記事「LLM APIの429・529エラー対処|リトライ実装とエラー一覧」で扱っています。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「LLMのストリーミング実装|SSEの形式・TTFT・切断・nginxを実測」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり