Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как. ai-агенты.. ai-агенты. chromadb.. ai-агенты. chromadb. llama.. ai-агенты. chromadb. llama. llm.. ai-агенты. chromadb. llama. llm. rag.. ai-агенты. chromadb. llama. llm. rag. redis.. ai-агенты. chromadb. llama. llm. rag. redis. sentence-transformers.. ai-агенты. chromadb. llama. llm. rag. redis. sentence-transformers. векторный поиск.. ai-агенты. chromadb. llama. llm. rag. redis. sentence-transformers. векторный поиск. локальные модели.. ai-агенты. chromadb. llama. llm. rag. redis. sentence-transformers. векторный поиск. локальные модели. память LLM.

Три месяца назад я наблюдал, как мой агент на 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()}
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 1

Использование тривиальное:

memory = FactMemory()
memory.remember("user_name", "Алексей")
memory.remember("project", "backend-api")
memory.remember("db", "PostgreSQL")

# После перезапуска, через неделю:
name = memory.recall("user_name")  # "Алексей"
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 2

Данные переживают перезапуск агента. Переживают перезагрузку сервера, если включить 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 []
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 3

Пример:

semantic = SemanticMemory()

# Сохраняем обсуждения
semantic.store("Выбрали PostgreSQL вместо MongoDB, потому что нужны транзакции")
semantic.store("Проблема с производительностью на эндпоинте /users — добавили индекс")
semantic.store("Пользователь просит использовать TypeScript везде")

# Ищем по смыслу
results = semantic.search("почему не взяли монгу?")
# Находит: "Выбрали PostgreSQL вместо MongoDB, потому что нужны транзакции"
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 4

Обратите внимание: запрос «почему не взяли монгу» находит текст про «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 []
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 5

Структура получается человекочитаемой:

agent_notes/
├── architecture/
│   ├── database.md
│   └── api.md
├── decisions/
│   └── typescript.md
└── context/
    └── project.md
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 6

Можно открыть любой файл руками и посмотреть, что агент думает о проекте. Это удобно для отладки.

Собираем агента

Теперь главное — научить агента пользоваться этой памятью.

Я добавляю в системный промпт инструкции и специальные команды. Агент пишет команды в ответе, я их парсю и выполняю.

SYSTEM_PROMPT = """Ты — ассистент с внешней памятью.

У тебя есть три хранилища:
1. Факты — быстрый доступ по ключу
2. Семантика — поиск по смыслу
3. Документы — структурированные заметки

Команды (пиши прямо в ответе):
[SAVE_FACT key="..." value="..."] — запомнить факт
[GET_FACT key="..."] — вспомнить факт
[SEARCH_MEMORY query="..."] — поиск по смыслу
[SAVE_DOC folder="..." name="..." content="..."] — записать документ
[READ_DOC folder="..." name="..."] — прочитать документ

Когда запоминать:
- Имя пользователя и его предпочтения
- Решения и их причины
- Технические детали проекта

Когда искать:
- Пользователь ссылается на прошлое ("как мы решили", "тот баг")
- Ты не уверен в чём-то, что обсуждали раньше

ВАЖНО: Не выдумывай. Если не помнишь — поищи или спроси.
"""
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 7

Парсер команд:

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()
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 8

Перед каждым запросом к модели я собираю контекст из памяти:

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)
Мой локальный агент помнит проект лучше меня. Контекст — 32K токенов. Расскажу, как - 9

Что это даёт на практике

Сценарий: вы работаете с агентом над проектом неделю. Каждый день — десятки сообщений.

Без внешней памяти: к третьему дню агент забывает имя, к пятому — забывает проект. На вопрос «почему мы выбрали 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

Источник

Rambler's Top100