Перейти до вмісту

Що Opus наплутав, будуючи динамічний воркфлоу для Claude Code

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

Схема, де один скрипт оркеструє кілька зациклених ШІ-агентів, із заголовком «Динамічні воркфлоу Claude Code».

Понад рік тому я зібрав цикл контролю якості перекладу для свого українського новинного сайту про великих котів — руками, у Laravel. Форма була проста: перекласти іншомовну статтю українською, запустити аналізатор, що вичитує результат на ознаки машинного перекладу, передати його правки редактору, що їх застосовує, і знову проаналізувати, доки виправляти не залишиться нічого. До шістнадцяти проходів для новини, тридцять два — для довшої статті. Я написав це ще до того, як почув, як хтось уживає словосполучення «агентний воркфлоу»; це просто було те, як я уявляв собі будову циклу вичитки.

Claude Code тепер постачає рівно цю форму як функцію. Динамічні воркфлоу — це JavaScript-скрипти, які викликають субагентів у циклі, розгалужують їх, передають один етап у наступний. Те, що я зробив руками, — з коробки. Тож я переніс свій цикл в один такий: невелику проєктну навичку, /translation-qa <news-id>, яка читає статтю з продакшну й запускає той самий цикл переклад → аналіз → застосування як воркфлоу.

Я керував портуванням; код писав Opus 4.8. Він наробив чимало помилок, бо функція нова, а модель насправді ще не знає, як та поводиться. Публічна документація описує воркфлоу на високому рівні; API скрипта — ту частину, яку ти власне й пишеш, — пізнаєш промацуванням. Там, де модель не знала, вона здогадувалася — з тією самою впевненістю, яку має щодо всього.

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

Середовище виконання, яке довелося пізнавати промацуванням

Першою стіною був синтаксис, який модель вигадала. Opus написав скрипт із допоміжними export function та запобіжником, що перевіряв typeof agent перед викликом. Жодного з цього в цьому середовищі немає. Скрипт навіть не запускався: SyntaxError: Unexpected keyword 'export'. Скрипт воркфлоу може експортувати рівно одне: export const meta — блок, що описує воркфлоу. Усе решта має бути inline-кодом верхнього рівня. Тут немає модульної системи, на яку можна повісити хелпери.

// the one and only export a workflow script is allowedexport const meta = {  name: 'translation-qa',  phases: [    { title: 'Translate', detail: 'translate to Ukrainian if not already' },    { title: 'Analyze',   detail: 'analyzer returns verdict + corrections' },    { title: 'Apply',     detail: 'editor applies the corrections' },  ],};// no other `export`; everything below is plain top-level code

Саме тут я й сказав їй, прямим текстом, припинити здогадуватися, як працює API, і суворо триматися документованих конструкцій. До цієї поправки я повертався найчастіше. Менша версія тієї самої пастки: скрипт завершується return верхнього рівня, що тут допустимо, але якщо перевірити файл через node --check, він повідомляє «Illegal return» і спокушає «полагодити» робочий код. Середовище — не звичайний Node, і перевірка його звичайним Node бреше тобі.

Наступна не падала, що робило її гіршою. Opus припустив, що args — вхідні дані, які я передаю воркфлоу, — приходять як об'єкт. Це не так. У цьому середовищі вони приходять як JSON-рядок. Тож A.title і A.content були undefined, цикл аналізував порожню статтю, а аналізатор бадьоро повідомляв, що виправляти нічого. Жодної помилки. Чистий прогін, який не зробив нічого. Виявив це валідаційний запуск: я звелів їй тестувати воркфлоу, а не припускати, що той працює, тож вона прогнала на зразку, побачила, що повертаються порожні title і content, і простежила це до args, які приходять рядком. Виправлення — один рядок:

const A = typeof args === 'string' ? JSON.parse(args) : (args || {});

Припущення, що падає, — це вдалий день. Припущення, що тихо видає правдоподібний результат, — те, що йде в продакшн.

