Мої тести не могли мене здивувати

У запобіжнику мого агента-анкетувальника була дірка, а тест, який мав довести, що запобіжник працює, раз у раз проходив. Обидва я написав за один присід, з одного й того самого списку формулювань у голові, тож і сліпа зона в них була спільна. Виправив це оракул, якого я сам не писав: відповіді, які система вже надіслала живим людям.

У коді мого агента-анкетувальника є коментар, який мені було неприємно писати. Він сидить у тестовому сценарії, єдина робота якого – довести, що бот ніколи не вигадує дату зворотного дзвінка, і в ньому записано: сценарій пройшов, поки бот обіцяв живій людині, що це станеться «usually inside a few days». Запобіжник звіряв відповідь зі списком написань. Тест звіряв її зі списком написань. А саме цього прийменника – звичайного синоніма того, що був в обох списках, – не знав жоден.

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

Звідси моя теза: перевірка, яку написав автор коду, успадковує сліпу зону цього коду, і ретельність робить лише гірше, бо дублюється саме вона. Єдиний оракул, що від цього вільний, – запис того, що система вже зробила на очах у людей, ще до того, як її автор вигадав про це якусь теорію.

Чому дорога перевірка цілий рік була правильним вибором

Агент веде структуровану розмову-анкету: 11 полів профілю збирає за кілька хвилин вільної розмови, а не через бланк, звіряє з живим каталогом і передає людині. Я вже писав про те, якої форми це зрештою набуло: правила належать коду, модель відповідає за мову, а кожне її твердження звіряють із тим, що насправді повернули інструменти.

Саме в цьому шарі перевірок і сидять витрати. Він розрісся приблизно до 120 механізмів із власними назвами, і за кожним стояв реальний інцидент та коментар, що його називав. П’ятдесят три з них за два тижні живих розмов не спрацювали жодного разу.

Перевірка стояла на двох ногах. Близько 1700 модульних тестів, швидких і безкоштовних, і кожен доводить, що один механізм працює сам по собі. І 63 персони – вигадані кандидати за сценарієм, яких проганяють через живого агента по HTTP: вони коштують справжніх токенів і доводять, що механізм уживається з іншими. Саме такі збої, на стику механізмів, і кусають найболючіше: обидві регресії, які я сам собі влаштував минулого місяця, коли переробляв архітектуру, зловив прогін персон – і більше ніщо.

[тодішній макс]

Тож персони свій хліб відпрацьовували. Чого я перестав помічати – то це з чого вони зроблені: вигаданий кандидат, вигаданий сценарій і список рядків, яких відповідь має уникати. Усі три – це моє власне судження в маскарадному костюмі.

До того ж їхній результат залежить від випадку. Цього тижня я двічі прогнав три з них на однаковому коді й отримав спершу два проходження, потім три. А сигнал, на який я спираюся, випускаючи нову версію, має змінюватися лише тоді, коли змінюється код.

Оракул має братися не з голови автора

Шар, що ухвалює рішення, тут уже чистий. composeReply бере два такі аргументи й повертає рядок; планувальник анкети бере один об’єкт і повертає, яке питання поставити далі. Жоден не заглядає ні в годинник, ні в базу даних, ні в глобальні змінні.

Отже, кожен хід, який бот будь-коли зробив, можна було відтворити, – а я весь цей час викидав вхідні дані. У базі лишався тільки фінальний текст і перелік механізмів, що його торкалися, через кому. Цього вистачає, щоб побачити, що запобіжник спрацював, і ніколи не вистачає, щоб побачити чому, тож кожну гіпотезу про дефект у живій системі доводилося перевіряти заново: складати свіжу розмову й проганяти її через живу модель.

Уся зміна – одна таблиця: на кожен хід рядок з аргументами, з якими викликали чисті функції, висновками за цей хід і прапорцями сесії на його початок. А ще скрипт, який проганяє все це на сьогоднішньому коді й порівнює результат із текстом, що справді дійшов до кандидата.

Скрипт видає два висновки, бо зрушити можуть дві речі. Може відрізнятися текст. А може текст вийти тим самим, але зібрав його вже інший набір механізмів, – і це означає, що якийсь запобіжник тихо помер, а його відсутність просто випадково не проявилася. Жодне порівняння фінального повідомлення такого другого випадку не покаже.

Найперше він перевірив самого себе

Скрипт бере налаштування бази даних зі змінних середовища, а на будь-якій машині, крім робочого сервера, вони ведуть до локального Docker-контейнера з десятком ходів із моїх власних тестів. Тож коли я запустив його вперше – просто глянути, чи записи взагалі надходять, – він відтворив ці дванадцять і запевнив мене, що на реальному трафіку поведінка не змінилася.

