Как скормить нейросети большой проект и не скормить лишнего: что я узнал, пока агенты читали мой код
Год назад я копировал файлы в чат с нейросетью по одному и каждый раз забывал какой‑нибудь интерфейс. Сейчас Claude Code и Codex читают проект сами, через MCP‑сервер, который я написал и выложил в открытый доступ. Эта статья не про сам инструмент, а про то, что выяснилось по дороге: почему текстовое дерево проекта обходится модели дороже JSON, почему папку build нельзя пропускать по имени, как спрятать пароль и не сломать код вокруг него, и что агент на самом деле делает с контекстом, когда его никто не контролирует. Последнее удивило меня сильнее всего.

Копипаста
Кто спрашивал нейросеть про свой код, знает этот ритуал. Открываешь файл, копируешь, вставляешь в чат, потом второй, потом третий. На третьем замечаешь, что модель уже сочиняет интерфейс, который ты забыл показать, и уверенно вызывает у него метод, которого нет. Идёшь за интерфейсом, вставляешь, и через полчаса в чате каша из кусков, а ответ приходится проверять построчно, потому что модель видела проект через замочную скважину.
Сначала я написал скрипт для своих проектов на C#, потом окно с деревом и галочками, потом это разрослось. Дальше в тексте будут цифры из этого инструмента и из его сравнения с Repomix, самым известным упаковщиком репозиториев. Все замеры лежат в репозитории вместе с коммитами, на которых они сделаны, ссылка в конце.
Самый дорогой формат
Долгое время я считал дерево проекта бесплатной частью контекста: пара экранов с именами файлов, о чём тут думать. Форматов у меня со временем стало четыре, ASCII, JSON, XML и Markdown, и выбирал я между ними на глаз, пока однажды не прогнал дерево Flask через настоящий токенизатор. Токенизатор Claude не опубликован, поэтому считал открытым o200k_base от OpenAI.
Любимое ASCII‑дерево, с которого всё начиналось, обошлось в 2 785 токенов. Markdown‑список с той же информацией уложился в 1 663. Даже JSON со всеми его кавычками и скобками вышел дешевле: 1 802. Всё дело в псевдографике: чтобы показать вложенность, ASCII‑дерево на каждой строке повторяет │ столько раз, сколько уровней над файлом, и токенизатор берёт плату за каждую палочку. Вот четыре строки одного и того же поддерева, в ASCII это 38 токенов:
│ ├── json
│ │ ├── provider.py
│ │ ├── tag.py
│ │ └── __init__.py
В Markdown те же строки занимают 24 токена. Обратные слэши здесь не опечатка, это настоящий вывод, экранирование подчёркиваний:
- json/
- provider.py
- tag.py
- __init__.py
С тех пор MCP‑сервер отдаёт дерево в Markdown, а ASCII я оставил для людей: глазами его читать всё‑таки приятнее. Разница в тысячу токенов на одном дереве кажется мелочью, но дерево агент запрашивает в начале почти каждой сессии, и мелочь превращается в постоянный налог.
Папка build, которая не build
Следующая очевидная мысль: не отдавать модели мусор. Составил список из bin, obj, node_modules, dist, пропускаешь всё, что совпало, и готово. Работает это ровно до первого чужого проекта. В JS‑монорепозитории папка packages и есть весь исходный код. vendor в Go хранит зависимости, а в каком‑нибудь интернет‑магазине так называется модуль работы с поставщиками. build бывает и результатом сборки, и пакетом с кодом сборщика. Пропусти такую папку по имени, и модель не увидит исходники; хуже того, она не узнает, что они вообще были, и начнёт уверенно рассуждать о проекте без половины кода.
Поэтому в моём фильтре имя папки ничего не решает, оно только повод присмотреться. Сначала фильтр смотрит на маркеры стека: package.json, .csproj, go.mod, pom.xml, Cargo.toml и десятки других. Ближайший маркер владеет своей частью дерева, и правила его стека действуют только внутри неё. В монорепозитории, где рядом лежат бэкенд на.NET, фронтенд на React и скрипты на Python, у каждой части свои правила, и node_modules фронтенда никак не влияет на соседнюю папку с тем же именем.
Для спорных имён вроде build, bin или vendor фильтр заглядывает внутрь и ищет улики: метаданные компилятора или пакетного менеджера, характерную структуру кэша, заголовки скомпилированных бинарников, сгенерированные манифесты. Смотрит он ограниченно и по симлинкам не ходит. Нет доказательств, что это артефакт сборки, значит, папка остаётся видимой. Так в.NET‑проекте прячется bin с результатами сборки, но не папка bin с исходниками. Лишний файл модель переживёт, а пропавший модуль уже нет.
С .gitignore вышло похоже. Сначала я думал обойтись простым парсером, но в живых репозиториях есть вложенные .gitignore, отрицания через !, вложенные репозитории и разная чувствительность к регистру. В какой‑то момент я перестал изобретать и принял одно правило: видно то, что сам Git считает неигнорируемым. Парсер при этом свой, а глобальный core.excludesFile я сознательно не читаю, чтобы результат не зависел от настроек конкретной машины.
Сам Git оказался не таким безобидным, как кажется. В чужом репозитории даже git status может запустить программу из его настроек, например через core.fsmonitor. Поэтому инструмент вызывает Git с отключёнными хуками, fsmonitor и внешними diff‑программами, а git.exe, подложенный в папку проекта, не запустит. Про безопасную работу с Git я напишу отдельно, там хватает материала на статью, а пока только общая часть каждого вызова:
git --no-pager --no-optional-locks
-c core.fsmonitor=false
-c core.quotepath=false
-c core.hooksPath=<пустая папка приложения>
-c credential.helper=
-c core.askPass=
-c log.showSignature=false
-c submodule.recurse=false
-c core.attributesFile=<пустой файл приложения>
-c core.excludesFile=<пустой файл приложения>
Карта вместо кода
Даже аккуратно отфильтрованный проект часто не влезает в контекст, а для половины вопросов модели не нужны тела методов. Ей нужна карта: какие есть классы, что принимают методы, что возвращают, какие константы заданы. Поэтому я взялся за сжатие: файл разбирается грамматикой Tree‑sitter, тела именованных методов вычищаются, остаются объявления, сигнатуры, поля, свойства и константы вместе со значениями. Вот обычный класс из моего проекта после сжатия:
namespace DevProjex.Application.Services;
public readonly record struct TopFileMetric(string Path, long Tokens);
public sealed class TopFileRanking
{
private readonly int _capacity;
private readonly List<TopFileMetric> _items;
public TopFileRanking(int capacity)
{ }
public IReadOnlyList<TopFileMetric> Items => _items;
public TResult[] Project<TResult>(Func<TopFileMetric, TResult> projection)
{ }
public void Add(string path, long tokens)
{ }
private sealed class TopFileMetricComparer : IComparer<TopFileMetric>
{
public static readonly TopFileMetricComparer Instance = new();
public int Compare(TopFileMetric left, TopFileMetric right)
{ }
}
}
Тот же файл до сжатия
namespace DevProjex.Application.Services;
public readonly record struct TopFileMetric(string Path, long Tokens);
public sealed class TopFileRanking
{
private readonly int _capacity;
private readonly List<TopFileMetric> _items;
public TopFileRanking(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
_items = new List<TopFileMetric>(capacity);
}
public IReadOnlyList<TopFileMetric> Items => _items;
public TResult[] Project<TResult>(Func<TopFileMetric, TResult> projection)
{
ArgumentNullException.ThrowIfNull(projection);
var result = new TResult[_items.Count];
for (var index = 0; index < _items.Count; index++)
result[index] = projection(_items[index]);
return result;
}
public void Add(string path, long tokens)
{
var candidate = new TopFileMetric(path, tokens);
var index = _items.BinarySearch(candidate, TopFileMetricComparer.Instance);
if (index < 0)
index = ~index;
if (index >= _capacity)
return;
_items.Insert(index, candidate);
if (_items.Count > _capacity)
_items.RemoveAt(_capacity);
}
private sealed class TopFileMetricComparer : IComparer<TopFileMetric>
{
public static readonly TopFileMetricComparer Instance = new();
public int Compare(TopFileMetric left, TopFileMetric right)
{
var tokenOrder = right.Tokens.CompareTo(left.Tokens);
return tokenOrder != 0
? tokenOrder
: StringComparer.Ordinal.Compare(left.Path, right.Path);
}
}
}
Было 387 токенов, осталось 181. Пустые { } агент видит не вслепую: в ответе сервера указано, какой уровень детализации применён к каждому файлу, так что он знает, что тело вырезано, а не отсутствует. Кое‑что сжатие специально не трогает. Безымянные лямбды остаются целиком: если опустошить тело без имени, от него не останется ничего полезного. А файл, который не получается безопасно разобрать, уходит целиком, без всякого сжатия.
На целых проектах результат сильно зависит от того, как написан код. Прикладной слой моего проекта на C# сжимается примерно втрое, с 427 до 143 тысяч токенов. Исходники Flask теряют меньше трети, потому что докстринги в Python я сохраняю: для модели докстринг часто полезнее самого кода. Если выкинуть заодно и комментарии, Flask ужимается с 87 до 26 тысяч. А Hono почти не поддаётся и теряет чуть больше пятой части. Причина нашлась быстро: 114 его файлов из 294 составляют тесты, а тест выглядит как test('...', () => { ... }), то есть та самая безымянная лямбда. После этого обещать «сжимаем в N раз» я не стану никому.
Секреты: значение, а не файл
Любой способ отдать проект нейросети рано или поздно отдаёт ей .env, и пароль от базы уезжает на чужой сервер вместе с кодом. Самое простое решение: выкинуть файл, в котором нашёлся секрет. Так делает Repomix, и логика у него есть: рядом с найденным секретом может лежать второй, которого правила не поймали. Но вместе с секретом пропадает и код вокруг него, например целый класс настроек, и модели остаётся гадать. Я выбрал другой путь: заменять только само значение. Вот что получает агент из файла с настройками, где лежат пароль от базы и токен GitHub (ключи, понятно, ненастоящие):
import os
class Settings:
"""Runtime configuration for the shop service."""
DATABASE_URL = "postgresql://shop_app:DEVPROJEX_REDACTED[credential-uri-password#1]@db.internal:5432/shop"
GITHUB_TOKEN = "DEVPROJEX_REDACTED[config-secret#1]"
CACHE_TTL_SECONDS = 300
SMTP_HOST = "smtp.internal"
def database_pool_size(self) -> int:
return int(os.environ.get("DB_POOL_SIZE", "10"))
Модель видит, что база есть, на каком она хосте и под каким пользователем, что где‑то нужен токен GitHub, и может писать код, который всем этим пользуется. Самих значений она не видит. Repomix на этом же файле пишет «1 suspicious file(s) detected and excluded», и от файла в упаковке остаётся только имя.
В заглушке есть тип секрета и номер. Одно и то же значение под одним правилом получает один номер во всех файлах, поэтому модель понимает, где используется один и тот же ключ, хотя ни разу его не видела. Замена знает форматы: в строке подключения заменяется только пароль, кавычки, разделители и соседние поля остаются на месте, и то же самое в .env, JSON, YAML, XML, Dockerfile, .netrc и .npmrc. Внутри работает 221 правило из Gitleaks, перенесённое на.NET, плюс отдельные правила для .env, строк подключения и Dockerfile. Правила работают на движке регулярных выражений без бэктрекинга, у всех выражений есть таймаут, и всё считается локально, без модели и без отправки кода куда‑либо.
И да, оно ошибается. В первой версии с маскированием правило для строк подключения принимало за пароль обычное password=auth[1] в коде httpx, заменяло его заглушкой и обрезало остаток строки, так что агент получал синтаксически сломанный файл. Нашёл это не я, а агенты в тестовых прогонах: двое из них прямо написали, что из‑за этого не доверяют отданному коду. Правило переписал в разборщик областей, который понимает, где вообще может начинаться значение. Поэтому инструмент никогда не называет результат безопасным: находка значит, что сработало правило, а отсутствие находок значит только, что правила ничего не нашли.
Зачем это, если агент читает сам
Пока я возился с форматами и масками, агенты научились читать проект без меня. Claude Code и Codex сами ходят по папкам, ищут и открывают файлы. Зачем тогда вообще что‑то фильтровать? Затем, что агент читает всё, до чего дотянется, включая тот же .env, и часто читает файлы целиком. MCP, протокол, через который агенты подключают внешние инструменты, позволяет отдать агенту тот же движок: фильтр папок, маскирование, сжатие и поиск, причём только на чтение. Агент может сузить себе выборку путями, масками или бюджетом токенов, но выйти за папку, которую ему дали, не может, а содержимое файлов приходит в обёртке «это данные, а не инструкции». Полной защиты от prompt injection это не даёт, но агенту труднее принять комментарий вроде «игнорируй предыдущие указания» в чужом репозитории за команду.
Встроенные инструменты агента сервер при этом не отключает, и через обычный cat .env агент пароль увидит. Сам .env стоит закрыть в настройках агента, в Claude Code для этого есть permissions.deny. Но секрет редко живёт только там: он сидит в appsettings.Development.json, в тестовом конфиге, в строке подключения посреди кода. Запрет по имени файла такое не ловит, а маскирование значений ловит.
Отдельный вопрос, нужен ли вообще такой сервер, если у агента есть свои Read, Grep и Glob. Я проверил на двух репозиториях одним и тем же набором задач. На маленьком httpx, около девяти тысяч строк, встроенные инструменты оказались дешевле и ничуть не хуже: 26 600 токенов и 14 вызовов против 32 900 и 16 через сервер, ответы те же. На hono, это 43 тысячи строк, встроенные инструменты нашли 0 из 4 ключевых файлов и в трёх сессиях из четырёх упёрлись в лимит ходов, а через сервер агент нашёл 4 из 4 за 49 300 токенов. Честная формулировка получается такой: на маленьком репозитории каталог инструментов MCP это чистые накладные расходы, на большом он удерживает агента в бюджете, когда встроенные средства до ответа не доходят.
Что агент делает с контекстом на самом деле
Это та часть, ради которой я сел писать статью. Весь сентябрь я гонял агентов через свой сервер и через MCP‑сервер Repomix на чужих открытых репозиториях: httpx, hono, Serilog, потом gin, gson, axum, jsoup, click, fmt, MediatR, chi. Задачи были из четырёх видов: найти баг по симптомам, найти точное значение, оценить последствия изменения интерфейса, разобраться в механизме. Серверы в сессиях назывались server_a и server_b, агент не знал, где чей, а ответы потом сравнивал отдельный судья, которому названия тоже не показывали. Модель везде одна, Claude Haiku, чтобы серии были сопоставимы и не разоряли меня.
Первое, что выяснилось: агенты обожают читать файлы целиком. Поиск уже показал нужные двадцать строк с именем объявления, а следующим вызовом агент всё равно просит весь файл. Я разобрал двенадцать таких чтений по одному. Семь оказались рефлекторными: имя объявления у агента уже было в руках, он просто не стал им пользоваться. Три случились из‑за того, что поиск обрезал результаты по алфавиту и нужный файл в выдачу не попал; агент знал только имя файла и прочитал его целиком «на пробу», это самый дорогой класс, около 46 килобайт на три чтения. Ещё два были угадыванием по названию папки из дерева. И во всех двенадцати случаях решение принималось сразу после результата предыдущего вызова, без единой строки рассуждений.
Из этого следует неочевидная вещь. Описание инструмента агент читает один раз, при старте сессии, и в момент выбора оно на решение не влияет. Я пробовал объяснять в описаниях, что есть чтение объявления по имени, что есть пакетное чтение нескольких диапазонов, что не надо читать файл целиком. Эффект нулевой: за весь обзорный прогон чтение по имени объявления использовалось 0 раз из 30 вызовов. А вот подсказка внутри самого результата, рядом с блоком «в каких объявлениях лежат совпадения», работала. Ещё лучше работал отказ с готовым вызовом: когда агент по ошибке передал вместо файла папку, сервер вернул ему правильный вызов для этой папки, и агент тут же его выполнил. С тех пор все подсказки у меня живут в ответах, а не в описаниях.
Второе: агенты оказались лучшими тестировщиками из всех, что у меня были. Первый же слепой прогон показал, что Claude Code молча не видит один из восьми инструментов. В схему get_file я добавил not, чтобы запретить два взаимоисключающих параметра, и клиент на такой схеме тихо выбросил инструмент из списка, а агенты в отчётах жаловались на «фантомный get_file», о котором пишут инструкции. Теперь контрактный тест запрещает в схемах not, allOf, anyOf, if, then, else, $ref и const. Там же всплыл и баг маскировщика с password=auth[1], о котором я писал выше.
Третье: статистика не предсказывает поведение. Поиск у меня возвращает тело объявления, в котором лежит лучшее совпадение, и встал вопрос, сколько символов отдавать. Я посчитал распределение размеров объявлений: 70 процентов укладываются в 1 800 символов, и логика подсказывала поднять лимит до 3 000, чтобы реже обрезать. Потом я измерил это на 36 сессиях, один и тот же бинарник, три значения лимита, четыре задачи по три повтора:
|
Лимит тела |
Ходов |
Стоимость |
Полных ответов |
Пропущено обязательных файлов |
|---|---|---|---|---|
|
выключено |
173 |
$1,27 |
2 из 12 |
22 из 54 |
|
1 800 символов |
161 |
$1,21 |
5 из 12 |
14 из 54 |
|
3 000 символов |
179 |
$1,38 |
2 из 12 |
30 из 54 |
Вариант 3 000, который на тот момент был значением по умолчанию, проиграл по всем осям, включая единственный ответ, который судья счёл неверным. Лишний объём стоил дороже, чем обрезка с подсказкой, какие строки дочитать. Лимит остался 1 800.
Четвёртое, и это главный вывод месяца: кто дешевле, решает не инструмент, а форма вопроса. Repomix упаковывает область в один документ и дальше ищет по нему, мой сервер ищет и читает ровно нужные фрагменты. На обзорных вопросах вида «как устроен механизм X» упаковка выигрывала: один раз заплатил, дальше читаешь дёшево. На вопросах с конкретным ответом выигрывало точечное чтение. В одной серии на одном и том же бинарнике это выглядело так: обзорные задачи 82 вызова и 70 825 токенов у меня против 47 и 50 199 у Repomix, точечные 33 вызова и 23 450 токенов против 29 и 50 393. Правильность ответов при этом совпадала на каждой задаче. С тех пор я перестал спрашивать «кто дешевле» и спрашиваю «дешевле на чём».
Финальная серия перед релизом, уже после всех исправлений: 11 задач на восьми репозиториях на шести языках, каждая по два раза с каждым сервером, 22 сессии на сторону. Через мой сервер агент израсходовал 973 020 токенов, через Repomix 1 174 907; обязательных файлов нашёл 85 из 90 против 83; вызовов сделал больше, 490 против 461, и почти весь перебор пришёлся на те самые обзорные задачи. Судья предпочёл ответы через мой сервер в 16 парах из 22. Тут нужны две оговорки. Задачи составлял я сам, и тот же набор потом проверял исправления, так что это бенчмарк разработки, а не независимый. И одинаковые прогоны у меня расходились на 20–25 процентов: одна и та же задача на одном и том же сервере стоила 45 824 токена в одной сессии и 23 299 в следующей. Разница меньше чем в два раза на отдельной задаче ничего не значит, значат только суммы.
Про судью тоже стоит сказать честно. Он читал каждую пару ответов в обоих порядках, и вердикт засчитывался только при совпадении. Правильность при этом оказалась устойчивой метрикой, а «какой ответ приятнее» нет: на половине пар судья спорил сам с собой. И заметная часть его доверия к моим ответам объяснялась тем, что ответы через Repomix ссылались на номера строк внутри упакованного файла, а не исходного, и судья считал такие ссылки неверными. Это особенность их режима упаковки, которую легко поправить, поэтому счёт судьи я нигде не выношу в заголовок.
Фокус задаёт человек
Последнее, что оставалось, не измерить, а решить. Агент не знает, над каким куском проекта я сейчас работаю, и каждую задачу начинает с поиска по всему репозиторию, а я пишу ему словами: «смотри в sessions.py, остальное не трогай». После месяца наблюдений ответ показался очевидным: пусть фокус задаёт человек, а инструмент честно сообщает агенту границы.
В окне инструмента отмечаешь галочками файлы и папки, и дерево, поиск, упаковка и связи между файлами работают только внутри отмеченного. Остальное агент может прочитать по имени, если оно проходит фильтры окна, но за сами фильтры не выйдет: галочки задают фокус, фильтры задают границу. Агента при этом не держат в неведении, каждый ответ говорит, сколько осталось снаружи, и это та же идея «подсказка в результате, а не в описании»:
[Live context] focus: 1 of 9 selectable files; 8 outside the focus were not searched; read any of them by name with get_file.
Когда я меняю галочки, агент на следующем же вызове узнаёт, что изменилось с прошлого раза, без перезапуска сессии:
[Live context] changed since revision 14: +2 folders, -1 file
Обратная связь нужна и в другую сторону. Файлы, которые агент получил, помечаются в дереве, а журнал сессии хранит клиента, число вызовов, примерную цену в токенах, список полученных файлов и количество замаскированных значений. Содержимое файлов и сами секреты в журнал не попадают: это квитанция о том, что именно ушло модели, а не копия.
Что я вынес из этого месяца
Если собрать всё вместе, получается короткий список, и ни один пункт в нём я бы не угадал заранее. Он же объясняет, почему инструмент выглядит именно так, а не иначе.
-
Форма вопроса решает, какой подход дешевле. Упаковка выигрывает обзорные задачи, точечное чтение выигрывает задачи с конкретным ответом, и оба утверждения верны одновременно.
-
Агент решает читать файл целиком сразу после результата. Управлять им можно только из результата: описания и инструкции двигают первый шаг сессии и ничего после него.
-
Статистика размеров не предсказывает поведение модели. Предсказывают только сессии, и 36 сессий дешевле, чем неверное значение по умолчанию в релизе.
-
Замена значения секрета оставляет код читаемым, удаление файла нет. В пробе с семью подложенными ключами оба подхода не пропустили ни одного, но код вокруг остался читаемым в семи файлах из семи против одного из семи.
-
На маленьком репозитории встроенных инструментов агента достаточно, а на большом они не доходят до ответа. Именно там внешний сервер и окупается.
-
Одинаковые сессии различаются на четверть. Без размера выборки любая цифра в этой области ничего не стоит, включая мои.
Инструмент, на котором всё это измерено, открыт под Apache-2.0, а полный журнал измерений по сериям, с коммитами, версиями Repomix и дословными вердиктами агентов и судьи, лежит в его репозитории в Docs/Benchmark-History.md: https://github.com/Avazbek22/DevProjex. Если вы прогоняете агентов на своих репозиториях иначе и получаете другие цифры, мне это интереснее, чем совпадающие.
Автор: Avazbek22

