7月21日の週に Gemini 3.6 Flash が入れ替わる形で入ってきたとき、私が最初にしたのは頭の中での足し算でした。
出力単価が 100万トークンあたり $9.00 から $7.50 へ。おおよそ 16.67% 減。あわせて「同じ課題でも出力トークンが約17%少ない」という話。
合わせて 33% くらい安くなるのだろう、と。
その見積もりを個人開発の自動処理に当てはめて予算表を書き直したところ、どうにも桁が合いませんでした。処理系によって、期待した削減の十分の一しか出てこない。
原因は二つありました。一つは掛け算すべきものを足していたこと。もう一つは、入力単価が据え置きである事実を勘定に入れていなかったことです。
以下は、その二つを潰すために書いた分解のコードと、実際に走らせて出てきた数字です。価格は2026年7月時点で公表されているものを使っています。料金は変わりますので、判断に使う前に必ず一次情報でご確認ください。
単価と数量が同時に動くと、引き算の順番で答えが変わる
出力側の費用は「単価 × 数量」です。単価が $9.00 から $7.50 へ、数量が 1.00 から 0.83 へ動いたとき、費用は 9.00 → 7.50 × 0.83 = 6.225 になります。
削減率は 1 − 6.225 / 9.00 で 30.84%。足し算で出した 33.67% ではありません。
差の 2.83 ポイントは、両方が同時に動いた分の重なりです。単価を下げた後の安い単価で、減った分の数量まで割り引いてしまっているという二重計上にあたります。
この重なりは経済統計で交差項と呼ばれるもので、金額の差を要因ごとに配るときには必ず出てきます。無視できる大きさではありません。
from dataclasses import dataclass
@dataclass(frozen=True)
class Price:
name: str
inp: float # USD / 1M input tokens
out: float # USD / 1M output tokens
OLD = Price("gemini-3.5-flash", 1.50, 9.00)
NEW = Price("gemini-3.6-flash", 1.50, 7.50)
def cost(p: Price, ti: int, to: int) -> float:
return (ti / 1_000_000) * p.inp + (to / 1_000_000) * p.out
def decompose(old_p: Price, new_p: Price, to_old: int, to_new: int):
"""出力側の総額差を 単価効果 / 数量効果 / 交差項 に厳密分解する。
三つの和は必ず実測差と一致する(浮動小数の丸め誤差を除く)。"""
q0, q1 = to_old / 1_000_000, to_new / 1_000_000
p0, p1 = old_p.out, new_p.out
price_effect = (p1 - p0) * q0 # 数量を据え置いて単価だけ動かす
volume_effect = (q1 - q0) * p0 # 単価を据え置いて数量だけ動かす
cross = (p1 - p0) * (q1 - q0) # 同時に動いた分
total = p1 * q1 - p0 * q0
residual = abs((price_effect + volume_effect + cross) - total)
if residual > 1e-9:
raise AssertionError(f"分解が閉じていません: 残差 {residual}")
return price_effect, volume_effect, cross, total
residual の検算を例外にしてあるのは、後から按分の定義をいじったときに黙って壊れないようにするためです。分解式は少し触ると簡単に閉じなくなります。
三つのワークロードで走らせた結果
手元の処理を、入力偏重・出力偏重・中庸の三つに単純化して同じ分解にかけました。数値は上記のコードの実行結果です。
| 処理 | 入力トークン | 出力トークン | 出力が旧総額に占める割合 | 総額削減 |
| A 分類バッチ(入力偏重) | 2,000,000 | 40,000 | 10.7% | 3.30% |
| B 下書き生成(出力偏重) | 30,000 | 120,000 | 96.0% | 29.60% |
| C 対話(中庸) | 200,000 | 60,000 | 64.3% | 19.82% |
同じモデル、同じ 17% の削減率で、総額の下がり方が 3.30% から 29.60% まで開きます。
A の内訳を金額で並べると次のようになりました。旧 $3.3600 に対して新 $3.2490。差は −$0.11100 で、単価効果 −$0.06000、数量効果 −$0.06120、交差項 +$0.01020 の和です。
交差項が正の符号で戻ってきている点に注意してください。単価と数量が同じ向きに減っているとき、交差項は削減を打ち消す方向に働きます。足し算の見積もりが常に楽観側に外れるのはこのためです。
A で削減が伸びない理由は分解を見ればはっきりします。出力が総額の 10.7% しか占めておらず、残る 89.3% は入力単価 $1.50 のままだからです。値下げが届いていない領域が大半を占めています。
削減率の上限は、出力側の構成比で決まる
一般化すると、総額の削減率はおおよそ次の式で押さえられます。
総額削減率 = 出力側の構成比 × ( 1 − 新出力単価 / 旧出力単価 × ( 1 − 出力トークン削減率 ) )
右側の括弧は出力側だけを見たときの削減率で、削減率 17% なら 30.84%。これに構成比を掛けたものが上限になります。
| 出力側の構成比 | 削減率 0% | 削減率 8% | 削減率 17% | 削減率 25% |
| 10% | 1.67% | 2.33% | 3.08% | 3.75% |
| 30% | 5.00% | 7.00% | 9.25% | 11.25% |
| 50% | 8.33% | 11.67% | 15.42% | 18.75% |
| 70% | 11.67% | 16.33% | 21.58% | 26.25% |
| 90% | 15.00% | 21.00% | 27.75% | 33.75% |
この表で私が救われたのは、左端の「削減率 0%」の列でした。
出力トークンが一つも減らなかったとしても、単価改定だけで出力側は 16.67% 下がります。つまり「17%減という報告が自分の用途で再現するか」は、切り替えの可否そのものを左右しません。再現しなくても損はしない構造になっています。
逆に、出力構成比が 20% の処理では、総額 5% の削減を得るのに出力トークンが 10.0% 減っている必要がありました。構成比 30% を超えると、削減率 0% でも 5% に届きます。
判断の順番が入れ替わります。先に測るべきは削減率ではなく、自分の請求のうち何割が出力側かという構成比のほうです。
予想と逆だった — ループを回すほど削減率が下がる
ここからが、事前の見込みが外れた部分です。
私はエージェント的なループでは削減がむしろ効くと考えていました。あるターンのモデル出力は、次のターンでは履歴として入力に積まれます。出力が 17% 減れば、以降のターンの入力トークンも連動して減るはずだ、と。
前半は当たっていました。後半が逆でした。
システム指示 4,000 トークン、ユーザー入力 600 トークン、ツール実行結果 900 トークンを毎ターン積む条件で、1ターンあたり出力 1,500 トークンのループを回した実行結果です。
IN_P, OUT_OLD, OUT_NEW = 1.50, 9.00, 7.50
SYS, USER, OBS = 4_000, 600, 900 # 毎ターン積まれる固定分とツール実行結果
def run(turns: int, out_per_turn: int, reduction: float = 0.0,
prune: bool = False, keep: int = 3):
"""モデル出力が次ターンの入力として再課金される分まで含めて積算する。"""
out_tokens = out_per_turn * (1 - reduction)
out_price = OUT_NEW if reduction else OUT_OLD
history, ti_total, to_total = [], 0, 0
for _ in range(turns):
ctx = history[-keep * 2:] if prune else history
ti = SYS + USER + sum(ctx)
ti_total += ti
to_total += out_tokens
history += [out_tokens, OBS]
cost = ti_total / 1e6 * IN_P + to_total / 1e6 * out_price
return ti_total, to_total, cost
for t in (1, 3, 5, 8, 12):
_, _, old_cost = run(t, 1500)
bi, _, new_cost = run(t, 1500, reduction=0.17)
print(f"{t:2d}ターン 総額削減 {(1 - new_cost / old_cost) * 100:5.2f}%")
| ターン数 | 旧(3.5 Flash) | 新(3.6 Flash) | 総額削減 | 入力トークンの削減 |
| 1 | $0.02040 | $0.01624 | 20.40% | 0.00% |
| 3 | $0.07200 | $0.05837 | 18.94% | 3.64% |
| 5 | $0.13800 | $0.11336 | 17.85% | 5.43% |
| 8 | $0.26400 | $0.21999 | 16.67% | 6.87% |
| 12 | $0.48240 | $0.40720 | 15.59% | 7.88% |
出力削減が入力側へ波及するという読み自体は合っていて、12ターンでは入力トークンが 7.88% 減っていました。
それでも総額の削減率は 20.40% から 15.59% へ落ちています。
理由は構成比です。ターンを重ねるほど履歴が膨らみ、値下げの届かない入力側が総額を占める割合が上がっていきます。波及で得た分より、希釈で失う分のほうが大きい。
長いループほどモデル切り替えの旨みが薄れる、という向きでした。短い単発呼び出しのほうが恩恵が大きいという、直感と逆の並びです。
効いたのは、切り替えより履歴の刈り込みだった
同じ 12 ターンで、履歴を直近3往復だけ残す条件を足しました。
| 条件 | 旧単価での総額 | 切り替え後 | 切り替えによる削減 |
| 履歴を全部積む | $0.48240 | $0.40720 | 15.59% |
| 直近3往復に刈る | $0.35280 | $0.29137 | 17.41% |
刈り込みだけで 26.9% 下がりました。モデル切り替えの 15.59% を上回っています。
しかも両者は競合しません。刈った上で切り替えると、切り替えによる削減率自体も 15.59% から 17.41% へ戻ります。入力側の希釈が減って、値下げの効く領域の構成比が上がるためです。
私はこの順番を取り違えていました。値下げの知らせが来ると、まず移行の可否を考えてしまう。実際には、自分のコンテキスト設計を先に見直すほうが、金額としても、その後の値下げの効き方としても大きかったわけです。
試算を請求と突き合わせる
ここまでは全て試算です。実際の請求と合わせないと意味がありません。
Gemini API のレスポンスには usage_metadata が付きます。文字数からの推定は日本語混在で簡単にずれますので、推定は使わずこの値を記録しておきます。
import json, time, pathlib
LEDGER = pathlib.Path("usage_ledger.jsonl")
def record_usage(response, *, model: str, route: str) -> dict | None:
"""1呼び出し分の実測トークンを追記する。失敗しても本処理は止めない。"""
usage = getattr(response, "usage_metadata", None)
if usage is None:
# ストリーミングでは最終チャンクにのみ載る実装がある
print(f"[warn] usage_metadata なし route={route} model={model}")
return None
row = {
"ts": time.time(),
"model": model,
"route": route, # 処理系ごとに分けて集計するための鍵
"prompt": getattr(usage, "prompt_token_count", 0),
"cached": getattr(usage, "cached_content_token_count", 0) or 0,
"output": getattr(usage, "candidates_token_count", 0),
"thoughts": getattr(usage, "thoughts_token_count", 0) or 0,
}
try:
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except OSError as e:
print(f"[warn] 台帳の追記に失敗しました: {e}")
return row
route を必ず付けるようにしたのは、本番運用に載せてから最初につまずいた点だからです。モデル別に集計しても構成比は出ません。同じモデルで入力偏重の処理と出力偏重の処理が混ざり、平均の構成比が実態のどれとも一致しない値になります。
後から分けようとすると、記録済みの行に route を復元する手立てがありません。回避するには最初の1行目から入れておくしかありませんでした。
もう一つの落とし穴が thoughts_token_count でした。思考トークンは出力として課金されますが、candidates_token_count には含まれません。分けて記録しておかないと、出力側の構成比を実際より小さく見積もることになります。
集計は次の一行で足ります。
jq -s 'group_by(.route)[] | {route: .[0].route,
prompt: (map(.prompt) | add),
output: (map(.output + .thoughts) | add)}' usage_ledger.jsonl
これで route ごとの構成比が出ますので、先ほどの表に当てて期待できる削減幅を先に見積もれます。
手を付ける順番
私が結局取った順番は次の三つでした。
usage_metadata を route 単位で1〜2週間ため、出力側の構成比を出す
- 構成比が 30% を下回る処理は、モデル切り替えを後回しにしてコンテキストの積み方を先に直す
- 構成比が高い処理から順に切り替え、切り替え前後を同じ分解式で突き合わせる
この順番を推奨します。理由は、2 を先に済ませておくと 3 の効きが上がるためです。
3 を分解式で見る理由は、切り替えと同時にプロンプトを触ってしまったときに、下がった分がどちらの効果か分からなくなるからです。単価効果は自分では動かせません。数量効果だけが自分の設計で動く部分です。この二つが分かれていれば、次に何をすればいいかが金額のまま読めます。
出力トークンが 17% 減るという知らせは、確かに良い知らせでした。ただ、その 17% が自分の請求のどこに掛かるのかは、自分の台帳を見るまで誰も教えてくれません。
私自身、足し算で見積もった予算表を一度作り直すはめになりました。同じ手戻りを避ける役に立てば嬉しいです。お読みいただきありがとうございました。