個人開発で LINE Bot に Gemini を載せたとき、ローカルの ngrok では完璧に動いていたものが、Cloud Run に上げた翌朝から「同じ返事が2回届く」「たまに無言になる」と報告が来ました。
原因はコードのバグではありませんでした。LINE Messaging API と、数秒かかる生成 AI、そしてサーバーレスのコールドスタート——この三つの時間軸が噛み合っていなかっただけです。
ここでは、その噛み合わせをどう設計し直したかを、動くコードと実測値とともに残しておきます。チュートリアルとして最初のセットアップから追えるようにしつつ、本番でだけ顔を出す落とし穴に紙幅の多くを割いています。
Gemini API そのものが初めての方は、先に Gemini API クイックスタート に目を通していただくと読みやすくなります。
まず最小構成を動かす
落とし穴の話に入る前に、土台となる最小構成を一度動かします。ここが動かないと、後半の議論が宙に浮いてしまうためです。
必要なもの
- Python 3.11 以上
- Google AI Studio の API キー(Google AI Studio で無料取得)
- LINE Developers アカウント(LINE Developers で無料登録)
- ngrok(ローカル検証用の HTTPS トンネル)または Google Cloud Run(本番)
LINE チャネルの作成
- LINE Developers Console にログインします
- プロバイダーを作成します(例:
My AI Bot)
- Messaging API チャネルを作成します
- チャネル設定から次の2つを控えます
- チャネルシークレット(Basic settings タブ)
- チャネルアクセストークン(Messaging API タブ → 「発行」)
- Webhook URL は後ほど設定します
パッケージのインストール
mkdir gemini-line-bot && cd gemini-line-bot
python -m venv venv
source venv/bin/activate
pip install google-genai flask line-bot-sdk gunicorn google-cloud-firestore
環境変数は .env にまとめます。
# .env
GEMINI_API_KEY=your_gemini_api_key_here
LINE_CHANNEL_SECRET=your_channel_secret_here
LINE_CHANNEL_ACCESS_TOKEN=your_channel_access_token_here
疎通確認
まず Gemini 単体が動くことを確かめます。
# test_gemini.py
from google import genai
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
response = client.models.generate_content(
model="gemini-3-flash",
contents="こんにちは。簡単に自己紹介してください。",
)
print(response.text)
チャットボットのバックエンドには、速度と単価のバランスから gemini-3-flash を私は推奨します。深い推論が要る場面だけ gemini-3-pro に切り替える二段構えが、コストと体感のどちらにも無理がありません。
なぜ素直な reply_message が本番で崩れるのか
多くのサンプルは、Webhook を受け取ったその場で Gemini を呼び、返ってきたテキストを reply_message で返します。ローカルでは問題なく動きます。
ところが本番では、LINE の reply token に二つの制約が効いてきます。
第一に、reply token は発行から約30秒で失効します。第二に、一つの reply token は一度しか使えません。
Gemini Flash の応答は速いとはいえ、長めのプロンプトや混雑時には数秒かかります。そこへ Cloud Run のコールドスタートが重なると、合計で30秒に近づく瞬間が出てきます。失効した token に返信しようとすれば、LINE 側は 400 Invalid reply token を返し、ユーザーには沈黙だけが残ります。
さらに厄介なのが再送です。LINE は Webhook が一定時間内に 200 を返さないと、同じイベントを再送します。再送のたびに Gemini を呼んで返信すれば、ユーザーには同じ答えが二度、三度と届きます。最初に私が遭遇した「2回届く」はこれでした。
ここから先は、この二つ(token 失効と再送)を正面から潰していきます。
設計方針 — 受信即 200、生成は後ろで
考え方はシンプルです。Webhook は受け取った瞬間に 200 を返し、Gemini の生成は別スレッドに逃がす。これで再送の引き金を引かなくなります。
そのうえで、生成が reply token の有効時間に収まりそうなら reply_message、間に合わない可能性があるなら push_message に切り替えます。push message は token を必要とせず、ユーザーIDさえあれば後からでも送れます。
# app.py
import os
import threading
from flask import Flask, request, abort
from google import genai
from linebot.v3 import WebhookParser
from linebot.v3.messaging import (
Configuration, ApiClient, MessagingApi,
ReplyMessageRequest, PushMessageRequest, TextMessage,
ShowLoadingAnimationRequest,
)
from linebot.v3.webhooks import MessageEvent, TextMessageContent
app = Flask(__name__)
GEMINI_API_KEY = os.environ["GEMINI_API_KEY"]
LINE_CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]
LINE_CHANNEL_ACCESS_TOKEN = os.environ["LINE_CHANNEL_ACCESS_TOKEN"]
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
configuration = Configuration(access_token=LINE_CHANNEL_ACCESS_TOKEN)
parser = WebhookParser(LINE_CHANNEL_SECRET)
def generate_reply(user_id: str, user_message: str) -> str:
"""会話履歴を踏まえて Gemini で応答を生成する。"""
history = load_history(user_id) # Firestore から取得(後述)
history.append({"role": "user", "parts": [{"text": user_message}]})
response = gemini_client.models.generate_content(
model="gemini-3-flash",
contents=history,
config={
"system_instruction": (
"あなたは LINE 上で動く親切なアシスタントです。"
"日本語で、200文字程度を目安に簡潔に答えてください。"
),
"temperature": 0.7,
"max_output_tokens": 500,
},
)
answer = response.text
history.append({"role": "model", "parts": [{"text": answer}]})
save_history(user_id, history) # Firestore へ保存(後述)
return answer
def process_event(user_id: str, reply_token: str, text: str):
"""別スレッドで実行。生成して reply、間に合わなければ push。"""
with ApiClient(configuration) as api_client:
api = MessagingApi(api_client)
# 入力中インジケータを出して「考えています」を伝える
api.show_loading_animation(
ShowLoadingAnimationRequest(chat_id=user_id, loading_seconds=20)
)
answer = generate_reply(user_id, text)
try:
api.reply_message(
ReplyMessageRequest(
reply_token=reply_token,
messages=[TextMessage(text=answer)],
)
)
except Exception:
# token 失効時は push にフォールバック
api.push_message(
PushMessageRequest(
to=user_id, messages=[TextMessage(text=answer)]
)
)
@app.route("/callback", methods=["POST"])
def callback():
signature = request.headers.get("X-Line-Signature", "")
body = request.get_data(as_text=True)
try:
events = parser.parse(body, signature)
except Exception:
abort(400)
for event in events:
if isinstance(event, MessageEvent) and isinstance(
event.message, TextMessageContent
):
if already_handled(event.webhook_event_id): # 冪等化(後述)
continue
threading.Thread(
target=process_event,
args=(event.source.user_id, event.reply_token, event.message.text),
daemon=True,
).start()
return "OK" # 生成を待たずに即 200
@app.route("/health")
def health():
return "OK"
ここで効いているのは三点です。return "OK" を生成より前に置いたことで再送の芽を摘み、show_loading_animation でユーザーに「無視されていない」ことを伝え、reply_message が落ちたら push_message に逃がして沈黙を防いでいます。
loading_seconds は最大60秒まで指定できますが、私は実応答時間の2〜3倍にあたる20秒前後を目安にしています。長すぎるとユーザーが離脱し、短すぎると生成中に消えてしまうためです。
再送による重複返信を冪等化で止める
スレッドに逃がして即 200 を返せば、再送はほぼ起きません。とはいえネットワークのゆらぎで二重配信が起きることはあります。ここを最後に塞ぐのが冪等化です。
LINE の各 Webhook イベントには webhookEventId という一意なIDが付きます。これを「処理済みかどうか」の鍵に使います。
from google.cloud import firestore
db = firestore.Client()
def already_handled(event_id: str) -> bool:
"""イベントIDを記録し、初回だけ False を返す。"""
ref = db.collection("handled_events").document(event_id)
# create は既存ドキュメントがあると例外になる性質を利用
try:
ref.create({"ts": firestore.SERVER_TIMESTAMP})
return False # 初回
except Exception:
return True # 再送・重複
create は同一IDのドキュメントが既にあると失敗します。この「失敗する」性質をロックの代わりに使うことで、複数インスタンスが同時に走っても二重処理を防げます。Firestore の TTL 機能で handled_events を24時間で自動削除するように設定しておけば、コレクションが肥大化することもありません。
会話履歴をインメモリから Firestore へ
サンプルでよく見る conversation_history = {} というモジュール変数は、本番で必ず破綻します。Cloud Run は min-instances 0 だと無通信でインスタンスを落とすため、次のアクセスで履歴は空になります。複数インスタンスにスケールすれば、同じユーザーのリクエストが別インスタンスに振られ、文脈が飛びます。
履歴は外部ストアに置くのが本筋です。LINE Bot のように書き込み頻度が読み取り頻度と近く、ユーザー単位で分離できるデータは、Firestore と相性が良いと私は感じています。
MAX_TURNS = 20 # 直近20メッセージ(ユーザー+モデルで10往復)
def load_history(user_id: str) -> list[dict]:
doc = db.collection("conversations").document(user_id).get()
return doc.to_dict().get("history", []) if doc.exists else []
def save_history(user_id: str, history: list[dict]):
trimmed = history[-MAX_TURNS:]
db.collection("conversations").document(user_id).set({"history": trimmed})
履歴を直近20メッセージで切るのは、コストと文脈のバランスを取るためです。Gemini は入力トークンに課金されるので、何百ターンも全部送れば一回の応答が無駄に高くなります。20メッセージ程度あれば、LINE での雑談やQ&Aの文脈はおおむね保てます。
長い文脈を毎回送るより、コンテキストキャッシュで使い回す方が安くなる場面もあります。その判断軸は Gemini API のコンテキストキャッシュでコストを抑える に整理しています。
ローカル検証 — ngrok で Webhook をつなぐ
本番設計の前に、手元で挙動を確かめます。LINE の Webhook は HTTPS 必須なので、ローカルでは ngrok を挟みます。
# ターミナル1
export GEMINI_API_KEY="your_key"
export LINE_CHANNEL_SECRET="your_secret"
export LINE_CHANNEL_ACCESS_TOKEN="your_token"
python app.py
# ターミナル2
ngrok http 8080
# https://xxxx-xxx-xxx.ngrok-free.app
LINE Developers Console の Messaging API タブで Webhook URL に ngrok の URL + /callback を設定し、「Webhook の利用」を ON、「検証」が成功すれば疎通完了です。ボットを友だち追加してメッセージを送り、応答が返れば土台は完成です。
ローカルでは reply token がほぼ即時で消費されるため失効を体感しづらいのですが、後述のコールドスタートを含めた本番では話が変わります。
画像にも答える — マルチモーダル対応
Gemini の強みはテキスト以外も扱える点です。ユーザーが送った画像を解析して返す導線を足します。
import requests, base64
from linebot.v3.webhooks import ImageMessageContent
def download_line_image(message_id: str) -> bytes:
url = f"https://api-data.line.me/v2/bot/message/{message_id}/content"
headers = {"Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"}
return requests.get(url, headers=headers).content
def describe_image(image_bytes: bytes) -> str:
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
response = gemini_client.models.generate_content(
model="gemini-3-flash",
contents=[{
"role": "user",
"parts": [
{"text": "この画像を日本語で簡潔に説明してください。"},
{"inline_data": {"mime_type": "image/jpeg", "data": image_b64}},
],
}],
config={"max_output_tokens": 500},
)
return response.text
# callback の分岐に追加:
# elif isinstance(event.message, ImageMessageContent):
# 画像も process_event と同じく別スレッドで処理し、reply/push を使い分ける
画像取得は LINE の api-data.line.me から行いますが、このダウンロード自体にも reply token の30秒は刻一刻と消費されます。画像処理こそ push_message フォールバックの恩恵が大きい領域です。私自身は、画像が絡む応答をほぼ push 前提で組んでいます。
Cloud Run へのデプロイと、コールドスタートの実測
本番はサーバーレスの Cloud Run に載せます。Dockerfile は素直です。
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "8", "app:app"]
別スレッドで生成を回すため、--threads を明示している点が重要です。スレッドが足りないと、生成中のリクエストが後続をブロックします。
gcloud run deploy gemini-line-bot \
--source . \
--region asia-northeast1 \
--allow-unauthenticated \
--set-env-vars "GEMINI_API_KEY=...,LINE_CHANNEL_SECRET=...,LINE_CHANNEL_ACCESS_TOKEN=..." \
--memory 512Mi \
--min-instances 0 \
--max-instances 10
ここで体感を左右するのが min-instances です。私が asia-northeast1 で測った範囲では、min-instances 0 のコールドスタート時、初回応答までに約4秒の上乗せが乗りました。Gemini の生成が2〜3秒だとすると、合計で reply token の30秒には収まるものの、ユーザー体験としては「最初の一通だけ妙に遅い」という形で表れます。
この遅延が気になるなら min-instances 1 にして1台を常時起動しておく手があります。ただし常時起動はわずかとはいえ課金が発生します。コールドスタートを潰す他の打ち手は Gemini API のコールドスタート対策 にまとめました。趣味のボットなら 0、ユーザーがいるサービスなら 1、というのが私の使い分けです。
おおよそのコスト感
小〜中規模であれば、月額は次のような内訳になります。
- Cloud Run リクエスト: 200万回/月まで無料、超過分は100万回あたり $0.40
- Gemini 3 Flash: 入力100万トークンあたり $0.10 程度(無料枠あり)
- LINE Messaging API: 無料枠を超えると従量課金プラン
reply ではなく push を多用すると、LINE の送信通数は増えます。push fallback は失効対策として有効ですが、無料枠の通数を消費する点は頭の片隅に置いておくとよいです。
レート制限とリトライ
混雑時には Gemini が 429(レート制限)を返すことがあります。スレッド内の生成呼び出しを、指数バックオフ付きのリトライで包んでおきます。
import time
from google.api_core.exceptions import ResourceExhausted, ServiceUnavailable
def generate_with_retry(contents, config, max_retries=3):
for attempt in range(max_retries):
try:
return gemini_client.models.generate_content(
model="gemini-3-flash", contents=contents, config=config,
)
except (ResourceExhausted, ServiceUnavailable):
wait = 2 ** attempt # 1s, 2s, 4s
time.sleep(wait)
raise RuntimeError("Gemini retries exhausted")
ここで注意したいのは、リトライで待つほど reply token の失効に近づくことです。リトライ回数を増やすほど push fallback の重要度が上がります。この二つは独立した対策ではなく、セットで設計すべきものだと考えています。503 を含むエラー別の対処は Gemini 3.2 API のエラー対処 が詳しいです。
動作確認のチェックリスト
本番に出す前に、私は次の順で確かめています。
- ngrok 経由でテキスト応答が返る
- わざと
time.sleep(35) を生成前に挟み、reply が失効しても push で届くことを確認する
- LINE 側の再送をシミュレートし、同じイベントIDで二重に届かないことを確認する
- Cloud Run にデプロイし、十分に時間を空けてからコールドスタートの初回遅延を測る
- 数十メッセージ会話を続け、Firestore の履歴が20件で頭打ちになることを確認する
2番と3番は、ローカルでは見えにくく本番で初めて牙をむく挙動です。意図的に壊して確かめておくと、リリース後の「2回届く」「無言になる」をほぼ事前に潰せます。
最初に動くものを作る楽しさと、それを誰かが毎日使えるものに育てる地道さは、少し性質が違います。後者の地味な詰めこそが、LINE Bot を「試作」から「サービス」に変えていくのだと感じています。同じ構成で詰まっている方の助けになれば幸いです。