ADR-0061: Динамический выбор LLM-модели по ролям

Status: accepted Date: 2026-06-02 Deciders: Максим Белканов, платформенная команда

Контекст

Модель LLM была зашита в окружение и читалась один раз при старте каждого процесса (LLM_MODEL_CHAR, LLM_MODEL_MATCH, ASSIGNMENT_RESOLVER_LLM_MODEL, LLM_MODEL_VALUENORM, TAXONOMY_BUILDER_LLM_MODEL, ESTIMATE_ASSISTANT_LLM_MODEL, LLM_MODEL_EMBEDDING, плюс пулы CHARNORM_LLM_MODELS / CATEGORY_CLASSIFIER_LLM_MODELS). Смена модели требовала передеплоя всех воркеров. При упоре в лимиты gateway lgm.tracium.ru это слишком медленно: нужна возможность переключать модель на лету, отдельно по каждому потребителю, и видеть выбор сразу во всех процессах (api-server + воркеры — отдельные бинарники).

Решение

Вводим runtime-конфигурацию модели по «ролям» (роли == компоненты квоты в platform/llm/context.go плюс embedding):

  • Таблица llm_model_config (role PK, models TEXT[], audit). Отсутствие строки или пустой массив = «использовать env-дефолт» (поведение не меняется до первой правки).
  • Порт ModelConfigStore (pg-реализация) + обёртка CachedModelConfig: атомарный in-memory снапшот, Snapshot() — hot-path-безопасный, без обращения к БД. Refresh вызывается извне.
  • Применение во всех процессах: периодический refresh-тикер (~30 c, фолбэк) и Postgres LISTEN/NOTIFY на канале llm_model_config_changed — при записи через Set все процессы получают пуш и обновляют снапшот мгновенно. Fail-open: при ошибке чтения держим последний снапшот, дальше — env-дефолт.
  • ModelResolver инжектится через uber/fx во все 9 потребителей; модель резолвится на каждый батч/вызов (PoolFor(role) / ModelFor(role)), а не захватывается строкой при старте. У каждого потребителя — защитный фолбэк на прежнее config-значение, если резолвер пуст.
  • Каталог доступных моделей (ModelCatalog) не хардкодится: тянется с gateway GET {LLM_BASE_URL}/models через OpenAI-совместимый SDK, кешируется в памяти (~5 мин refresh), last-good в llm_available_models (JSONB), фолбэк-цепочка память → last-good → курируемый статический список.
  • Admin API (Bearer на edge, актор из X-Tr-Admin-Email): GET /api/v1/admin/llm/models (роли + эффективное значение + источник + каталог), PUT /api/v1/admin/llm/models/{role} (валидация роли и модели по каталогу, allow_unknown для нестандартной модели).

Последствия

Плюсы

  • Смена модели без передеплоя, отдельно по каждому потребителю; применение почти мгновенное.
  • Поведение по умолчанию неизменно (env-дефолты сохранены, fail-open).
  • Единый паттерн с уже существующим cachedPauseStore (kill-switch) — низкая когнитивная нагрузка.

Минусы

  • Модель теперь резолвится на каждый батч (минимальная стоимость — чтение атомарного указателя).
  • Дополнительное LISTEN/NOTIFY-соединение на процесс (с reconnect-циклом).
  • Смена embedding-модели инвалидирует уже посчитанные pgvector-векторы — отмечается предупреждением в API/UI, не блокируется.

Нейтральные последствия

  • Тип CachedModelConfig экспортирован, чтобы admin-хендлер мог быть собран в пакете platform/di.

Рассмотренные альтернативы

Только опрос (без NOTIFY)

Проще, но задержка применения до интервала опроса. Отклонено: нужна near-instant реакция при упоре в лимиты. NOTIFY оставлен как primary, опрос — как фолбэк.

Поднять все модели в config.LLM и хранить override там

Не даёт runtime-изменения без рестарта. Отклонено.

Ссылки

  • Спека: docs/superpowers/specs/2026-06-02-admin-llm-model-config-and-worker-telemetry-design.md
  • План: docs/superpowers/plans/2026-06-02-llm-model-config.md
  • Зеркало паттерна: backend/internal/platform/llm/global_pause.go (cachedPauseStore)