Глава 9: Fork-агенты и кэш подсказок

Инсайт — 95%

Когда родительский агент порождает пять дочерних агентов параллельно, подавляющая часть каждого запроса к API у дочери идентична. Системная подсказка одинаковая. Определения инструментов одинаковы. История разговора одинаковая. Сообщение ассистента, которое запустило форки, одинаково. Единственное, что отличается — финальная директива: «вы займётесь миграцией базы данных», «вы напишете тесты», «вы обновите документацию».

В типичном форке с «тёплой» беседой общий префикс может составлять 80 000 токенов. Персональная директива на дочернего агента — около 200 токенов. Это 99.75% совпадения. Провайдер агентов предоставляет 90%-ую скидку на кешированные входные токены. Если вы сумеете заставить эти 80 000 токенов попасть в кэш для дочерних агентов со 2-го по 5-й, вы сократите входную стоимость этих четырёх запросов на 90%. Для родителя это разница между тратой $4 и $0.50 на одинаковую параллельную рассылку.

Загвоздка в том, что кэш подсказок — побайтово точный. Не «достаточно похож». Не «семантически эквивалентен». Байты должны совпадать, символ в символ, от первого байта системной подсказки до последнего байта перед расхождением пер-деталя для каждой дочери. Один лишний пробел, одна перестановка определения инструмента, один устаревший фрагмент системной подсказки — и кэш промахивается. Весь префикс перерабатывается по полной цене.

Форк-агенты — ответ агента на это ограничение. Они — не просто удобная обёртка «порождайте ребёнка с контекстом», а механизм эксплуатации кэша подсказок, замаскированный под оркестрационную фичу. Каждое проектное решение в системе форков возвращается к одному вопросу: как гарантировать побайтовую идентичность префиксов для параллельных дочерних агентов?


Что наследует дочерний форк

Дочерний форк-наследник получает от родителя четыре вещи, и он получает их по ссылке или побайтовой копии, а не путём рекомпиляции.

  1. Системную подсказку. Не генерируется заново — передаётся по нитке (threaded). Уже отрендеренные байты системной подсказки родителя передаются через override.systemPrompt, берутся из toolUseContext.renderedSystemPrompt. Это точная строка, которая была отправлена в последнем API-вызове родителя.

  2. Определения инструментов. Определение форк-агента декларирует tools: ['*'], но с флагом useExactTools = true дочерний агент получает собранный массив инструментов родителя напрямую. Никакой фильтрации, никакой перестановки, никакой повторной сериализации.

  3. Историю сообщений. Каждое сообщение, которым родитель обменивался с API — пользовательские ходы, ответы ассистента, вызовы инструментов, результаты инструментов — клонируется в контекст дочери через forkContextMessages.

  4. Конфигурацию мышления и модель. Определение форка указывает model: 'inherit', что резолвится в точную модель родителя. Одинаковая модель означает одинаковый токенизатор, одинаковое окно контекста и одинаковое пространство имён кэша.

Само определение форк-агента минимально — почти no-op:

Определение форк-агента намеренно минимально — оно наследует всё от родителя. Указывает все инструменты ('*'), наследует модель родителя, использует режим bubble для разрешений (чтобы подсказки отображались в терминале родителя) и предоставляет no-op функцию системной подсказки, которая на самом деле никогда не вызывается — реальная подсказка приходит через канал override, уже отрендеренная и побайтово стабильная.


Приём побайтово-идентичного префикса

Запрос к Claude имеет специфическую структуру: системная подсказка, затем инструменты, затем сообщения. Чтобы кэш сработал, каждый байт от начала запроса до некоторой границы префикса должен быть идентичен в разных запросах.

Форк-агенты достигают этого, замораживая три слоя:

Слой 1: Системная подсказка через threading, не повторная генерация.

Когда системная подсказка родителя была отрендерена для его последнего API-вызова, результат был сохранён в toolUseContext.renderedSystemPrompt. Это строка после всех динамических интерполяций — флаги GrowthBook, настройки окружения, описания MCP-серверов, содержимое скиллов. Дочерний форк получает эту точную строку.

