Skip to content

Пул моделей и вызовы

Пул моделей

Пул — это курируемый список бесплатных LLM, и вы получаете его, попросив брокер:

broker = llmbroker.Broker()

Ничего создавать и ничего поддерживать не нужно. Сформируйте заготовку ключей и заполните те, которые легко достать:

llmbroker env freetier > .env

llmbroker env печатает подсказку над каждым ключом — где его получить, см. CLI. Модель без ключа просто остаётся неактивной, это не ошибка.

Провайдера, не терпящего параллельных запросов с одним ключом, ограничивает parallel в его записи. Для пула это решает курируемый список, а не вы — файл принадлежит llmbroker, см. ниже; для своей модели это поле LLMConfig, который вы объявляете сами.

Платную модель можно назвать по имени: Broker(direct=["opus"]), дальше broker.direct("opus").ask(...). Она вызывается напрямую и в пул не попадает никогда — см. Прямые вызовы модели.

Где живёт список

Класть его никуда не нужно. Брокер держит список в собственном каталоге llmbroker и там же его обновляет. Каталог переносится переменной LLMBROKER_HOME: это то, что нужно контейнеру без доступа к кэшу.

Этот файл пишет llmbroker, а не вы: обновление перегенерирует его целиком, и в нём лежит только пул — модель, которую вы зовёте по имени, объявляется в коде. Указать брокеру свой файл со списком нельзя — список приходит именем курируемого пресета и никак иначе.

Держать список не в каталоге, а в БД, общей на несколько процессов, или вообще наполнять реестр самому — см. Серверы и кластеры.

Какая модель пробуется первой

Это решает не порядок строк, а weight:

[[llms]]
name        = "google-gemini-3.5-flash-lite"
base_url    = "https://generativelanguage.googleapis.com/v1beta/openai"
model       = "gemini-3.5-flash-lite"
api_key_ref = "GEMINI_API_KEY"
weight      = 0.75

Вес — число от 0 до 1: насколько хороших ответов вы ждёте от этой модели, по той же шкале, что и записываемые вами оценки. Больше — раньше в очереди. По умолчанию 0.0, поэтому запись, добавленная без веса, пробуется после всех взвешенных: проставьте вес своим записям, если хотите, чтобы они соревновались на равных.

Это отправная точка, а не жёсткий порядок. Каждая оценка, записанная через record_quality(), сдвигает модель с её веса к тому, что она зарабатывает на деле, а когда оценок накопится достаточно, вес перестаёт учитываться совсем: порядок определяют только оценки, как бы вы ни расставили модели вначале. Модель, которую ещё никто не оценивал, стартует там, куда её поставили, а не со дна, откуда ей было бы не выбраться.

Ключи не обязаны лежать в .env

AWS Secrets Manager, Vault, БД или своё хранилище — см. API-ключи.

Держать пул свежим

Провайдеры приходят и уходят, курируемый пресет за ними следует. Делать для этого ничего не нужно. Когда обновление хочется форсировать — это один вызов, и он возвращает отчёт о сделанном:

report = broker.sync("freetier")         # имя пресета — единственный вызов в сеть
print(llmbroker.format_report(report))   # или отправьте отчёт в свой админский канал

Но обычно это не нужно. Курируемый список поддерживает себя сам — без аргумента и без задачи в расписании: провайдеры закрывают бесплатные endpoint'ы без предупреждения, поэтому список, который перестал обновляться, постепенно перестаёт работать. Брокер перепроверяет его примерно раз в сутки, лениво — на вызове, который вы и так делали, никогда по таймеру, так что простаивающий процесс не делает ничего.

Обновление best-effort: если каталог недоступен, брокер напишет предупреждение в лог и продолжит на том конфиге, который уже есть. Явный вызов broker.sync(...) наоборот бросает исключение — вы его запросили, вам и обрабатывать.

Проверка, которая ничего не изменила, ничего и не трогает. Список переписывается, только если курируемый действительно сдвинулся, — проверка, не нашедшая новостей, оставляет его побайтово тем же, вместе с mtime.

