Поймали ИИ-агента на вранье — его же памятью

от автора

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

Из хаоса в видимость не-хаоса — почти мгновенно

Из хаоса в видимость не-хаоса — почти мгновенно

Это не рекламная зарисовка. Это лог. Ниже — два таких лога подряд, и во втором память заставила нас выбросить фичу, которой мы гордились.

Что мы вообще делаем

Мы пишем vecmory — «память по смыслу» для ИИ-агента. СТОП! Договоримся: фраза «у нас есть векторный поиск» — это не то, о чем статья. Векторный поиск умеют все. Мы про другое: чтобы агент не наступал на одни и те же грабли дважды.

Три операции: recall (вспомнить релевантное), remember (запомнить), link (связать два факта). recall — не плоский top-k. Мы называем это «гирляндой»: находим ближайшие по косинусу узлы-зёрна и разворачиваем от них каузально-временной граф связей. Векторы считаем локально, мультиязычной моделью MiniLM (384 измерения).

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

Сцена первая: роутер, который «показал 0.98»

В одну из сессий агент предложил улучшить ранжирование выдачи. Идея: сделать роутер, который по запросу решает, чем ранжировать — чистым косинусом или графовым методом (Personalized PageRank). Собрал синтетический бенчмарк. Корреляция предиктора с идеальным выбором — 0.98. Почти идеально. Оставалось отрапортовать и мержить.

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

Резонный вопрос: почему же он вообще взялся её строить — память ведь была на месте?

Потому что recall семантический, и что он поднимает, зависит от запроса. На старте запрос был широкий — «улучшить ранжирование», — под него всплывали топово-центральные заметки про ранг вообще, а специфичная «этот конкретный роутер уже проверялся и провалился» тонула под ними (ровно тот закон «частое топит редкое», к которому мы придём ниже). Она поднялась, только когда рабочий контекст сузился до конкретного — «cos-distribution роутер, PPR против косинуса, 0.98»: recall на этом тексте наконец её зацепил. Память не молчала — она всплыла ровно тогда, когда запрос стал достаточно точным, чтобы её достать. (Кстати, это и аргумент за детерминированный хук: полагаться на то, что нужная заметка сама всплывёт под каждый запрос, нельзя.)

Почему 0.98 было ложью

Синтетика честна ровно настолько, насколько честны синтетические эмбеддинги. А у мультиязычной MiniLM, на которой мы считаем векторы, косинус живёт в совсем другой геометрии, чем на синтетике. Мы это перемерили прямо на своём живом корпусе памяти (246 узлов), пока писали эту статью:

  • несвязанные, случайные пары дают косинус в среднем 0.53 (p5–p95: 0.24–0.76);

  • настоящие ближайшие соседи — в среднем 0.80 (p5–p95: 0.57–0.91);

  • а на синтетике из случайных векторов несвязанное сидит на 0.00 (±0.08).

Разница видна сразу. На синтетике «похоже» отделено от «не похоже» пропастью — любой разумный порог режет чисто. На реальных данных облака «соседи» и «случайные» перекрываются: случайные пары на верхнем перцентиле (0.76) залезают выше, чем соседи на нижнем (0.57). Порог, который на синтетике работает как скальпель, на реальных векторах проходит внутри облака случайного шума и не разделяет ничего.

Запись в памяти была ровно про это: у реальных эмбеддингов высокий сжатый базовый косинус, абсолютный порог с синтетики не переносится — опираться на РАНГ (top-k), а не на порог. Агент прочитал собственную заметку и убил идею — до того, как написал «готово, корреляция 0.98».

Все три числа выше — воспроизводимы: это замер на нашем живом графе памяти, а не картинка из презентации. И здесь та же закономерность, что погубила роутер: абсолютное значение косинуса ничего не значит в отрыве от модели и корпуса — оно едет от версии эмбеддера, от состава данных, от прогона. Поэтому мы ранжируем (top-k), а не двигаем пороги, и меряем на своих данных, а не переносим чужой — или свой вчерашний — порог.

Вот это — а не «у нас есть векторный поиск» — то, что мы, собственно, строим. Агент без памяти гордо представит 0.98 и будет формально не виноват: бенчмарк зелёный. Агент с памятью поймал себя на секунду раньше.

Сцена вторая: агент выкинул свою же фичу

Первый случай можно списать на удачу. Второй — тот же паттерн, но больнее: память заставила нас выбросить фичу, которую мы уже успели похвалить в README.

