Мы не пишем тест-кейсы для нашего ИИ. Их пишет продакшен.
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-кейса, и как только паттерн действительно повторяется, он доезжает быстро: медианное время от первой правки до живого регрессионного кейса существенно меньше суток, полностью автоматически, без единой встречи по триажу на всём пути.
Набор тестов никогда не видит ваших данных
На идею генерировать тесты из продакшена есть очевидное возражение: вы только что построили постоянный архив бухгалтерии клиентов внутри своей 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 организует повторяющиеся финансовые процессы так, чтобы вы успевали к дедлайнам с меньшим объемом ручной работы.