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(rolePK,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) не хардкодится: тянется с gatewayGET {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)