По умолчанию vecmory ранжировал выдачу не только по косинусу, но и с добавкой «важности» узла — его входящей степени в графе (сколько записей на него ссылаются). Логика красивая: центральный, многократно упомянутый факт всплывает выше. Мы зашили это в дефолт.

Потом — измерили. Не recall@k (это про точность поиска), а именно качество РАНГА, на реальных парах «запрос → правильный ответ»:

  • чистый косинус: MRR 0.81;

  • наш «умный» бленд с важностью: MRR 0.41.

Важность топила точные ответы. Редкий специфический факт, на который никто не ссылается, проседал под общеупотребительными «хабами».

Дальше — интереснее. На синтетическом «состаренном» графе с явными хабами всё наоборот: косинус зарывал хаб (MRR 0.033), а важность его вытаскивала (до 1.0). То есть знак пользы от важности зависит от интента запроса: для «дай мне точный факт» она вредит, для «дай мне про эту тему вообще» — помогает. Единого статического веса, выигрывающего оба класса, нет.

Мы перепробовали четыре способа примирить сигналы — взвешенную сумму, гейт, RRF и PPR — и сошлись на неприятном выводе: дело не в формуле смешивания, а в самом сигнале. Глобальная важность узла — это приор популярности, а не релевантности.

Вывод для дефолта был однозначный: для нашего основного кейса (точечный recall) лучший ранг — чистый косинус. И мы убрали важность из дефолтного ранжирования — свою же фичу, о которой уже написали в README. Графовый метод (query-seeded PPR) оставили, но опцией, а не по умолчанию: он честно вытаскивает хабы на широких запросах и справедливо проигрывает косинусу на точечных.

Что мы поняли про «важность» на самом деле

Если глобальная популярность (входящая степень) как сигнал провалилась, какой сигнал правильный? Мы пришли к неожиданно очевидному ответу: важно не то, на что много ссылок, а то, что человек исправлял повторно.

Это измеримо. Посмотрели на закрытые PR в одном из наших рабочих репозиториев — сколько раз одна и та же поправка возвращалась: revert — 39 раз, xsrf — за 60, token — 14, cookie — 10. Вот это и есть выстраданное знание — не самый популярный узел, а самые частые грабли. Такие уроки мы теперь подмешиваем первым блоком в каждый recall — они всплывают на каждом ходу, а не когда «повезёт с косинусом».

И вот здесь value proposition становится честным. Мы продаём не «графовую память» (снова коммодити). Мы продаём: агент перестаёт бить по одним и тем же граблям, которые ты уже правил.

Это не единичные случаи, а закономерности

Оба эпизода легко списать на удачу. Но когда чистишь всякий хлам в памяти достаточно долго (мы всю разработку vecmory ведём в vecmory же), видно: это не флуктуации, а несколько устойчивых законов. Сейчас будет свод — тремя откровениями, без историй «однажды я попал».

Что память даёт снова и снова. Правило «Сверься перед „готово“» отменило и роутер (сцена 1), и нашу долгожданную фичу (сцена 2) — один механизм, разные жертвы. Потому что на запрос-симптом память достаёт по причинно-временны́м рёбрам (caused_by — «из-за», followed_by — issue→PR) не «похожие слова», а конкретную причину и прошлый фикс. Плоский top-k так не умеет — это и есть «связать точки». И это точно измеримо, а не на глаз.

Взяли живой репозиторий тикетов (4299 узлов: 1993 issue + 2306 PR) и разметку, которую не сами придумали: стандартный GitHub-паттерн Closes #N в теле PR — готовые пары «симптом → его фикс», извлечённые голым regex, без всякого LLM. На 300 held-out запросов-симптомов чистый косинус достаёт нужный PR-фикс в 38% случаев (иногда фикс делит словарь с симптомом), а обход причинного графа — в 87%. Разница в 49 пунктов — это ровно вклад графа поверх сходства слов. Скрипт замера лежит в репозитории, число воспроизводится командой, а не берётся с потолка.

Что стабильно мешает — каждый раз, а не однажды. Во-первых, агент сам память не зовёт: без принудительного хука recall не вызывается систематически, каждую сессию. Память, которую надо «не забыть спросить», не работает — спрашивать должен детерминированный триггер, а не добрая воля агента. Во-вторых, абсолютный порог по косинусу не переносится нигде: синтетика → реал, вчера → сегодня, одна модель → другая, широкий корпус → узкий (на демо-корпусе одного домена, где «всё похоже», косинус почти перестаёт различать). Лечение всегда одно: ранжируй top-k, не угадывай пороги.

