Полгода назад я начал писать веб-лист персонажа для D&D 5e. Сейчас это D1MANYCH/dnd-app: ванильный JS без сборщика, 44 модуля, ~70 000 строк, 597 коммитов, версия 3.91.0. Код в нём почти целиком написан Claude Code — я держу архитектуру, ревью и решения по контенту. Эту статью модель тоже вычитала. Раз уж за такое иногда прилетает, говорю сразу, в первом абзаце.

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

Сначала замер, потом выводы

Ощущения тут бесполезны: «кажется, много читаю» — не диагноз. У Claude Code все сессии лежат в ~/.claude/projects/**/*.jsonl, и в каждой строке ассистента есть message.usage с реальным биллингом. Тридцать строк на Node дают полную картину:

// usage.js — расход по всем транскриптам: node usage.js
const fs = require('fs'), path = require('path');
const root = path.join(require('os').homedir(), '.claude', 'projects');
const files = [];
(function walk(d) {
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
    const p = path.join(d, e.name);
    if (e.isDirectory()) walk(p);
    else if (e.name.endsWith('.jsonl')) files.push(p);
  }
})(root);

let req = 0, read = 0, write = 0, out = 0, sessions = 0;
const bySession = [];
for (const f of files) {
  let n = 0, r = 0;
  for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
    if (!line.includes('"cache_read_input_tokens"')) continue;
    if (line.includes('"isSidechain":true')) continue;   // сабагенты — отдельный контекст
    let u; try { u = JSON.parse(line).message.usage; } catch (e) { continue; }
    if (!u) continue;
    n++; r += u.cache_read_input_tokens || 0;
    read += u.cache_read_input_tokens || 0;
    write += u.cache_creation_input_tokens || 0;
    out += u.output_tokens || 0;
  }
  if (n) { sessions++; req += n; bySession.push({ n, r }); }
}
bySession.sort((a, b) => b.n - a.n);
const long = bySession.filter(s => s.n > 300);
console.log('сессий:', sessions, '| запросов:', req);
console.log('кеш-чтение:', (read / 1e9).toFixed(2) + 'B', '| вывод:', (out / 1e9).toFixed(2) + 'B');
console.log('средний контекст запроса:', Math.round(read / req / 1000) + 'k');
console.log('сессий >300 запросов:', long.length,
  '| их доля расхода:', Math.round(long.reduce((a, s) => a + s.r, 0) / read * 100) + '%');

Что он мне выдал:

Метрика

Значение

Сессий / запросов к модели

169 / 28 649

Чтение из кеша

5,75 млрд токенов

Из них на этот проект

3,64 млрд (63%)

Запись в кеш

0,20 млрд

Выход (ответы модели)

0,03 млрд

Средний контекст одного запроса

201k

Сессий длиннее 300 запросов

22 штуки — 67% всего расхода

Самая длинная сессия

886 запросов

Если выложить сессии в ряд, отсортировав по расходу, картина становится неприличной:

Расход токенов по 169 сессиям Claude Code: 22 сессии длиннее 300 запросов дают 67% расхода
Расход токенов по 169 сессиям Claude Code: 22 сессии длиннее 300 запросов дают 67% расхода

Хвост из полутора сотен нормальных сессий — треть расхода. Слева — два десятка марафонов, которые съели остальное.

Первая же строчка таблицы отменяет половину советов из интернета. Выход — 0,03B против 5,75B входа, это полпроцента. Всё, что можно выгадать, укорачивая свои промпты и прося «отвечать кратко», лежит в этих полпроцента. Деньги — во входе, то есть в том, что тащится в каждый запрос.

Почему длинная сессия дороже нескольких коротких

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

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

Стоимость ≈ N·S + d·N²/2

Квадрат — вот вся суть. Сессия на 800 запросов стоит не вдвое, а вчетверо дороже четырёх сессий по 200 при том же объёме сделанной работы. Мои 22 «марафонские» сессии съели две трети всего, что я потратил, — ровно поэтому.

Второе следствие того же неравенства — слагаемое N·S. Стартовый контекст умножается на количество запросов. Каждая лишняя тысяча токенов в CLAUDE.md — это тысяча, умноженная на 150 запросов сессии, умноженная на все сессии. Мой CLAUDE.md ужат до 74 строк и написан по-английски (на 15–20% короче того же текста по-русски), при том что весь проект, UI, коммиты и чат — русские.

Куда конкретно уходил контекст

Отдельным проходом я разложил накопленный контекст по источникам:

Источник

Доля

Стартовый контекст × число запросов

39%

Результаты инструментов

32%

Сама переписка

28%

Внутри инструментов: чтение файлов — 43%, команды оболочки — 25%, картинки — 13%. Дальше пошли неприятные подробности.

32% всего объёма чтения — повторные чтения одного и того же файла в одной сессии. style.css (647 КБ, 16 123 строки) был прочитан 266 раз. Модель читает файл, что-то делает, через двадцать шагов «на всякий случай» читает снова — хотя первое чтение никуда не делось, оно лежит в контексте и оплачивается на каждом запросе. За второй Read я плачу дважды: за сам факт и за то, что он теперь навсегда в этой сессии.

459 скриншотов, около 130 миллионов токенов. Я проверял вёрстку через браузерное превью прямо в основном чате. Один скриншот — это единицы тысяч токенов, но он остаётся в контексте до конца сессии и едет в каждый следующий запрос.

Что я поменял

1. Жёсткий лимит сессии и Stop-хук, который о нём напоминает

Правило: 150 запросов или 200k контекста — и сессию режем, даже посреди задачи. Соблюдать это на глазок нельзя, поэтому счётчик повесил на Stop-хук — он срабатывает, когда модель закончила ход:

// tools/check-session-size-hook.js (сокращённо)
const WARN_REQ = 120, HARD_REQ = 150, WARN_CTX = 150000, HARD_CTX = 200000;
const lines = fs.readFileSync(input.transcript_path, 'utf8').split('\n');
let req = 0;
for (const l of lines) {
  if (!l.includes('"cache_read_input_tokens"')) continue;
  if (l.includes('"isSidechain":true')) continue;   // сабагенты не влияют на цену главной сессии
  req++;
}
// контекст — из последней строки с usage
const u = JSON.parse(last).message.usage;
const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);

if (req >= HARD_REQ || ctx >= HARD_CTX)
  console.error('⛔ Сессия разрослась: ' + req + ' запросов, контекст ' +
    Math.round(ctx / 1000) + 'k. Закрыть: /carry → /clear.');

Хук ничего не блокирует (exit 0), просто печатает строку на пороге. Этого достаточно: раньше я не знал, что сижу в 600-запросной сессии, теперь узнаю на 120-й.

2. /carry — ручная передача смены вместо автосуммаризации

Автоматическое сжатие контекста спасает от переполнения, но это не экономия: до момента сжатия вы уже оплатили всю дорогу. Поэтому у меня своя слэш-команда /carry: модель пишет пять строк — что сделано, в каком состоянии файлы, следующий шаг, открытые вопросы, — я делаю /clear и начинаю новую сессию с этих пяти строк вместо ста тысяч токенов истории.

Это же лечит и другую болезнь. Работа нарезана на фазы, каждая начинается с «начать фазу X-N» и заканчивается /carry. Одна задача — одна сессия.

3. Карта кода вместо разведки грепом

Классический сценарий: «поправь отступ в шапке» → три Grep, чтение 400 строк не туда, потом ещё 400 туда. Всё это остаётся в контексте до конца сессии.

Решается генератором: node tools/gen-map.js строит docs/map.md — 259 строк, где перечислены секции style.css с диапазонами строк, блоки верхнего уровня index.html, индекс функций по файлам и индекс констант в файлах данных. Правило в CLAUDE.md: перед правкой большого файла сначала оглавление карты (первые 20 строк), потом нужный раздел карты, потом чтение файла с точным offset/limit. Вместо тысячи строк разведки — сорок.

Это стоит делать, только если в проекте есть монстры вроде моих: style.css 647 КБ, data.js 610 КБ, spells.js 562 КБ, build-notes-data.js 545 КБ. На проекте из аккуратных файлов по 300 строк карта не окупится.

4. Прямой запрет на повторное чтение

Формулировка в CLAUDE.md: «Никогда не читай один файл дважды за сессию. Прочитанное всё ещё в контексте — прокрути назад. Если файл изменился, читай только изменённый диапазон». Звучит как очевидность, но без явного запрета модель перечитывает — ей так надёжнее, а цену этой надёжности видит только владелец аккаунта.

