Почему скрытые токены рассуждения ломают расчёт цены?
Скрытые токены рассуждения: как разнести input, output, кэш и reasoning, не задвоить стоимость и правильно учитывать лимиты API.

Расходы на LLM чаще всего ломаются не из-за неверной цены за миллион токенов. Их ломает неверная модель учёта. Команда берёт prompt_tokens, прибавляет completion_tokens, умножает на тариф и получает красивую, но иногда вымышленную цифру. Затем появляется reasoning, prompt caching, streaming, инструментальный контекст или маршрутизация между провайдерами. Старый калькулятор продолжает считать, хотя его предпосылки уже не верны.
Скрытые токены рассуждения не являются отдельной мистической комиссией. Обычно это часть работы модели, которая тарифицируется как вывод, но по-разному показывается в API. Опасность в другом: одинаково названные поля у разных провайдеров могут иметь разный смысл, а поля с разными названиями могут описывать одну и ту же категорию. Если не зафиксировать семантику до написания аналитики, вы либо удвоите расход, либо спрячете его в «прочее».
Usage в ответе не равно вашей финансовой модели
Объект usage описывает измерение, которое решил отдать конкретный API. Он не заменяет внутреннюю модель биллинга. Сначала нужно ответить на три отдельных вопроса: сколько токенов модель обработала, сколько из них подлежит оплате по каждой ставке и какие из них съели ограничение запроса или лимит пропускной способности.
Эти вопросы часто дают разные числа. Контекст, прочитанный из кэша, может быть дешевле обычного входа, но всё ещё занимать место в окне контекста. Внутреннее рассуждение может входить в оплачиваемый output, хотя пользователь видит один короткий абзац. Токены при ответе из кэша на уровне шлюза могут вообще не доходить до провайдера, и тогда usage способен оказаться нулевым при успешном HTTP-ответе.
Поэтому не называйте поле total_tokens «стоимостью в токенах». Это только итог, определённый схемой данного ответа. Иногда он проверяет арифметику, иногда отражает всё обработанное содержимое, иногда не даёт достаточной детализации, чтобы восстановить счёт.
Минимальная модель наблюдаемости должна хранить как минимум два слоя:
- сырой ответ
usageбез изменений; - нормализированную запись, где каждое число имеет бизнес-смысл;
- применённую таблицу тарифов с версией и валютой;
- идентификатор запроса, провайдера, модели и фактического маршрута.
Сырые данные нужны не из любви к логам. Через две недели кто-то спросит, почему расходы на один сценарий выросли, а вы не сможете ответить, если оставили только агрегат по дням. Нормализированная запись нужна потому, что отчёт не должен знать все капризы каждого SDK.
Четыре корзины, которые нельзя смешивать
Практичный расчёт начинается с четырёх корзин: обычный вход, чтение из кэша, запись в кэш и выход. Внутреннее рассуждение не должно быть пятой оплачиваемой корзиной по умолчанию. Чаще это аналитическая часть выхода, уже включённая в него.
Обозначим их так:
I = обычные входные токены
R = токены, прочитанные из кэша
W = токены, записанные в кэш
O = все оплачиваемые выходные токены
T = reasoning или thinking внутри O, если провайдер его раскрыл
Для цены используйте формулу:
cost = I * p_input + R * p_cache_read + W * p_cache_write + O * p_output + fixed_fees
fixed_fees оставьте в схеме, даже если сейчас он равен нулю. Некоторые режимы и продукты считают запросы, изображения, вызовы поиска, хранение кэша или отдельные операции, а не только токены. Если ваша таблица физически не умеет принять такую строку, разработчики начнут прятать расход в цене токена, и аудит превратится в гадание.
В этой модели T не добавляется в стоимость второй раз. Он отвечает на другой вопрос: какая доля выходного бюджета ушла на мышление, а какая на текст, структурированный ответ и вызовы инструментов. Проверка выглядит так:
0 <= T <= O
visible_output_approx = O - T
Слово approx здесь намеренное. Видимый текст нельзя надёжно пересчитать простым токенайзером на клиенте. Провайдер может суммаризировать reasoning, скрыть его, добавить служебные фрагменты или считать мультимодальные части иначе. Для денег авторитетен O из ответа или биллинговой выгрузки, а не число токенов в строке message.content.
Есть и важное исключение. Некоторые API выдают только общий вход, где уже сидят кэшированные части, а другие отделяют input_tokens от cache read и cache write. В первом случае нельзя честно вывести I, R и W, если ответ не даёт разбиения. Сохраняйте input_total_reported, ставьте детализацию как unavailable и не сочиняйте пропорции.
Reasoning показывает состав выхода, а не новый расход
Самая частая ошибка выглядит так: инженер видит completion_tokens: 1200 и reasoning_tokens: 900, затем записывает 2100 выходных токенов. Это двойной учёт, если документация API говорит, что reasoning уже входит в completion.
У OpenAI детализация completion_tokens_details.reasoning_tokens относится к выходу, а completion_tokens остаётся общим счётчиком генерации. В документации Anthropic для extended thinking сказано ещё прямее: output_tokens является итоговым числом для тарификации, а output_tokens_details.thinking_tokens показывает, сколько из этого оплачиваемого выхода ушло на внутреннее мышление. Google Gemini публикует thoughtsTokenCount в usageMetadata рядом с входом, кэшем, токенами кандидата и итогом. Эти схемы похожи внешне, но не дают права применять одну формулу без проверки документации конкретной модели и endpoint.
Правило простое: каждое детализирующее поле сначала помечайте как subset, additive или unknown.
subsetуже включено в родительский счётчик и служит для анализа;additiveнадо прибавить к базовому счётчику, потому что API исключил его из базы;unknownнельзя использовать для денежной формулы без сверки с документацией и счётом.
Это решение должно лежать в конфигурации адаптера, а не в голове автора дашборда. Пример записи для каталога схем:
{
"provider": "example-provider",
"endpoint": "responses",
"model_pattern": "*",
"usage_rules": {
"output_total": "usage.completion_tokens",
"reasoning": "usage.completion_tokens_details.reasoning_tokens",
"reasoning_relation": "subset"
}
}
Не используйте reasoning_relation: "subset" для всех интеграций только потому, что так устроен один популярный API. Версия модели, нативный endpoint и прокси могут менять форму ответа. Нормализатор обязан знать, откуда пришёл объект, а не угадывать по имени поля.
Отдельно следите за разрывом между видимым и оплачиваемым. Anthropic прямо предупреждает, что при суммаризированном или скрытом thinking видимые токены не совпадут с исходным reasoning, за который идёт оплата. Это нормальное поведение. Ошибка начинается, когда продуктовая команда обещает «короткие ответы за фиксированную цену», измеряя только символы, которые увидел пользователь.
Кэширование меняет цену входа, но не отменяет контекст
Prompt caching и response caching решают разные задачи, а их названия слишком похожи. Их нельзя сводить в один столбец «кэш».
Prompt caching происходит у провайдера во время обработки контекста. Модель снова получает длинный общий префикс, но часть вычислений берёт из кэша. Поэтому usage может содержать cache read, cache write или оба значения. Cache write означает, что текущий запрос создал или обновил кэшируемый префикс. Cache read означает, что запрос использовал ранее созданную часть. Первый вызов серии может стоить больше обычного входа, а последующие дешевле. Если смотреть только на среднюю цену одного запроса, вы не заметите цену прогрева.
Response caching возвращает готовый результат для идентичного запроса до обращения к модели. В документации OpenRouter указано, что при cache hit биллинговые счётчики токенов обнуляются, потому что вызов не дошёл до провайдера. Это не prompt cache hit и не доказательство, что модель обработала ноль токенов в исходном запросе. Это другой класс события: ответ обслужил слой выше модели.
Храните их раздельно:
{
"cache": {
"provider_prompt_read_tokens": 18400,
"provider_prompt_write_tokens": 0,
"gateway_response_cache": "miss"
}
}
На следующем идентичном запросе вы можете увидеть:
{
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
},
"cache": {
"gateway_response_cache": "hit"
}
}
Не пытайтесь сравнить эти две записи как будто первая «хуже» второй по качеству модели. Во второй модель вообще не работала. Для продуктовой аналитики это успешный ответ. Для оценки качества модели, свежести данных и нагрузки на провайдера это событие другого типа.
Кэш также не гарантирует экономию. Он требует повторяемого префикса, подходящего срока жизни и правильного расположения стабильной части запроса. Команда часто держит длинную инструкцию после динамического сообщения пользователя, затем удивляется нулевому cache read. Длинный неизменный system prompt, описания инструментов и справочные документы должны идти до изменяемых данных, если API кэширует префиксы.
Нормализатор должен сохранять неизвестное, а не подменять его нулём
Когда один шлюз даёт prompt_tokens, другой даёт input_tokens, а третий отправляет usageMetadata, соблазнительно сделать десяток выражений с || 0. Так рождаются отчёты, где отсутствующая детализация выглядит как отсутствие расхода.
Ноль и неизвестность имеют разный смысл. Ноль означает, что источник подтвердил отсутствие токенов категории. Неизвестность означает, что источник не передал число, шлюз его отбросил или адаптер ещё не умеет его прочитать. В финансовой системе это три разных состояния: 0, null и ошибка разбора.
Ниже пример TypeScript-подобного нормализатора. Он не пытается угадать всё, зато оставляет след, по которому можно проверить новый формат ответа.
type MaybeNumber = number | null;
type UsageLedger = {
input_uncached: MaybeNumber;
cache_read: MaybeNumber;
cache_write: MaybeNumber;
output_total: MaybeNumber;
reasoning_output: MaybeNumber;
reported_total: MaybeNumber;
reasoning_relation: "subset" | "additive" | "unknown";
source_schema: string;
};
function numberOrNull(value: unknown): MaybeNumber {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function openAIStyle(raw: any): UsageLedger {
const u = raw.usage ?? {};
return {
input_uncached: numberOrNull(u.prompt_tokens),
cache_read: numberOrNull(u.prompt_tokens_details?.cached_tokens),
cache_write: numberOrNull(u.prompt_tokens_details?.cache_write_tokens),
output_total: numberOrNull(u.completion_tokens),
reasoning_output: numberOrNull(u.completion_tokens_details?.reasoning_tokens),
reported_total: numberOrNull(u.total_tokens),
reasoning_relation: "subset",
source_schema: "openai-compatible-chat"
};
}
Этот пример ещё не решает вопрос обычного входа. Если prompt_tokens уже включает cached input, поле input_uncached здесь названо слишком смело. В реальном адаптере назовите его input_reported_total, пока документация конкретного endpoint не подтверждает разбиение. Затем вычисляйте:
ordinary_input = input_reported_total - cache_read
только когда одновременно выполнены три условия: cache read действительно входит в input total, оба значения относятся к одному запросу и разность не отрицательна. При нарушении хотя бы одного условия верните null, добавьте флаг invalid_usage_relation и сохраните оригинальный JSON.
Для Anthropic формула иная. Их документация по prompt caching определяет общий вход как сумму input_tokens, cache_creation_input_tokens и cache_read_input_tokens. Там input_tokens относится к части после последней точки кэширования, а не ко всему переданному пользователем контексту. Если вы прибавите cache read к «общему input», а затем ещё раз вычтете его как скидку, вы снова исказите цену.
Проверки инвариантов стоит запускать на каждой записи:
if reasoning_output != null and output_total != null:
assert reasoning_output <= output_total
if cache_read != null and cache_read < 0:
reject record
if reported_total != null and input_total != null and output_total != null:
compare reported_total with input_total + output_total
Последняя проверка не должна отклонять запрос автоматически. Мультимодальность, инструменты и особые правила провайдера могут менять равенство. Она нужна как сигнал, что схема поменялась или адаптер неверно понял поля.
Лимит контекста, лимит генерации и rate limit отвечают на разные вопросы
Финансовый учёт часто смешивают с ограничениями API, потому что оба мира говорят о токенах. Но ограничение контекста отвечает на вопрос «влезет ли запрос и ответ в окно модели». Лимит генерации отвечает «сколько модель может потратить на текущий output». Rate limit отвечает «как быстро можно потреблять запросы или токены за интервал». Цена отвечает «сколько это стоит». Один и тот же токен может участвовать в двух или трёх из этих расчётов.
Для запроса со сложным reasoning полезно считать запас генерации так:
requested_output_cap = max_completion_tokens
actual_output = visible_text + tool_arguments + reasoning + other_output
actual_output <= requested_output_cap
Если API документирует, что reasoning входит в лимит output, короткий ответ пользователю не спасёт от length. Модель может потратить почти весь потолок на рассуждение и не успеть сформировать финальный JSON. Это особенно неприятно в сценариях с инструментами: оркестратор получает неполный аргумент, повторяет запрос и платит второй раз.
Не лечите это простым увеличением max_tokens для всех запросов. Такой ход популярен, потому что быстро убирает часть ошибок. Он же открывает путь к длинным внутренним рассуждениям на задачах, где достаточно краткого извлечения фактов. Разделите классы задач: извлечение, классификация, генерация текста, анализ документов, программирование, планирование с инструментами. Для каждого класса задайте верхнюю границу output и допустимый режим reasoning, затем проверьте качество на фиксированном наборе примеров.
Rate limit тоже нельзя вычислять из цены. Провайдер может учитывать cache read в лимите входных токенов, а может считать только токены, дошедшие до инференса. Документация Anthropic отдельно предупреждает, что эффективное кэширование меняет вид input_tokens, но не отменяет необходимости понимать полный объём обработанного контекста. Если вы строите очередь по собственному счётчику «обычного входа», она может резко упереться в 429 при длинных кэшированных диалогах.
Streaming требует дождаться финального usage
При streaming нельзя считать фактическую стоимость по длине полученных чанков. Текстовые события показывают пользовательский вывод, но не обязаны нести итоговую детализацию кэша, reasoning и даже общего usage. У OpenRouter usage для stream возвращается один раз в финальном сообщении перед [DONE]. У Anthropic часть итоговых чисел приходит в message_delta. Похожие правила встречаются у других API, но конкретный порядок событий нужно проверять в их документации.
Сделайте обработчик двухфазным. Во время потока он собирает контент для пользователя и предварительно фиксирует request_started. После финального usage он закрывает финансовую запись. Если соединение оборвалось раньше, помечайте запрос как usage_pending или stream_interrupted, а не записывайте ноль.
Рабочая последовательность выглядит так:
- Создайте запись с
request_id, моделью, параметрами reasoning и временем старта. - Сохраняйте содержимое и метаданные событий, но не вычисляйте финальную цену.
- Прочитайте завершающее usage-событие и пропустите его через адаптер схемы.
- Рассчитайте цену по версии прайса, действовавшей для этой модели и маршрута.
- Если финального usage нет, выполните отложенную сверку по серверному журналу или endpoint статистики, если он доступен.
Не подставляйте локальный токенайзер как окончательную сумму после разрыва stream. Он годится для предварительной оценки лимита до отправки, но не знает внутренние токены, серверные системные инструкции, преобразования инструментов и правила кэширования. В отчёте лучше показать «стоимость ожидает сверки», чем точную на вид ложь.
Один запрос нужно уметь разложить до тенге
Рассмотрим не вымышленный счёт, а форму записи, которую должен понимать ваш расчёт. Пусть адаптер получил такой нормализованный usage:
{
"model": "provider/model-x",
"input_reported_total": 24000,
"cache_read": 18000,
"cache_write": 0,
"output_total": 1400,
"reasoning_output": 950,
"reasoning_relation": "subset",
"gateway_response_cache": "miss"
}
Если документация этой схемы подтверждает, что cache read включён в input_reported_total, то обычный вход равен 6000. Дальше применяйте не одну цену input, а отдельные ставки:
ordinary_input_cost = 6000 * p_input
cache_read_cost = 18000 * p_cache_read
cache_write_cost = 0 * p_cache_write
output_cost = 1400 * p_output
request_cost = сумма четырёх строк
reasoning_output = 950 не входит в формулу как пятая строка. Он создаёт два полезных показателя:
reasoning_share = 950 / 1400
visible_output_approx = 1400 - 950
Первый помогает увидеть, какие сценарии расходуют выходной бюджет на размышление. Второй помогает расследовать жалобу «ответ из двух предложений стоит как длинный». Но не используйте второй показатель как обещание того, сколько текста пользователь увидит: он приблизителен.
Теперь представьте, что следующий запрос вернул такой же текст, но с response cache hit на уровне шлюза и нулевым usage. Цена этого ответа может быть нулевой, если правила шлюза так устроены. При этом не надо записывать reasoning_share = 0. Reasoning в этом запросе не выполнялся. Правильнее поставить not_applicable, поскольку метрика описывает работу модели, которой не было.
Именно такие детали ломают месячные отчёты. Если смешать cache hit с обычными запросами, средняя доля reasoning внезапно «улучшится». На самом деле вы просто увеличили долю ответов без вызова модели.
Дашборд должен показывать причины, а не один общий график
График total tokens полезен как сигнал, но бесполезен для решения. Когда он вырос, инженер должен за минуту увидеть источник: увеличился базовый контекст, сорвался cache read, появились cache write, вырос output, поднялась доля reasoning или изменился маршрут на другую модель.
Я бы оставил на основном дашборде пять разрезов: стоимость по модели, обычный вход, cache read и cache write, общий output, доля reasoning среди output. Отдельно покажите количество запросов с неизвестной детализацией. Если оно растёт, проблема не в модели, а в телеметрии.
Нужны и жёсткие правила уведомлений. Не ставьте алерт на каждый рост total tokens. Поставьте его на события, которые требуют действия:
- cache write резко вырос при прежнем объёме запросов;
- cache read упал после изменения шаблона промпта;
- reasoning share вырос у конкретной задачи после смены модели;
- поток завершился без финального usage;
- цена по вашему расчёту не сошлась с серверной статистикой выше допустимой погрешности округления.
Проверяйте маршрутизацию отдельно. Один OpenAI-совместимый endpoint не означает один счётчик и один токенайзер. AI Router сохраняет совместимость с привычными SDK и маршрутизирует запросы к разным моделям, поэтому в вашей записи должны оставаться фактическая модель и провайдерский маршрут, а не только имя, которое отправило приложение.
Самая полезная привычка здесь скучная: перед изменением промпта, модели, reasoning effort или кэширования прогоняйте короткий набор контрольных запросов и сохраняйте usage рядом с оценкой качества. Тогда обсуждение не сводится к фразам «стало дороже» или «модель думает слишком долго». Вы увидите, сколько именно входа перестало попадать в кэш, сколько выхода ушло во внутреннее мышление и где поставленный лимит обрезал ответ.
Не пытайтесь сделать токены одинаковыми между провайдерами. Сделайте их проверяемыми. Сырые поля, явная семантика, раздельные ставки, финальная сверка streaming и запрет на двойной учёт reasoning дадут вам расчёт, который выдерживает новые модели и очередной «совместимый» API.
Часто задаваемые вопросы
Токены рассуждения входят в output tokens или считаются отдельно?
Не всегда. У части API reasoning или thinking входит в общее число выходных токенов и показывается отдельной детализацией. Если сложить его с output или completion без проверки семантики поля, вы получите двойной учёт.
Почему расчёт по токенам не совпадает со счётом провайдера?
Потому что счёт выставляет провайдер по своей модели биллинга, а не ваш счётчик. Ваш расчёт должен хранить сырые usage-поля, применённый тариф и идентификатор модели, чтобы расхождение можно было разобрать по конкретному запросу.
Может ли reasoning съесть лимит max tokens?
Да, если провайдер считает их частью output. Ограничение на генерацию часто покрывает и видимый текст, и внутреннее рассуждение, поэтому короткий ответ не означает, что у модели остался большой запас на мышление.
Кэшированные токены бесплатны?
Нет. Кэш-чтение обычно уменьшает цену повторно использованного префикса, но эти токены всё равно могут учитываться в контексте и в лимитах пропускной способности. Смотрите отдельно цену, контекст и rate limit.
Нужно ли разделять cache read и cache write?
Храните отдельные поля для чтения и записи. Первая запись в кэш нередко стоит по особому тарифу, а последующие чтения дешевле обычного входа. Если свести их в одно cached_tokens, финансовый отчёт потеряет смысл.
Как считать токены при streaming-ответе?
Сохраняйте usage из финального SSE-события, а не пытайтесь сложить видимые текстовые чанки. Промежуточные события могут не содержать итоговых чисел, а reasoning и кэш-детали нередко приходят только в конце.
Что означает отсутствие reasoning_tokens в usage?
Это скорее неизвестное значение, чем ноль. Некоторые API не раскрывают reasoning, некоторые модели не генерируют его, а некоторые шлюзы отбрасывают детализацию при нормализации. Помечайте такое поле как unavailable.
Какие поля нужны для аудита расходов на LLM?
Для каждого запроса сохраняйте провайдера, модель, endpoint, режим streaming, сырой usage, нормализованные категории, тарифную версию, валюту, timestamp и request_id. Без модели и версии тарифа восстановить цену через месяц трудно.
Достаточно ли хранить только total_tokens?
Нет, если вы хотите управлять затратами. Общий total полезен как проверка целостности, но не отвечает на вопрос, что выросло: новый контекст, кэш-записи, видимый вывод, внутреннее мышление или вызовы инструментов.
Как ограничить стоимость reasoning без потери качества?
Сначала сравните реальные ответы на одном и том же наборе задач при нескольких настройках reasoning. Если качество не меняется, снижайте effort или бюджет. Нельзя выбирать режим по средней длине ответа, потому что именно скрытая часть часто делает запрос дорогим.