Gemini API でチャットボットを運用していると、あるタイミングで「先週まで動いていたのに、特定のユーザーだけ応答が返ってこなくなった」という現象に出会うことがあります。ログを掘ると Request contains too many tokens が並んでいて、原因が会話履歴の肥大化だと分かります。
私は個人プロダクトでも Discord ボットでも同じ壁にぶつかってきました。会話履歴を全部送るシンプルな実装は最初の数十ターンまでは美しく動くのですが、ヘビーユーザーが現れた瞬間に料金もレイテンシも崩れていきます。ここではローリングサマリ(rolling summary)と呼ばれる古典的だけれど効果の高い手法を、Gemini API 向けに実装した形でご紹介します。
なぜ単純なスライディングウィンドウでは足りないのか
最初に思いつく対策は「直近 N 件だけ送る」というスライディングウィンドウ方式です。実装は簡単ですが、ユーザーから見ると「さっき自分が言った名前を覚えていない」という致命的な体験を生みます。N=10 で切ると、11 ターン前に伝えた前提条件は完全に消えてしまうわけです。
ローリングサマリは、この問題を「古い会話は要約して残す、新しい会話は逐語で残す」という二段構えで解決します。要約は固定長に保てるので、トークン総量がほぼ一定値で頭打ちになります。
アーキテクチャの全体像
実装は3つの関数で十分です。
count_tokens(history): 会話履歴の現在のトークン数を取得します
should_compress(history, threshold): 圧縮すべきかどうかを判定します
compress(history, keep_recent): 古い部分を要約に置き換え、直近 N 件は逐語で残します
ポイントは「いつ圧縮するか」と「何を要約に含めるか」です。閾値はモデルの上限ではなく コスト基準 で決めます。Gemini 2.5 Pro のコンテキストウィンドウは 100 万トークンありますが、毎リクエストで数十万トークンを送り続けるのは料金的に持ちません。
実装:Python での最小構成
以下のコードはそのまま動かせる構成にしてあります。google-genai SDK を使い、要約も Gemini 自身に任せています。
# pip install google-genai==1.* tiktoken-style
from google import genai
from google.genai import types
import os
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
MODEL = "gemini-2.5-flash" # 要約は安くて速い Flash で十分
# 圧縮を発動するトークン閾値(コスト基準で決める)
COMPRESS_THRESHOLD = 8000
# 逐語で残す直近メッセージ数(ユーザー+モデルで2の倍数が扱いやすい)
KEEP_RECENT = 6
def count_tokens (history: list[types.Content]) -> int :
"""会話履歴の合計トークン数を返します。"""
resp = client.models.count_tokens( model = MODEL , contents = history)
return resp.total_tokens
def summarize (messages: list[types.Content]) -> str :
"""古い会話部分を1段落の要約に変換します。"""
# 要約専用プロンプト。「事実」「前提」「未解決の質問」を必ず保持させる
transcript = " \n " .join(
f " { m.role } : { m.parts[ 0 ].text } " for m in messages if m.parts
)
instruction = (
"次の会話を、後続のチャットで失ってはいけない情報に絞って5〜8行で要約してください。"
"ユーザーが共有した固有名詞・数値・好み・未解決の質問は必ず保持してください。"
"会話の雰囲気や挨拶は省略して構いません。 \n\n "
f "--- \n{ transcript }\n ---"
)
resp = client.models.generate_content(
model = MODEL ,
contents = instruction,
config = types.GenerateContentConfig( temperature = 0.2 ),
)
return resp.text
def compress (history: list[types.Content]) -> list[types.Content]:
"""履歴を「要約 + 直近 KEEP_RECENT 件」に圧縮します。"""
if len (history) <= KEEP_RECENT :
return history
old, recent = history[: - KEEP_RECENT ], history[ - KEEP_RECENT :]
summary = summarize(old)
summary_msg = types.Content(
role = "user" ,
parts = [types.Part.from_text( text = f "[これまでの会話の要約] \n{ summary } " )],
)
return [summary_msg] + recent
期待する出力は、compress() を通したあとの履歴がおおむね 1,500〜2,500 トークンに収まることです。これで何百ターン続くセッションでも、毎リクエストの入力コストが安定します。
いつ圧縮を発動するか
毎ターン圧縮するのは無駄です。要約自体にトークンを消費するためです。私は次のようなループで運用しています。
def chat_turn (history, user_message):
history.append(types.Content(
role = "user" ,
parts = [types.Part.from_text( text = user_message)],
))
if count_tokens(history) > COMPRESS_THRESHOLD :
history = compress(history)
resp = client.models.generate_content( model = "gemini-2.5-pro" , contents = history)
history.append(resp.candidates[ 0 ].content)
return history, resp.text
COMPRESS_THRESHOLD を一度だけ越えたタイミングで圧縮が走り、その後はしばらく逐語追記で進み、また閾値を越えたら再圧縮、という流れになります。要約コストは 8,000 トークン処理で Flash なら 1 円未満です。
要約してはいけない情報
ローリングサマリで失敗しがちなのが、ユーザー名・予約番号・コード片・URL のような「失うと致命的な情報」を要約器が落としてしまうケースです。対策はふたつあります。
ひとつは、要約プロンプトに「固有名詞・数値・URL は原文のまま残してください」と明示することです。Gemini はかなり素直に従ってくれます。
もうひとつは、構造化メモリを別レイヤで持つことです。ユーザープロフィール、現在のタスク状態、コードスニペットなどはアプリ側で抽出して JSON で別管理し、毎リクエストの先頭に必ず差し込みます。要約器の善意に頼らず「絶対に消えない場所」を用意しておくと安心です。これは Gemini API の長期記憶設計 で詳しく扱っています。
他のアプローチとの使い分け
コンテキストキャッシュが向く場面 : 不変の長文(マニュアル・ドキュメント)を都度添付するケース。会話のように内容が変わり続ける履歴には不向きです。詳しくは コンテキストキャッシュでコストを下げる方法 をご覧ください
ベクトル検索が向く場面 : 過去の会話から「この話題に関連する部分だけ」を引き出したいとき。会話が数千ターンに及ぶ場合や、検索可能な記憶がほしい場合に有効です
ローリングサマリが向く場面 : 単一スレッドの会話を中長期で続ける典型的なチャットボット。実装コストと効果のバランスが最も良い選択肢だと感じています
トークン上限エラーそのものへの対症療法は Gemini API トークン上限超過エラーの対処 にまとめてあります。本番運用に入る前に一度目を通しておくと、エラーが出た日に慌てずに済みます。
要約モデルをピン留めする — 既定モデルが差し替わると要約の癖が静かに変わる
2026-06-21 に Gemini 3.5 Flash が一般提供(GA)となり、各プロダクトの既定モデルが順次新しいティアへ引き上げられています。ここで見落としやすいのが、要約器のモデルを世代名でしか指定していないコード です。要約は会話本体ではなく裏方の処理なので、モデルが静かに差し替わったことに気づきにくいのです。
私自身、個人開発で回している要約パイプラインで gemini-2.5-flash のように世代だけを指定していたことがあり、上位ティアへ自動的に引き上げられた結果、要約が以前より長く詳細になり、入力トークンがじわじわ増えてコストが膨らんだ経験があります。出力は「良くなった」ように見えるので、コストの請求を見るまで原因に気づけませんでした。
対策は単純で、要約モデルは完全修飾名でピン留めし、応答が実際にどのモデルで処理されたかを毎回記録する ことです。要求したモデルと応答したモデルがずれていれば、その場で気づけます。
# 要約モデルは「世代名」ではなく安定したIDでピン留めする
SUMMARY_MODEL = "gemini-2.5-flash" # 例: 検証済みの安定版を明示的に固定
def summarize_pinned (messages: list[types.Content]) -> tuple[ str , str ]:
"""要約テキストと、実際に応答したモデル名を返します。"""
transcript = " \n " .join(
f " { m.role } : { m.parts[ 0 ].text } " for m in messages if m.parts
)
instruction = (
"次の会話を、後続のチャットで失ってはいけない情報に絞って5〜8行で要約してください。"
"固有名詞・数値・URL は原文のまま残してください。 \n\n "
f "--- \n{ transcript }\n ---"
)
resp = client.models.generate_content(
model = SUMMARY_MODEL ,
contents = instruction,
config = types.GenerateContentConfig( temperature = 0.2 ),
)
# 応答メタデータから実際に処理したモデルを取得(SDKのバージョン差を吸収)
served = getattr (resp, "model_version" , None ) or SUMMARY_MODEL
if not served.startswith( SUMMARY_MODEL ):
# 既定モデルの差し替えを検知。ここでログ&アラートを上げる
print ( f "[warn] summary model drift: requested= { SUMMARY_MODEL } served= { served } " )
return resp.text, served
期待する挙動は、served が常に SUMMARY_MODEL で始まることです。ある朝ここに警告が出たら、それは既定モデルが上がったサインです。要約の長さやコストが変わる前に、固定 ID を更新するか、新しいティアで要約品質を測り直すかを落ち着いて判断できます。なぜ応答側のモデル名まで記録するのかというと、要求モデル名だけを信じていると「自分は固定したつもり」の思い込みが温存され、静かなコスト増を取り逃すからです。
要約が事実を落としていないかを自動で検出する
本文の終盤で「週に一度サンプリングして要約品質を見る」とご紹介しましたが、本番で数百セッションが動き出すと、人手のサンプリングだけでは取りこぼします。そこで私は、要約前の原文から「失うと致命的な要素」を機械的に抽出し、要約に生き残っているかを照合するゲート を挟んでいます。
考え方はシンプルです。固有名詞・数値・URL は正規表現で拾えます。それらが要約テキストに含まれているかを数え、生存率が閾値を割ったら、その要約は採用せずに作り直す(あるいは構造化メモリ側に退避する)という判断にします。
import re
URL_RE = re.compile( r "https ? :// [ ^ \s ) ] + " )
NUM_RE = re.compile( r " \b\d[\d , \. ] {2,} \b " ) # 予約番号・金額・桁数の大きい数値
PROPER_RE = re.compile( r " [ A-Z ][ a-zA-Z0-9_ \- ] {2,} " ) # 製品名・ID・コード片の近似
def critical_tokens (text: str ) -> set[ str ]:
"""落としてはいけない要素を原文から抽出します。"""
toks = set ( URL_RE .findall(text)) | set ( NUM_RE .findall(text)) | set ( PROPER_RE .findall(text))
# 一般的すぎる語を除外(必要に応じて辞書化)
return {t for t in toks if t.lower() not in { "http" , "https" , "the" }}
def retention_ratio (original: str , summary: str ) -> float :
"""要約が原文の重要要素をどれだけ保持したかを 0.0〜1.0 で返します。"""
must_keep = critical_tokens(original)
if not must_keep:
return 1.0
survived = sum ( 1 for t in must_keep if t in summary)
return survived / len (must_keep)
def compress_with_gate (history: list[types.Content], min_retention: float = 0.9 ):
"""生存率ゲートつきの圧縮。閾値を割ったら逐語保持にフォールバックします。"""
if len (history) <= KEEP_RECENT :
return history, 1.0
old, recent = history[: - KEEP_RECENT ], history[ - KEEP_RECENT :]
original = " \n " .join(m.parts[ 0 ].text for m in old if m.parts)
summary, _ = summarize_pinned(old)
ratio = retention_ratio(original, summary)
if ratio < min_retention:
# 重要要素が落ちた → 要約を採用せず、直近を多めに逐語で残す保守側に倒す
print ( f "[warn] summary retention { ratio :.2f } < { min_retention } ; keeping more verbatim" )
return history[ - ( KEEP_RECENT * 2 ):], ratio
summary_msg = types.Content(
role = "user" ,
parts = [types.Part.from_text( text = f "[これまでの会話の要約] \n{ summary } " )],
)
return [summary_msg] + recent, ratio
このゲートを入れてから、個人開発で回している手元のボットでは「ユーザーが10ターン前に伝えた予約番号が要約から消える」という事故がほぼ止まりました。完璧な抽出ではありませんが、生存率という1つの数字で要約の劣化を毎回監視できることに価値があります。閾値 min_retention は最初は 0.9 から始めて、誤検知が多ければ少し下げ、取りこぼしが出れば上げる、という形で育てていくのが現実的です。なぜ機械ゲートを優先するのかというと、要約器の善意は再現性がなく、品質の劣化は静かに、しかし確実に積み上がるからです。
二重圧縮レースと、要約されない構造化メモリ層
最後に、本番でだけ現れる2つの落とし穴をご紹介します。どちらも単一スレッドのローカル実装では見えず、並行リクエストが入った瞬間に表面化します。
ひとつ目は二重圧縮レース です。同じセッションの履歴に対して、ほぼ同時に2つのリクエストが入り、両方が圧縮閾値を越えていると、compress() が二重に走ります。すると「要約の要約」が生まれ、情報がさらに痩せます。対策は、セッション単位で圧縮を直列化する小さなガードを置くことです。
ふたつ目は、本文でも触れた構造化メモリ層 を実際にコードへ落とすことです。ユーザープロフィールや現在のタスク状態のような「絶対に消えてはいけない情報」は、要約器に一切渡さず、アプリ側で JSON として保持して毎リクエストの先頭に必ず差し込みます。要約の生存率に頼らず、消えない場所を物理的に用意するわけです。
import threading
from collections import defaultdict
_locks: dict[ str , threading.Lock] = defaultdict(threading.Lock)
# セッションごとの「絶対に消さない」構造化メモリ(DB列でもよい)
sticky_memory: dict[ str , dict ] = defaultdict( dict )
def build_prompt (session_id: str , history: list[types.Content]) -> list[types.Content]:
"""構造化メモリを先頭に固定し、その後ろに圧縮済み履歴を続けます。"""
mem = sticky_memory[session_id]
header = types.Content(
role = "user" ,
parts = [types.Part.from_text(
text = "[セッション固定情報・要約対象外] \n " + str (mem)
)],
) if mem else None
return ([header] if header else []) + history
def chat_turn_safe (session_id: str , history, user_message):
history.append(types.Content(
role = "user" , parts = [types.Part.from_text( text = user_message)]))
# 同一セッションの圧縮を直列化して二重圧縮を防ぐ
if count_tokens(history) > COMPRESS_THRESHOLD :
with _locks[session_id]:
if count_tokens(history) > COMPRESS_THRESHOLD : # ロック取得後に再確認
history, _ = compress_with_gate(history)
prompt = build_prompt(session_id, history)
resp = client.models.generate_content( model = "gemini-2.5-pro" , contents = prompt)
history.append(resp.candidates[ 0 ].content)
return history, resp.text
with _locks[session_id] のあとでもう一度閾値を確認しているのは、ロック待ちの間に別リクエストが先に圧縮を終えている可能性があるためです(二重チェックロッキング)。地味ですが、これがないとロックが空振りして二重圧縮が残ります。構造化メモリを要約対象から外す設計は、Gemini API の長期記憶設計 の考え方と地続きです。要約は「忘れてよい情報を安く畳む」ための道具であって、「絶対に忘れてはいけない情報」はそもそも要約器に渡さない、という線引きを最初に決めておくと、運用が驚くほど安定します。
運用に入る前のひと工夫
ローリングサマリを入れたあとは、要約品質のサンプリング監視を1日1回でも回しておくことをおすすめします。要約結果を別テーブルに保存し、週に一度ランダムサンプリングで「要約に含まれていない重要情報がないか」を Gemini 自身にレビューさせる、という運用が現実的です。完璧なサマリは存在しないので、欠落を見つけたら要約プロンプトを改善する、という小さなフィードバックループを回し続けることになります。
LLM アプリのアーキテクチャをもう一段深く
まずは手元のチャットボットに count_tokens を1行入れて、現在の入力サイズをログに吐かせてみてください。圧縮が必要かどうかは、データを見れば一目で判断できます。