Пул моделей и вызовы
Пул моделей
Пул — это курируемый список бесплатных 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 хранит немного своего: скачанный пресет, платный каталог, время последней проверки обновлений — а если базу вы не назвали, то ещё и сам список моделей, на котором он работает, и журнал вызовов. Всё это лежит в одном каталоге на машине. Какой это каталог, решается по порядку, до первого, в который получается писать:
home=— этого брокера, если вы его задали;$LLMBROKER_HOME— этого процесса, если переменная задана;$XDG_CACHE_HOME/llmbroker, а без этой переменной — платформенный кэш:~/Library/Caches/llmbrokerна macOS,~/.cache/llmbrokerна Linux,%LOCALAPPDATA%\llmbrokerна Windows;- каталог во временном, свой на пользователя, — последнее, что остаётся.
То есть $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.