Пользователь нажимает кнопку. Крутится спиннер. Появляется текст. Внутренний баланс пользователя уменьшается на 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';
Сценарий с холдированием работает так:
- При старте запроса бэкенд оценивает размер промпта локально (с помощью библиотеки токенизатора, например, tiktoken).
- Вычисляется максимальная теоретическая стоимость ответа (на основе параметра
max_tokens). - В леджер пишется транзакция со статусом
pending, замораживающая эту максимальную сумму. - Открывается поток к RouterAPI.
- Если поток успешно завершен и получен блок
usage, бэкенд обновляет транзакцию: меняет статус наcompletedи корректируетamountдо фактического значения (высвобождая неиспользованный остаток). - Если запрос падает с ошибкой провайдера, транзакция переводится в статус
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-вызов слеплен на скорую руку с простым апдейтом баланса. Построение устойчивого ИИ-продукта требует отношения к учету токенов со строгостью банковского приложения. Токены — это деньги. Как только архитектура начинает отражать этот факт, пользователи перестают задавать вопросы о пропавших кредитах.