Перевірка, яка не може сказати, що саме перевірила, – це те саме зелене світло над порожнечею, якого корпус і мав позбутися. Тепер кожен запуск друкує рядок із джерелом і назвою хоста, а коли джерело локальне – ще й слово LOCAL, і галочка внизу відмовляється з’являтися, якщо відтворено менше сотні ходів. Вона словами пояснює чому, а не відмовчується.

Шар, який лише віднімає, правдивого речення не напише

Кожна перевірка на чесність у збирачі відповіді щось видаляє. Повернути правдиве речення можуть лише два шари: той, що каже, що насправді знайшов пошук, і той, що підставляє нейтральний початок, коли перевірки все вичистили. І обидва працювали раніше за перевірки.

Цього ходу модель сама написала свою версію «nothing matches that exactly, but here are the closest ones». Шар, що відповідає за вступну фразу, слушно відступив, бо чернетка вже її містила. А тоді пізніший запобіжник видалив усе речення через знак питання в кінці. Вступний шар оцінив чернетку, якої на момент відправлення вже не існувало, і людина, яка шукала одну конкретну річ, отримала чотири пропозиції «майже те» і жодного слова про те, що саме потрібного їй немає.

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

Учорашні розмови дешевші за одну вигадану

Записи ходів накопичуються лише від сьогодні й далі, а корпус мені був потрібен цього тижня, а не за два. Відтворення порівнює сьогоднішні чисті функції з текстом, записаним тоді, і обидві половини лишаються чесними, хоч цей хід прожив живий кандидат, хоч годину тому скрипт заново прогнав його переписку. Тож я прогнав їх заново: 105 справжніх розмов витягнув через експорт із bearer-токеном і згодував локальній копії системи, по одному повідомленню кандидата за раз.

Тисяча шістсот сорок вісім ходів. Чотири години двадцять дві хвилини. Одинадцять доларів тридцять вісім центів. Із цих ходів відтворюються 99%; решту код обриває сам, не доходячи до жодної з чистих функцій, і скрипт рахує їх пропущеними й пише причину, а не домішує до відсотка успішних.

[адвокат диявола]

Для порівняння: одна персона – це кілька доларів за одну вигадану розмову, яка щоразу звертає на іншу гілку.

Першим новий оракул знаходить того, хто його збудував

Цього тижня я перебудовував шар перевірок у збирачі відповіді – друкував за мене Claude Code, – і корпус знайшов у цій роботі чотири дефекти ще до того, як хоч один дістався до живої людини. Два з них варто переказати, бо жоден модульний тест їх би й не шукав.

Перший сидів у самому записі. Збережені вхідні дані планувальника тримали живе посилання на лічильник запитань у сесії, а той збільшується пізніше в тому самому ході, тоді як рядок серіалізується наприкінці. Тож корпус зберіг число на одиницю більше, ніж бачив планувальник, і відтворення показало розбіжність там, де з кодом усе було гаразд. Рівно таку саму помилку я виправив днем раніше для іншого поля – і виправив як разовий випадок, а не як правило. А це та сама звичка латати по одному, від якої вся ця система й мала б мене вилікувати.

Другим було те саме видалене речення про те, що точного збігу немає.

Ще два: запобіжник, що видаляв справжнє запитання лише тому, що в ньому траплялися саме ті слова, на які він полює, і мітка, яку модель ставить на власну відповідь, – їй довіряли більше, ніж самому тексту поруч, і через це зникло повідомлення про те, що далі кандидатом займеться людина, а йшло воно кандидатові, якого щойно записали.

[макс]

Чого не бачить відтворення

Тут дві межі, і обидві чіткі.

Відтворення перевіряє нову логіку на записаних вхідних даних. Про нові вхідні дані воно не каже нічого. Коли я додав поле до аргументів збирача, у записаних ходах його не було, відтворення пішло старою гілкою і доповіло, що нічого не змінилося, – про зміну, до якої навіть не дійшло. Врятувало мене тільки те, що корпус зберігає більше, ніж самі аргументи збирача, тож відсутнє значення вдалося відновити тією самою чистою функцією, яку тепер викликає жива система. Наступного разу може й не пощастити.

І воно сліпе до рішень самої моделі, бо відтворює записані її відповіді. Чи видає модель і далі осмислену структуру після того, як я міняю, чого від неї прошу, – на це відповість лише живий прогін. Ось для чого тепер персони: їх три, і обрані вони тому, що навантажують саме половину моделі, – замість шістдесяти трьох, обраних тому, що розмови бувають різні.

А вимірювання, з якого все почалося, досі лежить там, де я його залишив. Зі 108 розмов у 63 трапляється питання, на яке кандидат уже відповідав. Це найбільший за обсягом дефект у системі, я знаю це число вже чотири дні, і це досі найбільший за обсягом дефект у системі.