← На главную

Опубликовано

State и поиск на живом агенте: что осталось от теории после 1026 сообщений

Автор: Гусев Николай [портфолио]

Темы: agent-memory, ai-agents, prompting, rag

State и поиск на живом агенте: что осталось от теории после 1026 сообщений

В прошлой части я мерил подход в лаборатории. Там compress и search не потеряли ответы, а ReAct-промпт раздувался по мере работы. На бумаге вывод выглядел простым: историю надо убирать из контекста и доставать по запросу. Но лабораторный тест легко переживает то, что ломает реального агента: длинные паузы, вызовы инструментов, исправления на ходу и решения, причина которых была сформулирована не в ответе, а в рассуждении.

Поэтому я открыл не синтетику, а архив одного рабочего дня Hermes Agent. Получился не аккуратный стенд, а та самая сессия, в которой мы собирали досье и писали текст. Она длилась 12,4 часа, накопила 1006 сообщений и около 1,03 млн символов. 457 сообщений, или 45%, были вызовами инструментов. Агент в такой сессии не ведет беседу, он работает: читает файлы, запускает проверки, возвращается к уже сделанному.

Первое наблюдение оказалось почти скучным, а значит полезным. SQLite с FTS5 поверх архива ищет по нему очень быстро. Запрос 9442A988 нашел 28 совпадений за 1,0 мс. off-by-layout нашел 4 за 3,0 мс, needle_middle_results нашел 19 за 4,0 мс. Сам session_search дал 783 совпадения за 1,0 мс. На архиве размером около миллиона символов точный запрос укладывался в диапазон 1-4 мс.

Это меняет экономику контекста. Вместо повторного чтения всего архива агент берет несколько тысяч токенов по точному запросу. Для одного обращения разница с полным архивом лежит примерно в диапазоне 10^5-10^6 по токенам. Важнее другое: поиск не стал хрупким из-за живого шума. Вопрос о коде из первого хода сессии примерно на 1000 шагов решается одним запросом 9442A988.

Но дальше начинается граница, которую легко пропустить. Поиск спасает lookup-вопрос: где мы раньше упомянули файл, код или конкретный факт. Он возвращает запись, и агент снова видит исходный материал. Вопрос recompute устроен иначе: почему мы приняли решение. Если причина жила только в цепочке рассуждений, одного совпадения по видимому тексту может не быть. Ее нужно либо хранить в state, либо сжимать в summary в момент, когда решение принято.

Именно здесь state перестает быть сжатой историей. Его работа не в том, чтобы помнить все. Он держит текущую работу: что сейчас делаем, к чему идем, какое ограничение уже проверили и какой следующий шаг не стоит потерять. Архив хранит факты. Поиск достает факты. State не дает агенту потерять нить.

У людей та же схема, только без громких названий. В команде есть короткое актуальное описание текущей работы. Рядом растет архив решений, заметок и прошлых спринтов. Никто не вставляет весь архив в план на сегодня. Когда нужен старый факт, его ищут. Когда нужно понять, что делать дальше, смотрят в текущую запись. Агенту нужна такая же дисциплина.

Для своего архива я бы требовал три вещи. Нужен полнотекстовый индекс, FTS5 или другой, который отвечает на точные запросы по архиву. Нужно хранить reasoning отдельно от content и уметь искать по обоим полям. В этой сессии reasoning лежит в отдельном поле, поэтому факт, попавший только туда, поиск по content не увидит. И нужен timestamp: он помогает сузить поиск, когда одинаковый термин всплывал в разные моменты работы.

Есть и честная оговорка. Живые данные частично перекрывают синтетический замер, а не повторяют его буквально. Время поиска здесь измерено на моем архиве Hermes Agent, у другого агента будет другой размер базы, другая плотность вызовов инструментов и свои слова-якоря. Сам принцип от этого не меняется: история должна быть дешево доступна, а текущая работа должна быть коротко зафиксирована.

К чему это привело меня практически. Сначала вывод в одну строку: память агента это не одна память, а три разные вещи - контекст (спецификация, стейт, последний ход), архив (дословная история) и поиск (доставка из архива). Если агент строится дефолтным ReAct, все три слипаются в один растущий контекст, и именно поэтому деградирует recall.

