マガジン

ストリーミング中に利用者が画面を閉じたら:LLM APIの中止・切断・途中保存の扱い

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

ノートPCを閉じるそばで、手が未完了の文章をノートに書き留めている静物。
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

ストリーミング応答の途中で利用者がタブを閉じても、LLM側の生成が自動的に止まるとは限りません。ブラウザと自社サーバーの間が切れただけで、自社サーバーからLLM APIへの接続は生きたままかもしれないからです。しかも途中まで届いた文章を保存していなければ、利用者が戻ってきたときに何も残りません。 先に答えを書きます。次の3点を、状況ごとに自分で決めておくのが実務上の答えです。 1. 止めるか続けるか:切断を自社サーバーで検知し、上流のAPI呼び出しを中止するのか、最後まで受け取るのか。 2. 何を保存するか:届いた分を「完了した回答」と区別できる状態付きで残す。 3. 課金をどう確かめるか:中止した分が請求にどう出るかは、読んだ公式資料に明記が見つからなかったので、自分のアカウントで一度測る。 この記事では、状況別の処理一覧、途中結果の保存方針テンプレート、実装の骨格、課金の確認手順を順に示します。確認日は2026-10-27で、公式資料から読み取れた範囲と、編集上の提案は分けて書きます。

この記事の目次
  1. 状況別の処理一覧
  2. 公式資料で確認できたこと
  3. 課金の確認点:自分のアカウントで測る手順
  4. 途中結果の保存方針テンプレート
  5. 実装の骨格
  6. リリース前の動作テスト

状況別の処理一覧

以下は編集上の提案です。「上流」は自社サーバーから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社のページからは判断できません。ここは推測で埋めず、次の手順で自社環境の利用量を照合します。

課金の確認点:自分のアカウントで測る手順

以下は手順の提案で、結果はまだ得ていません。

  1. 出力が長くなる固定のプロンプトを用意します。説明用の例:「300字ずつ10段落で、架空の商店街の歴史を書いてください」。機密情報は入れません。
  2. 同じ設定で、(a) 最後まで受け取る、(b) 数秒で上流の接続を中止する、の2通りを複数回実行します。
  3. 各回のリクエストID(Claudeならrequest-id、他社は応答のIDなど)を控え、受信した文字数も記録します。
  4. 管理画面や利用量の出力で、入力・出力トークンを(a)と(b)で比べます。集計対象や反映に時間差があり得るため、提供元の利用量記録の定義と更新間隔を確認し、同じ条件で複数回照合します。
  5. 結果は観測した利用量として記録します。これだけで実際の請求額や「切断時に課金が止まる位置」を確定できるとは限らないため、請求明細・提供元の料金規則とも照合してください。

あわせて、中止時にSDKが接続を閉じているか、プロキシやCDNが上流の接続を保持していないかも点検します。ブラウザが閉じても、途中のプロキシが上流を読み続ける構成はあり得ます。

途中結果の保存方針テンプレート

次をそのまま社内文書やREADMEに貼り、< >の部分を埋めてください。

yaml
# 途中結果の保存方針(説明用テンプレート)
対象機能: <例:回答生成チャット>
切断時の既定: 上流を中止する   # 続行する場合は理由と上限時間を書く
状態の種類:
  - 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で中止できる前提です。

ts
// 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後のエラーイベントでも状態が確定する
  • 途中で切れた回答の次の質問 → 履歴の扱いが方針どおりになる

課金の照合は、先の手順で観測した利用量を方針テンプレートの「課金照合」欄に書き戻し、提供元の記録の定義・更新間隔と請求明細に照らして定期的に確認します。頻度は利用量の反映・請求サイクルに合わせて決めてください。

出典・参考資料