記事
LLMアプリを最新に保つ:「モデル名の更新」で実際に必要だったこと
日本語の単語カードアプリを更新したところ、リトライ、画像の扱い、コスト追跡に問題が見つかりました。何を変更し、どう確認したかをまとめます。
日本語の単語カードアプリのモデル更新は、ちょっとした保守作業で済むと思っていました。ところが連携部分を確認してみると、リトライ、アップロードされた画像、コスト追跡に問題が見つかりました。この記事では、何を変更し、どうテストしたかを説明します。
対象のアプリは、私のJapanese Flashcard Generator(プロジェクトについて読む)です。学習ページの写真を、間隔反復型の単語カードアプリであるAnki用の単語カードに変換します。自分の日本語学習に使っていて、オープンソースで公開しています。ここで紹介する更新は、2026年10月9日に公開しました。
小さなStreamlitアプリ(Streamlitは、シンプルなWebアプリを作るためのPythonフレームワークです)で、1回の実行は次の4つのステップで構成されます。
- Geminiの呼び出しで、写真が学習ページとして使えるかを確認します。
- LLMWhispererで、その写真にOCR(光学文字認識)をかけます。
- 2回目のGemini呼び出しで、OCRテキストを画像と照合し、カードをJSONとして返します。このJSONはPydanticで検証します。このステップは、組み込みのFew-shotの例(何を出力すべきかをモデルに示すサンプルページ)で誘導できます。
- アプリはAnki用のカンマ区切りのテキストファイルをエクスポートし、その実行にかかったコストを表示します。

