0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

そのAI機能、全部を最上位モデルに丸投げしてませんか? — 品質を保ったままコストと速度を半分にする「モデルルーティング」実践ガイド

0
Posted at

AIの記事って、精度とか品質の話ばっかりになりがちなんですよね。でも、正直に聞きますね。

その機能のAPI請求書、ちゃんと見てますか。

僕自身、個人開発でLLMを組み込んだとき、月末に届いた請求を見て「あれ、こんなに使ったっけ」ってなったことがあります。趣味の小さなツールでも、毎回いちばん賢い最上位モデルに投げてると、地味に、でも確実にお金が溶けていく。チームで運用してたらなおさらです。

そして厄介なのが、「じゃあ安いモデルに変えよう」と思っても、品質が落ちるのが怖くて結局また最上位モデルに戻す という無限ループ。これ、すごく多いと思うんです。

この記事は、その板挟みを抜けるための話です。品質をほぼ落とさずに、コストと速度を半分くらいまで持っていく ための考え方と、そのまま貼って使えるコードをまとめました。1記事で完結する実務リファレンスのつもりで書いたので、よかったらストックして、自分のプロジェクトを見直すときに開いてもらえたら嬉しいです。

AI開発に不慣れな方でも読めるように、専門用語は全部その場で噛み砕きます。


まず結論から — コストは「見えない技術的負債」です

いきなり結論を置きますね。

LLMのコストって、技術的負債とそっくり なんです。見えないうちに、静かに膨らむ。そして気づいたときには「どこから手をつければ」という状態になっている。

なぜ膨らむかというと、多くの人が すべてのリクエストを、いちばん賢くて、いちばん高いモデル1つで処理している からです。たとえるなら、会社に届くすべての案件を、経費精算のハンコ押しから重要な経営判断まで、全部いきなり社長に回しているようなもの。社長は優秀だけど、時給が高くて、しかも1件ずつしか捌けない。これじゃあコストも時間ももったいないですよね。

解決の武器は、大きく3つの層です。

  • 層1: モデルルーティング(振り分け) … リクエストを見て、簡単な仕事は安いモデル、難しい仕事だけ高いモデル、と最初に仕分ける
  • 層2: モデルカスケード(滝落とし) … まず安いモデルにやらせて、ダメなときだけ上のモデルに昇格させる
  • 層3: セマンティックキャッシュ(呼ばずに返す) … 過去と似た質問なら、モデルを呼ばずに前の答えを再利用する

どれくらい効くのか、という話も先に出しておきます。ルーティングの研究で有名な RouteLLM(LMSYS、arXiv:2406.18665)では、MT-Benchというベンチマークで、最上位モデルを使う割合を約14%まで絞りながら、その品質の約95%を保ち、コストを最大85%削減できた、と報告されています。もちろん自分のワークロードで同じ数字が出るわけではないですが、「賢く振り分けるだけで、これだけ変わる余地がある」という手応えの参考にはなると思います。

では、1層ずつ見ていきましょう。


なぜ「全部いちばん賢いモデル」は損なのか

考えてみてほしいんですけど、あなたのアプリが受け取るリクエスト、本当に全部が難しいですか。

実務で流れてくる仕事の多くは、けっこう簡単なんですよね。短い文章の分類、キーワードの抽出、決まったフォーマットへの整形、YES/NOの判定。こういうのは、正直、小さいモデルでも十分に正解を出せます。難しいのは、多段の推論が要る設計判断や、長い文脈を踏まえた複雑なコード生成といった、ごく一部です。

つまり 能力を過剰に供給している 状態。ハンコ押しに社長を呼んでいる。ここに無駄が眠っています。

イメージとしては、モデルを「棚」で持つ感覚が近いです。

  • cheap-model(小) … 速くて安い。分類・抽出・整形みたいな定型作業が得意
  • mid-model(中) … バランス型。一般的なQ&Aやコード補完くらいならこなす
  • frontier-model(大) … 遅くて高いけど賢い。難しい推論や設計判断はここ

この棚から、仕事に応じて適切な引き出しを開ける。それだけの話なんです。難しく考えなくて大丈夫。


