6月11日に Gemini API の大規模障害が起きたとき、私はエラー率のグラフを眺めながら復旧を待っていました。翌朝、すべてが平常に戻ったダッシュボードを確認していて、ふと別のことが気になったのです。エラー率は正常、レイテンシも正常、トークン消費も想定どおり。では、この機能は本当に「使われている」のか。その問いに答えられる数字が、手元に1つもありませんでした。
私が個人開発で運営している癒し系アプリには、朝の起動時に短いメッセージを生成して表示する「今日のひとこと」という機能があります。Gemini を組み込んだ小さな機能ですが、リリースから2週間、私が見ていたのは API 側の健全性だけで、ユーザーがその出力を保存したのか、すぐに作り直したのか、それとも黙って画面を閉じたのかを示すデータは何も取っていませんでした。この記事は、その反省から組み直した「製品側の計測」の設計記録です。
トークンの消費量は「価値」を測ってくれない
API のダッシュボードが教えてくれるのは、リクエスト数、エラー率、レイテンシ、そしてトークン消費量です。これらは運用の健全性を測る指標としては不可欠ですが、すべて「供給側」の数字であって、「需要側」の数字ではありません。
実際、私のアプリでは生成回数が1日およそ 1,900 回で安定しており、供給側だけ見れば順調でした。ところが後述する計測を入れてみると、生成された文章のうちユーザーが保存ボタンを押したのは 41% に過ぎず、28% は「再生成」タップで捨てられていました。トークンは消費されているのに、その半分近くがユーザーにとって外れだったわけです。供給側の数字がきれいなまま、需要側で静かに失敗している。これが AI 機能の怖いところだと、身をもって学びました。
LLM 運用の文脈では出力品質の自動評価やコスト監視の仕組みがよく語られますが、それらと「ユーザーが受け入れたか」は別のレイヤの話です。品質評価で満点の文章でも、ユーザーの気分に合わなければ捨てられます。製品としての AI 機能には、製品としての計測が必要です。
生成のライフサイクルを5つのイベントに分解する
計測を設計するにあたり、まず「今日のひとこと」機能でユーザーと生成物の間に起きることを時系列で書き出し、5つのイベントに分解しました。
ai_shown — 機能の入口が表示された(生成ボタンが見える状態になった)
ai_generated — 初回の生成が完了し、出力がユーザーに提示された
ai_regenerated — ユーザーが「作り直す」をタップし、別の出力に置き換えた
ai_accepted — ユーザーが保存・シェアなど「受け入れ」に相当する行動をした
ai_generation_failed — 生成がエラーで完了しなかった
「黙って閉じた」を表す abandoned はイベントとしては送らず、ai_generated があって ai_accepted も ai_regenerated もないセッションとして集計側で導出します。離脱は「何もしない」ことなので、クライアントから確実に送るのが難しく、導出値にした方が漏れなく数えられるからです。
全イベントに共通で付けるパラメータは次の4つに絞りました。
feature — 機能の識別子(例: daily_message)。複数の AI 機能を持つアプリで横断比較するための軸です
prompt_version — プロンプトのバージョン文字列。改善の効果測定はすべてこの軸で割ります
model_id — 呼び出したモデル名。モデル更新の影響を切り分けるために必須です
latency_ms — ユーザーが体感した待ち時間。API 側のレイテンシではなく、タップから表示までを測ります
ここで大切なのは、イベントの数を増やしすぎないことです。最初の設計では12イベントありましたが、集計で実際に使ったのは上の5つだけでした。計測は「集計クエリを書く自分」が顧客です。クエリに登場しないイベントは、ただの送信コストになります。
Swift 実装 — 生成呼び出しを計測でラップする
実装は「生成を呼ぶ場所と計測する場所を分離しない」のが要点です。呼び出しと計測が別の場所にあると、リトライや再生成の経路で必ず数え漏れが起きます。
まず、計測を入れる前のコードです。Firebase AI Logic 経由で Gemini を呼ぶ、ごく普通の形でした。
// Before: 生成するだけで、何も記録に残らない
import FirebaseAI
let model = FirebaseAI. firebaseAI ( backend : . googleAI ())
. generativeModel ( modelName : "gemini-3.5-flash" )
func generateDailyMessage ( context : DailyContext) async throws -> String {
let response = try await model. generateContent ( buildPrompt (context))
return response. text ?? ""
}
これを、ライフサイクルイベントを必ず通る1枚のラッパーに置き換えました。
// After: 生成・再生成・失敗・受け入れがすべて1箇所で記録される
import FirebaseAI
import FirebaseAnalytics
struct GenerationResult {
let text: String
let latencyMs: Int
let attempt: Int
}
final class MeasuredGenerator {
static let feature = "daily_message"
static let promptVersion = "daily_message_v2"
static let modelId = "gemini-3.5-flash"
private let model = FirebaseAI. firebaseAI ( backend : . googleAI ())
. generativeModel ( modelName : modelId)
// attempt: 1 = 初回生成、2以降 = ユーザーの「作り直す」タップ
func generate ( context : DailyContext, attempt : Int ) async throws -> GenerationResult {
let started = Date ()
do {
let response = try await model. generateContent ( buildPrompt (context))
let text = response. text ?? ""
let latencyMs = Int ( Date (). timeIntervalSince (started) * 1000 )
Analytics. logEvent (attempt == 1 ? "ai_generated" : "ai_regenerated" , parameters : [
"feature" : Self .feature,
"prompt_version" : Self .promptVersion,
"model_id" : Self .modelId,
"latency_ms" : latencyMs,
"output_chars" : text. count ,
"attempt" : attempt,
])
return GenerationResult ( text : text, latencyMs : latencyMs, attempt : attempt)
} catch {
Analytics. logEvent ( "ai_generation_failed" , parameters : [
"feature" : Self .feature,
"prompt_version" : Self .promptVersion,
"model_id" : Self .modelId,
"error_domain" : (error as NSError).domain,
"error_code" : (error as NSError).code,
])
throw error
}
}
// 保存・シェアなど「受け入れ」操作のタイミングで呼ぶ
func logAccepted ( generated : String , saved : String ) {
let distance = normalizedEditDistance (generated, saved)
Analytics. logEvent ( "ai_accepted" , parameters : [
"feature" : Self .feature,
"prompt_version" : Self .promptVersion,
// GA4 のパラメータは数値型が安全なので、0〜1 の距離を 100 倍した整数で送る
"edit_distance_x100" : Int (distance * 100 ),
])
}
}
ポイントは3つあります。1つ目は、初回生成と再生成を attempt 引数で同じ経路に通しつつ、イベント名で区別していることです。経路を分けると将来の修正でどちらかだけ計測が壊れます。2つ目は、編集距離を 100 倍した整数で送っていることです。GA4 のイベントパラメータは数値型で揃えた方が BigQuery 側の集計が素直になります。3つ目は、latency_ms が「ユーザーのタップから表示まで」を測っている点です。障害の日でなくても、ここには API のレイテンシに加えてネットワークや UI の遅延が乗ります。ユーザーが捨てる判断には、この体感値の方が効いています。
編集距離 — 「保存された」の中身を見る指標
受け入れ率だけを見ていると、1つ落とし穴があります。ユーザーが「どうでもいいからそのまま保存した」のか、「内容を気に入って保存した」のか、それとも「方向性は良いが手直しして保存した」のかが区別できないことです。
そこで、生成された文章と最終的に保存された文章の正規化編集距離(レーベンシュタイン距離を長い方の文字数で割った 0〜1 の値)を ai_accepted に添えています。実装は標準的な動的計画法で十分です。
func normalizedEditDistance ( _ a: String , _ b: String ) -> Double {
let s = Array (a), t = Array (b)
if s. isEmpty || t. isEmpty {
return s. count == t. count ? 0 : 1
}
var prev = Array ( 0 ... t. count )
var curr = [ Int ]( repeating : 0 , count : t. count + 1 )
for i in 1 ... s. count {
curr[ 0 ] = i
for j in 1 ... t. count {
let cost = s[i - 1 ] == t[j - 1 ] ? 0 : 1
curr[j] = min (prev[j] + 1 , curr[j - 1 ] + 1 , prev[j - 1 ] + cost)
}
swap ( & prev, & curr)
}
return Double (prev[t. count ]) / Double ( max (s. count , t. count ))
}
私の機能では中央値が 0.03 で、ほとんどのユーザーは手を入れずに保存していました。一方で 0.3 を超える保存が一定数あり、中身を見ると「文末の言い回しだけ自分の口調に直す」編集が大半でした。これは後述するプロンプト改善の直接のヒントになりました。編集距離は「ユーザーが何を直したがるか」を教えてくれる、受け入れ率より一段解像度の高い指標です。
BigQuery で週次ロールアップを組む
GA4 の BigQuery エクスポートを有効にしておけば、イベントは events_ プレフィックスの日次テーブルに落ちてきます。週に1度、prompt_version 軸で次のクエリを回しています。
WITH events AS (
SELECT
event_name,
( SELECT value . string_value FROM UNNEST(event_params) WHERE key = 'prompt_version' ) AS prompt_version,
( SELECT value . int_value FROM UNNEST(event_params) WHERE key = 'latency_ms' ) AS latency_ms,
( SELECT value . int_value FROM UNNEST(event_params) WHERE key = 'edit_distance_x100' ) AS edit_x100
FROM `myapp.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260601' AND '20260614'
AND ( SELECT value . string_value FROM UNNEST(event_params) WHERE key = 'feature' ) = 'daily_message'
)
SELECT
prompt_version,
COUNTIF(event_name = 'ai_generated' ) AS generated ,
COUNTIF(event_name = 'ai_regenerated' ) AS regenerated,
COUNTIF(event_name = 'ai_accepted' ) AS accepted,
SAFE_DIVIDE(
COUNTIF(event_name = 'ai_accepted' ),
COUNTIF(event_name = 'ai_generated' )
) AS acceptance_rate,
SAFE_DIVIDE(
COUNTIF(event_name = 'ai_regenerated' ),
COUNTIF(event_name = 'ai_generated' ) + COUNTIF(event_name = 'ai_regenerated' )
) AS regenerate_share,
APPROX_QUANTILES(latency_ms, 100 )[OFFSET(95)] AS latency_p95_ms,
APPROX_QUANTILES(edit_x100, 100 )[OFFSET(50)] AS edit_distance_median_x100
FROM events
GROUP BY prompt_version
ORDER BY prompt_version
受け入れ率は「初回生成に対する受け入れ」、再生成率は「全生成のうち作り直しが占める割合」と定義しています。分母の取り方は流派がありますが、大切なのは定義を固定して同じクエリで追い続けることです。途中で定義を変えると、改善の効果なのか定義変更の効果なのか分からなくなります。
なお、GA4 のカスタムパラメータを探索レポートで使うにはカスタムディメンション登録が必要ですが、BigQuery 側はその制約を受けません。私はこの手の分析はすべて BigQuery 側に寄せて、GA4 の管理画面は触らないことにしています。登録上限を気にしながらイベント設計を歪めるより、SQL を1本書く方が早いからです。
実測2週間 — 数字が変えた判断
計測を入れて最初の2週間で取れた数字は、次のとおりでした。
入口表示(ai_shown): 26,800 回
初回生成(ai_generated): 19,400 回
受け入れ率: 41%
再生成率: 28%
編集距離の中央値: 0.03
体感レイテンシ p95: 3.8 秒
予想より悪かったのは再生成率です。時間帯で割ってみると、朝 6 時から 8 時の再生成率だけが 37% と突出していました。出力を読み比べて気づいたのは、朝に表示されるにしては文章が長く、内省的すぎることです。通勤前の数十秒に読むものとしては重かったのだろうと解釈しました。
そこで prompt_version を v2 に上げ、3つの変更を入れました。時間帯コンテキスト(朝・昼・夜)をプロンプトに渡すこと、出力を 60 文字以内に制約すること、編集距離の分析で分かった「ユーザーが直したがる文末の言い回し」を口語寄りに指定することです。あわせて maxOutputTokens を 256 から 96 に絞ったところ、思考の余地が減って体感レイテンシ p95 は 2.4 秒まで下がりました。
v2 適用後の2週間は、受け入れ率 63%、再生成率 14%、朝帯の再生成率も 16% まで収束しました。出力が短くなった分だけ出力トークン課金も下がったので、品質とコストが同時に改善した形です。どの変更が効いたのかを厳密に分離できていない点は正直に書いておきますが、個人開発の規模では「束で当てて数字で確認する」で十分回ると感じています。
もう1つ、副産物がありました。latency_ms を体感値で取っていたおかげで、「生成に 3 秒以上かかったセッションは受け入れ率が 2.1 分の 1 に落ちる」という相関が見えたことです。プロンプトの改善とトークン上限の削減がレイテンシ改善を兼ねていたのは、この数字を見ていたからこその判断でした。
受け入れ率が低いときの切り分け手順
数字を取り始めると、今度は「低いときに何を疑うか」が問題になります。私が使っている順序は次のとおりです。
まずレイテンシで割る : 体感 3 秒未満と以上で受け入れ率を比べます。差が大きいなら内容より先に速度です。モデルを軽くする、maxOutputTokens を絞る、ストリーミング表示にするなど、内容に手を付けない改善から試します
初回ユーザーと再訪ユーザーで割る : 初回だけ低いならオンボーディングや見せ方の問題、再訪だけ下がっていくなら「飽き」、つまり出力の多様性不足を疑います
編集距離の分布を見る : 受け入れ率が低いのに編集距離がほぼ 0 なら、ユーザーは内容を吟味せず惰性で保存しています。機能の置き場所やタイミングを疑います。編集距離が高めなら方向性のずれなので、何が直されているかを読みにプロンプトを調整します
再生成直後の受け入れ率を見る : 「作り直したら受け入れた」が多いなら、出力のばらつきが大きすぎる兆候です。temperature を下げる、制約を増やすなど、分散を絞る方向に調整します
この順序にしているのは、上から順に「直すのが安い」からです。プロンプトの方向転換は影響範囲が読みにくいので、速度と見せ方で説明がつくうちはそちらを先に直します。
採算と品質の計測につなげる
製品としての受け入れ計測は、それ単体で完結するものではなく、出力品質の継続監視と機能単位の採算管理の間を埋めるレイヤだと考えています。出力の品質が安定しているかは Gemini API の出力品質を継続監視する評価基盤の設計 で書いた評価基盤の領域ですし、その機能がコストに見合っているかは Gemini を組み込んだ機能の採算を、機能単位で見る設計 — 残す・直す・畳むを判断するために で扱った台帳の領域です。
3つのレイヤは見ている断面が違います。品質評価は「モデルは良い文章を出しているか」、受け入れ計測は「ユーザーはそれを欲しがったか」、採算管理は「その営みは続けられるか」。私の場合、受け入れ計測を入れたことで初めて、品質評価では満点に近いのに受け入れ率が伸びない(つまり良い文章と欲しい文章は別物だ)という事実に向き合うことになりました。この3点が揃うと、AI 機能の改善は当てずっぽうではなく、どのレイヤの問題かを特定してから手を打つ作業に変わります。
まとめ — 最初の一歩は5つのイベントだけ
計測の設計と聞くと大掛かりに感じられるかもしれませんが、今回の仕組みでアプリに足したのは、アナリティクスイベント5種類と、生成呼び出しを包むラッパークラス1枚、そして週に1度回す SQL が1本だけです。ダッシュボードも専用ツールも増やしていません。
もし Gemini を組み込んだ機能をすでに運用されているなら、次のリリースで ai_generated と ai_accepted の2イベントだけでも仕込んでみてください。受け入れ率という1つの数字が出るだけで、その機能との向き合い方が変わるはずです。私自身、エラー率だけを見て安心していた2週間を、今では少しもったいなく思っています。この記事が、皆さんのアプリの「静かに失敗している数字」を見つけるきっかけになれば嬉しいです。