Skip to content

필사 모드: LLM APIのコスト最適化 — 出力トークン、プロンプトキャッシング、ルーティングの損益分岐計算

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 請求書はトークン単位で来ます

LLMのコスト最適化の議論はたいてい「もっと安いモデルに変えましょう」で始まり、「品質が落ちそうですが」で終わります。どちらにも根拠がないので結論が出ません。

ところがこの問題は、もともと根拠を作りやすい部類に入ります。請求額はトークン数と単価の積で、両方の値はすでにログの中にあります。必要なのは、どの項が請求書を支配しているのかを確認し、各レバーがその項を何パーセント減らすのかを計算することだけです。

この記事ではその計算を順番に行います。まずコストの構造を分解し、レバーを削減幅の大きい順に扱い、各レバーの損益分岐を式で示します。単価はプロバイダごとに異なりよく変わるのですべて変数として置き、例には「100万トークンあたり入力3ドル、出力15ドル」というよくある比率を入れます。ご自身の単価を入れて回し直していただければと思います。

コストの構造 — 入力と出力の単価は違います

最初に確認すべき事実は、入力トークンと出力トークンの価格が同じではないということです。出力は入力よりたいてい3倍から5倍高くなります。理由は構造的です。入力はプリフィル段階で一度に並列処理されますが、出力はトークンごとにモデル全体をもう一度通らなければならない逐次的な作業だからです。

この非対称性のせいで直感がよく外れます。

def daily_cost(reqs, in_tok, out_tok, price_in, price_out):
    """price_*は100万トークンあたりのドル単価"""
    c_in = reqs * in_tok * price_in / 1e6
    c_out = reqs * out_tok * price_out / 1e6
    return c_in, c_out, c_in + c_out

c_in, c_out, total = daily_cost(
    reqs=100_000, in_tok=2_000, out_tok=500,
    price_in=3.0, price_out=15.0,
)
print(f"입력 {c_in:8.0f}  출력 {c_out:8.0f}  합계 {total:8.0f}  (일)")
print(f"출력 비중 {c_out / total:.1%},  월 {total * 30:,.0f}")
# 입력      600  출력      750  합계     1350  (일)
# 출력 비중 55.6%, 월 40,500

出力トークンは入力の4分の1なのにコストのほうが大きいのです。プロンプトを削って500トークン減らす作業と、応答を125トークン減らす作業の金銭的効果が同じです。ほとんどのチームはプロンプトのダイエットに時間を使い、出力長は放置します。

ですから最初の質問はいつもこれです。うちの出力はなぜこんなに長いのか。

最大のレバーは出力長です

出力が長くなる理由はたいてい三つです。モデルが前置きと結びを付け、頼んでいない説明を足し、構造化された結果を散文で包みます。

効果の大きい順に整理すると次のようになります。

第一に、出力形式を散文から構造に変えます。同じ情報をJSONスキーマや列挙型で受け取れば、トークン数は何分の一かに落ちます。「分類結果を説明とともに教えてください」と「ラベルだけを出力してください」の差は200トークンと3トークンです。安定して受け取る方法は構造化された出力の安定性の回にまとめました。

第二に、max_tokensを実際の必要量に合わせて下げます。これはコスト削減というより事故防止の装置です。モデルが繰り返しループに陥って上限までトークンを吐き出す事故は珍しくなく、その一回が通常のリクエスト数百件ぶんになります。

第三に、思考の連鎖を使う区間を狭めます。段階的な推論は難しい問題で精度を上げますが、出力トークンを数倍にします。すべてのリクエストに一律で適用せず、難易度を判別してから必要なリクエストにだけ付けるほうが良いです。

ここで正直に指摘しておくことが一つあります。出力を減らすと品質が落ちるタスクは確かに存在します。要約や分類は短くしてもほとんど損失がありませんが、推論が必要なタスクで思考過程を切り落とすと精度が下がります。ですから出力長の短縮は「無条件にやること」ではなく「タスクごとに測ってからやること」です。測り方は後ほどあらためて扱います。

プロンプトキャッシング — 接頭辞しか捕まえません

同じシステムプロンプトとfew-shot例を毎リクエストの先頭に付けるサービスなら、その部分の入力コストは原理的に繰り返しの支払いです。プロンプトキャッシングはこの繰り返しをなくします。

核心的な制約は一つです。キャッシュは接頭辞単位でしか効きません。プロンプトの先頭からトークンが完全に一致する区間までだけが再利用され、一トークンでも違った瞬間にその後はすべて計算し直しになります。途中に挟まった同一の段落は捕まえられません。

この制約がプロンプトの配置順を事実上強制します。

# 悪い順序: キャッシュヒット0
prompt = f"""오늘 날짜는 {today}입니다.
{SYSTEM_RULES}          # 8,000トークンの固定指示
{FEW_SHOT_EXAMPLES}     # 4,000トークンの固定例
사용자 질문: {question}"""

# 良い順序: 先頭の12,000トークンが丸ごとキャッシュヒット
prompt = f"""{SYSTEM_RULES}
{FEW_SHOT_EXAMPLES}
오늘 날짜는 {today}입니다.
사용자 질문: {question}"""