Чтобы не следовать ничему — потому что реестр вы наполняете сами — скажите об этом; интервал проверки тоже ваш:

llmbroker.Broker(sync=None)              # ничего не обновляется
llmbroker.Broker(sync_interval=3600)     # проверять раз в час
llmbroker.Broker(sync_interval=None)     # сам не проверяет — синхронизацию запускаете вы

sync_interval=None — для процесса, которому нельзя ходить в сеть во время обслуживания: он останавливает все автоматические загрузки, включая ту, что наполняет пустой реестр на старте, и свежесть списка становится вашей заботой — см. Серверы и кластеры.

Отчёт о синхронизации. Поля — в SyncReport, а понимать в нём стоит три вещи:

  • Ожидающий ключ (pending key) — модель ждёт ключ, который вы не задали. Это безобидно: она остаётся неактивной, а пул работает на остальных. В отчёте написано, где взять ключ.
  • Удалённая запись (removed) — модель, которой больше нет в курируемом списке. Она уходит независимо от того, есть ли у вас к ней ключ: именно список решает, по каким моделям маршрутизируется пул, а из списка модель уходит только тогда, когда её уже нельзя вызвать. Если она вернётся позже, ничего не потеряно: ключ остался в хранилище секретов, а всё, что о модели узнали, выводится из вашего журнала вызовов.
  • Неиспользуемый ключ (unused key) — ключ, который у вас реально есть и на который в вашем конфиге больше никто не ссылается. Отзывать ли его у провайдера — ваше решение, а ваша собственная запись, которая его ещё использует, убирает этот совет вовсе. Провайдер, ключа к которому у вас никогда не было, просто тихо исчезает — отзывать нечего.

Удаление никогда не проходит молча: именно потеря провайдера двигает счётчик пригодных провайдеров, и тревога по пулу срабатывает на переходе к одному и к нулю.

Где llmbroker хранит своё состояние

llmbroker хранит немного своего: скачанный пресет, платный каталог, время последней проверки обновлений — а если базу вы не назвали, то ещё и сам список моделей, на котором он работает, и журнал вызовов. Всё это лежит в одном каталоге на машине. Какой это каталог, решается по порядку, до первого, в который получается писать:

  1. home= — этого брокера, если вы его задали;
  2. $LLMBROKER_HOME — этого процесса, если переменная задана;
  3. $XDG_CACHE_HOME/llmbroker, а без этой переменной — платформенный кэш: ~/Library/Caches/llmbroker на macOS, ~/.cache/llmbroker на Linux, %LOCALAPPDATA%\llmbroker на Windows;
  4. каталог во временном, свой на пользователя, — последнее, что остаётся.

То есть $XDG_CACHE_HOME перекрывает платформенный кэш, а home= и $LLMBROKER_HOME перекрывают и его: так два проекта на одной машине держат полностью раздельное состояние.

Этот порядок решает, куда состояние пишется. Чтение того, что уже лежит на машине, от возможности писать не зависит: укажите home= на каталог только для чтения — например, на каталог, примонтированный в контейнер, — и вы получите именно то, что в нём лежит, а не содержимое другого каталога, в который пишется.

Что бы с этим каталогом ни случилось, брокер не сломается: удалите его или запуститесь там, где писать некуда, — он продолжит работать. Он скачает заново, а если не пишется ни один из каталогов, включая временный, состояние живёт только в памяти этого запуска. Даже совсем без сети первый запуск стартует с копии пресета, вшитой в пакет.

Но безвредно это только для скачанного: журнал вызовов, а вместе с ним всё, что пул о моделях выучил, восстановить неоткуда. В режиме без базы журнал лежит в этом же каталоге, так что удаление каталога стирает историю вызовов и накопленные оценки качества окончательно — модели начнут с курируемых весов, как на первом запуске. Если история вам нужна, держите журнал в базе — см. Серверы и кластеры.

