Рабочая память код-агента¶
Обычная память чат-бота (см. память чат-бота) хранит диалог. Код-агент устроен иначе: он сам выполняет десятки шагов — читает файлы, правит код, гоняет тесты, смотрит логи. За каждый шаг появляется новый кусок текста, и почти весь он — шум. Агента интересует только то, что действительно важно для текущей задачи.
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) превращается в эмбеддинг, и каждый
элемент получает бонус за смысловую близость к ней. Поэтому:
Пока цель одна, память «держит курс». Сменили задачу — set_goal с новым
текстом, и приоритеты перестроятся.
Как считается важность (скоринг)¶
Итоговый балл — сумма пяти простых слагаемых:
балл = вес_вида
+ вес_ссылок (насколько часто на элемент ссылались позже)
+ смысл (косинус с целью)
+ свежесть (чем старше, тем меньше)
− размер (чем больше текста, тем больше штраф)
Никаких LLM-вызовов в скоринге нет — всё считается на лету. LLM нужен только для эмбеддингов (смысл и цель).
Особый сигнал — ссылки: агент упомянул в новом элементе имя, которое
определялось в старом (например, count_attempts). Старый элемент получает
+1 к refcount. Это «сборка мусора по счётчику ссылок»: на что ссылаются —
то важно.
Закрепление и бюджет¶
- Элемент с
pin=Trueне выселяется до тех пор, пока вы его не отпинните. - Заметки
note()по умолчанию закреплены: собственные выводы агента живут, а сырые логи умирают молодыми. max_pinned_tokens— страховка: если закреплённое занимает слишком много, самые старые пины снимаются автоматически.- Когда бюджет переполнен, выселяется самый низкобалльный незакреплённый элемент. Если закреплено всё — бюджет временно превышается, а в лог пишется предупреждение.
Возврат из холода: recall¶
Вытесненное не пропадает. recall(query) ищет по двум каналам:
- По символам — определяет идентификаторы в запросе и ищет их в манифесте. Работает даже без LLM.
- По смыслу — векторный поиск по холодной зоне (если есть и 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