日付の一行を先頭に置くと、その後ろの1万2千トークンが毎回計算し直しになります。位置を変えるだけで全部を救えます。原則は単純です。変わらないものから、よく変わるものを後ろへ。

単価の構造はおおむね、キャッシュ読み取りが基本入力価格の10分の1程度、キャッシュ書き込みが基本価格より少し高い形です。正確な倍率とキャッシュの保持時間はプロバイダのドキュメントで確認する必要があります。この構造で損益分岐を計算すると次のようになります。

def caching_breakeven(write_mult=1.25, read_mult=0.1):
    """キャッシュ保持時間内に何回再利用すれば得になるか"""
    # n回使用時: write_mult + (n-1) * read_mult  vs  n * 1.0
    n = 1
    while write_mult + (n - 1) * read_mult >= n:
        n += 1
    return n

print(caching_breakeven())  # 2

def cached_input_cost(reqs, static_tok, dynamic_tok, price_in, hit_rate,
                      write_mult=1.25, read_mult=0.1):
    dyn = reqs * dynamic_tok * price_in / 1e6
    stat_tok = reqs * static_tok
    stat = (stat_tok * hit_rate * read_mult + stat_tok * (1 - hit_rate) * write_mult) * price_in / 1e6
    return dyn + stat

base = 100_000 * 2_000 * 3.0 / 1e6
cached = cached_input_cost(100_000, static_tok=1_600, dynamic_tok=400,
                           price_in=3.0, hit_rate=0.9)
print(f"캐시 없음 {base:.0f} → 적중률 90%에서 {cached:.0f} (일 {base - cached:.0f} 절감)")
# 캐시 없음 600 → 적중률 90%에서 223 (일 377 절감)

二回再利用するだけで得になります。ですからキャッシングは「導入するかどうか」を悩む対象ではなく、ヒット率をどう上げるかを悩む対象です。ヒット率を殺すよくある犯人は、プロンプトの前方にあるタイムスタンプ、リクエストID、ユーザー名、そして順序が保証されない方法でシリアライズされたツール定義です。

RAGと全文投入 — 損益分岐はどこか

コンテキストウィンドウが大きくなったことで「全部入れればいいのでは」という選択肢が生まれました。コストだけを見れば計算は簡単です。

def per_request_cost(tokens, price_in, cached=False, read_mult=0.1):
    mult = read_mult if cached else 1.0
    return tokens * price_in * mult / 1e6

DOC = 100_000     # 全文書のトークン
K, CHUNK = 5, 400 # RAG: 上位5個のチャンク

full_raw    = per_request_cost(DOC, 3.0)
full_cached = per_request_cost(DOC, 3.0, cached=True)
rag         = per_request_cost(K * CHUNK, 3.0)
print(f"전체 {full_raw:.3f} / 전체+캐싱 {full_cached:.3f} / RAG {rag:.3f} (요청당 달러)")
# 전체 0.300 / 전체+캐싱 0.030 / RAG 0.006

# 損益分岐となる文書サイズ: RAGの検索オーバーヘッドを考慮しても
print(f"RAG가 유리해지는 문서 크기: {K * CHUNK}토큰 초과")

数字が語っていることは二つです。第一に、RAGは全文投入に比べて50倍安いのですが、文書が静的でキャッシングが効くとその差は5倍に縮みます。第二に、5倍は依然として大きな差ですが、「RAGパイプラインを作って維持するコスト」と比べるべき大きさです。一日のリクエストが1千件のサービスで、リクエストあたり0.024ドルの差は月700ドル程度です。エンジニアの時間のほうが高くつきます。

ですから私は、この判断はコストではなく別の軸で下すほうが妥当だと考えています。

文書がコンテキストウィンドウに収まらなければRAG以外の選択肢はありません。文書がユーザーごとに違えばキャッシュヒット率が低く、全文投入は高くつきます。レイテンシが重要なら10万トークンのプリフィルによる最初のトークンの遅延が問題になります。逆に文書が小さく(数万トークン)、静的で、検索失敗の代償が大きいなら、全部入れるほうが単純で安全です。そしてコンテキストが長くなるほど中間に置かれた情報を取りこぼす傾向が報告されてきたので、「全部入れれば精度は常に高い」という前提も検証の対象です。

モデルルーティング — 削減額と精度の損失を同じ表に置く

簡単なリクエストを小さいモデルに送るとどれだけ節約できるかは、二つの数字で決まります。委譲比率と単価比です。

def routing_saving(p_small, price_ratio):
    """p_small: 小さいモデルに送る比率、price_ratio: 小さいモデルの単価 / 大きいモデルの単価"""
    return p_small * (1 - price_ratio)

def cascade_saving(price_ratio, escalate_rate, judge_ratio=0.0):
    """小さいモデルで先に答え、不足なら大きいモデルへ昇格。昇格分は二回払う。"""
    cost = price_ratio + judge_ratio + escalate_rate * 1.0
    return 1 - cost

