Три модели вместо одной: дешёвая на рутину, дорогая на архитектуру
Технический разбор от команды go2llm, не отзыв клиента. Код рабочий, арифметика показана целиком — пересчитайте сами. Все суммы — расчёт по опубликованному прайсу (каталог v38, 2026-09-27), а не замер чужого счёта.
Задача
В приложении один LLM-вызов обслуживает всё: нормализацию текста, извлечение полей, ответ пользователю и разбор архитектурного вопроса. Модель выбрана по худшему случаю — самая сильная, потому что «иначе на сложных запросах плохо». Результат: 90% трафика решается моделью в разы дешевле, но платите вы по верхней ставке за всё.
Нужно разложить трафик по трём уровням, оставив один ключ, один баланс и одну кодовую базу. И нужна честная арифметика: роутер сам стоит денег, и надо знать, когда он окупается.
Как было
# ДО: одна модель на всё
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["GO2LLM_API_KEY"],
base_url="https://go2llm.tech/v1",
)
def run(system: str, user: str, max_tokens: int = 1200) -> str:
resp = client.chat.completions.create(
model="claude-opus-5", # выбрана по худшему случаю
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return resp.choices[0].message.content
Проблема не в коде, а в экономике: claude-opus-5 стоит 0.503 / 2.51 $ за 1M токенов вход/выход, claude-haiku-4-5 — 0.13 / 0.65, то есть примерно в 3.9 раза дешевле на обеих сторонах.
Что делаем
1. Определяем уровни, а не модели
Уровень задаётся тем, что требуется от ответа, и описывается словами до того, как выбирается модель:
| Уровень | Что это | Пример |
|---|---|---|
cheap | детерминированное преобразование, короткий ответ, есть эталон | извлечь поля, нормализовать, классифицировать |
main | обычная содержательная работа, нужен связный текст и следование инструкции | ответ пользователю, ревью диффа, черновик |
hard | многошаговое рассуждение, архитектура, компромиссы, длинный контекст | разбор трейдоффов, план миграции |
2. Сначала правила, только потом LLM-классификатор
Дешёвый классификатор всё равно стоит денег и добавляет задержку. Большая часть трафика классифицируется бесплатно — по типу вызова в вашем коде.
3. Фиксируем model ID из каталога
curl -sS https://go2llm.tech/v1/models \
| jq -r '.data[] | select(.id | test("haiku|sonnet|opus|deepseek")) | .id'
Строки копируются из вывода: приблизительное написание даёт 404 model_not_found.
4. Ограничиваем ключ ровно теми моделями, что есть в роутере
На каждый ключ настраивается список разрешённых моделей, квота в долларах, IP allow-list, срок и лимит 20 RPM по умолчанию. Если в списке только маршруты роутера, ошибка в коде вернёт 403 model, а не счёт за лишнюю модель.
5. Пишем роутер
# router.py
import json
import os
import re
from dataclasses import dataclass
from enum import Enum
import openai
from openai import OpenAI
client = OpenAI(
api_key=os.environ["GO2LLM_API_KEY"],
base_url="https://go2llm.tech/v1",
max_retries=0,
timeout=90.0,
)
class Tier(str, Enum):
CHEAP = "cheap"
MAIN = "main"
HARD = "hard"
ROUTES: dict[Tier, dict] = {
Tier.CHEAP: {"model": "claude-haiku-4-5", "max_tokens": 500},
Tier.MAIN: {"model": "claude-sonnet-5", "max_tokens": 900},
Tier.HARD: {"model": "claude-opus-5", "max_tokens": 2000},
}
CLASSIFIER_MODEL = "deepseek-v4-flash" # 0.0226 / 0.0679 $ за 1M
# Бесплатный слой: то, что можно решить без вызова LLM.
_HARD_MARKERS = re.compile(
r"\b(архитектур|трейдофф|trade-?off|миграц|отказоустойчив|"
r"почему именно|сравни подход|спроектируй)\w*", re.I
)
_CHEAP_MARKERS = re.compile(
r"\b(извлеки|нормализуй|переведи|классифицируй|отформатируй|"
r"переформулируй|проверь орфограф)\w*", re.I
)
CLASSIFIER_PROMPT = (
"Ты классификатор сложности запроса. Ответь строго JSON вида "
'{"tier":"cheap|main|hard"}. '
"cheap — механическое преобразование текста или извлечение полей. "
"main — обычный содержательный ответ. "
"hard — многошаговое рассуждение, архитектура, сравнение компромиссов."
)
@dataclass(slots=True)
class Result:
text: str
tier: Tier
model: str
cost_usd: float
classifier_cost_usd: float
@property
def total_cost_usd(self) -> float:
return self.cost_usd + self.classifier_cost_usd
def _cost(resp) -> float:
return float(getattr(resp.usage, "cost_usd", 0.0) or 0.0)
def classify(user: str) -> tuple[Tier, float]:
"""Возвращает (уровень, стоимость классификации в USD)."""
head = user[:1500]
# Слой 0: правила. Стоит ноль.
if _CHEAP_MARKERS.search(head) and len(head) < 600:
return Tier.CHEAP, 0.0
if _HARD_MARKERS.search(head) or len(user) > 6000:
return Tier.HARD, 0.0
# Слой 1: дешёвая модель. Короткий вход, 20 токенов на выход.
try:
resp = client.chat.completions.create(
model=CLASSIFIER_MODEL,
max_tokens=20,
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": CLASSIFIER_PROMPT},
{"role": "user", "content": head},
],
)
raw = json.loads(resp.choices[0].message.content)
return Tier(raw.get("tier", "main")), _cost(resp)
except (openai.APIStatusError, ValueError, json.JSONDecodeError):
# Классификатор упал или вернул мусор — деградируем в середину,
# а не вверх: неправильный hard дороже неправильного main.
return Tier.MAIN, 0.0
def run(system: str, user: str, force: Tier | None = None) -> Result:
tier, cls_cost = (force, 0.0) if force else classify(user)
route = ROUTES[tier]
resp = client.chat.completions.create(
model=route["model"],
max_tokens=route["max_tokens"],
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return Result(
text=resp.choices[0].message.content,
tier=tier,
model=route["model"],
cost_usd=_cost(resp),
classifier_cost_usd=cls_cost,
)
Как стало
Вызывающий код не знает про модели, он знает про уровни — и может форсировать уровень там, где сложность известна заранее.
# app.py
from router import Tier, run
# Тип вызова известен — классификатор не нужен, force экономит его стоимость.
fields = run(
"Извлеки поля в JSON: сумма, дата, контрагент.",
invoice_text,
force=Tier.CHEAP,
)
# Тип вызова неизвестен — пусть решает классификатор.
answer = run("Ты инженерный ассистент.", user_question)
print(answer.tier, answer.model, f"${answer.total_cost_usd:.6f}")
Учёт расхода по уровням обязателен: без него вы не узнаете, что классификатор сваливает 40% трафика в hard:
# metrics.py
from collections import defaultdict
spend = defaultdict(float)
calls = defaultdict(int)
def track(result) -> None:
spend[result.tier] += result.total_cost_usd
calls[result.tier] += 1
def report() -> str:
total = sum(spend.values())
lines = [f"total ${total:.4f}"]
for tier in spend:
share = spend[tier] / total * 100 if total else 0.0
avg = spend[tier] / calls[tier]
lines.append(
f"{tier.value:<6} n={calls[tier]:<6} "
f"${spend[tier]:.4f} ({share:.1f}%) avg=${avg:.6f}"
)
return "\n".join(lines)
Смоук по всем маршрутам перед выкаткой:
for m in deepseek-v4-flash claude-haiku-4-5 claude-sonnet-5 claude-opus-5; do
printf '%-20s ' "$m"
curl -sS https://go2llm.tech/v1/chat/completions \
-H "Authorization: Bearer $GO2LLM_API_KEY" \
-H "content-type: application/json" \
-d "{\"model\":\"$m\",\"max_tokens\":40,
\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: работает\"}]}" \
| jq -r '[.choices[0].message.content, (.usage.cost_usd|tostring)] | @tsv'
done
Сколько это стоит
Расчёт по опубликованному прайсу, каталог v38 от 2026-09-27, а не замер у клиента. Проверить: /pricing и GET /api/pricing.
Цены, $ за 1M токенов:
| Модель | in | out |
|---|---|---|
deepseek-v4-flash | 0.0226 | 0.0679 |
claude-haiku-4-5 | 0.13 | 0.65 |
claude-sonnet-5 | 0.329 | 1.64 |
claude-opus-5 | 0.503 | 2.51 |
Смешанный профиль, 100 000 запросов в месяц:
- классификация на каждом запросе: 500 in / 20 out,
deepseek-v4-flash; - 55 000 запросов (55%) —
cheap, 1 500 in / 500 out наclaude-haiku-4-5; - 35 000 запросов (35%) —
main, 3 000 in / 900 out наclaude-sonnet-5; - 10 000 запросов (10%) —
hard, 8 000 in / 2 000 out наclaude-opus-5.
Арифметика по строкам
Классификация, deepseek-v4-flash: 100 000 × 500 = 50M вход → 50 × 0.0226 = $1.13 100 000 × 20 = 2M выход → 2 × 0.0679 = $0.1358 итого $1.2658
Уровень cheap, claude-haiku-4-5: 55 000 × 1 500 = 82.5M вход → 82.5 × 0.13 = $10.725 55 000 × 500 = 27.5M выход → 27.5 × 0.65 = $17.875 итого $28.60
Уровень main, claude-sonnet-5: 35 000 × 3 000 = 105M вход → 105 × 0.329 = $34.545 35 000 × 900 = 31.5M выход → 31.5 × 1.64 = $51.66 итого $86.205
Уровень hard, claude-opus-5: 10 000 × 8 000 = 80M вход → 80 × 0.503 = $40.24 10 000 × 2 000 = 20M выход → 20 × 2.51 = $50.20 итого $90.44
Итого с роутингом: 1.2658 + 28.60 + 86.205 + 90.44 = $206.51
Сравнение с одной моделью на всё
Те же объёмы токенов, без классификатора, всё на claude-opus-5:
- cheap-объём:
82.5 × 0.503 = 41.4975+27.5 × 2.51 = 69.025= $110.52 - main-объём:
105 × 0.503 = 52.815+31.5 × 2.51 = 79.065= $131.88 - hard-объём: $90.44
- итого $332.84
Экономия: 332.84 − 206.51 = $126.33, то есть 126.33 / 332.84 = 38.0%, или в 332.84 / 206.51 = 1.61 раза дешевле.
Справочный рублёвый эквивалент по курсу ЦБ 86,3793 ₽ за $1 на 01.09.2026: $332.84 ≈ 28 751 ₽ против $206.51 ≈ 17 838 ₽, разница ≈ 10 913 ₽ в месяц. Баланс и списания ведутся в USD.
Честная контрольная точка: а если всё на Sonnet?
Тот же объём целиком на claude-sonnet-5:
- cheap-объём:
82.5 × 0.329 = 27.1425+27.5 × 1.64 = 45.10= $72.24 - main-объём: $86.205
- hard-объём:
80 × 0.329 = 26.32+20 × 1.64 = 32.80= $59.12 - итого $217.57
То есть роутинг стоит на 217.57 − 206.51 = $11.06 дешевле (−5.1%), чем «всё на Sonnet» — в этом каталоге Opus относительно официальной цены дешевле, чем Sonnet (скидка около 90% против 84%), и доплата за 10% трафика наверх, на Opus, не перекрывает экономию от увода 55% вниз, на Haiku. При другом наборе моделей или других пропорциях трафика соотношение может стать обратным — это не универсальный вывод, а точная картина при этих цифрах. Ценность роутинга не в ±5%, а в том, что вы платите за качество ровно там, где оно нужно, вместо равномерной ставки по всему трафику.
Нижняя граница для сравнения — всё на claude-haiku-4-5: 28.60 + (105 × 0.13 + 31.5 × 0.65) + (80 × 0.13 + 20 × 0.65) = 28.60 + 34.125 + 23.40 = $86.13. Дешевле в 2,5 раза, чем «всё на Sonnet», но hard-запросы обслуживаются моделью, выбранной не под них.
Сколько стоит сам роутер
Классификация — $1.2658 из $206.51, то есть 0.6% счёта. Тот же классификатор на claude-haiku-4-5 стоил бы 50 × 0.13 + 2 × 0.65 = 6.50 + 1.30 = $7.80, в 6,2 раза дороже. На glm-5.3-flash (0.018 / 0.06): 50 × 0.018 + 2 × 0.06 = $1.02.
Правило окупаемости: стоимость классификации должна быть заметно меньше разницы между уровнями. Если cheap и main различаются на копейки, классификатор — чистый убыток плюс лишняя задержка, и работают только бесплатные правила слоя 0.
Грабли
1. Классификатор, деградирующий вверх. Если при сбое парсинга JSON роутер падает в hard, стоимость ошибки максимальна. В коде выше fallback идёт в MAIN намеренно: неправильный main дешевле неправильного hard.
2. 404 model_not_found на одном из трёх маршрутов. Опечатка в редком маршруте вскроется не сразу, а на первом hard-запросе в проде. Проверяйте все маршруты на старте через GET /v1/models.
3. 403 model после сужения списка моделей на ключе. Добавляя четвёртый маршрут в код, не забудьте добавить модель в ключ. Ошибка выглядит как отказ авторизации, хотя ключ действующий.
4. 429 rate_limit из-за удвоения запросов. Роутер с LLM-классификатором делает два запроса вместо одного. При лимите 20 RPM по умолчанию эффективная пропускная способность падает вдвое. Слой правил, решающий часть случаев бесплатно, снимает и эту проблему, а не только денежную.
5. 402 insufficient_balance в середине пайплайна. Классификатор прошёл, основной вызов упал: пользователь остался без ответа, а деньги за классификацию потрачены. Следите за балансом заранее.
6. 502 upstream_unavailable только на одном уровне. Доступность зависит от внешних поставщиков. До начала ответа шлюз может использовать совместимый fallback-маршрут на разрешённых классах ошибок, но после первого байта или валидного SSE-события переключение не выполняется. Разумный прикладной fallback — деградировать hard в main при устойчивом 502, а не ретраить тот же маршрут четвёртый раз. Коды — /docs/errors.
Лимит 20 RPM считается на ключ, а не на модель. Он суммарный по всем моделям и по всем endpoints сразу, поэтому вызов классификатора и основной вызов расходуют один и тот же счётчик — роутер с LLM-классификатором действительно удваивает нагрузку на лимит. Слой правил, отвечающий на часть запросов без обращения к модели, снимает и это. Отдельно действует лимит параллельных запросов на аккаунт — по умолчанию 2.
Prompt caching работает на обоих протоколах, включая /v1/chat/completions, и тарифицируется: запись кэша идёт по цене in, чтение — по цене cache из прайса. Важная деталь: шлюз кэш не включает сам — он работает от того, что клиент положил в тело запроса. В расчёте выше кэш намеренно не учитывался, поэтому реальный счёт на повторяющихся системных промптах будет ниже приведённого.
Поддержку response_format: {"type":"json_object"} и structured outputs мы пока не документировали — напишите в @go2llm_support, ответим и дополним страницу. До тех пор закладывайте в роутер устойчивый ручной разбор ответа классификатора: требуйте одно слово из фиксированного списка и падайте в main на любом неожиданном выводе.
Итог
Роутинг по сложности — не «включить дешёвую модель», а решение о том, где именно вы готовы платить за качество. На разобранном профиле переход с «всё на Opus 5» на три уровня даёт $332.84 → $206.51, минус 38.0% при 100 000 запросов в месяц. Сам роутер стоит 0.6% счёта.
Технически это возможно потому, что один ключ gk_sk_… покрывает все опубликованные модели, а endpoint один и тот же: смена уровня — это смена строки model, а не второй провайдер и второй биллинг. Перед выкаткой прогоните смоук по всем маршрутам и ограничьте ключ списком моделей из ROUTES.
Документация — /docs, цены — /pricing, коды ошибок — /docs/errors, главная — go2llm.tech.