ИИ-мастер DnD: реплей 66 слепков покраснел без строки правок - виноват SELECT без ORDER BY

Реплей 66 слепков живых ходов ИИ-мастера DnD покраснел не из-за правки кода, а из-за SELECT без ORDER BY: Postgres отдавал строки Fact и NpcInstance в изменчивом физическом порядке, SQLite - по вставке, из-за чего промпт LLM собирался по-разному и слепки не совпадали. Фикс - order_by(id) и строгий отпечаток запроса в каждом слепке.
Хочешь попробовать это в игре прямо сейчас? Соло-ДнД с ИИ-мастером в Telegram · базовая игра бесплатная.
В прошлый раз я рассказывал, как перед большим распилом pipeline.py - главного файла с логикой ходов ИИ-Мастера - мы натянули трос: 66 слепков живых ходов, которые должны краснеть при любом расхождении. Трос был нужен как раз для такого случая. Начинаем второй срез распила, ещё не тронули ни строчки - и реплей краснеет. Код тот же. Слепки те же. А тесты не сходятся.
Что за трос из слепков ходов
Трос - это набор снятых с прода записей реальных ходов игры: что было на входе и что ответила модель, как после хода изменились память и состояние партии. Тест прогоняет код заново на этих же входных данных и сверяет результат со слепком: разошлось хоть что-то - тест красный. Не юнит-тест на кусочек логики, а полный слепок “что происходит за столом”, снятый ещё до того, как код начали резать.
Перед вторым срезом на тросе стояло 69 тестов: 66 слепков ходов плюс несколько проверок самого механизма. Все зелёные - это и была стартовая точка.
Почему реплей покраснел, хотя код не трогали
Первая мысль - дрейф кода: кто-то что-то поправил в мастер-ветке, пока готовился срез. Отвергли за пять минут: откатились на код недельной давности - краснеют те же слепки, в том же составе. Вторая мысль - тесты просто нестабильные, флак, бывает. Тоже мимо: два прогона подряд дали ноль расхождений между собой. Реплей стабильно предсказывал сам себя - и так же стабильно расходился со слепком) От такой стабильности радости было мало.
Стабильно неправильный результат - это не шум, это баг, который просто раньше было некому поймать. Из 539 отпечатков, которые трос сверяет построчно (отдельный отпечаток на каждый LLM-запрос внутри хода, их в ходе может быть несколько), красными оказались 313 - больше половины. Не первый раз зелёный набор тестов врал о реальном состоянии дел - только в этот раз соврал не тестовый прогон, а порядок строк, который годами приходил из базы правильным просто по счастливой случайности.
В чём был баг: SELECT без ORDER BY
SELECT без ORDER BY - это запрос к базе, в котором не указано, в каком порядке возвращать строки. Согласно документации PostgreSQL, если сортировка не задана явно, порядок строк не специфицирован: он зависит от плана выполнения запроса и физического расположения данных на диске - и рассчитывать на него нельзя.
Ровно в эту дыру мы и провалились. Загрузчик данных хода дёргал факты о мире и активных NPC двумя запросами без сортировки. В проде на Postgres строки отдавались в физическом порядке, который сдвигается после каждого UPDATE. В тестах на SQLite - в порядке вставки, который не меняется никогда. Два движка, два честных, но разных порядка - а промпт для модели собирается из этих строк построчно. Другой порядок фактов в промпте - другой промпт - другой хэш ответа, хотя по смыслу ход мог быть тем же самым.
Баг жил в коде и раньше, просто трос его не видел: старый набор слепков не хранил строгий отпечаток самого запроса, только итоговый ответ и память после хода - а в большинстве ходов порядок фактов случайно совпадал с тем, что помнил слепок. Второй срез затронул как раз тот кусок кода, где строки собираются в контекст, - и вероятность совпадения перестала работать в нашу пользу.
Чинили просто: order_by(id) на оба запроса плюс вторичный ключ там, где по времени возможна ничья. И - чтобы это больше никогда не проходило незамеченным - новый вид слепка со строгим отпечатком самого запроса, не только результата хода.
Мост из 39 свойств, который прятал баг
Второй срез - это перенос состояния хода в отдельный объект-контекст, который становится единственным хозяином всего, что происходит за один ход: флаги, память, прочитанные из базы данные. До этого всё было раскидано по атрибутам одного очень большого объекта - удобно для старого кода, неудобно для нового.
Проблема переезда: тянуть весь код за собой одним рывком нельзя, слишком большой риск. Поэтому между старым и новым домом состояния построили мост - 39 свойств, которые синхронизируют старые обращения к атрибутам с новым контекстом, пока не переедет весь код целиком. Мост не писали руками: таблица соответствий плюс генератор, который её же и проверяет - расхождение между таблицей и кодом ловится отдельной автоматической проверкой при каждом коммите.
Мост - ровно та точка, где могло тихо потеряться любое из состояний хода, включая порядок чтения фактов. Поэтому прежде чем доверять мосту, каждый метод старого и нового кода сверили по смыслу тела, а не только по имени: девятнадцать пар методов, ноль расхождений.
Трос проверили нарочными поломками
Чтобы доверять тросу, его нарочно ломали: вносили заведомо неправильное поведение, смотрели, покраснеет ли реплей, откатывали правку.
| Что сломали нарочно | Что ожидали | Что показал трос |
|---|---|---|
| Счётчик хода в мосте стал возвращать “+1” | Покраснеют слепки со счётчиками | 10 упало, 63 прошло - поймано |
| Сортировка фактов заменена на обратную | До нового вида слепков - не должно ловиться | Не поймано: старый набор слеп к порядку строк - доказательство, зачем нужен строгий отпечаток |
| Флаг режима “директора” инвертирован | Должны покраснеть ходы с включённым директором | Не поймано: трос прогоняет ходы с флагами именно того слепка, а не с дефолтом кода - так спроектирован реплей |
| То же, но через подмену чтения флага в момент хода | - | 67 упало, 6 прошло - поймано |
| Восстановление памяти теряет последнюю сделку | Один конкретный ход должен покраснеть | 10 упало, включая тот самый ход - поймано |
Три из пяти поломок трос поймал сразу. Одна не поймалась вообще, и это нормально: флаг “директора” в слепках всегда записан как включённый - таким он был на проде во время записи, так что дефолт кода в реплее просто не участвует. Ещё одна не поймалась старым набором слепков ровно по той причине, из-за которой всё это расследование и началось. Ребят, вот вам и доказательство на пальцах, зачем нужен строгий отпечаток, а не просто “результат хода совпал”.
Что было → что стало
Кроме самого бага, второй срез почистил код: pipeline.py уменьшился с 15 704 до 15 124 строк, из которых 314 - это сгенерированный мост (не в счёт), а “настоящей” новой логики набежало около 147 строк при бюджете до 400. Отдельно мигрировали 321 точку в 56 файлах тестов, где тесты напрямую лазили в атрибуты флагов, - скриптом, с проверкой на ноль расхождений после прогона.
Как и с архитектурной ошибкой в кассе бота, баг лежал не в логике конкретного хода, а в фундаменте под ней. Просто в этот раз фундамент - не касса, а порядок строк из базы.
Что дальше: сколько срезов осталось и какой самый рискованный
После второго среза в плане распила остаются срезы с третьего по девятый - ещё семь штук. Самый нервный из них - третий: он должен разрезать самую большую и самую нагруженную часть кода хода и вытащить из неё транзакцию с базой данных. Уже на этапе ревью плана предупредили: поторопится - либо нарушит архитектурные правила, которые мы только что навели, либо утащит за собой кусок кода, для которого пока нет отдельного дома. Так что трос и строгие отпечатки к третьему срезу понадобятся ещё сильнее, чем ко второму.
Правило после этого случая у меня простое: если тест не сходится сам с собой - ему верить нельзя, даже когда он зелёный. А развилки вида “как именно резать код” я всё чаще отдаю агенту целиком, оставляя себе только цель - перенести состояние хода в контекст и ничего не сломать. Брейншторм от этого пошёл быстрее. Трос - единственная причина, по которой можно себе такое позволить.
Частые вопросы
Что такое SELECT без ORDER BY и почему это баг?
SELECT без ORDER BY - запрос без заданного порядка строк: СУБД вправе отдавать их как угодно, и порядок может меняться между запусками. Postgres в проде отдавал факты и NPC в физическом порядке, который сдвигался после UPDATE, а SQLite в тестах - по порядку вставки. LLM собирает промпт из этих строк по порядку, поэтому разный порядок строк - разный промпт и разные ответы модели.
Что такое трос (tripwire) из слепков ходов?
Трос - набор снятых с прода записей реальных ходов игры (слепков), которые повторно прогоняют через код: если ответ модели, состояние или память после хода расходятся со слепком, тест краснеет. Такой трос ловит баги до деплоя, а не после жалоб игроков.
Сколько ещё срезов осталось в рефакторинге ИИ-мастера DnD?
После среза 2 в плане остаются срезы 3-9. Самый рискованный - срез 3: он должен разрезать самый большой кусок кода хода и вынести из него транзакцию с базой данных, а ревью уже предупредило, что это может нарушить архитектурные правила проекта.
Как готовился материал: черновик собирает мой ИИ-конвейер по темам из практики таверны, факты сверяются с первоисточниками и правилами, финальную версию я читаю и правлю руками перед публикацией. Обложку тоже рисует нейросеть. Про кухню — на странице о проекте.