使っているモデルが廃止されたとき
アプリのデフォルト兼フォールバックのモデルだったgemini-2.0-flashは、2026年6月1日に提供を終了しました。アプリのドロップダウンにあった8つのモデルのうち、デフォルトを含む5つが、提供終了または廃止になっていました。残る3つは今も動きますが、使える人は限られます。Googleの変更履歴 (changelog)の2026年9月18日の項目は、Gemini 2.5モデルの利用を「過去に実際に利用したことのあるユーザー」に限定する一方で、非推奨ではないとしています。
1つ驚いたことがあります。10月の時点でも、models.get("gemini-2.0-flash")はメタデータを返していました。models.listには含まれておらず、ドキュメント上も提供終了とされていたにもかかわらずです。「このモデルは存在するか?」を確かめる起動時チェックなら、通過していたでしょう。信頼できる情報源は、非推奨 (deprecations) のページでした。
モデル名の変更だけでは済まなかった
まず連携部分を読み取り専用でレビューし、書面の計画を作成しました。計画の内容は、Googleのドキュメント、インストール済みのSDK、オフラインのHTTPプローブで確認しています。その後、コーディングエージェントが変更を加え、私がレビューしてマージしました(この進め方については『仕様ファースト、テストファースト』で紹介しています)。
「主にモデル名の変更で済むか?」という問いに対する計画の答えは、「いいえ。ただし、ほぼそのとおり」でした。モデル名だけを変えていたら、アプリはGemini 3の上で、次のような状態のまま動いていたはずです。
- ドキュメントが使わないよう警告しているサンプリング設定
- 機能しないリトライ
- プロンプトがブロックされるたびに起きるクラッシュ
- ユーザーのGeminiプロジェクト内のファイルをすべて削除するクリーンアップ処理
Gemini 3には別の設定が必要
GoogleのGemini 3向けガイダンスは、temperature、top_p、top_kを削除するよう求めており、temperatureを1.0未満に設定すると「ループや性能の低下など、予期しない動作につながるおそれがある」と警告しています。私の適合性チェックは、0.1を送っていました。
Gemini 3では、thinking budget(思考予算)もthinking level(思考レベル)に置き換わっており、詳細はモデルによって異なります。3.8 Flashでは、minimalレベルを指定するとエラーになり、levelとbudgetを同時に送ると400エラーが返ります。そこでアプリは、リクエストの設定をモデルファミリーごとに組み立てるようにしました。Gemini 3のモデルにはtemperatureを送らず、呼び出しごとにthinking_levelを明示的に指定します。こうしておけば、Googleがデフォルト値を変えても、アプリの動作が知らないうちに変わることはありません。レガシーの2.5モデルは、従来の設定のままです。
一度も実行されなかったリトライ
アプリは、Geminiの呼び出しをtenacityのリトライデコレーターで包んでいました。その判定条件 (predicate) は、google.api_coreの例外とgRPCのステータスコードを探すものでした。しかし現行のgoogle-genai SDKは、HTTPステータスコードを持つ独自のエラーを送出するため、判定条件に一致しません。そもそも、エラーはデコレーターまで届いていませんでした。関数のcatch-allハンドラーがエラーを捕まえてエラー用の辞書を返していたため、デコレーターが例外を目にすることはなかったのです。さらに、アプリが現在使っているバージョンのSDKでは、設定しない限り何もリトライされません。実際に再現してみると、429(レート制限)のレスポンスに対して、呼び出しはちょうど1回だけでした。
修正では、SDK自体のHttpRetryOptionsを使い(408、429、5xxのレスポンスに対して4回試行、タイムアウト180秒)、古いリトライのコードを削除しました。これで、直接の依存関係も2つ減りました。現在はテストで、429の後に200を返すモック化したトランスポートを通して実際のSDKクライアントを動かし、呼び出しがちょうど2回だったことを確認しています。
誤ったファイルを削除したクリーンアップ
画像は、Files API経由でGeminiに送っていました。呼び出しのたびに、その後でfinally:ブロックが、APIキーのプロジェクト内にある全ファイルを一覧にして、すべて削除していました。
APIキーを各自で用意する公開版のアプリでは、それは訪問者のプロジェクトです。再現テストでは、プロジェクトにもともとあった無関係なファイル2つが削除されました。その間、アプリ上には何の異常も見えません。訪問者の他のアプリのファイルが、ただ消えていたはずです。
現在、アプリはFiles APIを使っていません。画像はインラインで、リクエスト自体に含めて送ります。テストでは、フェイククライアントのFiles APIに何かが触れるとエラーを送出するため、全削除の処理が知らないうちに復活することはありません。
画像をインラインで送る
画像をインラインで送るとなると、リクエストのサイズが問題になります。Googleの画像理解のガイドでは、インライン画像を含むリクエストの上限を、プロンプトを含めて合計20 MBとしています。一方、より新しいファイル入力のガイドでは、Googleが2026年1月に発表した上限の引き上げを受けて、最大100 MB(PDFは50 MB)までのインラインのペイロードを認めています。アプリは、厳しいほうの数字を十分に下回るようにしました。
以前の前処理では、1枚あたり約6〜10.5 MBのPNGを書き出していました。語彙 (Vocabulary) モードの実行では、組み込みの例の画像3枚と学習ページを送るため、1回のリクエストは約27〜37 MBになっていました。そこで、JPEG(長辺3,072 px、品質90、透過部分は白で塗りつぶし)に切り替えました。語彙モードの1回の実行(例の画像3枚と学習ページ1枚)を変更の前後で測定し、プルリクエストに記録した結果は次のとおりです。
- リクエストのペイロードは、34.8 MBから3.0 MBに減りました。
- 前処理時間は、45.7秒から2.1秒に短縮しました。
前処理時間は、リクエストを送る前にアプリ自身が行う画像の準備にかかる時間で、Geminiが応答するまでの時間は含みません。アプリ自体にはリクエストサイズの上限はなく、各画像を約2 MB以下にリサイズするようになっています。(次のセクションで説明する20 MBのアップロード上限はファイル単位のもので、アップロード欄を守るためのものです。)
アプリが使うGemini 3のモデルでは、デフォルトの画像解像度がすでに「high」の設定と同等なので、解像度を指定する必要はありませんでした。