Настоящий каталог нужен ровно одному — обновлению, которое затем и существует, чтобы оставить копию: если писать некуда, оно падает и говорит об этом — сделайте каталог записываемым или запускайтесь с sync_interval=None и ничего не качайте автоматически.

Один журнал на машину в режиме без базы — это сознательно: ключи там берутся из окружения, значит лимиты, которые он помнит, действительно относятся к одному пулу, а журнал, размазанный по рабочим каталогам, заставлял бы каждый запуск заново нарываться на тот же 429. Нужен отдельный журнал проекту — передайте home=.

Чтобы поддерживать реестр в базе свежим из собственной задачи деплоя, см. Серверы и кластеры.

Вызов брокера

broker = llmbroker.Broker()

reply = broker.ask("Переведи на английский: Привет мир")
print(reply.text)

# Полный messages API
reply = broker.chat([
    {"role": "system", "content": "Отвечай кратко."},
    {"role": "user",   "content": "Что такое Python?"},
])

Любой вызов принимает и trace_id= — ваш идентификатор запроса или задачи: llmbroker кладёт его в журнальные записи этого вызова и никак не интерпретирует, так что журнал сходится с вашими логами. См. Трассировка одного запроса.

Чтобы печатать ответ по мере написания, а не целиком, пул умеет стримить — async for delta in broker.stream(...), только async, см. Стриминг.

Закрывать брокер в скриптах не нужно; когда всё же нужно — см. Серверы и кластеры.

Сколько ждать ответа

try:
    reply = broker.ask("Вопрос", wait=5.0)   # максимум 5 секунд от начала до конца
except llmbroker.NoLLMAvailableError:
    print("Никто не ответил за отведённое время")

wait покрывает обе половины вызова: ожидание свободной модели и сам ответ. Провайдера, который к концу бюджета не сказал вообще ничего, брокер бросает и на короткое время отставляет в сторону: для вас он был неотличим от мёртвого, и следующий вызов не должен тратить тот же бюджет, чтобы выяснить это заново. Без wait одна попытка ограничена только внутренним потолком в 60 секунд.

Кроме того, модель, не уложившаяся в ваш бюджет, перестаёт быть первым выбором для столь же коротких бюджетов — и это переживает короткую паузу: следующий вызывающий получит соседа, а не ту же ловушку: за находку платит один вызов, а не все. Насовсем ничего не отключается: вызывающие с более щедрым бюджетом по-прежнему идут к этой модели первыми, она используется, когда других не осталось, а первый её удачный ответ метку снимает.

wait=0 — единственное исключение: это «не вставать в очередь», а не «ответить мгновенно»: пробуются все модели, свободные прямо сейчас, но своего дедлайна на ответ вы не задаёте.

Спросить несколько моделей сразу

reply = broker.ask("Вопрос", fastest_of=2)   # стартуют две модели, побеждает первый ответ

fastest_of=N запускает на одном вызове до N разных моделей и оставляет ту, что ответила первой; остальные отбрасываются. Это обмен провайдерской квоты на скорость — за выброшенные ответы вы всё равно заплатили запросом, — поэтому по умолчанию он выключен и нужен там, где медленный ответ обходится дороже лишнего запроса. N — это максимум: если свободных моделей сейчас меньше, меньше будет и дорожек. Ваш wait на это число не умножается: у всех дорожек один общий бюджет.

Есть и второй, куда более узкий случай, который брокер берёт на себя сам. Когда модель после сбоя была отставлена в сторону и её пауза только что истекла, вызов, который пробует её снова, — это лотерея: вернулась она или нет, ещё никто не знает. Такой вызов идёт параллельно с другой доступной моделью, если она есть, чтобы проверка не оставалась одна на вашем пути к ответу; ответом становится тот из двух, кто ответил первым. Больше ничего параллельным не делается: пул из здоровых моделей по-прежнему отвечает на один вызов одним запросом.

reply = broker.ask("Вопрос", parallel_recovery=False)  # никогда не тратить второй запрос

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

