Лимит LLM есть, а бюджет превышен: атомарный резерв на Python для нескольких worker
У каждого worker свой запрос и своя проверка остатка. Все видят свободный бюджет, все проходят проверку — и вместе отправляют больше, чем проект собирался потратить. Разберу атомарное резервирование на Python и SQLite: как учитывать незавершённые вызовы, почему timeout не возвращает деньги и что именно проверять конкурентным тестом.
Проблема не в цене токена, а в моменте допуска
В обсуждении fan-out мы уже разделяли бюджет одной задачи и общий лимит проекта. Здесь разберу следующий слой: общий бюджет должны одновременно соблюдать несколько исполнителей, а не только один цикл агента.
Учебный пример: лимит — 100 условных единиц учёта, резерв одного вызова — 30. Это не тариф модели и не реальные расходы. Если двадцать worker независимо читают остаток до обновления счётчика, каждый может решить, что денег достаточно. Семафор в одном процессе не защитит другие процессы.
Нужен инвариант допуска: spent + held + новый резерв ≤ cap. spent — уже подтверждённый расход, held — суммы, закреплённые за попытками с ещё неизвестным итогом. Проверка и создание резерва должны попасть в одну транзакцию.
Что именно резервировать
В рабочем сервисе я бы хранил целые минимальные единицы выбранной валюты, а не float и не «вес сложности». Для текстового вызова резерв строится из консервативной оценки входа, ограничения оплачиваемого выхода и актуальной версии тарифа; дробные результаты округляются вверх. Скидку за кэш нельзя заранее считать гарантированной.
У изображения, видео, поиска или другого платного инструмента свои единицы тарификации. Если reasoning-токены, дополнительные инструменты или динамическая цена не укладываются в доказуемую верхнюю границу, это контроль по оценке, а не математически жёсткий денежный потолок. Для неизвестного тарифа безопаснее отказать в автоматическом допуске или потребовать отдельный лимит риска.
Один ID попытки соответствует одной потенциально оплачиваемой отправке. Это не ID пользовательского диалога. В production к нему нужно привязать tenant, период бюджета, модель, версию тарифа и отпечаток параметров. Ниже — сокращённый пример для одного заранее созданного бюджета.
1. Короткая транзакция вместо локального Lock
SQLite выбран для воспроизводимого примера на одном хосте, не как рекомендация поставить общий файл на сетевой диск. BEGIN IMMEDIATE начинает write-транзакцию сразу; SQLite допускает только одного writer одновременно. При конкуренции операция может получить SQLITE_BUSY. Платный вызов при ошибке БД не запускаем.
Инициализация выполняется один раз для нового файла. Это первый фрагмент budget.py; следующие два дополняют тот же файл.
2. Резерв и запись попытки — вместе
Повторный ключ не даёт разрешения ещё раз вызвать провайдера. False здесь означает «нового допуска нет»: либо остатка не хватает, либо попытка уже известна. Для API сервиса эти причины лучше возвращать разными статусами.
Критическая часть reserve:
Транзакция не охватывает HTTP-запрос. Сначала фиксируем резерв, закрываем транзакцию и только после этого отправляем запрос. Иначе медленная генерация удержит блокировку учёта для всего проекта.
3. Сверка расхода без двойного списания
Подтверждённая стоимость заменяет резерв: held уменьшается на reserved, spent увеличивается на actual. Повторная доставка того же результата не должна повторять списание. Конфликтующая сумма — повод для сверки, не для тихой перезаписи.
Завершающий фрагмент budget.py:
actual берётся из проверенного ответа провайдера или доверенного биллингового контура, не из JSON, который придумала модель, и не из параметра клиентского запроса. Если провайдер сначала возвращает предварительный usage, а затем корректирует счёт, нужен отдельный журнал корректировок; этот пример его не реализует.
Обратите внимание: код честно записывает actual даже выше резерва. Иначе отчётность начнёт скрывать перерасход. После выхода за cap новые резервы перестанут проходить, но уже потраченное не исчезнет. Значит, жёсткая гарантия cap работает только при корректных верхних границах, отсутствии обходных вызовов и едином контуре учёта.
Самый неприятный случай: timeout после отправки
Оборвалось соединение — это состояние транспорта, а не подтверждение нулевой стоимости. Если провайдер уже начал генерацию, освобождать held по таймеру опасно: новый worker потратит те же деньги ещё раз.
В такой ситуации резерв остаётся занят. Сохраняем provider request ID, проверяем статус или usage, при необходимости отправляем на ручную сверку. Если подтверждено, что оплачиваемой операции не было, settle(..., 0) освобождает резерв. Возраст неизвестной попытки должен вызывать алерт, а не автоматическое обнуление.
Цена осторожности — временная недоступность части бюджета. Это осознанный обмен доступности на контроль расхода. Retry с новой потенциально оплачиваемой генерацией требует нового резерва; старый неизвестный вызов не становится бесплатным. Встроенные повторы SDK тоже необходимо учитывать, иначе они обойдут этот контур.
Проверка: двадцать worker, три допуска
Код проверен локально восемью unit-тестами без вызовов модели и без платного API. Конкурентный тест открывает отдельное SQLite-соединение в каждом worker и синхронизирует старт барьером. При cap=100 и резерве 30 проходят ровно три новых попытки: spent=0, held=90.
Также проверены повтор резерва, смена суммы для занятого ключа, освобождение разницы после сверки, повторная и конфликтующая сверка, сохранение резерва при неизвестном исходе, actual выше резерва и недопустимые значения. Тест timeout проверяет именно политику учёта — он не симулирует биллинг реального провайдера.
Самостоятельный конкурентный тест приведён в конце статьи. Сохраните три фрагмента выше как budget.py, а тест — в соседний файл и запустите его Python. Платный API для этой проверки не нужен.
Где заканчивается этот пример
Резервирование не даёт exactly-once для сети. Между COMMIT и отправкой процесс может упасть. Повторный reserve вернёт False и сохранит деньги, но сам не продолжит workflow. Для восстановления нужны сохранённое состояние отправки, стабильный ID попытки, поддерживаемая провайдером идемпотентность и сверка неопределённых исходов. Без этих механизмов нельзя обещать автоматический повтор без риска дубля.
В нескольких регионах или при большом потоке нужен общий согласованный ledger либо заранее выделенные непересекающиеся квоты. Асинхронно реплицируемые локальные остатки не дают тот же инвариант. В PostgreSQL можно сохранить принцип условного UPDATE и транзакции с журналом попыток, но перенос требует отдельного конкурентного теста.
В реальном сервисе также нужны аутентификация tenant, привязка попытки к параметрам, ограниченный retry транзакций, метрики отказов допуска, возраст held, разница оценки и actual и отдельный бюджет для fallback. Бюджетный период закрепляется при резервировании: поздняя сверка не должна случайно списать стоимость вчерашней попытки в новый пустой лимит.
Что я бы проверил перед подключением модели
Первое: разрешение на отправку возникает только после COMMIT. Второе: все потенциально оплачиваемые отправки, включая retry и fallback, проходят один контур. Третье: неизвестный исход не освобождает резерв. Четвёртое: повторная сверка не меняет счётчики. Пятое: ошибка или недоступность ledger означает отказ от новой отправки, а не обход проверки.
Отдельный проект или ключ у провайдера остаётся полезным вторым рубежом, если его лимит действительно блокирует расход. Но он не заменяет локальный учёт попыток, а локальный учёт не заменяет проверку условий биллинга.
Суть схемы: сначала закрепить право потратить, затем отправить запрос, после — подтвердить расход. Считать только завершённые вызовы слишком поздно: незавершённые уже могут стоить денег.
Как вы поступаете с резервом после timeout: держите до сверки с провайдером или допускаете ограниченный перерасход ради доступности? Где для вашего продукта проходит эта граница?
Техническая основа
SQLite: транзакции, BEGIN IMMEDIATE и SQLITE_BUSY. Python: sqlite3, управление транзакциями и соединениями. Пример выше — локальный учебный ledger, не измерение тарифов и не готовая распределённая платёжная система.