層1: モデルルーティング(最初に振り分ける)

ルーティングというのは、要は 振り分け です。リクエストが来た瞬間に「これは簡単だから安いモデル」「これは難しいから高いモデル」と、モデルを呼ぶ前に決めてしまう。判断が1回だけなので速いし、コストの予測も立てやすい。まずはここから始めるのがおすすめです。

いちばんシンプルなのは、ルール(条件分岐)で振り分けるやり方です。そのまま自分のLLM呼び出しラッパに貼れる粒度で書いてみます。

def choose_model(prompt: str) -> str:
    """プロンプトを見て、呼ぶモデルを1回で決めるだけのルーター。"""
    text = prompt.lower()

    # 明らかに簡単: 分類・抽出・要約・整形など、短い定型作業
    simple_keywords = ("分類", "抽出", "要約", "翻訳", "整形", "json", "yes/no")
    if len(prompt) < 400 and any(k in text for k in simple_keywords):
        return "cheap-model"

    # 中くらい: 一般的なQ&Aや、短めのコード補完
    if len(prompt) < 2000:
        return "mid-model"

    # 長い/難しい: 多段の推論や設計判断は最上位モデルへ
    return "frontier-model"

これだけでも、簡単なリクエストが安いモデルに逃げてくれるので、けっこう効きます。ただ、キーワードとプロンプト長だけだと粗いので、もう少し賢くしたいときは「複雑度スコア」を出して判断するといいです。

from dataclasses import dataclass


@dataclass
class Task:
    prompt: str
    needs_reasoning: bool = False       # 多段の推論が要るか
    needs_tools: bool = False           # 外部ツール/関数呼び出しが要るか
    output_is_structured: bool = False  # 厳密なJSON等の構造化出力が要るか


