Gemini 3.6 Flash へ切り替えた翌日、個人開発で回している画像分類パイプラインの出力が、以前より雑になっていました。
カテゴリの粒度が粗い。以前なら二段目まで踏み込んでいた判定が、一段目で止まっています。
モデルは新しくなったはずです。単価も下がり、トークン効率も上がったと発表されています。それなのに、出力の質だけが後ろへ下がりました。
原因はモデルではありませんでした。システム命令に、ずいぶん前に書いた一行が残っていたのです。
2025年11月時点で、この API は画像とテキストの同時入力に非対応です。
3.5 Flash 時代には、これは正しい補足でした。モデルが知らない事実を、こちらが補ってあげていたわけです。
けれど 3.6 Flash では知識カットオフが 2025年1月から 2026年3月へ前進しています(値は公式のリリースノートでご確認ください)。モデルは、その仕様がすでに変わったことを自分で知っています。
補足だったものが、矛盾に変わった瞬間でした。
古くなるのはモデルではなくプロンプトの側です
知識カットオフの話題は、たいてい「モデルが最近のことを知らない」という方向で語られます。
実務で先に痛むのは、逆側でした。
システム命令に書いた日付付きの断定は、書いた瞬間から固定されます。モデルの知識だけが更新され、プロンプトは更新されません。カットオフが前進するたび、両者の差は開きます。
しかも壊れ方が静かです。API エラーにはなりません。構造化出力のスキーマ検証も通ります。ただ、モデルが自分の知識と命令文のどちらを信じるか迷い、出力が慎重な方へ寄っていく。それだけです。
私の場合、その「慎重さ」がカテゴリ粒度の低下として表に出ました。矛盾を抱えたモデルは、断定を避ける方向に振れます。
危険域は「2つの日付の間」だけに閉じます
では、システム命令に書いた日付付きの記述を全部見直すべきかというと、そうではありません。
書いた時期によって、意味が三つに分かれます。
| 記述の時期 |
3.5 Flash 時代の役割 |
3.6 Flash での意味 |
対処 |
| 旧カットオフ(2025年1月)より前 |
もともとモデルが知っていた領域の重複 |
変わらず重複。トークンを消費するだけ |
削除候補(優先度は低い) |
| 旧カットオフと新カットオフの間 |
モデルの盲点を埋める有効な補足 |
モデル自身の知識と競合する |
最優先で確認・書き換え |
| 新カットオフ(2026年3月)以降 |
(まだ存在しない記述) |
引き続き必要な補足 |
維持 |
真ん中の帯だけが、役割を反転させます。
かつて最も価値のあった行が、いま最も邪魔をしている。この非対称が、私にとっては意外でした。
そして帯の幅は 2025年1月から 2026年3月まで、14ヶ月あります。3.5 Flash を本番で使っていた期間とほぼ重なる長さです。つまり、その期間に真面目に補足を書き足した人ほど、反転する行を多く抱えていることになります。
窓を機械判定する監査ツール
手で洗い出すには、システム命令は長すぎます。判定ロジックはごく単純なので、スクリプトに落としました。
やることは二つです。日本語のシステム命令から「日付が刺さった断定」を抽出し、その日付を2つのカットオフと比較して三分類する。それだけです。
#!/usr/bin/env python3
"""cutoff_window_audit.py — システム命令の日付付き断定を2つのカットオフで三分類する。"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from datetime import date
from pathlib import Path
JP_DATE = re.compile(r"(?P<y>19\d{2}|20\d{2})\s*年(?:\s*(?P<m>1[0-2]|0?[1-9])\s*月)?")
ISO_DATE = re.compile(r"(?P<y>20\d{2})-(?P<m>0[1-9]|1[0-2])(?:-\d{2})?\b")
ASSERTIVE = (
"未対応", "非対応", "対応していません", "存在しません", "使えません",
"利用できません", "サポートされていません", "最新", "現時点", "現在のところ",
"not supported", "does not exist", "unavailable", "latest",
)
HEDGED = ("かもしれません", "可能性があります", "may ", "might ", "例:", "例)", "e.g.")
# 断定語彙に載らない事実記述(「上限は N です」型)を拾うための第2段
DECLARATIVE = re.compile(r"(?:です|ます|である|だ)[。.]?$")
IMPERATIVE = re.compile(r"(?:ください|下さい|ましょう|しなさい)[。.]?$")
@dataclass
class Assertion:
source: str
line_no: int
text: str
asserted_on: str | None
verdict: str
reason: str
def _to_month(y: int, m: int | None) -> date:
return date(y, m or 1, 1)
def extract_date(line: str) -> date | None:
"""行に刺さっている月精度の日付のうち、最も古いものを返す。"""
found: list[date] = []
for pat in (JP_DATE, ISO_DATE):
for hit in pat.finditer(line):
y = int(hit.group("y"))
raw_m = hit.group("m")
m = int(raw_m) if raw_m else None
try:
found.append(_to_month(y, m))
except ValueError:
# 13月などの不正な組み合わせは黙って捨てる
continue
return min(found) if found else None
def is_assertive(line: str, dated: bool = False) -> bool:
low = line.lower()
if any(h in low for h in HEDGED):
return False
if any(tok.lower() in low for tok in ASSERTIVE):
return True
# 日付が刺さっていて、命令文ではない断定で終わる行も事実記述とみなす
if dated and DECLARATIVE.search(line) and not IMPERATIVE.search(line):
return True
return False
def classify(pinned: date | None, old_cutoff: date, new_cutoff: date) -> tuple[str, str]:
if pinned is None:
return "undated", "日付が特定できないため人手確認"
if pinned < old_cutoff:
return "redundant", "旧カットオフ以前。モデルが既に知っていた領域"
if pinned < new_cutoff:
return "contested", "2つのカットオフの間。補足が矛盾に反転する危険域"
return "current", "新カットオフ以降。引き続き必要"
def audit(paths: list[Path], old_cutoff: date, new_cutoff: date) -> list[Assertion]:
out: list[Assertion] = []
for path in paths:
try:
body = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
print(f"skip {path}: {exc}", file=sys.stderr)
continue
for i, line in enumerate(body.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
pinned = extract_date(stripped)
if not is_assertive(stripped, dated=pinned is not None):
continue
verdict, reason = classify(pinned, old_cutoff, new_cutoff)
out.append(
Assertion(
source=path.name,
line_no=i,
text=stripped[:120],
asserted_on=pinned.isoformat() if pinned else None,
verdict=verdict,
reason=reason,
)
)
return out
def parse_month(text: str) -> date:
y, m = text.split("-")[:2]
return date(int(y), int(m), 1)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+", type=Path)
ap.add_argument("--old-cutoff", required=True, help="例: 2025-01")
ap.add_argument("--new-cutoff", required=True, help="例: 2026-03")
ap.add_argument("--json", action="store_true")
ap.add_argument("--fail-on-contested", action="store_true")
args = ap.parse_args()
old_cutoff, new_cutoff = parse_month(args.old_cutoff), parse_month(args.new_cutoff)
if old_cutoff >= new_cutoff:
ap.error("--old-cutoff は --new-cutoff より前である必要があります")
files = [p for p in args.paths if p.is_file()]
rows = audit(files, old_cutoff, new_cutoff)
if args.json:
print(json.dumps([asdict(r) for r in rows], ensure_ascii=False, indent=2))
else:
for r in rows:
print(f"[{r.verdict:9}] {r.source}:{r.line_no} ({r.asserted_on or '日付なし'}) {r.text}")
counts: dict[str, int] = {}
for r in rows:
counts[r.verdict] = counts.get(r.verdict, 0) + 1
print("---", " ".join(f"{k}={v}" for k, v in sorted(counts.items())) or "検出なし")
contested = sum(1 for r in rows if r.verdict == "contested")
return 1 if (args.fail_on_contested and contested) else 0
if __name__ == "__main__":
raise SystemExit(main())
判定の中心は classify() の三分岐だけです。難しいのは、その手前の「どの行が事実の断定なのか」を見分ける方だと、あとで思い知ることになります。
第1版が取りこぼした2行
検証用に、実際に書いていたものへ近い11行のシステム命令を用意しました。
# 画像分類アシスタントのシステム命令(抜粋・監査対象)
2024年12月時点で、日本語のカテゴリ名は正規化せずそのまま返してください。
2023年5月時点では構造化出力は未対応のため、JSON は本文中に埋め込んで返してください。
2025年6月時点で thinking_budget は Flash 系では利用できません。
2025年11月時点で、この API は画像とテキストの同時入力に非対応です。
2026-01 時点で Files API のアップロード保持期間は48時間です。
2026年4月時点で、Batch API の同時実行上限はプロジェクト単位です。
2026-05 時点で、この機能はサポートされていません。
出力は常に日本語で、丁寧語を用いてください。
迷った場合は「判定不能」を返してかまいません。
2025年8月時点で latest エイリアスは自動更新されます。
モデルの応答が長くなる可能性があります。2025年3月時点の挙動です。
最初の実装は、ASSERTIVE の語彙リストだけで判定していました。結果はこうです。
[redundant] ...:3 (2023-05-01) 2023年5月時点では構造化出力は未対応のため...
[contested] ...:4 (2025-06-01) 2025年6月時点で thinking_budget は Flash 系では利用できません。
[contested] ...:5 (2025-11-01) 2025年11月時点で、この API は画像とテキストの同時入力に非対応です。
[current ] ...:8 (2026-05-01) 2026-05 時点で、この機能はサポートされていません。
[contested] ...:11 (2025-08-01) 2025年8月時点で latest エイリアスは自動更新されます。
--- contested=3 current=1 redundant=1
5件。正解は7件ですから、検出率は 71% にとどまりました。
抜けたのは、次の2行でした。
2026-01 時点で Files API のアップロード保持期間は48時間です。
2026年4月時点で、Batch API の同時実行上限はプロジェクト単位です。
どちらも「未対応」「使えません」といった否定語を含みません。仕様の値を、ただ書いてあるだけです。
けれど陳腐化という観点では、この形が最も危険でした。数値や区分は、否定形の記述より静かに変わります。しかも読み手からは変化が見えません。
そこで第2段の判定を足しました。日付が刺さっていて、命令形で終わらず、断定の語尾(です/ます/である)で終わる行も事実記述とみなす、というルールです。
命令形の除外を入れたのは、2026年2月の売上データを月次で集計してください。 のような行を巻き込まないためです。これを入れないと、日付を含む業務指示がすべて監査対象に化けます。
第2版の結果です。
[redundant] ...:3 (2023-05-01) 2023年5月時点では構造化出力は未対応のため...
[contested] ...:4 (2025-06-01) 2025年6月時点で thinking_budget は Flash 系では利用できません。
[contested] ...:5 (2025-11-01) 2025年11月時点で、この API は画像とテキストの同時入力に非対応です。
[contested] ...:6 (2026-01-01) 2026-01 時点で Files API のアップロード保持期間は48時間です。
[current ] ...:7 (2026-04-01) 2026年4月時点で、Batch API の同時実行上限はプロジェクト単位です。
[current ] ...:8 (2026-05-01) 2026-05 時点で、この機能はサポートされていません。
[contested] ...:11 (2025-08-01) 2025年8月時点で latest エイリアスは自動更新されます。
--- contested=4 current=2 redundant=1
7件すべて拾えました。検出率は 71% から 100% へ上がっています。
誤検出の側も測っています。日付は出るが陳腐化しない6行(業務指示・転記対象の日付文字列・テスト用の固定日付など)を負例として用意し、第1版・第2版ともに検出0、つまり誤検出率 0% でした。命令形の除外がなければ、この6行のうち4行、率にして 67% が誤検出に化けていた計算です。
なお、末尾の モデルの応答が長くなる可能性があります。2025年3月時点の挙動です。 は、意図的に検出していません。可能性があります を含む行は HEDGED で先に落としています。断定していない記述は、モデルの知識と正面から衝突しないという判断です。ここは好みが分かれるところで、厳しく見たい場合は HEDGED を空にすれば拾えます。
処理速度は問題になりませんでした。同じ内容を複製した500ファイル・9,500行に対して、判定は中央値 0.093 秒です。1ファイルあたり 0.186 ミリ秒、毎秒およそ 102,000 行という水準でした(Python 3.10.12・x86_64・4コアのコンテナ上、5回実行の中央値)。CI の1ステップとして常時走らせても、体感には出ません。
「12ヶ月以上前の記述を洗う」では半分もすくえませんでした
ここまで作ってから、素朴な疑問が浮かびました。
カットオフの日付を持ち出さず、単に「古い記述を棚卸しする」ではいけないのか。運用としては、そちらの方が簡単です。
同じ11行に対して、二つの基準で危険域の集合を出してみました。片方は本記事の窓判定(contested)、もう片方は「今日から12ヶ月以上前に書かれた記述」という経過月数の基準です。
| 基準 |
拾った行 |
件数 |
| カットオフ窓判定(contested) |
4, 5, 6, 11 |
4 |
| 経過12ヶ月判定 |
3, 4, 11 |
3 |
| 共通 |
4, 11 |
2 |
一致率は Jaccard 係数で 0.40 でした。
内訳の方が示唆的です。経過月数の基準は、行3(2023年5月の記述)を危険域として上げてきます。これは旧カットオフより前の記述で、モデルがもともと知っていた領域です。害はありません。ただトークンを食っているだけです。
一方で、行5(2025年11月)と行6(2026-01)を取りこぼします。ここがまさに、私が実際に踏んだ場所でした。
なぜこうなるかというと、経過月数は「今日」を基準にした尺度だからです。壊れ方を決めているのは今日ではなく、モデルのカットオフが動いた地点です。基準にする軸が違えば、拾う集合も違って当然でした。
古さと危険度は比例しません。私はこの一点だけでも、監査の軸を経過月数からカットオフへ置き換える価値があると考えています。
CI に載せるときの設計
判定が単純なので、ゲート化も難しくありません。--fail-on-contested を付けると、危険域が1件でも残っていれば終了コード1で落ちます。
python3 cutoff_window_audit.py prompts/*.txt \
--old-cutoff 2025-01 --new-cutoff 2026-03 --fail-on-contested
運用してみて、いくつか調整が要りました。次にカットオフが動いたときは、この順番でたどれば済むようにしてあります。
- カットオフの値をスクリプトに埋め込まない — 引数として外から渡す形にしておくと、旧値を新値に、新値を次の値に繰り上げるだけで済みます。判定コード自体はそのまま使えます。
- 意図して残す断定に印を付ける — 社内固有の運用ルールなど、モデルが知りようのない事実は日付を書いてでも残す必要があります。私は行末に
# pinned:intentional を付けて判定から外しました。除外の理由を同じ行に書ける形にしたのは、半年後の自分に説明するためです。
undated は落とさず警告に回す — 日付が刺さっていない断定は、このツールでは判定できません。ここを厳しくすると、開発中に日付を書き忘れただけでビルドが止まります。
- 契約テストと同じ扱いにする — 危険域が残っている状態を「壊れている」と定義し、修正するまで先へ進めない構造にしておくと、棚卸しが後回しになりません。
厳しさの設定は、プロンプトをどれだけ頻繁に触るかで変わります。日常的に書き換えるチームには、まず警告のみで導入することを推奨します。逆に、システム命令が半年単位でしか動かない構成を採用する場合は、最初から --fail-on-contested を有効にしてかまいません。この場合は、カットオフが動いた日が唯一の修正機会になるためです。
もう一つ、記録の入れ物として _recorded 系のフィクスチャと相性がよいことにも気づきました。モデル停止日をフィクスチャの鮮度で見る仕組みは以前に録画フィクスチャとモデル停止の freshness gateで書きましたが、あれが「外部の期限」を見る仕組みなのに対して、今回のものは「自分が書いた断定の期限」を見ています。監視する対象が違うだけで、発想は同じでした。
そもそも断定を書かないという選択
ここまで監査の話をしてきましたが、根本の対処は別にあります。
システム命令に、時制に依存する事実を書かないことです。
とはいえ現実には、書かざるを得ない場面があります。モデルが本当に知らない社内仕様、直近すぎて学習に入っていない変更。そういう補足は今後も必要です。
私が変えたのは、書き方の方でした。
# 修正前
2025年11月時点で、この API は画像とテキストの同時入力に非対応です。
# 修正後
画像とテキストの同時入力について、あなたの知識と実際の挙動が食い違う場合は、
実際に返ってきたエラーメッセージを優先してください。
事実を固定する代わりに、判断の優先順位を書く。こうしておくと、カットオフが前進してモデルの知識が正しくなったとき、命令文が自然に無効化されます。壊れる代わりに、静かに役目を終えます。
すべての補足をこの形に書き換えられるわけではありません。それでも、書き換えられる行から順に減らしていけば、次にカットオフが動いたときの棚卸しは軽くなります。
サンプリングパラメータのように、明示的なエラーで移行を教えてくれる変更は、まだ親切な部類だったのだと今は思います(こちらはtemperature 非推奨で先に困ったのは決定性ではなく多様性の側でしたにまとめています)。知識カットオフの前進には、そういう合図がありません。
まとめ
モデルのカットオフが動いたら、まず旧カットオフと新カットオフに挟まれた期間に書いた断定を洗い出してください。そこだけが、補足から矛盾へ反転します。
本記事のスクリプトをそのまま使う場合は、--old-cutoff に切り替え前のモデルのカットオフ、--new-cutoff に切り替え後のカットオフを渡すところから始めていただければ十分です。
私自身、この仕組みを入れたのは出力が雑になった原因を三日追いかけた後でした。もっと早く測っていれば、と思っています。同じ回り道をされる方が一人でも減れば嬉しいです。お読みいただきありがとうございました。