Перейти к содержимому

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

Включите комментарии, приняв куки.

Необходимые куки работают всегда. Куки для статистики и комментариев используются только с вашего согласия. Подробнее