Зачем не вызывать getSystemPrompt() снова? Потому что генерация системной подсказки не является чистой функцией. Флаги GrowthBook меняют состояние от «холодного» к «тёплому», когда SDK подтягивает remote config. Флаг, который вернул false при первом ходе родителя, может вернуть true ко времени запуска дочернего агента. Если системная подсказка содержит условный блок, зависящий от этого флага, повторный рендер даст расхождение хотя бы в один символ. Кэш сломан. Полная переработка 80 000 токенов, умноженная на пять детей.

Потоковая передача отрендеренных байтов устраняет весь класс таких расхождений.

Слой 2: Определения инструментов через точную передачу.

Обычные под-агенты проходят через resolveAgentTools(), который фильтрует пул инструментов на основе массивов tools и disallowedTools в определении агента, применяет различия режимов разрешений и потенциально переставляет инструменты. Получившийся сериализованный массив инструментов отличался бы от родительского — другой набор, другой порядок, другие аннотации разрешений.

Форк-агенты пропускают эту стадию полностью:

const resolvedTools = useExactTools
  ? availableTools  // массив родителя, побайтово тот же самый
  : resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools

Флаг useExactTools выставлен в true только на пути форка. Ребёнок получает пул инструментов родителя как есть. Те же инструменты, тот же порядок, та же сериализация. Это включает сохранение инструмента Agent в пуле ребёнка, даже если ребёнку запрещено им пользоваться — удаление поменяло бы массив инструментов и сломало бы кэш.

Слой 3: Конструкция массива сообщений.

Именно здесь buildForkedMessages() делает аккуратную работу. Функция конструирует последние два сообщения, которые размещаются между общим history и пер-детализированной директивой:

Функция buildForkedMessages() конструирует последние два сообщения, которые вклиниваются между общей историей и пер-детальной директивой. Алгоритм:

  1. Клонировать родительское сообщение ассистента (сохраняются все блоки tool_use с их оригинальными ID).
  2. Для каждого tool_use-блока создать tool_result с константной плейсхолдерной строкой (идентичной для всех детей).
  3. Построить единое пользовательское сообщение, содержащее все плейсхолдерные результаты, за которым следует пер-детальная директива, обернутая в шаблонный тег.
  4. Вернуть [clonedAssistantMessage, userMessageWithPlaceholdersAndDirective].
// Псевдокод — иллюстрирует построение сообщений
function buildChildMessages(directive, parentAssistant) {
  const cloned = cloneMessage(parentAssistant)
  const placeholders = parentAssistant.toolUseBlocks.map(b =>
    toolResult(b.id, CONSTANT_PLACEHOLDER)  // побайтово идентично для всех детей
  )
  const userMsg = createUserMessage([...placeholders, wrapDirective(directive)])
  return [cloned, userMsg]
}

Результирующий массив сообщений для каждого ребёнка выглядит так:

[...shared_history, assistant(all_tool_uses), user(placeholder_results..., directive)]

Каждый элемент до директивы идентичен для всех детей. FORK_PLACEHOLDER_RESULT — константная строка «Форк запущен — выполняется в фоновом режиме» — обеспечивает, что даже блоки результатов инструментов побайтово идентичны. Значения tool_use_id идентичны, потому что они ссылаются на одно и то же сообщение ассистента. Единственным отличием является финальный текстовый блок, содержащий пер-детальную директиву.

Граница кэша ставится прямо перед этим финальным текстовым блоком. Всё, что выше — возможно десятки тысяч токенов системной подсказки, определений инструментов, истории разговоров и плейсхолдеров — попадает в кэш с 90%-ой скидкой для каждого ребёнка после первого.


Тег-шаблон форка (Boilerplate Tag)

Директива каждого ребёнка оборачивается в шаблонный XML-тег, который выполняет две задачи: инструктирует ребёнка, как вести себя, и служит маркером для обнаружения рекурсивных форков.

Boilerplate содержит примерно 10 правил. Ключевые из них:

  • Переопределить родительскую инструкцию по форкингу. Системная подсказка родителя говорит «по умолчанию форкуйся» — boilerplate явно указывает ребёнку: «эта инструкция для родителя. Вы — форк. НЕ порождайте под-агентов».
  • Выполнять молча, отчитаться один раз. Никакого разговорного текста между вызовами инструментов. Используйте инструменты напрямую, затем сформируйте структурированное резюме.
  • Держаться в рамках задачи. Ребёнок не должен расширять сферу задания.
  • Структурированный формат вывода. Ответ должен следовать шаблону Область/Результат/Ключевые файлы/Изменённые файлы/Проблемы, чтобы результаты было легко парсить родителю, когда несколько детей отчитываются одновременно.

