Как отличить регресс image-модели от смены конфигурации

Типичная ситуация: кто-то пишет «вчера лица получались похожими, а сегодня — нет» или «модель перестала слушаться промпта». По одной такой жалобе нельзя понять, что изменилось. Это может быть сама модель, интерфейс запуска (другие параметры по умолчанию, автоматическое переписывание промпта), аккаунт (тариф, уровень модерации, квоты) или исходный запрос — его могли «чуть-чуть поправить».
В статье разберём, как собрать regression harness — небольшой стенд, который прогоняет фиксированный набор входов, записывает полный отпечаток конфигурации и считает метрики по отдельности: лицо, референс, инструкция, safety-отказы и технические ошибки. Тогда вопрос «регресс или смена конфигурации?» превращается в сравнение двух журналов.
Почему разовые жалобы ничего не доказывают
Генерация изображений стохастична: даже с одними и теми же входами результат разбросан. Если сравнить одну картинку «до» с одной «после», вы сравниваете два случайных образца. Кроме того, сигналы смешиваются: если модерация отклоняет часть запросов, а интерфейс отдаёт заглушку или пустой ответ, пользователь видит это как «качество упало».
Значит, нужны три вещи:
- Фиксированные входы — чтобы исключить изменение запроса.
- Журнал конфигурации — чтобы увидеть изменение модели, параметров или аккаунта.
- Раздельные метрики с повторениями — чтобы отделить качество от отказов и сбоев и не принимать шум за тренд.
Четыре слоя, которые могут измениться
| Слой | Что меняется | Как это заметить |
|---|---|---|
| Модель | версия, снапшот, алиас вида «latest» | идентификатор модели в ответе API, если он там есть; дата прогона |
| Интерфейс запуска | параметры по умолчанию, размер, качество, переписывание промпта, версия SDK или UI | полный набор отправленных параметров, версия клиента |
| Аккаунт | тариф, проект, ключ, регион, настройки модерации | хеш идентификатора проекта или ключа (не сам ключ), заголовки ответа |
| Запрос | текст промпта, референсы, их сжатие и разрешение | SHA-256 промпта и каждого файла референса |
Главное правило: метрики сравнивают только после того, как сравнили конфигурацию. Если отпечаток конфигурации отличается, падение метрик сначала объясняют конфигурацией.
Шаг 1. Зафиксируйте набор входов
Соберите небольшой «золотой» набор — кейсы, которые покрывают ваши реальные сценарии. Для каждого кейса нужны промпт, референсы (если есть) и список проверяемых требований инструкции. Набор должен быть небольшим, чтобы прогон был дешёвым, и неизменным: новый кейс добавляют как новую версию набора, а старые не правят.
harness/
cases/
v1/
cases.jsonl # один кейс на строку
refs/
face_01.png
product_02.png
runs/ # результаты прогонов, только дозапись
manifest.sha256 # хеши всех входов
Пример строки cases.jsonl:
{"id": "face-portrait-01", "prompt": "Студийный портрет человека с референса, нейтральный серый фон, мягкий свет слева", "refs": ["refs/face_01.png"], "checks": ["серый фон", "свет слева", "один человек в кадре"], "metrics": ["face", "instruction"]}
{"id": "product-02", "prompt": "Товар с референса на деревянном столе, вид сверху", "refs": ["refs/product_02.png"], "checks": ["деревянный стол", "вид сверху"], "metrics": ["reference", "instruction"]}
Зафиксируйте хеши входов — это безопасная операция, она только читает файлы:
cd harness/cases/v1
find . -type f | sort | xargs sha256sum > ../../manifest.sha256
# перед каждым прогоном:
sha256sum -c ../../manifest.sha256 --quiet && echo "входы не менялись"
Если проверка упала, дальше не идём: изменился запрос, и сравнение с прошлым прогоном некорректно.
Шаг 2. Ведите журнал конфигурации
Каждый вызов генерации записывает, кроме результата, отпечаток конфигурации. Это всё, что могло повлиять на выход, кроме случайности. Секреты в журнал не попадают: вместо ключа — короткий хеш, по которому можно понять «ключ тот же или другой», но восстановить ключ нельзя.
{
"run_id": "2026-10-03T09-00-00Z",
"case_set": "v1",
"inputs_manifest_sha256": "…",
"model_requested": "имя-модели-как-в-запросе",
"model_reported": "идентификатор из ответа, если API его возвращает",
"params": {"size": "1024x1024", "quality": "…", "n": 1, "seed": null},
"client": {"sdk": "имя@версия", "harness_git_sha": "…"},
"account": {"project_hash": "a1b2c3d4", "key_hash": "e5f6a7b8"},
"prompt_rewrite": "unknown"
}
Пара практических замечаний:
- Записывайте все параметры, включая те, что вы не меняли: значения по умолчанию на стороне интерфейса как раз и меняются незаметно.
- Если API возвращает переписанный промпт или идентификатор снапшота, сохраняйте их. Если не возвращает, пишите
unknown, а не пропускайте поле: так видно, что этот слой не наблюдается. - Хеш для отпечатка считайте по каноническому JSON (с отсортированными ключами), иначе одинаковые конфигурации будут давать разные хеши.
Шаг 3. Прогон с повторениями
Ниже — каркас прогона на Python. Функцию generate вы реализуете под своего провайдера; harness ничего не знает о конкретном API. Каждый вызов повторяется N раз, и каждый исход классифицируется в одну из корзин: ok, refusal, error.
import hashlib, json, time, pathlib
N_REPEATS = 5 # подберите под бюджет; один образец ничего не доказывает
def sha(s: bytes) -> str:
return hashlib.sha256(s).hexdigest()
def fingerprint(cfg: dict) -> str:
return sha(json.dumps(cfg, sort_keys=True, ensure_ascii=False).encode())[:16]
def generate(prompt, ref_paths, params):
"""Подключите своего провайдера. Верните dict:
{"status": "ok"|"refusal"|"error", "image_bytes": ..., "meta": {...}, "error": "..."}"""
raise NotImplementedError
def run(case_file, cfg, out_dir):
fp = fingerprint(cfg)
out = pathlib.Path(out_dir) / f"{cfg['run_id']}.jsonl"
out.parent.mkdir(parents=True, exist_ok=True)
with open(case_file, encoding="utf-8") as f, open(out, "a", encoding="utf-8") as log:
for line in f:
case = json.loads(line)
for i in range(N_REPEATS):
t0 = time.time()
try:
r = generate(case["prompt"], case["refs"], cfg["params"])
except Exception as e: # сбой клиента или сети — тоже данные
r = {"status": "error", "error": type(e).__name__}
rec = {"case": case["id"], "rep": i, "fp": fp, "cfg": cfg,
"status": r["status"], "error": r.get("error"),
"meta": r.get("meta", {}), "latency_s": round(time.time() - t0, 2)}
if r.get("image_bytes"):
img = out.parent / f"{cfg['run_id']}_{case['id']}_{i}.png"
img.write_bytes(r["image_bytes"])
rec["image"] = img.name
rec["image_sha256"] = sha(r["image_bytes"])
log.write(json.dumps(rec, ensure_ascii=False) + "\n")
Различайте отказ и ошибку на уровне кода. Отказ — это явный ответ модерации (специальный код, флаг или текст отказа от провайдера). Ошибка — всё остальное: тайм-ауты, 5xx, лимиты, пустой или повреждённый файл. Если смешать их, рост лимитов из-за смены тарифа будет выглядеть как «модель стала строже».
Шаг 4. Раздельные метрики
Пять метрик считаются независимо, и каждая — только на тех кейсах, где она осмысленна (поле metrics в кейсе).
| Метрика | Что измеряет | Как считать | Знаменатель |
|---|---|---|---|
| Лицо | сохранение идентичности человека с референса | косинусное сходство эмбеддингов лица (модель распознавания лиц, которую вы уже используете) между референсом и результатом; отдельно — доля кадров, где лицо не найдено | успешные генерации |
| Референс | сохранение объекта, стиля, композиции | перцептивное сходство или эмбеддинги изображений; при спорных случаях — ручная разметка | успешные генерации |
| Инструкция | выполнение явных требований промпта | доля выполненных пунктов checks; проверяет человек или мультимодальная модель-оценщик с фиксированной версией и промптом | успешные генерации |
| Safety-отказы | доля запросов, отклонённых модерацией | count(refusal) / все попытки | все попытки |
| Технические ошибки | сбои, не связанные с содержанием | count(error) / все попытки, с разбивкой по типу | все попытки |
Обратите внимание на знаменатели. Метрики качества считаются только по успешным генерациям, иначе отказы и ошибки «утягивают» качество вниз и маскируют свою истинную причину.
Оценщик сам является частью конфигурации. Если инструкцию проверяет модель, её версия и промпт оценщика тоже входят в журнал. Иначе вы получите регресс оценщика, а не генератора.
Каркас агрегации по корзинам:
import json, sys
from collections import Counter, defaultdict
def summarize(path):
by_fp = defaultdict(Counter)
for line in open(path, encoding="utf-8"):
r = json.loads(line)
by_fp[r["fp"]][r["status"]] += 1
if r["status"] == "error":
by_fp[r["fp"]]["error:" + str(r.get("error"))] += 1
for fp, c in by_fp.items():
total = c["ok"] + c["refusal"] + c["error"]
print(fp, f"n={total}",
f"refusal={c['refusal']/total:.2%}",
f"error={c['error']/total:.2%}",
{k: v for k, v in c.items() if k.startswith("error:")})
summarize(sys.argv[1])
Метрики лица, референса и инструкции добавляются отдельным скриптом, который читает сохранённые изображения и дописывает оценки в отдельный файл. Сырые результаты при этом не перезаписываются.
Шаг 5. Сначала дифф конфигурации, потом метрики
Чтобы сравнить два прогона, начните с отпечатков:
jq -c '.cfg | del(.run_id)' runs/A.jsonl | sort -u > /tmp/cfg_a.json
jq -c '.cfg | del(.run_id)' runs/B.jsonl | sort -u > /tmp/cfg_b.json
diff <(jq -S . /tmp/cfg_a.json) <(jq -S . /tmp/cfg_b.json) || echo "конфигурация различается"
Дальше разбор идёт по таблице решений:
| Наблюдение | Вероятная причина | Следующее действие |
|---|---|---|
| Хеши входов не совпадают | изменился запрос | вернуть исходные входы и прогнать заново |
Отпечаток различается в params или client | смена интерфейса запуска | прогнать новую модель со старыми параметрами явно |
Отпечаток различается в account | смена аккаунта или проекта | прогнать на обоих аккаунтах одновременно |
| Конфигурация та же, растут только ошибки | инфраструктура, лимиты | смотреть разбивку по типам ошибок и время прогона |
| Конфигурация та же, растут только отказы | изменение модерации | сравнить, на каких кейсах появились отказы |
| Конфигурация та же, падают метрики качества на нескольких прогонах | кандидат в регресс модели | повторить прогон в другое время, проверить стабильность оценщика |
Если model_reported отличается при одинаковом model_requested, это сильный сигнал смены модели за алиасом. Но учтите, что не все API такой идентификатор возвращают.
Проверка результата
Перед тем как доверять стенду, проверьте сам стенд:
- A/A-прогон. Два прогона подряд с одинаковой конфигурацией. Разница метрик между ними — это ваш уровень шума. Изменения меньше этого уровня регрессом считать нельзя.
- Намеренное изменение. Поменяйте один параметр (например, размер) и убедитесь, что дифф конфигурации его ловит, а отпечаток меняется.
- Проверка корзин. Найдите кейс, который гарантированно отклоняется по вашей политике, и убедитесь, что он попадает в
refusal, а не вerror. - Воспроизводимость отчёта. Сводку можно перестроить из
runs/*.jsonlбез повторных вызовов модели.
Типовые ошибки
- Одна картинка на кейс. Без повторений шум не отличить от изменения.
- Общий балл «качество». Одно число смешивает лицо, инструкцию и отказы, и причину уже не восстановить.
- Правка кейсов «на месте». После этого старые прогоны несравнимы с новыми. Нужен новый
case_set. - Пропуск значений по умолчанию. Не записали параметр, потому что «не меняли», а он изменился на стороне интерфейса.
- Секреты в журнале. Ключи, токены и персональные данные в JSONL. Храните только хеши, а референсы с лицами — с тем же уровнем доступа, что и исходники.
- Неверсионированный оценщик. Меняется модель-оценщик, а вывод делают о генераторе.
Ограничения
- Harness не видит то, что провайдер не раскрывает: скрытое переписывание промпта или серверную смену снапшота можно заподозрить по метрикам, но не доказать без поля в ответе.
- Если seed не поддерживается или не гарантирует детерминизм, сравнение возможно только статистически, по распределениям.
- Автоматические метрики лица и референса — приближения. Сходство эмбеддингов не равно субъективному «похоже», поэтому спорные случаи нужно размечать вручную.
- Небольшой набор кейсов ловит заметные изменения, но может пропустить регресс в сценарии, которого в наборе нет.
- Каждый прогон стоит денег и квот, поэтому размер набора и
N_REPEATS— это компромисс, а не константа.
Что дальше
Когда стенд готов, на жалобу «стало хуже» вы отвечаете не догадкой, а двумя журналами: совпадают ли входы, совпадает ли конфигурация и какая из пяти метрик изменилась сильнее уровня шума. Другие практические разборы собраны в гайдах, а термины из статьи — в глоссарии.