Gemini 3.5 Flash-Lite の単価を並べた夜、手元の見積もり表を作り直しました。入力 $0.30、出力 $2.50(100万トークンあたり)。常用している Gemini 3.6 Flash の入力 $1.50・出力 $7.50 と比べると、入力で5倍、出力で3倍の開きです。
個人開発で回しているのは、壁紙画像に説明とカテゴリを付けるバッチ処理です。全件を Flash-Lite に通し、判定が怪しいものだけ 3.6 Flash に昇格させる。二段構成にすれば総額は半分近くまで落ちる、と見積もりました。昇格率は25%と置きました。
実際に組んでみると、削減率は見積もりよりずっと小さい水準に着地しました。
原因は昇格率ではありませんでした。昇格した集合の出力が、平均より長かったこと。当然のことのようでいて、私の見積もりの式にはその項が入っていませんでした。
二段構成の総額を、まず式として書く
一件あたりの費用を素直に書き下します。前段(Flash-Lite)は全件に走り、後段(3.6 Flash)は昇格した割合 e にだけ走ります。
from dataclasses import dataclass
@dataclass(frozen=True)
class Price:
name: str
inp: float # USD / 1M input tokens
out: float # USD / 1M output tokens
LITE = Price("gemini-3.5-flash-lite", 0.30, 2.50)
FLASH = Price("gemini-3.6-flash", 1.50, 7.50)
def unit_cost(p: Price, t_in: float, t_out: float) -> float:
"""1件あたりの費用(USD)"""
if t_in < 0 or t_out < 0:
raise ValueError(f"token counts must be non-negative: {t_in=}, {t_out=}")
return (t_in * p.inp + t_out * p.out) / 1_000_000
def cascade_unit_cost(t_in, t_out_lite, t_out_flash, e, k=1.0) -> float:
"""二段構成の1件あたり費用。
e: 昇格率、k: 昇格した集合の出力長倍率(母集団平均に対する比)
"""
if not 0.0 <= e <= 1.0:
raise ValueError(f"escalation rate out of range: {e}")
if e * k > 1.0:
# 昇格集合が k 倍長いなら、母集団平均を満たすには e*k <= 1 が必要
raise ValueError(f"inconsistent bias: e*k must be <= 1 (got {e * k:.3f})")
first = unit_cost(LITE, t_in, t_out_lite)
second = unit_cost(FLASH, t_in, t_out_flash * k)
return first + e * second
昇格した件は入力を二度払います。前段で1回、後段でもう1回。ここが二段構成でいちばん警戒される点で、私も最初はここを疑いました。
昇格した集合は、平均より長い
昇格するのは、前段が扱いきれなかった件です。曖昧な画像、要素が多い構図、指示に対して素直に収まらない対象。そういう件を強いモデルに渡せば、返ってくる出力も長くなります。推論を挟むモデルであれば、思考側のトークンも増えます。
そこで、昇格した集合の出力長を母集団平均の k 倍として式に入れました。k は測って決める値です。1.0 と置くのは「昇格した件も平均的な長さで返ってくる」という仮定で、私はそれを無自覚に置いていました。
制約が一つあります。母集団平均を崩さないためには e * k <= 1 が必要です。上の実装では、この不整合を例外で弾いています。見積もりの式を人手で触っていると、ここを踏み越えたまま数字が出てしまいます。
損益分岐点を解き直す
昇格率がどこまで上がったら、二段構成が強いモデル単独より高くなるか。式を e について解きます。
def breakeven_e(t_in, t_out_lite, t_out_flash, k=1.0) -> float:
"""二段構成が強モデル単独と同額になる昇格率"""
strong = unit_cost(FLASH, t_in, t_out_flash)
first = unit_cost(LITE, t_in, t_out_lite)
second = unit_cost(FLASH, t_in, t_out_flash * k)
if second <= 0:
return float("inf")
return (strong - first) / second
三つのワークロード像で解いた結果です。入力優位は画像1枚に短い指示を添えた分類(入力1,600・出力40トークン)、均衡は要約や抽出(2,000・800)、出力優位は生成や推論を含む処理(600・2,400)を想定しています。
| ワークロード | k=1.0 | k=1.5 | k=2.0 | k=3.0 |
| 入力優位(画像1枚+短い指示) | 78.5% | 74.4% | 70.7% | 64.2% |
| 均衡(要約・抽出) | 71.1% | 53.3% | 42.7% | 30.5% |
| 出力優位(生成・推論込み) | 67.3% | 45.6% | 34.5% | 23.2% |
出力優位のワークロードでは、k を 1.0 から 2.0 に変えるだけで許容できる昇格率が 67.3% から 34.5% へ、ほぼ半分になります。私が「昇格率25%なら十分に余裕がある」と考えていた根拠は、k=1.0 の列だけを見ていたことでした。
昇格率を固定して、総額の削減率を並べるとこうなります(100万件あたり、e=0.25)。
| ワークロード | 強モデル単独 | 二段(k=1) | 二段(k=2) | 削減率 k=1 | 削減率 k=2 |
| 入力優位 | $2,700 | $1,255 | $1,330 | 53.5% | 50.7% |
| 均衡 | $9,000 | $4,850 | $6,350 | 46.1% | 29.4% |
| 出力優位 | $18,900 | $10,905 | $15,405 | 42.3% | 18.5% |
同じ昇格率、同じ単価表。違うのは k だけです。それだけで、出力優位のワークロードでは削減率が 42.3% から 18.5% へ落ちます。
予想と逆だったこと
私が事前に立てていた仮説はこうでした。「昇格した件は入力を二度払う。だから入力の重いワークロードほど二段構成に向かない」。
算術は逆を示しました。
| ワークロード | 入力が総額に占める割合 | 分岐点 k=1 | 分岐点 k=2 | 分岐点の低下率 |
| 入力優位 | 88.9% | 78.5% | 70.7% | 10.0% |
| 均衡 | 33.3% | 71.1% | 42.7% | 40.0% |
| 出力優位 | 4.8% | 67.3% | 34.5% | 48.8% |
入力が総額の9割を占めるワークロードでは、k が2倍になっても分岐点は10.0%しか下がりません。出力が支配しているワークロードでは48.8%下がります。
理由は単価差の向きにあります。入力側の単価差は5倍あるため、前段で払う入力は強モデルの2割です。二重に払っても 0.30 + 1.50 = 1.80 で、強モデル単独の 1.50 に対する上乗せは 0.30 分。小さい。一方 k は出力側にしか掛かりません。出力が総額を支配していれば、k はほぼそのまま総額に乗ります。
つまり、二重計上を心配すべき相手と、出力長の偏りを心配すべき相手は別でした。入力が重いワークロードは、二重に払っても頑健。出力が重いワークロードは、二重計上ではなく偏りで崩れる。
この見立ての反転は、式を書いて数字を通すまで私自身まったく気づけませんでした。単価表を眺めているだけでは、どちらの項が効いているのかが手触りになりません。
k を外すと、総額はどこまで動くか
k は推定値です。外したときの影響を出しておきます(出力優位・e=0.25 固定)。
| k | 100万件あたり総額 | 削減率 |
| 1.00 | $10,905 | 42.3% |
| 1.25 | $12,030 | 36.3% |
| 1.50 | $13,155 | 30.4% |
| 2.00 | $15,405 | 18.5% |
| 2.50 | $17,655 | 6.6% |
| 3.00 | $19,905 | -5.3% |
k=3.0 で削減率が負に転じます。二段構成にしたことで、強いモデル単独より高くなる領域です。k の推定を 2.0 と 2.5 のあいだで間違えるだけで、削減率は12ポイント動きます。
昇格の判定に払うコストは、二次的でした
昇格の判定を別呼び出しで立てるべきか、前段の出力に信頼度を混ぜて済ませるべきか。私はここを長く迷っていました。判定のためにもう一度入力を送るのは無駄に見えたからです。
判定呼び出しを Flash-Lite で全件に掛けた場合の総額を出しました(出力優位・k=2.0)。
| 判定呼び出しの構成 | e=0.10 | e=0.25 | e=0.40 |
| 判定呼び出しなし | 47.8% | 18.5% | -10.8% |
| 出力0(入力の再送のみ) | 46.8% | 17.5% | -11.7% |
| 出力30 | 46.4% | 17.1% | -12.1% |
| 出力120 | 45.2% | 16.0% | -13.3% |
| 出力300 | 42.9% | 13.6% | -15.7% |
判定呼び出しを立てない場合と、出力30トークンで立てた場合の差は1.4ポイントです。出力300トークンまで使っても4.9ポイント。一方、昇格率が 0.10 から 0.40 に動けば削減率は58ポイント動きます。なお判定を独立呼び出しにすると入力を再送するため、出力が0トークンでも100万件で $180 は掛かります。それでも桁が違います。
判定コストを惜しんで判定精度を落とすのは、桁を間違えた節約でした。私はここで設計を変えました。前段の出力に信頼度を混ぜる方式をやめ、判定を独立した小さな呼び出しに切り出しています。
本番のバッチで昇格率が想定を超えたとき
分岐点を計算しても、実際の本番運用では昇格率が跳ねます。入力の分布が変わった日、細かい構図の画像がまとまって流れてきた日に、昇格率が想定の倍まで上がりました。その日の総額は、強モデル単独を上回りました。
対処として、バッチ窓ごとに昇格の上限を設けています。上限に達したら、それ以降は前段の結果を採用し、needs_review の印を付けて次の窓に回します。
落とし穴は、上限に達した件を静かに捨てる実装にしてしまうことです。印を残さないと、品質の劣化が請求書にも記録にも現れません。
class EscalationBudget:
"""バッチ窓ごとの昇格上限。超過分は次の窓へ回す"""
def __init__(self, window_items: int, max_rate: float):
if not 0.0 < max_rate <= 1.0:
raise ValueError(f"max_rate out of range: {max_rate}")
self.cap = max(1, int(window_items * max_rate))
self.used = 0
self.deferred: list[str] = []
def allow(self, item_id: str) -> bool:
if self.used < self.cap:
self.used += 1
return True
self.deferred.append(item_id) # 静かに捨てない
return False
上限は、計算した分岐点より少し内側に置くことを推奨します。私は分岐点の7割の位置に置いています。この場合は削減率が想定を下回ることはあっても、単体構成より高くつくことは起きません。
切り替え前に測るべきは、二つの数値だけ
e と k です。どちらも前段だけを走らせても分かりません。後段の出力長は、後段を走らせないと測れないからです。
そこで、切り替え前に少量の並行実行を掛けます。同じ入力を両モデルに通し、件ごとの出力トークン数と昇格判定を記録します。
import random
import statistics
from typing import Iterable, NamedTuple
class Paired(NamedTuple):
item_id: str
escalated: bool # ゲートが昇格と判定したか
out_tokens_flash: int # 後段の出力トークン数(usage_metadata から)
def estimate_e_and_k(rows: Iterable[Paired]) -> dict:
rows = list(rows)
if not rows:
raise ValueError("no paired rows collected")
esc = [r.out_tokens_flash for r in rows if r.escalated]
allv = [r.out_tokens_flash for r in rows]
mean_all = statistics.fmean(allv)
if mean_all <= 0:
raise ValueError("mean output tokens is zero; check usage_metadata parsing")
e = len(esc) / len(rows)
k = (statistics.fmean(esc) / mean_all) if esc else 1.0
return {"n": len(rows), "e": e, "k": k, "mean_all": mean_all}
def bootstrap_k(rows: list[Paired], iters: int = 2000, seed: int = 17) -> tuple:
"""k の95%区間。昇格集合が小さいと区間は素直に広がる"""
rng = random.Random(seed)
ks = []
for _ in range(iters):
sample = [rows[rng.randrange(len(rows))] for _ in range(len(rows))]
try:
ks.append(estimate_e_and_k(sample)["k"])
except ValueError:
continue
if len(ks) < iters * 0.5:
raise RuntimeError("bootstrap unstable; collect more paired rows")
ks.sort()
return ks[int(0.025 * len(ks))], ks[int(0.975 * len(ks))]
標本数の目安も出しておきます。昇格率が0.25付近だと仮定したときの Wilson 区間です。
| 標本数 | 95%信頼区間 | 区間の幅 |
| 50 | 15.1% 〜 38.5% | 23.4ポイント |
| 100 | 17.5% 〜 34.3% | 16.8ポイント |
| 200 | 19.5% 〜 31.4% | 11.9ポイント |
| 400 | 21.0% 〜 29.5% | 8.5ポイント |
| 800 | 22.1% 〜 28.1% | 6.0ポイント |
50件では区間の幅が23.4ポイントあり、分岐点 34.5% に対する余裕を語れません。400件まで積んで、ようやく片側4ポイント程度に収まります。k のほうは昇格した件しか使えないため、同じ400件でも実効標本は100件前後です。ブートストラップの区間を必ず併記するようにしています。
自己申告の信頼度をゲートに使わなかった理由
前段に「確信度を0から1で返してください」と添える方式を、私は最初に試して外しました。誤分類の多くが、高い確信度とともに返ってきたためです。迷っている件だけが低い値になる、という前提が私の題材では成り立ちませんでした。
代わりに使っているのは、前段を2回走らせて結果が一致しなければ昇格させる方式です。判定に別のモデルを立てる必要がなく、前段のコストが2回分になるだけで済みます。
その2回分が耐えられるかも、同じ式で確認できます(e=0.25・k=2.0)。
| ワークロード | 前段1回の削減率 | 前段2回の削減率 | 前段2回での分岐点 |
| 入力優位 | 50.7% | 29.3% | 51.3% |
| 均衡 | 29.4% | 0.6% | 25.3% |
| 出力優位 | 18.5% | -14.2% | 17.7% |
入力優位のワークロードなら、前段を2回走らせても29.3%の削減が残ります。均衡では0.6%まで削られ、実質的に意味がなくなります。私の壁紙分類は入力優位側なので、この方式を選べました。私はこの判定方式を、要約のパイプラインには横展開していません。
この式を、自分のワークロードに当てはめる
やることは三つです。
- 自分のワークロードの入力・出力トークン数を一件分でも実測し、入力が総額に占める割合を出す
- 少量の並行実行で e と k を測る。k は昇格した集合の平均出力長 ÷ 母集団の平均出力長
breakeven_e に k を入れて分岐点を求め、実測の e との距離を見る
入力が総額の8割を超えているなら、二段構成は昇格率のブレに強い構造です。出力が支配しているなら、k を測らないまま切り替えるのは避けたほうが賢明です。
単価表の倍率は、そのままでは総額の倍率になりません。掛かる場所と、掛かる集合が違います。私はその二つを混ぜたまま見積もりを作り、作り直しました。同じ手戻りを一度減らせたら、それがこの記事の役目です。お読みいただきありがとうございました。
なお本文の単価は 2026年7月30日時点で確認した値です。価格は改定されるため、実際の計算では必ず一次情報を確認してください。