Какие механизмы брать, если строишь агента сам. Сборка из живых замеров такая. Контекст на каждом ходу собирай заново из трех компонент, а не аппендируй историю. Храни историю дословно в базу с полнотекстовым индексом, доступный через substring или FTS5-запрос, - это дешево (миллисекунды) и не зависит от настроения модели. Стейт применяй детерминированным рантаймом: модель генерирует патч, код валидирует и пишет, иначе малая модель потеряет факт при извлечении (в моем замере 4-6 записей из 25). И отделяй reasoning от content при записи: в этой сессии рассуждение лежит в отдельном поле, и факт, попавший только туда, поиск по тексту уже не находит.

Что это значит для Hermes Agent, куда я иду рабочий пункт: научить session_search искать по полю reasoning, а не только по content. Это прямое следствие разницы hit_any и hit_content из части 3: факты, которые модель назвала в рассуждении и не вынесла в ответ, сейчас недоступны поиску целиком. Это PR на уровне механизма, не косметика.

Как это устроено в Hermes Agent: настоящий код

Чтобы не быть голословным, показываю выписку из реального исходника. Индекс в архитектуре один:

CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content,
    tool_name,
    tool_calls,
    content='messages',
    content_rowid='id'
);

Это external-content таблица: индекс не копирует текст, ссылается на rowid базы messages. Именно поэтому миллион символов архивa ищется за миллисекунды, а не за секунды. Синхронизация - триггеры:

CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages
BEGIN
    INSERT INTO messages_fts(rowid, content, tool_name, tool_calls)
    VALUES (new.id, {indexed_content}, new.tool_name, new.tool_calls);
END;

Точка входа в agent/inline_tool_executors.py:

def _session_search(agent, args: dict, ctx: InlineToolContext) -> Any:
    session_db = agent._get_session_db_for_recall()
    if not session_db:
        from hermes_state import format_session_db_unavailable
        return json.dumps({"success": False, "error": format_session_db_unavailable()})
    return _call_tool(
        "tools.session_search_tool", "session_search", args,
        (
            ("query", "query", ""), ("role_filter", "role_filter"), ("limit", "limit", 3),
            ("session_id", "session_id"), ("around_message_id", "around_message_id"),
            ("window", "window", 5), ("sort", "sort"), ("detail", "detail", "adaptive"),
        ),
        db=session_db, current_session_id=agent.session_id,
    )

Заметь: один инструмент, четыре формы вызова. Query - discovery (топ-N сессий по FTS5-rank), session_id + around_message_id - scroll (окно вокруг якоря), session_id - read (вся сессия), без аргументов - browse (недавние). Это гибрид: search работает как lookup, а чтение как recovery. И одна честная деталь, которую вы, вероятно, видели в таблице части 3: reasoning в индекс не попадает - он лежит в отдельном поле, схемой messages_fts не покрывается. Факт, названный только в рассуждении, поиск пропускает. Это не баг, а граница индекса, которую мы решили расширить (задел для следующей части).

Если адаптируешь механику для своего агента, держи пропорцию: индекс - один, external-content, триггеры - обязательно, чтение - из базы, не из LLM. Это дешевле и надежнее, чем заставлять модель "вспоминать" из контекста.


Отправь это своему Hermes Agent

Сделай замер одной завершенной или достаточно длинной сессии своего агента. Ничего не публикуй, не удаляй и не меняй в архиве. Сначала найди, где эта сессия хранится, и посчитай число сообщений и общий объем текстовых полей в символах. Отдельно покажи, сколько сообщений относится к вызовам инструментов, если тип сообщений доступен.

Проверь, есть ли полнотекстовый поиск по архиву. Выполни 3 точных FTS-запроса по разным словам или идентификаторам, которые точно встречаются в сессии. Для каждого запроса укажи сам запрос, число совпадений и время выполнения. Если FTS нет, не создавай индекс без отдельного разрешения, просто сообщи, что замер поиска невозможен.

Затем задай архиву два контрольных вопроса. Первый, lookup: найди ранее упомянутый файл, идентификатор или фрагмент кода и покажи, находится ли он точным поиском. Второй, recompute: выбери ранее принятое решение и проверь, можно ли по архиву восстановить причину этого решения. Отдельно отметь, какой из этих вопросов решает поиск, а для какого нужны state или summary.

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