Модуль 12. Дообученная модель в бою: версии, fallback, фидбек
Цель модуля: провести дообученную модель от tuned.uri до продового трафика —
версионировать так, чтобы откат был одной строкой, катить через shadow и A/B,
деградировать до base-модели без падения фичи и превращать фидбек пользователей
в следующую итерацию датасета.
Перед началом: модуль 10 (
tuned.uri) или модуль 11 (свои веса); golden set и regression suite — модуль 8; гигиена данных — модуль 5. Инструменты: песочница сайта (Go 1.21, stdlib); боевые вызовы — аккаунт YC. Трудоёмкость: ~2–2.5 ч (разбор 60 мин · практика 60 мин).
🔥 Инцидент: «ассистент поплыл» — а какой версии был uri?
Прошёл месяц после выката. Ассистент поддержки отвечал коротко и по делу, но сегодня саппорт приносит переписку: ответы стали длинными, вместо инструкций — вежливые общие фразы. Первая реакция — откатиться. На что?
В конфиге сервиса стоит gpt://folder/yandexgpt-lite tuned:assistant. Без
суффикса версии. На прошлой неделе коллега «улучшил датасет» и прогнал тюнинг
с тем же именем — uri теперь указывает на другую модель. Датасет правили
поверх старого файла, гиперпараметры записали в чат. Итог: непонятно, что
отвечало пользователям на прошлой неделе, на что откатываться и из чего
воспроизводить прежнее поведение.
Дообученная модель в проде — не строчка в конфиге, а объект с версиями, маршрутом выката, деградацией и телеметрией — как база данных с миграциями. Модуль собирает этот контур: вызов из бэкенда, версионирование, выкат, fallback и петля фидбека, которая замыкает всё в цикл.
📖 12.1. Вызов из бэкенда: модель как обычная внешняя зависимость
Главный сдвиг перспективы: LLM-эндпоинт для бэкенда — та же внешняя зависимость, что платёжный шлюз: таймаут, retry с backoff, ограничение параллелизма, метрики исходов — всё применимо. Отличие одно: каждый вызов стоит токенов, то есть денег, поэтому ошибки обходятся дороже.
AI Studio отдаёт REST API: нативный контракт (modelUri, messages) и
OpenAI-совместимый (/v1/chat/completions) под любой OpenAI-клиент. Сниппет
модуля использует упрощённую смесь полей — перед боем сверьте имена полей
и адреса с документацией (https://aistudio.yandex.ru). Нативный вызов:
bash# Нативный контракт AI Studio; ключ — из секрета, не из репозитория curl -s "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" \ -H "Authorization: Api-Key ${API_KEY}" \ -d '{ "modelUri": "gpt://folder/yandexgpt-lite tuned:assistant-v3", "completionOptions": {"temperature": 0.3, "maxTokens": 200}, "messages": [{"role": "user", "text": "Как вернуть деньги за курс?"}] }'
Дальше — три обязательных предохранителя на любой вызов:
- Таймаут на весь вызов. В Go —
http.Client{Timeout: 8 * time.Second}. Дефолтное «ждать бесконечно» превращает деградацию облака в деградацию вашего API; быстрый fallback лучше минуты спиннера. - Retry только для транзиентных ошибок. Повторяйте 429/5xx и сетевые
сбои с экспоненциальным backoff (100 → 200 → 400 мс) и джиттером; при 429
уважайте
Retry-After. Ошибки 4xx — кривой запрос, нет прав — повторять бессмысленно: они детерминированы. - Лимит одновременных вызовов. Семафор или пул: без него всплеск трафика превращается в очередь таймаутов (параллелизм ≈ RPS × latency), а каждый таймаут ещё и ретраится — шторм на задымленном эндпоинте.
Два уточнения из практики мультипровайдерных сервисов. Первое — про
Retry-After: уважать его — значит пауза = max(backoff, retry_after), но
с потолком; если провайдер просит паузу больше потолка, а в цепочке есть
куда уходить, умный клиент не ждёт — он переключается на следующую модель,
не сжигая попытки. Второе — лимиты провайдера — это не ваш семафор, а
лимит площадки: параллельные генерации (у некоторых моделей их всего
«две»), запросы в минуту, токены в минуту. Их нужно знать и уважать заранее
(token bucket по RPM/TPM), иначе 429 перестают быть исключением и
становятся повседневностью.
📖 12.2. Версии: суффикс в uri, никаких «latest»
Правило, которое спасает в инциденте из крючка: имя дообученной модели —
всегда с суффиксом версии, и одна версия — один тюнинг. assistant-v3,
а не assistant: повторный тюнинг — это assistant-v4, даже если в датасете
поменялась одна пара. Тогда rollback — правка одной строки конфига на
проверенную версию, а не расследование.
Вторая половина правила — неизменность. Версия модели — это immutable тройка «датасет + код + гиперпараметры»: датасет (версионированный файл), код (промпты, шаблоны, chat template), гиперпараметры (epochs, learning rate, ранг LoRA). Изменился любой элемент — новая версия, старую не трогаем. Как чистить и версионировать пары — модуль 5.
yaml# assistant.yaml — модели сервиса поддержки; деградация слева направо models: chain: - "gpt://folder/yandexgpt-lite tuned:assistant-v3" # текущая версия - "gpt://folder/yandexgpt-lite" # базовая, вечный fallback versions: # журнал: из чего собрана каждая версия assistant-v3: {dataset: "ds-support-2026-10-12.jsonl", epochs: 2, lr: 0.0001} assistant-v2: {dataset: "ds-support-2026-09-28.jsonl", epochs: 2, lr: 0.0001} rollout: tuned_percent: 10 # A/B: hash(user_id) % 100 < 10 → v3 http: timeout_ms: 8000 # бюджет на всю цепочку, не на один ретрай max_retries: 1
Блок versions — журнал воспроизведения: «что менялось между v2 и v3» —
сравнение двух строк, а не память участников. Обратное — алиас «latest» —
гарантия инцидента: модель под кодом меняется молча, заметите по жалобам.
📖 12.3. Маршрут выката: shadow → A/B по hash(user_id) → 100%
Прогнать regression suite из модуля 8 необходимо, но недостаточно: лабораторный набор не покроет реальное распределение запросов, кривые промпты и ошибки интеграции. Поэтому между «eval зелёный» и «прод 100%» — три ступени.
Shadow. Сервис вызывает tuned параллельно с base, но ответ пользователю не показывается — только лог и метрики. Тень ловит то, что не ловит eval: ошибки интеграции, реальную латентность под вашим трафиком, стоимость в токенах. Плата — двойной расход токенов; латентности вызов не добавляет — он асинхронный.
A/B. Часть пользователей получает tuned. Деление — по стабильному хешу идентификатора, не по запросу и не по случайному броску на каждый вызов: один пользователь всегда в одной группе. Иначе он увидит «шатание» стиля между соседними сообщениями, а метрики групп станут несравнимыми.
go// Роутер A/B: стабильно отправляет пользователя в одну и ту же группу. // hash/fnv — стандартная библиотека; хеш детерминирован. func useTuned(userID string, percent int) bool { h := fnv.New32a() _, _ = h.Write([]byte(userID)) // ошибка записи в hash.Hash невозможна return int(h.Sum32()%100) < percent }
Долю поднимайте лесенкой: 1% (дым) → 10% → 50% → 100%, между ступенями —
пауза и прогон метрик: латентность, доля fallback, thumb down, жалобы.
Откат — это уменьшение tuned_percent до нуля в конфиге, без деплоя.
100%. Прежняя версия не удаляется: она остаётся в fallback-цепочке ещё неделю-две, пока новый тюнинг набирает статистику. Если что-то пошло не так — возвращаете долю назад за минуты, а не за час отката.
📖 12.4. Fallback-цепочка: tuned → base → шаблонный ответ
Падение tuned-эндпоинта — плановый режим: квота, работы в облаке, сеть. Пользователь не должен это замечать. Цепочка tuned → base → шаблонный ответ: base жива всегда, шаблон («передал оператору») — последний рубеж.
Именно это демонстрирует сниппет модуля — yagpt-client-go.go. Разбор:
ModelChain []string— версии в порядке деградации: сначалаassistant-v3, затем базовая модель. Цепочка — данные, а не код: добавить ступень или поменять порядок — правка конфига, не логики.- Retry с backoff внутри одной модели.
Completeделает доMaxRetries+1попыток; после каждой неудачи пауза растёт вдвое (1<<attempt× 100 мс: 100 → 200 мс). Повторяются только ошибки, которые могут «поправиться сами», — в моке это 5xx. - Переход к следующей модели. Ретраи исчерпаны — внешний цикл
for _, model := range c.ModelChainидёт дальше. Бюджет времени считается на всю цепочку: три модели по три ретрая — девять таймаутов подряд. - Мок-транспорт.
MockTransportреализуетhttp.RoundTripperи отвечает 503 на tuned-версию и текстом на base — сеть не нужна, сценарий воспроизводим. В бою на этом месте обычный транспортhttp.Client; код клиента не меняется, потому что транспорт внедрён как зависимость.
Последняя строка вывода — «Фича жива»: tuned лежит, пользователю отвечает base. Пользователь получает чуть менее точный ответ вместо ошибки 500. Деградация обязана быть видимой для вас — метка в логе, метрика доли fallback — и незаметной для пользователя.
📖 12.5. Наблюдаемость и фидбек: замыкаем цикл
Без телеметрии инцидент из крючка повторится. Минимальный набор на каждый
вызов: версия модели, которая реально ответила (с fallback это не то же
самое, что «версия в конфиге»); исход ok | retry | fallback;
латентность p50/p99 и токены на входе/выходе — токены это деньги;
user_id или его хеш — без него не разделить A/B-группы; фидбек —
thumb up/down в интерфейсе.
PII-предосторожности. Логи промптов — чувствительные данные: в поддержку пишут телефоны и номера заказов. Сырые тексты — только в защищённое хранилище с доступом кураторов и сроком хранения; в метрики и дашборды — лишь агрегаты (токены, латентность, тема), персональные фрагменты маскируются; в обучение — только пары, прошедшие ручную разметку.
Две детали журнала вызовов, окупающиеся в первый же инцидент. Во-первых,
HTTP-статус — отдельной колонкой, а не текстом ошибки: «у какого
провайдера и когда были 429» становится SQL-запросом, а не grep'ом по
логам. Во-вторых, снапшот тарифа на момент вызова: цены провайдеров
плавают, и «сколько стоил октябрь» должно восстанавливаться по журналу, а
не пересчитываться по сегодняшнему прайсу. Пишите рядом оценку
(estimated_cost) и факт (actual_cost) — расхождение между ними само
подскажет, когда прайс обновился раньше каталога. Попадание в кэш
(модуль 4) логируется сэкономленной суммой, а не нулевым расходом: иначе
отчёт «экономия от кэша» построить не на чем.
Фидбек «плохо» — не мусор, а сырьё: куратор смотрит пару «запрос → плохой ответ», пишет эталон, и пара уходит в очередь на следующий датасет — конвейер из модуля 5. Контур замыкается:
Чтение схемы по кругу. Логи и фидбек после разметки куратором становятся
черновиком датасета и проходят гигиену; на тюнинг идёт только её
выход, причём train и val разведены — пунктирное ребро напоминает:
валидация не должна утекать в обучение. Параллельно golden set даёт
baseline base-модели, и свежий тюнинг сравнивается с ним на eval —
это golden set плюс regression suite из
модуля 8. Развилка: «да» —
shadow → A/B → прод 100%; «нет» — откат: остаёмся на base, причины —
в бэклог датасета. Из прода логи возвращаются в начало круга.
Это не проект «обучили и забыли», а постоянный цикл. Два механизма делают каждый оборот безопасным: immutable-версии (12.2) — всё воспроизводимо; fallback (12.4) — деградация не роняет фичу, пока вы разбираетесь.
📖 12.6. Компромиссы
- Именованные версии vs «latest». Rollback в одну строку против «удобно, пока не поплыло».
- Shadow vs сразу A/B. Тень удваивает токены, но проверяет интеграцию без риска для пользователей; без неё A/B встречает падения на проде.
- Быстрый набор статистики vs малая доля A/B. Большая доля даёт данные быстрее, но при скрытой проблеме затрагивает больше пользователей.
- Агрессивные ретраи vs латентность. Больше попыток — выше шанс успеха, но дольше худший случай и сильнее шторм на задымленном эндпоинте.
- Полные логи пар vs PII. Тексты ускоряют разбор и датасет, но тянут требования защиты; агрегаты безопасны, но не объясняют «почему плохо».
- Fallback на base vs честная ошибка. Фича жива и UX стабилен, но ответ другого тона; метка degraded в логе и метрике обязательна.
💻 Практика
Все упражнения — в песочнице: откройте сниппет yagpt-client-go.go, он
самодостаточен (мок-транспорт, сеть не нужна). Время — ориентир.
-
Прочитать деградацию (10 мин). Запустите сниппет и разберите вывод: две попытки tuned с паузами, исчерпание ретраев, переход к base, «Фича жива». Какие строки вы искали бы в логах инцидента?
-
Посчитать бюджет ретраев (10 мин). Поставьте
MaxRetries: 2и до запуска предскажите число вызовов tuned и сумму пауз (3; 700 мс). Запустите, сверьте. -
Оживить base ретраем (15 мин). Добавьте в
MockTransportсчётчик: base отвечает 500 на первый запрос и 200 дальше. Убедитесь, что retry спасает и без перехода по цепочке. Почему 400 ретраить бессмысленно? -
Третья ступень: шаблон (15 мин). Добавьте в
ModelChain«модель»template:refund-stub, чей мок возвращает заготовку «Я передал вопрос оператору…». Уроните base — и покажите: пользователь всё равно получает ответ. -
Ответьте письменно (10 мин). Почему 429 ретраится с оглядкой на
Retry-After, а 403 — нет? Что залогировать при исходеfallback, чтобы через месяц ответить на вопрос из крючка — какая версия отвечала?
🧩 Quiz "Проверь себя"
✅ Итоги модуля
- Настроить вызов модели как зависимости: таймаут, retry с backoff, лимит.
- Версионировать модель суффиксом uri и вести журнал immutable-тройки «датасет + код + гиперпараметры».
- Объяснить, почему «latest» в проде делает откат невозможным.
- Провести версию по маршруту shadow → A/B по hash(user_id) → 100%.
- Построить цепочку tuned → base → шаблон и показать в песочнице «фича жива».
- Перечислить телеметрию вызова и PII-правила для логов промптов.
- Нарисовать итеративный цикл по диаграмме и назвать место фидбека в нём.
🔗 Мостик к Модулю 13
Оружие собрано: вы умеете готовить датасет, оценивать до выката, тюнить в облаке, версионировать, катить лесенкой и деградировать без падения фичи. Осталось сделать всё это своими руками в одном проекте. В модуле 13 — капстоун: за уик-энд вы пройдёте весь цикл на выбранном домене, от ста пар до отчёта «что улучшилось, что деградировало и какой следующий шаг».