Модуль 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": "Как вернуть деньги за курс?"}]
  }'

Дальше — три обязательных предохранителя на любой вызов:

  1. Таймаут на весь вызов. В Go — http.Client{Timeout: 8 * time.Second}. Дефолтное «ждать бесконечно» превращает деградацию облака в деградацию вашего API; быстрый fallback лучше минуты спиннера.
  2. Retry только для транзиентных ошибок. Повторяйте 429/5xx и сетевые сбои с экспоненциальным backoff (100 → 200 → 400 мс) и джиттером; при 429 уважайте Retry-After. Ошибки 4xx — кривой запрос, нет прав — повторять бессмысленно: они детерминированы.
  3. Лимит одновременных вызовов. Семафор или пул: без него всплеск трафика превращается в очередь таймаутов (параллелизм ≈ 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. Контур замыкается:

Итеративный цикл дообучения: логи → датасет → тюнинг → eval → A/B → прод

Чтение схемы по кругу. Логи и фидбек после разметки куратором становятся черновиком датасета и проходят гигиену; на тюнинг идёт только её выход, причём 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, он самодостаточен (мок-транспорт, сеть не нужна). Время — ориентир.

  1. Прочитать деградацию (10 мин). Запустите сниппет и разберите вывод: две попытки tuned с паузами, исчерпание ретраев, переход к base, «Фича жива». Какие строки вы искали бы в логах инцидента?

  2. Посчитать бюджет ретраев (10 мин). Поставьте MaxRetries: 2 и до запуска предскажите число вызовов tuned и сумму пауз (3; 700 мс). Запустите, сверьте.

  3. Оживить base ретраем (15 мин). Добавьте в MockTransport счётчик: base отвечает 500 на первый запрос и 200 дальше. Убедитесь, что retry спасает и без перехода по цепочке. Почему 400 ретраить бессмысленно?

  4. Третья ступень: шаблон (15 мин). Добавьте в ModelChain «модель» template:refund-stub, чей мок возвращает заготовку «Я передал вопрос оператору…». Уроните base — и покажите: пользователь всё равно получает ответ.

  5. Ответьте письменно (10 мин). Почему 429 ретраится с оглядкой на Retry-After, а 403 — нет? Что залогировать при исходе fallback, чтобы через месяц ответить на вопрос из крючка — какая версия отвечала?

🧩 Quiz "Проверь себя"

1. tuned-эндпоинт лежит; что видит пользователь при рабочей цепочке?
2. Что писать в лог вызова, чтобы спустя месяц понять, какая версия отвечала?
3. Почему A/B-группы делят по hash(user_id), а не случайно на каждый запрос?
4. Почему uri без суффикса версии в проде — мина замедленного действия?
5. Что ловит shadow-режим и чего не ловит?

✅ Итоги модуля

  • Настроить вызов модели как зависимости: таймаут, retry с backoff, лимит.
  • Версионировать модель суффиксом uri и вести журнал immutable-тройки «датасет + код + гиперпараметры».
  • Объяснить, почему «latest» в проде делает откат невозможным.
  • Провести версию по маршруту shadow → A/B по hash(user_id) → 100%.
  • Построить цепочку tuned → base → шаблон и показать в песочнице «фича жива».
  • Перечислить телеметрию вызова и PII-правила для логов промптов.
  • Нарисовать итеративный цикл по диаграмме и назвать место фидбека в нём.

🔗 Мостик к Модулю 13

Оружие собрано: вы умеете готовить датасет, оценивать до выката, тюнить в облаке, версионировать, катить лесенкой и деградировать без падения фичи. Осталось сделать всё это своими руками в одном проекте. В модуле 13 — капстоун: за уик-энд вы пройдёте весь цикл на выбранном домене, от ста пар до отчёта «что улучшилось, что деградировало и какой следующий шаг».