Далі — модель виконання. Промпти аналізатора й редактора — мої, ті самі, що крутять мій продакшн-конвеєр, і я хотів, щоб їх доставляли як справжні системні промпти, а не вклеювали в репліку користувача. У воркфлоу єдиний спосіб це зробити — agentType: ти націлюєш виклик agent() на запечений файл .claude/agents/*.md, тіло якого стає системним промптом субагента.

const a = await agent(analyzerMsg, {  agentType: analyzerType,   // resolves .claude/agents/<type>.md; its body is the system prompt  schema: ANALYZER_SCHEMA,  model: 'opus',});

Дві речі тут забрали час. У agent() немає параметра system, щоб передати промпт inline — agentType єдиний шлях, — тож першою роботою було довести, що тіло файлу агента справді приходить як системний промпт, замість того щоб вірити, що так і є: проба з маркером-токеном, який жив лише всередині визначення агента, підтверджена, коли той токен повернувся у виводі. Другою була реєстрація. Файл .claude/agents/*.md, створений посеред сесії, невидимий, доки не перезапустиш Claude Code: проєктні агенти реєструються лише на старті, тож свіжозгенерований просто повідомляє «agent type not found» до наступного запуску. Проба й це виявила — її перший прогін упав саме з цією помилкою — і знадобився не один перезапуск, доки всі агенти не зареєструвалися.

Ще один факт про середовище виконання, який варто знати, перш ніж на нього спиратися: пісочниця воркфлоу не має файлової системи, шелу, крипто, Date.now, Math.random. Мій Laravel-цикл відстежував осциляцію, хешуючи кожну версію статті, щоб упіймати, як редактор кидається між двома формулюваннями, і моя інструкція була питанням здійсненності: чи може це виявлення працювати всередині воркфлоу, а якщо ні — викинути його. Тут Opus вгадав правильно. Хешування md5 не портується, бо крипто немає, але саме виявлення — це лише порівняння речень одного циклу з реченнями раннішого; хеш був тільки оптимізацією місця, тож зберігаєш натомість нормалізовані рядки — і воно працює як слід. Викинути його було вибором, а не обмеженням: ту машинерію кидань я будував для слабших моделей, які борсалися, а Opus сходиться й без неї. Обмеження реальні, і висновок тримається: усе, що потребує часу, випадковості, хешування чи файлу, має жити поза воркфлоу або передаватися всередину. Просто відстеження осциляції було не тим, що вбили; його викинути — моє рішення.

Знати, коли зупинитися

Умова виходу з циклу була тією частиною, де я мав найбільше продакшн-досвіду, і тією, яку Opus сам вгадав найгірше.

Версія Opus зупиняла цикл першого ж разу, коли аналізатор повертав «Ні» — спосіб аналізатора сказати, що виправляти більше нічого. Один чистий вердикт — і статтю оголошено готовою. Я знав, що це хибно, бо Laravel-цикл навчив мене, що один чистий прохід — не доказ: аналізатор може раз дати добро, а тоді впіймати щось уже на наступному проході по тому самому тексту. У продакшні я переганяю, доки не отримаю два чисті читання поспіль. Тож я звелів воркфлоу робити так само. На «Ні» не зупиняйся; пройди ще один прохід аналізу для підтвердження й завершуй цикл лише тоді, коли два лягають чисто поспіль. Одне щасливе «Ні» більше не може його закрити.

Друга половина умови зупинки виявилася хибною тривогою, і погоня за нею навчила мене, як читати власну звітність воркфлоу. Цикл має стелю — шістнадцять циклів для новини й тридцять два для довшої статті, ті самі межі, що їх уживає продакшн-конвеєр. На прогоні статті воркфлоу надрукував «16/16 agents done», і я прочитав це як спрацювання стелі на шістнадцяти, тоді як статті мало б дозволятися тридцять два. Це була не стеля. «16/16» — це підрахунок середовищем виконання кожного виклику agent(), який зробив воркфлоу: аналізатор, підтвердження й редактор, складені по всіх циклах, — а цикл зупинився рано, бо зійшовся, два чисті проходи, і близько не до стелі в тридцять два. Дійти до стелі — це означає здатися: воркфлоу повертає outcome: 'capped', тобто в нього скінчилися спроби, перш ніж аналізатор узагалі дав добро. Сходження — це добрий вихід, outcome: 'converged-clean'. Лічба агентів і стеля циклів — різні числа, а рядок прогресу показує тобі те, про яке ти не хвилювався. Жодної помилки не було. Я позначив «16», бо знав, що статтю має пройти тридцять два проходи; та частина була моя. Але число на екрані було не стелею, і воркфлоу був у порядку.

Дістати статтю назад чисто

Перша редакторська схема Opus мала два поля, title і content — форма, що видається очевидною. Але промпт редактора був моїм продакшн-промптом, написаним відповідати в # Title і далі тіло як Markdown. Схема й промпт суперечили одне одному, і сухий прогін це виявив. Коли її проштовхнули крізь схему з двома полями, модель заповнила її хибно: title повернулося як «Виправлені поля статті», буквально «corrected article fields». Вона описала поля замість того, щоб видати статтю. Схема виграла формат і програла зміст.

Виправленням було припинити боротися з промптом. Одне поле markdown, розібране так, як продакшн уже його розбирає: перший рядок — заголовок, прибрати #, решта — тіло.

const EDITOR_SCHEMA = {  type: 'object',  required: ['markdown'],  properties: { markdown: { type: 'string' } },};
const md = ed.markdown.trim();const nl = md.indexOf('\n');const title   = md.slice(0, nl).replace(/^[#*\s]+/, '').trim();const content = md.slice(nl + 1).trim();

Структурована схема не дається задарма. Прикрути багатопольну схему до промпта, написаного видавати вільний текст, — і ці двоє суперечитимуть. Узгодь схему з тим, як промпт уже відповідає, або перепиши промпт під схему, але не роби обидва наполовину.

Споріднений витік: дату конкретної статті не запекти в статичного агента, бо вона змінюється щопрогону, тож вона їде разом у репліці користувача. Дата має значення — це власна дата статті, якір, що не дає агенту, який мислить у сьогоднішньому часі, «виправити» відносну згадку в матеріалі, написаному тиждень тому. Але на варіанті наукової статті редактор склав рядок дати, «Дата статті: …», у тіло статті. Вона з'явилася там і не з'явилася на варіанті новини — на тому самому коді воркфлоу. Витік, який спрацьовує на одному шляху й не спрацьовує на іншому, не можна вилучити тестуванням; його треба усунути проєктуванням. Виправлення — відгородити дату як явний позасмуговий текст і сказати агенту її не повторювати:

[СЛУЖБОВЕ, не частина статті — НЕ включай у відповідь] Дата статті: <date>

Префікс у дужках означає, грубо кажучи, «службовий текст, не частина статті — не включай його у свою відповідь», а інструкція застосування повторює правило прямим текстом: не додавай дату чи будь-який службовий рядок до тіла. Щойно метадані відгороджено й названо, витік припиняється.

Білий список проти чорного

Спусковим гачком був прогін, де субагент забрів геть від роботи. Аналізатор провів шість веб-пошуків (досліджував термінологію гепардів і факти про заповідники, щоб звірити з ними статтю), а субагент зайшов через шел у Laravel-кодову базу, всередині якої випадково запускався. Веб-пошуки були виправдані; звірити український термін чи власну назву з авторитетним джерелом — це справжня перекладацька робота. Читати репозиторій для перекладу статті безглуздо, і саме це й слід було спинити.

Перше виправлення Opus — білий список, перелік tools, що називав, чим кожен агент може користуватися, звужений майже до нічого. Хибний інструмент із двох причин. Він заблокував би веб-дослідження, якого аналізатор законно потребує. А надто вузький білий список ризикує винести власний інструмент структурованого виводу воркфлоу — механізм, яким агент повертає свою відповідь за схемою, який середовище виконання впорскує й про який документація не каже, що він винятковий; втратиш його — і агент узагалі не зможе відповісти. Безпечніший інструмент — чорний список: заборони файлову систему, шел і інструменти редагування, веб-пошук лиши, і ніколи випадково не ризикуй заглушити інструмент структурованого виводу.

---name: translation-analyzerdisallowedTools: Read, Write, Edit, NotebookEdit, Bash, Grep, Globmodel: opus---

Те саме блукання вискочило на рівень вище — в оркестраторі. Він вирішив, що іншомовна, ще не перекладена стаття, що лежить у черзі вичитки, — це «незвичний стан, вартий перевірки», і взявся розслідувати, тоді як це рівно той звичайний випадок, заради якого навичка й існує. Мені довелося прописати рамки явно: роби фази, не перевіряй стан статті, не лізь читати кодову базу, не витрачай токени, підозрюючи нормальний вхід. Лінгвістичні агенти можуть витрачати скільки треба на власне переклад; оркестратор довкола них лишається ощадливим.

Байти, які моделі не варто нести

Статті — це близько двадцяти кілобайтів української. Спершу модель несла цей текст сама: вона вклеювала статтю у вхід воркфлоу, а base64 — у команду запису в базу. Великі мовні моделі псують довгі рядки, що не є справжньою мовою, а base64 української — саме воно: довга низка [A-Za-z0-9+/=] без лінгвістичної структури, за яку можна вхопитися. На справжньому прогоні запис повернувся пошкодженим. Той я впіймав, і питання, яке я поставив, було не як полагодити кодування — а навіщо модель узагалі відтворює base64 руками.

А не варто. Виправлення вивело модель із байтового шляху на обох кінцях. Байти тепер обробляють два невеликі допоміжні скрипти: один серіалізує статтю у вхід воркфлоу, інший base64-кодує результат у скрипті й виконує запис. Модель лише передає шлях до файлу та id. base64 лишається транспортом, бо обходить тричі вкладене шел-екранування апострофів і лапок-ялинок дорогою до бази, — але тепер його генерують і споживають скрипти, ніколи не вистукує модель. Сам запис захищено так, як і має бути захищено продакшн-запис: оптимістичне блокування, що оновлює рядок лише якщо його вміст досі збігається зі знімком, прочитаним на старті, і підрахунок зачеплених рядків, що зривається на нулі замість того, щоб вірити, що запис ліг.

Інструктаж, який я мав написати першим

Глибша помилка була моєю. Я вручив Opus роботу з портування й майже нічого з того, що знав. Кожна проблема вище — це те, що я міг сформулювати одним реченням, перш ніж він написав хоч рядок. Тож ось інструктаж, з якого я мав почати, — той, що піде поперед моделі наступного разу. Він короткий:

  • Скрипт експортує лише export const meta; усе решта — inline верхнього рівня. return верхнього рівня нормальний, хоч node --check і не згоден.
  • args приходять як JSON-рядок, тож парсь їх. Для чогось великого запечи це в скрипт і не передавай нічого.
  • Пісочниця не має файлової системи, шела, крипто, Date.now чи Math.random. Усе, що їх потребує, живе ззовні.
  • Не вір одному чистому проходу; вимагай двох поспіль. А лічба агентів середовищем виконання — це підрахунок викликів agent(), а не твоя стеля циклів — читай outcome, а не лічильник.
  • Субагенти отримують системний промпт через agentType; параметра system немає, а нові файли агентів потребують перезапуску, перш ніж зареєструються.
  • Узгодь схему структурованого виводу з тим, як промпт уже відповідає. Не прикручуй багатопольну схему до промпта з вільним текстом.
  • Обмежуй інструменти чорним списком, ніколи білим.
  • Модель ніколи не несе довгий текст чи base64; це роблять скрипти, а запис захищено оптимістичним блокуванням на рядку, який вона прочитала, а не надією, що байти вціліли.
  • Коли модель заявляє про поведінку фреймворку, звір це з джерелом. Упевнено й хибно читається точнісінько як упевнено й правильно.

Останній пункт — той, що взагалі робить інструктаж потрібним. Модель не скаже тобі, коли вона здогадується, тож прогалини ніколи не дають про себе знати. Інструктаж має нести те, у браку чого модель не зізнається, і написати його, а не код, — і є справжньою роботою.

Більшості багів я не впіймав. Валідаційні запуски впіймали порожню статтю; сухі прогони впіймали зіткнення схем і витік дати; проба впіймала агента, що не реєструвався. Що я впіймав — це систему, що поводиться неправильно у відкриту: оркестратор трактує цілком нормальну статтю як стан, вартий розслідування, субагент читає Laravel-кодову базу замість перекладати, запис, що повернувся пошкодженим. То були місця, де я дивився, як система поводиться, замість читати її код. Решту модель знайшла сама, бо я змусив її перевіряти замість припускати. Мій рік зі старим циклом дав мені ціль і специфікацію — два чисті проходи, стелю, що викинути, як має читатися чистий переклад. Але виловлювати баги середовища виконання було рідко моєю роботою. Натомість я робив таке: вирішував, що будувати, тримав модель при документації й відмовлявся дати їй назвати себе готовою.

Увімкніть коментарі, прийнявши куки.

Необхідні куки працюють завжди. Куки для статистики та коментарів використовуються лише з вашої згоди. Докладніше