Практика эксплуатации LLM

Как распределять стоимость LLM по бизнес-процессам, а не по API-ключам

Счёт провайдера отвечает на вопрос «сколько потрачено», но обычно не объясняет, какой сценарий, этап агента или повторный вызов создал расходы. Исправим это с помощью событийной модели, в которой каждый вызов LLM связан с бизнес-процессом и его результатом.

Уровень: средний Время чтения: до 8 минут Результат: модель атрибуции затрат по процессам, моделям, этапам и повторам

Почему API-ключ — плохая единица учёта

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

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

Практическая единица наблюдения — один вызов модели. Каждый вызов получает контекст процесса, сведения об использовании модели и причину, по которой он состоялся.

Что именно нужно измерять

Минимальная схема события должна позволять ответить на четыре вопроса:

  1. Какой бизнес-процесс и какой его экземпляр инициировали вызов?
  2. На каком этапе и какой моделью он был выполнен?
  3. Был ли это первый вызов, повтор или запасной маршрут?
  4. Сколько входных и выходных единиц тарификации использовано и сколько это стоило?
{
  "event_id": "evt_01",
  "occurred_at": "2026-07-29T09:15:00Z",
  "workflow": "support_request",
  "workflow_version": "3",
  "workflow_run_id": "run_example_42",
  "stage": "draft_answer",
  "attempt": 2,
  "call_reason": "retry_invalid_schema",
  "provider": "provider_name",
  "model": "model_name",
  "input_units": 1840,
  "output_units": 310,
  "cached_input_units": 0,
  "latency_ms": 920,
  "status": "success",
  "estimated_cost": "0.000000",
  "currency": "USD",
  "pricing_version": "2026-07-01"
}

Это демонстрационная запись, а не результат реального запроса. Названия процесса, модели и числовые значения приведены только для иллюстрации структуры.

Не помещайте в событие промпт, ответ модели, API-ключ, персональные данные или содержимое документа. Для учёта стоимости достаточно идентификаторов, счётчиков и технических признаков.

Шаг 1. Определите стабильную иерархию

Начните с четырёх уровней:

  • workflow — бизнес-процесс, например обработка обращения;
  • workflow_run_id — один запуск процесса;
  • stage — логический этап: классификация, поиск, черновик, проверка;
  • attempt — порядковый номер вызова внутри этапа.

Не используйте URL обработчика или имя функции как название процесса: техническая реализация меняется быстрее бизнес-смысла. Версию процесса храните отдельно, чтобы сравнивать старую и новую схемы без переименования всей истории.

workflow: support_request
workflow_version: 3

stages:
  - classify
  - retrieve_context
  - draft_answer
  - validate_answer

Этот YAML — пример соглашения об именовании. Его можно хранить рядом с конфигурацией приложения и проверять при сборке.

Шаг 2. Передавайте контекст через весь запуск

Контекст стоимости создаётся в точке входа и передаётся каждому этапу. Генерируйте внутренний идентификатор запуска; не подставляйте вместо него номер заявки или адрес пользователя.

from dataclasses import dataclass
from uuid import uuid4

@dataclass(frozen=True)
class CostContext:
    workflow: str
    workflow_version: str
    workflow_run_id: str

def new_cost_context() -> CostContext:
    return CostContext(
        workflow="support_request",
        workflow_version="3",
        workflow_run_id=f"run_{uuid4().hex}",
    )

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

Шаг 3. Оберните все вызовы модели одной функцией

Центральная обёртка исключает ситуацию, когда часть команды считает стоимость, а часть вызывает SDK напрямую. Обёртка должна фиксировать событие и при успехе, и при ошибке.

def call_llm(*, client, context, stage, attempt, reason, request):
    started = monotonic()
    status = "error"
    usage = None

    try:
        response = client.generate(**request)
        usage = response.usage
        status = "success"
        return response
    finally:
        emit_cost_event({
            "event_id": new_event_id(),
            "occurred_at": utc_now_iso(),
            "workflow": context.workflow,
            "workflow_version": context.workflow_version,
            "workflow_run_id": context.workflow_run_id,
            "stage": stage,
            "attempt": attempt,
            "call_reason": reason,
            "provider": request["provider"],
            "model": request["model"],
            "input_units": usage.input_units if usage else 0,
            "output_units": usage.output_units if usage else 0,
            "cached_input_units": usage.cached_input_units if usage else 0,
            "latency_ms": elapsed_ms(started),
            "status": status
        })

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

Шаг 4. Отделите использование от цены

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

{
  "pricing_version": "2026-07-01",
  "currency": "USD",
  "models": {
    "model_name": {
      "input_per_million": "0.00",
      "cached_input_per_million": "0.00",
      "output_per_million": "0.00"
    }
  }
}

Нули здесь намеренные: это безопасный шаблон, а не действующие цены. Заполните его значениями из вашего договора или панели провайдера и зафиксируйте дату начала действия.

from decimal import Decimal

MILLION = Decimal("1000000")

