請求の内訳を眺めていて、指が止まりました。
壁紙の分類に使うプロンプトは、少し前に v3 へ一本化したはずでした。それなのに、v2 の系統が毎日きちんと呼ばれている。コードを検索しても wallpaper.classify.v2 という文字列はどこにも出てきません。出てこないのに、動いている。
原因そのものは、後から振り返れば単純なものでした。けれど、その日にいちばん堪えたのは、原因ではなく構造のほうです。個人開発でアプリを何本も抱えたままモデルを呼ぶ箇所が増えていくと、プロンプトはコードほど素直には死んでくれません。参照が消えても、設定に残り、スケジューラに残り、キャッシュのハンドルとして残ります。そして誰も見ていない場所で、静かに課金され続けます。
その週末に組んだのが、本稿で扱う退役台帳です。
プロンプトはコードほど素直に死なない
関数は、呼び出し元が消えれば静的解析が教えてくれます。リンタが未使用を警告し、削除しても何も壊れません。プロンプトはそうなりません。
理由は三つあると考えています。
第一に、プロンプトの識別子は文字列です。設定ファイルや環境変数、データベースの行に入り込むと、コンパイラの視界から外れます。第二に、実行の起点がアプリ本体の外にあります。スケジューラ、Webhook、バッチ、Apps Script — 呼ぶ側がリポジトリの中にいるとは限りません。第三に、Gemini API では資産がプロンプト本文だけで完結しません。ピン留めしたモデル ID、cachedContents のハンドル、responseSchema の版がぶら下がっていて、それぞれ別の寿命を持ちます。
とくに三つ目は 2026 年に入ってから重みが増しました。既定モデルが告知を伴わずに置き換わる場面が現実に起きていますし、旧来の画像生成モデルには停止日が設定されています。モデル ID をプロンプト側にピン留めしている資産は、放っておくと「消し忘れ」ではなく「期限切れ」として跳ね返ってきます。
だから必要なのは、プロンプトを消すための道具ではありません。消してよいかどうかを、複数の観点から同時に判定する台帳 です。
最初の版は、稼働中の経路に「即時退役可」を出した
最初に書いたのは素直な実装でした。ソースを走査してプロンプト ID の文字列参照を集め、見つからなければ退役可、という判定です。フラグの既定値と最終実行日も見るようにして、上から順に当てはまった分類を返す先勝ち方式にしました。
サンプルの台帳に対して走らせた出力が、これです。
store.screenshot.caption.v1 MODEL_EOL gemini-3.1-flash-image-preview は 2026-08-17 停止。移行期限あり
wallpaper.classify.v2 UNREACHABLE ソース参照なし・最終実行 1 日前。即時退役可
wallpaper.caption.legacy UNREACHABLE ソース参照なし・最終実行 162 日前。即時退役可
review.reply.draft.v2 GATED フラグ review_autoreply が既定 false。フラグ削除が先・現状は到達不能
二行目で手が止まりました。
「ソース参照なし」と「最終実行 1 日前」が、同じ行に並んでいます。判定は「即時退役可」。この助言に従っていたら、毎日動いている経路を消していました。
静的に見つからないことと、動いていないことは、別の事実です。頭では分かっていたつもりでしたが、先勝ちの分類器はそれを一つのラベルに潰してしまう。静的解析の結果を優先した瞬間に、より確度の高い証拠である「昨日実行された」という観測を捨てていたわけです。
そこで、この状態に固有の名前を与えることにしました。PHANTOM — 静的には見えないのに稼働している経路です。退役台帳において、これは「消してよい」の対極にあります。消してはいけないどころか、真っ先に調べるべき対象です。ID がどこで組み立てられているのか分からない、という事実そのものが、その資産の管理が外れている証拠だからです。
判定はこう書き換えました。
referenced = pid in refs
live = idle <= period # 直近 1 周期以内に実行された=稼働中
if not referenced and live:
findings.append(( "PHANTOM" , f "ソース参照なし・しかし { idle } 日前に実行。ID が動的生成されている疑い" ))
elif not referenced:
findings.append(( "UNREACHABLE" , f "ソース参照なし・ { idle } 日休眠。退役可" ))
たった一行の分岐ですが、台帳の性格が変わりました。「参照が無い」を退役の根拠ではなく、観測と突き合わせるべき仮説 として扱うようになったからです。
休眠日数を一律にすると、両方向に間違える
もう一つ、最初に迷ったのが休眠の判定です。「90 日呼ばれていなければ退役候補」といった一律の閾値は、実装が楽で、直感にも合います。
同じサンプル台帳に対して、一律 90 日で判定した場合と、スケジュール周期を考慮した場合を並べてみました。
プロンプト ID 休眠日数 スケジュール 一律90日判定 周期考慮の判定
wallpaper.classify.v3 0 日 daily 維持 ACTIVE
store.metadata.translate.v4 5 日 weekly 維持 ACTIVE
store.screenshot.caption.v1 26 日 monthly 維持 MODEL_EOL
review.reply.draft.v2 85 日 daily 維持 GATED
tax.yearend.summary.v1 210 日 yearly 退役 ACTIVE
wallpaper.caption.legacy 162 日 on_demand 退役 UNREACHABLE
一律の閾値は、二方向に外していました。
年末にだけ動く集計プロンプトは、210 日休眠していても正常です。一律 90 日ならこれを退役候補に挙げてしまいます。逆に、毎日動くはずの返信下書きが 85 日止まっているのは明確な異常ですが、90 日には届かないので一律判定は素通りします。消すべきでないものを消し、拾うべきものを取りこぼす — 一つの数字で両方を賄おうとした結果です。
採用したのは、スケジュール周期に紐づく相対的な窓です。
PERIOD_DAYS = { "on_demand" : 30 , "daily" : 1 , "weekly" : 7 , "monthly" : 31 , "yearly" : 365 }
period = PERIOD_DAYS .get(entry.get( "schedule" , "on_demand" ), 30 )
window = period * 2 # 1 周期では休眠と停止を区別できない
倍率を 2 にしたのには理由があります。1 周期ちょうどでは、実行が数時間ずれただけで休眠扱いになります。3 周期以上にすると、日次の資産で異常検知が一週間近く遅れます。日次から年次までを一つの式で扱うなら、2 倍が扱いやすい落としどころでした。年次の資産に対して 730 日の観測窓を要求するのは長すぎるようにも見えますが、年次バッチの停止は年に一度しか観測機会がない以上、これは仕様として受け入れるべき性質だと考えています。
台帳の全体像 — 所見を先勝ちにしない
書き直した実装の全体です。ポイントは、分類を一つに絞らず、該当する所見をすべて返す ところにあります。
#!/usr/bin/env python3
"""プロンプト資産の退役台帳。静的参照・フラグ既定値・観測窓・モデル EOL・
キャッシュ実効率を突き合わせ、該当する所見を「すべて」出す(先勝ちにしない)。"""
from __future__ import annotations
import json, re, sys
from datetime import datetime, timezone
from pathlib import Path
PERIOD_DAYS = { "on_demand" : 30 , "daily" : 1 , "weekly" : 7 , "monthly" : 31 , "yearly" : 365 }
SOURCE_SUFFIXES = { ".py" , ".ts" , ".tsx" , ".js" , ".swift" , ".kt" , ".gs" }
# 停止日が確定しているモデル(ai.google.dev の deprecations ページで要確認)
MODEL_EOL = {
"gemini-3.1-flash-image-preview" : "2026-08-17" ,
"gemini-3-pro-image-preview" : "2026-08-17" ,
"gemini-2.5-flash" : "2026-06-01" ,
}
SEVERITY = { "PHANTOM" : 0 , "MODEL_EOL" : 1 , "ZOMBIE_CACHE" : 2 , "GATED_BUT_LIVE" : 3 ,
"UNREACHABLE" : 4 , "GATED" : 5 , "DORMANT_CONFIRMED" : 6 ,
"DORMANT_WATCH" : 7 , "ACTIVE" : 8 }
def scan_references (root: Path) -> dict[ str , list[ str ]]:
"""ソース中に literal として現れるプロンプト ID を集める。"""
found: dict[ str , list[ str ]] = {}
for path in root.rglob( "*" ):
if path.suffix not in SOURCE_SUFFIXES or not path.is_file():
continue
text = path.read_text( encoding = "utf-8" , errors = "replace" )
for literal in re.findall( r " [ \" ' ]([ a-z0-9_. \- ] + \. [ a-z0-9_ \- ] + )[ \" ' ] " , text):
found.setdefault(literal, []).append( str (path))
return found
def audit (entry: dict , refs: dict , flags: dict , now: datetime) -> list[tuple[ str , str ]]:
pid, findings = entry[ "id" ], []
period = PERIOD_DAYS .get(entry.get( "schedule" , "on_demand" ), 30 )
window = period * 2
last_hit = datetime.fromisoformat(entry[ "last_hit" ].replace( "Z" , "+00:00" ))
idle = (now - last_hit).days
referenced = pid in refs
flag = entry.get( "flag" )
flag_on = flags.get(flag, False ) if flag is not None else True
live = idle <= period
# 静的に見えないのに動いている経路。台帳で最も危険な状態
if not referenced and live:
findings.append(( "PHANTOM" ,
f "ソース参照なし・しかし { idle } 日前に実行。ID が動的生成されている疑い" ))
elif not referenced:
findings.append(( "UNREACHABLE" , f "ソース参照なし・ { idle } 日休眠。退役可" ))
if not flag_on:
label = "GATED_BUT_LIVE" if live else "GATED"
findings.append((label, f "フラグ { flag } が既定 false・最終実行 { idle } 日前" ))
# 到達不能な資産の EOL 警告はノイズ。移行ではなく削除で片づく
eol = MODEL_EOL .get(entry.get( "model" , "" ))
if eol and (referenced or live):
findings.append(( "MODEL_EOL" , f " { entry[ 'model' ] } は { eol } 停止。移行期限あり" ))
ratio = entry.get( "cache_hit_ratio_30d" )
if entry.get( "cached_content" ) and ratio is not None and ratio < 0.5 :
findings.append(( "ZOMBIE_CACHE" , f "キャッシュ実効率 { ratio :.0% } ・全文課金で稼働中" ))
if referenced and flag_on and not live:
if idle > window:
findings.append(( "DORMANT_CONFIRMED" ,
f "観測窓 { window } 日超の { idle } 日休眠。退役候補" ))
else :
findings.append(( "DORMANT_WATCH" ,
f " { idle } 日休眠・観測窓 { window } 日に未達。保留" ))
return findings or [( "ACTIVE" , f "最終実行 { idle } 日前・周期 { period } 日以内" )]
def main (root: str = "." ) -> int :
base = Path(root)
inventory = json.loads((base / "prompt_inventory.json" ).read_text( encoding = "utf-8" ))
flags = json.loads((base / "flags.json" ).read_text( encoding = "utf-8" ))
refs = scan_references(base / "src" )
now = datetime.now(timezone.utc)
rows = []
for entry in inventory:
for status, reason in audit(entry, refs, flags, now):
rows.append((entry[ "id" ], status, reason))
rows.sort( key =lambda r: ( SEVERITY [r[ 1 ]], r[ 0 ]))
width = max ( len (r[ 0 ]) for r in rows)
for pid, status, reason in rows:
print ( f " { pid :< { width }} { status :<18 } { reason } " )
blocking = {p for p, s, _ in rows if SEVERITY [s] <= 3 }
print ( f " \n 所見 { len (rows) } 件 / 資産 { len (inventory) } 件・要対応 { len (blocking) } 資産" )
return 1 if blocking else 0
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 ] if len (sys.argv) > 1 else "." ))
入力側の台帳は、こういう素朴な JSON にしています。既存の設定から機械的に生成できる形に留めておくのが長続きの条件でした。
[
{ "id" : "wallpaper.classify.v3" , "model" : "gemini-3.5-flash" , "flag" : "wallpaper_auto_tag" ,
"schedule" : "daily" , "cached_content" : "cachedContents/wp-taxonomy" ,
"cache_hit_ratio_30d" : 0.94 , "last_hit" : "2026-07-26T03:10:00Z" },
{ "id" : "wallpaper.classify.v2" , "model" : "gemini-3.5-flash" , "flag" : "wallpaper_auto_tag" ,
"schedule" : "daily" , "cached_content" : "cachedContents/wp-taxonomy" ,
"cache_hit_ratio_30d" : 0.11 , "last_hit" : "2026-07-25T03:10:00Z" },
{ "id" : "tax.yearend.summary.v1" , "model" : "gemini-3.5-flash" , "flag" : null ,
"schedule" : "yearly" , "cached_content" : "cachedContents/tax-rules" ,
"cache_hit_ratio_30d" : null , "last_hit" : "2025-12-28T22:00:00Z" }
]
last_hit と cache_hit_ratio_30d は、呼び出しのたびに書き出しているログから日次で集計しています。AI Studio のダッシュボードから Interactions API の開発者ログを追えるようになったので、自前の集計と食い違ったときの答え合わせが以前より楽になりました。台帳の値が本当に信用できるかどうかは、この照合を一度でもやったかどうかで変わります。
実行結果の読み方
サンプルの台帳に対して走らせた出力です(実行日は 2026-07-27・last_hit は台帳の値そのままです。日付が進むと休眠日数が変わるため、手元で再現する際は last_hit を実行日に合わせてください)。
wallpaper.classify.v2 PHANTOM ソース参照なし・しかし 1 日前に実行。ID が動的生成されている疑い
store.screenshot.caption.v1 MODEL_EOL gemini-3.1-flash-image-preview は 2026-08-17 停止。移行期限あり
wallpaper.classify.v2 ZOMBIE_CACHE キャッシュ実効率 11%・全文課金で稼働中
wallpaper.caption.legacy UNREACHABLE ソース参照なし・162 日休眠。退役可
review.reply.draft.v2 GATED フラグ review_autoreply が既定 false・最終実行 85 日前
store.metadata.translate.v4 ACTIVE 最終実行 5 日前・周期 7 日以内
tax.yearend.summary.v1 ACTIVE 最終実行 210 日前・周期 365 日以内
wallpaper.classify.v3 ACTIVE 最終実行 0 日前・周期 1 日以内
所見 8 件 / 資産 7 件・要対応 2 資産
先勝ちをやめた効果が、wallpaper.classify.v2 の二行に出ています。この資産は幽霊経路であると同時に、キャッシュ実効率 11 パーセントで動いている割高な経路でもありました。先勝ちの分類器では、最初に当たった UNREACHABLE の陰に隠れてキャッシュの所見が出ません。分類を一つに絞る設計は、二つ目以降の問題を構造的に見えなくします。
もう一つ、書き直しの過程で削った所見があります。当初は wallpaper.caption.legacy に対して MODEL_EOL と UNREACHABLE の二つが出ていました。停止済みモデルを参照している、かつ到達不能な資産です。移行期限を告げられても、やることは移行ではなく削除ですから、この警告はノイズでした。
# 到達不能な資産の EOL 警告はノイズ。移行ではなく削除で片づく
if eol and (referenced or live):
多重ラベルにすると所見は増えます。増えた分だけ、要らない所見を落とす作業が必要になる。全部出せば良いというものでもない、という当たり前のことを、出力を眺めて初めて実感しました。
キャッシュが死ぬのは、プロンプトが死ぬより先
台帳を組んでみて、いちばん認識が変わったのがここです。
コンテキストキャッシュには TTL があります。プロンプト資産そのものは生きていて、コードからも参照されていて、毎日きちんと動いている。それでもキャッシュのハンドルだけが先に失効し、以後は毎回フルの入力トークンを支払い続ける、という状態が成立します。
これは失敗として観測されません。レスポンスは返りますし、エラーにもならず、出力の品質も変わりません。変わるのは請求だけです。個人開発の収支では、AdMob の収益側は毎日ダッシュボードで確認するのに、支出側は月末の明細まで誰も見ない — その非対称が、この種の劣化を長く生き延びさせます。退役台帳を「消してよいものを探す道具」だと思って作り始めたのに、実際に最初に効いたのは、消してはいけない資産の稼働品質を見つける機能 のほうでした。
判定の閾値を 0.5 に置いたのは、これを下回るとキャッシュを持っている意味がほぼ無くなるからです。厳密な損益分岐は入力サイズと呼び出し頻度で動きますが、実効率が半分を切った時点で、TTL 設定か再作成の手順のどちらかが壊れていると考えて調べたほうが早い、というのが手を動かしてみての結論です。
順序としては、こう扱っています。
PHANTOM — まず経路を特定する。消す判断はその後
ZOMBIE_CACHE / GATED_BUT_LIVE — 稼働中で費用または意図に問題がある。是正が先
MODEL_EOL — 停止日から逆算して移行計画に載せる
UNREACHABLE / DORMANT_CONFIRMED — ここで初めて退役の話をする
退役から始めないこと。これが、実際に走らせて得た一番の学びでした。
運用に載せる
台帳は、作った日がいちばん精度が高く、そこから落ちていきます。落とさないために、二つの仕掛けを置いています。
一つは CI での自動実行です。重大度 3 以下の所見が一つでも残っていれば終了コード 1 を返すようにしてあるので、そのままジョブを落とせます。ただし、落とす境界は慎重に選びました。DORMANT_CONFIRMED で CI を落とすと、休暇明けに必ず赤くなって、やがて誰も見なくなります。落とすのは「稼働中に問題がある」層だけに絞るのが、長く続ける条件でした。
もう一つは、四半期ごとの棚卸しです。CI は所見の増加を止めますが、判定ルールそのものの陳腐化は止められません。モデルの停止日一覧は更新が要りますし、スケジュール周期の定義も実態とずれていきます。年に四回、MODEL_EOL の辞書と PERIOD_DAYS を実際の運用と突き合わせる時間を取るようにしました。
プロンプトを増やすのは簡単です。個人開発なら、試したい思いつきがあればその日のうちに動くものが作れてしまう。増やした分をたたむ仕組みを持たないまま数を重ねると、どこかで請求書のほうから知らせが来ます。台帳は、その知らせを自分の側から先に取りに行くための道具でした。
まずは手元の呼び出し箇所を数え、last_hit を記録するところから始めてみてください。判定ロジックはその後からで間に合います。お読みいただきありがとうございました。