Наконец, один закон, который объясняет половину наших граблей: частый, плотный сигнал топит редкий, но ценный. Он всплывает в трёх типичных местах:

  • глобальная популярность узла топит точные редкие факты — и проваливает все четыре способа примешать её к рангу (взвешенная сумма, гейт, RRF, PPR);

  • узлы-хабы зарывают specific-факты в косинусном ранге, и наоборот;

  • плотные авто-similar_to рёбра заглушают редкие причинные caused_by — поэтому причинный recall приходится выносить в отдельный изолированный режим.

Когда воспринимаешь это как единый закон, лечение очевидно: редкое-но-ценное нельзя смешивать с частым-но-общим — достань его отдельным механизмом (изолированный обход, граф-осведомлённый ранг), а не надейся, что оно само всплывёт в общем top-k. Тот же закон стоит и за нашим сигналом важности: цену имеет не популярное, а то, что человек исправлял повторно.

Почему мы стали заниматься этим? Потому что перестало хватать терпения в сотый раз долбить одно и то же. Да и ругательные слова закончились — в тикетах мы не ругаемся, а в терминале эмпирическим путем вычислили, что «тупица» работает лучше всего. Агент спотыкается в самый неудачный момент, когда забывает то, что знал при ответе на предыдущий вопрос.

Выводы

Обе истории — про одно: выкинь мантру «память помогает агенту» и прозрей: вот два лога, где память сработала против нас самих — против нашего оптимизма и нашей же фичи.

Агент с памятью отличается от агента без памяти не тем, что он знает больше, а тем, что может поймать себя на «bingo!» за мгновение до коварного отчёта — потому что в прошлый раз это уже было и уже не сработало. И тем, что готов выбросить собственную фичу, когда факты показали, что она хуже.

Память, которая подтверждает то, что тебе приятно, — это не память, а эхо. Память приобретает исключительную ценность тогда, когда она возражает своему автору.

📎 Как мы намерили 87% — методология, цифры, воспроизведение (для тех, кто любит всё проверить)

Разметку мы не придумывали. Ground truth — стандартный GitHub-паттерн Closes / Fixes / Resolves #N в теле PR: это одновременно и пара «проблема → её фикс», и ребро графа. Извлекается голым regex, без всякого LLM — никакого риска «модель неправильно поняла причинность». Запрос — заголовок issue (симптом); фикс сформулирован иначе (fix(#N): …), по словам далёк — это и делает задачу held-out.

Две руки, 300 запросов (не cherry-pick):

корпус: 4299 узлов (1993 issue + 2306 PR), 1684 gold-пары issue→PRпрогнано: 300 запросов-симптомов(a) семантика-только (обычный recall):   chain-hit  38.0%   ← косинус сам достаёт фикс(b) причинный recall (обход графа):      chain-hit  87.0%                              вклад графа:  +49 процентных пунктов

Воспроизводимо (скрипт demo/causal-recall-eval.mjs в репозитории):

vecmory ingest --repo <owner/name> --dsn <dsn> --schema mem   # наполнить граф из тикетовDSN=<dsn> SCHEMA=mem REPO=<owner/name> EVAL_MAX=300 \  node demo/causal-recall-eval.mjs                             # прогнать eval

Живой кейс — можно открыть и проверить. issue #4155 «задание не привязано к заказу, пустые партии сырья» (симптом) → закрывающий его PR #4157 fix(atex #4155): продолжение дробления по дням несёт «Партию сырья» головы (фикс сформулирован иначе). По тексту симптома косинус вытащит десяток похожих issue того же модуля; правильный фикс достаёт ребро followed_by, а не сходство слов.

Честная граница. Оставшиеся 13% — там, где заголовки одного модуля начинаются одинаково (общий путь файла), и обход садится на брата-issue: семантическое заякоривание на плотном корпусе неточно. Отсюда 87, а не 99. Ту же дисциплину — назвать слабое место, а не спрятать — мы применяем и здесь.


Это первая статья из трёх. Дальше:

2. «Почему мы не написали ещё один Bad CaRMa» — про то, как гипер-универсальная модель данных обычно превращается в катастрофу, и что мы сделали, чтобы наша — не превратилась.

3. «Строили ANN руками — когда это того стоило, а когда нет» — открытый разбор, зачем мы вообще написали HNSW сами и в каких случаях правильный ответ был бы «возьми pgvector и не выделывайся».

ссылка на оригинал статьи https://habr.com/ru/articles/1061628/