Как построить LLM-роутер и проверить экономию без потери качества

Зачем это нужно и в чём подвох
Типичная картина: все запросы, от «переведи слово» до «найди ошибку в миграции», уходят одной самой сильной модели. Это надёжно, но дорого. Очевидное решение — LLM-роутер: дешёвая модель берёт простые запросы, сложные отправляются сильной.
Подвох в том, как роутер решает, что дешёвого ответа достаточно. Самый популярный способ — спросить у модели её уверенность и выйти раньше, если она высокая. Этот ранний выход ненадёжен: модель может быть уверенной и ошибаться, и тогда роутер закрепляет неверный ответ, а сэкономленные деньги превращаются в незаметную деградацию качества. Поэтому в этой статье решение об эскалации принимает внешняя проверка: валидация схемы, обязательные поля, прогон тестов. Экономию мы признаём только после парного сравнения с базовой стратегией на заранее зафиксированном наборе запросов.
Все цены, пороги и примеры запросов ниже — шаблоны. Подставьте тарифы своего провайдера и собственные данные. Цифр «у нас получилось X% экономии» здесь нет намеренно: их нужно получить на своём наборе.
Архитектура
- Классификатор уровня. Дешёвые детерминированные правила (длина, ключевые слова) выбирают стартовый уровень:
cheapилиstrong. - Внешняя проверка во время работы. Это проверка, которая не знает эталонного ответа: парсится ли JSON, есть ли нужные ключи, проходят ли тесты. Самооценка модели в решение не входит.
- Эскалация. Если проверка не пройдена, запрос уходит на следующий уровень, но только если позволяет бюджет.
- Бюджет. Ограничены и стоимость одного запроса, и общая сумма. Когда лимит исчерпан, роутер возвращает явный статус. Тихой подмены на более слабую модель нет.
- Журнал затрат. Каждый вызов модели записывается отдельной строкой JSONL: модель, токены, стоимость, результат проверки, причина эскалации.
Главное различие: runtime-проверка работает в продакшене и не знает правильного ответа, а оценка (grade) использует эталон и существует только в эксперименте. Если эталон попадёт в runtime-проверку, результаты эксперимента окажутся завышенными.
Шаг 1. Окружение
Все команды создают файлы только в новой папке проекта и ничего не отправляют наружу:
mkdir llm-router && cd llm-router
python3 -m venv .venv
. .venv/bin/activate
mkdir -p cache runs data
Скрипты используют только стандартную библиотеку Python. SDK провайдера подключите сами в функции provider_call.
Шаг 2. Конфигурация
Файл router_config.json. Имена моделей и цены — заглушки, замените их актуальными значениями из прайса вашего провайдера.
{
"tiers": { "cheap": "MODEL_CHEAP", "strong": "MODEL_STRONG" },
"models": {
"MODEL_CHEAP": { "usd_per_mtok_in": 0.0, "usd_per_mtok_out": 0.0 },
"MODEL_STRONG": { "usd_per_mtok_in": 0.0, "usd_per_mtok_out": 0.0 }
},
"rules": {
"long_prompt_chars": 4000,
"strong_keywords": ["докажи", "миграц", "race condition", "архитектур"]
},
"max_output_tokens": 1024,
"budget": { "total_usd": 5.0, "per_request_usd": 0.05 },
"log_path": "runs/cost_log.jsonl"
}
Пока цены нулевые, бюджет не сработает, и это заметно сразу. Так и задумано: заглушку нельзя случайно принять за реальные данные.
Шаг 3. Роутер
Файл router.py:
"""LLM-роутер: выбор уровня, внешняя проверка, эскалация, бюджет, журнал затрат."""
import hashlib
import json
import time
import uuid
from pathlib import Path
CONFIG = json.loads(Path("router_config.json").read_text(encoding="utf-8"))
CACHE_DIR = Path("cache")
class BudgetExceeded(Exception):
pass
class Budget:
def __init__(self, total_usd, per_request_usd):
self.total = total_usd
self.per_request = per_request_usd
self.spent = 0.0
def reserve(self, worst_case):
if self.spent + worst_case > self.total:
raise BudgetExceeded("total budget")
def charge(self, cost):
self.spent += cost
def estimate_cost(model, tokens_in, tokens_out):
p = CONFIG["models"][model]
return tokens_in / 1e6 * p["usd_per_mtok_in"] + tokens_out / 1e6 * p["usd_per_mtok_out"]
def approx_tokens(text):
# Грубая оценка только для резерва бюджета; фактические токены берём из ответа провайдера.
return len(text) // 3 + 1
def provider_call(model, prompt, max_tokens):
"""Верните (text, tokens_in, tokens_out) из usage вашего провайдера."""
raise NotImplementedError("подключите SDK провайдера")
def call_model(model, prompt, max_tokens):
key = hashlib.sha256(f"{model}\n{max_tokens}\n{prompt}".encode()).hexdigest()
path = CACHE_DIR / f"{key}.json"
if path.exists():
return tuple(json.loads(path.read_text(encoding="utf-8")))
result = provider_call(model, prompt, max_tokens)
path.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
return result
def pick_tier(prompt):
rules = CONFIG["rules"]
if len(prompt) > rules["long_prompt_chars"]:
return "strong"
if any(k in prompt.lower() for k in rules["strong_keywords"]):
return "strong"
return "cheap"
def runtime_check(query, answer):
"""Проверка без эталона: только то, что доступно в продакшене."""
check = query.get("runtime_check", {"type": "none"})
if check["type"] == "json_keys":
try:
data = json.loads(answer)
except json.JSONDecodeError:
return False, "invalid_json"
missing = [k for k in check["keys"] if k not in data]
return (not missing), ("missing:" + ",".join(missing) if missing else "ok")
if check["type"] == "non_empty":
return bool(answer.strip()), "ok" if answer.strip() else "empty"
# Нечего проверить: эскалировать нечем, честно помечаем.
return True, "unchecked"
def log_event(record):
with open(CONFIG["log_path"], "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def route(query, budget, force_tier=None, strategy="router"):
request_id = str(uuid.uuid4())
start = force_tier or pick_tier(query["prompt"])
chain = ["cheap", "strong"] if start == "cheap" else ["strong"]
spent, answer, final_model, status = 0.0, None, None, "ok"
for step, tier in enumerate(chain):
model = CONFIG["tiers"][tier]
worst = estimate_cost(model, approx_tokens(query["prompt"]), CONFIG["max_output_tokens"])
if spent + worst > budget.per_request:
status = "per_request_cap"
break
try:
budget.reserve(worst)
except BudgetExceeded:
status = "total_budget"
break
text, tin, tout = call_model(model, query["prompt"], CONFIG["max_output_tokens"])
cost = estimate_cost(model, tin, tout)
budget.charge(cost)
spent += cost
ok, reason = runtime_check(query, text)
answer, final_model = text, model
log_event({
"ts": time.time(), "request_id": request_id, "query_id": query["id"],
"strategy": strategy, "step": step, "tier": tier, "model": model,
"tokens_in": tin, "tokens_out": tout, "cost_usd": round(cost, 8),
"check_ok": ok, "check_reason": reason,
"escalate": (not ok) and step < len(chain) - 1,
})
if ok:
break
status = "check_failed"
return {"answer": answer, "model": final_model, "cost_usd": spent,
"status": status, "escalated": final_model == CONFIG["tiers"]["strong"] and start == "cheap"}
Что важно в этом коде:
- Бюджет резервируется по худшему случаю до вызова, а списывается по фактическим токенам после. Поэтому лимит не превышается даже на длинном ответе.
- Если лимит исчерпан, вызывающий код получает статус
per_request_capилиtotal_budget. Что делать дальше (поставить в очередь, отказать, позвать человека), решает он, а не роутер молча. - Кэш по хэшу (модель, лимит, промпт) делает повторные прогоны бесплатными и детерминированными. Обе стратегии получают одинаковые ответы сильной модели на одинаковые запросы.
- Статус
uncheckedпоказывает, где внешней проверки нет. Такие ответы дешёвой модели проходят без контроля, и их долю нужно видеть в отчёте.
Шаг 4. Фиксированный набор запросов
Файл data/test.jsonl, по одной записи на строку. Ниже две записи-примера, а не реальный датасет:
{"id": "q001", "prompt": "Верни JSON с ключами city и country для: Казань", "runtime_check": {"type": "json_keys", "keys": ["city", "country"]}, "grade": {"type": "json_equals", "value": {"city": "Казань", "country": "Россия"}}}
{"id": "q002", "prompt": "Найди race condition в этом коде: ...", "runtime_check": {"type": "non_empty"}, "grade": {"type": "contains_all", "values": ["lock"]}}
Правила формирования набора:
- Берите запросы из реального трафика в тех пропорциях, в которых они приходят, а не только «интересные».
- Разделите данные на
data/dev.jsonl(на нём подбираете ключевые слова и пороги) иdata/test.jsonl(запускаете один раз, когда правила заморожены). - Зафиксируйте
temperatureи версии моделей. Если провайдер обновил модель, старый кэш для сравнения не годится.
Шаг 5. Прогон обеих стратегий
Файл evaluate.py. Здесь грейдер видит эталон, а роутер нет:
import argparse
import json
from pathlib import Path
from router import CONFIG, Budget, route
def grade(query, answer):
if answer is None:
return False
g = query["grade"]
if g["type"] == "json_equals":
try:
return json.loads(answer) == g["value"]
except json.JSONDecodeError:
return False
if g["type"] == "contains_all":
return all(s.lower() in answer.lower() for s in g["values"])
raise ValueError(f"unknown grade type: {g['type']}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--strategy", choices=["baseline", "router"], required=True)
ap.add_argument("--out", required=True)
args = ap.parse_args()
b = CONFIG["budget"]
budget = Budget(b["total_usd"], b["per_request_usd"])
force = "strong" if args.strategy == "baseline" else None
with open(args.out, "w", encoding="utf-8") as out:
for line in Path(args.data).read_text(encoding="utf-8").splitlines():
q = json.loads(line)
r = route(q, budget, force_tier=force, strategy=args.strategy)
out.write(json.dumps({
"id": q["id"], "passed": grade(q, r["answer"]),
"cost_usd": r["cost_usd"], "model": r["model"],
"status": r["status"], "escalated": r["escalated"],
}, ensure_ascii=False) + "\n")
if __name__ == "__main__":
main()
Запуск. Сначала идёт baseline, чтобы ответы сильной модели попали в кэш:
python3 evaluate.py --data data/test.jsonl --strategy baseline --out runs/baseline.jsonl
python3 evaluate.py --data data/test.jsonl --strategy router --out runs/router.jsonl
Учтите одну тонкость. Из-за общего кэша второй прогон не платит за повторные вызовы сильной модели, но в cost_usd стоимость всё равно записывается по токенам. Сравнение остаётся честным, а реальный счёт на эксперимент меньше.
Шаг 6. Парное сравнение и решение
Порог допустимых регрессий задаётся до того, как вы увидите результаты. Файл compare.py:
import json
import sys
def load(path):
with open(path, encoding="utf-8") as f:
return {r["id"]: r for r in map(json.loads, f)}
base, rout = load(sys.argv[1]), load(sys.argv[2])
max_regressions = int(sys.argv[3])
assert base.keys() == rout.keys(), "наборы запросов различаются"
both = sum(base[i]["passed"] and rout[i]["passed"] for i in base)
regressed = [i for i in base if base[i]["passed"] and not rout[i]["passed"]]
improved = [i for i in base if not base[i]["passed"] and rout[i]["passed"]]
cost_b = sum(r["cost_usd"] for r in base.values())
cost_r = sum(r["cost_usd"] for r in rout.values())
escalated = sum(r["escalated"] for r in rout.values())
capped = [i for i, r in rout.items() if r["status"] in ("per_request_cap", "total_budget")]
print(f"запросов: {len(base)}, оба прошли: {both}")
print(f"регрессии (baseline да, router нет): {len(regressed)} {regressed}")
print(f"улучшения (baseline нет, router да): {len(improved)} {improved}")
print(f"стоимость baseline: {cost_b:.4f} USD, router: {cost_r:.4f} USD")
if cost_b > 0:
print(f"экономия: {100 * (1 - cost_r / cost_b):.1f}%")
print(f"эскалаций: {escalated}, упёрлись в бюджет: {capped}")
ok = len(regressed) <= max_regressions and not capped
print("РЕШЕНИЕ:", "принять" if ok else "не принимать")
python3 compare.py runs/baseline.jsonl runs/router.jsonl 0
Смотрите на расходящиеся пары, а не только на итоговый процент прохождения. Одинаковый общий процент может скрывать пять регрессий и пять случайных улучшений. Каждую регрессию из списка откройте вручную: обычно по ней видно, какой проверки не хватило в runtime_check.
Проверка результата
Роутер готов, когда выполнены все пункты:
compare.pyвыдаёт «принять» наdata/test.jsonlпри пороге, записанном заранее.- В журнале у каждого
request_idесть хотя бы одна строка и суммаcost_usdсовпадает с итогом вruns/router.jsonl:python3 -c "import json; print(sum(json.loads(l)['cost_usd'] for l in open('runs/cost_log.jsonl') if json.loads(l)['strategy']=='router'))" - Доля
check_reason == "unchecked"у ответов дешёвой модели известна и устраивает вас:grep '"strategy": "router"' runs/cost_log.jsonl | grep -c '"check_reason": "unchecked"' - Тест бюджета: временно задайте
per_request_usdменьше стоимости одного вызова и убедитесь, что запросы получают статусper_request_cap, а не ответ модели.
Перед повторным полным прогоном удалите старый журнал (rm runs/cost_log.jsonl), иначе суммы задвоятся.
Типовые ошибки
- Ранний выход по самооценке. «Уверенность 0.9» — это текст, который сгенерировала та же модель, а не независимая проверка. Если другой проверки нет, лучше честно пометить ответ как
unchecked, чем делать вид, что он проверен. - Утечка эталона в runtime-проверку. Если
runtime_checkсравнивает ответ с эталоном, роутер в эксперименте эскалирует ровно тогда, когда нужно. В продакшене такого не будет. - Подбор правил на тестовом наборе. Ключевые слова, подогнанные под тест, дают красивый отчёт и ничего не говорят о реальном трафике.
- Учёт только успешных вызовов. Неудачный дешёвый вызов перед эскалацией тоже стоит денег. Журнал пишет каждый шаг, и сумма считается по всем шагам.
- Тихая деградация при исчерпании бюджета. Если при нехватке денег роутер незаметно переключается на слабую модель, качество падает и в метриках это не видно.
- Сравнение разных версий моделей. Если baseline и router прогоняли в разные дни без кэша, вы сравниваете в том числе обновления провайдера.
Ограничения
- Ноль регрессий на небольшом наборе не доказывает отсутствие деградации. Он лишь означает, что на этих запросах её не нашли. Чем меньше набор, тем осторожнее вывод.
- Правила по длине и ключевым словам грубые. Обученный классификатор может выбирать уровень точнее, но его тоже нужно проверять тем же парным сравнением.
- Роутер полезен ровно настолько, насколько хороши внешние проверки. Для свободного текста без структуры их часто нет, и такие запросы безопаснее сразу отправлять сильной модели.
- Задержка не измеряется. Эскалация добавляет второй вызов. Если важен p95, добавьте в журнал время ответа и сравните его отдельно.
- Цены у провайдеров меняются. Держите их в конфиге и пересчитывайте отчёт по журналу токенов, а не по сохранённым суммам.
Что дальше
Похожие практические разборы собраны в руководствах, определения терминов — в глоссарии. Следующий логичный шаг — подключить журнал затрат к ежедневному отчёту и раз в неделю повторять парное сравнение на свежей выборке трафика.