Skip to content

Наблюдение и журнал

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

Здоров ли пул

Один вызов отвечает на этот вопрос — факты по моделям и картина по пулу целиком приходят с одного объекта:

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, а на обычной — поход за курируемым списком, который экрану статистики не нужен.