- BrainTools - https://www.braintools.ru -
Шестьдесят дней назад я держал всю координацию своего SaaS в голове. Двенадцать вкладок Claude Code, в каждой своя роль: техлид, аналитик, тестировщик, медиабайер. Я переключался между ними и был единственным местом, где эти вкладки узнавали друг о друге.
Сейчас в очереди 1002 задачи, из них 954 закрыты. Медиана от постановки до закрытия составляет 47 минут. Вся координация умещается в 600 строк Python на стандартной библиотеке и один markdown-файл.
Статья про то, как устроена координация. Не про то, что я попробовал модный инструмент, и без советов «промптите лучше». Три подхода, которые не сработали. Один, который сработал. Отказ, стоивший мне двух рабочих окон. И метрики, которые я не приукрашивал.
Кода в открытом доступе нет, это внутренний инструмент, а не библиотека. Но механизмы я показываю снипетами, их хватит, чтобы собрать своё.
Я делаю SKUmind, аналитику прибыли для продавцов на Ozon. Один основатель, никакой команды. Разработку веду через Claude Code: открываю несколько окон, в каждом даю модели роль и контекст, каждое окно занимается своим куском.
Первые недели это работало. Потом окон стало восемь, и я заметил, что провожу день не за задачами, а за пересказом. Техлид дописал миграцию, надо пойти к тестировщику и сказать, что можно проверять. Аналитик уточнил требование, надо пойти к техлиду и передать. Медиабайер собрал ключевые слова, надо отнести их тому, кто пишет статьи.
Информация ходила через меня. Я стал не архитектором, а маршрутизатором пакетов. Причём маршрутизатором с плохой памятью [1]: к вечеру я не помнил, кому что передал и что осталось висеть, а иногда переставал понимать, чем занимался весь день.
Это ощущается как рост производительности, потому что руки заняты. На самом деле это потолок: сколько бы окон я ни открыл, пропускная способность упирается в скорость, с которой я успеваю носить сообщения между ними.
Нужен был способ передавать работу между окнами так, чтобы я не был обязательным звеном.
Первая мысль была простая: пусть каждое окно знает, кто оно, через переменную окружения. SKUMIND_AGENT=qa, и скрипт при запуске понимает, чей это инбокс.
Не полетело сразу. Я работаю в панелях VS Code, а панели делят одно окружение процесса. Переменная, выставленная для одной, видна всем. Все окна одинаково считали себя последним, кого я объявил.
Полдня на то, чтобы понять: среда вообще не умеет их различать.
Вторая идея была амбициознее: поднять бота, каждое окно шлёт сообщения в общий чат, читает адресованное себе. Красиво, работает с телефона, история сама собой ведётся.
Начал прикидывать схему и остановился. Бот решал доставку сообщения. Но проблема была не в доставке: окна на одной машине, файл в общей папке доставит что угодно. Проблема была в том, что нет структуры. Кто кому что должен, что висит, что закрыто.
Без структуры это превращается в чат, где всё написано и ничего не найдено. Плюс цикл «бот будит агента, агент пишет боту» я представил и не понял, где он останавливается.
Третья попытка: положить описание ролей и правил координации в CLAUDE.md [2], файл, который Claude Code читает автоматически.
Технически сработало бы. Но CLAUDE.md [2] лежит в репозитории, его видит любой, кто откроет проект. Пихать туда служебные инструкции для координации агентов значит смешивать код с оснасткой процесса. К тому же файл один, а проектов у меня несколько, и роли в них разные.
Отказался. Протокол в итоге живёт в хуке, об этом ниже.
Паттерн, который сработал, называется blackboard. Общая доска, куда участники пишут задачи и откуда их забирают. Он канонически подходит для топологии «сеть», а не «цепочка»: когда любой участник может передать работу любому, а не только следующему по конвейеру.
У меня именно сеть. Аналитик пишет требования техлиду. Техлид отдаёт задачу тестировщику. Тестировщик находит баг и возвращает техлиду. Медиабайер отдаёт ключевые слова тому, кто пишет тексты. Любой может эскалировать вопрос основателю.
Доска это обычный markdown-файл. Секция на каждого участника, строки задач внутри:
## coder-tl - [open] 2026-07-10 (from ba): уточнить контракт эндпоинта /v1/pnl - [done] 2026-07-09 (from qa): баг в пересчёте комиссии при возврате
Всё. Никакой базы, никакого сервера. Файл лежит локально, рядом с конфигом Claude Code, специально не в облачной папке: параллельная запись из нескольких окон в синхронизируемую директорию даёт конфликтующие копии и потерянные задачи.
Вот что отличает эту доску от списка задач. У каждой роли есть поле boundaries, формальное описание того, чего она не делает и кому передаёт.
{ "id": "coder-tl", "name": "Техлид", "boundaries": "Архитектура, ревью, код. Деплой на прод только ты." }, { "id": "ba", "name": "Бизнес-аналитик", "boundaries": "ЧТО строить: требования, ТЗ, спеки. Не кодишь: код передаёшь техлиду. Не продаёшь." }, { "id": "mediabuyer", "name": "Медиабайер", "boundaries": "Платный трафик, ключи, брифы для Директа и РСЯ. Не кодишь." }, { "id": "qa", "name": "QA-инженер", "boundaries": "Находишь, репортишь и верифицируешь баги. Не чинишь код, фикс передаёшь техлиду." }
Эта строка подставляется в системный промпт агента при каждом ходе. Не «мы договорились, что тестировщик не чинит код», а «тестировщику при каждом сообщении напоминают, что чинить код не его дело, и говорят, кому передать».
Без этого модель вежливо помогает за пределами роли. Тестировщик начинает править баг, аналитик пишет код. Каждый раз по отдельности это выглядит полезно, а в сумме разваливает разделение труда, ради которого всё затевалось.
Раз переменные окружения не работают, нужен другой способ понять, какое окно чем является.
Решение: Claude Code передаёт хукам session_id, идентификатор сессии окна. Он стабилен, пока окно живо, и уникален между окнами. Осталось связать его с ролью.
Связывание происходит первым сообщением в окне. Я пишу одно слово: qa. Хук ловит его, узнаёт валидный идентификатор роли и записывает связку в файл:
cm = CLAIM_RE.match(prompt) aid = cm.group(1).lower() if cm else None if aid in AGENT_TO_PROJECT: pid = AGENT_TO_PROJECT[aid] m[sid] = {"project": pid, "agent": aid} save_map(m) emit(aid, pid, with_protocol=True) return
Дальше окно знает себя до самого закрытия.
Одна деталь, на которой я споткнулся: сначала я хотел синтаксис @qa, привычно и наглядно. Оказалось, VS Code перехватывает символ @ для своих упоминаний, и до хука доходит не то, что я напечатал. Пришлось взять голый идентификатор без префикса. Регулярка вышла такой:
CLAIM_RE = re.compile(r"^s*=?s*([a-z][a-z0-9-]*)s*$", re.IGNORECASE)
Слово целиком, ничего лишнего. Опциональный = остался рудиментом раннего варианта, держу для совместимости с мышечной памятью.
Хук глобальный: он срабатывает в любом окне Claude Code на машине. Но у меня открыты и обычные окна, не относящиеся к агентам. Спамить их протоколом координации плохая идея.
Логика [3] в три ветки:
if hook: # первое слово = валидная роль → запомнить и выдать протокол if aid in AGENT_TO_PROJECT: ... # окно уже связано → подсунуть свежий инбокс elif sid in map: ... # окно ничьё → молчать else: return
Третья ветка важнее первых двух. Инструмент, который лезет туда, куда его не звали, довольно быстро отключают.
Весь инструмент это четыре файла на Python 3.14. Импорты: json, os, re, sys, argparse, datetime. Ни одной внешней зависимости.
handoff.py [4] (200 строк) отвечает за запись. Поставить задачу, закрыть задачу, оставить заметку самому себе.
python handoff.py --to qa --from coder-tl --task "протестить PR #493" python handoff.py --done --to qa --match "PR #493"
inbox.py [5] (406 строк) отвечает за чтение и хук. Определяет, кто спрашивает, и выдаёт его открытые задачи. Здесь же живёт текст протокола.
board.py [6] и server.py [7] рисуют борд. Локальный веб-борд на ThreadingHTTPServer, обновляется каждые пять секунд, показывает светофор по каждой роли: чисто, висит меньше суток, висит больше двух.
У тимлида двенадцать открытых, старейшая на восемнадцатый день.
Весь протокол доски описывается тремя регулярками:
HEAD_RE = re.compile(r"^##s+([w-]+)s*$") LINE_RE = re.compile(r"^- [(open|done)]s+(d{4}-d{2}-d{2})s+(froms+([w-]+)):s*(.+)$") CLAIM_RE = re.compile(r"^s*=?s*([a-z][a-z0-9-]*)s*$", re.IGNORECASE)
Секция роли, строка задачи, объявление идентичности. Больше в формате ничего нет.
Роли, границы, правила эскалации подставляются в контекст агента через хук на события SessionStart и UserPromptSubmit. Не через системный промпт, который я бы забыл обновить. Не через CLAUDE.md [2], который общий для проекта.
Хук читает из stdin JSON с session_id, именем события и текстом сообщения, смотрит в реестр ролей и печатает в stdout то, что нужно этому окну прямо сейчас. Claude Code добавляет напечатанное в контекст.
Побочный эффект оказался важнее задуманного: инбокс подсовывается при каждом сообщении, а не только при старте окна. Агент физически не может забыть о висящей на нём задаче, она перед ним на каждом ходу.
Код занял выходные. Правила отлаживались два месяца, и они важнее кода.
Правило звучит так: любой вопрос, решение или блокер, который ждёт меня, агент обязан записать задачей в мою секцию. Не написать в чат окна.
Причина простая. Написанное в чат живёт в одном окне и умирает вместе с ним. Записанное в очередь появляется на общем борде, где я его вижу, даже не открывая то окно. Пока правила не было, агенты вежливо задавали вопросы в чат и ждали. Я их не видел. Задачи стояли.
Отдельная строка протокола, которую я считаю самой ценной:
Деньги клиентов, идемпотентность платежей, миграции БД, перезапись данных, безопасность, налоги: максимальный уровень усилий, без обсуждения. Рутину (рефакторинг без миграций, документация, мелкий UI) не флагать.
Заметьте, что это не про роль и не про модель. Одна и та же роль на одной и той же модели работает по-разному в зависимости от того, чем пахнет задача.
Мой техлид формулирует это на живом примере: фикс обработки ошибок при подключении внешнего API формально «мелкая правка», но лежит на пути, где пользователь отдаёт свой API-ключ. Уровень усилий не понижался.
Сюда же относится обязанность агента сказать вслух, если ему кажется, что текущего уровня усилий на задачу мало. Решение об апгрейде за мной, но молчать нельзя.
В конфиге у каждой роли записана модель по умолчанию. На практике я переключаю окна руками: сейчас почти все на Opus, медиабайер на Sonnet. Завтра под нагрузкой и бюджетом раскладка будет другой.
Мой техлид, когда я спросил, есть ли у него правило выбора модели, ответил честно: правила нет, и врать не буду. Зато правило по усилиям жёсткое и записано.
Мне кажется, это здоровая асимметрия. Уровень усилий определяется ценой ошибки [8], это свойство задачи, его можно записать в протокол. Выбор модели определяется тем, сколько у вас сегодня бюджета, это свойство момента, оператор решает вручную.
Мультиагентные системы обычно рисуют так: старший агент нарезает задачу на подзадачи и раздаёт их субагентам подешевле, экономя контекст и деньги. Красивая иерархия, все её узнают.
Перед тем как это написать, я пошёл к своему техлиду и спросил, как у него на самом деле устроен спавн субагентов. Он начал ответ с поправки. Спавнит он редко. Основная параллельность не внутри окна, а между окнами: разработчик интеграции, аналитик, тестировщик это отдельные агенты в шине, а не его подчинённые процессы. Иерархия из популярных сейчас статей описывает не то, что здесь работает.
Механизма два, и решают они разные задачи.
Это шина, о которой вся статья. Двенадцать самостоятельных ролей, каждая со своим контекстом и своими границами, координация через доску. Параллельность здесь означает, что тестировщик проверяет вчерашний PR, пока техлид пишет сегодняшний, а аналитик готовит завтрашнее ТЗ. Они не ждут друг друга и не ждут меня.
Спавн субагентов внутри окна не про экономию. Он окупается там, где нужно опровергнуть, а не найти.
Правило техлида, когда он спавнит:
Спавню, когда ответ требует прочесать много, а мне нужен только вывод. И не спавню, когда нужен один проверяемый факт: там раунд-трип к субагенту дороже, чем grep.
Живой пример последнего. Аналитик спросил, отдаёт ли Ozon штрихкоды и габариты товара. Техлид не стал никого спавнить, сделал два read-only вызова к реальному API на тестовом продавце и получил ответ за минуту: штрихкоды приезжают массивом, габариты только в отдельном методе, в миллиметрах и граммах, единицы явными полями. Субагент вернул бы пересказ документации, а не факт.
А вот где спавн реально окупился. Девятого июля мы делали реферальную программу: приглашённый получает скидку на первый месяц, а пригласивший дополнительные дни подписки. Это деньги, значит максимальный уровень усилий и адверсариальное ревью.
Ревью на высоком уровне усилий разворачивается в веер независимых агентов-искателей. Каждый ищет проблемы самостоятельно. А дальше самое важное: каждую находку отдельно пытаются опровергнуть. Не подтвердить, а именно опровергнуть, с презумпцией «скорее всего, показалось».
Нашлось пять настоящих ошибок с деньгами. Самая злая: обработчик платёжного вебхука пересчитывал скидку заново и отвергал валидный платёж, если ссылку на оплату выставил администратор вручную. Один ревьюер такое пропускает, потому что находка звучит правдоподобно в обе стороны. И «это баг», и «это защита от подделки» одинаково убедительны, пока не заставишь кого-то честно защищать вторую версию.
Работает тут не количество глаз. Работает то, что каждый скептик обязан опровергать, а не соглашаться. Согласиться это путь наименьшего сопротивления для языковой модели. Чтобы получить настоящую проверку, надо явно поставить задачу разрушать.
Наблюдение техлида, которое я не ожидал и которое стоит того, чтобы его повторить:
Растерянный субагент почти всегда сигнал, что я плохо поставил вопрос, а не что модель слабая. Переспавн на модели помощнее обычно воспроизводит ту же муть, только увереннее.
Поэтому дефолтная реакция [9] не эскалировать модель, а идти читать самому.
И порог, когда спавнить, а когда читать самому, оказался не в количестве файлов:
Спавню, когда не знаю, где смотреть. Читаю сам, когда знаю где, но не знаю что там.
Пример из того же дня. Техлид знал, где искать гейт подключения к внешнему API, и читал сам. Нашёл, что валидатор ключа ловит только ошибки HTTP-клиента, а специфическое исключение «у продавца кончился платный пакет API» пролетает мимо и превращается в пятисотку прямо на экране подключения. Такое субагент не находит: это надо пройти ногами по воронке пользователя.
Двадцать восьмого мая у меня умерли два самых нагруженных окна.
Симптом: при попытке восстановить сессию API отвечает четырёхсоткой с текстом про то, что блоки размышлений в последнем сообщении ассистента не могут быть изменены. Повторная попытка даёт ту же ошибку. Восстановление подтягивает ту же самую сломанную историю, и она снова не проходит валидацию.
Размеры файлов сессий на тот момент: одно окно 25.8 МБ, второе 678 МБ.
Оба были долгоживущими окнами на Opus, которые я не перезапускал неделями. Расширенные размышления копятся в истории, история растёт, и в какой-то момент попытка её восстановить упирается в валидацию, которую эта история не проходит.
Из этого вышли три правила, ровно в таком приоритете.
Спавн субагента помогает не потому, что junior дешевле, а потому что его транскрипт остаётся в его окне. Ко мне приходит вывод, а не полторы тысячи строк, которые он прочитал по дороге.
Но это только смягчает. Решения-то всё равно копятся в главном окне. Мой техлид формулирует прямо: спавн save-state не отменяет. За один вчерашний день у него в одном окне прошло пять PR и шесть деплоев, и всё это осело в истории.
Правило записано в протокол и подставляется в контекст каждому агенту:
Контекст разросся, а задача не закончена, сохрани состояние себе в очередь.
Порог по модели: у Sonnet это шестьдесят процентов, у Opus семьдесят пять. Opus дороже платит за раздувшуюся историю, поэтому режем раньше.
Механика: агент пишет задачу самому себе с префиксом [RESUME], куда складывает где остановился, какие файлы трогал, что делать дальше. Это тот же handoff.py [4], только --to совпадает с --from.
python handoff.py --to coder-tl --from coder-tl --resume --task "Рефакторинг адаптера, 3 модуля из 5. Дальше миграция и прогон тестов."
После очистки окно объявляет себя заново, хук показывает [RESUME] первым в инбоксе, работа продолжается.
Тонкость, которая стоила мне одного потерянного треда: очистка контекста меняет session_id. Связка «сессия → роль» протухает, окно перестаёт быть агентом. Поэтому первое сообщение после очистки снова голое имя роли.
Восстановление подтягивает ту же битую историю и падает так же. Но вся работа окна лежит на диске: Claude Code пишет транскрипт в .jsonl по ходу дела, не в конце.
Поэтому лечение не «попробовать восстановить ещё раз», а «прочитать транскрипт с диска, вытащить из него результаты и открыть свежее окно». Неприятно, но работает и занимает минуты.
Срез на десятое июля, снят той же командой, что доступна любому агенту:
КОЛИЧЕСТВО (очередь сейчас): всего 1002 · open 48 · done 954 ПОТОК (event-лог): создано 24ч=76 7д=297 · закрыто 24ч=66 7д=278 СКОРОСТЬ open→done (n=1146): медиана 47мин · средн 20.5ч · min -1мин · max 42.3д
Медиана 47 минут против среднего в 20.5 часа. Это не противоречие, а форма распределения: половина задач закрывается меньше чем за час, но есть длинный хвост из тех, что висят днями. Показал бы я только среднее, картина была бы «медленно». Только медиану, «молниеносно».
Максимум в 42 дня стоит объяснить отдельно, иначе выглядит так, будто агент две недели чесал затылок. В хвосте лежат задачи, эскалированные мне: регистрация товарного знака, решение о запуске, звонок клиенту. Агент закрывает свою часть за минуты и передаёт мне то, что без меня сделать нельзя. Дальше сроки задаёт не он и не я, а Роспатент. Между агентами задачи ходят быстро, упирается всё в человека и во внешний мир.
Минимум отрицательный, минус одна минута. Артефакт: задачу закрыл тот же скрипт, что и создал, в пределах секунды, округление уехало. Чинить там нечего, но я его показываю, чтобы было видно: вывод я не подчищал.
Чего эти метрики не измеряют, так это производительности агента. Задача «поправь опечатку» и задача «спроектируй интеграцию», обе одна строка в очереди. Они меряют здоровье системы: не копится ли, не висит ли, закрывается ли примерно с той же скоростью, с какой создаётся. Семьдесят шесть созданных против шестидесяти шести закрытых за сутки означает, что очередь медленно растёт, и стоит посмотреть, где затык.
Токены я не считаю. Цифры экономии у меня нет, и придумывать её я не буду.
Примерно через месяц понадобилось то же самое для второго проекта: другой продукт, другая команда ролей, другой домен.
Изменений в коде: ноль. Реестр проектов вынесен в JSON, скрипты читают его при каждом запуске.
{ "default": "skumind", "projects": { "skumind": { "files": { "queue": "agent-handoffs.md", "events": "agent-events.jsonl" }, "agents": [ ... ] }, "second": { "files": { "queue": "data/second/handoffs.md", "events": "data/second/events.jsonl" }, "agents": [ ... ] } } }
Добавить проект значит дописать блок. Файл очереди создастся при первой записи. Идентификаторы ролей глобально уникальны, поэтому по --to скрипт сам понимает, в какой проект писать.
Единственная засада: веб-борд кэширует реестр при старте. Дописал роль, перезапусти сервер, иначе на борде её не будет, а в командной строке она уже работает. Полчаса непонимания, пока не дошло.
Что этот опыт [10] показал: доска не была решением под конкретную команду. Роли в двух проектах разные, домены разные, а структура координации одна.
Второй проект принёс и неприятное наблюдение. На борде видно, что у части ролей задачи висят неделями там, куда я давно не заходил. Доска не заставляет работать. Она честно показывает, что работа стоит, и это отдельно ценно: раньше я про эти задачи просто забывал, а теперь у них горит красный флажок с числом дней.
Задача, записанная в очередь, не будит агента. Никакой автономности: я подхожу к окну и пингую его руками. Двенадцать ролей не работают, пока я сплю.
Это сознательный выбор. Автономный цикл, где агент будит агента, я пробовал представить и испугался. Но и честный ответ тем, кто ждёт «поставил задачу, ушёл, вернулся к результату». Ближе к этому можно подойти: очередь снимает с меня необходимость помнить и пересказывать. Заменить меня целиком пока нет, но это скорее хорошо, чем плохо. Если очередь заменит меня, зачем вообще я нужен в этой цепочке?
Не трогал бы переменные окружения. Полдня ушло на подход, который отваливается от первого же вопроса: а среда вообще различает мои окна?
Ввёл бы сохранение состояния сразу. Правило про порог появилось после того, как я потерял два окна. Могло появиться до. Механика там на пятнадцать строк.
Не строил бы три пульта. У меня их получилось три: слэш-команда, HTA-панель и веб-сервер. Пользуюсь одним. Два других лежат мёртвым грузом, и оправдание у меня только одно: я не знал заранее, каким буду пользоваться.
Спрашивал бы исполнителей раньше. Половина того, что вы прочитали про спавн субагентов, это прямая цитата моего техлида, и она разошлась с тем, как я себе это представлял. Красивую иерархию из чужих статей легко принять за описание собственной системы, если не проверить.
Как это масштабируется за тридцать ролей? Сейчас двенадцать, и борд ещё читается глазом. При тридцати нужна группировка и приоритет, а по какому признаку, не знаю.
Что делать с задачами, которые никому не срочны? Они не тормозят работу, но копятся, и красный флажок на них горит вечно. Автоматически закрывать страшно, вручную разбирать некогда.
Нужен ли свой инструмент теперь, когда у Anthropic есть штатные механизмы параллельных агентов? Когда я собирал шину, их ещё не было. Сейчас они есть, и мне кажется, что это про другое: они дают взрывную параллельность внутри одной задачи, а мне нужна стоячая организация из именованных ролей, которые живут неделями, помнят свои границы и держат историю переданного. Может быть, я просто привык к своему.
Всё это оснастка вокруг того, что я на самом деле делаю: SKUmind [11], аналитика прибыли по каждому SKU для продавцов на Ozon, а с этой недели и на Wildberries, интеграция уже в бете. Шина осталась внутренним инструментом, в открытый доступ я её не выкладываю.
Если вы держите несколько окон с ролями и ловите себя на том, что стали маршрутизатором между ними, то markdown-файл и двести строк на запись в него решают эту конкретную проблему за выходные. Правила, которые заставят его работать, вы будете отлаживать два месяца.
Расскажите, как координацию решали вы, если дошли до тех же граблей.
Автор: vkobzar
Источник [12]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32940
URLs in this post:
[1] памятью: http://www.braintools.ru/article/4140
[2] CLAUDE.md: http://CLAUDE.md
[3] Логика: http://www.braintools.ru/article/7640
[4] handoff.py: http://handoff.py
[5] inbox.py: http://inbox.py
[6] board.py: http://board.py
[7] server.py: http://server.py
[8] ошибки: http://www.braintools.ru/article/4192
[9] реакция: http://www.braintools.ru/article/1549
[10] опыт: http://www.braintools.ru/article/6952
[11] SKUmind: https://skumind.ai/?utm_source=habr&utm_medium=article&utm_campaign=habr-2026-07-10
[12] Источник: https://habr.com/ru/articles/1057980/?utm_campaign=1057980&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.