Назад к разделу Technology

Мы не пишем тест-кейсы для нашего ИИ. Их пишет продакшен.

Golden dataset, написанный руками, портится на той же неделе, когда вы его составили. Наш регрессионный набор для ИИ-категоризации генерируется из реальных правок пользователей, а промах должен повториться три раза, прежде чем заслужит тест. Ниже сам пайплайн, цифры за ним и шарнир, который всё это время был тихо сломан.

Категория
Общее
Обновлено
Автор
Stan Kharlap

Спросите инженера в любой ИИ-компании, откуда он знает, что модель стала лучше, и обычно получите один из двух ответов. Уверенный ответ: «у нас есть evals». Честный ответ: «у нас есть табличка, которую кто-то сделал в марте».

Мы прожили честную версию. Она не работает, и причина не в лени. Golden dataset, написанный руками, это снимок тех ошибок, которые вы могли вообразить у модели, сделанный в тот день, когда вы сели их воображать. Наша система читает чеки, назначает бухгалтерские категории и готовит черновики налоговых деклараций для немецкого бизнеса. То, в чём она ошибается, определяется тем, каким мерчантам наши пользователи платят в этом квартале, какие банковские форматы присылают какие строки назначения платежа и в какой угол немецкого закона о НДС попадает та или иная подписка. Ничего из этого нет в моём воображении. Всё это есть в базе данных.

Поэтому мы перестали писать тест-кейсы и построили пайплайн, который их собирает.

Сигнал это правка, а не жалоба

Первый инстинкт: спросить у пользователей фидбек. Палец вверх, палец вниз, виджет «это было полезно?». Мы это тоже сделали, и по объёму данных оно почти бесполезно: люди не оценивают софт, они его правят и идут дальше.

Сигнал с реальным объёмом это сама правка. Когда наш категоризатор предлагает Software для транзакции, а пользователь молча меняет её на Advertising, эта правка и есть размеченный пример с человеком в цикле, полученный бесплатно, ровно в тот момент, когда человеку было достаточно важно быть точным. За последние две недели этот путь дал несколько тысяч правок. Путь с явными пальцами вверх и вниз дал ошибку округления.

Отсюда сразу вытекают два ограничения, и оба скучные в правильном смысле.

Во-первых, запись правки не может жить внутри запроса. Пользователь, обновляющий транзакцию, находится на чувствительном к задержке пути записи, и ничто в нашем цикле обучения не стоит ни одной лишней секунды его времени и ни одного лишнего шанса получить 500. Правка отдаётся в background task, и этот task проглатывает любое своё исключение. Если цикл обучения ломается, продукт этого не замечает.

# Иллюстративный набросок, не наш исходник. Только форма.
@deferred(timeout="short")
def on_manual_override(record_ref, machine_value, human_value, actor):
    try:
        local_memory.upsert(record_ref, human_value)
        if machine_value:                 # здесь угадывала машина, и угадала мимо
            defect_log.observe(record_ref, machine_value, human_value)
    except Exception:
        log.warning("override not recorded", exc_info=True)

Во-вторых, цикл обязан различать неправильный ответ и другой ответ. И в этом, как выясняется, вся суть.

Большинство правок не дефекты

Пользователь, поменявший категорию, ещё не доказательство того, что что-то сломано. Есть минимум четыре причины, по которым появляется правка, и только одна из них инженерная проблема:

  • Промах извлечения. Мы прочитали документ или строку назначения платежа и получили неверный ответ. Это дефект.
  • Баг маппинга. Сработало детерминированное правило и отмапило на неверный счёт. Тоже дефект, и более стыдный.
  • Предпочтение пользователя. Обе категории защитимы, и этот бизнес предпочитает вторую. Не дефект.
  • Налоговое суждение. Правильный ответ зависит от фактов, которые знает только бизнес или его консультант. Не дефект, и уж точно не то, что надо чинить в prompt.

Инженерным сигналом становятся только первые два. Остальные записываются, считаются и отгораживаются файрволом, потому что набор тестов, который считает вкус ошибкой, с радостью оптимизирует вашу модель под чужое мнение.

