Дешёвая LLM оказалась дороже: считаем cost per successful task, а не цену токена

Низкая цена за миллион токенов ещё не означает экономию. Если модель чаще ошибается, запускает retries и требует ручной проверки, стоимость выполненной задачи растёт. Покажу, как это измерить и использовать в роутинге.

Дешёвая LLM оказалась дороже: считаем cost per successful task, а не цену токена

Почему цена токена вводит в заблуждение

В таблице провайдеров легко выбрать модель, которая дешевле за миллион входных и выходных токенов. Но пользователь покупает не токены. Ему нужен корректно выполненный запрос: извлечённые данные, классификация, ответ поддержки или завершённый шаг агента.

Если дешёвая модель чаще возвращает невалидный JSON, выбирает неверный инструмент или требует повторной генерации, экономия исчезает. Поэтому базовая метрика для production — стоимость успешной задачи, а не стоимость одного вызова.

Простой пример

Допустим, модель A стоит 0,20 рубля за средний вызов, а модель B — 0,35 рубля. На первый взгляд A дешевле почти вдвое.

Но из 100 задач модель A успешно завершает 70. Ещё 20 проходят после retry, а 10 уходят человеку. Модель B успешно закрывает 92 задачи с первой попытки, шесть — после retry, две — вручную.

Если ручная проверка стоит 50 рублей, итог может выглядеть так:

• модель A: 100 вызовов + 30 повторов + 10 ручных проверок;
• модель B: 100 вызовов + 8 повторов + 2 ручные проверки.

Более дорогая по токенам модель оказывается дешевле на уровне процесса.

Что включать в полную стоимость

Я считаю пять компонентов:

• стоимость input и output tokens;
• стоимость повторных LLM-вызовов;
• вызовы embeddings, reranker и внешних инструментов;
• инфраструктуру: CPU, очередь, хранение и observability;
• ручную проверку и исправление ошибок.

Для агента добавляется стоимость побочных эффектов. Неверно созданная сделка в CRM или отправленное письмо может быть дороже всех токенов за месяц.

Формула cost per successful task

Минимальный вариант:

cost_per_success = total_cost / successful_tasks

Но для сравнения моделей полезно считать отдельно прямые и операционные расходы:

CODE:
from dataclasses import dataclass

@dataclass
class ModelStats:
tasks: int
successful: int
llm_cost: float
tool_cost: float
infra_cost: float
manual_reviews: int
manual_review_cost: float

@property
def total_cost(self) -> float:
return (
self.llm_cost
+ self.tool_cost
+ self.infra_cost
+ self.manual_reviews * self.manual_review_cost
)

@property
def cost_per_success(self) -> float:
if self.successful == 0:
return float("inf")
return self.total_cost / self.successful

Успех нужно определить заранее

Самая сложная часть — не формула, а определение successful. HTTP 200 не означает, что задача решена. Валидный JSON тоже не гарантирует корректный результат.

Для каждого сценария нужен собственный критерий:

• extraction: обязательные поля заполнены и проходят бизнес-валидацию;
• support: ответ соответствует базе знаний и не требует эскалации;
• tool calling: выбран правильный инструмент и достигнуто ожидаемое состояние;
• code generation: тесты прошли, а изменение принято;
• RAG: ответ содержит подтверждённые источники и проходит groundedness check.

Одна общая метрика качества для всех маршрутов скрывает проблемы. Считать нужно по модели, маршруту, классу задачи и версии prompt.

Почему retries и repair-loop надо учитывать отдельно

Retry после timeout и repair после ошибки схемы — разные причины дополнительных расходов. Если объединить их в один счётчик, непонятно, что исправлять: сеть, provider или качество модели.

Я сохраняю для каждого запуска:

• initial model call;
• число retries и причина;
• число repair-вызовов;
• использованные tools;
• итоговый статус задачи;
• факт ручной проверки;
• полную стоимость цепочки.

Тогда можно увидеть, что модель дешёвая на первом вызове, но дорогая из-за repair rate.

Пример агрегации событий

