Наблюдение и журнал
Два вопроса и два ответа: что с пулом прямо сейчас — снимок, что происходило раньше — журнал вызовов. Оба работают на любой установке, от скрипта до кластера.
Здоров ли пул
Один вызов отвечает на этот вопрос — факты по моделям и картина по пулу целиком приходят с одного объекта:
snap = broker.snapshot()
print(f"{snap.providers_usable} of {snap.providers_total} providers usable")
if snap.degraded:
print("переключаться некуда")
for key in snap.missing_keys:
print(f"{key.api_key_ref} держит {', '.join(key.entry_names)}")
print(key.help) # где его взять
for key in snap.direct_missing_keys: # ваши собственные модели, вызываемые по имени
print(f"{key.api_key_ref} — direct({key.entry_names[0]!r}) не сработает")
print(key.help)
for name, llm in snap.items(): # по-прежнему отображение имя -> факты о модели
print(name, llm.has_key, llm.cooldown_until)
В асинхронном коде это await broker.snapshot(). Одного объекта хватает и на
админский экран целиком: строки по моделям и вердикт по пулу приходят вместе,
вторым запросом их добирать не надо. Поля — в
PoolSnapshot.
direct_missing_keys намеренно отделён от missing_keys: объявленная вами модель
никогда не маршрутизируется, поэтому отсутствующий у неё ключ не может ухудшить
пул, а появившийся в пуле провайдер — не может её починить. help у обоих один и
тот же: из вашего блока [keys], если вы его написали, иначе из курируемого
каталога.
Единица счёта — провайдер (api_key_ref), а не модель: две записи на одном ключе
это один лимит и один домен отказа, поэтому считаются один раз. degraded
истинно, пока рабочих провайдеров меньше двух: при одном пул ещё отвечает, но при
rate limit переливаться некуда, при нуле — уже не отвечает. Реестр, в котором
пула нет вовсе, не деградировал: degraded там ложно.
Ключ, отозванный в вашем бэкенде секретов, выпадает из счёта на ближайшей перестройке пула — их четыре, и все перечислены в Что процессы делят, а что нет. Модель, которую вы выключили сами, продолжает считать своего провайдера: этот вердикт виден в строке самой модели.
На что вешать алерт
Опрашивать snapshot() по расписанию не нужно: когда здоровье пула меняется,
llmbroker сам пишет об этом строку в лог — в логгер llmbroker.broker. Алерт
поэтому вешается не на метрику, а на три строки; если ваш сбор логов умеет искать
подстроку, этого достаточно.
Пул потерял запасной вариант — рабочий провайдер остался один. Пул ещё
отвечает, но при первом же rate limit переливаться будет некуда. Уровень ERROR:
pool degraded, no failover left: 1 of 3 providers usable — no key for GEMINI_API_KEY
Пул не может обслуживать вообще — рабочих провайдеров не осталось. Уровень
ERROR:
pool cannot serve any request: no provider has a key — no key for GROQ_API_KEY, GEMINI_API_KEY
Провайдеры есть, но все модели сейчас в кулдауне — ключи на месте и пул
укомплектован, просто в эту минуту отвечать некому: все упёрлись в лимиты.
Означает, что моделей в реестре мало для вашего трафика. Уровень WARNING, не
чаще раза в минуту:
pool under-provisioned: all LLMs are COOLING — add more LLMs to the registry
Первые две строки называют ключи, которых не хватает, — по ним видно, что чинить.
Строка пишется на смену состояния, а не на каждый вызов. Пул, который просто лежит, залил бы вам лог; поэтому одна строка на переход. При этом переход «остался один провайдер» и переход «не осталось ни одного» — разные события, и вторая строка появится после первой, а не вместо неё.
Возвращение в норму — один INFO:
pool recovered: 3 of 3 providers usable
Пишется он один раз, когда рабочих провайдеров снова стало двое или больше; третий и последующие отдельной строки уже не стоят.
Сам по себе незаполненный ключ — никогда не алерт: двух рабочих провайдеров может быть ровно столько, сколько вы и выдали.
Журнал вызовов
Журнал самоочищается; глубина хранения — параметр retention бэкенда журнала
(по умолчанию 90 дней), см. Серверы и кластеры. Прочитать:
broker.calls(limit=50).
Одна запись на одну попытку вызова — поля в
Call, и в score лежит поставленная вами
оценка качества (или None). Сузить чтение можно по времени, по операции и по
одному из идентификаторов, с которыми вы звали (см.
Трассировка одного запроса):
from datetime import UTC, datetime, timedelta
week_ago = datetime.now(UTC) - timedelta(days=7)
broker.calls(limit=50, since=week_ago, operation="summarize")
Фильтры сужают вызовы, но не оценки, свёрнутые на них: вердикт, записанный через месяц после вызова, всё равно на нём виден, а если вызов оценили дважды — виден более новый вердикт.
since включает границу. В MongoDB — с точностью до миллисекунды: даты BSON не
хранят ничего мельче, поэтому и сохранённые отметки времени, и граница
округляются вниз до целых миллисекунд.
Трассировка одного запроса
ask, chat и stream принимают trace_id= — ваш собственный идентификатор,
который llmbroker пишет в каждую журнальную запись этого вызова и никак не
интерпретирует. Передайте то, чем ваша система уже пользуется, — id запроса или
id задачи, — и журнал сойдётся с вашими логами без второй схемы корреляции.
broker.ask("Перескажи этот пункт", operation="summarize", trace_id=request_id)
Один вызов — обычно несколько записей. Failover журналирует каждую сделанную
попытку, и все они несут один и тот же trace_id — ради этого поле и
существует: в трассе остаются две модели, упёршиеся в лимит до того, как
ответила третья, а это и есть объяснение, почему запрос занял столько времени.
Ответившая попытка — та запись, у которой status равен CallStatus.OK; стрим,
оборвавшийся уже после первых дельт, ею не является: он не завершился.
from llmbroker import CallStatus
rows = broker.calls(limit=200, trace_id=request_id)
answered = next((c for c in rows if c.status is CallStatus.OK), None)
Фильтр применяется внутри хранилища, поэтому limit ограничивает подошедшие
записи, а не просмотренные: трасса, сделанная час и миллион вызовов назад, всё
равно вернётся целиком. В базах колонка проиндексирована; у файлового хранилища
индекса нет по устройству, так что там фильтр даёт правильность, а не скорость.
Передайте call_id=, чтобы поднять одну попытку, — это ровно значение
result.call_id.
Для журнала trace_id — просто поле, которое llmbroker хранит и по которому
фильтрует, так что сгруппировать им несколько вызовов вам никто не помешает. Но
трасса задумана как идентификатор одного вызова, и оценка по
ней исходит именно из этого: она находит один вызов, а не все.
Оба идентификатора — способ оценить вызов задним числом, и call_id из них
точный.
Статистика за период
stats() считает записи вызовов по моделям за период — сколько вызовов сделала
каждая модель и чем они закончились:
from llmbroker import CallStatus
for name, s in broker.stats(since=week_ago).items():
failed = s.total - s.by_status.get(CallStatus.OK, 0)
print(name, s.total, failed, s.last_status, s.last_at)
Поля — в LLMStats.
by_status содержит только те статусы, которые реально встретились в периоде,
поэтому неудачи считайте вычитанием из total, а не сложением остальных
статусов. Один статус не относится ни к тем, ни к другим: SUPERSEDED — это
модель, которая ещё отвечала, когда более быстрый сосед уже ответил; о самой
модели это не говорит ничего, так что если вам нужен счётчик неудач, вычтите и
его. Оценка вызова не добавляет записи, поэтому завысить счётчики она не может.
operation= считает только одну операцию.
Что считать неудачей, какой длины брать окно и как показывать модель без вызовов за период — решаете вы; llmbroker возвращает счётчики и никакой политики.
limit (по умолчанию 1000) ограничивает число прочитанных записей — это защита
от аномального периода вроде шторма повторов, а не сам период. Он должен быть не
меньше 1. Если сумма total в точности равна limit, период мог быть обрезан:
поднимите лимит или сократите окно.
since должен быть с таймзоной (datetime.now(UTC), а не datetime.now()):
наивное значение отвергается, а не угадывается — иначе окно сдвинулось бы на
смещение вашей машины.
calls() и stats() читают только журнал: в отличие от snapshot(), они не
инициализируют пул моделей, поэтому экран отрисуется и на установке, где реестр
ни разу не синхронизировали. Для этого создавайте брокер напрямую: вход в
контекстный менеджер (with Broker(...) as broker) инициализирует пул сразу — на
установке, которая ничего не тянет сама, это EmptyRegistryError, а на обычной —
поход за курируемым списком, который экрану статистики не нужен.