def complexity_score(task: Task) -> int:
    """タスクの難しさを点数化する。高いほど賢いモデルが必要。"""
    score = 0
    score += min(len(task.prompt) // 500, 4)   # 長さ: 0〜4点
    score += 3 if task.needs_reasoning else 0
    score += 2 if task.needs_tools else 0
    score += 1 if task.output_is_structured else 0
    return score


def route(task: Task) -> str:
    score = complexity_score(task)
    if score <= 2:
        return "cheap-model"
    if score <= 5:
        return "mid-model"
    return "frontier-model"

ポイントは、「難しさ」を勘ではなく点数という数字に落とすこと です。こうしておくと、あとで「スコア3以上は中モデル」みたいに閾値を1行いじるだけでチューニングできる。数字にしておくと、未来の自分がすごく助かるんですよね。

タスクとモデルの対応は、こんなイメージで持っておくと迷いにくいです。

タスクの種類 例 推奨モデル
定型・軽作業 分類、タグ付け、抽出、整形、短い翻訳 cheap-model
一般作業 よくあるQ&A、短いコード補完、要約 mid-model
高難度 多段推論、設計判断、長文脈のコード生成 frontier-model

ちなみに、振り分けそのものを言葉の分類でやりたいときは、安いモデルに「仕分け係」をやらせる手もあります。判定用のプロンプトはこんな感じ。

あなたはタスクの難易度を判定する分類器です。
次のユーザー要求を読み、難易度を "easy" / "medium" / "hard" の
いずれか1語だけで答えてください。理由は書かないでください。

判定基準:
- easy: 抽出・分類・整形など、1ステップで終わる定型作業
- medium: 一般的な質問応答や短いコード生成
- hard: 多段の推論、設計判断、長い文脈の統合が必要なもの

ユーザー要求:
"""
{ここにユーザーの入力を入れる}
"""

ひとつ正直に書いておくと、ルーティングにもコストはあります。分類のために安いモデルを1回挟むと、その分の呼び出しが増える。とはいえ、この判定は軽くて安いので、重い本番の生成を1回でも上位モデルから安いモデルに移せれば、十分おつりが来る ことが多いです。判定のオーバーヘッドと、節約できる生成コストを天秤にかける、という視点だけ持っておけば大丈夫です。


層2: モデルカスケード(安く始めて、ダメなら昇格)

ルーティングは「最初に決め打ち」でした。カスケードは発想が逆で、まず一番安いモデルにやらせてみて、その答えがダメだったときだけ上のモデルに昇格させる やり方です。滝(cascade)が上から下へ落ちていくように、必要なぶんだけ段を降りていくイメージですね。

この考え方は FrugalGPT(arXiv:2305.05176)という研究が先鞭をつけました。安いモデルで済むものは安く済ませ、品質チェックに通らなかったものだけ高いモデルに回す。これで品質を保ちつつコストを大きく下げられる、という話です。

ここでカギになるのが verifier(検証係) です。verifierというのは、AIが出した答えを、機械的に○×判定する関数 のこと。人間がいちいち目視しなくても、「この答えは合格か」を自動で決める門番ですね。

import json


def verify(answer: str) -> bool:
    """AIの答えを機械的に○×判定する検証係。
    ここでは『有効なJSONで、必須キーが揃っているか』を例にする。"""
    try:
        data = json.loads(answer)
    except json.JSONDecodeError:
        return False
    return all(key in data for key in ("title", "summary", "tags"))


def cascade(prompt: str) -> str:
    # 1) まず一番安いモデルに投げる
    answer = call_llm("cheap-model", prompt)
    if verify(answer):
        return answer   # 検証OKなら、ここで終了(昇格しない=安上がり)

    # 2) ダメなら中モデルへ昇格
    answer = call_llm("mid-model", prompt)
    if verify(answer):
        return answer

    # 3) それでもダメなら最上位モデル(最後の砦)
    return call_llm("frontier-model", prompt)

verifierは、タスクによって中身を変えます。JSONの妥当性チェックだけじゃなくて、正規表現で禁止語を弾く、必須項目が入っているか確かめる、あるいは別の安いモデルに「この答えは要求を満たしてる?」と聞く自己評価もアリです。自己評価をさせるなら、こんなプロンプトになります。

あなたは回答の品質を判定する検査官です。
以下の「要求」と「回答」を読み、回答が要求を十分に満たしているかを
判定してください。少しでも不足・誤り・的外れがあれば "NG" とします。

出力は "OK" または "NG" の1語のみ。説明は不要です。

# 要求
{元のユーザー要求}

# 回答
{安いモデルが出した回答}

カスケードの注意点も正直に書きますね。昇格が起きるたびにモデルを呼び直すので、その分だけ遅くなります。 ルーティングが「1回で決める」のに対して、カスケードは最悪3回呼ぶ。だから、レイテンシ(応答の速さ)が超シビアな場面には向きません。逆に、バッチ処理みたいに「多少遅くてもいいから安く大量に捌きたい」場面ではめちゃくちゃ相性がいいです。


層3: セマンティックキャッシュ(そもそも呼ばない)

いちばん安いLLM呼び出しは、呼ばないこと です。当たり前なんですけど、これが意外と見落とされます。

普通のキャッシュは「まったく同じ文字列」じゃないとヒットしません。でも人間の質問って、同じ意味でも言い回しが毎回ちょっと違いますよね。「返品したい」「返品ってできる?」「品物を返したいんだけど」。文字は違うけど、聞きたいことは同じ。

そこで セマンティックキャッシュ の出番です。セマンティック=意味。文字が違っても、意味が近ければ「同じ質問」とみなして、前の答えを返す 仕組みです。

意味の近さをどう測るかというと、埋め込み(embedding) を使います。埋め込みというのは、文章の意味を数値のベクトル(数字の並び)に変換したもの です。意味が近い文どうしは、ベクトルの向きも近くなる。その近さ(コサイン類似度)が一定以上なら「実質同じ質問」と判断します。

import numpy as np


class SemanticCache:
    def __init__(self, threshold: float = 0.92):
        self.threshold = threshold   # 意味の近さの下限(0〜1)。高いほど厳しい
        self.keys = []               # 過去クエリの埋め込み
        self.values = []             # 対応する回答

    def _embed(self, text: str) -> np.ndarray:
        vec = np.array(embed(text), dtype=np.float32)   # 意味を数値ベクトル化
        return vec / (np.linalg.norm(vec) + 1e-8)       # 長さを1にそろえる

    def get(self, prompt: str):
        if not self.keys:
            return None
        q = self._embed(prompt)
        sims = [float(q @ k) for k in self.keys]        # コサイン類似度を計算
        best = int(np.argmax(sims))
        if sims[best] >= self.threshold:
            return self.values[best]   # 十分に近い→LLMを呼ばずに即返す
        return None

    def put(self, prompt: str, answer: str):
        self.keys.append(self._embed(prompt))
        self.values.append(answer)

繰り返しの多いアプリ(FAQボットとか、社内の定番質問とか)だと、これがよく効きます。ヒットした分はLLM料金がまるごとゼロになるし、応答も一瞬で返る。

ただし注意が1つ。キャッシュは「古い答え」を返してしまうリスクがあります。 料金表や在庫みたいに内容が変わるものは、古い回答を返すと事故になる。なので、有効期限(TTL)を設けて古いものは捨てる、内容が更新されたら該当キャッシュを消す、という無効化の設計をセットにしてください。「速いから」で何でもキャッシュすると、今度は正確さで足をすくわれます。

ちなみに、各社が提供している prompt caching(プロバイダ側のキャッシュ) とは別物です。あちらは「同じ長いプロンプトの前半を使い回してトークン代を割り引く」仕組み。こちらのセマンティックキャッシュは「似た質問なら呼び出し自体を省く」仕組み。役割が違うので、両方を併用できます。


3層の合わせ技と、コストは「観測」して初めて減る

3つの層は、組み合わせると強いです。定番の順番は キャッシュ → ルーティング → (必要なら)カスケード です。まずキャッシュで呼ばずに済むものを弾き、残ったものをルーティングで振り分ける。

cache = SemanticCache()


def smart_answer(task: Task) -> str:
    # 層3: まずキャッシュ。呼ばずに返せるなら一番安い
    cached = cache.get(task.prompt)
    if cached is not None:
        return cached

    # 層1: タスクの難易度でモデルを選ぶ
    model = route(task)
    answer = call_with_stats(model, task.prompt)

    cache.put(task.prompt, answer)
    return answer

3つの層を、性質の違いで並べておきます。

層 やること よく効く場面 主なコスト・副作用
ルーティング 呼ぶ前にモデルを振り分け 難易度がバラつくトラフィック全般 判定のオーバーヘッド(軽い)
カスケード 安く始めて失敗したら昇格 多少遅くてOKなバッチ・大量処理 昇格するほど遅くなる
セマンティックキャッシュ 似た質問は呼ばずに返す FAQ等、繰り返しの多い用途 古い答えを返す危険(TTLで対策)

そして、ここがいちばん大事なんですけど。コストは、観測して初めて減らせます。 見えないものは改善できない。だから、どのモデルを何回呼んだか、上位モデルにどれくらい昇格したか(=エスカレーション率)を、必ずログに出しておきましょう。

from collections import Counter

# ダミーの価格表(1kトークンあたりの相対コスト)。実際の値は各自の契約で置き換える
PRICE = {"cheap-model": 1, "mid-model": 5, "frontier-model": 30}
stats = Counter()


def call_with_stats(model: str, prompt: str) -> str:
    stats[f"calls:{model}"] += 1
    answer = call_llm(model, prompt)
    tokens = (len(prompt) + len(answer)) // 4          # ざっくりトークン概算
    stats[f"cost:{model}"] += PRICE[model] * tokens / 1000
    return answer


def report():
    total = sum(v for k, v in stats.items() if k.startswith("calls:"))
    frontier = stats["calls:frontier-model"]
    escalation_rate = frontier / total if total else 0
    total_cost = sum(v for k, v in stats.items() if k.startswith("cost:"))
    print(f"呼び出し合計: {total}")
    print(f"最上位モデル比率(=エスカレーション率): {escalation_rate:.1%}")
    print(f"推定コスト合計: {total_cost:.1f}")

エスカレーション率は、生きたコストの指標 です。ここがじわじわ上がってきたら、安いモデルで足りなくなっている(=節約が消えている)サイン。逆にずっと0%に近いなら、閾値が厳しすぎてもっと安くできる余地があるサインです。数字が出ていれば、勘で悩まずに判断できます。


人間が設計し、AIが実行する — 役割分担

ここまで読んで、気づいた方もいると思います。この仕組み、「何を安く済ませ、何を検証し、いつ昇格させるか」を決めているのは、全部あきらパパたち人間 なんですよね。AIは、決められた役割を実行しているだけ。

AI時代のエンジニアの仕事は、実装そのものから、この「判断の設計」へ静かに移っている と僕は思っています。整理すると、こんな分担です。

工程 人間が設計・判断すること AIに任せること
振り分け基準 何を簡単/難しいと定義するか、閾値をどこに置くか 個々の入力の難易度を推定する
検証(verifier) 「合格」の条件を決める、禁止事項を定める 生成した答えを出す
昇格ルール 何回まで昇格を許すか、コスト上限 昇格先での再生成
キャッシュ 何をキャッシュしてよいか、TTL・無効化 過去回答の意味的な照合
観測 どの数字を見るか、異常の閾値 ログの生成そのもの

導入するときに、勢い余って事故らないための撤退・注意ラインもまとめておきます。

注意ライン なぜ危険か 対策
品質監視なしのルーティング 安いモデルに回した分の品質劣化に気づけない 出力サンプルの定点評価を必ず並走させる
検証のない安易な安モデル化 「動いてるように見えて実は間違い」を量産 verifierとエスカレーションをセットにする
キャッシュの無期限保持 古い/誤った答えを返し続ける TTLと更新時の無効化を設計する
エスカレーション率の放置 節約したつもりがコストが元に戻る 率をログに出し、閾値超えでアラート

「安くする」って聞くとケチくさく感じるかもしれないですけど、僕はちょっと違う捉え方をしていて。これは 削る話じゃなくて、配分を最適化する話 なんです。同じ予算で、より多くの機能を回せるようになる。浮いたコストは、次の機能への投資に変わる。


おわりに — 今日、関数を1個貼るところから

長くなりましたけど、やることはシンプルです。全部を最上位モデルに投げるのをやめて、仕事に応じて棚から引き出しを選ぶ。 それだけ。

いきなり3層フルセットを組む必要はありません。まずは今日、choose_model() みたいな振り分け関数を1個、自分のLLM呼び出しの手前に挟んでみる。それだけで、簡単なリクエストが安いモデルに逃げ始めます。慣れてきたら、カスケードとキャッシュを足していけばいい。小さな一歩で十分です。

最後に、僕が大事にしている2つの軸で締めさせてください。

ひとつは Code & Capital です。無駄なコストは、失われた利益と同じです。逆に言えば、賢く配分できたぶんは、そのままプロダクトを育てる資本になる。コスト設計は、もう立派なエンジニアリングスキルなんですよね。

もうひとつは、明日の自分にあざっす という軸。エスカレーション率やコストをログに出しておく仕組みは、今日の自分にはちょっと面倒です。でも、請求が跳ねた月に原因をすぐ特定できる。半年後に「あのとき数字を出しておいてよかった」と、未来の自分が言ってくれる。そういう保険を、今日ちょっとだけ仕込んでおく。

精度の追求も大事ですけど、たまには請求書のほうも、優しく見てあげてください。あなたのAI機能、きっともっと軽くて速くできます。

最後に、自分のプロジェクトを棚卸しするときのプロンプトを置いておきますね。

あなたはLLMコスト最適化のコンサルタントです。
以下の「LLMを使っている機能の一覧」を読み、各機能について
次の3点を表で整理してください。

1. その処理の難易度(easy / medium / hard)と、その理由
2. 推奨モデル階層(cheap / mid / frontier)
3. キャッシュやカスケードで安くできる余地(あり/なし+一言)

# LLMを使っている機能の一覧
- {機能1: 何をしているか}
- {機能2: 何をしているか}
- {機能3: 何をしているか}

ここまで読んでくださって、ありがとうございました。よかったらストックして、コスト見直しのお供にしてもらえたら嬉しいです。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?