ストリーミング中に利用者が画面を閉じたら:LLM APIの中止・切断・途中保存の扱い
AIを使いこなすClaudeで執筆し、GPTで自動校閲・修正しています。

ストリーミング応答の途中で利用者がタブを閉じても、LLM側の生成が自動的に止まるとは限りません。ブラウザと自社サーバーの間が切れただけで、自社サーバーからLLM APIへの接続は生きたままかもしれないからです。しかも途中まで届いた文章を保存していなければ、利用者が戻ってきたときに何も残りません。 先に答えを書きます。次の3点を、状況ごとに自分で決めておくのが実務上の答えです。 1. 止めるか続けるか:切断を自社サーバーで検知し、上流のAPI呼び出しを中止するのか、最後まで受け取るのか。 2. 何を保存するか:届いた分を「完了した回答」と区別できる状態付きで残す。 3. 課金をどう確かめるか:中止した分が請求にどう出るかは、読んだ公式資料に明記が見つからなかったので、自分のアカウントで一度測る。 この記事では、状況別の処理一覧、途中結果の保存方針テンプレート、実装の骨格、課金の確認手順を順に示します。確認日は2026-10-27で、公式資料から読み取れた範囲と、編集上の提案は分けて書きます。
状況別の処理一覧
以下は編集上の提案です。「上流」は自社サーバーからLLM APIへの接続を指します。
- A. タブを閉じた/ネットワーク断、検知の手がかり:自社サーバーで下流の接続クローズ、上流の扱い:既定は中止。続けるのは「戻ってきて続きを読ませる」設計のときだけ、保存する状態:partial_disconnect、利用者への見せ方:再訪時に「途中まで」と表示
- B. 利用者が停止ボタンを押した、検知の手がかり:明示のキャンセルAPI呼び出し、上流の扱い:中止、保存する状態:partial_user_stop、利用者への見せ方:停止位置までを表示し、再生成ボタンを出す
- C. 上流の通信断、検知の手がかり:読み取り中の例外、タイムアウト、上流の扱い:再試行するか、そこで打ち切るかを方針で決める、保存する状態:partial_error、利用者への見せ方:「通信が途切れました」と途中分を併記
- D. 200返却後のエラーイベント、検知の手がかり:ストリーム内のerrorイベント、上流の扱い:接続を閉じる、保存する状態:partial_error(コードを記録)、利用者への見せ方:Cと同じ
- E. 上限到達(出力トークン上限など)、検知の手がかり:終了理由の値、上流の扱い:正常終了扱い、保存する状態:complete_truncated、利用者への見せ方:「長さの上限で終了」と表示
- F. 自社サーバーの再起動・デプロイ、検知の手がかり:起動時にstreamingのまま残った行、上流の扱い:上流は既に切れている、保存する状態:partial_errorへ更新、利用者への見せ方:再生成を促す
Fは見落としやすい点です。保存の途中でstreaming状態の行が残るので、起動時や定期ジョブで「更新が一定時間止まっている行」をpartial_errorに直す掃除が要ります。
公式資料で確認できたこと
提供元ごとに、読んで確認できた範囲だけ書きます。
Anthropic(Claude API):エラーのページに、SSEでストリーミング中はHTTP 200を返した後でもエラーが起こり得て、通常のエラー処理とは別の扱いになる、とあります。つまりHTTPステータスだけで成否を判断できません。レスポンスにはrequest-idヘッダーがあり、問い合わせ時に使えるとされています。長時間のリクエストでは、アイドル接続が途中で切られる可能性があるためストリーミングかBatchを使うよう勧められています。また、最新のアシスタントメッセージに含まれるthinkingブロックを加工して送り返すと400になる、との記載もあります。途中で切れたターンを履歴に戻す設計にするなら、加工せずそのまま扱えるか確認してください。
OpenAI:バックグラウンドモードの解説によると、backgroundとstreamを両方trueにして作成すると、クライアントが切断されても処理は継続し、後で再接続できます。各イベントにsequence_numberが付き、starting_afterで再開位置を指定できます。ただしキープアライブイベントは再開位置に含まれません。データは非同期実行のために約10分保持され、ゼロデータ保持(ZDR)対象のプロジェクトではstore=falseで動く、との記載です。同ページのキャンセルAPIの説明では、処理中のバックグラウンドレスポンスをキャンセルできます。通常のストリーミングの解説ページには、キャンセルや課金の記述は見当たりませんでした。
Google(Gemini API):エラー一覧に、cancelled(499)は「完了前にクライアントがキャンセルした」、deadline_exceeded(504)は「期限内に終わらなかった」とあります。ストリーミングでは、エラーがSSE上のevent_typeがerrorのイベントとして届くとされています。課金のページ(ページ上の更新日は2026-09-28 UTC)には、400や500で失敗したリクエストはトークン分を請求されないがクォータには数えられる、とあります。キャンセル・タイムアウト・切断時の課金は、読んだ範囲に記載がありませんでした。
整理すると、「切断後も生成を続けたい」場合はOpenAIのバックグラウンドモードのように専用の仕組みがあります。一方、通常のストリーミングで切断や中止の後に生成がどこで止まるか、課金対象がどうなるかは、確認した3社のページからは判断できません。ここは推測で埋めず、次の手順で自社環境の利用量を照合します。
課金の確認点:自分のアカウントで測る手順
以下は手順の提案で、結果はまだ得ていません。
- 出力が長くなる固定のプロンプトを用意します。説明用の例:「300字ずつ10段落で、架空の商店街の歴史を書いてください」。機密情報は入れません。
- 同じ設定で、(a) 最後まで受け取る、(b) 数秒で上流の接続を中止する、の2通りを複数回実行します。
- 各回のリクエストID(Claudeならrequest-id、他社は応答のIDなど)を控え、受信した文字数も記録します。
- 管理画面や利用量の出力で、入力・出力トークンを(a)と(b)で比べます。集計対象や反映に時間差があり得るため、提供元の利用量記録の定義と更新間隔を確認し、同じ条件で複数回照合します。
- 結果は観測した利用量として記録します。これだけで実際の請求額や「切断時に課金が止まる位置」を確定できるとは限らないため、請求明細・提供元の料金規則とも照合してください。
あわせて、中止時にSDKが接続を閉じているか、プロキシやCDNが上流の接続を保持していないかも点検します。ブラウザが閉じても、途中のプロキシが上流を読み続ける構成はあり得ます。
途中結果の保存方針テンプレート
次をそのまま社内文書やREADMEに貼り、< >の部分を埋めてください。
# 途中結果の保存方針(説明用テンプレート)
対象機能: <例:回答生成チャット>
切断時の既定: 上流を中止する # 続行する場合は理由と上限時間を書く
状態の種類:
- streaming # 生成中
- complete # 正常終了
- complete_truncated # 長さ上限などで終了
- partial_user_stop # 利用者が停止
- partial_disconnect # 下流の切断
- partial_error # 上流の通信断・エラーイベント・サーバー再起動
保存タイミング: <N文字ごと/N秒ごと/終了時> のいずれか早い方
保存する項目:
- メッセージID, 利用者ID, 状態
- 本文(受信済みの分)
- 終了理由 / エラーコード
- 上流のリクエストID
- 入出力トークン数(取得できた場合。なければnull。請求額とは区別して記録)
会話履歴への戻し方:
- partial_* は次の呼び出しの履歴に <含める/含めない/注記付きで含める>
- ツール呼び出しの途中で切れた場合は、閉じていない呼び出しを破棄する
表示: partial_* には「途中まで」の表示と再生成ボタンを付ける
保持期間: 通常のメッセージと同じ(<日数>)
掃除: 更新が <分> 以上止まった streaming 行を partial_error に更新
課金照合: 週次で、保存したトークン数と提供元の利用量を比較判断のポイントは「会話履歴への戻し方」です。途中で切れた文章をそのまま次の入力に含めると、モデルが未完の文を完成した回答として扱う恐れがあります。既定は「含めない」か「途中で終わった旨の注記を付けて含める」にし、画面の表示用には残す、という分け方が安全です。
実装の骨格
説明用の擬似的なコードで、実行・検証はしていません。streamLLMは各社SDKをラップした自作関数を想定した仮の名前で、signalで中止できる前提です。
// Express + Node想定。streamLLM と db は仮の実装。
app.post("/chat", async (req, res) => {
const ac = new AbortController();
const id = await db.create({ status: "streaming", text: "" });
let text = "", status = "partial_error", reason = "";
let requestId: string | null = null;
let usage: unknown = null;
let lastSave = Date.now();
res.on("close", () => { // 下流が切れた
if (!res.writableEnded) { status = "partial_disconnect"; ac.abort(); }
});
req.on("aborted", () => ac.abort());
res.setHeader("Content-Type", "text/event-stream");
try {
for await (const ev of streamLLM({ prompt: req.body.prompt, signal: ac.signal })) {
if (ev.type === "text") {
text += ev.delta;
res.write(`data: ${JSON.stringify(ev.delta)}\n\n`);
if (Date.now() - lastSave > 2000) { // 定期保存
await db.update(id, { text }); lastSave = Date.now();
}
} else if (ev.type === "done") {
status = ev.truncated ? "complete_truncated" : "complete";
reason = ev.stopReason; usage = ev.usage; requestId = ev.requestId;
} else if (ev.type === "error") { // 200後のエラーイベント
status = "partial_error"; reason = ev.code; break;
}
}
} catch (e) {
if (ac.signal.aborted && status !== "partial_disconnect") status = "partial_user_stop";
else if (!ac.signal.aborted) { status = "partial_error"; reason = String(e); }
} finally {
await db.update(id, { text, status, reason, requestId, usage });
if (!res.writableEnded) res.end();
}
});
app.post("/chat/:id/stop", async (req, res) => {
stopRegistry.get(req.params.id)?.abort(); // 停止ボタン用。登録処理は省略
res.sendStatus(204);
});注意点が3つあります。
- finallyで必ず保存します。切断の経路が複数あっても、最後に1か所で状態を確定すれば保存漏れが減ります。
- 停止ボタンは別のHTTPリクエストになります。生成中のAbortControllerを、メッセージIDで引ける場所に登録しておく必要があります(例では省略)。複数台構成では、停止要求が別のサーバーに届いても止められる設計が要ります。
- 定期保存の間隔は、書き込み負荷との兼ね合いで決めてください。2秒は例です。
リリース前の動作テスト
次の6ケースを、ステージング環境で手動確認することをおすすめします。
- 生成中にタブを閉じる → 上流が止まり、行がpartial_disconnectになる
- 停止ボタンを押す → partial_user_stopになり、再生成できる
- 開発者ツールで回線を切る → 画面に途中分と再試行の導線が出る
- サーバープロセスを強制終了する → 再起動後の掃除でpartial_errorに直る
- 上流のエラーを模したモックを返す → 200後のエラーイベントでも状態が確定する
- 途中で切れた回答の次の質問 → 履歴の扱いが方針どおりになる
課金の照合は、先の手順で観測した利用量を方針テンプレートの「課金照合」欄に書き戻し、提供元の記録の定義・更新間隔と請求明細に照らして定期的に確認します。頻度は利用量の反映・請求サイクルに合わせて決めてください。

