gemini-flash-latest が指す実体が 3.5 Flash に入れ替わった日、私の夜間処理は何事もなく完走しました。
エラーはゼロ。信頼度ゲートに引っかかった件数も、前日とほとんど同じ。ダッシュボードは静かなままでした。
おかしいと気づいたのは、それから数日後です。ストア用のメタデータをたまたま眺めていて、あるロケールの文言が妙に平板になっていることに気づきました。文字数制限は守られている。禁止語も入っていない。スキーマにも適合している。ただ、以前なら含まれていた製品固有の言い回しが、どの言語でも同じ骨格の説明文に均されていたのです。
モデルは迷っていませんでした。迷っていないから、信頼度ゲートは何も言わなかった。
個人開発で複数のアプリのストア文言を生成している都合上、私は全件を目で追うことができません。だからこそ信頼度でふるいにかけていたのですが、このとき理解したのは、レビュー対象を「モデルが自信のない出力」に絞る設計が、静かな劣化に対しては構造的に無力だということでした。自信を持って外し続ける出力は、レビューキューに一度も現れません。
信頼度ゲートの盲点は、通過した側にある
Human-in-the-Loop の定石は、信頼度でふるいにかけて、低い側だけ人間に回すというものです。私もHuman-in-the-Loop ワークフローの本番導入 で書いた通りの3層構成を組んでいました。
この設計は、モデルの不確かさと出力の誤りが相関している限りうまく働きます。破綻するのは、その相関が切れたときです。
モデルの実体が入れ替わる、プロンプトの一部が別の解釈をされる、参照データの傾向が変わる。こうした変化はモデルの確信度を下げません。新しい実体は、新しい前提のもとで自信を持って答えます。
誤りの型 モデルの確信度 信頼度ゲート 気づく経路
入力が曖昧・情報不足 低い 捕捉できる レビューキュー
スキーマ違反・空出力 — 捕捉できる バリデーション
実体入れ替えによる傾向変化 高いまま 素通り 抜き取りか、偶然
プロンプト改修の副作用 高いまま 素通り 抜き取りか、偶然
下2行の「偶然」を「抜き取り」に置き換えること。それが今回の設計の目的です。
昇格ゲートを組んでいれば防げるのでは、という指摘はもっともです。実際、gemini-flash-latest の切替に備える昇格ゲート は今も動いています。ただ、昇格ゲートが守っているのはゴールデンセットという既知の入力集合です。本番の入力分布は、そこからじわじわとずれていきます。ゴールデンセットで測れるのは「昨日の想定に対する回帰」であって、「今日の実データに対する当たり」ではありません。
両方が要ります。前者は変更のたびに。後者は毎日、少しずつ。
抜き取り母集団を「自動採用された出力」に切り直す
まず定義を変えます。抜き取りの母集団は、生成した全件ではありません。バリデーションを通り、信頼度ゲートも通り、人間が一度も見ないまま本番に出た出力 だけです。
この切り分けは効きます。私の場合、日次で生成する 1,200 件前後のうち、既にレビューキューに入るのが 40〜60 件、スキーマ違反で弾かれるのが数件。残る 1,150 件ほどが、誰の目にも触れずに出ていく層でした。母集団を全件にしてしまうと、抜き取ったサンプルの一部が「どうせレビューされる側」に当たってしまい、限られた予算が二重に使われます。
実装は、採用経路をレコードに刻むところから始まります。
# accepted_pool.py
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
import sqlite3, json
@dataclass
class GenerationRecord :
record_id: str
model_id: str # 解決後の固定モデルID(エイリアスではなく実体)
prompt_rev: str # プロンプトのリビジョン
locale: str
surface: str # 出力の用途(description / keywords など)
route: str # "auto" | "review_queue" | "rejected"
changed_input: bool # 入力に前回からの差分があったか
created_at: str
def init (db: sqlite3.Connection) -> None :
db.execute( """
CREATE TABLE IF NOT EXISTS generations (
record_id TEXT PRIMARY KEY,
model_id TEXT NOT NULL,
prompt_rev TEXT NOT NULL,
locale TEXT NOT NULL,
surface TEXT NOT NULL,
route TEXT NOT NULL,
changed_input INTEGER NOT NULL,
created_at TEXT NOT NULL
)
""" )
db.execute( "CREATE INDEX IF NOT EXISTS idx_pool ON generations(route, created_at)" )
db.commit()
def record (db: sqlite3.Connection, r: GenerationRecord) -> None :
d = asdict(r)
d[ "changed_input" ] = int (r.changed_input)
db.execute(
"INSERT OR REPLACE INTO generations VALUES "
"(:record_id,:model_id,:prompt_rev,:locale,:surface,:route,:changed_input,:created_at)" ,
d,
)
db.commit()
def auto_accepted_pool (db: sqlite3.Connection, day: str ) -> list[ dict ]:
cur = db.execute(
"SELECT * FROM generations WHERE route='auto' AND created_at LIKE ?" ,
( f " { day } %" ,),
)
cols = [c[ 0 ] for c in cur.description]
return [ dict ( zip (cols, row)) for row in cur.fetchall()]
model_id にエイリアスではなく解決後の実体を刻んでいるのは、後から「いつ入れ替わったか」を母集団側から復元するためです。エイリアス名だけを保存していると、実体が変わった日を特定する手がかりが記録に残りません。これは実体入れ替えを経験するまで、私は軽視していた点でした。
「何日目に気づけるか」を先に決める
抜き取り検査の設計で最初に答えるべき問いは、何件見るかではありません。どれくらいの不良率なら、何日以内に気づきたいか です。ここを決めないと、サンプル数は勘で決まります。
1日に n 件を無作為抽出し、真の不良率が p のとき、その日に1件も不良を引かない確率は (1-p)^n です。d 日連続で引き当てられない確率は (1-p)^(n·d)。したがって d 日以内に少なくとも1件捉える確率は、次で求まります。
# detection_power.py
from math import log, ceil
def detect_prob (p: float , n: int , days: int ) -> float :
"""不良率 p の母集団から毎日 n 件抜いたとき、days 日以内に1件以上捉える確率"""
return 1.0 - ( 1.0 - p) ** (n * days)
def days_to_detect (p: float , n: int , confidence: float = 0.95 ) -> int :
"""confidence の確率で捉えるまでに要する日数"""
if p <= 0 or n <= 0 :
return 10 ** 9
return ceil(log( 1 - confidence) / (n * log( 1 - p)))
def daily_n_for (p: float , days: int , confidence: float = 0.95 ) -> int :
"""days 日以内・confidence で捉えるために必要な1日あたり抜き取り件数"""
return ceil(log( 1 - confidence) / (days * log( 1 - p)))
if __name__ == "__main__" :
for p in ( 0.01 , 0.02 , 0.05 , 0.10 ):
row = [ f "p= { p :.0% } " ]
for n in ( 10 , 20 , 30 ):
row.append( f "n= { n } : { days_to_detect(p, n) } 日" )
print ( " " .join(row))
手元で走らせると、次の表が出ます。95% の確率で1件以上を捉えるまでの日数です。
真の不良率 1日10件 1日20件 1日30件
1% 30日 15日 10日
2% 15日 8日 5日
5% 6日 3日 2日
10% 3日 2日 1日
この表を最初に見たとき、私は素直に落ち込みました。1日20件でも、不良率1%の劣化には15日かかる。半月です。
同時に、腑にも落ちました。冒頭の一件で私が気づくまでに数日を要したのは、注意力の問題ではなく、抜き取りをそもそも設計していなかったからです。ゼロ件の抜き取りに検出日数は定義できません。
ここで判断が要ります。1% の劣化を15日で見つけるのは、ストアメタデータという用途では許容できました。文言が少し平板になる程度の劣化で、15日分の生成物を後から直すコストは、レビュー時間を倍にするコストより軽い。一方で5%の劣化を1週間放置するのは避けたい。1日20件は、その二つの線の間に収まる選び方でした。
用途が変われば線も変わります。課金や送信を伴う出力なら、同じ表を見て別の結論になるはずです。大切なのは、件数を先に決めて表を後から眺めるのではなく、許容できる劣化の大きさと放置日数を先に言語化してから件数を導く 順序だと考えております。
同じ予算で検出を速める — 層別に配分する
1日20件という予算は動かさないまま、検出日数を縮める余地があります。無作為抽出をやめて、危ない層を厚く抜く ことです。
不良は一様に分布しません。実体が入れ替わった直後のモデル、リビジョンを変えたばかりのプロンプト、入力に差分があったレコード。これらの層は、他より事前確率が高い。層別配分は、この事前分布を予算配分に反映させる操作です。
# stratified_sampler.py
import random
from collections import defaultdict
# 層ごとのリスク重み。運用しながら手で調整する値
RISK_WEIGHTS = {
"new_model" : 4.0 , # 解決後モデルIDが直近7日以内に初出
"new_prompt" : 3.0 , # プロンプトリビジョンが直近7日以内に初出
"changed_input" : 2.0 , # 入力に前回からの差分あり
"steady" : 1.0 , # 上記いずれにも当たらない定常層
}
def stratum_of (rec: dict , fresh_models: set[ str ], fresh_prompts: set[ str ]) -> str :
if rec[ "model_id" ] in fresh_models:
return "new_model"
if rec[ "prompt_rev" ] in fresh_prompts:
return "new_prompt"
if rec[ "changed_input" ]:
return "changed_input"
return "steady"
def allocate (pool: list[ dict ], budget: int ,
fresh_models: set[ str ], fresh_prompts: set[ str ],
min_per_stratum: int = 2 , seed: int | None = None ) -> list[ dict ]:
rng = random.Random(seed)
strata: dict[ str , list[ dict ]] = defaultdict( list )
for rec in pool:
strata[stratum_of(rec, fresh_models, fresh_prompts)].append(rec)
present = {k: v for k, v in strata.items() if v}
# 定常層にも必ず最低枠を残す。ここを削ると「安全なはずの層」が永久に見られなくなる
reserved = {k: min (min_per_stratum, len (v)) for k, v in present.items()}
remaining = max (budget - sum (reserved.values()), 0 )
total_w = sum ( RISK_WEIGHTS [k] * len (v) for k, v in present.items())
picked: list[ dict ] = []
for k, v in present.items():
share = 0 if total_w == 0 else remaining * ( RISK_WEIGHTS [k] * len (v)) / total_w
n = min ( len (v), reserved[k] + int ( round (share)))
picked.extend(rng.sample(v, n))
return picked[:budget]
min_per_stratum を置いているのは、実運用で一度失敗したからです。重みだけで配分した週、定常層の抜き取りが0件になる日が続きました。リスクの高い層に予算を寄せた結果、「安全だと仮定した層」が仮定のまま検証されなくなる。層別化は事前確率を使う技法ですから、その事前確率自体が誤っていたときに気づく経路を、細くても残しておく必要があります。
配分を変えた効果は、素直に出ました。実体入れ替えのような、新規モデル層に不良が集中する型の劣化について、私の環境での実測は次の通りです。
抜き取り方式 1日あたり件数 新規モデル層への配分 不良1件目までの日数(4回の入れ替えの中央値)
抜き取りなし 0 — 4日(偶然の発見)
単純無作為 20 約2件 3日
層別配分 20 約9件 1日
母数が4回ですから、統計的に強い主張はできません。それでも、同じ20件で新規モデル層への配分が2件から9件に増えれば、その層に偏在する不良の検出が速くなるのは道理です。私はこの数字を、証明ではなく方向の確認として扱っております。
日次のしきい値では沈黙する劣化を、累積で捉える
抜き取ったサンプルを、その日ごとに「不良2件以上ならアラート」といったしきい値で見るのは、直感的ですが鈍い方法です。20件中1件の不良は、不良率5%でも1%でも普通に起こります。日次で判定する限り、緩やかな劣化はノイズに埋もれます。
必要なのは、日をまたいで積み上げる見方です。基準の不良率 p0 を置き、観測が p0 より上振れした分だけを累積し、下振れでは負に沈まないよう0で切る。工程管理でいう累積和の考え方です。
# sequential_monitor.py
from dataclasses import dataclass
@dataclass
class CusumState :
s: float = 0.0 # 累積和
days_above: int = 0 # しきい値超過が続いた日数
def update (state: CusumState, defects: int , sampled: int ,
p0: float = 0.01 , slack: float = 0.005 , limit: float = 0.06 ) -> tuple[CusumState, bool ]:
"""
p0 : 許容する定常不良率
slack : この幅の上振れは日常のゆらぎとして吸収する
limit : 累積和がこれを超えたら劣化とみなす
"""
if sampled == 0 :
return state, False
observed = defects / sampled
state.s = max ( 0.0 , state.s + (observed - p0 - slack))
fired = state.s > limit
state.days_above = state.days_above + 1 if fired else 0
return state, fired
def reset_after_action (state: CusumState) -> CusumState:
"""原因に手を入れたら累積をリセットする。放置したままだと鳴り続ける"""
return CusumState()
if __name__ == "__main__" :
# 前半は定常(1%相当)、後半に3%へ劣化したときの挙動
import random
rng = random.Random( 7 )
st = CusumState()
for day in range ( 1 , 21 ):
p = 0.01 if day <= 10 else 0.03
defects = sum ( 1 for _ in range ( 20 ) if rng.random() < p)
st, fired = update(st, defects, 20 )
print ( f "day { day :2d } p= { p :.0% } defects= { defects } S= { st.s :.3f } { 'ALERT' if fired else '' } " )
このスクリプトを回すと、不良率が1%から3%へ上がった11日目以降、累積和が数日かけて積み上がり、しきい値を越えて鳴ります。日次のしきい値なら、20件中1〜2件という数字は最後まで沈黙したままです。
slack と limit の値は、理屈から一意には決まりません。私は2週間ほど、定常期のデータだけで空回ししてから決めました。誤報が2週間に1回を超えると、私はアラートを真面目に見なくなる。逆に、実際の劣化を1週間見逃すのは困る。この二つの間で挟み込んだのが slack=0.005 / limit=0.06 という値です。
reset_after_action を分けているのは、原因に手を入れたあとも累積が残っていると鳴り続け、対処済みの事象で判断が鈍るからです。累積和は状態を持つ以上、状態を畳む操作を必ず対にしておく必要があります。
公式の資料に書かれていないところ
半年ほどこの仕組みを回して、資料には出てこないが実務で効いたことが三つありました。
レビュー票の設計が、検出力そのものを左右します。 「良い / 悪い」の二値で記録していた最初の1ヶ月、私のレビューは日によって基準がぶれていました。疲れている日の「良い」と、余裕のある日の「良い」が同じ意味ではない。用途ごとに具体的な観点を3つだけ並べ、各観点を満たすか否かで記録する形に変えてから、記録が再現するようになりました。抜き取り検査の精度は、サンプル数の前に、判定の一貫性に律速されます。
不良を見つけた日にすることは、修正ではありません。 最初の頃、私は不良を見つけると、その1件を直して満足していました。これは抜き取り検査の目的を取り違えています。抜き取りは母集団の状態を推定するための行為です。1件見つかったということは、その日の母集団に十数件から数十件が潜んでいるという推定を意味します。すべきは、同じ層をその場で追加抽出して不良率を測り直すことです。私は「1件出たら同じ層から10件を追加で抜く」という手順に固定しました。
抜き取り記録は、生成記録より長く残す価値があります。 生成物そのものは上書きされていきますが、いつ・どの層から・何件抜いて・何件が不良だったかという記録は、後から効きます。実体入れ替えの前後で不良率がどう動いたかを言えるのは、この記録だけだからです。容量も微々たるものでした。半年で数MBです。
状況別に、どこから手を付けるか
三つの選択肢を、私なりの推奨とともに並べます。
いまの状況 推奨する一手 理由
抜き取りをしていない route の記録だけ足す(サンプリングはまだしない) 母集団を定義できない状態では、何件抜いても検出日数を語れないため
無作為に抜いているが遅い 層別配分へ。予算は据え置く 実体入れ替え型の劣化は新規モデル層に偏るため、配分の変更だけで効く
日次しきい値で誤報が多い 累積和へ移し、定常データで2週間空回しする 日次判定は 20 件規模では原理的にノイズに勝てないため
課金・送信を伴う出力を扱う 抜き取りではなく全件ゲート。本稿の設計は適さない 抜き取りは母集団の推定であって、個別の防止策ではないため
明日、最初に置くもの
全部を一度に組む必要はありません。私自身、順に足していきました。
いま抜き取りをしていないのであれば、置くべきは route の記録です。生成レコードに「人が見ないまま出た」という印を1カラム足す。抜き取りも累積和も、この母集団の定義がなければ始まりません。
母集団を切り出せたら、days_to_detect を一度だけ走らせてみてください。自分が許容できる劣化の大きさを入れて、何日で気づけるかを見る。その数字が受け入れられるなら、いまの体制は妥当です。受け入れられないなら、レビュー予算を増やすか、層別で配分を変えるか、許容する劣化の線を引き直すか。選べる手は、その三つしかありません。
モデルの実体は、これからも静かに入れ替わっていきます。8月には画像生成モデルの停止も控えております。変化そのものを止めることはできませんが、変化に気づくまでの日数は、設計で短くできる。そう考えて、私は今日も20件を抜いております。
抜き取りは地味な作業です。それでも、数字で気づける状態を持てているという安心感は、個人開発で無人の処理を夜に委ねる身にとって代えがたいものでした。ここまでお付き合いいただき、ありがとうございました。