Замер здесь редко состоит из одного агента. Обычная работа у меня это 12 штук, на которые надо посмотреть, а с каждой по 3 действия: прочитать источник, потом вытащить числа и сверить их обратно с ним. Выходит 36 агентов и запускает их файл на JavaScript с циклом внутри. Когда меня спрашивают, как это устроено, я называю такую сборку детерминированной. На этой неделе я сел разобраться, какую часть прогона это слово покрывает.
Нужно мне от него возобновление. Прогон из 36 стоит денег и примерно 20 минут ожидания. Поэтому когда я правлю предложение в третьей стадии, я хочу заплатить за третью стадию и взять остальных 24 агентов из журнала прошлого прогона. В этом всё обещание. Держится оно только если второй прогон просит то же самое в том же порядке и я ни разу не проверял, что это так.
Стенд
В оркестраторе 118 строк. Там есть agent(), parallel(), который ставит барьер между стадиями, pipeline(), который не ставит, семафор FIFO на потолок параллельности и журнал, куда попадает каждый вызов. Ничто в нём не обращается к модели. Агент там ждёт заданное число миллисекунд и возвращает строку, собранную из собственного имени.
Миллисекунды настоящие. Я записал 30 вызовов через claude CLI 2.1.235 на claude-opus-5, из пустой папки. CLAUDE.md в рабочем каталоге попадает в промпт молча и сдвигает ответ и время. Односложные ответы дали в среднем 6742 мс. Ответы на 700 слов дали 40164 мс, средний вес встал на 11043. Весь набор уложился в промежуток от 5790 до 54520 мс и я проигрываю эти длительности делёнными на 20, чтобы сетка из 36 заканчивалась за секунды. Node 22.23.1, 4 ядра.
Заодно я поставил число на границу процесса, потому что и этого раньше не делал. Тот же прогон настоящими дочерними процессами вместо таймеров занял 5674 мс против 5497, то есть 4.9 мс на агента. Повтор дал 5730 и 6.5 мс. Рядом с агентом, которому нужно от 6 до 55 секунд, любое из этих чисел ничто. Только цену планировщика это не измеряет. Семафор и журнал работают в обоих плечах и сокращаются, так что цикл вокруг агентов остаётся неизмеренным.
Барьер против конвейера
На потолке 4 версия с барьером занимает 8514 мс, версия с конвейером 6986. Если снять потолок, чтобы все 36 шли разом, выходит 6644 против 5053, разрыв в 23.9 процента. Обе формы в этот момент стоят в 14 мс от собственного предела. Барьер не может уложиться быстрее, чем сумма самых медленных агентов по стадиям, а это 6630 мс на такой сетке. Конвейер не может уложиться быстрее самой медленной цепочки из 3, а это 5044.
Я хотел убедиться, что выигрыш даёт именно разброс длительностей, поэтому я прогнал ту же сетку со всеми длительностями, выставленными в среднюю. Барьер после этого не стоит ничего на потолках 4, 16 и 36. На потолке 8 он всё равно стоит 16.7 процента, потому что 12 элементов не делятся на 8 и в последней волне каждой стадии работают 4 агента, пока 4 слота простаивают.
Замер, который меня обманул
Дальше я прогнал ту же сетку 30 раз на потолке 4 и захешировал порядок, в котором делались вызовы. Обе формы вернули 1 уникальный порядок на 30 прогонов. Порядков завершения вышло 2 из 30 у барьера и 1 у конвейера. Повтор всего замера дал у барьера тоже 1, то есть и эта 2 была шумом таймера. Идеальная воспроизводимость. Я верил в неё минут 10, пока не заметил, что мои поддельные агенты всегда занимают ровно одно и то же время. Настоящие так не умеют. Я отправил один и тот же промпт 24 раза в 2 захода и ответ приходил в промежутке от 7347 до 10586 мс.
Так что я вернул этот разброс на место. Каждая проигрываемая длительность теперь умножается на один из тех 24 измеренных коэффициентов. С дрожанием барьер по-прежнему даёт 1 уникальный порядок вызова на 30 прогонов, а конвейер даёт 26. Порядок завершения у обоих 30 из 30.
Причина в том, откуда берётся номер вызова. У барьера все 12 вызовов стадии делаются одним синхронным проходом, поэтому вызов номер 0 это элемент 0 стадии 0 в любом прогоне, который вообще случится. У конвейера вызов делается, когда вернулась предыдущая стадия этого элемента, поэтому номер достаётся тому, кто закончил первым.
Журнал
У моего журнала ключ это номер вызова. Он повторяет самый длинный префикс, который ещё совпадает. Для скрипта, который и есть цикл, это очевидное правило. С барьером оно работает ровно как обещано. Без правок выходит 36 попаданий из 36, правка последней стадии даёт 24, правка средней даёт 12, правка первой даёт 0.
Конвейер с тем же ключом возвращает 12 из 36, когда не поправлено вообще ничего и входы те же самые. 24 агента покупаются во второй раз. Первые 12 совпадают всегда, потому что это те 12 вызовов первой стадии, которые конвейер делает сразу, до того как что-либо успевает вернуться. С вызова 12 журнал и возобновлённый прогон расходятся. 2 свежих прогона того же скрипта расходятся впервые на вызове 13, а возобновлённый расходится на 1 вызов раньше. Причина резче, чем шум. Повторённый агент возвращается мгновенно, поэтому все вызовы второй стадии уходят по порядку элементов и номер 12 достаётся элементу 0. Я проверил это на 60 сетках и на всех 60 держалось. В журнале номер 12 принадлежит тому, кто выиграл гонку, а на этой сетке это был элемент 1.
Число 12 это частый случай, а не закон. Из тех же 60 сеток 47 порвались ровно на 12, 12 порвались на 13 и 1 на 14, потому что иногда элемент 0 свою гонку всё-таки выигрывает.
Ключ по задаче вместо ключа по позиции это чинит. Обе формы после этого возвращают 36 из 36 на нетронутом скрипте и 24 из 36 после правки любой одной стадии. Для работы из 3 стадий это честное число, потому что изменённая стадия и есть треть работы. Держится оно только пока промпт агента не тащит в себе текст предыдущего. Половина моих настоящих стадий делает ровно это, так что их ключ меняется, как только меняется ответ предыдущей стадии.
Сами агенты не повторяются вообще
Тот же промпт, 24 раза, дал 24 различных ответа длиной от 375 до 473 знаков. Возобновлённый прогон поэтому кладёт старые ответы рядом с новыми. Это никогда не тот прогон, который получился бы, начни я заново. Ничто в результате здесь не воспроизводится, поэтому единственное, что журнал способен удержать, это план и ключ.
Чего я не проверял
Оркестратор не зовёт модель, поэтому всё это про планирование запусков и ничего про качество. Одна машина, 4 ядра, без сети и без лимитов. Ни один агент на стенде не падает, а это как раз тот случай, ради которого возобновление и нужно. Числа для него у меня нет. Одна сетка, одно зерно, 3 повтора на точку во временном замере против 30 в замере порядка и 10 в замере возобновления. Эти 10 прогонов возобновления читают один и тот же журнал, поэтому разойтись между собой они и не могли. Цена самого планировщика нигде не отделена от цены процесса, который он запускает. Ключ по содержанию у меня к тому же просто имя задачи, а настоящий был бы хешем промпта и опций.
Свой журнал я перевёл на ключ по содержанию и конвейер оставил как был. Следующее, что стоит померить, это что со всем этим делает падение в середине.