Правило №1 особенно интересно. Системная подсказка родителя — которая унаследована дочерью дословно ради кэша — содержит инструкции типа «по умолчанию форкаться, когда есть параллельная работа». Если ребёнок следовал бы этому, он попытался бы форкаться сам, создавая бесконечную рекурсию агентов. Boilerplate явно переопределяет: «эта инструкция для родителя. Вы — форк».

Структурированный формат вывода (Область/Результат/Ключевые файлы/Изменённые файлы/Проблемы) не декоративен. Он ограничивает вывод ребёнка фактологическим отчётом, что облегчает парсинг и агрегирование результатов родителем, когда пять детей отчитываются одновременно.


Предотвращение рекурсивного форка

Дочерний форк сохраняет Agent tool в своём пуле инструментов. Он должен — удаление изменило бы сериализованный массив инструментов и сломало бы кэш подсказок. Но если ребёнок действительно вызовет Agent tool без subagent_type, путь форка снова сработает, создав внука. Внук унаследит ещё больший контекст (родитель + ребёнок), породит свои форки — и так далее.

Два механизма предотвращают это:

Первый механизм: проверка querySource. Когда дочерний форк создаётся, в context.options.querySource выставляется строка 'agent:builtin:fork'. Метод call() проверяет это перед разрешением пути форка:

// В AgentTool.call():
if (effectiveType === undefined) {
  // Путь форка — но мы уже не во форке ли?
  if (querySource === 'agent:builtin:fork') {
    // Отклонить: уже дочерний форк
  }
}

Это быстрый путь. Он проверяет одну строку в объекте options.

Резервный механизм: сканирование сообщений. Предотвращение рекурсии использует два охранных уровня: тег querySource, установленный при спавне (быстрый путь — одно сравнение строк), и резервный механизм, который сканирует историю сообщений в поисках boilerplate XML-тега. Резервный механизм существует потому, что querySource переживает автокомпактизацию, но в крайних случаях, когда он не был корректно пронесён, сканирование сообщений ловит рекурсию. Это подход «ремень и подтяжки», где стоимость проверки (сканирование сообщений) тривиальна по сравнению с затратами от случайной рекурсивной форкации (неуправляемые траты на API).

Зачем нужен запасной механизм? Потому что у Агента есть функция autocompact, которая переписывает массив сообщений при росте контекста. Autocompact может переписать содержимое сообщений, но сохраняет querySource в options. Теоретически querySource один достаточен. На практике резервное сканирование сообщений ловит пограничные случаи, где querySource не был корректно протянут — подход «ремень и подтяжки», где стоимость проверки ничтожно мала по сравнению с потенциальной ценой ошибочной рекурсии.


Переход из синхронного режима в асинхронный

Дочерний форк стартует в foreground: его сообщения стримятся в терминал родителя, и родитель блокируется в ожидании завершения. Но что, если ребёнок работает слишком долго? Агент позволяет в середине выполнения перевести агент в background — пользователь (или авто-таймаут) может переместить выполняющийся foreground-агент в фон без потери работы.

Механизм достаточно чист:

  1. Когда foreground-агент регистрируется через registerAgentForeground(), создаётся promise-сигнал для background.

  2. Синхронный цикл родителя гоняет гонки между потоком сообщений агента и сигналом background:

while (true) {
  const result = await Promise.race([
    iterator.next(),         // следующее сообщение от агента
    backgroundSignal,        // триггер "перенести в фон"
  ])
  if (result === BACKGROUND_SIGNAL) break
  // ... обработать сообщение
}
  1. Когда background-сигнал срабатывает, синхронный итератор корректно возвращается через iterator.return(). Это вызывает выполнение finally генератора, который занимается очисткой.

  2. Новая инстанция runAgent() порождается с isAsync: true, используя тот же ID агента и накопленную историю сообщений. Агент продолжает с места остановки, теперь в фоне.

  3. Оригинальный синхронный call() возвращает { status: 'async_launched' }, и родитель продолжает диалог.

Никакая работа не теряется, потому что история сообщений — это состояние агента. Транскрипт боковой цепочки на диске содержит каждое сообщение, которое агент произвёл. Новая асинхронная инстанция воспроизводит этот транскрипт и продолжает с того места, где остановился синхронный экземпляр.


