Мониторинг "сожженных" токенов: Прозрачность расходов для вашего юзера

15.06.2026 21:00

Пользователь нажимает кнопку. Крутится спиннер. Появляется текст. Внутренний баланс пользователя уменьшается на 42 кредита. На следующий день он пишет в поддержку: «Я купил 1000 кредитов, сделал пять запросов, и половины баланса нет. Ваша система сломана, или вы списываете лишнее».

Это классический конфликт биллинга в ИИ-продуктах. Продавая ИИ-функции, разработчики прячут сложность LLM за простым интерфейсом. Но скрывая сложность, они скрывают и механику ценообразования. Пользователь не знает, что его история чата из десяти сообщений, приклеенная к массивному системному промпту, сжигает 8000 токенов контекста за один запрос. Он видит только, как тает его кошелек.

Доверие исчезает. В SaaS доверие — единственная валюта, которая важнее ARR. Чтобы устранить конфликт, нужно разрушить «черный ящик». Вы обязаны показать пользователю, за что конкретно он платит, вплоть до последнего токена.

Архитектура честности

С чего начинается прозрачность? С ответа API. Любой крупный LLM-провайдер, и в частности роутинговый слой вроде RouterAPI, возвращает блок usage вместе со сгенерированным текстом.

Этот блок обычно содержит:

  • prompt_tokens: Объем прочитанного моделью (системный промпт + история пользователя + текущий запрос).
  • completion_tokens: Объем сгенерированного ответа.
  • total_tokens: Сумма этих значений.

Задача бэкенда — перехватить эти данные, конвертировать их во внутреннюю валюту приложения (кредиты, монеты, центы), списать их с баланса пользователя и вывести чек в UI. Всё это должно происходить в реальном времени.

Нельзя просто держать целочисленное поле credits в таблице users и делать декремент. Такой подход порождает состояние гонки (race conditions) при параллельных запросах и не оставляет аудиторского следа. Когда пользователь спрашивает «куда ушли мои деньги?», вам нечего ему показать.

Вам нужен неизменяемый реестр транзакций — леджер.

CREATE TABLE users (
 id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 email VARCHAR(255) NOT NULL,
 current_balance DECIMAL(15, 6) DEFAULT 0.000000
);

CREATE TABLE credit_ledger (
 id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 user_id BIGINT UNSIGNED NOT NULL,
 transaction_type ENUM('deposit', 'withdrawal') NOT NULL,
 amount DECIMAL(15, 6) NOT NULL,
 balance_after DECIMAL(15, 6) NOT NULL,
 reference_type VARCHAR(50) NOT NULL, -- например, 'chat_completion'
 reference_id VARCHAR(255) NOT NULL, -- ID запроса из RouterAPI
 meta JSON, -- Сырой блок usage хранится здесь
 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
 INDEX (user_id)
);

Поле current_balance в таблице users работает как кешированная проекция леджера. Сам леджер неизменяем (append-only). При завершении генерации вы вставляете запись о списании и обновляете кеш в рамках единой транзакции базы данных. Колонка meta в формате JSON критически важна — она сохраняет точную разбивку по токенам. Это ваша форензика для разрешения споров.

Перехват данных (Имплементация)

Рассмотрим логику бэкенда. Приложение связывается с RouterAPI. Вы отправляете пейлоад и ждете ответа. Для точности биллинга необходим ответный блок usage.

Ниже приведен пример на PHP (в стиле Laravel), демонстрирующий обработку ответа и безопасное списание средств:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\User;
use App\Models\CreditLedger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

class GenerationService
{
 // Стоимость 1 токена во внутренней валюте
 private const PROMPT_MULTIPLIER = 0.001;
 private const COMPLETION_MULTIPLIER = 0.003;

 public function generateResponse(User $user, array $messages): string
 {
 if ($user->current_balance <= 0) {
 throw new \Exception('Insufficient balance.');
 }

 $response = Http::withToken(config('services.routerapi.key'))
 ->post('https://api.routerapi.net/v1/chat/completions', [
 'model' => 'anthropic/claude-3-opus',
 'messages' => $messages,
 ]);

 if (! $response->successful) {
 throw new \Exception('API request failed.');
 }

 $data = $response->json;
 $content = $data['choices'][0]['message']['content'];
 $usage = $data['usage'];

 $promptCost = $usage['prompt_tokens'] * self::PROMPT_MULTIPLIER;
 $completionCost = $usage['completion_tokens'] * self::COMPLETION_MULTIPLIER;
 $totalCost = $promptCost + $completionCost;

 DB::transaction(function use ($user, $totalCost, $usage, $data) {
 // Блокировка строки пользователя предотвращает двойные траты
 $lockedUser = User::where('id', $user->id)->lockForUpdate->first;
 
 $newBalance = $lockedUser->current_balance - $totalCost;

 CreditLedger::create([
 'user_id' => $lockedUser->id,
 'transaction_type' => 'withdrawal',
 'amount' => $totalCost,
 'balance_after' => $newBalance,
 'reference_type' => 'chat_completion',
 'reference_id' => $data['id'],
 'meta' => json_encode([
 'model' => $data['model'],
 'prompt_tokens' => $usage['prompt_tokens'],
 'completion_tokens' => $usage['completion_tokens'],
 'prompt_cost' => $promptCost,
 'completion_cost' => $completionCost
 ])
 ]);

 $lockedUser->update(['current_balance' => $newBalance]);
 });

 return $content;
 }
}

