Перейти к содержанию

Экономика контекста

Контекст главного потока — единственное, что проходит через весь долгосрочный прогон от начала до конца; то, что в него попадает и что не попадает, определяет, как далеко этот прогон уедет. Форма flower — координатор не работает руками, длинные результаты уходят на диск, hook отсекает лишнее на месте — целиком выводится из этого одного факта. Эта страница про то, почему.

Какую проблему решает

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

Разница в моменте. Вывод одного pytest легко занимает десятки тысяч символов; модель посмотрела на него один раз, вынесла вывод, а оставшиеся символы с этого момента пересылаются заново каждый ход; когда окно заполняется, компактирование сводит их вместе с соседними решениями в одну сводку — экономится объём, теряется «почему тогда решили именно так». Порог срабатывания auto-compact — окно − 33k (core/agent.py), и к этому моменту то, что можно выбросить, и то, что нельзя, уже лежат вперемешку.

flower решает это четырьмя слоями, порядок и есть приоритет — по величине экономии:

Слой Что делает Где
1. Разделение труда Ручная работа делегируется subagent, пробы и ошибки уходят в его собственный transcript core/roles.py
2. Верстак Скрипты / длинные результаты / решения уходят на диск, индекс внедряется в system prompt core/workbench.py
3. Сброс на диск на месте Hook PostToolUse сбрасывает на диск результаты инструментов сверх порога, в контексте остаётся одна строка с путём core/guard.py
4. Усечение и отсечение Перед resume сессия переписывается: устаревшие результаты, отклонённые вызовы, мусор от обрывов связи назад не подаются stores/trim.py, stores/prune.py

Первые два слоя управляют тем, попадает ли что-то внутрь, последние два — тем, остаётся ли то, что уже попало. Порядок не переставить: каким бы жёстким ни был четвёртый слой, он не догонит объём, просочившийся сквозь первый.

Как пользоваться (минимальный код)

from flower import Runtime, coordinator, worker

分析员 = worker("分析文件:统计、查找、比对。要真读文件、跑命令的活派给它。",
               "你负责文本分析。用命令行完成,不要手工估算。",
               tools=["Read", "Write", "Bash", "Glob", "Grep"])   # model по умолчанию "inherit"

主控 = coordinator("主控", "目标:摸清 data/ 的规模。", {"分析员": 分析员})
rt = Runtime(workspace="repo", workbench=True)

Эти несколько строк ставят первые три слоя: coordinator() всегда задаёт delegate_only=True (слой 1); workbench=True создаёт верстак и внедряет индекс в system prompt координатора (слой 2), а заодно ставит Runtime hook spill_guard (слой 3). Четвёртый слой есть по умолчанию — хранилище сессий у Runtime жёстко задано как PruningSessionStore, в параметрах конструктора нет входа, чтобы его заменить.

workbench=True — не опция

delegate_guard, который не даёт координатору работать руками, висит в workbench_hooks, а workbench_hooks ставятся только когда у Runtime есть верстак; whitelist_guard же пропускается из-за delegate_only=True. Вывод: при Runtime(workbench=False) в паре с coordinator() у Bash / Write / Edit в главном потоке нет ни одной стены.

Что она делает на самом деле

Слой 1: разделение труда (экономит больше всего)

Координатор играет роль «человека, умеющего пользоваться Claude Code»: разбирает задачу, раздаёт работу, читает отчёты, принимает решения. Bash / Write / Edit ему недоступны — из инструментов только Agent, TodoWrite, Read (при glance=True добавляется ещё ограниченный Bash, см. ниже). Вся ручная работа уходит исполнителям.

Когда срабатывает: на каждый вызов Bash|Write|Edit|NotebookEdit из главного потока hook PreToolUse delegate_guard тут же выдаёт deny и указывает путь — «через инструмент Agent отправь subagent, в задании чётко напиши цель и критерии приёмки и потребуй писать длинные результаты в .flower/artifacts/, а в ответе давать только пути и выводы». Subagent пропускается всегда. Критерий — есть ли в данных hook agent_id: если нет, это главный поток.

Сколько экономит: вызовы инструментов и пробы subagent уходят в его собственный transcript (в хранилище сессий они различаются по subpath), в главном потоке остаётся только тот единственный вызов Agent и итоговый отчёт. Процесс проб и ошибок не сжимается — он никогда не попадал в главный поток.

  • Замер (задача, порождающая много вывода инструментов): 83% transcript приходится на subagent, главный поток — 13 записей, 21K символов, subagent — 105K символов.
  • Замер (реальный масштаб, прогон длиной 10.4 часа, см. HT001): на subagent приходится 97,7% ходов и 94,8% символов основного текста; 1,893 ручных вызова инструментов против 32 в главном потоке (59:1). Раннее компактирование перестаёт быть основной линией.

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

Экономится контекст, а не класс модели: у worker() по умолчанию model="inherit" — исполнителя нельзя понижать в классе.

Единственная обратная цена разделения труда — задание, тот текст, который координатор пишет при раздаче работы: он попадает в главный поток и остаётся там навсегда. Замер: 8/8 заданий пересказывали дисциплину, уже известную адресату, в самом коротком из 521 символа лишь около 120 символов были специфичны для задачи, один ход впустую занимал около 4.8k постоянного контекста. Поэтому в COORDINATOR_RULES жёстко записано правило: в задании пишется только то, что специфично для этой задачи. Единственное, что всё-таки нужно проговорить, — «где верстак + длинные результаты в artifacts/ + в ответе только пути и выводы», потому что индекс верстака до subagent не доходит, и задание — единственный канал.

Слой 2: верстак (лечит «переписывание каждый раз»)

Три каталога в .flower/ живут вместе с рабочей областью:

Каталог Что лежит Что решает
scripts/ Проверочные / воспроизводящие скрипты, которые запустятся ещё раз, в первой строке # desc: одна фраза Написан один раз, дальше просто запускается. Больше нет «после компактирования потеряно, пишем заново каждый раз»
artifacts/ Длинные результаты свыше 2000 символов: логи, данные, отчёты, diff В диалоге появляются только путь и вывод
notes/ Ключевые решения и обоснования, одно решение — один файл После компактирования, перезапуска, переезда на другую машину выводы всё ещё на месте

Когда срабатывает: INDEX.md генерируется автоматически (по умолчанию не более 40 записей), refresh() вызывается из index_guard на PostToolUse, когда Write / Edit попадают внутрь верстака, плюс индекс обновляется перед стартом каждого шага. Эти три правила внедряются через prompt_block() в system prompt координатора — он с самого начала знает, какие готовые скрипты есть, и не тратит вызов инструмента на их обнаружение.

Сколько экономит: замер на прогоне длиной 10.4 часа — 61 скрипт записан 95 раз и выполнен 331 раз; 92% выполнялись больше одного раза, написанных и ни разу не запущенных — 0. Качественно: audit-fake-ai-server.py переиспользован в 7 скриптах.

Этот слой работает благодаря одному различию: компактирование вычищает контекст, но не вычищает диск и не вычищает индекс в system prompt.

Индекс не наследуется subagent'ом

Индекс идёт через system_prompt.append на уровне сессии, у subagent свой system prompt, и он не наследуется (замер $0.2461, tests/prelude_live.py). Поэтому «где верстак + длинные результаты в artifacts/» координатор обязан пересказать в задании — это единственный канал, а не избыточность.

Слой 3: сброс на диск на месте

spill_guard — это hook PostToolUse, который смотрит на результат инструмента до того, как он попадёт в модель: всё, что превышает threshold (по умолчанию 4000 символов), сбрасывается на диск в каталог spill/ верстака, а в контексте заменяется одной строкой-указателем + первыми 400 символами. Содержимое не потеряно, оно просто не сидит в контексте постоянно.

Когда срабатывает: matcher — Bash|Read|Grep|Glob|WebFetch|WebSearch; по умолчанию main_only=False, так что результаты subagent тоже сбрасываются. Заменяются только слишком длинные строковые поля в структуре вывода инструмента, list не трогается никогда (внутри могут быть блоки с изображениями), потому что updatedToolOutput обязан сохранять структуру вывода исходного инструмента.

Чтение самого сброшенного файла пропускается и повторно не сбрасывается. Иначе подсказка «нужен полный текст — прочитай его через Read» превращается в пустые слова: прочитал — снова превысил порог — снова сброшен — снова получил строку-указатель, бесконечный цикл. Наткнулись на это на практике (tests/handoff_live.py, первый настоящий прогон): модель подряд пробовала пять способов обойти, сама сказала "The spill read loops back on itself", в итоге прогрызла файл кусками по 40 строк, впустую сожгла семь-восемь ходов. Смысл сброса — «не запихивать автоматически большое в контекст»; если она сама решает посмотреть полный текст, это её выбор.

Runtime(workspace="repo", workbench=True, spill_threshold=4000)   # None или 0 = не ставить этот hook

Сколько экономит: в том прогоне HT001 — 103 сброса, 791.4K символов заменены указателями на пути и в контексте не сидели.

Слой 4: усечение и отсечение

Этот слой живёт в хранилище сессий. store у Runtime всегда PruningSessionStore (цепочка наследования SqliteSessionStoreTrimmingSessionStorePruningSessionStore), и в load() — то есть перед resume — он переписывает историю, которую предстоит подать назад. Оригинал в SQLite не меняется ни на символ. Четыре вещи:

① Устаревание по времени (ephemeral, включено по умолчанию). Результаты одноразовых команд вроде git status, ls, cat через несколько ходов заменяются на одну поясняющую фразу, сохраняются последние 6. Устаревшее содержимое на диск не сбрасывается — архивировать старый git status бессмысленно, достаточно выполнить команду заново:

[`git status -s` 的结果已过期(第 7 轮前),当前状态可能已变。需要请重新执行]

Живой замер на resume: expired: 2; на реальном transcript при keep_recent, выставленном в 2, устарело 5 записей.

Усечение (trim, по умолчанию выключено). Тело tool_result размером >= 2000 символов сбрасывается в <workspace>/.flower/spill/, содержимое блока заменяется указателем на файл, последние 20 оригиналов сохраняются. Обратите внимание: этот каталог и каталог сброса из слоя 3 (spill_guard) — не один и тот же: последний пишет в корень верстака, а этот обязан лежать внутри рабочей области, иначе Read агента до него не дотянется.

from flower import Runtime, TrimPolicy

Runtime(workspace="repo", trim=TrimPolicy(keep_recent=20, min_chars=2000))   # True тоже подойдёт

Отсечение отклонённых вызовов (keep_denials, по умолчанию 1). Сам факт блокировки тоже загрязняет контекст: сообщение об отказе — это tool_result, и оно вместе с никогда не выполнявшейся командой остаётся навсегда. Замер: 273 символа за раз (93 символа отказа + 180 символов мёртвой команды), мёртвая команда дороже самого отказа.

Важнее токенов то, что это вводит в заблуждение: замер показал, что координатор, прочитав несколько «не используй Bash напрямую», перестаёт даже пробовать разрешённый git status и сразу говорит «Bash ограничен, отправлю agent посмотреть» — выученная беспомощность, и в итоге лишний запуск subagent. По умолчанию оставляем 1 запись, а не 0: последний отказ — полезный сигнал, он не даёт модели в том же ходу многократно повторять одну и ту же заблокированную команду. Опознание идёт по структурной метке toolDenialKind: "permission-rule", которую ставит сам harness, а не по совпадению текста — текст может поменяться в любой момент, метка нет. Живой замер: 2 отказа → 1 убран, 1 оставлен, цепочка не порвана, resume проходит нормально, и модель по-прежнему знает, что произошло.

④ Отсечение мусора от обрывов связи. Синтетические сообщения об ошибках API, возникшие во время повторов при обрыве сети, назад не подаются; tool_result, оставшийся от прерывания, заменяется нейтральным пояснением ([上一轮在此处被中断,该工具结果未产生]), сама запись сохраняется.

Красная линия при удалении: tool_use и его tool_result должны удаляться вместе (не хватит одного — получите Missing Tool Result Block), другие вызовы в том же сообщении assistant задевать нельзя, цепочку parentUuid нужно заново сшить.

Runtime(trim=False) (по умолчанию) не означает, что ничего не чистится: выключается только усечение больших результатов, устаревание, отклонённые вызовы и мусор от обрывов работают как обычно.

Контрпример: работу «глянуть одним глазом» делаем сами

Первые три слоя говорят «делегируй», но есть контрпример: у команд вроде git status, ls, cat результат — несколько десятков символов, а один только запуск subagent стоит около 4.3k контекста (замер, размазать нельзя). Платить эту цену за одну ls — чистый убыток.

Поэтому координатор получает обратно ограниченный Bash (coordinator(..., glance=True), включён по умолчанию). Критерий не «команда короткая», а устареет ли результат, причём «пропустить» и «устареет» определяются одной и той же функцией is_ephemeral():

Пропустить, выполняет сам Результат будет помечен как устаревший
git status / ls / cat
git commit / pytest / pip install ✗ делегировать

Обе стороны обязаны опираться на одну и ту же таблицу, иначе любая из них по отдельности вредна: пропустили, но не усекаем — устаревший git status навсегда занимает контекст и ещё и вводит в заблуждение, выдавая себя за текущее состояние; усекаем, но не пропускаем — координатор платит 4.3k за одну ls. tests/glance.py прибивает этот инвариант ассертом — замер на 46 командах дал полностью совпадающие вердикты с обеих сторон, включая 10 состязательных примеров.

Грабли (наступали дважды): модель не пишет одиночные команды, она пишет git status -s && echo "--- LOG ---" && git log --oneline -10. Первая версия рубила все команды с && / | / 2>&1, и в итоге glance перестал работать вовсе — замер: все три попытки координатора были заблокированы, пришлось возвращаться к отправке subagent. Сейчас команда разбирается по сегментам: пропускается, только если каждый сегмент в белом списке, а git status && rm -rf x по-прежнему блокируется (вторая часть в таблице отсутствует).

Добавление, а не замена

system_prompt = {"type": "preset", "preset": "claude_code", "append": spec.instructions}

Когда build_options() компилирует AgentSpec в опции SDK, instructions идут через append — дописываются после родного системного промпта Claude Code, а не заменяют его. Поэтому все эти тексты дисциплины (COORDINATOR_RULES, WORKER_RULES и прочие) — сложение: специализация не достигается ценой потери общих способностей.

Индекс верстака идёт по тому же каналу. Он присутствует каждый ход, но он часть system prompt, места в истории диалога не занимает, и компактирование его не вычищает — цена та самая, что выше: доходит только до координатора.

Не используйте disallowed_tools, чтобы координатор не работал руками

disallowed_tools действует на уровне сессии и запретит инструмент заодно и subagent'ам. Текст ошибки из замера:

Bash is disabled for this session, in subagents as well as here

Правильный способ — два шага: не давать инструмент в allowed_tools, а затем hook'ом PreToolUse по agent_id блокировать только главный поток. coordinator() уже так и делает — он ставит delegate_only=True, а delegate_guard блокирует главный поток и пропускает subagent.

Одного allowed_tools тоже мало: это список без запроса подтверждения, а не исключающий белый список. Замер показал, что модель может вызвать инструмент, которого там нет: в зонде за $0.1 агент с allowed_tools=["Read"] спокойно вызывал Write / Bash. Реально блокирует именно hook.

allowed_tools тоже уровня сессии, тот же урок пройден дважды. Инструменты, не попавшие в этот список, при вызове subagent'ом так же уходят на согласование прав. В автономном режиме подтверждать некому, поэтому не будет ни ошибки, ни остановки — модель раз за разом повторяет один и тот же вызов (toolDenialKind=user-rejected). Замер: исполнителю добавили WebFetch/WebSearch, но прописали их только в AgentDefinition.tools, и тот прогон дал два с лишним десятка user-rejected и ни единого символа результата (roles.py:513-518). Симптом искать труднее, чем у disallowed_tools: там ошибка сразу, а здесь на экране ничто не похоже на сбой. Поэтому coordinator() теперь добавляет read-only web-инструменты своих исполнителей в собственный allowed_tools (roles.py:523-526), а Write/Edit/Bash намеренно не добавляет — добавить значит разобрать ту самую hook-стену выше.

Когда её не нужно использовать

Все четыре слоя экономят рабочую фактуру. Перечисленные ниже проблемы они не решают, а некоторые из-за них становятся ещё менее заметными:

  1. Цель понята неверно — эти четыре слоя усугубят проблему. После того как фактура выброшена, остаётся ровно то решение, которое построено на ошибочной посылке, и выглядит оно точь-в-точь как правильное. Долгосрочный прогон раздувает это до предела: на ошибочной посылке система работает несколько часов, отправляет десяток с лишним subagent, кладёт на диск кучу результатов — и только потом это вскрывается. К тому моменту дороги не токены, а то, что каждый результат построен под неверные требования. От этого защищает предварительное уточнение, а не какой-либо слой этой страницы.
  2. Главный поток всё равно растёт монотонно. Четыре слоя давят наклон, а не направление. Замер: за 70 ходов главный поток вырос с 28.7K до 185.9K, наклон 2.2K/ход, компактирования не было ни разу, израсходовано 18,6% окна в 1M, экстраполяция даёт удар в стену примерно на 440-м ходу. Перешагнуть эту стену помогает смена поколения.
  3. После отключения полного компактирования подстраховки нет. Когда смена поколения включена, Runtime принудительно ставит spec CompactPolicy(mode="no_summary"), и auto-compact на этом выключается (если spec сам явно задал compact, это уважается). Упереться в лимит — жёсткая ошибка, поэтому эти четыре слоя обязаны использоваться в связке со сменой поколения, а не просто «выключили компактирование и всё».
  4. Слой 4 работает только при resume. Усечение и отсечение происходят в load(), непрерывно идущая сессия от них меньше не станет. Если разделение труда сделано так, как описано выше, этот слой чаще всего и не понадобится — в главный поток и так помещается немного результатов инструментов.
  5. Делегировать работу «глянуть одним глазом» — чистый убыток. Запуск subagent около 4.3k, см. раздел про glance выше.
  6. Прежде чем переставлять контекст, посчитайте кэш. Замер одного прогона: 299.4M входных токенов, 96,1% попаданий в кэш, и $171 держатся только на этом. Любая оптимизация, переписывающая историю, обязана сначала свести этот счёт.
  7. Результаты инструментов с изображениями и документами на диск не сбрасываются. spill_guard меняет только строковые поля в структуре вывода, list не трогается никогда.

Полные значения по умолчанию и сигнатуры параметров — в Python API; термины — в глоссарии.