Автоматическое перевод в фон

Когда переменная окружения AGENT_AUTO_BACKGROUND_TASKS или флаг GrowthBook tengu_auto_background_agents включены, foreground-агенты автоматически переводятся в фон через 120 секунд:

Когда включено через переменную окружения или feature-flag, foreground-агенты автоматически переводятся в фон через 120 секунд. Когда выключено — функция возвращает 0 (нет автопереноса в фон).

Это UX-решение с экономическими последствиями. Foreground-агент блокирует терминал родителя — пользователь не может печатать, не может отдавать новые инструкции, не может порождать других агентов. Две минуты — достаточно, чтобы агент завершил быстрые задачи синхронно (где стриминг вывода даёт полезную обратную связь), но достаточно мало, чтобы долгие задачи не держали терминал в заложниках.

В эксперименте с форком вопрос автопереноса в фон неактуален: все форк-спавны принудительно асинхронные с самого начала. Параметр run_in_background полностью скрыт от схемы. Каждый дочерний форк работает в фоне, отчитывается через <task-notification> по завершении, и родитель никогда не блокируется.


Когда форк НЕ используется

Форк — один из нескольких режимов оркестрации, и он целенаправленно исключён в трёх случаях:

Координаторский режим. Режим координатора и режим форка взаимно исключают друг друга. Координатор имеет структурированную модель делегирования: он поддерживает план, назначает задачи рабочим с явными подсказками и отслеживает прогресс. Подход форка «унаследовать всё» подорвал бы это. Форкованный координатор унаследит системную подсказку родителя (которая говорит «ты координатор — делегируй работу»), и ребёнок стал оркестровать вместо выполнения. Функция isForkSubagentEnabled() проверяет isCoordinatorMode() первой и возвращает false, если он активен.

Неинтерактивные сессии. SDK и потребители API (--print режим, Claude Agent SDK) работают без терминала. У форка permissionMode: 'bubble' выводит запросы разрешений в терминал родителя — которого в неинтерактивном режиме нет. Вместо того чтобы строить отдельный поток разрешений, путь форка просто отключён. SDK-потребители используют явный выбор subagent_type.

Явный subagent_type. Когда модель указывает subagent_type (например, "Explore", "Plan", "general-purpose"), путь форка не срабатывает. Форк срабатывает только когда subagent_type опущен. Это даёт модели выбор между «я хочу специализированного агента с собственной системной подсказкой и набором инструментов» (явный тип) и «я хочу клона себя, наследующего контекст, чтобы параллельно выполнить это» (тип опущен).


Экономика

Рассмотрим конкретный сценарий. Разработчик просит Агента рефакторить модуль. Родительский агент анализирует кодовую базу, формирует план и распараллеливает пять форк-дочерей: одна обновляет схему БД, одна переписывает layer сервиса, одна обновляет маршрутизатор, одна чинит тесты, одна обновляет типы.

В этот момент общий контекст велик:

  • Системная подсказка: ~4 000 токенов
  • Определения инструментов (40+ инструментов): ~12 000 токенов
  • История диалога (анализ + планирование): ~30 000 токенов
  • Сообщение ассистента с пятью блоками tool_use: ~2 000 токенов
  • Плейсхолдерные результаты инструментов: ~500 токенов

Итого общий префикс: ~48 500 токенов. Пер-детальная директива на ребенка: ~200 токенов.

Без форка (пять независимых агентов, каждый с новым контекстом и собственной системной подсказкой):

  • Каждый ребёнок обрабатывает свою системную подсказку + инструменты + задачу
  • Никакого совместного использования кэша (разные системные подсказки, разные наборы инструментов)
  • Стоимость: 5 × полная обработка входа

С форком (побайтово идентичные префиксы):

  • Ребёнок 1: 48 700 токенов по полной цене (кэш-промах на первом запросе)
  • Дети 2–5: 48 500 токенов по 10% цене (кэш-хит) + 200 токенов по полной цене каждый
  • Эффективная стоимость для детей 2–5: ~4 850 + 200 = ~5 050 токенов эквивалентно для каждого

Экономия масштабируется с размером контекста и количеством детей. Для «тёплой» сессии с 100k токенов истории, порождающей 8 параллельных форков, экономия кэша может превысить 90% от того, что стоило бы входным токенам без совместного использования.