Разделение между первыми двумя выводится, а не спрашивается. Если у транзакции уже была категория, установленная движком правил, правку списывают на правила; если там было предложение модели, на извлечение. Никто не заполняет форму. В продакшене это соотношение перекошено так, что меня это скорее успокаивает: на детерминированные правила приходится существенно меньше процента правок. Когда мы ошибаемся, это почти всегда модель, читающая документ, а не наш собственный жёстко прописанный маппинг.

Fingerprint вместо тикетов

Сырой поток правок непригоден для работы. Тысячи правок это не тысячи проблем; это пара сотен проблем с длинным хвостом шума. Поэтому правки не хранятся как рабочие задачи. Они сворачиваются в findings с ключом по fingerprint.

Fingerprint это hash по workflow, назначенной причине, нормализованному пути поля и идентификатору контрагента. Тот же мерчант, то же поле, та же причина сбоя, тот же fingerprint, и счётчик доказательств растёт на единицу.

# Иллюстративный набросок. Идентичность выводится, а не назначается.
def group_key(stage, blame, target, party=None):
    parts = {
        "stage": stage,
        "blame": blame or "unknown",
        "target": canonical(target),
    }
    if party:                    # ставим только при наличии, чтобы ключи
        parts["party"] = party   # из времён до этого поля совпадали
    return digest(parts)

Этот if заслуживает отдельного предложения. Когда мы добавили идентификатор контрагента, наивная версия перехешировала бы каждый исторический finding в новую корзину и обнулила месяцы накопленных доказательств. Добавление ключа только тогда, когда у него есть значение, оставляет старые трёхключевые fingerprint хешируемыми ровно так же. Эволюция схемы в content-addressed хранилище это миграция базы данных, надевшая другую шляпу.

Три промаха, и ты тест

Вот правило, которое я в этом дизайне защищал бы упорнее всего:

Finding должен повториться минимум три раза, прежде чем заслужит тест-кейс.

Распределение в продакшене объясняет, почему именно. Из накопленных fingerprint около двух третей встречались ровно один раз. Один пользователь, один мерчант, одна правка, и больше никогда. Если бы все они стали регрессионными кейсами, набор сейчас состоял бы в основном из шума: каждый кейс фиксировал бы поведение, которого никто не попросил дважды, и каждое будущее изменение prompt зажигало бы десяток бессмысленных красных лампочек. Набор тестов, который вы приучились игнорировать, хуже, чем отсутствие тестов, а самый быстрый способ получить такой набор это дать пожарному шлангу писать его за вас.

Три промаха превращают ручку громкости в фильтр. Примерно один finding из шести проходит планку и повышается до eval-кейса, и как только паттерн действительно повторяется, он доезжает быстро: медианное время от первой правки до живого регрессионного кейса существенно меньше суток, полностью автоматически, без единой встречи по триажу на всём пути.

Воронка от правки до eval-кейса. Несколько тысяч правок пользователей за две недели сворачиваются в более чем тысячу findings с fingerprint; две трети из них единичные и отбрасываются гейтом трёх промахов; предпочтения пользователя и налоговые суждения отгораживаются как не дефекты; выжившие становятся несколькими сотнями активных eval-кейсов, из которых пятьдесят каждую ночь прогоняются по текущему пайплайну только по хешам.
Объём внутрь, сигнал наружу. Гейт на третьей стадии и делает набор тестов достойным чтения: около двух третей fingerprint встречаются ровно один раз и тестами не становятся.

Набор тестов никогда не видит ваших данных

На идею генерировать тесты из продакшена есть очевидное возражение: вы только что построили постоянный архив бухгалтерии клиентов внутри своей CI-системы.

Не построили, и причина в том, что eval-кейсы хранят хеши исправленных значений, а не сами значения. Ночной реплей заново прогоняет текущий пайплайн по тем же входным данным, хеширует результат и сравнивает. Он может сказать вам, что поведение изменилось. Он не может сказать, что кто-то купил.