def calculate_cost(event, price):
    regular_input = max(
        event["input_units"] - event["cached_input_units"], 0
    )
    return (
        Decimal(regular_input) * price["input_per_million"]
        + Decimal(event["cached_input_units"])
          * price["cached_input_per_million"]
        + Decimal(event["output_units"]) * price["output_per_million"]
    ) / MILLION

Используйте десятичный тип, а не двоичное число с плавающей точкой. Храните сумму с достаточной точностью и округляйте только при выводе отчёта.

Шаг 5. Сделайте повторы видимыми

Повтор — не просто ещё один вызов. Его причина подсказывает, где искать оптимизацию. Задайте короткий закрытый справочник:

  • initial — штатный первый вызов;
  • retry_timeout — повтор после тайм-аута;
  • retry_rate_limit — повтор после ограничения частоты;
  • retry_invalid_schema — ответ не прошёл проверку структуры;
  • retry_quality_gate — ответ не прошёл проверку качества;
  • fallback_model — переход на запасную модель;
  • agent_loop — очередной шаг цикла агента.

Не складывайте все случаи в значение retry. Тайм-аут, ошибка схемы и лишний цикл агента требуют разных решений.

Шаг 6. Сформируйте отчёты, которые ведут к действию

Для начала достаточно таблицы событий и трёх представлений. Пример SQL использует условные имена полей и совместимый с PostgreSQL синтаксис.

Стоимость процесса и одного запуска

SELECT
  workflow,
  COUNT(DISTINCT workflow_run_id) AS runs,
  SUM(estimated_cost) AS total_cost,
  SUM(estimated_cost)
    / NULLIF(COUNT(DISTINCT workflow_run_id), 0) AS cost_per_run
FROM llm_cost_events
GROUP BY workflow
ORDER BY total_cost DESC;

Стоимость этапов и моделей

SELECT
  workflow,
  stage,
  model,
  COUNT(*) AS calls,
  SUM(input_units) AS input_units,
  SUM(output_units) AS output_units,
  SUM(estimated_cost) AS total_cost
FROM llm_cost_events
GROUP BY workflow, stage, model
ORDER BY total_cost DESC;

Цена повторных вызовов

SELECT
  workflow,
  stage,
  call_reason,
  COUNT(*) AS calls,
  SUM(estimated_cost) AS total_cost
FROM llm_cost_events
WHERE attempt > 1 OR call_reason <> 'initial'
GROUP BY workflow, stage, call_reason
ORDER BY total_cost DESC;

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

Проверка результата

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

  1. Убедитесь, что у всех событий одинаковый workflow_run_id.
  2. Проверьте, что число событий равно числу фактических обращений к клиенту.
  3. У повторного события должны быть attempt = 2 и точная причина.
  4. Сложите единицы использования вручную и сравните с агрегатом по запуску.
  5. Рассчитайте стоимость одного события вручную по зафиксированному тарифу.
  6. Сравните сумму событий с отчётом провайдера за тот же период и в той же валюте.
SELECT
  workflow_run_id,
  COUNT(*) AS calls,
  SUM(input_units) AS input_units,
  SUM(output_units) AS output_units,
  SUM(estimated_cost) AS estimated_cost
FROM llm_cost_events
WHERE workflow_run_id = 'run_example_42'
GROUP BY workflow_run_id;

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

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

Типовые ошибки

Считать стоимость только после успешного ответа

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

Перезаписывать тарифы

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

Смешивать оценку и выставленную сумму

estimated_cost — вычисленная стоимость события. Сумма счёта — отдельный финансовый факт. Храните их раздельно и регулярно выполняйте сверку.

Не учитывать кэш и пакетные режимы

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

Использовать свободный текст в названиях этапов

draft, generate_draft и answer_generation быстро превращаются в три строки одного этапа. Проверяйте значения по реестру процесса.

Оптимизировать без метрики результата

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

Ограничения модели

Такая атрибуция хорошо описывает прямые расходы на вызовы LLM, но не заменяет полный расчёт себестоимости. В неё не входят хранение данных, поиск, внешние инструменты, выполнение кода, наблюдаемость, разработка и ручная проверка.

Стоимость общего вызова, обслуживающего несколько процессов, нельзя честно распределить без дополнительного правила. Возможные базы распределения — число запросов, объём обработанных данных или фактическое использование результата. Правило следует документировать и применять последовательно.

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

Минимальный план внедрения

  1. Выберите один заметный бизнес-процесс и опишите его этапы.
  2. Добавьте контекст запуска и единую обёртку клиента LLM.
  3. Сохраняйте сырые единицы использования и причины повторов.
  4. Версионируйте тарифы и рассчитывайте стоимость отдельно.
  5. Постройте отчёты по процессу, этапу, модели и повторным вызовам.
  6. Сверьте агрегаты с отчётом провайдера и зафиксируйте причины расхождений.
  7. Свяжите стоимость запуска с результатом процесса.

После этого вопрос «какая модель дороже?» можно заменить более полезным: «какой этап какого процесса создаёт устранимые расходы и как изменение повлияет на результат?»