Стрим закрепляется за той моделью, которая выдала первый кусок текста, и остаётся с ней до конца — двух склеенных ответов вы не получите никогда. Обе опции относятся только к маршрутизируемому пулу: модель, которую вы зовёте по имени через direct(), — это одна модель, и к ней они неприменимы.

Запрос JSON по схеме

reply = broker.ask(
    "Собери карточку для слова 'tenacious'",
    operation="card",
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "card", "schema": MY_SCHEMA, "strict": True},
    },
)

response_format без изменений уходит той модели, которая отвечает. И синхронный, и асинхронный клиенты принимают его в ask и chat; асинхронный — ещё и в stream. Это собственное значение провайдера в стиле OpenAI; llmbroker его не читает.

Брокер его маршрутизирует, но не гарантирует. Пул разнородный: часть моделей соблюдает строгую схему при каждой попытке, часть принимает параметр и отвечает в своей форме. Отличить одно от другого llmbroker не может — для этого надо читать ответ по вашей схеме, а смысл схемы принадлежит вам. Поэтому продолжайте проверять то, что пришло.

Эта же проверка и есть решение. Верните её как оценку качества с operation= для этой задачи — и модели, которые игнорируют схему, окажутся в конце очереди именно для неё, через уже существующее ранжирование, без новых механизмов. По сравнению с просьбой отдать JSON в промпте — а это то, что вы сделали бы иначе — параметр строго выигрывает: от игнорирующих моделей придёт тот же ответ, а от остальных — точно соответствующий схеме.

Модель, ответившая не по схеме, ничего не нарушила: это обычный успешный вызов, её не «остужают» и failover за ним не идёт. Замеренное поведение курируемого бесплатного пула записано в specs/reference/freetier-providers.md.

Это про пул. Модель, до которой вы дошли через direct(), принимает вместо этого произвольные параметры запроса — потому что вы её назвали.

Когда ответить некому

Пул перебирает модели, пока одна не ответит. Модель, вернувшая HTTP 200 без текста и без вызовов инструментов, не ответила — брокер переключается на следующую, как при любом другом испорченном ответе, поэтому ответ, не сказавший ничего, никогда не доходит до вас как успешный. Если не ответил никто, вызов поднимает NoLLMAvailableError, и разбирать текст сообщения не нужно: причина лежит в полях.

try:
    reply = broker.ask("Вопрос", wait=5.0)
except llmbroker.NoLLMAvailableError as exc:
    if exc.retry_at is not None:
        retry_after(exc.retry_at)          # к этому моменту кто-то освободится сам
    else:
        alert(f"пул не обслуживает: {exc.reason}")

reason — короткая строка, различающая пять непохожих ситуаций:

reason что случилось что делать
empty_pool в реестре нет ни одной записи наполнить его — см. Держать пул свежим и Серверы и кластеры
no_keys записи есть, но ни одного ключа этот вызывающий предъявить не может задать ключи — см. API-ключи
all_disabled все модели выключены вручную включить хотя бы одну
timeout ваш wait истёк — в очереди за свободной моделью или уже на ответе повторить с бо́льшим бюджетом или позже
excluded все кандидаты выбыли на этом конкретном запросе — например, провайдер отверг ключ каждого из них смотреть в журнал вызовов: там причина по каждой попытке

Первые три — это «установка не настроена», и чинит их человек, а не повтор. Последние два относятся к одному запросу, и следующий может пройти.

retry_at заполняется только там, где известно, когда модель вернётся сама, и только когда обслужить вас сейчас некому: это момент, когда у ближайшей остывающей модели истечёт кулдаун. При empty_pool, no_keys, all_disabled его нет — ждать нечего. У timeout он есть, когда остывает весь пул, и его нет, когда какая-то модель свободна прямо сейчас: кончились ваши часы, а не пул, и повторять лучше сразу, а не выжидая.