Та же политика работает на уровень ниже. Каждая единица работы модели записывается как traced run: провайдер, модель, количество токенов, задержка, стоимость и hash входа и выхода. Сами payload никогда не сохраняются, и каждая строка трейса несёт политику хранения, под которой она была записана, так что гарантия видна в данных, а не в документе. Это ограничение несущее, а не декоративное: именно поэтому никому не приходится договариваться о том, сколько может жить регрессионный корпус.

Как это выглядит на объёме

Архитектурные споры без масштаба ничего не стоят, поэтому: за последние две недели наш агентный слой затрейсил заметно больше ста тысяч запусков, в основном категоризацию, с пиком выше десяти тысяч за один день и ровным потоком извлечения из документов рядом. Это порядка ста миллионов токенов в каждую сторону.

Одно число из этой горы изменило то, как мы пишем prompt. Примерно семь из десяти входных токенов отдаются из кешированного префикса провайдера, а не обрабатываются заново, и это не везение: каждый prompt собирается статической частью вперёд, а весь контекст по компании и по пользователю дописывается в конец, чтобы длинная общая преамбула оставалась байт в байт одинаковой у всех пользователей и оставалась пригодной для cache. Это правило с зубами. Один благонамеренный рефакторинг, поднимающий имя компании в шапку, тихо сожжёт cache на каждом запросе, и именно поэтому наша модель стоимости считает кешированные и некешированные входные токены отдельно. Ущерб проявляется в деньгах.

Где наш цикл всё ещё разомкнут

Мне было бы приятнее писать о той части, которая работает, но интересная инженерия всегда в стыке, а в нашем была дыра.

Ночной реплей исправно ходит по расписанию уже недели, пятьдесят кейсов за ночь, добросовестно выдавая строку результата на каждый. И каждая из этих строк это error. Не pass, не fail: error, с одним и тем же сообщением каждый раз. Половина системы, которая продвигает findings, пишет ожидаемый результат на своём языке, описывая причины и пути полей, которые должны перестать повторяться. Половина, которая оценивает реплей, понимает другой язык, язык сравнения значений. Ни одна из половин не ошибается в своих терминах. Их построили с разницей в недели и никогда не заставили договориться, поэтому грейдер читает каждый сгенерированный нами кейс, не находит там ничего, что распознал бы как assertion, и сдаётся.

Цикл собран целиком, вплоть до последнего шарнира, а тот проворачивается вхолостую. Три вывода, и они обобщаются за пределы нашей кодовой базы.

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

Самопишущемуся набору тестов нужен контрактный тест. Когда производитель и потребитель тест-кейсов это разные модули, формат между ними является публичным интерфейсом и заслуживает собственного теста. Наш был задокументирован в каждой половине и не проверялся ни в одной.

Амбиция в схеме это не возможность. Каждый traced run несёт внешний ключ на версию prompt, которая его породила, и в продакшене эта колонка равна null у всех ста с лишним тысяч. Реестр реальный, он живёт в базе, prompt меняются без деплоя, и код читает активную версию в момент вызова. А потом выбрасывает её вместо того, чтобы записать. Так что сегодня мы не можем ответить на единственный вопрос, ради которого вся эта машинерия существует: помогло ли то изменение prompt? Правится это двумя строками. Дыра продержалась так долго потому, что ничего никогда не падало, и теперь я считаю «ошибок нет, но и ответа тоже нет» отдельной категорией бага.

Скучные части и есть продукт

Ничего из интересной работы здесь не было работой с моделью. Модель это компонент, который мы арендуем. Части, которыми владеем мы, это те, что решают, какие из тысяч человеческих правок заслуживают называться дефектами; которые отказываются повышать совпадение до теста; которые держат регрессионный корпус, не держа данные клиентов; и которые достаточно честны, чтобы признать, что последнее звено не подключено.

Пусть тест-кейсы пишет продакшен, потому что продакшен знает то, чего не знаете вы. А потом будьте параноиком в отношении пайплайна, который их переносит, потому что цикл обучения, который тихо перестал учиться, вам об этом никогда не скажет.

Norman берет операционную финансовую работу на себя

От invoicing до bookkeeping: Norman организует повторяющиеся финансовые процессы так, чтобы вы успевали к дедлайнам с меньшим объемом ручной работы.