GEMINI LABEN
SUNSET — 画像生成モデルの停止まで残り6日です。imagen-4.0 系と Gemini 3 Image 系が8月17日に止まりますMIGRATE — 移行先として gemini-3.1-flash-image が案内されています。generate_images から generate_content への書き換えが必要ですCHECK — 移行後に同じプロンプトで同じ絵が出る保証はありません。必要な生成物は停止前に確保しておく判断が要りますCLASSROOM — 8月17日は Gemini in Classroom のモバイル提供が始まる日でもあります。web は8月10日に全年齢の学生へ広がりましたDEPRECATION — Grok 4.1 ファミリーの停止は8月20日、gemini-robotics-er-1.6-preview は8月31日で、後継は er-2 系ですCHANGELOG — Gemini API の changelog は7月30日が最新のままです。直近の大きな変更は Gemini 3.6 Flash と 3.5 Flash-Lite の GA ですSUNSET — 画像生成モデルの停止まで残り6日です。imagen-4.0 系と Gemini 3 Image 系が8月17日に止まりますMIGRATE — 移行先として gemini-3.1-flash-image が案内されています。generate_images から generate_content への書き換えが必要ですCHECK — 移行後に同じプロンプトで同じ絵が出る保証はありません。必要な生成物は停止前に確保しておく判断が要りますCLASSROOM — 8月17日は Gemini in Classroom のモバイル提供が始まる日でもあります。web は8月10日に全年齢の学生へ広がりましたDEPRECATION — Grok 4.1 ファミリーの停止は8月20日、gemini-robotics-er-1.6-preview は8月31日で、後継は er-2 系ですCHANGELOG — Gemini API の changelog は7月30日が最新のままです。直近の大きな変更は Gemini 3.6 Flash と 3.5 Flash-Lite の GA です
記事一覧/API / SDK
API / SDK/2026-04-27中級

Gemini API のストリーミング応答をユーザー操作で中止する — AbortController と asyncio の正しい使い方

チャットUIの『停止』ボタンを押したのに Gemini API のストリーミングが裏で動き続けている — そんな見落としを防ぐための、AbortController と asyncio.CancelledError を使った安全な中止処理の実装方法をまとめます。

Gemini API207ストリーミング12AbortControllerPython38TypeScript8チャットUI

チャットUIに「停止」ボタンを置いてみたものの、押した瞬間に画面の文字は止まっても、サーバー側ではトークンが流れ続けている — そんな実装を本番で見つけることがあります。私自身、最初に Gemini API でチャットアプリを書いたときに同じ落とし穴にはまりました。表示は止まっても課金は続くわけで、ユーザー体験としても、コストの観点でも、後から直したくなるポイントです。

ユーザー操作によるストリーミング応答の中止を、TypeScript(フロントエンド/Node.js)と Python(FastAPI)の両方で正しく実装する方法を実例とともに整理しました。SDK の挙動を踏まえた、現場で動くパターンに絞っています。

中止が「効いていない」典型的な状態

まず、よくある見落としから整理します。次のコードは for await...of で受け取ったチャンクを画面に出すだけのシンプルな例です。

// アンチパターン: 中止できない実装
const stream = await ai.models.generateContentStream({
  model: "gemini-2.5-flash",
  contents: prompt,
});
 
for await (const chunk of stream) {
  ui.append(chunk.text);
}

このコードには中止する手段がありません。ボタンを押しても、for await のループが自然終了するまで HTTP コネクションは閉じませんし、サーバー側はトークン生成を続けます。生成し終わるまでの数秒〜十数秒、課金は止まりません。

中止を機能させるには、(1) HTTP リクエスト自体を打ち切る、(2) ループから即座に抜ける、(3) 後始末(途中まで書いた DB レコードや UI 状態)を行う、の三つを揃える必要があります。

TypeScript / Node.js — AbortController で打ち切る

@google/genai SDK は AbortSignal を受け取れます。React のチャットUIから使う場合は、コンポーネントのライフサイクルと連動させるのがコツです。

// app/api/chat/route.ts (Next.js App Router)
import { GoogleGenAI } from "@google/genai";
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
 
export async function POST(req: Request) {
  const { prompt } = await req.json();
 
  // クライアントが切断したときに発火する AbortSignal
  const signal = req.signal;
 
  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(controller) {
      try {
        const stream = await ai.models.generateContentStream({
          model: "gemini-2.5-flash",
          contents: prompt,
          // ★ ここで signal を渡すのが肝心
          config: { abortSignal: signal },
        });
 
        for await (const chunk of stream) {
          if (signal.aborted) break;
          controller.enqueue(encoder.encode(chunk.text ?? ""));
        }
      } catch (err) {
        // signal.aborted 起因のエラーは正常終了として扱う
        if (!signal.aborted) controller.error(err);
      } finally {
        controller.close();
      }
    },
  });
 
  return new Response(body, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

ポイントは req.signal を SDK に渡すこと、そしてループ内でも signal.aborted を見ることです。SDK バージョンによっては abortSignal を渡しても内部のフェッチが完全には止まらない過渡期があったため、二重に守ると安心です。期待する出力としては、ユーザーが停止ボタンを押した瞬間に Network タブのリクエストが (canceled) になり、Cloud Logging 側でも該当リクエストの token usage が打ち切り時点までで確定します。

クライアント側はもっとシンプルで、fetchAbortController.signal を渡すだけです。

// クライアント (React)
const controllerRef = useRef<AbortController | null>(null);
 
const send = async (prompt: string) => {
  controllerRef.current = new AbortController();
  const res = await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ prompt }),
    signal: controllerRef.current.signal,
  });
  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));
  }
};
 
const stop = () => controllerRef.current?.abort();

