Лимит LLM есть, а бюджет превышен: атомарный резерв на Python для нескольких worker

У каждого worker свой запрос и своя проверка остатка. Все видят свободный бюджет, все проходят проверку — и вместе отправляют больше, чем проект собирался потратить. Разберу атомарное резервирование на Python и SQLite: как учитывать незавершённые вызовы, почему timeout не возвращает деньги и что именно проверять конкурентным тестом.

Иллюстрация сгенерирована в GPT Image 2: общий бюджет и занятые резервы. Визуальная метафора, не схема реализации.
Иллюстрация сгенерирована в GPT Image 2: общий бюджет и занятые резервы. Визуальная метафора, не схема реализации.

Проблема не в цене токена, а в моменте допуска

В обсуждении 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; следующие два дополняют тот же файл.

import sqlite3 from contextlib import closing, contextmanager def init(path, cap): with closing(sqlite3.connect(path)) as db, db: db.executescript(""" CREATE TABLE budget( id INTEGER PRIMARY KEY CHECK(id=1), cap INTEGER NOT NULL CHECK(cap>=0), spent INTEGER NOT NULL DEFAULT 0 CHECK(spent>=0), held INTEGER NOT NULL DEFAULT 0 CHECK(held>=0)); CREATE TABLE attempts( key TEXT PRIMARY KEY, reserved INTEGER NOT NULL CHECK(reserved>0), actual INTEGER CHECK(actual>=0)); """) db.execute("INSERT INTO budget(id,cap) VALUES(1,?)", (cap,)) @contextmanager def tx(path): db = sqlite3.connect(path, isolation_level=None, timeout=5) try: db.execute("BEGIN IMMEDIATE") yield db db.commit() except BaseException: if db.in_transaction: db.rollback() raise finally: db.close()

2. Резерв и запись попытки — вместе

Повторный ключ не даёт разрешения ещё раз вызвать провайдера. False здесь означает «нового допуска нет»: либо остатка не хватает, либо попытка уже известна. Для API сервиса эти причины лучше возвращать разными статусами.

Критическая часть reserve:

def reserve(path, key, amount): if not key or type(amount) is not int or amount <= 0: raise ValueError("positive integer amount and nonempty key required") with tx(path) as db: old = db.execute( "SELECT reserved FROM attempts WHERE key=?", (key,) ).fetchone() if old is not None: if old[0] != amount: raise ValueError("attempt key reused with another amount") return False # Already recorded: this is NOT permission to resend. changed = db.execute( """UPDATE budget SET held=held+? WHERE id=1 AND spent+held+?<=cap""", (amount, amount) ).rowcount if changed != 1: return False db.execute( "INSERT INTO attempts(key,reserved) VALUES(?,?)", (key, amount) ) return True

Транзакция не охватывает HTTP-запрос. Сначала фиксируем резерв, закрываем транзакцию и только после этого отправляем запрос. Иначе медленная генерация удержит блокировку учёта для всего проекта.

3. Сверка расхода без двойного списания

Подтверждённая стоимость заменяет резерв: held уменьшается на reserved, spent увеличивается на actual. Повторная доставка того же результата не должна повторять списание. Конфликтующая сумма — повод для сверки, не для тихой перезаписи.

Завершающий фрагмент budget.py:

def settle(path, key, actual): if type(actual) is not int or actual < 0: raise ValueError("nonnegative integer actual required") with tx(path) as db: row = db.execute( "SELECT reserved,actual FROM attempts WHERE key=?", (key,) ).fetchone() if row is None: raise KeyError(key) reserved, previous = row if previous is not None: if previous != actual: raise ValueError("conflicting settlement") return False db.execute( "UPDATE budget SET held=held-?,spent=spent+? WHERE id=1", (reserved, actual), ) db.execute("UPDATE attempts SET actual=? WHERE key=?", (actual, key)) return True

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, не измерение тарифов и не готовая распределённая платёжная система.

import sqlite3 import tempfile from concurrent.futures import ThreadPoolExecutor from contextlib import closing from pathlib import Path from threading import Barrier from budget import init, reserve with tempfile.TemporaryDirectory() as folder: path = str(Path(folder) / "budget.db") init(path, 100) start = Barrier(20) def worker(i): start.wait() return reserve(path, f"attempt-{i}", 30) with ThreadPoolExecutor(max_workers=20) as pool: accepted = list(pool.map(worker, range(20))) assert sum(accepted) == 3 with closing(sqlite3.connect(path)) as db: counters = db.execute("SELECT spent,held FROM budget").fetchone() assert counters == (0, 90) print("accepted=3; spent=0; held=90")