5. Скриншоты и браузер — только в сабагенте

Ключевое свойство сабагента: у него свой контекст, а в главную сессию возвращается только финальный ответ. Вся визуальная проверка ушла в отдельного агента verifier — он поднимает превью, кликает, читает консоль и сеть, делает скриншоты, а мне отдаёт пять строк вердикта. 459 картинок больше не оседают в главном треде.

Та же логика для широкого поиска по репозиторию: пусть агент прочитает двадцать файлов у себя и вернёт «вот эти три места».

6. Рутину — дешёвым моделям

Основной тред я веду на Opus, но релизная рутина в Opus не нуждается. Модель сабагента задаётся во фронтматтере .claude/agents/*.md:

Агент

Модель

Что делает

releaser

Sonnet

тесты → бамп версии → проверка инвариантов → коммит → пуш → ожидание CI

relpost

Haiku

собирает текст анонса релиза

content

Sonnet

добавляет игровой контент по готовому шаблону

verifier

Sonnet

проверка UI в браузере

Планирование, архитектура, разбор багов остаются в главном чате на старшей модели. Смысл разделения в том, что механическая работа по написанной инструкции не становится лучше от более умной модели, а стоит заметно дороже.

7. Батчинг вызовов и фон вместо polling

Цена растёт с количеством запросов, а не с количеством слов в них. Значит, независимые действия надо складывать в один ход: три чтения и два грепа в одном сообщении — это один запрос вместо пяти, и пять раз по 200k превращаются в 200k один раз.

Из того же: длинные команды — в фон, и ни в коем случае не «поспать и проверить». Каждый цикл «sleep → проверил → ещё не готово» — это полноценный оплаченный запрос с полным контекстом.

8. Детерминированные проверки — хуками, а не моделью

Всё, что можно проверить программой, проверяет программа, а не модель, которая для этого читает файлы. На PostToolUse у меня висят пять хуков: два блокирующих (exit 2) — «правил sw.js, но не бампнул CACHE_NAME» и рассинхрон версии с чейнджлогом, плюс node --check на любой изменённый JS; два предупреждающих — тесты и контраст темы.

Хук стоит ноль токенов и ловит ошибку в ту же секунду. Тот же класс проблем, пойманный моделью через три хода, стоит трёх полных контекстов.

Что не сработало

Сабагент ради одной команды. Агент стартует холодным: заново читает CLAUDE.md, заново ищет файлы. На короткой задаче это дороже, чем сделать самому. Сабагент окупается там, где он прочитает много, а вернёт мало.

Локальные модели как замена сабагентам. У меня RTX 4060 Ti на 8 ГБ — влезает 7–8B в четырёхбитном кванте. Для рутины в реальном коде этого мало, а Claude Code к локальным моделям и не подключается.

Просить «отвечай короче». Выход — 0,5% расхода. Экономить на нём — это торговаться за копейки, пока в соседней комнате горит проводка.

Чеклист

Приём

Что лечит

Лимит 150 запросов / 200k + Stop-хук со счётчиком

квадратичный рост стоимости сессии

/carry/clear вместо автосжатия

оплату всей истории до момента сжатия

Короткий CLAUDE.md (у меня 74 строки)

стартовый контекст × число запросов

Карта кода с диапазонами строк

разведочные Grep и чтение больших файлов целиком

Запрет повторного чтения

32% объёма чтения

Браузер и скриншоты — в сабагента

картинки, оседающие в главном треде

Рутина на Sonnet/Haiku

переплату за механику

Батчинг независимых вызовов, фон вместо polling

лишние запросы, каждый с полным контекстом

Проверки хуками, а не моделью

циклы «ошибся — прочитал — исправил»

Честный финал

Цифр «после» у меня пока нет: правила и хуки введены недавно, а сравнивать надо на дистанции в пару недель работы, иначе намеряешь шум. Перезамерю тем же скриптом; цель — средний контекст запроса ниже 150k и полное отсутствие сессий длиннее 300 запросов.

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

Если у вас есть свои приёмы — особенно если вы дошли до цифр, а не ощущений, — напишите в комментариях, интересно сравнить методики замера.