fetch の中断はサーバー側の req.signal.abortedtrue にするので、API ルートのループも自然に抜けます。停止ボタンと、コンポーネントのアンマウント時 (useEffect のクリーンアップ) の両方で abort() を呼ぶようにしておくと、ページ遷移時のリーク防止にもなります。

Python / FastAPI — request.is_disconnected() と CancelledError

Python SDK でも考え方は同じですが、AbortController の代わりに Request.is_disconnected()asyncio.CancelledError を組み合わせます。

# main.py (FastAPI)
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from google import genai
 
app = FastAPI()
client = genai.Client()
 
async def stream_response(prompt: str, request: Request):
    stream = await client.aio.models.generate_content_stream(
        model="gemini-2.5-flash",
        contents=prompt,
    )
    try:
        async for chunk in stream:
            # クライアント切断を毎チャンクで確認する
            if await request.is_disconnected():
                break
            yield (chunk.text or "").encode("utf-8")
    except asyncio.CancelledError:
        # FastAPI が disconnect を検知して例外を投げてくることもある
        # ここでは追加処理せず、上位に再 raise しない(黙って終わる)
        pass
    finally:
        # SDK は async with で閉じることを推奨。明示的に閉じる API があれば呼ぶ
        await stream.aclose() if hasattr(stream, "aclose") else None
 
@app.post("/chat")
async def chat(prompt: str, request: Request):
    return StreamingResponse(
        stream_response(prompt, request),
        media_type="text/plain; charset=utf-8",
    )

期待される動作は、ブラウザ側で AbortController.abort() を呼んだ瞬間に、FastAPI 側の request.is_disconnected()True を返し、async for を抜けて finally でストリームを閉じる、というものです。asyncio.CancelledError は黙って受け止めるのが基本です — 上位に再 raise すると ASGI ミドルウェアが 500 を返してしまうことがあり、ログが汚れます。

なお、Cloud Run などプロキシ越しにデプロイする場合は、リクエストの切断検知に最大 1〜2 秒のラグが出ることがあります。チャンクごとの is_disconnected() チェックでは、ループ内の他の処理(DB 書き込みなど)にもタイムアウトを設けておくと、より早く中止できます。

中止後の後始末 — 途中まで書いたものをどう扱うか

中止処理で意外と漏れるのが、途中まで蓄積したテキストの扱いです。チャットアプリでは「停止までに表示した内容をそのまま会話履歴として残すか、破棄するか」をプロダクト判断として決めておく必要があります。

私のおすすめは、中断時点までの応答に [中断されました] のサフィックスを付けて履歴に保存する設計です。理由は二つあります。

第一に、ユーザーは「途中で止めた」という事実を後から見返したいことが多く、完全に消してしまうと「あのとき何を聞いたんだっけ」が失われます。第二に、課金は中断時点までで発生しているので、何かしら残しておかないと「お金だけ取られた」感覚が残ってしまいます。

// 中断時の履歴保存(クライアント)
try {
  await streamToUI(prompt, controllerRef.current.signal);
} catch (err) {
  if ((err as DOMException).name === "AbortError") {
    saveHistory({ prompt, response: bufferRef.current + "\n\n[中断されました]" });
    return;
  }
  throw err;
}
saveHistory({ prompt, response: bufferRef.current });

サーバー側の DB 書き込みは、ストリームが完了してから一括で書くのではなく、一定間隔(例: 500ms ごと)でバッファをフラッシュする設計にすると、中断後の状態が一貫します。リトライ処理を別途実装しているなら、Gemini API のエラーハンドリング・リトライ実装パターンも合わせて参照してみてください。

ストリーミング全般の制御を学びたい方へ

中止だけでなく、チャンクの組み立て・接続断からの自動復帰・UX 最適化まで含めて深掘りしたい場合は、Gemini API のストリーミングレスポンスを完全制御する が実装パターンを網羅しています。React のチャットUIを一から組み立てる流れは Gemini API で React チャット UI を作る実践ガイド でフロー全体を追えます。

書籍で体系立てて

まずは今日できる一歩

もし手元のチャットアプリにまだ「停止」ボタンが無い、あるいはあっても AbortController を渡していない実装になっているなら、今日のうちにクライアント側の fetchsignal を渡す変更だけでも入れてみてください。これだけでサーバーへの不要なトークン生成が大幅に減り、月末のコストレポートで体感できるはずです。サーバー側の is_disconnected() チェック、後始末用の履歴保存は、その後で順に積み上げていけば十分です。

無料で読める Gemini API の基本機能解説と並行して、本サイトでは個人開発者向けの実装ノウハウを地道に書き溜めています。「もう少し深い話を読みたい」と感じた方は、メンバーシップでより踏み込んだ内容にも触れていただけたら嬉しいです。

シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API / SDK2026-06-11
Gemini 3.2 API 実装ガイド — 正しいモデルID・3.1 からの移行と本番チェックポイント
Gemini 3.2 を API から呼び出すための正しいモデルID、Gemini 3.1 からの移行時の変更点、Python・TypeScript の実装例、本番環境への移行チェックリストをまとめました。
API / SDK2026-03-11
Gemini API クイックスタート — Python/TypeScriptで始める
Gemini APIをPythonとTypeScriptで実装する方法を詳しく解説。SDKのインストールから応答処理まで、実装に必要な全知識をカバーします。
API / SDK2026-08-07
ストリーミング応答の日本語が静かに欠ける — バイト境界とイベント境界を実測で切り分ける
ストリーミング表示の日本語だけが欠ける現象を、ローカルの疑似SSEサーバで実測しました。壊れていた層は二つあり、片方は例外にすらならず、英語のテストでは検出率がゼロでした。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →