Глава 17: Производительность — каждая миллисекунда и каждый токен на счету
Плейбук старшего инженера
Оптимизация производительности в агентной системе — это не одна проблема. Это пять:
- Задержка запуска — время от нажатия клавиши до первого полезного вывода. Пользователи бросают инструменты, которые ощущаются медленными на старте.
- Эффективность токенов — доля окна контекста, занятая полезным содержимым, а не накладными расходами. Окно контекста — самый дефицитный ресурс.
- Стоимость API — сумма в долларах за ход. Prompt caching может снизить её на 90%, но только если система сохраняет стабильность кэша между ходами.
- Пропускная способность рендеринга — число кадров в секунду во время потокового вывода. В главе 13 была архитектура рендеринга; в этой главе — измерения производительности и оптимизации, которые держат его быстрым.
- Скорость поиска — время, за которое находится файл в кодовой базе с 270 000 путей при каждом нажатии клавиши.
Agent атакует все пять задач техниками от очевидных (memoization) до тонких (26-битные битмапы для предварительной фильтрации fuzzy search). Примечание о методологии: это не теоретические оптимизации. В Agent встроено более 50 контрольных точек профилирования запуска, которые снимаются у 100% внутренних пользователей и у 0,5% внешних. Каждая оптимизация ниже была вызвана данными из этой телеметрии, а не интуицией.
Сэкономить миллисекунды при запуске
Параллелизм I/O на уровне модуля
Точка входа main.tsx намеренно нарушает правило «никаких побочных эффектов на уровне модуля»:
profileCheckpoint('main_tsx_entry');
startMdmRawRead(); // запускает подпроцессы plutil/reg-query
startKeychainPrefetch(); // запускает оба чтения macOS keychain параллельно
Два элемента macOS keychain в противном случае стоили бы ~65 мс последовательных синхронных запусков. Запуская оба как fire-and-forget promises на уровне модуля, мы выполняем их параллельно с ~135 мс загрузки модуля, в течение которых CPU иначе простаивал бы.
Предподключение к API
apiPreconnect.ts отправляет запрос HEAD к API провайдера во время инициализации, перекрывая handshake TCP+TLS (100–200 мс) с работой по настройке. В интерактивном режиме это перекрытие не ограничено — соединение прогревается, пока пользователь печатает. Запрос отправляется после applyExtraCACertsFromConfig() и configureGlobalAgents(), чтобы прогретое соединение использовало правильную транспортную конфигурацию.
Диспетчеризация по быстрому пути и отложенные импорты
Точка входа CLI содержит ранние выходы для специализированных подкоманд — claude mcp никогда не загружает React REPL, claude daemon никогда не загружает систему инструментов. Тяжёлые модули подгружаются через динамический import() только когда они нужны: OpenTelemetry (~400KB + ~700KB gRPC), журналирование событий, диалоги ошибок, upstream-прокси. LazySchema откладывает построение Zod-схемы до первой валидации, сдвигая стоимость за пределы старта.
Сэкономить токены в окне контекста
Резервирование слота: 8K по умолчанию, 64K при эскалации
Самая заметная единичная оптимизация:
Резерв слота вывода по умолчанию — 8 000 токенов, с повышением до 64 000 при truncation. API резервирует max_output_tokens ёмкости под ответ модели. Значение SDK по умолчанию — 32K–64K, но продакшн-данные показывают, что p99 длины вывода — 4 911 токенов. Значение по умолчанию завышает резерв в 8–16 раз, сжигая 24 000–59 000 токенов за ход. Agent ограничивается 8K и повторяет запрос на 64K при редком truncation (<1% запросов). Для окна в 200K это даёт улучшение полезного контекста на 12–28% — бесплатно.
Бюджетирование результатов инструментов
| Лимит | Значение | Назначение |
|---|---|---|
| Символов на инструмент | 50,000 | Результаты сохраняются на диск при превышении |
| Токенов на инструмент | 100,000 | Верхняя граница текста ~400KB |
| Агрегат на сообщение | 200,000 символов | Не даёт N параллельным инструментам взорвать бюджет за один ход |
Агрегат на сообщение — ключевая идея. Без него «прочитать все файлы в src/» могло бы породить 10 параллельных чтений, каждое из которых возвращает по 40K символов.
Размер окна контекста
Окно по умолчанию на 200K токенов можно расширить до 1M через суффикс [1m] в имени модели или treatment эксперимента. Когда использование приближается к пределу, 4-слойная система компакции постепенно суммирует более старый контент. Подсчёт токенов опирается на фактическое поле usage из API, а не на клиентскую оценку — это учитывает кредиты prompt caching, thinking tokens и серверные трансформации.
Сэкономить деньги на вызовах API
Архитектура prompt cache
Prompt cache у Provider работает по точному совпадению префикса. Если один токен меняется в середине префикса, всё, что идёт после, становится cache miss. Agent строит весь prompt так, чтобы стабильные части шли первыми, а изменчивые — последними.
Когда shouldUseGlobalCacheScope() возвращает true, элементы system prompt до динамической границы получают scope: 'global' — два пользователя, работающие с одной и той же версией Agent, делят префиксный кэш. Global scope отключается при наличии MCP-инструментов, поскольку MCP-схемы зависят от пользователя.
Липкие latch-поля
Пять булевых полей используют паттерн «sticky-on» — как только становятся true, такими и остаются на всю сессию:
| Latch Field | Что предотвращает |
|---|---|
promptCache1hEligible | Смену TTL кэша при mid-session overage flip |
afkModeHeaderLatched | Переключения Shift+Tab, ломающие кэш |
fastModeHeaderLatched | Двойной bust кэша при входе/выходе cooldown |
cacheEditingHeaderLatched | Ломающие кэш переключения конфигурации во время сессии |
thinkingClearLatched | Переключение thinking mode после подтверждённого cache miss |
Каждое соответствует заголовку или параметру, изменение которого в середине сессии уничтожило бы ~50 000–70 000 токенов закэшированного prompt. Латчи жертвуют переключением в середине сессии ради сохранения кэша.
Запоминание даты начала сессии
const getSessionStartDate = memoize(getLocalISODate)
Без этого дата менялась бы в полночь, ломая весь закэшированный префикс. Устаревшая дата — это косметика; cache bust заставляет заново обрабатывать всю беседу.
Memoization секций
Секции system prompt используют двухуровневый кэш. Большая часть содержимого использует systemPromptSection(name, compute), который кэшируется до /clear или /compact. Ядерный вариант DANGEROUS_uncachedSystemPromptSection(name, compute, reason) пересчитывается на каждом ходу — соглашение об именовании заставляет разработчиков документировать, ПОЧЕМУ нужно ломать кэш.
Сэкономить CPU на рендеринге
Глава 13 подробно разбирала архитектуру рендеринга — упакованные typed arrays, pool-based interning, двойную буферизацию и построчное diffing. Здесь мы сосредоточимся на измерениях производительности и адаптивном поведении, которое удерживает систему быстрой.
Терминальный рендерер ограничивается 60 fps через throttle(deferredRender, FRAME_INTERVAL_MS). Когда терминал в blur, интервал удваивается до 30 fps. Кадры с drain scroll идут с четвертью интервала для максимальной скорости прокрутки. Такое адаптивное throttling гарантирует, что рендеринг никогда не съедает больше CPU, чем нужно.
React Compiler (react/compiler-runtime) автоматически memoizes рендеры компонентов по всему кодовой базе. Ручные useMemo и useCallback подвержены ошибкам; компилятор делает это правильно по построению. Предварительно созданные замороженные объекты (Object.freeze()) убирают аллокации для типовых значений пути рендеринга — одна сэкономленная аллокация на кадр в alt-screen mode накапливается в течение тысяч кадров.
Полные детали рендерингового пайплайна — система interning CharPool/StylePool/HyperlinkPool, оптимизация blit, отслеживание прямоугольников повреждения, компонент OffscreenFreeze — см. в главе 13.
Сэкономить память и время на поиске
Fuzzy-поиск файлов запускается при каждом нажатии клавиши и просматривает более 270 000 путей. Три слоя оптимизации удерживают его в пределах нескольких миллисекунд.
Bitmap-предфильтр
Каждый индексированный путь получает 26-битный bitmap того, какие строчные буквы в нём есть:
// Псевдокод — иллюстрирует концепцию 26-битного bitmap
function buildCharBitmap(filepath: string): number {
let mask = 0
for (const ch of filepath.toLowerCase()) {
const code = ch.charCodeAt(0)
if (code >= 97 && code <= 122) mask |= 1 << (code - 97)
}
return mask // Каждый бит означает наличие a-z
}
Во время поиска: if ((charBits[i] & needleBitmap) !== needleBitmap) continue. Любой путь, в котором отсутствует буква запроса, отбрасывается мгновенно — одно целочисленное сравнение, без строковых операций. Коэффициент отбраковки: ~10% для широких запросов вроде “test”, более 90% для запросов с редкими буквами. Цена: 4 байта на путь, около 1 МБ для 270 000 путей.
Отбраковка по верхней границе score и объединённый scan indexOf
Пути, пережившие bitmap, проходят проверку на потолок score до дорогого scoring по границам/camelCase. Если наилучший возможный score не может превзойти текущий порог top-K, путь пропускается.
Собственно сопоставление объединяет поиск позиции с вычислением бонусов за разрывы/непрерывность через String.indexOf(), который ускоряется SIMD и в JSC (Bun), и в V8 (Node). Оптимизированный поиск движка заметно быстрее ручных циклов по символам.
Асинхронная индексация с частичной queryability
Для больших кодовых баз loadFromFileListAsync() отдаёт управление event loop примерно каждые 4 мс работы (по времени, а не по количеству — адаптация к скорости машины). Она возвращает два promise: queryable (разрешается на первом чанке, позволяя сразу получать частичные результаты) и done (полный индекс готов). Пользователь может начать поиск через 5–10 мс после того, как список файлов становится доступен.
Проверка на yield использует (i & 0xff) === 0xff — branchless modulo-256, чтобы amortize стоимость performance.now().
Сайд-запрос на релевантность памяти
Одна оптимизация находится на пересечении эффективности токенов и стоимости API. Как описано в главе 11, система памяти использует лёгкий вызов модели Sonnet — не основной модели Opus — чтобы выбрать, какие файлы памяти включать. Стоимость (256 max output tokens на быстрой модели) ничтожна по сравнению с токенами, сэкономленными за счёт не включённых нерелевантных файлов памяти. Одна нерелевантная память на 2 000 токенов стоит в потерянном контексте больше, чем side query стоит в API-вызовах.
Спекулятивное выполнение инструментов
StreamingToolExecutor начинает выполнять инструменты по мере их стриминга, ещё до завершения полного ответа. Только читающие инструменты (Glob, Grep, Read) могут выполняться параллельно; пишущим нужен эксклюзивный доступ. Функция partitionToolCalls() группирует последовательные безопасные инструменты в батчи: [Read, Read, Grep, Edit, Read, Read] превращается в три батча — [Read, Read, Grep] параллельно, [Edit] последовательно, [Read, Read] параллельно.
Результаты всегда выдаются в исходном порядке инструментов, чтобы мышление модели оставалось детерминированным. Соседний abort controller убивает параллельные подпроцессы, когда один из Bash-инструментов падает, предотвращая лишние ресурсы.
Потоковая передача и raw API
Agent использует raw streaming API вместо помощника SDK BetaMessageStream. Помощник вызывает partialParse() на каждом input_json_delta — это O(n^2) по длине input инструмента. Agent накапливает сырые строки и парсит их один раз, когда блок завершён.
Стриминговый watchdog (AGENT_STREAM_IDLE_TIMEOUT_MS, по умолчанию 90 секунд) прерывает и повторяет попытку, если чанки не приходят, с fallback на нестриминговый messages.create() при сбое прокси.
Применяйте это: производительность для агентных систем
Проверяйте бюджет окна контекста. Разница между резервом max_output_tokens и фактической p99 длиной вывода — это потерянный контекст. Ставьте жёсткий дефолт и повышайте его при truncation.
Проектируйте под стабильность кэша. Каждое поле в prompt либо стабильно, либо изменчиво. Ставьте стабильное первым, изменчивое последним. Любое изменение стабильного префикса посреди беседы считайте багом с денежной стоимостью.
Параллелизуйте startup I/O. Загрузка модулей упирается в CPU. Чтения keychain и сетевые handshake — в I/O. Запускайте I/O до импортов.
Используйте bitmap-предфильтры для поиска. Дешёвый предфильтр, отбрасывающий 10–90% кандидатов до дорогого scoring, даёт значимый выигрыш при цене 4 байта на запись.
Измеряйте там, где это важно. В Agent больше 50 контрольных точек запуска, которые снимаются на 100% внутренних и 0,5% внешних пользователей. Работа над производительностью без измерений — это гадание.
Финальное наблюдение: большинство этих оптимизаций не являются алгоритмически изощрёнными. Bitmap-предфильтры, кольцевые буферы, memoization, interning — это базовые вещи CS. Изощрённость в том, чтобы знать, где их применить. Startup profiler подсказывает, где лежат миллисекунды. Поле API usage подсказывает, где лежат токены. Коэффициент cache hit rate подсказывает, где лежат деньги. Сначала измерение, потом оптимизация, всегда.