print(f"라우팅   {routing_saving(0.7, 1/15):.1%}")            # 65.3%
print(f"캐스케이드 {cascade_saving(1/15, 0.25, 0.02):.1%}")   # 66.3%

# カスケードが成立する条件: 昇格率が (1 - 単価比 - 審査費) 未満
print(f"승급률 상한 {1 - 1/15 - 0.02:.1%}")                   # 91.3%

コストだけを見れば、どちらの方式も大きく余ります。カスケードは昇格率が91パーセントを超えない限り得なので、事実上いつでも得です。つまりこの判断のボトルネックはコストではなく品質です。

ですから削減率と同じ画面に、必ずこの値を置く必要があります。

def routed_accuracy(p_small, acc_small, acc_large):
    return p_small * acc_small + (1 - p_small) * acc_large

acc = routed_accuracy(0.7, acc_small=0.88, acc_large=0.94)
print(f"라우팅 후 정확도 {acc:.3f} (단일 대형 0.940, 손실 {0.94 - acc:.3f})")
# 라우팅 후 정확도 0.898 (단일 대형 0.940, 손실 0.042)

ここに入れた0.88と0.94は私が作った例の値です。実際にはルーティング対象の分布で両モデルの精度をそれぞれ測定して入れる必要があります。そしてこの測定には落とし穴が一つあります。評価セット全体での精度ではなく「小さいモデルに送ると決めたリクエスト」での精度を測らなければなりません。ルーターが役割を果たしているなら、その部分集合では両モデルの差ははるかに小さいはずで、差がそのままならルーターが難易度をまったく区別できていないという信号です。

カスケードで追加で見るべきものは昇格判定の信頼度です。判定が間違って悪い答えを昇格させられなければ、それは削減ではなく単なる品質低下です。判定器自体の適合率と再現率を別に測る必要があります。

レバー作用点典型的な削減幅品質リスク実装負担
出力形式の構造化出力トークン30~70%低い(タスク依存)低い
max_tokensの引き下げ出力トークン事故防止が主途中で切れる危険非常に低い
プロンプトキャッシング入力トークン入力の最大90%なし低い(順序の再配置)
RAGでコンテキスト縮小入力トークン5~50倍検索失敗時に大きい高い
モデルルーティング全体50~70%測定必須
バッチ処理全体おおむね半分なし(遅延のみ)低い

バッチ処理、そして推定値と請求書がずれる理由

レイテンシを諦められる作業は、別の非同期処理経路に送ると単価が大きく下がります。多くのプロバイダが時間の余裕をもらう代わりに半分程度の単価を適用します。夜間のバッチ分類、ログの要約、埋め込みの再生成、評価セットの採点のように人が待たない作業がすべて候補です。対話型の経路とバッチ経路をコードレベルで分けておけば、後からどのワークロードがどちら側なのかを判断し直す必要がなくなります。

最後に、計算機が吐いた数字と実際の請求額が合わないよくある原因です。

システムプロンプトとツール定義も毎リクエストの入力トークンです。ツールを20個付けておけばそのスキーマ全体が毎回課金されます。使わないツールを整理するだけで入力トークンが目に見えて減ることがあります。

再試行と中断も課金されます。ストリーミング中にユーザーがウィンドウを閉じても、その時点までに生成されたトークンは請求されます。JSONのパース失敗で再試行すれば、そのリクエストは二回決済されます。パース失敗率が5パーセントならコストも5パーセント増えます。

トークン数を文字数で概算すると言語によって大きく外れます。同じ意味の文でも、韓国語は英語よりトークンが多く出ることがよくあります。実際のトークナイザで数えるか、プロバイダのトークンカウント応答フィールドをログに残すのが唯一信頼できる方法です。

マルチモーダル入力は別の換算式に従います。画像一枚が解像度によって数百から数千トークンになるので、テキスト基準の推定にそのまま乗せると大きく外れます。

そしてログにはリクエストあたりの入力トークン、出力トークン、キャッシュ読み取りトークン、キャッシュ書き込みトークン、モデル名をすべて残す必要があります。この五つのフィールドがなければ、どの最適化がいくら節約したのかを事後に証明する方法がありません。

おわりに — ログの五フィールドが先です

コスト最適化で最もよくある失敗は、間違ったレバーを引くことではなく、引いたあとに効果が分からないことです。リクエスト単位でトークン種別ごとの使用量を残していないなら、どんな改善案も「そんな気がする」で終わります。

記録があるなら順序はおおむね決まっています。出力トークンが請求書を支配しているかをまず確認し、支配しているなら出力形式から手を入れます。次にプロンプトを固定部分と可変部分に分けて順序を正します。ここまでは品質リスクがほとんどない区間です。ルーティングとRAGは品質を賭けて行う取引なので、削減額の隣に精度の損失を同じ大きさで書いておいて決めていただければと思います。

현재 단락 (1/105)

LLMのコスト最適化の議論はたいてい「もっと安いモデルに変えましょう」で始まり、「品質が落ちそうですが」で終わります。どちらにも根拠がないので結論が出ません。

작성 글자: 0원문 글자: 7,032작성 단락: 0/105