У бота два счётчика, и они почти не разговаривают друг с другом. Первый показывает пользователю: «Осталось 7 сообщений». Он считает реплики, потому что реплики понятны человеку. Второй живёт у поставщика модели: он считает токены, отдельно входные и выходные, а потом превращает их в деньги по ценовой карте. Сообщение из трёх слов и сообщение с приложенным документом на сорок страниц для первого счётчика равны единице. Для второго между ними пропасть.
Отсюда главная мысль статьи: лимит сообщений защищает интерфейс, но не кошелёк. Чтобы ограничить дневные расходы, нужен предел, выраженный в тех же единицах, в которых приходит счёт, то есть в деньгах. А если запросы выполняются одновременно, этот предел приходится охранять в трёх местах: до запуска, во время работы и после ответа.
Почему «десять сообщений в день» не равно «десять долларов в день»
Стоимость одного запроса складывается из нескольких слагаемых. Входные токены: системная инструкция, история диалога, найденные фрагменты документов, вложения. Выходные токены: то, что модель написала. Выходные обычно стоят дороже входных, но точные тарифы зависят от поставщика и модели, поэтому в формулах ниже я использую условные числа, которые вы замените своими.
Что делает лимит по сообщениям слепым:
- История растёт. Если бот каждый раз отправляет весь диалог, десятое сообщение дороже первого, потому что в него входят предыдущие девять вместе с ответами.
- Контекст не равен реплике. Подмешанные документы могут весить в сотни раз больше вопроса пользователя.
- Ответ непредсказуем по длине. Просьба «объясни подробно» и просьба «да или нет» занимают одну строку в счётчике сообщений.
- Один запрос может породить несколько. Бот с инструментами делает вызов модели, получает результат, вызывает снова. Пользователь видит одно сообщение, поставщик выставляет счёт за пять обращений.
- Повторы тоже стоят денег. Если сетевая ошибка заставила клиента отправить запрос заново, а первый всё же дошёл до модели, оплачены оба.
Поэтому лимит сообщений полезен как вежливое ограничение поведения человека, но не как финансовая защита. Финансовая защита должна смотреть на токены и стоимость.
Что на самом деле происходит в момент «вечером расходы выросли»
Когда бюджет улетает за вечер, причина обычно одна из двух, и лечатся они по-разному.
Длинные, но честные ответы. Пользователи прислали большие документы или попросили развёрнутые тексты. Расходы растут пропорционально числу запросов, а в журнале видно много запросов с большим числом токенов, у каждого из которых есть автор и осмысленный вход. Лекарство: потолок на размер входа и на длину ответа, сжатие истории, более дешёвая модель для простых задач.
Зацикливание. Агент вызывает инструмент, не получает нужного результата, пробует снова и так до бесконечности. Или два компонента перебрасывают друг другу задачу. В журнале один и тот же ключ или одна сессия порождает длинную цепочку однотипных обращений с растущим контекстом, а пользователь за это время не написал ни слова. Лекарство: предел числа шагов на одну задачу и автоматическая остановка при повторяющихся вызовах.
Различить их можно по двум признакам. Первый: распределение по ключам. При длинных ответах расходы размазаны по многим пользователям, при цикле сосредоточены на одном ключе или одной сессии. Второй: связь с входящими сообщениями. Если стоимость растёт, а новых сообщений от людей нет, это не нагрузка, а самоподдерживающийся процесс. Для такого разбора нужен учёт расходов по ключам, пользователям и командам; именно такую разбивку ведёт отслеживание расходов в LiteLLM, и без неё вы будете угадывать виновника по общему счёту.
С агентами, которые работают без присмотра, эта проблема острее; о защите от ночного перерасхода подробнее в статье как ограничить агента, чтобы он не сжёг бюджет за ночь.
Три состояния запроса и почему сумма «потрачено» врёт
Самая частая ошибка проектирования: проверять бюджет по формуле «потрачено меньше лимита, значит запускаем». Эта формула знает только про завершённые запросы. Но в любой момент запросы бывают в трёх состояниях:
- Завершены. Фактический usage известен, деньги посчитаны.
- Выполняются. Модель уже работает, токены уходят, а итоговой суммы ещё нет.
- Стоят в очереди. Ничего не потрачено, но они уже приняты и скоро будут запущены.
Проверка по завершённым видит только первое состояние. Если десять запросов стартуют одновременно, каждый из них посмотрит на одну и ту же цифру «потрачено» и каждый решит, что денег достаточно. Это гонка: решения принимаются по устаревшему снимку.
Арифметика одного вечера
Возьмём условные числа. Дневной бюджет бота равен 10 единицам. К восьми вечера завершённых запросов на 7,5, остаток 2,5. В одну минуту приходят четыре длинных запроса, и каждый в худшем случае может обойтись в 1,0: это оценка сверху по размеру входа и максимальной длине ответа.
Наивная проверка «остаток больше нуля» пропускает все четыре. Если они отработают в худшем случае, расход составит 4,0 при остатке 2,5, и бюджет превышен на 1,5. Ничего экзотического для этого не нужно, достаточно одновременности.
Теперь та же ситуация с резервом. Перед запуском шлюз вычисляет верхнюю оценку запроса и откладывает её из остатка:
- Остаток 2,5. Первый запрос резервирует 1,0, свободно 1,5.
- Второй резервирует 1,0, свободно 0,5.
- Третий хочет 1,0, а свободно 0,5. Его не запускают: он получает отказ или ждёт, пока освободится резерв.
- Четвёртый получает то же самое.
Когда первый запрос завершился и пришёл фактический usage, скажем на 0,6, резерв в 1,0 снимается и заменяется реальной суммой 0,6. Свободный остаток вырос на 0,4 и составляет 0,9. Это всё ещё меньше 1,0, поэтому третий запрос в такой оценке по-прежнему не проходит; но запрос с меньшей верхней оценкой, например 0,8, уже прошёл бы. Так сверка фактического usage возвращает в оборот то, что было зарезервировано с запасом.
Формула одна: свободно = лимит − завершённые траты − резервы выполняющихся запросов. Запросы в очереди резервируют сумму в момент запуска, а не в момент постановки, иначе длинная очередь заморозит весь бюджет раньше, чем начнёт работать.
Где живёт резерв
Резервирование суммы перед запуском реализуется в коде вашего шлюза, то есть той прослойки, через которую бот обращается к моделям. Ему нужно четыре вещи: верхняя оценка стоимости, атомарная операция «проверить остаток и отложить сумму», запись об идентификаторе запроса и обязательное освобождение резерва при завершении, ошибке или таймауте. Последнее критично: потерянный резерв никогда не вернётся, и бюджет будет незаметно «худеть» без реальных трат.
Верхнюю оценку считают из того, что известно заранее: размер входа (его можно оценить до отправки) плюс запрошенный потолок выходных токенов, умноженные на тариф. Потолок выходных токенов поэтому нужен не только для ограничения длины ответа, но и для того, чтобы оценка вообще имела верхнюю границу. Без него «худший случай» неопределён.
Резерв не обещает идеальной денежной отсечки: фактический расход считается по usage, который поставщик сообщает после ответа, а у запроса, оборванного на полпути, итоговые токены могут оказаться известны не сразу. Поэтому небольшой зазор между лимитом и фактом возможен, и дневной лимит имеет смысл задавать с запасом относительно суммы, которую вы действительно не готовы превысить.
Что делают штатные бюджетные проверки LiteLLM
Если шлюз у вас построен на прокси LiteLLM, часть работы уже сделана. У него есть бюджеты и лимиты: индивидуальные, командные и внутригрупповые. Они проверяются на стороне прокси перед выполнением запроса и сверяются с накопленными расходами.
Важное условие, о котором легко забыть при быстром запуске: бюджетные проверки требуют базы данных. Расходы должны где-то накапливаться, чтобы с ними было что сравнивать. Если прокси запущен без базы, глобальный бюджет запросы не ограничивает: настройка в конфигурации есть, а удержать нечем. Поэтому первым пунктом приёмки бюджета должна стать проверка, что база подключена и расходы действительно копятся. Практический тест такой: выставьте тестовому ключу крошечный бюджет, отправьте запросы и убедитесь, что после исчерпания прокси отвечает отказом, а в записях расходов видны накопленные суммы.
Теперь вернёмся к вечерней арифметике и посмотрим, как два компонента делят работу.
- Штатная проверка прокси смотрит на накопленные расходы по ключу, пользователю или команде и отказывает, когда лимит достигнут. Она отвечает на вопрос «потрачено ли уже слишком много».
- Резерв в вашем шлюзе отвечает на вопрос «хватит ли денег на то, что мы запускаем прямо сейчас, вместе со всем, что уже летит». Он учитывает выполняющиеся запросы, о которых накопленная сумма ещё не знает.
В нашем примере остаток 2,5 и четыре запроса по 1,0. Штатная проверка по накопленному видит 7,5 из 10 и пропускает всех четверых, пока не завершится достаточно запросов, чтобы накопленное дошло до лимита. Резерв отсекает третий и четвёртый ещё до старта. А когда запросы завершаются, фактический usage попадает в учёт расходов, и обе части снова видят одинаковую картину. Штатные проверки остаются страховочной сеткой, а резерв закрывает окно одновременности, которое сетка по своей природе не покрывает.
Если ваш трафик скромный и запросы идут по одному, резерв может оказаться избыточным: гонке просто неоткуда взяться. Но стоит появиться пакетной обработке, нескольким воркерам или агентам с параллельными вызовами, и зазор между «проверили» и «потратили» становится вполне ощутимым.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Личные, командные и общие бюджеты: почему их нельзя складывать механически
Бюджеты бывают разных уровней, и соблазн велик: выдать каждому пользователю по 1, команде из десяти человек поставить 10, а общий лимит сделать 10 и считать, что всё сходится. Но эти числа не складываются, потому что у них разные роли.
- Личный бюджет защищает от одного пользователя: от случайного цикла, от злоупотребления, от чрезмерно любопытного коллеги. Он отвечает на вопрос «сколько может потратить один человек».
- Командный бюджет защищает от коллективного перерасхода и показывает, кто за что платит. Он отвечает на вопрос «сколько может потратить группа».
- Общий бюджет защищает кошелёк целиком. Он отвечает на вопрос «сколько мы вообще готовы заплатить сегодня».
Они вложены друг в друга, как матрёшки, и запрос должен пройти все уровни. Допустим, у человека остаток личного бюджета 0,8, у его команды 0,3, а у общего 5,0. Запрос оценён в 0,5. Личный лимит позволяет, общий позволяет, но командный нет. Запрос не пройдёт, и это правильно: самый тесный уровень определяет возможность. Эффективный остаток для запроса равен минимуму по всем уровням, а не их сумме.
Из этого следуют две практические вещи. Во-первых, сумма личных лимитов может спокойно превышать командный: это сознательная переподписка, как в сетях провайдеров. Если все одновременно упрутся в личные потолки, командный лимит остановит их раньше. Во-вторых, сумма командных лимитов может превышать общий, и тогда общий бюджет работает как аварийный потолок, а не как план распределения. Важно только понимать, что он будет срабатывать на тех, кто пришёл позже, а не на тех, кто потратил больше, поэтому для справедливости стоит предусмотреть командные доли, которые в сумме не превосходят общую, если вам важен гарантированный минимум каждой команде.
Резерв в шлюзе должен работать по тем же уровням: сумму откладывают одновременно в личном, командном и общем счётчиках, а при отказе на любом уровне откат делается для всех. Иначе можно получить неприятный эффект: запрос зарезервировал деньги на трёх уровнях, на четвёртом получил отказ и оставил три резерва висеть.
Граница суток: что считать «сегодня»
Дневной бюджет предполагает, что где-то есть полночь. И сразу возникают вопросы, на которые стоит ответить заранее, а не в момент инцидента.
В каком часовом поясе заканчиваются сутки. Если лимит сбрасывается по UTC, а пользователи сидят в Москве, то он обнулится в три часа ночи по их времени, и вечерний пик не пострадает, но утренний перерасход можно ошибочно приписать «вчера». Выберите одну границу и пишите её в журнал расходов вместе с каждой записью, чтобы потом не спорить.
К какому дню относится запрос, начатый в 23:59. Самый разумный вариант: к тому дню, когда он был запущен и когда под него взят резерв. Тогда резерв и итоговая сумма оказываются в одних сутках, и учёт не расползается. Альтернатива, когда сумма записывается по времени завершения, приводит к тому, что новый день начинается с чужого долга, а старый кажется дешевле, чем был.
Что делать с запоздалым ответом. Длинная задача стартовала в 23:58, а usage пришёл в 00:03. Деньги потрачены вчера, и вчерашний счётчик должен их получить. Если ваша система умеет только «прибавить к текущему дню», в первые минуты нового дня окажется чужой расход, а вчерашний бюджет будет выглядеть невыполненным. Решение простое по сути: у каждого резерва и каждой записи расхода есть собственный ключ дня, определённый в момент запуска, а сверка usage дописывает в этот день, пусть даже он уже закрыт.
Поэтому при сверке со счётом поставщика сравнивайте не «вчера по календарю», а точно тот же период в том же часовом поясе. Расхождение с биллингом чаще всего сводится к тому, что периоды не совпали на несколько часов. Для расхождений документация по учёту расходов предлагает сравнить период, категории токенов и ценовую карту: три очевидных кандидата, и их стоит проверять именно в таком порядке. Сначала убедитесь, что обе стороны смотрят на один интервал. Затем проверьте, что входные, выходные и, если у модели они выделены, кэшированные токены учитываются по своим тарифам. И только потом сверяйте цены: устаревшая ценовая карта даёт систематическую ошибку, которая выглядит как «примерно пять процентов лишних», хотя на деле просто не обновлён тариф.
Аварийная остановка: когда лимит уже не лимит, а выключатель
Бюджеты отвечают за штатное ограничение. Но есть сценарии, при которых нужно не плавно упереться в потолок, а остановить поток немедленно: странный всплеск, подозрение на утечку ключа, цикл, который виден по графику. Для этого нужен отдельный механизм, и его полезно строить так, чтобы он останавливал новые запуски, а не рубил всё подряд.
Что именно выключать
Самый ценный рычаг: запретить старт новых запросов. В шлюзе это один флаг, который проверяется перед взятием резерва. Пока он установлен, любой новый запрос получает отказ с понятным сообщением. Это быстро, обратимо и не требует знать, откуда пришла нагрузка.
Запросы, которые уже летят, это отдельный вопрос. У вас два варианта:
- Дать дотечь. Запущенные запросы завершаются, их usage попадает в учёт, резервы закрываются честной суммой. Минус: пока они идут, расходы ещё растут.
- Оборвать. Прервать соединения с поставщиком. Минус: токены, уже сгенерированные, всё равно оплачены, а вы получаете половину ответа и неопределённость в учёте.
Для большинства ботов разумнее первый вариант с жёсткими таймаутами: новые запуски заблокированы сразу, а оставшиеся доживают в пределах уже взятых резервов. Верхняя граница доплаты известна заранее, это сумма резервов в момент остановки. Именно поэтому резерв полезен и для выключателя: он превращает вопрос «сколько ещё может уйти после нажатия» в сумму, которую можно прочитать.
Автоматика и ручной режим
Выключатель стоит повесить на два триггера. Автоматический срабатывает, когда расход за скользящий короткий интервал превышает порог, например за последний час потрачено больше заранее заданной доли дневного бюджета. Ручной нужен человеку, который заметил проблему раньше любого порога. Оба должны вести к одному и тому же состоянию, иначе вы получите два режима отказа, которые никто не умеет различать.
Выключатель, который ни разу не нажимали, ненадёжен. Проверьте его на тестовом ключе с маленьким бюджетом: запустите несколько параллельных запросов, включите блокировку и убедитесь по записям, что новые запросы получили отказ, а запущенные завершились и были учтены. Отдельно проверьте обратный ход: после снятия блокировки бот снова принимает запросы, а освободившиеся резервы не зависли.
Где запускать шлюз и учёт
Шлюз с резервами, база расходов и прокси требуют места, где они работают постоянно и не зависят от ноутбука разработчика. Для этого подходит обычный VPS: нагрузка здесь не вычислительная, шлюз передаёт запросы и записывает суммы, а тяжёлую работу делает внешняя модель. Поэтому на первых порах хватает небольшой конфигурации. У VPS MAATRIX стартовый тариф Ba даёт 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe, а Uab удваивает процессор до 2 vCPU при 4 ГБ и 80 ГБ. Это ориентиры по ресурсам, а не обещание ёмкости: сколько параллельных запросов потянет ваш шлюз вместе с базой, покажет только измерение на вашей нагрузке. Память стоит оставить с запасом под базу данных, потому что без неё бюджетные проверки, как уже сказано, ничего не ограничивают. Доступ root позволяет установить и прокси, и базу, и собственный шлюз так, как вам нужно.
Что увидит человек, когда лимит исчерпан
Всё сказанное выше невидимо пользователю, пока не наступает момент отказа. И тут решается, будет ли ограничение восприниматься как защита или как поломка.
Плохой отказ: бот молчит, отвечает общей ошибкой или бесконечно «печатает». Человек не понимает, сломалось ли что-то, и повторяет запрос, а повторы при неудачной обработке могут порождать новые обращения. Хороший отказ сообщает три вещи: что лимит исчерпан, о каком именно лимите речь (личный, командный или общий) и когда он обновится. Различие между уровнями важно: «вы потратили свою долю» и «исчерпан общий дневной лимит» требуют от человека разных действий. Первое можно решить, обратившись к администратору команды, второе означает, что до новых суток ничего не поделать.
Теперь о запущенных задачах. Пользователь может отправить большой документ на обработку и сразу же упереться в лимит со следующим сообщением. Что с первой задачей? Она была запущена под резервом, значит, деньги под неё уже отложены. Она продолжит выполняться, и её итоговый usage будет учтён в том дне, когда она стартовала, даже если закончится после полуночи. Человеку стоит сказать об этом прямо: новые запросы приостановлены, задача, которая уже идёт, будет доведена до конца, а результат появится как обычно. Так отказ не выглядит потерей работы.
И последнее. Сообщение об исчерпании не должно запускать новые платные вызовы: не стоит просить модель красиво объяснить пользователю, что денег нет. Текст отказа задайте шаблоном, пусть он ничего не стоит. Иначе защита, срабатывая, сама будет тратить из бюджета, который охраняет.