Ошибка в самом запросе — это не NoLLMAvailableError. Если каждая перепробованная модель ответила «запрос неверен» (4xx, кроме 401/403/429 — скажем, 400 на неправильной схеме tools), моделей вокруг это не касается: наверх уходит ProviderError с полями .status и .detail — код и кусок тела ответа, то единственное, по чему можно действовать. Это тот же класс, что поднимают прямые вызовы, так что ловить обе ошибки можно одним except:

try:
    reply = broker.ask(prompt, wait=5.0)
except llmbroker.NoLLMAvailableError as exc:
    ...                                    # отвечать некому — см. выше
except llmbroker.ProviderError as exc:
    log.error("запрос отвергли все: HTTP %s%s", exc.status, exc.detail)

Модели при этом не охлаждаются — следующий, исправленный запрос пойдёт к ним как обычно.

Оценка качества

Оценивайте ответы — брокер выучит, какие модели хороши для каких задач:

reply = broker.ask("Кратко перескажи этот пункт договора", operation="summarize")
reply.record_quality(0.9)   # 1.0 — хороший ответ, 0.0 — неудачный; вне [0, 1] — ValueError

Оценки копятся по паре (модель, операция): модель, стабильно слабая на данной операции, уходит в конец очереди, а накапливаясь, они вытесняют вес, с которого она начинала. Демоция мягкая — если других моделей нет, она всё равно ответит — и снимается новыми хорошими оценками; отдельного «сброса» нет. Вызовы без operation= попадают в один общий bucket.

Оценить позже. Вердикт часто приходит уже после вызова — пользователь просматривает созданный LLM артефакт на следующий день. Оценка всегда называет вызов, к которому относится, и назвать его можно двумя способами. Передайте свой идентификатор как trace_id= во время вызова и оцените по нему позже:

broker.ask("Кратко перескажи этот пункт", operation="summarize", trace_id=document_id)

# ...через день, когда приходит отзыв пользователя
broker.record_quality(0.0, trace_id=document_id)

Либо сохраните reply.call_id и оцените одну конкретную попытку: broker.record_quality(0.0, call_id=saved_call_id). Нужен ровно один из двух.

Ключ нужен, когда самого вызова у вас на руках уже нет. Если он ещё есть — в том числе handle, который вернул стрим, — его собственный record_quality(...) обойдётся без ключа и без чтения журнала. У стрима он становится доступен, когда ответ закончился, а не пока он ещё идёт.

Модель и операция читаются из самого вызова, так что хранить их не нужно. Неудавшиеся попытки — модель, упёршаяся в лимит до того, как ответила другая, — не оцениваются: судить было нечего, и оценка достаётся той попытке, которая ответила.

Одна оценка — один вызов, и искать его будут за последнюю неделю. Два ограничения, о которых стоит знать заранее:

  • trace_id — это идентификатор одного вызова. Повесить его на несколько llmbroker не мешает — в журнале они так и сгруппируются, — но оценка называет ровно один вызов, и им окажется последний ответивший под этой трассой. Если записей под трассой окажется заметно больше, чем бывает у одного вызова, llmbroker ещё и предупредит об этом в лог. Нужно оценить конкретный вызов из нескольких — сохраните его call_id, он ровно для этого.
  • Вызов ищется среди сделанных за последние 7 дней. Оценка более старого вызова не имеет смысла: окно качества пересобирается из свежего хвоста журнала, и такой вердикт не пережил бы ближайшую перестройку пула. Поэтому вместо того чтобы ради исчезающего эффекта перебирать журнал за квартал, llmbroker отказывается: UnknownCallError. Его же вы получите, если по ключу вообще ничего не нашлось или ни одна попытка не ответила, — молча оценка никуда не уходит никогда.

Всё это про оценку по ключу. Оценка через сам вызов — reply или handle стрима — ничего не ищет и ничем не ограничена по времени.

Оценивайте через тот же caller, который делал вызов: scope берётся из объекта caller, а не из ключа, поэтому вызов со scope оценивается через broker.for_scope(user).record_quality(...) — отправленная через «голый» брокер оценка окажется без scope.

Пороги и размер окна оценок настраиваются — см. Optimizer.