Именно поэтому каждое проектное решение в системе форков — threading вместо рекомпиляции, точная передача инструментов, плейсхолдеры результатов, даже сохранение Agent tool в пуле ребёнка вопреки запрету — оптимизирует одну вещь: побайтовую идентичность префиксов. Каждое решение жертвует каплей элегантности или безопасности ради измеримого уменьшения затрат на API.


Конфликты проектирования

Система форков делает явные компромиссы, которые важно понимать:

Изоляция vs. эффективность кэша. Дети наследуют всё, включая историю разговоров, которая может быть не релевантна их задаче. Ребёнку, переписывающему тесты, не нужны 15 сообщений о проектировании схемы базы данных. Но включение этих сообщений — то, что делает префикс идентичным. Стрижка нерелевантной истории бы сэкономила место в контекстном окне ценой нарушения кэша. Ставка — что экономия от кэша перевешивает накладные расходы на расширенный контекст.

Безопасность vs. эффективность кэша. Agent tool остаётся в пуле инструментов форк-дочери, хотя ребёнок не должен его использовать. Удаление его было бы безопаснее (ребёнок не смог бы даже попытаться форкнуться), но это изменило бы сериализованный массив инструментов. Boilerplate-тег и охранные проверки рекурсивности — компенсирующие меры на уровне выполнения вместо статического удаления.

Простота vs. эффективность кэша. Плейсхолдерные результаты инструментов — ложь. Ребёнок видит «Форк запущен — выполняется в фоновом режиме» для каждого блока tool_use в сообщении ассистента родителя, независимо от того, что эти вызовы инструментов фактически сделали. Это приемлемо, потому что директива ребёнка определяет, что ему делать — ему не нужны точные результаты инструментов из момента диспатча родителя. Но это делает историю ребёнка технично несогласованной. Плейсхолдер выбран ради краткости и однообразия, а не ради точности.

Каждый из этих компромиссов отражает один приоритет: когда вы платите за токены API в масштабе, побайтовые идентичные префиксы стоят того, чтобы перестроить архитектуру вокруг них.


Примените на практике: проектирование для эффективности кэша подсказок

Паттерн форк-агента обобщается за пределами Claude Code. Любая система, которая отправляет несколько параллельных LLM-вызовов из одного и того же контекста, может выиграть от кэш-ориентированного построения запросов. Принципы:

  1. Протягивайте отрендеренные подсказки, не перемендеривайте. Если ваша системная подсказка включает динамический контент — feature-флаги, временные метки, пользовательские предпочтения, варианты A/B — захватите отрендеренный результат и передайте его дочерним агентам значением. Повторный рендер рискует расхождением.

  2. Замораживайте массив инструментов. Если ваши дети нуждаются в разных наборах инструментов, вы теряете совместное использование кэша в блоке инструментов. Рассмотрите возможность оставлять полный набор инструментов и использовать runtime-охрану (как «не использовать Agent» в boilerplate) вместо компиляционного удаления.

  3. Максимизируйте общий префикс, минимизируйте пер-детальный суффикс. Структурируйте массив сообщений так, чтобы всё общее шло первым, а пер-детальное содержимое дописывалось в конце. Перемешивание общих и пер-детальных фрагментов фрагментирует границу кэша.

  4. Используйте постоянные плейсхолдеры для изменяющегося содержимого. Когда структура сообщений требует ответов на предыдущие вызовы инструментов, используйте идентичные плейсхолдерные строки для всех детей вместо фактических (и расходящихся) результатов.

  5. Измеряйте точку безубыточности. Совместное использование кэша имеет накладные расходы: большее окно контекста у детей (они несут нерелевантную историю), runtime-охрану вместо статической безопасности, архитектурную сложность. Посчитайте, действительно ли ваша параллелизация (сколько детей, какой размер общего префикса) экономит деньги с учётом дополнительных токенов контекста.

Система форк-агентов по сути является движком эксплуатации кэша подсказок. Она отвечает на вопрос, с которым рано или поздно сталкивается любой разработчик мультиагентной системы: если кэш даёт вам 90%-ую скидку на повторяющиеся префиксы, насколько далеко вы готовы перестроить архитектуру, чтобы получить эту скидку? Ответ Агента: очень далеко.

Prompt Cache Calculator

Calculate fork agent cache sharing savings.