公開アップロード欄の堅牢化
ライブ版のアプリは公開されているため、誰でもファイルをアップロードできます。リスクの1つが展開爆弾 (decompression bomb) です。デコードすると極端に大きく膨らむ小さなファイルのことで、20 KBのPNGが、APIの呼び出しやキーのチェックが行われる前に、約483 MBにまで展開されることがあります。現在は、何かをデコードする前に、ファイルヘッダーをもとにサイズ、形式、ピクセル数をチェックしています。上限は次のとおりです。
- JPEGとPNGのみ
- 1ファイルあたり20 MB (
MAX_UPLOAD_MB = 20)、1回の実行につき10ファイルまで - JPEGは120メガピクセル(縮小スケールでデコード)、PNGは40メガピクセル
スモークテストでは、爆弾PNG、.pngに名前を変えたGIF、21 MBのファイル、11ファイルのバッチを、いずれもAPIを呼び出す前に拒否しました。
もう1つの問題は位置情報でした。OCRのステップには、元のアップロードファイルがメタデータごと渡されていました。スマートフォンの写真にはGPS座標が含まれていることが多く、私自身のテスト写真にも含まれていました。修正にあたっては、画質を変えないことを条件にしました。現在は、EXIF、XMP、コメント、末尾のデータを除去し、圧縮された画像データはバイト単位でそのままコピーして、向きとカラープロファイルも保持しています。デコード後のピクセルは同一です。
更新後に公開リポジトリのセキュリティレビューを行ったところ、コミットされた秘密情報は見つからず、重大度が高いと評価された問題もありませんでした。
また、生成したカードの表をダウンロード (Download) ボタンの上に追加しました。ダウンロードをクリックしても、表は消えなくなりました。
決まった日に変わる価格
執筆時点(2026年10月)で、Googleの料金ページには、StandardティアのGemini 3.8 Flashと3.6 Flashの料金が、2026年12月31日まで入力100万トークンあたりUS$0.75、出力100万トークンあたりUS$3.75と掲載されています。この料金は2027年1月1日に2倍になり、それぞれUS$1.50とUS$7.50になる予定です。モデルごとに価格を1つしか持たないと、元日以降、これらのモデルでのすべての実行について、アプリのコスト表示が実際の半分になってしまいます。Gemini 3.5 Flash-Liteについては、価格改定は発表されていません。
そこで、アプリの価格レジストリの各エントリーに、effective_fromの日付を付けたprice_changesを持たせられるようにしました。思考トークンは、出力の料金で課金されます。-latestのモデルエイリアスは除外しました。エイリアスがどのモデルを指しているかを、コスト表示の側では知りようがないからです。
2026年10月9日の有料のスモークテストでは、語彙モードの1回の実行(例のページ3枚と学習ページ1枚)のコストは、2026年10月の価格で、3.8 Flashで約US$0.02、3.5 Flash-Liteで約US$0.0065でした。これはGeminiのトークン料金のみで、OCRはLLMWhispererの無料枠で実行しました。2026年10月時点で、この無料枠ではクレジットカードの登録なしで1日100ページまで処理できます。それを超えると、LLMWhispererは処理モードに応じてページ単位で課金し、料金はデジタルPDFのUS$0.001から、フォームや表を含む読み取りの難しいスキャンのUS$0.015までです。手計算の結果は、アプリの表示と完全に一致しました(適合性チェック1回でUS$0.001723)。有料のスモークテスト全体の費用は、上限US$2に対してUS$0.32でした。
フェイクを使ったテスト
今回の更新で、37件の自動テストを追加しました。内訳は、33件のオフラインテストと、4件のヘッドレスStreamlit UIテストです。オフラインテストでは、フェイクのGeminiクライアント、モック化したHTTPレイヤー、フェイクのOCRクライアント、メモリ上で生成した画像を使うため、APIキーは必要ありません。フェイクを選んだのは、そうすればテストが実際のAPIを呼び出さないため、速く実行でき、費用がかからず、毎回同じ結果になるからです。テストはリポジトリのtestsフォルダにあります。テストスイートの初版を元のコードに対して実行したところ、11件のテストが失敗し、修正を書く前にバグを確認できました。
このテストスイートには限界もあります。Python 3.10と3.12で動作しますが、まだCIには組み込んでいません。フェイクを使っているため、テストしているのは配管部分、つまりAPI呼び出しの周りにあるアプリ自身のロジックです。カードの品質を判断することはできません。Pydanticは出力の構造を確認しますが、日本語が正しいかどうかは、引き続きライブのスモークテストと、人がカードを読んで確かめることにかかっています。
2回目のコードレビューで見つかったこと
2番目のレビュアーは、GitHub上のプルリクエストを自動でレビューする、GitHub Copilotのレビューエージェントでした。5件の異なる指摘が挙がり、どう対応するかを決める前に、それぞれを再現しました。4件は実際のバグで、それぞれの修正には、その修正がなければ失敗する回帰テストを付けました。
- 透過のあるグレースケールPNGやパレットPNGが、Geminiに送るコピーで黒くなっていた。
- メタデータの除去で、JFIFのサムネイルが残っていた。
- ちょうど200,000トークンのプロンプトが、高いほうの料金区分で課金されていた。
- 空のOCRテキストがステータスチェックを通過し、何もないところからカードを作るようGeminiに求めていた。
5件目は、レジストリのレート制限のセクションが旧モデルしかカバーしていない、という指摘でした。これには手を付けませんでした。レート制限はアカウントの利用ティアによって変わるため、値を埋めれば数字をでっち上げることになるからです。
アプリの両方のバージョンを更新する
リポジトリには、長期間維持している2つのブランチがあります。mainは私自身のAPIキーを使い、app_publicは訪問者にキーの入力を求めます。Streamlit Community Cloudがデプロイするのはapp_publicのほうです。どちらにも同じ修正が必要だったため、1つ目のプルリクエスト (#11) でmainに取り込み、2つ目のプルリクエスト (#12) で同じ変更セットをapp_publicにマージして、コンフリクトの解消は1回で済ませました。現在、両ブランチは同じコミットを共有しています。

未解決:Few-shotの例の漏れ
例の画像を有効にすると、レガシーのGemini 2.5 Proは、ページ上の漢字ではなく、組み込みの例にある7つの漢字をそのまま返しました。2.5 Flash-Liteは、2つのセットを混ぜて返しました。
A/B比較の実行では、以前の前処理でも同じ漏れが起き、例を無効にすると漏れは起きませんでした。つまり、このバグは今回の更新より前からあったものです。テストの範囲では、Gemini 3のモデルには影響がなく、この問題がはるかに起きにくいようです。2.5系の問題はまだ修正しておらず、おそらくモデル自体の限界によるものです。選択肢は、例を別々の会話ターンとして送るか、2.5モデルでは例を無効にするかです。Googleは現在、2.5モデルの利用を過去に利用したことのあるユーザーに限定しているため、アプリではこれらを「legacy」と表示しています。
振り返り
今では、モデル一覧を有効期限付きの依存関係として扱っています。プレビューモデルは2週間前の通知で廃止されることがあり、APIのメタデータのエンドポイントは提供終了したモデルの情報をまだ返していました。そのため、非推奨のページを確認するようにしています。価格にも日付が必要です。モデルごとに価格が1つだけでは、次の改定までしか正確ではないからです。最も深刻だった2つのバグ、つまり一度も動いていなかったリトライ処理と、アプリが所有していないファイルを削除していたクリーンアップは、どちらも表に出ないものでした。それを明らかにしたのは、フェイククライアントとモック化したトランスポートです。
また、アプリの表面積を小さく保つようにしました。依存関係を減らし、Files APIもモデルエイリアスも使っていません。Googleは現在generateContentを「legacy」と呼んでいますが、引き続き完全にサポートしています。そのため、同じ変更の中でAPIまで切り替えることはせず、そのまま使い続けることにしました。
2026年10月18日時点で、ホスティング中のアプリはまだPython 3.10で動いています。Python 3.10は、2026年10月1日にサポート終了 (end of life) を迎えました。テストは3.12でも通るので、ホスティング中のアプリを3.12に移行する予定です。