CODE:
def aggregate_task(events):
result = {
"llm_cost": 0.0,
"tool_cost": 0.0,
"retries": 0,
"repairs": 0,
"manual_review": False,
"success": False,
}

for event in events:
if event["type"] == "llm_call":
result["llm_cost"] += event["cost"]
elif event["type"] == "tool_call":
result["tool_cost"] += event["cost"]
elif event["type"] == "retry":
result["retries"] += 1
elif event["type"] == "repair":
result["repairs"] += 1
elif event["type"] == "manual_review":
result["manual_review"] = True
elif event["type"] == "task_completed":
result["success"] = event["accepted"]

return result

Не сравнивайте модели на разных задачах

Если модель A получает простые запросы, а модель B — сложные, средний cost per success будет бесполезен. Нужен одинаковый набор задач или стратификация по сложности.

Практичный вариант — shadow evaluation. Новый кандидат получает копию production-запроса, но не выполняет внешние действия. Затем результаты обеих моделей проходят одинаковую автоматическую и выборочную ручную проверку.

Для tool calls необходимо блокировать побочные эффекты: использовать sandbox, mock-инструменты или режим dry-run.

Как учитывать latency

Самая дешёвая успешная задача может отвечать 30 секунд. Поэтому стоимость нельзя рассматривать отдельно от latency и SLA.

Я сравниваю модели по трём координатам:

• cost per successful task;
• success rate без ручного вмешательства;
• p95 end-to-end latency.

Модель проходит в production только тогда, когда укладывается во все три ограничения. Выигрыш в цене не компенсирует нарушение SLA.

Маршрутизация по сложности

Выбирать одну модель для всего трафика необязательно. Простые задачи можно отправлять дешёвой модели, сложные — более сильной.

Но router тоже ошибается и стоит денег. Его нужно включать в общий расчёт, а fallback считать частью стоимости задачи.

CODE:
async def run_with_fallback(task):
route = await classify_complexity(task)
primary = cheap_model if route == "simple" else strong_model

result = await primary(task)
verdict = validate(result)

if verdict.ok:
return result

if verdict.retryable:
return await strong_model(task)

raise TaskFailed(verdict.reason)

В метриках такой запуск должен быть одной задачей с двумя модельными вызовами, а не двумя независимыми запросами.

Не оптимизируйте только среднее

Средний cost per success может выглядеть хорошо, пока небольшая группа запросов создаёт огромные расходы. Полезно смотреть p50, p95 и p99 стоимости задачи.

Длинный prompt, циклический agent loop или несколько неудачных tool calls формируют тяжёлый хвост. Для него нужны ограничения:

• максимальный токен-бюджет;
• лимит шагов агента;
• максимум retries и repairs;
• отдельный бюджет на tools;
• kill switch при превышении стоимости.

Эксперимент без риска для бюджета

Безопасный rollout можно провести так:

1. Зафиксировать определение успеха для каждого маршрута.
2. Собрать baseline стоимости, качества и latency текущей модели.
3. Прогнать кандидата в shadow-режиме на том же трафике.
4. Включить его на 1–5% production-запросов.
5. Ограничить retries, ручные проверки и максимальную стоимость задачи.
6. Сравнить доверительные интервалы, а не один средний день.
7. Расширять трафик только при устойчивом выигрыше.

Какие метрики вывести на дашборд

• tasks и successful_tasks;
• first_pass_success_rate;
• retry_rate и repair_rate;
• manual_review_rate;
• cost_per_success p50/p95/p99;
• end-to-end latency p50/p95/p99;
• tool error rate;
• стоимость по модели, маршруту и версии prompt.

Итог

Цена токена удобна для прайс-листа, но плохо описывает production. Правильная единица экономики — завершённая и принятая задача с учётом повторов, инструментов, инфраструктуры и ручного труда.

Иногда более сильная и дорогая модель оказывается дешевле, потому что быстрее приходит к правильному результату. А иногда дешёвая модель действительно выигрывает — но это должно подтверждаться на одинаковых задачах и полной цепочке расходов.

Вы уже считаете cost per successful task или пока сравниваете модели по цене токенов и стоимости одного API-вызова?