Этот код — ядро прозрачности. Метод lockForUpdate захватывает пессимистичную блокировку базы данных. Без нее возникает уязвимость «двойной траты» (double-spend): если пользователь одновременно отправляет три тяжелых запроса, все три процесса прочитают один и тот же стартовый баланс, уйдут в минус и запишут некорректный остаток. Леджер сохраняет доказательства: какая модель использовалась, сколько токенов прочитано, сколько сгенерировано.

Обработка сбоев и возвраты (Refunds)

Банковская архитектура подразумевает не только списания, но и корректную обработку ошибок. Что произойдет, если RouterAPI вернет HTTP 500 на середине генерации? Или соединение оборвется до получения финального чанка с usage?

Если бэкенд не получил блок usage, точный биллинг невозможен. В классической реализации разработчики часто применяют пре-авторизацию: сначала замораживают определенную сумму кредитов («hold»), а после успешного завершения запроса списывают фактическую стоимость.

-- Добавляем статус транзакции
ALTER TABLE credit_ledger ADD COLUMN status ENUM('pending', 'completed', 'failed', 'refunded') DEFAULT 'completed';

Сценарий с холдированием работает так:

  1. При старте запроса бэкенд оценивает размер промпта локально (с помощью библиотеки токенизатора, например, tiktoken).
  2. Вычисляется максимальная теоретическая стоимость ответа (на основе параметра max_tokens).
  3. В леджер пишется транзакция со статусом pending, замораживающая эту максимальную сумму.
  4. Открывается поток к RouterAPI.
  5. Если поток успешно завершен и получен блок usage, бэкенд обновляет транзакцию: меняет статус на completed и корректирует amount до фактического значения (высвобождая неиспользованный остаток).
  6. Если запрос падает с ошибкой провайдера, транзакция переводится в статус refunded, и замороженные кредиты возвращаются на current_balance.

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

Вывод данных в клиентский UI

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

Как только ИИ заканчивает печать ответа, UI должен отрисовать небольшой чек прямо под сообщением. Для этого бэкенд возвращает метаданные вместе с текстом:

{
 "content": "Вот краткая выжимка вашего документа..",
 "receipt": {
 "tokens": {
 "prompt": 4500,
 "completion": 350
 },
 "cost_credits": 1.5,
 "remaining_balance": 148.5
 }
}

В React или Vue компоненте этот блок выглядит лаконично: «Контекст: 4500 токенов | Генерация: 350 токенов | Стоимость: 1.5 кредита».

Если пользователь наводит курсор или кликает на этот чек, система разворачивает детали. Она объясняет, что массивный контекст в 4500 токенов образовался из-за десяти предыдущих сообщений в истории чата. Здесь же появляется кнопка «Очистить историю» или «Начать новый чат».

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

Специфика потоковой передачи (Streaming)

Приведенный выше код описывает синхронный запрос. Большинство современных ИИ-интерфейсов используют потоковую передачу данных (Server-Sent Events) для снижения задержки первого байта (TTFB). Как считать токены, когда ответ приходит по кускам?

RouterAPI поддерживает спецификацию OpenAI, позволяющую отправлять блок usage в последнем чанке потока. Если при формировании запроса добавить флаг stream_options: {"include_usage": true}, финальный фрагмент данных выглядит так:

{"id":"chatcmpl-123","choices":[],"created":1700000000,"model":"claude-3-opus","system_fingerprint":"fp_44709d","object":"chat.completion.chunk","usage":{"prompt_tokens":4500,"completion_tokens":350,"total_tokens":4850}}

Задача бэкенда при проксировании SSE-потока — транслировать текстовые чанки клиенту, параллельно перехватывая чанк с usage. Поймав этот финальный кусок, бэкенд выполняет транзакцию записи в леджер. Только после фиксации списания бэкенд отправляет клиенту кастомное событие event: receipt с деталями биллинга и закрывает соединение.

Системный результат

Строгий леджер-мониторинг токенов дает внутреннюю наблюдаемость. База данных позволяет анализировать маржинальность моделей. Агрегация поля meta->prompt_tokens выявляет раздутые системные промпты. Сегментация транзакций показывает «опытных» пользователей, экономящих контекст, и тех, кто сжигает лимиты впустую.

Если архитектура требует переключения моделей под капотом (например, фоновый фолбэк с Claude 3.5 Sonnet на GPT-4o-mini), внутренний леджер изолирует пользовательский биллинг от тарифов внешних провайдеров. Пользователь платит по фиксированной внутренней ставке, а продукт рассчитывается с RouterAPI по фактическим расходам.

Непрозрачность биллинга редко возникает из злого умысла. Чаще это результат ленивой архитектуры, где API-вызов слеплен на скорую руку с простым апдейтом баланса. Построение устойчивого ИИ-продукта требует отношения к учету токенов со строгостью банковского приложения. Токены — это деньги. Как только архитектура начинает отражать этот факт, пользователи перестают задавать вопросы о пропавших кредитах.

Теги

Ещё по теме