Перейти к содержанию

Рабочая память код-агента

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

protoprompt.agent — это рабочая память для таких агентов. Вместо «недавнее важнее старого» она умеет «важное важнее неважного».

Главная идея

Память делится на две зоны:

  • Горячая — то, что агент использует прямо сейчас. Живёт в оперативке.
  • Холодная — то, что вытеснили за ненадобностью. Живёт в хранилище (store), про него помнит manifest.

Когда токены выходят за лимит, самые слабые элементы вытесняются из горячей зоны в холодную. Это не удаление — просто «на склад». По запросу recall их можно вернуть. Забывание здесь — понижение в должности, а не увольнение.

Как использовать

from protoprompt import MemoryScope, SqliteStore, RegexTokenCounter
from protoprompt.agent import WorkingMemory
from protoprompt.integrations import OllamaClient

llm = OllamaClient(host="http://localhost:11434")

mem = WorkingMemory(
    store=SqliteStore("agent.db"),       # холодная зона (переживает перезапуск)
    llm=llm,                             # нужен для эмбеддингов и цели
    counter=RegexTokenCounter(),
    max_tokens=2048,                     # бюджет горячей зоны
    scope=MemoryScope(tenant="acme", user="u-42", kind="agent"),
)

await mem.set_goal("исправить падающий тест в retry.py")
await mem.add("log", huge_test_output)         # умрёт молодым
await mem.note("retry_it живёт в retry.py:42")  # закреплено
context = await mem.assemble()                 # соберёт контекст в бюджет

Ключевые действия

Действие Метод Что делает
Поставить цель await mem.set_goal(text) Задаёт вектор «к чему стремимся»
Добавить сырьё await mem.add(kind, text) Кладёт элемент и пересчитывает бюджет
Заметка агента await mem.note(text) Закрепляется, похожие сливаются
Закрепить mem.pin(id) / unpin(id) Не выселять / снова можно выселять
Поднять важность mem.touch(id) Сказать «я это перечитал, это важно»
Собрать контекст await mem.assemble() Готовый текст для модели
Вернуть из холода await mem.recall(query) Найти и вернуть вытесненное
Выселить вручную await mem.forget(id) Убрать в холод принудительно
Снапшот состояния mem.export_state() JSON для сохранения и перезапуска

Виды элементов и их вес

Каждый элемент имеет kind — что это по своей природе. От этого зависит, насколько элемент ценен по умолчанию:

kind Вес Что это
edit 3.0 Правка, которую сделал агент
note 2.5 Собственная заметка агента
recalled 2.0 Элемент, возвращённый из холода
file 1.5 Прочитанный файл
test_result 1.0 Результат тестов
tool_output 0.8 Вывод инструмента
log 0.5 Сырые логи

При одинаковом прочем, правка переживёт лог, а заметка — результат теста. Это заложено в KIND_WEIGHTS.

Зачем агенту цель

Элементы оцениваются не только по «что это», но и по «насколько это про текущую задачу». Цель (set_goal) превращается в эмбеддинг, и каждый элемент получает бонус за смысловую близость к ней. Поэтому:

await mem.set_goal("изучить tenacity и добавить хелпер подсчёта попыток")

Пока цель одна, память «держит курс». Сменили задачу — set_goal с новым текстом, и приоритеты перестроятся.

Как считается важность (скоринг)

Итоговый балл — сумма пяти простых слагаемых:

балл = вес_вида
     + вес_ссылок (насколько часто на элемент ссылались позже)
     + смысл (косинус с целью)
     + свежесть (чем старше, тем меньше)
     − размер (чем больше текста, тем больше штраф)

Никаких LLM-вызовов в скоринге нет — всё считается на лету. LLM нужен только для эмбеддингов (смысл и цель).

Особый сигнал — ссылки: агент упомянул в новом элементе имя, которое определялось в старом (например, count_attempts). Старый элемент получает +1 к refcount. Это «сборка мусора по счётчику ссылок»: на что ссылаются — то важно.

Закрепление и бюджет

  • Элемент с pin=True не выселяется до тех пор, пока вы его не отпинните.
  • Заметки note() по умолчанию закреплены: собственные выводы агента живут, а сырые логи умирают молодыми.
  • max_pinned_tokens — страховка: если закреплённое занимает слишком много, самые старые пины снимаются автоматически.
  • Когда бюджет переполнен, выселяется самый низкобалльный незакреплённый элемент. Если закреплено всё — бюджет временно превышается, а в лог пишется предупреждение.

Возврат из холода: recall

Вытесненное не пропадает. recall(query) ищет по двум каналам:

  1. По символам — определяет идентификаторы в запросе и ищет их в манифесте. Работает даже без LLM.
  2. По смыслу — векторный поиск по холодной зоне (если есть и LLM, и стор).

Чтобы вытесненное не прыгало туда-сюда, есть карантин: recall_cooldown_steps запрещает возвращать элемент первые N шагов после выселения. Целенаправленный поиск (когда запрос близок к холодному документу сильнее recall_bypass_sim) карантин обходит.

Сохранение и перезапуск

Холодная зона уже лежит в сторе — её перезапуск не касается. Горячую зону можно сохранить в JSON и поднять после рестарта:

state = mem.export_state()            # JSON-снапшот горячей зоны
new_mem = WorkingMemory(store=store, llm=llm)
new_mem.import_state(state)           # восстановить

Мини-рецепт: агент в цикле

async def run_agent(llm, store, budget=2048):
    mem = WorkingMemory(store=store, llm=llm, max_tokens=budget)
    await mem.set_goal("добавить count_attempts в tenacity")

    tree = read_tree()
    await mem.add("tool_output", tree, summary="дерево проекта")

    await mem.add("edit", code_a, summary="новый модуль attempts.py")
    log = run_pytests()
    await mem.add("test_result", log, summary="pytest: attempts.py")

    # всё, что нужно агенту для следующего шага
    ctx = await mem.assemble()
    next_move = await llm.chat([{"role": "user", "content": ctx.render()}])

    # что-то вытеснилось, но вопрос важный — вернём
    restored = await mem.recall("что делает count_attempts?")
    return next_move