配信率 1% の初日、管理画面のクラッシュ件数は 2 件でした。前の版と並べても増えているようには見えなかったので、私は翌日の引き上げをそのまま承認しました。
5% まで広がった三日目、同じ症状を訴えるレビューが並び始めました。初日のあの 2 件は、いま思えば「問題がない」を意味していませんでした。単に、何も言えないだけの数だったのです。
個人開発でアプリを並行して出していると、段階公開の各日にかけられる時間は数分しかありません。その数分で判断を誤らないために、どこを機械に任せ、どこを言語モデルに任せ、どこを自分の手元に残すかを引き直しました。結論から書くと、止めるかどうかの判断そのものはモデルに渡さない、という線引きになりました。
初日の「静かでした」は、静かさの証拠ではありません
App Store の段階的リリースは 7 日間かけて 1% / 2% / 5% / 10% / 20% / 50% / 100% と配信率が上がります。ここで見落としやすいのは、初日の母数が最終日の 200 分の 1 しかないという点です。
1 日あたりの新規セッションが 100% 配信時に 20,000 件あるアプリを例にします。真のクラッシュ発生率が 0.8% で一定だと仮定して、各日に観測される件数と、その観測から言える範囲(Wilson の 95% 信頼区間)を計算しました。
| 日 | 配信率 | 母数 | 観測件数 | 95%信頼区間 | 区間の幅 |
|---|---|---|---|---|---|
| Day 1 | 1% | 200 | 2 | 0.27% 〜 3.57% | 3.30pt |
| Day 2 | 2% | 400 | 3 | 0.26% 〜 2.18% | 1.93pt |
| Day 3 | 5% | 1,000 | 8 | 0.41% 〜 1.57% | 1.16pt |
| Day 4 | 10% | 2,000 | 16 | 0.49% 〜 1.30% | 0.80pt |
| Day 5 | 20% | 4,000 | 32 | 0.57% 〜 1.13% | 0.56pt |
| Day 6 | 50% | 10,000 | 80 | 0.64% 〜 0.99% | 0.35pt |
| Day 7 | 100% | 20,000 | 160 | 0.69% 〜 0.93% | 0.25pt |
Day 1 の行を見てください。観測値は 1.00% ですが、真の値は 0.27% かもしれないし 3.57% かもしれません。平常時の 4 倍に悪化していても、初日の数字は同じ顔をして並びます。
私が三日目に取り逃がしたのは、まさにこの幅でした。数字が小さいことを安全と読み替えていたわけです。
「進める・止める」の二択をやめて、「まだ言えない」を状態にする
判断を誤った原因は、判断の選択肢が 2 つしかなかったことにあります。人は 2 択を渡されると、判断できない状況でもどちらかを選んでしまいます。そこで状態を 3 つに増やしました。
import math
def wilson(k, n, z=1.96):
"""k 件 / n 件 の観測から、真の発生率がありうる範囲を返します。"""
if n == 0:
return (0.0, 1.0)
p = k / n
d = 1 + z * z / n
center = (p + z * z / (2 * n)) / d
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
return (max(0.0, center - half), min(1.0, center + half))
def verdict(n, k, baseline, ceiling):
"""baseline = 平常時の発生率 / ceiling = ここを超えたら止めるという上限。"""
lo, hi = wilson(k, n)
if lo > ceiling:
return "STOP" # 上限を超えていることが、母数の少なさを差し引いても言える
if hi <= ceiling:
return "GO" # 上限を超えていないことが言える
return "UNDECIDED" # まだどちらとも言えない
def required_users(baseline, ceiling, z=1.96, step=100):
"""平常どおりの発生率が続いた場合に、GO と言えるようになる母数。"""
n = step
while n <= 2_000_000:
if wilson(round(n * baseline), n, z)[1] <= ceiling:
return n
n += step
return None平常時 0.8%、上限を倍の 1.6% に置いて実行すると、次の結果になりました。
| 母数 | 観測件数 | 観測値 | 信頼区間 | 判定 |
|---|---|---|---|---|
| 200 | 2 | 1.00% | 0.27% 〜 3.57% | UNDECIDED |
| 200 | 8 | 4.00% | 2.04% 〜 7.69% | STOP |
| 1,000 | 8 | 0.80% | 0.41% 〜 1.57% | GO |
| 1,000 | 30 | 3.00% | 2.11% 〜 4.25% | STOP |
| 4,000 | 90 | 2.25% | 1.83% 〜 2.76% | STOP |
required_users(0.008, 0.016) は 900 を返します。つまり平常どおりの数字が並んでいても、900 人に届くまでは「大丈夫でした」と言う根拠がありません。冒頭の 200 人はその 4 分の 1 以下でした。
一方で母数が少なくても STOP は出ます。200 人中 8 件なら、少ない母数を差し引いてもなお上限を超えています。壊れているときは早く分かり、壊れていないことは遅れて分かるという非対称は、そのまま受け入れてよいと考えています。
レビュー本文だけは、数えるだけでは足りません
クラッシュ率は機械で判定できます。困るのはレビューです。「アップデートしてから写真が保存できません」と「広告が多すぎます」は、どちらも星 1 として同じ列に積まれます。片方は今回の版の話で、もう片方は前から言われ続けている話です。
この帰属の判定こそ、言語モデルに向いた仕事だと感じています。ここでは responseSchema で出力の形を固定して、1 件ずつ分類させています。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
SCHEMA = {
"type": "object",
"properties": {
"attributable_to_update": {"type": "boolean"},
"symptom": {"type": "string"},
"severity": {"type": "string", "enum": ["blocking", "major", "minor", "none"]},
},
"required": ["attributable_to_update", "symptom", "severity"],
}
PROMPT = """次のアプリレビューを分類してください。
判定するのは「この更新のあとに起きた不具合を述べているか」だけです。
アプリの良し悪しや、公開を続けるべきかどうかは判定しないでください。
価格や広告量など、更新前から存在する不満は attributable_to_update を false にします。
レビュー本文:
{body}"""
def classify(body: str) -> dict:
res = client.models.generate_content(
model="gemini-3.7-flash",
contents=PROMPT.format(body=body),
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=SCHEMA,
),
)
return res.parsedプロンプトの 2 行目と 3 行目は、書き足したのではなく削り出した部分です。最初は「このレビューは公開停止に値しますか」まで聞いていました。返ってくる文章はもっともらしく、しかも母数が少ない日ほど「様子を見るのが妥当です」と穏当な方向へ寄りました。
穏当な答えは反論しにくく、根拠らしい文章が添えられていると、そのまま承認してしまいます。判断をモデルに聞いた瞬間、自分の 3 状態は 2 択に戻っていました。だから聞くのをやめました。
レビュー返信そのものを Gemini に書かせる場合の注意点は、App Store / Google Play のレビュー返信を Gemini API で自動化したときにはまった「8秒ルール」 に別途まとめています。
モデルの出力を、しきい値の分子に混ぜない
分類結果を得たあと、それをクラッシュ率の計算に足したくなります。実際に一度そうしました。結果として、モデルが 1 件を取りこぼすたびに判定が揺れる仕組みになりました。
いまは 2 つの系統を分けたまま合流させています。合流点で行うのは、どちらかが赤なら止める、判断が割れたら人に回すという単純な突き合わせだけです。
def route(judgements, crash_state):
"""judgements: モデルの分類結果 / crash_state: 決定的に算出した3状態。"""
regressions = [
j for j in judgements
if j["attributable_to_update"] and j["severity"] in ("blocking", "major")
]
if crash_state == "STOP":
return ("STOP", "クラッシュ率の下限が上限しきい値を超えました")
if len(regressions) >= 3:
return ("HUMAN_REVIEW", f"更新起因の重大レビューが {len(regressions)} 件あります")
if crash_state == "UNDECIDED":
return ("HOLD", "母数が不足しています。配信率を上げずに翌日まで待ちます")
return ("GO", "しきい値内です。次の配信率へ進みます")HUMAN_REVIEW を独立した戻り値にしたことが、この配線で一番効きました。モデルが 3 件を拾ったとき、システムは止めもしませんし進めもしません。私に本文を読ませます。読むのは 3 件ですから、朝の数分で足ります。
HOLD と GO の差も意識して分けました。HOLD は「昨日と同じ配信率でもう一日置く」という積極的な行動です。何もしないことと、待つと決めることは別のものだと考えています。
運用に落とす前に決めた 4 つ
数値を後から動かすと、都合の良い方へ動かしてしまいます。リリース前に固定して、リリース中は触らないと決めました。
| 決めごと | 私の設定 | そう決めた理由 |
|---|---|---|
| baseline(平常時の発生率) | 直前 4 週間の中央値 | 平均だと過去の悪い版に引っ張られます |
| ceiling(止める上限) | baseline の 2 倍 | 1.5 倍だと UNDECIDED が最終日まで解けませんでした |
| 最小母数 | required_users の戻り値 | この数に届く前の GO は、根拠のない GO です |
| モデル出力の扱い | 人へ回す判断のみに使用 | しきい値の分子に入れると、分類漏れが判定を揺らします |
ceiling を 2 倍にした経緯だけ補足します。1.5 倍で試したところ、平常どおりの数字が続いても required_users が 2,800 まで上がり、Day 4 を過ぎるまで UNDECIDED が解けませんでした。厳しくすると安全になるのではなく、7 日間ずっと何も言えないだけになります。しきい値の厳しさと、判断できるようになる速さは交換関係にあります。
次に段階公開を始めるときは、まず自分のアプリの required_users を計算してみてください。その数を配信率で割れば、判断が意味を持ち始めるのが何日目からなのかが分かります。私の場合は Day 3 でした。それより前の数字を眺めていた時間は、安心を買っていただけだったことになります。
リリース直後に届く通知やストア文言のように、生成した文字列を配信前に検査する話は 通知文の長さ指示が英語版だけ効かなかった理由と、生成後に幅で詰める実装 で扱っています。あわせて読んでいただけると、リリース当日の手順がもう少し繋がるはずです。
数字の読み違えは、私自身がつい先日やったばかりの失敗です。同じところで足を止める方が一人でも減れば嬉しく思います。お読みいただきありがとうございました。