Цілий вівторок я писав запобіжник, щоб один запобіжник перестав сперечатися з іншим запобіжником.
Стерегли вони чат-агента, який веде структуровану розмову-анкету. Він розпитує про ваш досвід і навички, де ви хочете працювати й коли зможете почати – загалом 11 полів, – а тоді пропонує з живого каталогу те, що вам пасує. Під капотом дев’ять інструментів, а назовні – одна розмова, яка мусить звучати як жива людина.
Після моделі стояло ще близько 25 запобіжників у звичайному коді: вони правили її текст, перш ніж його побачить кандидат. Одинадцять із них переписували сам текст, і назви мали на кшталт stripLeakedToolCalls та enforceOneQuestion, а два з них ніяк не могли дійти згоди, чи речення вже переписане.
Ось до чого я дійшов: запобіжник – це правило, яке живе там, куди не дотягнеться жоден тест, і коли система все більше обростає запобіжниками, вона розповідає вам, де живуть її правила, а не те, який ретельний у неї автор. Кожен мій запобіжник вмикався вже після того, як рішення ухвалено, працював із готовим текстом і бачив лише кінцевий рядок. Правилам на такому рівні потрібні правила про правила – і мені довелося самому таке збудувати, щоб у це повірити.
Запобіжник – це правило, що спрацьовує вже після рішення
Чотири місяці я вважав, що агент – це кастрований ChatGPT. Прив’язуєш загальну модель до ролі, видаєш їй фіксований набір інструментів – і маєш працівника, який ніколи не спить. Перша версія саме такою й була, і працювала чудово – аж доки їй не почали відповідати живі люди.
[тодішній макс]Живі люди на питання про вік називають рік народження. Надсилають два номери телефону – або один, але без однієї цифри. Буває ім’я, з якого не вгадаєш стать, і бот мусить про неї спитати так, щоб не вийшло дивно. Буває запитання без знака питання, і коду, що стоїть далі, доводиться вгадувати: відповідати чи йти далі.
Кожен такий випадок ставав рядком у системному промпті, аж поки промпт не розрісся так, що модель почала пропускати його шматки повз вуха. Тож уся архітектура звелася до гасла: ШІ реагує, код перевіряє. Перш ніж відповідь потрапляла до кандидата, код переглядав чернетку, записував те, що модель записати забула, і підказував їй, як поставити наступне питання.
Кілька тижнів я почувався всемогутнім: будь-яка моя забаганка могла стати ще одним запобіжником.
А тоді відхилення посипалися одне за одним. Номер телефону дають аж наприкінці, коли вакансія видається вартою того. Посеред розмови передумують, де хочуть працювати. В одній переписці жінка шукає роботу і собі, і чоловікові. У текст просочується уламок виклику інструмента. Бот, ще не знаючи, як звати кандидата, називає його своїм іменем.
Кожне виправлення потрапляло в одне й те саме місце, бо інших місць уже не лишилося: після того, як модель написала речення, – просто в це речення.
Найсильніший аргумент за запобіжники – кожен виріс із реального випадку
За кожним запобіжником стояв справжній інцидент і коментар у коді, який цей інцидент називав. Жоден не з’явився «про всяк випадок», і кожен окремо був найдешевшим правильним виправленням, яке я мав того дня. Хто скаже, що варто було одразу краще все спроєктувати, той сперечається з тим, як насправді приходили вимоги: по одному кандидату за раз.
Найкращою ідеєю в усьому підході був аудитор. 14 липня я дав дешевій другій моделі чернетку відповіді й те, що насправді сталося за цей хід, – реальні виклики інструментів – і спитав: чи не приписує собі текст дії, якої не було? Аудитор розумів сенс, тож перефразування повз нього не прослизнуло б. Йому було байдуже, якою мовою відповідає агент, тож він упорався б і з тими мовами, під які мої шаблони ніколи не писалися. На першій же перевірці на живих даних він зловив речення на кшталт «let me check a few more details» – обіцянку зробити щось потім, сформульовану так, як не передбачав жоден мій шаблон.
Саме такому аргументові я й готовий програти – і години зо шість я вигравав.
Точність коректор мусить заслужити
На прогоні з 15 вигаданими кандидатами аудитор спрацював 18 разів із точністю близько 17%. Ось три речення, які він «полагодив»:
- Наша еталонна, власноруч затверджена фраза про те, що з кандидатом незабаром зв’яжеться людина.
- «I have signed you up for that one» – хоча в самого аудитора перед очима лежало
record_interest → ok:true, recorded:[75604]. - «So when could you start?» – звичайне запитання, яке взагалі нічого не стверджує.
Спершу я подумав, що йому бракує контексту, і став давати аудиторові не назви інструментів, а результати їхньої роботи. Щойно я записав у лог, що саме він отримує, питання закрилося: результати були на місці, а він однаково бив на сполох. Маленька модель, якій доручили пам’ятати стільки винятків, якісь із них неодмінно загубить, а хибне виправлення коштує дорожче за пропущене, бо псує той природний голос, заради якого все це й будувалося.
[адвокат диявола]Тож я звузив його до одного-єдиного питання: чи обіцяє бот сам щось зробити згодом? До того ж це єдине, чого не вміли запобіжники в коді. Я виміряв звужену версію через npm run audit:probe, і вона набрала 8 із 8 і за повнотою, і за точністю – разом із двома обіцянками самого бота мовами, під які мої шаблони ніколи не писалися. Кількість спрацювань за весь прогін упала з 18 до нуля.
А тоді я подивився, що ж у мене вийшло. Модель, уся робота якої – не довіряти іншій моделі, обгорнута власними запобіжниками, і ставить вона одне-єдине питання, на яке задарма відповіло б одне поле в структурованій відповіді.
Перенести рішення означає прибрати збій, а не ловити його
Через два дні переписаний код уже працював: мінус 1500 рядків і три зміни.
Модель більше не пише фінального повідомлення. Вона повертає частини: що мала на увазі, як відреагувала, відповідь на запитання кандидата, якщо воно було, і як поставити наступне питання анкети. Рядок складає код. Уламки викликів інструментів, два питання на одному подиху, речення, обірване на півслові, – у ході розмови просто не лишилося кроку, де щось із цього могло б з’явитися, тож запобіжникам, які раніше це вичищали, більше нема роботи.
Модель більше не вирішує, якого поля бракує. Це робить чиста функція, тож нікого вже не питають про одне й те саме двічі.
Кожне твердження має мітку наміру із закритого списку з 11 варіантів, а код проглядає журнал викликів інструментів за цей хід і звіряє. Якщо відповідь каже, що знайшла кілька вакансій, а пошуку в журналі немає, код або запускає цей пошук, або знижує відповідь до чогось чесного. Виправлення лишає відповідь моделі слово в слово й підміняє тільки хибне твердження – саме цього весь час і намагалася досягти хірургія над текстом, тільки тепер так улаштована сама система.
- усі правила живуть у системному промпті, а модель частину з них пропускає, щойно він задовгий
- модель сама пише все фінальне повідомлення
- модель вирішує, якого поля ще бракує
- ~25 запобіжників переписують чернетку перед відправленням
- друга модель перевіряє першу, і їй самій потрібні запобіжники
- дешевий розбір змісту (NLU) читає повідомлення ще до будь-якої генерації
- модель повертає частини; рядок складає код
- наступне поле обирає чиста функція
- закритий список з 11 намірів, кожен звіряють із журналом інструментів за цей хід
- хибне твердження – це окреме поле зі своїм типом, тож аудиторові більше нема чого читати
- ● 01звіркадешевий розбір змісту (NLU), ще до генерації
- ● 02планplanIntakeMove(): поле обирає код
- ● 03генераціяпласка відповідь: намір, реакція, відповідь, питання
- ● 04перевірканамір звіряють із журналом інструментів
- ● 05складанняcomposeReply(): єдине місце, де складається рядок
Міняти все це було страшно, бо старе таки працювало. Тож – тести: 804 зелені того вечора, коли запрацював новий структурований цикл, і 1190 на момент, коли я це пишу. Дев’яносто сім вигаданих кандидатів, по 16 ходів кожен, а людину навмисно погано грає інша модель: відповідає лише на те, про що спитали, по одному факту за раз – так, як роблять живі люди і як ніколи не роблять заготовлені тестові дані. Один кандидат чотири ходи поспіль намагається зламати агента, не може, а тоді заспокоюється і, як усі, шукає роботу. Одне правило в цьому тестовому стенді існує лише для того, щоб вигаданий кандидат часом пропускав знайомство й одразу починав із питання, – саме в такій ситуації агент раніше звертався до незнайомця на єдине ім’я, яке було в кімнаті. Своє.
[макс]Чого не купиш, перенісши правила в код
Системний промпт вийшов із цієї історії довшим, ніж увійшов. Я думав, що коли перенесу правила в код, він схудне. А переїхала несуча частина – туди, де її дістане тест, і це зовсім не та перемога, по яку я йшов.
І ще одне: теза допомагає лише тоді, коли правила вже існують. Цей агент розмовляє з кандидатом, а служить компанії: дає відгук живою мовою і веде розмову за сценарієм. Винахідливо розв’язувати чиїсь проблеми в його завдання ніколи не входило – тому правила взагалі й вдалося перелічити.
[адвокат диявола]Так. Агент із по-справжньому відкритим завданням, який служить меті самого користувача, не має такого списку, щоб перенести його в код, – і все, про що я тут пишу, довелося б доводити заново, з нуля.
Тепер у мене є переконлива надія, що цей агент поводитиметься передбачувано і його буде дешево розширювати. Рано чи пізно ми знову перевантажимо його мінливістю й суперечностями власних вимог, за якими самі погано стежимо, і він знову почне обростати запобіжниками. Різниця в тому, що тепер кожен новий запобіжник я читаю як питання: де насправді місце його правилу? І досі не знаю, скільки їх має набратися, щоб це питання я вже не міг не поставити.