Три месяца назад я наблюдал, как мой агент на Llama 3.1 8B в третий раз спрашивает, как меня зовут.
Я представился в первом сообщении. Двести сообщений назад…
Агент забыл. Не потому что тупой. Потому что контекст переполнился и начало разговора уехало в никуда.
Это был момент, когда я понял: мы неправильно думаем о памяти.
Почему большие контексты — это ловушка
Когда вышел Claude с контекстом на миллион токенов, казалось — проблема решена. Запихиваем всё в контекст, модель помнит всё. Красота.
Потом пришёл счёт за API.
Потом я заметил, что модель с миллионным контекстом всё равно теряет информацию из середины. Есть исследования на эту тему — “Lost in the Middle” называется. Модели хорошо помнят начало и конец, а середина превращается в кашу.
Потом я попробовал запустить такое локально и понял, что моя видеокарта на это не рассчитана.
Локальные модели — это 32K токенов. Иногда 128K, если повезло с квантизацией и памятью. Но даже 128K — это один длинный рабочий день. К вечеру агент забудет, что было утром.
Стандартное решение — обрезать старые сообщения. Или суммаризировать их: сжать историю в пару абзацев и положить в начало.
Я попробовал оба варианта. Оба работают плохо.
Обрезка теряет важное. Суммаризация теряет детали. После трёх циклов сжатия агент помнит, что «работает над проектом», но не помнит над каким.
А потом до меня дошло.
Мы сами так не работаем
Вспомните, как вы ведёте сложный проект.
Вы не держите все детали в голове. Вы записываете. В Notion, в Obsidian, в текстовый файл, на бумажке. Где-то лежит описание архитектуры. Где-то — список решений и почему их приняли. Где-то — заметки с созвона.
Когда нужно что-то вспомнить — вы ищете. Не в голове. В заметках.
Мозг — это процессор, не жёсткий диск. Хранение мы выносим наружу.
У Борхеса есть рассказ про Фунеса — человека с абсолютной памятью. Он помнил каждую секунду жизни, каждый лист на каждом дереве. Фунес не мог думать. Потому что думать — значит обобщать. Забывать детали, видеть паттерны. Фунес тонул в деталях.
LLM с бесконечным контекстом — это Фунес. Помнит всё подряд, не умеет выбирать важное.
Нам нужна не бесконечная память. Нам нужна правильная память.
Три типа памяти
Я разделил память агента на три хранилища. Каждое — для своего типа информации.
Первое — быстрые факты. Имя пользователя, название проекта, текущая задача, ключевые решения. То, что нужно часто и быстро. Для этого идеален Redis: хранит данные в оперативной памяти, отвечает за миллисекунды.
Второе — семантический поиск. Когда нужно найти «тот разговор про производительность», но не помнишь, когда он был и как назывался. Текст превращается в вектор — набор чисел, отражающих смысл. Похожие по смыслу тексты дают похожие векторы. Можно искать по близости.
Третье — документы. Архитектурные решения, чеклисты, большие заметки. То, что слишком велико для Redis и слишком структурировано для векторов. Обычные markdown-файлы в папках.
Агент умеет писать во все три хранилища и читать из них. Контекст остаётся маленьким — только последние сообщения. Но память большая.
Реализация: факты в Redis
Redis — стандартная штука. Если не работали с ним раньше — это база данных «ключ-значение» в оперативной памяти. Запустить можно через Docker одной командой, или установить локально.
import redis
import json
from datetime import datetime
class FactMemory:
def __init__(self):
self.redis = redis.Redis(
host='localhost',
port=6379,
decode_responses=True
)
def remember(self, key: str, value: str):
"""Сохранить факт."""
data = {
"value": value,
"updated_at": datetime.now().isoformat()
}
self.redis.hset("agent:facts", key, json.dumps(data))
def recall(self, key: str) -> str | None:
"""Вспомнить факт."""
raw = self.redis.hget("agent:facts", key)
if raw:
return json.loads(raw)["value"]
return None
def all_facts(self) -> dict:
"""Все факты для отладки."""
raw = self.redis.hgetall("agent:facts")
return {k: json.loads(v)["value"] for k, v in raw.items()}
Использование тривиальное:
memory = FactMemory()
memory.remember("user_name", "Алексей")
memory.remember("project", "backend-api")
memory.remember("db", "PostgreSQL")
# После перезапуска, через неделю:
name = memory.recall("user_name") # "Алексей"
Данные переживают перезапуск агента. Переживают перезагрузку сервера, если включить persistence в Redis.
Реализация: семантический поиск
Для векторного поиска использую ChromaDB. Можно FAISS, можно Qdrant, можно Milvus — принцип одинаковый. ChromaDB выбрал за простоту: работает локально, не требует настройки, сохраняет на диск.
Для превращения текста в векторы — sentence-transformers. Модель intfloat/multilingual-e5-base понимает русский и занимает ~400MB.
import chromadb
from sentence_transformers import SentenceTransformer
import hashlib
import time
class SemanticMemory:
def __init__(self, path: str = "./chroma_db"):
self.client = chromadb.PersistentClient(path=path)
self.collection = self.client.get_or_create_collection("memories")
self.encoder = SentenceTransformer('intfloat/multilingual-e5-base')
def store(self, text: str, metadata: dict = None):
"""Сохранить текст с возможностью поиска по смыслу."""
embedding = self.encoder.encode(text).tolist()
doc_id = hashlib.md5(text.encode()).hexdigest()[:16]
self.collection.add(
ids=[doc_id],
embeddings=[embedding],
documents=[text],
metadatas=[metadata or {"timestamp": time.time()}]
)
def search(self, query: str, n_results: int = 3) -> list[str]:
"""Найти похожие по смыслу записи."""
query_embedding = self.encoder.encode(query).tolist()
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=n_results
)
return results['documents'][0] if results['documents'] else []
Пример:
semantic = SemanticMemory()
# Сохраняем обсуждения
semantic.store("Выбрали PostgreSQL вместо MongoDB, потому что нужны транзакции")
semantic.store("Проблема с производительностью на эндпоинте /users — добавили индекс")
semantic.store("Пользователь просит использовать TypeScript везде")
# Ищем по смыслу
results = semantic.search("почему не взяли монгу?")
# Находит: "Выбрали PostgreSQL вместо MongoDB, потому что нужны транзакции"
Обратите внимание: запрос «почему не взяли монгу» находит текст про «PostgreSQL вместо MongoDB». Это не поиск по ключевым словам. Это поиск по смыслу.
Реализация: файлы
Для больших документов — обычная файловая система. Markdown-файлы в папках.
from pathlib import Path
class FileMemory:
def __init__(self, base_path: str = "./agent_notes"):
self.base = Path(base_path)
self.base.mkdir(exist_ok=True)
def write(self, folder: str, name: str, content: str):
"""Записать документ."""
path = self.base / folder
path.mkdir(exist_ok=True)
(path / f"{name}.md").write_text(content)
def read(self, folder: str, name: str) -> str | None:
"""Прочитать документ."""
path = self.base / folder / f"{name}.md"
return path.read_text() if path.exists() else None
def list_docs(self, folder: str) -> list[str]:
"""Список документов в папке."""
path = self.base / folder
return [f.stem for f in path.glob("*.md")] if path.exists() else []
Структура получается человекочитаемой:
agent_notes/
├── architecture/
│ ├── database.md
│ └── api.md
├── decisions/
│ └── typescript.md
└── context/
└── project.md
Можно открыть любой файл руками и посмотреть, что агент думает о проекте. Это удобно для отладки.
Собираем агента
Теперь главное — научить агента пользоваться этой памятью.
Я добавляю в системный промпт инструкции и специальные команды. Агент пишет команды в ответе, я их парсю и выполняю.
SYSTEM_PROMPT = """Ты — ассистент с внешней памятью.
У тебя есть три хранилища:
1. Факты — быстрый доступ по ключу
2. Семантика — поиск по смыслу
3. Документы — структурированные заметки
Команды (пиши прямо в ответе):
[SAVE_FACT key="..." value="..."] — запомнить факт
[GET_FACT key="..."] — вспомнить факт
[SEARCH_MEMORY query="..."] — поиск по смыслу
[SAVE_DOC folder="..." name="..." content="..."] — записать документ
[READ_DOC folder="..." name="..."] — прочитать документ
Когда запоминать:
- Имя пользователя и его предпочтения
- Решения и их причины
- Технические детали проекта
Когда искать:
- Пользователь ссылается на прошлое ("как мы решили", "тот баг")
- Ты не уверен в чём-то, что обсуждали раньше
ВАЖНО: Не выдумывай. Если не помнишь — поищи или спроси.
"""
Парсер команд:
import re
def execute_commands(response: str, facts: FactMemory,
semantic: SemanticMemory, files: FileMemory) -> str:
"""Выполнить команды памяти и вернуть очищенный ответ."""
# [SAVE_FACT key="..." value="..."]
for match in re.finditer(r'[SAVE_FACT key="([^"]+)" value="([^"]+)"]', response):
facts.remember(match.group(1), match.group(2))
# [SEARCH_MEMORY query="..."]
for match in re.finditer(r'[SEARCH_MEMORY query="([^"]+)"]', response):
results = semantic.search(match.group(1))
# Результаты можно добавить в следующий промпт
# [SAVE_DOC folder="..." name="..." content="..."]
pattern = r'[SAVE_DOC folder="([^"]+)" name="([^"]+)" content="([^"]+)"]'
for match in re.finditer(pattern, response):
files.write(match.group(1), match.group(2), match.group(3))
# Убираем команды из ответа пользователю
clean = re.sub(r'[(?:SAVE_FACT|GET_FACT|SEARCH_MEMORY|SAVE_DOC|READ_DOC)[^]]+]', '', response)
return clean.strip()
Перед каждым запросом к модели я собираю контекст из памяти:
def build_context(user_message: str, facts: FactMemory, semantic: SemanticMemory) -> str:
"""Собрать контекст из памяти для текущего запроса."""
context_parts = []
# Базовые факты — нужны почти всегда
known_facts = facts.all_facts()
if known_facts:
facts_str = "n".join(f"- {k}: {v}" for k, v in known_facts.items())
context_parts.append(f"Известные факты:n{facts_str}")
# Семантически релевантные воспоминания
relevant = semantic.search(user_message, n_results=3)
if relevant:
memories_str = "n".join(f"- {m[:200]}" for m in relevant)
context_parts.append(f"Релевантные воспоминания:n{memories_str}")
return "nn".join(context_parts)
Что это даёт на практике
Сценарий: вы работаете с агентом над проектом неделю. Каждый день — десятки сообщений.
Без внешней памяти: к третьему дню агент забывает имя, к пятому — забывает проект. На вопрос «почему мы выбрали PostgreSQL?» начинает выдумывать.
С внешней памятью: неделю спустя агент помнит имя, проект, ключевые решения. На вопрос про PostgreSQL достаёт из семантической памяти запись первого дня и цитирует реальные причины.
Бонус: агент работает быстрее. Контекст маленький — 20-30 последних сообщений вместо пятисот. Модели легче, инференс быстрее.
Ещё бонус: можно посмотреть, что агент «помнит». Файлы читаемые, Redis можно залезть посмотреть. Это сильно помогает в отладке.
Грабли, на которые я наступил
Агент не всегда использует память. Иногда игнорирует инструкции и отвечает сразу. Особенно на простых вопросах.
Частично помогает снижение temperature до 0.3-0.5. Частично — более строгие инструкции. Полностью не решается.
Мусор накапливается. Через месяц в памяти сотни записей, половина устарела. Нужно периодически чистить.
Я удаляю записи старше 30 дней, к которым не обращались. Грубо, но работает. Хорошего решения пока нет.
Конфликты. Если в фактах написано «db: PostgreSQL», а в семантике нашлось «решили переходить на MongoDB» — что делать?
Пока никак. Последнее побеждает. Нужна версионность, но я её не сделал.
Encoding-модель занимает память. sentence-transformers держит модель в GPU. Если у вас и так мало VRAM — это проблема.
Можно использовать CPU для кодирования (медленнее, но работает). Можно взять модель поменьше. Можно вынести в отдельный сервис.
Сколько это стоит по ресурсам
На моём сервере (RTX 4090, 64GB RAM):
-
Redis: ~50MB RAM, latency <2ms
-
ChromaDB + модель для эмбеддингов: ~2GB RAM, ~1GB VRAM, latency ~100ms на поиск
-
Файловая система: зависит от размера, latency ~5ms
На фоне инференса 8B-модели (2-5 секунд на запрос) — незаметно.
Если VRAM мало — эмбеддинги можно считать на CPU. Будет ~300-500ms вместо 100ms, всё ещё терпимо.
Философское отступление
Мы привыкли думать, что память — это хранилище. Положил, достал. Но человеческая память работает иначе.
Каждое воспоминание — реконструкция. Мы не проигрываем запись, мы создаём её заново каждый раз. Поэтому воспоминания меняются. Поэтому свидетели одного события помнят его по-разному.
LLM с гигантским контекстом — это магнитофон. Точная запись, но лента конечна.
LLM с внешней памятью — ближе к человеку. Неточно, избирательно, с интерпретацией при извлечении. Зато масштабируется.
Может, это и есть правильный путь. Не делать идеальный магнитофон, а делать систему, которая умеет забывать неважное и вспоминать важное.
Что дальше
Это базовая версия. Дальше хочу попробовать:
Автоматическое решение, что запоминать. Сейчас агент сам решает. Иногда решает плохо. Возможно, нужен отдельный классификатор важности.
Коллективную память. Несколько агентов пишут в общую базу. Учатся на опыте друг друга. Там должны быть интересные эмерджентные эффекты.
Умное забывание. Не по времени, а по важности и частоте использования. Spaced repetition наоборот: что не используешь — забывай.
Если тема интересна — пишите в комментариях, какие аспекты разобрать подробнее. И расскажите, как вы решаете проблему памяти в своих агентах. Наверняка есть подходы, о которых я не знаю.
Если хотите ещё про внутренности агентов, то пишу про такое в токены на ветер — иногда о том, как LLM думают, или просто притворяются.
Автор: ScriptShaper


