Полгода назад я начал писать веб-лист персонажа для 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.jsconst 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 запросов |
Если выложить сессии в ряд, отсортировав по расходу, картина становится неприличной:
Хвост из полутора сотен нормальных сессий — треть расхода. Слева — два десятка марафонов, которые съели остальное.
Первая же строчка таблицы отменяет половину советов из интернета. Выход — 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++;}// контекст — из последней строки с usageconst 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:
|
Агент |
Модель |
Что делает |
|---|---|---|
|
|
Sonnet |
тесты → бамп версии → проверка инвариантов → коммит → пуш → ожидание CI |
|
|
Haiku |
собирает текст анонса релиза |
|
|
Sonnet |
добавляет игровой контент по готовому шаблону |
|
|
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 + |
квадратичный рост стоимости сессии |
|
|
оплату всей истории до момента сжатия |
|
Короткий |
стартовый контекст × число запросов |
|
Карта кода с диапазонами строк |
разведочные |
|
Запрет повторного чтения |
32% объёма чтения |
|
Браузер и скриншоты — в сабагента |
картинки, оседающие в главном треде |
|
Рутина на Sonnet/Haiku |
переплату за механику |
|
Батчинг независимых вызовов, фон вместо polling |
лишние запросы, каждый с полным контекстом |
|
Проверки хуками, а не моделью |
циклы «ошибся — прочитал — исправил» |
Честный финал
Цифр «после» у меня пока нет: правила и хуки введены недавно, а сравнивать надо на дистанции в пару недель работы, иначе намеряешь шум. Перезамерю тем же скриптом; цель — средний контекст запроса ниже 150k и полное отсутствие сессий длиннее 300 запросов.
Но одну вещь можно сказать, не дожидаясь замера. Работа с кодовым агентом — это не «попросил и получил». Это управление одним-единственным ресурсом: тем, что модель тащит с собой на каждом шагу. Всё остальное — производные.
Если у вас есть свои приёмы — особенно если вы дошли до цифр, а не ощущений, — напишите в комментариях, интересно сравнить методики замера.
ссылка на оригинал статьи https://habr.com/ru/articles/1077658/