Опубликовано
State и поиск: от замера до пул-реквеста в Hermes Agent
Автор: Гусев Николай [портфолио]
Темы: agent-memory, ai-agents, benchmarks, local-models
State и поиск: от замера до пул-реквеста в Hermes Agent
В четвертой части я закончил на рабочем пункте: session_search должен уметь искать не только по content, но и по полю reasoning. Тогда это было следствием замера на живой сессии. Теперь есть issue #109092 и PR #109117. PR открыт, ревью еще впереди, мержа нет.
Но сам факт отправки не самая интересная часть. Между наблюдением "поиск не находит важный факт" и пул-реквестом лежит несколько решений, каждое из которых можно проверить. Что считать дырой, где именно она возникает, какой вариант исправления не ломает существующее поведение, какими тестами доказать результат и как не записать в свой счет чужое красное окружение. Это повторяемый путь для любого агента с архивом, а не рассказ о кнопке "Create pull request".
В третьей части различие между hit_any и hit_content уже показало неприятную вещь. Модель может назвать факт в рассуждении и не вынести его в видимый ответ. Архив этот факт хранит, однако поиск по одному content делает его write-only: записать можно, найти нельзя. В этой статье разбираю, как такое наблюдение превратить в изменение апстрима без прыжка от гипотезы сразу к большой переделке.
Сначала измерить дыру, а не называть ее багом
Полнотекстовый поиск, FTS, означает поиск по словам и фразам в тексте. В Hermes Agent он построен на FTS5 SQLite. Индекс messages_fts содержит только content, tool_name и tool_calls:
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
tool_name,
tool_calls,
content='messages',
content_rowid='id'
);
Это не предположение о том, что поле забыли добавить. Оно следует из трех маршрутов исходника. Основной индекс не видит reasoning. Trigram-представление messages_fts_trigram_src читает id, role, content, tool_name, поэтому и там рассуждения нет. Наконец, LIKE-фолбэк, то есть запасной поиск по совпадению подстроки, сравнивает те же три текстовые колонки. Один и тот же пропуск повторяется на трех уровнях.
На снимке state.db было 289 433 строки. Данные ниже описывают именно этот архив, а не средний агентский журнал.
Метрика Значение
----------------
Строки с reasoning 61 043, или 21,1%
Объем reasoning 95,1 млн знаков
Объем индексируемого content 307,4 млн знаков
Добавка к индексируемому объему 30,9%
Ходы с пустым content и непустым reasoning 22 454
Последняя строка особенно полезна. Здесь не спорят о том, насколько часто полезная мысль окажется в рассуждении. У 22 454 ходов видимого текстового содержимого вообще нет, а рассуждение есть. Если в таком ходе лежит название файла, аргумент выбора или точный идентификатор, существующий поиск не может его вернуть.
Проверка дыры должна быть такой же узкой, как и утверждение. Берется токен, который встречается только в reasoning, и выполняется запрос через messages_fts. Результат равен 0, хотя сам токен в базе есть. Этого достаточно, чтобы отделить проблему поиска от вопроса о качестве модели, от неудачного запроса и от предположений по данным. База хранит факт. Индекс его не читает.
Отсюда практическое правило: до выбора исправления пройди путь записи от хранилища к выдаче, то есть таблица, индекс, представления, запасной маршрут, снипет. Проверка только DDL создала бы ложное ощущение полноты, если бы LIKE-маршрут уже покрывал reasoning, а проверка только интерфейса инструмента ничего не сказала бы о том, где теряется текст. Три слоя совпали, значит граница механизма определена точно.
Что предлагаем
Добавить в session_search опциональный флаг include_reasoning. Без флага поиск работает как сейчас: по видимому тексту, вызовам инструментов и именам инструментов. С флагом - по полям рассуждения, reasoning и reasoning_content, которые Hermes уже пишет в каждую строку архива.
Запрос выглядит так: агент ищет токен обычным поиском, не находит его и повторяет запрос с include_reasoning=True. Ему возвращается тот ход, где токен встречается, со сниппетом, взятым из текста рассуждения, а не из головы ответа, и с пометкой в описании инструмента: попадание означает "модель об этом думала", а не "модель это сказала".
Технически это переиспользование готового механизма. В Hermes уже есть маршрут для строк, которых нет в индексе, - так ищутся результаты инструментов, role_filter=['tool']. Новый охват идет тем же маршрутом, меняется только список колонок. Схема базы, версия хранилища и переиндексация не затрагиваются.
Почему флаг, а не колонка в индексе
Альтернатива очевидна: добавить reasoning третьей колонкой в FTS-индекс, и поиск по рассуждениям станет обычным поиском с ранжированием по релевантности. Против нее три цены. Первая: bump версии хранилища и переиндексация всех существующих архивов, у больших баз это часы блокирующей перестройки. Вторая: рост индекса примерно на 31%, рассуждения занимают треть индексируемого объема. Третья, главная: изменения получает каждый пользователь, включая того, кто ни разу не захочет искать по рассуждениям, а откатить индексную колонку после релиза в разы дороже, чем не добавлять ее.
Флаг переворачивает распределение цены. Платит только тот, кто включил охват, и платит скоростью: скан по каноническим строкам без ранжирования медленнее индексного поиска, и это честно записано в описание инструмента. Дефолтное поведение не меняется ни на байт, поэтому риск совместимости нулевой. Если охват окажется востребованным, перевести его на индексную колонку можно отдельным решением - API инструмента при этом не изменится, изменится только скорость ответа. PR описывает оба пути, выбор за мейнтейнерами.
Что это дает пользователю Hermes
Сценарий, который сегодня заканчивается "не найдено". Ты помнишь, что агент в какой-то сессии разобрался, почему падал деплой, но в видимых ответах он сказал только "исправил". Причина падения осталась в рассуждении. Обычный поиск по слову из той проблемы не возвращает ход, поиск с флагом возвращает.
Второй сценарий - аудит чужой сессии. Ход с пустым видимым текстом, а рассуждение на 3000 знаков: в моем архиве таких 22 454. Сегодня это мертвая зона архива, с флагом она читается тем же инструментом, которым агент и так пользуется.
Как это проверялось
Изменение маленькое, поэтому проверка важнее объема. Четыре шага. Сначала RED: реализация откатывается к базовому коммиту, новые тесты остаются и падают, значит они ловят отсутствие возможности, а не украшают отчет. Затем GREEN: те же тесты с реализацией, 4 из 4. Затем соседние сьюты: 370 тестов прошли, 9 упали. И последний шаг - базалайн: те же 9 падают на базовом коммите без наших правок, это Windows-специфика файловой системы в трех старых тестовых файлах, к PR отношения не имеет. Оба лога приложены к PR, чтобы ревьюер не разбирал чужой красный хвост.
Код писался делегацией в Codex по ТЗ в файле: 136 879 токенов, около 14 минут, три коммита по фазам - тесты, реализация, документация. Проверка шла не по самоотчету исполнителя, а по диффу построчно и по гейтам выше; один посторонний хунк исполнителя убран до отправки. Стиль измененных файлов чист по ruff check, тесты гонялись каноническим раннером проекта scripts/run_tests.sh.
Что уже отправлено, а чего пока нет
В issue #109092 зафиксирована проблема, в PR #109117 - реализация и описание альтернативы с индексом. Пять файлов, 194 строки добавлено, 21 удалена. Статус без будущего времени: PR открыт и ждет мейнтейнера, проверок и комментариев пока нет. Писать, что Hermes "получил" поиск по рассуждениям, еще рано - корректно так: изменение отправлено на ревью. Если мейнтейнеры выберут индексную дорогу, вывод замера не изменится: reasoning должно быть искомым. Поменяется реализация и распределение цены.
Что закрыл этот цикл
Пять частей начались с вопроса, как выбирать модель по задаче, а заканчиваются тем, как замер превращается во вклад. Из них остается не один рецепт и не обещание универсальной памяти.
Во-первых, recall на длинной сессии держит архитектура. В контексте остается короткий актуальный state, архив хранит исходные записи, поиск возвращает нужный фрагмент. Модель не должна дословно помнить весь журнал. Ее задача в этот момент сформулировать запрос и работать с найденным материалом.
Во-вторых, отдельное хранение reasoning само по себе не решает ничего. Поле нужно не только записать, но и включить в путь поиска, если в нем могут остаться значимые факты. Иначе создается тихое хранилище: данные существуют, но обычный инструмент их не видит. Разница hit_any и hit_content из третьей части была ранним сигналом именно такого разрыва.
В-третьих, вклад в апстрим начинается не с пожелания "добавьте возможность". Он начинается с точного наблюдения, которое можно опровергнуть: токен лежит только в reasoning, база его хранит, messages_fts возвращает 0. Затем идет выбор наименьшего изменения, тесты до и после, проверка соседей и базалайн. PR становится понятным не потому, что он большой или маленький, а потому, что его можно проверить по шагам.
У этого вывода есть пределы. Числа 21,1%, 30,9% и 22 454 сняты с одного архива Hermes, а не с выборки агентов. Поиск с include_reasoning идет сканом без ранжирования и потому медленнее обычного маршрута. PR еще не смержен. Эти ограничения не ослабляют наблюдение о конкретной дыре, но не дают превращать снимок одной базы в общую статистику или считать решение окончательно принятым.
Проверьте свой архив
На своем агенте найдите слово, которое встречается только в поле рассуждений, а не в видимом ответе. Затем выполните обычный точный поиск по архиву и посмотрите, возвращается ли этот ход. Ничего менять в архиве для этого не нужно.
Если слово находится, вы проверили lookup-вопрос: поиск умеет вернуть ранее записанный факт. Если нужно восстановить, почему решение было принято, одного совпадения может не хватить. Для такого recompute-вопроса нужен текущий state или summary, где причина решения была сохранена в момент работы. Поиск возвращает источник, state удерживает текущую нить. Смешивать эти роли по-прежнему дорого и ненадежно.