ストリーミングの失敗は、ほとんどの場合モデルが原因ではなく、あなたとモデルの間にある通信経路が原因です。アイドルタイムアウトのあるプロキシは静かな接続を終了させ、nginxのバッファリングはイベントを飲み込み、途中までしか消費されないストリームは「APIが応答を停止した」ように見えます。まず通信経路を確認してください。
エラー
- Stream stops mid-sentence, connection closed (no error event)
- Client hangs after the last token, never sees [DONE]
- usage is null on streamed responses
- Works in curl, dies behind nginx / a corporate proxy原因と対処法の概要
| 原因 | 対処法 |
|---|---|
| プロキシ/ロードバランサーのアイドルタイムアウト(多くの構成でデフォルト60s) | API経路の読み取りタイムアウトを延長してください。長い思考の一時停止は、プロキシにはアイドル状態に見えます。 |
| SSEの前段でのバッファリング(nginx proxy_buffering、一部のCDN) | ストリーミング経路のバッファリングを無効にしてください(X-Accel-Buffering: no / proxy_buffering off)。 |
| クライアントが消費を停止する(awaitの欠落、イテレーターの破棄) | 最後まで消費するか、明示的に閉じてください。ストリーム途中でGCされたイテレーターは、途中終了と区別できません。 |
| 要求せずに使用量を期待している | OpenAI-wireでは、stream_options: {"include_usage": true}を渡してください。使用量は最後のチャンクで届きます。 |
curl -NでAPIに直接接続して再現する
すべてのプロキシを迂回します。生のSSEが生成全体にわたって正常に流れるなら、問題はアプリの経路にあります。中継点を1つずつ戻してください:
curl -N https://api.kunavo.com/v1/chat/completions \
-H "Authorization: Bearer $KUNAVO_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-5","stream":true,
"stream_options":{"include_usage":true},
"max_tokens":300,
"messages":[{"role":"user","content":"Count slowly to 20 in words."}]}'壊れている経路を修正する
nginx:経路に対してproxy_buffering off + proxy_read_timeout 300sを設定します。サーバーレス:プラットフォームのレスポンスストリーミング制限を確認します。企業プロキシ:SSE自体に対応できないものもあります。その場合は非ストリーミングに切り替えてください。
末尾を正しく処理する
ストリーミングされた請求データは最後に届きます。要求した場合、最後のチャンクには[DONE]の前に使用量が含まれます。差分を集計し、最後のチャンクから使用量を読み取り、早期切断(finish_reasonなし)を再試行可能として扱ってください。
「SSEストリームが[DONE]なしで終了しました」— 回答は完全ですか?
このメッセージは、APIが送信したエラーではなく、クライアント独自のチェックです。OpenAI互換ストリームが終了時に送るdata: [DONE]行の前に接続が閉じられました。原因は3つあります。途中の経路が接続を閉じた(上記のプロキシタイムアウトやバッファリング)、サーバーが回答途中で失敗し終端フレームなしに閉じた、または回答は完成していたもののセンチネルだけが失われた、のいずれかです。どれに該当するかは、受信した最後のチャンクで判断できます。そこにfinish_reasonがあればテキストは完全で、finish_reasonがなければ途中で切れているため再試行すべきです。このチェックをSDK任せにしないでください。OpenAI Python SDKは、[DONE]なしで接続が閉じるとループを静かに終了し、チャンクにエラーオブジェクトが含まれる場合にのみ例外を発生させます。独自コードにチェックを追加してください:
from openai import OpenAI
client = OpenAI(base_url="https://api.kunavo.com/v1", api_key="sk-kn-...")
finish_reason, parts = None, []
stream = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": "Count slowly to 20 in words."}],
stream=True,
)
for chunk in stream: # raises openai.APIError on a chunk that carries "error"
for choice in chunk.choices:
parts.append(choice.delta.content or "")
finish_reason = choice.finish_reason or finish_reason
if finish_reason is None:
raise RuntimeError("stream closed without a finish_reason: cut off, retry it")
print("".join(parts))空のストリーム:200の後、何も届かない
ストリームがHTTP 200で開いた後、コンテンツチャンクを1つも送らずに終了することがあります。テキストもツール呼び出しもなく、場合によってはroleチャンクすらありません。これはほぼ常に、ヘッダー送信後に上流で失敗したことを意味します。過負荷状態のプロバイダ、独自の上流がリクエストを拒否したゲートウェイ、または本文を破棄したプロキシなどです。途中終了したストリームと同様に扱い、バックオフを入れて再試行してください。また、失敗したレスポンスの生の本文を1件記録してください。原因は、SDKが読み飛ばしたストリーム内のエラーイベントであることが多いためです。Claude Codeは同じ状態に対して、ストリーミングなしでリクエストを再試行します。
Kunavo経由で呼び出している場合
Kunavoは、/v1/messagesで標準のOpenAI-wire SSE(stream_options.include_usageに対応)とAnthropic-wireイベントをストリーミングします。そのため、上記のcurlによる再現は互換性テストにもなります。/v1/chat/completionsのClaudeおよびGPTモデルでは、回答が完了したかどうかにかかわらず、Kunavoのストリームはdata: [DONE]で終了します。回答が完了した場合はfinish_reasonチャンクの後、上流が回答途中で切断した場合は、type upstream_error、code upstream_disconnectまたはupstream_timeoutのエラーチャンクの後に終了します。そのため、OpenAI SDKはAPIErrorを発生させ、断片を完全な返信として返しません。コンテンツが届く前に終了する上流ストリーム、または最初のトークン前に失敗するストリームは、空の200として届きません。モデルに別のチャネルがある場合はそちらで再試行され、それ以外の場合はHTTPエラーとして返されます。出力前に失敗したストリーミングリクエストは課金されませんが、回答途中で途切れたリクエストが課金されないとは限りません。無料だと決めつけず、もう一度送信してください。
よくある質問
ストリーミングされた応答でusageがnullなのはなぜですか?
OpenAI-wire APIでは、stream_options: {"include_usage": true}を渡さない限り、ストリームにusageは含まれません。渡すと最後のチャンクで届きます。Anthropicのネイティブストリームでは、message_start/message_deltaイベントで使用量が報告されます。
ストリームが途中終了したのか、完了したのかをどう検出できますか?
完了したストリームはfinish_reason(またはAnthropicのmessage_stop)で終了し、その後に[DONE]が続きます。これらのマーカーなしに接続が閉じた場合は途中終了です。短い回答ではなく、再試行可能な失敗として扱ってください。
「SSEストリームが[DONE]なしで終了しました」とはどういう意味ですか?
クライアントが接続の終端までストリームを読み取ったものの、OpenAI互換ストリームを閉じるdata: [DONE]行を見つけなかったという意味です。APIがそのメッセージを送ったのではなく、クライアントまたはエージェントが出力したものです。最後のチャンクにfinish_reasonがあれば、回答は完全で、切断だけが失われています。なければ、プロキシ、タイムアウト、サーバー側の失敗によって回答が途中で切れているため、リクエストを再試行してください。
「ストリームがfinish_reasonなしで終了しました」とはどういう意味ですか?
クライアントがストリームの終端まで読み取ったものの、finish_reasonを含むチャンクがなかったという意味です。finish_reasonは、OpenAI互換ストリームが回答の完了(stop、length、tool_calls)を示すために使用するフィールドです。これがなければ、最後の文が自然に見えても、手元のテキストは断片です。リクエストを再試行してください。繰り返し発生する場合は、APIとの間にあるプロキシのタイムアウトやバッファリングを確認してください。
LLM APIが空のストリームを返すのはなぜですか?
HTTPヘッダーの送信後に失敗が発生したためです。プロバイダが過負荷だった、ゲートウェイの上流がリクエストを拒否した、またはプロキシが本文を破棄した可能性があります。ステータス行は200のままなので、ステータスチェックでは検出できません。コンテンツチャンクのないストリームは失敗したリクエストとして扱い、バックオフを入れて再試行し、内部のエラーイベントを見つけるために生のレスポンスを1件記録してください。
すでにテキストを受け取っている場合、[DONE]の欠落を無視しても安全ですか?
最後のチャンクにfinish_reasonが含まれている場合に限ります。含まれていなければ、手元のテキストは文の途中やツール呼び出しの途中で終わっている可能性があり、JSON引数も不完全です。回答として保存せず、再試行してください。