Как скормить нейросети большой проект и не скормить лишнего: что я узнал, пока агенты читали мой код

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

Как скормить нейросети большой проект и не скормить лишнего: что я узнал, пока агенты читали мой код - 1

Копипаста

Кто спрашивал нейросеть про свой код, знает этот ритуал. Открываешь файл, копируешь, вставляешь в чат, потом второй, потом третий. На третьем замечаешь, что модель уже сочиняет интерфейс, который ты забыл показать, и уверенно вызывает у него метод, которого нет. Идёшь за интерфейсом, вставляешь, и через полчаса в чате каша из кусков, а ответ приходится проверять построчно, потому что модель видела проект через замочную скважину.

Сначала я написал скрипт для своих проектов на 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

Источник

Оставить комментарий