
Клонировал разум — дай разуму память. Как я строил слой памяти для команды из ИИ‑агентов, чтобы они перестали переписывать то, что уже есть
Это третья статья из серии о том, как я строю свой стартап руками ИИ‑агентов, и если первые две были про то, с чего всё началось и как я добрался до сути, то эта — про проблему, к которой меня привела сама логика предыдущих шагов.
В первой я рассказывал, как вообще в это ввязался: один человек, Telegram‑first SaaS для авторов курсов и владельцев сообществ, и вся разработка идёт через агентов Claude Code, без наёмной команды.
Во второй я разбирал, как клонировал не код, а само мышление — вытаскивал из переписок и живых разговоров с экспертом цепочку его рассуждений по научной методике, складывал её в манифест и скармливал агенту, чтобы тот не просто знал факты, а принимал решения так, как принял бы их этот человек. Получился цифровой двойник, который воспроизводит не ответы, а сам способ к ним приходить.
А эта статья — про то, что выросло из второй само собой и чего я сначала даже не заметил: разум я склонировал, но у склонированного разума нет ни малейшей памяти о моём коде, и поэтому он раз за разом забывает то, что уже было сделано, и принимается переделывать заново.
Я довольно долго не мог сформулировать, что именно меня раздражает в работе с агентами, потому что придраться вроде бы не к чему: они умные, причём умные по‑настоящему — держат архитектуру в голове, ловят edge‑кейсы, пишут тесты лучше, чем добрая половина живых разработчиков, которых я в своё время нанимал. И всё‑таки с каждым новым агентом я будто заново встречаю сеньора в его первый рабочий день — блестящего, но с абсолютной амнезией на всё, что касается именно моего проекта.
Выглядит это так. Агент получает задачу — ему нужно, допустим, проверить, что строка является валидным UUID, — и в кодовой базе для этого уже три недели как лежит готовая функция, написанная другим агентом, вот только текущий про неё ничего не знает. И дальше он делает ровно то, что сделал бы на его месте любой человек без памяти: начинает угадывать и грепать по имени, которое ему кажется правильным, — пробует isValidUuid, такого нет, пробует validateUuid, снова нет, пробует checkUuid, и опять мимо. А вот теперь происходит самое дорогое во всей этой истории: не найдя ничего по трём придуманным именам, агент делает на первый взгляд логичный вывод — «значит, этого ещё не существует» — и пишет свою реализацию, чуть‑чуть отличающуюся от той, что уже есть.
Через неделю таких эпизодов в кодовой базе уже живут три проверки UUID, два рендерера шаблонов с разной семантикой и одна и та же склейка для записи файла в S3, размазанная по четырём местам и в каждом чуть‑чуть своя, — паттерны медленно расходятся, и код, который со стороны выглядит как написанный слаженной командой, на деле написан четырьмя незнакомцами, которые ни разу друг с другом не разговаривали.
И в какой‑то момент до меня дошло, где здесь настоящее узкое место, — а оно совсем не там, где его ищешь по привычке. Дело не в интеллекте модели, потому что модель как раз умнее, чем требуется для этой задачи; дело в памяти и извлечении. Агент ничуть не глупее живого сеньора (ну может чуть‑чуть) — он просто каждый раз выходит на работу с чистой головой, и спросить ему при этом решительно не у кого.
Дальше — честный рассказ о том, как я с этим разбирался: с тупиками, с по‑настоящему неприятным отрицательным результатом ровно посередине и с развилкой, на которой я чуть было не принял неверное архитектурное решение. Тупики я сознательно не стал вырезать, потому что именно в них, по моему опыту, и прячется самое полезное.
Сразу честно: к этой проблеме я подхожу уже не в первый раз
И прежде чем рассказывать, что я в итоге построил, будет нечестно делать вид, будто граф концептов для кода родился у меня на ровном месте, - на самом деле я воюю с агентской амнезией не один месяц, и нынешнее решение это уже надцатый подход к снаряду, выросший из целого кладбища предыдущих, каждый из которых что-нибудь да дал.
Начинал я с того, что у меня в репозитории с самого старта проекта жили тех-дизайны - живые документы, по одному на доменную область, в которых я фиксировал, зачем и почему делается фича, какая за ней стоит преамбула и какие у неё интерфейсы. По мере того как фича доезжала до реализации, инлайновые описания интерфейсов я заменял на ссылки на конкретные файлы, и делал это ровно затем, чтобы не плодить дубликат одного и того же интерфейса сразу в двух местах - в коде и в документе. Со временем эти тех-дизайны повели себя как эпики: каждый накрывал целый домен от края до края, и в какой-то момент они уже физически не могли удержать в себе всю деталь, не разрастаясь до того состояния, когда документ просто перестают читать.
Поверх этого выросло то, что я и сегодня считаю лучше всего, что было на рынке в тот момент, - spec-driven development, целый конвейер артефактов на диске, где proposal отвечал на «зачем», дельта-спеки в папке specs/ - на «что», design - на «как», а tasks был чек-листом реализации, и сверху всё это сшивалось мета-табличкой .specmeta.yaml, в которой лежали ссылки на те тех-дизайны, с которыми данный change-request работает. По завершении всех тасков change-request целиком уезжал в папку archive, чтобы не мозолить глаза, - штука получилась мощная, но довольно вербозная, потому что один замысел растекался по пяти, шести, а то и восьми декомпозированным документам.
Этот spec-driven я ещё и обвесил специализированными агентами под каждый слой: один сидел на бэкенде, другой на фронте, отдельный занимался работой с базой данных, плюс был ревьюер, плюс безопасник, плюс qa, - и в каждого из них я подмешивал техники, подсмотренные у GetShiDone и у BMad-метода, всё то, что позволяло агенту работать над кодом вдумчивее и почаще проверять самого себя, а не лупить с плеча. Но даже этого в итоге оказалось мало, потому что концептов и change-request'ов становилось всё больше, грепы по ним уже не спасали, а главное - не хватало своего рода снапшота, честного ответа на простой вопрос «а как эта фича должна выглядеть прямо сейчас». Агенты ведь то не находили нужные файлы, то напрочь забывали про них, то не читали их вовсе, то искали совсем не так, как надо.
Поэтому я несколько раз возвращался к встроенному режиму планирования в Claude и начал сохранять планы целиком, отдельными файлами, - и этим я, по сути, сознательно ушёл от spec-driven, чтобы детально проработанный план не размазывался по шести-восьми кускам и не терял по дороге, на швах между документами, ровно ту информацию, ради которой он и затевался.
Тогда же я прикрутил graphify-запрос - тот самый graphify, который, к слову, дёргает под капотом и GetShiеDone в своей команде /gsd-graphify, - и вот он-то меня как раз и не спас. Я честно натравил его и на код, и на документы, он показал мне какое-то количество рёбер между сущностями, но толку вышло немного: graphify - инструмент чисто структурный, у него нет ни эмбеддингов, ни векторного хранилища, поэтому отвечать он умеет на «кто вызывает X», «каков радиус поражения» и «где god-node», но не на главный для дедупликации вопрос «а есть ли вообще что-то похожее на это». Найти в нём можно ровно то, что ты уже способен назвать по имени. К тому же его межфайловые рёбра почти наполовину выведены самой моделью, то есть это такие же догадки, как и греп, только этажом выше, а на выходе - неудобные авто-именованные и пронумерованные community-страницы и god-узлы, обход которых стоит дорого, да и сам индекс быстро устаревал и в рабочих копиях-worktree вообще не подтягивался. (Я сначала думал, что у него попросту «нет входящих рёбер», но это оказалось не так - входящие он как раз умеет, и проблема глубже: без семантики он не отвечает на «похоже ли это на уже написанное».)
И вот уже по мотивам Карпатова - его паттерна «LLM-wiki», который я отдельно исследовал и от которого взял идею, но не взял ни одной из чужих реализаций, потому что все они оказались однодневками одного автора, - я и собрал себе concepts-search. Работает он на простой локальной модели Xenova/bge-small-en-v1.5 на 384 измерения, которая крутится прямо на машине, так что ни единой строчки кода наружу не утекает, а в основе всего лежит харвестинг: отдельный пайплайн вычёсывает всё, что накопилось в architecture/changes, в тех-дизайнах и в самом реализованном коде, и складывает это в граф из маленьких курируемых markdown-концептов - на сегодня их 214 штук, из которых 205 уже проиндексированы эмбеддингами, - по которым агент потом ходит через MCP-сервер с горсткой команд (search, semantic-search, get, neighbors, propose).
И это, надо сказать, дало хороший результат - агенты стали заметно лучше находить бизнес-концепции, то самое «а как мы здесь делаем X» на уровне домена. Вот только покрывал этот граф именно доменное поведение, дай бог процентов шестьдесят контроллеров и сервисов, а то, чего ему по-прежнему отчаянно недоставало, - это то же самое, но для кода: для утилит из shared/, для тестовой инфраструктуры, для канонических паттернов. И ровно об этом - о том, как проиндексировать концепции, которые живут уже не в документах, а в самом коде, чтобы наконец-то сбить процент дубликации, - и пойдёт дальше речь.
А подтолкнули меня к этому цифры, которые к тому моменту откровенно пугали: после недели работы агентов у меня в репозитории обычно оказывалось плюс шестьдесят, а то и все шестьдесят пять тысяч строк кода - и всего минус полторы тысячи, - и это притом, что у меня довольно толстый набор end-to-end тестов на Playwright, куча unit-тестов и живая документация в придачу. С такими цифрами проект не может расти ни предсказуемо, ни управляемо. А происходило это потому, что агенты, кодившие в том числе и по ночам, регулярно стреляли себе в ногу: то лениво не дочитывали файлы, в чём потом сами же честно и признавались, то лезли «заодно» что-нибудь оптимизировать вообще без разрешения, когда у них падал тест в соседней фиче, - и плодили одну и ту же реализацию по второму и третьему разу. Да и я сам, чего греха таить, не всегда вычитывал эти концепции до конца, потому что тот объём кода, который ИИ генерирует за ночь, живой человек прочитать руками просто не успевает.
Сначала я просто хотел измерить
Первое правило, которое я держу для себя ещё с инженерных времён, звучит просто: не чини то, что не сломано чего не измерил. Поэтому я не кинулся сразу строить «систему памяти», как подмывало, а сначала полез считать, сколько у меня вообще этого дублирования.
Собрал тонкую обвязку поверх двух community‑инструментов, потому что сами детекторы изобретать не стал — это был бы отдельный многомесячный проект, который и без меня уже сделали люди поумнее:
jscpd — токенный детектор клонов на алгоритме Рабина‑Карпа. Ловит то, что в литературе называют клонами Type-1, Type-2 и частично Type-3: точные копии, копии с переименованными переменными, копии с небольшими вставками.
similarity‑ts — детектор на AST с метрикой Tree Structure Edit Distance. Ловит Type-3 и, что критично, частично Type-4 — функции, которые делают одно и то же, но написаны структурно по‑разному.
Поверх этих двух детекторов обвязка для каждого найденного кластера считает метрику pain = дублированные_строки * git-churn / blast_radius, и нужна она затем, чтобы я видел сверху не все клоны подряд, а только те, которые реально болят, — где много строк, где код часто меняется и где от него много кто зависит. Запускается это всё одной командой и выдаёт детерминированный JSON, причём два прогона на одном и том же коммите дают байт‑в-байт одинаковый результат: иначе это уже не измерение, а гадание.
И вот на этом этапе вылез первый технический инсайт, ради которого, как мне кажется, и стоит читать дальше.
Клоны, производимые ИИ‑агентами, — это в подавляющем большинстве Type-4: семантически идентичные, но структурно разные. Логика тут понятная — агент ведь не копипастит, он каждый раз заново выводит решение, а раз выводит заново, то и имена, и структура у него всякий раз получаются чуть‑чуть иными. И именно поэтому классические копипаст‑детекторы, заточенные на токены, такие клоны попросту не видят.
Самое наглядное доказательство я получил на той самой склейке для записи в S3, о которой уже упоминал. В четырёх местах кода у меня крутится один и тот же сценарий — загрузить обложку из Telegram в объектное хранилище, получить URL, сохранить его в сущность, — и встречается он при создании курса, при создании оффера, в побочных эффектах урока и в обработчике отредактированного сообщения: один и тот же концепт, аккуратно разошедшийся на четыре копии. jscpd эти четыре места в один кластер не спарил, потому что они лежат ниже его токенного порога; similarity‑ts подобрался заметно ближе, но и он не сшил все четыре, потому что entity‑специфичные идентификаторы в каждом месте свои, а «стоимость переименования» в его метрике из‑за этого выходит за порог чистого совпадения. В итоге кластер всплыл во всей красе только тогда, когда я взял эти четыре куска и руками сложил их рядом.
Вывод, который я записал в отчёт как ключевую находку, звучит так: token‑детекторы систематически недооценивают именно ваш худший случай в эпоху ИИ. Так что если вы меряете AI‑дублирование одним jscpd и видите свои честные 3% — радоваться рано, потому что настоящая картина прячется в Type-4, и достать её можно только AST‑детектором, да ещё и с ручной перекрёстной проверкой сверху.
И, кстати, я в этом не одинок. Отчёт GitClear за 2025 год, в котором проанализировали 211 миллионов изменённых строк кода, показывает, что за один только 2024-й количество дублированных блоков выросло примерно восьмикратно, а доля рефакторинга упала с 25% до менее чем 10% изменений, — и 2024-й стал, по сути, первым годом, когда копипаст обогнал «перенос кода в переиспользуемые модули». Тренд тут корреляционный, а не доказанно‑причинный, и точные цифры стоит читать как индикативные, но направление подтверждается сразу из нескольких источников, так что отмахнуться от него не выходит. А сама Anthropic, к слову, запрос на встроенный детектор дублей сначала высоко приоритизировала, а затем закрыла с формулировкой «не планируется», что в переводе на человеческий означает ровно одно: инвалидную коляску каждый строит себе сам.
Измерить — не значит починить. И чинить рано — бессмысленно
Когда я разложил перед собой этот baseline, меня накрыло вторым осознанием, и оно переписало весь первоначальный план.
Представим, что я прямо сейчас героически схлопну всё дублирование — потрачу неделю, сведу четыре S3-склейки в одну, три проверки UUID тоже в одну, наведу идеальный порядок. А дальше приходит следующий агент, которому опять нужна проверка UUID, и он снова не может её найти — грепает, не находит, пишет свою, — и вот я уже стою ровно в той же точке, что и неделю назад, только теперь беднее на неделю работы.
Чинить дублирование, пока агенты не умеют находить уже написанное, — это всё равно что вычерпывать воду из лодки, так и не заделав пробоину. Агенты остаются слепыми ровно до тех пор, пока не появится слой извлечения, а значит, и последовательность шагов должна быть обратной той, что напрашивается сама собой.
Поэтому я пересобрал инициативу в порядке «сначала концепты, сначала библиотеки»:
Сперва построить слой извлечения — семантический «граф концептов», в котором лежат курируемые ответы на вопрос «а как мы здесь делаем X».
Затем наполнить его, начиная с библиотечных пакетов, — с той самой переиспользуемой поверхности, с того
shared/, откуда все тянут утилиты (по крайней мере должны но не всегда помнят или «ленятся»).И только потом, когда зрение уже появилось, — консолидировать дубли.
Сначала зрение хирургу, и только потом операция на пациенте, — а не наоборот.
Я не стал делать ещё одну векторную базу
Соблазн тут был ровно один, зато до боли типовой: «ну так добавь векторную базу — и дело с концом». И вот это, как ни странно, неправильный основной фикс, причём я разобрался в нём достаточно глубоко, чтобы сэкономить вам как минимум месяц жизни.
Дело в том, что плотные эмбеддинги проваливаются ровно на тех запросах, которые здесь важнее всего, — на точных идентификаторах: на бенчмарке LIMIT recall@100 у топовых эмбеддинг‑моделей проседает ниже 20% на запросах по точным именам. И проваливаются они при этом молча и уверенно — возвращают неправильный результат с высокой уверенностью, — а для агента, который на основании этого «не нашёл» идёт всё переписывать, уверенно‑неправильное «не найдено» оказывается худшим из всех возможных режимов провала. Sourcegraph Cody в своё время стартовал на эмбеддингах, а потом сознательно от них отказался в пользу keyword/BM25 плюс структурный граф — из‑за приватности, стоимости поддержки свежести и масштаба; и даже Cursor, обучивший собственную код‑эмбеддинг модель, получил в итоге прирост точности всего +12.5% и пришёл к выводу, что лучший результат даёт комбинация грепа и семантики, а вовсе не семантика сама по себе.
Поэтому вместо новой базы я просто расширил то, что у меня и так уже работало, — семантический граф концептов прямо внутри репозитория.
---
id: pattern.scoped-to-tenant
title: Scope / restrict / filter a database query to the current tenant (scopedToTenant)
status: active
confidence: owner-reviewed
code_paths:
- packages/nest-core/src/shared/scoped-to-tenant.ts
tests:
- rstest.unit.shared.scoped-to-tenant
aliases:
- "scope query to tenant"
- "filter rows by tenant ownership"
- "add a where clause for the current tenant id"
- "make a query tenant-scoped"
- "restrict a database query to the current tenant"
- "prevent cross-tenant data leak"
- "tenant ownership filter"
- "add tenantId filter"
- "make query tenant-aware"
why: "Centralised enforcement prevents per-developer inline `.where('tenantId', ...)` that can be omitted or mistyped, which would expose cross-tenant data. The only auditable location for the tenant ownership filter is this helper; a cross-tenant data leak can only happen here, not scattered across repository bodies."
variants:
- "packages/nest-core/src/shared/scoped-to-tenant.ts - pass a table-qualified column `\"offerings.tenantId\"` as the third argument for JOIN queries where Kysely requires column disambiguation"
---
Scope / restrict / filter rows to the current tenant - `scopedToTenant(qb, tenantId, column?)` appends a `WHERE tenantId = $1` clause to a Kysely query builder for multi-tenant row isolation / tenant ownership filtering. Canonical path: `packages/nest-core/src/shared/scoped-to-tenant.ts`.
Use this, NOT inline `.where("tenantId", "=", id)` in repository bodies (charter P05). The only auditable location for this tenant ownership filter is this helper; a cross-tenant data leak can only happen here.
Intentional variant - do NOT collapse: pass a table-qualified column like `"offerings.tenantId"` as the third argument for JOIN queries where Kysely requires disambiguation (e.g. `.where("offerings.tenantId", "=", tenantId)` becomes `scopedToTenant(qb, tenantId, "offerings.tenantId")`).
Invocation via `$call` preserves the full concrete query builder type for subsequent chaining (`.execute()`, `.executeTakeFirst()`, etc.).
Устроен он, в общем-то, просто. Каждый концепт - это маленький курируемый markdown-документ, в котором лежит один канонический способ делать X плюс задокументированные намеренные варианты, если они вообще есть, а сверху у него frontmatter с полями id, title, code_paths (указывающими именно в живой код, а не в его копию) и tests. Все эти документы прогоняются через локальную эмбеддинг-модель Xenova/bge-small-en-v1.5 на 384 измерения, после чего агент ищет по ним семантически одной-единственной командой concepts-search, - и по правилу №4 моего репозитория это обязательный первый шаг перед любой правкой кода, потому что 400 токенов прицельного контекста всегда лучше, чем 5000 токенов грепового шума.
Философия за этим стоит не моя — она взята у тех, кто десятилетиями писал код и сумел разложить ремесло на принципы. У Эрика Эванса в DDD один и тот же термин в разных bounded‑context означает разные вещи, и граф концептов как раз и есть то место, где я фиксирую, какой именно способ считается каноническим в каком контексте. У Мартина Фаулера настоящий DRY про то, что агент не должен заново выводить знание, — он должен прочитать уже готовый курируемый ответ. Поэтому концепт — это не сниппет на копипаст, а указатель на живой код плюс объяснение, почему именно он канонический.
Заодно пришлось переопределить, что вообще такое DRY для ИИ‑команды
Здесь я неожиданно споткнулся о собственные старые рефлексы. Меня ведь годами учили «Правилу трёх» — не абстрагируй до тех пор, пока не появились три копии, — и для команды из живых людей это и правда разумная защита от преждевременной абстракции.
А вот для ИИ‑команды это же правило начинает откровенно вредить, поэтому я переформулировал его для себя так:
DRY — это про дублированное ЗНАНИЕ, а не про дублированную форму и уж тем более не про счётчик копий. Две копии одного и того же знания — это уже проблема, которую стоит чинить, а Правило трёх стоит держать в голове как слабую эвристику, но никак не как порог и тем более не как индульгенцию, чтобы оставить реальный дубль в покое.
Но самое важное правило здесь — всё‑таки про честность, и далось оно мне через отдельный разговор с самим собой. Никогда не штампуй «намеренный вариант / держим раздельно» без положительного задокументированного доказательства, что расхождение действительно требуется. Потому что отсутствие знания — это ещё не доказательство наличия замысла, а преждевременный штамп «intentional» превращается в замороженную ложь, которую следующий агент послушно унаследует и которой будет верить, — и это, пожалуй, худший fail во всей затее.
Поэтому дефолтом, когда я не уверен, у меня стал статус OPEN‑CANDIDATE — то есть «дубль нашли, а что с ним делать, пока не знаем», — и это совершенно честное и валидное состояние. У каждого такого кандидата постепенно растёт журнал вхождений, то есть список мест, где встретилось то же самое знание, и решение по нему принимается только тогда, когда доказательств накопилось достаточно. Причём решений тут не два, как кажется поначалу, а целых четыре: схлопнуть в одно (если concern один и тот же, а расхождение — это просто баг), скомпоновать (общее переиспользуемое ядро плюс тонкие адаптеры под контекст — правильный ответ ровно тогда, когда контексты и впрямь различаются), оставить раздельно (но только с задокументированным доказательством на руках) или же оставить как есть (если совпадение вышло случайным, а concerns на самом деле разные).
Живой пример из моего собственного кода:
Проверка UUID встретилась мне сразу в трёх видах. Первый — это канонический регистронезависимый type‑guard isUuid в shared/uuid.ts. Второй — константа TIER_ID_UUID_RE, которая переобъявляет ровно ту же самую регулярку инлайн в другом файле: никакого отдельного поведения за ней нет, это случайный дубль, и вердикт по нему — свернуть в канонический isUuid. А третий — UUID_RE внутри схемы вариантов обложки, встроенный в Zod‑регулярку как регистрочувствительный фрагмент, — и вот это уже законный вариант, потому что там валидируется поверхность, где регистр важен (ключи S3), а сигнатура type‑guard туда попросту не подходит, так что вердикт — остаётся раздельно, и оба концепта получают перекрёстную ссылку с объяснением различия. Вот в этом и кроется вся разница между «тремя греповыми результатами без единого сигнала, какой из них правильный» и «графом, который даёт один ответ и сразу объясняет, почему он канонический».
Боевая проверка вслепую: прежде чем масштабировать — доказать ценность
Прежде чем разворачивать граф на всю кодовую базу, я прогнал его через слепой A/B‑тест.
Схема была такая: два агента сажаются на одну и ту же задачу, одному из них даётся граф концептов, а другому — нет, и каждый выдаёт на выходе план, а не код, — и сделано это специально, чтобы мерить именно нахождение и использование канонического, а не качество имплементации. Дальше я просто смотрю, нашёл ли агент канонический способ и пустил ли он его в дело.
Тест 1 — греппабельная утилита (isUuid)
Агент с графом нашёл нужный концепт первым же результатом — семантическое сходство 0.78, — использовал канонический isUuid и, что особенно важно, уважил задокументированный намеренный вариант, то есть не полез его схлопывать. Контрольный же агент тоже нашёл isUuid, но уже через греп, и при этом так и остался в неуверенности насчёт варианта: он видел перед собой три реализации и не понимал, которая из них авторитетная.
А по токенам агент с графом вышел даже дешевле контрольного — и это несмотря на разовую плату за построение индекса.
Вывод, который сначала меня едва не разочаровал, а потом, наоборот, обрадовал, такой: на греппабельном имени экономия токенов — это пол, а не потолок, и составила она всего около 7.5%, то есть негусто. Но настоящая‑то ценность графа здесь вовсе не в токенах, а в дизамбигуации — индекс взял и остановил неправильную консолидацию законного варианта. Ведь без графа агент либо оставил бы все три копии в неведении, либо, что куда хуже, схлопнул бы регистрочувствительный вариант в регистронезависимый и тихо, без единого звука, сломал бы валидацию ключей S3. Граф изменил само решение — вот в этом и есть его ценность.
Тест 2 — страшный отрицательный результат
Дальше я взял утилиту посложнее — scopedToTenant. Это низкоуровневый Kysely‑хелпер, который дописывает к запросу WHERE tenantId = $1, чтобы тот не утёк за пределы текущего тенанта, и имя у него настолько неочевидное, что сам агент его нипочём не угадает.
И вот тут граф провалился. Семантический поиск не вытащил концепт в топ-5 ни по одному интент‑запросу: «Restrict a query to the current tenant» — мимо, «Filter rows by tenant» — снова мимо, концепта просто не было в выдаче, и причина оказалась в том, что слово «tenant» само по себе высокочастотное, а более высокоуровневые концепты, где оно тоже встречается, попросту забивали мою низкоуровневую утилиту.
Вот это был ровно тот самый момент, когда хочется закрыть ноутбук и пойти подышать: я только что построил красивую систему, а она на втором же серьёзном тесте не нашла ровно то, ради чего вообще затевалась.
Развилка, ради которой стоило всё это затевать
И передо мной встал вопрос: что это вообще такое — провал ретривера (а значит, надо срочно прикручивать keyword/BM25/символьный индекс, городить гибрид и усложнять архитектуру) или всё‑таки провал авторинга (то есть концепт просто‑напросто плохо написан)?
Инстинкт, конечно, кричит «виноват ретривер, давай гибрид», — и это тот самый путь, на котором ты сначала добавляешь второй индекс, потом третий, а в итоге получаешь зоопарк поисковых движков и кривой шов между ними. Я, признаюсь, почти туда и пошёл.
Но вместо того чтобы решать вопрос верой, я решил решить его эмпирически и запустил двух агентов параллельно:
Первый отправился исследовать когнитивную науку о человеческой памяти — что именно заставляет живого инженера вспомнить «стоп, я же это уже решал, для этого есть утилита», а что, наоборот, заставляет его не вспомнить. Я Вспомнил как мне помогли white-papers на тему вытягивания причинно-следственной связи действий и скрытых рассуждений в голове эксперта и перекладывания этого на модель. Хотелось проверить не упустил ли я что-то и не переизобретать колесо.
Второй переписал провалившийся концепт с явными retrieval‑cue техниками и заново перемерил ранги.
И оба, что характерно, сошлись в одной и той же точке — один со стороны теории, второй со стороны измерения.
Имя этому — encoding specificity Тульвинга (Tulving & Thomson, 1973): извлечение из памяти срабатывает только тогда, когда сигнал в момент поиска совпадает с тем сигналом, что присутствовал в момент кодирования. А мой концепт был закодирован разреженным заголовком из одного символьного имени и вообще без синонимов в теле, — и интент‑запрос на естественном языке физически не мог совпасть с тем, как этот концепт был записан, потому что память адресуется не по содержимому, а по контексту и намерению исходного события кодирования.
Второй агент подтвердил ровно то же самое, только уже числом, — он переписал концепт по пяти приёмам:
Заголовок = глагол ищущего + доменное существительное + символ в скобках. Было
pattern.scoped-to-tenant, а стало «Scope / restrict / filter a database query to the current tenant (scopedToTenant)».Тело открывается слэш‑списком интент‑синонимов, и только за ним идёт механическое описание, — так этот слэш‑список превращается в плотную синонимическую поверхность, по которой и работает эмбеддинг.
Преамбула aliases / problem‑statement — она индексирует концепт по симптому и намерению, а не по имени реализации.
Поле
why— то есть почему это вообще канонический способ (инвариант корректности, свойство безопасности): централизованное применениеtenantIdне даёт ни одному методу репозитория забыть про фильтр и протечь данными между тенантами.Анти‑паттерн прямо внутри концепта — чтобы даже тот, кто уже делает неправильно и подсознательно ищет подтверждения своей правоте, всё равно наткнулся на канонический способ.
Результат на том же самом ретривере bge-small-en-v1.5, без единого нового инструмента, получился такой:
Запрос | Ранг до | Ранг после | Сходство после |
|---|---|---|---|
“restrict a database query to the current tenant” | вне топ-5 | 1 | 0.700 |
“filter rows by tenant ownership” | вне топ-5 | 1 | 0.658 |
“add a where clause for the current tenant id” | вне топ-5 | 1 | 0.675 |
“make a query tenant‑scoped” | вне топ-5 | 1 | 0.742 |
Из «нет в топ-5» — в «ранг 1 на всех четырёх запросах», и всё это на том же самом ретривере; изменился только текст концепта, выровненный теперь на словарь извлечения.
Вывод, который я зафиксировал у себя как закрытое решение: никакого keyword/BM25/символьного индекса для этого класса утилит не нужно в принципе, потому что bge-small вполне достаточно — при одном условии: концепты должны быть закодированы под извлечение. Разрыв был не в инструментарии, он был в дисциплине авторинга.
Это контринтуитивно — и ровно поэтому ценно: я ведь был буквально в одном шаге от того, чтобы навсегда усложнить себе архитектуру ради проблемы, которая на деле решалась переписыванием одного‑единственного абзаца.
Наука → дизайн: почти один‑в-один
Самое красивое во всей этой истории — то, что исследование человеческой памяти ложится на дизайн агентной памяти почти без зазора, и поскольку это, по сути, главная интеллектуальная нагрузка всей статьи, разложу её по авторам.
Тульвинг — encoding specificity, а ещё recall против recognition (Tulving & Thomson, 1973). Суть в том, что распознавание («это мне знакомо?») когнитивно и дешевле, и точнее, чем свободное припоминание («придумай‑ка имя с нуля»), — а ведь греп по имени и есть то самое свободное припоминание, тогда как поиск по намерению — это распознавание. Отсюда и правило: кодируй концепты словарём намерения ищущего, а не словарём реализации, то есть всякий раз превращай задачу припоминания в задачу узнавания.
Вегнер — transactive memory (Wegner, 1986, 1995). Группы распределяют память между собой вовсе не за счёт того, что каждый знает всё, а за счёт того, что они записывают, кто и где знает что, — и граф концептов как раз и работает директорией «кто/где знает что» для моей команды, в которую свежий агент заглядывает вместо того, чтобы выводить ответ заново. Заспавнить агента — это и есть онбординг разработчика без памяти, и первый его вопрос всегда один: «а для X уже что‑то есть?»; в команде с хорошей transactive memory ответ прилетает из общей директории за две секунды, а в команде без неё каждый новичок старательно изобретает колесо.
Спэрроу — cognitive offloading, тот самый «эффект Google» (Sparrow, Liu & Wegner, 2011, Science). Когда люди заранее знают, что в будущем у них будет доступ к хранилищу, они запоминают не само содержимое, а лишь то, где его найти, — штука полезная, но с важной оговоркой (Grinschgl, 2021): разгрузка без вовлечения даёт поверхностное рассуждение. Отсюда вытекает правило хранить ПОЧЕМУ, а не один только указатель, потому что иначе агент скопирует путь, так и не поняв инварианта, и применит его там, где тот не держится; и поле why — это и есть разница между голым указателем, на который разгружаться опасно, и каркасом для рассуждения, на который разгружаться уже безопасно.
Хатчинс — distributed cognition (Hutchins, 1995) в паре с Кларком и Чалмерсом — extended mind (Clark & Chalmers, 1998). Познание, оказывается, не заперто внутри черепа, и внешний артефакт, надёжно сцепленный с агентом, становится частью самой когнитивной системы, — а значит, когда индекс плотно вшит в рабочий процесс и его всегда консультируют и всегда держат свежим, он перестаёт быть «внешним справочником» и превращается в часть когниции агента. Именно это и оправдывает отношение к графу как к первоклассному архитектурному артефакту, а не как к опциональной подпорке сбоку.
де Гроот и Чейз‑Саймон — chunking (Chase & Simon, 1973) плюс Кляйн — recognition‑primed decision (Klein, 1998). Шахматный мастер восстанавливает позицию с пятисекундного взгляда просто потому, что узнаёт около 50 000 хранимых у него в голове паттернов‑чанков, а новичок этого не может, — то есть эксперт действует распознаванием хранимого чанка, а не рассуждением с чистого листа. Свежий агент — это и есть тот самый новичок, у которого ещё нет ни одного чанка кодовой базы, и поэтому концепт должен быть структурирован как экспертный чанк: фиксированное ядро (что это и где канонический способ) плюс переменные слоты (варианты, related, предостережения), и никак не плоский кусок текста.
Feeling‑of‑knowing, она же метапамять (Hart, 1965). У человека есть метакогнитивный сигнал, который тихо подсказывает «что‑то такое точно существует, копай дальше», — то самое ощущение, когда слово вертится на языке. У агента же этого сигнала нет вовсе, и он физически не способен знать, что лежит в кодовой базе, если его прямо не заставить туда посмотреть, — отсюда и жёсткий вывод, что concepts-search обязан быть жёстким обязательным предшагом, а не вежливым напоминанием. Опциональное «не забудь поискать» для существа без feeling‑of‑knowing структурно бесполезно, потому что ему попросту не за что зацепиться.
Есть тут и честные оговорки — места, где наука прямо спорит с моим подходом, и прятать их я не буду. Над‑разгрузка деградирует внутреннее припоминание — это я митигирую тем, что концепт несёт на себе why и cautions, а не один лишь путь. Устаревшая директория, как известно, хуже, чем вообще никакой, потому что агент ей доверяет и уверенно идёт по мёртвому указателю, — и поэтому каждый концепт привязан к живому коду через code_paths, а сверху есть отдельная роль ревью, которая обновляет записи при любом изменении кода по этому пути. Ну и retrieval‑induced forgetting, когда слишком часто используемый концепт начинает вытеснять законные, но менее известные варианты, — это я митигирую полем related и регулярным ревью на разошедшихся «сиблингов».
Инструменты — и почему каждый
Чтобы в голове не было каши, вот честная карта, кто здесь за что отвечает, ни один инструмент не решает задачу целиком, каждый закрывает ровно свой слой.
jscpd — токенные клоны Type-1/2. Дешёвый детерминированный гейт в CI. Слеп к Type-4.
similarity‑ts — AST‑near‑dups, Type-3/4. Ловит именно AI‑специфичную форму «то же самое, написанное иначе». Главный детектор для эпохи агентов.
ast‑grep — структурный поиск и линт по форме AST через tree‑sitter. Отвечает на «найди весь код формы Z» — например, все контроллеры с инлайновым
.safeParse(). Синтаксический, без типов и кросс‑файловости.граф концептов + эмбеддинги bge‑small — семантический слой «а как мы здесь делаем X / есть ли вообще установленный способ». Отвечает на интент, имя‑независимо. Проваливается на точных идентификаторах — поэтому это бустер recall, а не основной примитив.
graphify — AST‑граф структуры (tree‑sitter, без эмбеддингов). Отвечает на «что вызывает X / каков blast‑radius / где god‑node». Только для ориентации. Важная честность: graphify не умеет отвечать на «а есть ли что‑то похожее на это» — в нём нет векторного хранилища, и найти можно только то, что ты можешь назвать. Поэтому он остаётся для навигации, а не для дедупликации. Тут получается что как ты ни get shit done, shit still smells.
оркестратор Claude Code + изолированные саб‑агенты в worktree — сама модель, в которой это всё живёт. Оркестратор координирует и мержит; каждый имплементирующий агент работает в своей git‑worktree на своей ветке, коммитит и не мержит сам. Это и создаёт ту самую «команду из амнезиков», для которой нужна общая память.
Структурно‑точный слой через LSP/MCP — тот, что отвечает на точные вопросы «существует ли символ» и «кто вызывает», — я пока сознательно отложил, и держать его в стороне буду ровно до тех пор, пока измерения не докажут, что угадывание имён остаётся доминирующим промахом уже после того, как граф закрыл интент‑запросы. Потребность в нем ещё не измерил.
Как повторить у себя
Всё это — вовсе не про мой конкретный стек, оно переносится на любой проект, где код пишут агенты, и отдаю я это в общую копилку просто потому, что встроенного решения Anthropic нам не даст, а грабли тут одни на всех.
Относись к каждой сессии агента как к найму разработчика без памяти. Это не красивая метафора, а вполне рабочая модель: его первый вопрос всегда звучит как «а для X уже что‑то есть?», и задать его, кроме как тебе, ему попросту некому.
Построй маленький семантический индекс документов «как мы делаем X». Не векторную базу с нуля, а именно курируемые markdown‑указатели на живой код, — и обязательно указатель, а не копию, потому что источник истины должен остаться в коде, чтобы запись со временем не начала дрейфовать.
Кодируй под намерение извлечения, а не под имя реализации. Это, пожалуй, самый контринтуитивный урок из всех: когда поиск ничего не находит, чаще виноват авторинг, а вовсе не ретривер. Заголовок = глагол + домен + символ, тело = слэш‑список синонимов, плюс добавь
whyи добавь анти‑паттерн, — и прежде чем городить гибридный поиск, сначала перепиши один концепт и перемерь ранг.Сделай поиск по индексу ЖЁСТКИМ предшагом, а не напоминанием. У агента нет feeling‑of‑knowing, и поэтому опциональное «не забудь поискать» здесь не сработает — ему просто не за что зацепиться.
Мерь AI‑дублирование через similarity‑ts, а не одним только jscpd. Твой худший случай — это Type-4, а токенный детектор его в упор не видит, так что зелёный jscpd сам по себе ещё ровным счётом ничего не значит.
Переопредели DRY как дублированное ЗНАНИЕ, а дефолтом сделай OPEN‑CANDIDATE. Две копии одного и того же знания — это уже проблема, но при этом никогда не штампуй «держим раздельно» без доказательства, потому что записанная ложь в итоге обходится дороже, чем честное «пока не знаю».
Используй индекс ещё и как гейт ревью. Ревьюер точно так же сверяется с графом и ловит ситуацию «ты проигнорировал канонический способ».
Доказывай ценность слепым A/B ещё до масштабирования. Два агента, один с индексом, другой без, и оба выдают именно план, а не код, — а ты смотришь, нашёл ли агент канонический способ и использовал ли его. Стоит это дёшево, а спасает от куда более дорогой беды — влюбиться в собственное решение.
Результат сессии по нескольким модулям:
34 new concepts + 1 upgrade, graph at 250, all reviewed + merged, development clean:
┌────────────────────────────────────────────────────────────────┬───────────────────────┬──────────────────────────────────────┐
│ Group │ Concepts │ Gate outcome │
├────────────────────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────┤
│ common │ 9 + community upgrade │ CONCEPT_OK │
├────────────────────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────┤
│ lib cluster (postgres/state-machine/config/journal/os/valkey) │ 13 │ fixed 2 contradicted claims → merged │
├────────────────────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────┤
│ server │ 2 │ verified → merged │
├────────────────────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────┤
│ nest-core keystone (shared + charter P02/03/07/08/11/13/15/16) │ 10 │ fixed 1 stale adoption-gap → merged │
└────────────────────────────────────────────────────────────────┴───────────────────────┴──────────────────────────────────────┘
The gate is doing real work - it caught a mislabeled test-seam, a wrong file path, and a stale "duplication" that was already
fixed.
Zero fabrication reached development.
The dedup ledger (triage.md) is capturing the payoff (catalogued, not touched): the HTML-conversion x2 (your flagged concern,
root-caused), renderTemplate x2, and the standout - @ZodBody exists but 19 controllers still inline .safeParse(), plus
withTransaction/loadOrThrow/queue-mode adoption gapsЗачем мне это вообще
Я строю платформу для авторов курсов и владельцев сообществ — такую, чтобы человек, у которого есть знание и своя аудитория, мог отдавать накопленное людям, не утопая при этом в Excel, ручных платежах и рассылках по одному. Вся разработка идёт руками агентов просто потому, что я один, — и качество того, что в итоге получают мои пользователи, напрямую упирается в то, насколько чисто и непротиворечиво написан код под капотом, ведь расходящиеся паттерны рано или поздно превращаются в баги, и два дня агентной работы быстро перерастают в три недели тестирования и исправления get shit done кода.
Память для команды из агентов — это для меня не академическое упражнение, а способ держать инженерную честность на том масштабе, который один человек руками физически не удержал бы. Я не верю в успех за счёт срезанных углов и колхоза — нормально делай, нормально будет, — и если агент переписывает то, что уже есть, и при этом тихо плодит расхождения, то это значит ровно одно: я недоделал свою собственную работу как тот, кто эту команду собрал.
В прошлой статье я склонировал сам способ думать, а в этой — дал думающему память о том, что он уже однажды делал (концепт поиск по спекам и дизайн доками) и как он это делал (концепт-поиск по реализациям). Следующий слой — это память о том, почему были приняты те или иные решения, а не только о том, какие, — но об этом я расскажу тогда, когда дойдут руки и появятся настоящие цифры.
Если вы строите что‑то похожее — напишите мне, померяемся костылями. Я на связи.
























