Когда агент «теряет» факты

Посмотрим на конкретный разговор. Пользователь называет факты о себе в первых сообщениях, а в 21-м — агент ведёт себя так, будто ничего этого не было.

Пример: агент без entity memory
1
USER
Привет! Я Иван, работаю в «Иннотех», пишу backend на FastAPI.
2
AGENT
Привет, Иван! Чем могу помочь с FastAPI?
3
USER
У нас проект «Трекер задач», дедлайн через 2 недели.
INFO
18 сообщений обсуждают архитектуру endpoints, middleware, валидацию — окно контекста наполняется…
21
USER
Кстати, какую базу данных посоветуешь для моего проекта?
22
AGENT ✗
Зависит от требований! Расскажи подробнее: какой язык/фреймворк вы используете? Это новый проект или существующий? Какая нагрузка ожидается?

Проблема в том, что контекстное окно ограничено. Старые сообщения либо выталкиваются, либо «тонут» в большом объёме текста — и LLM перестаёт обращать на них внимание. Суммаризация истории (краткосрочная память) помогает, но теряет конкретные факты. Векторный поиск (долгосрочная память) ищет по семантике, а не по структуре.

Entity memory — отдельный слой. Это не замена краткосрочной или долгосрочной памяти, а дополнение. Задача entity memory — хранить структурированные факты о конкретных объектах и инжектировать их в контекст при каждом запросе, независимо от длины истории.

Что такое сущность и entity memory

Сущность (entity) — это именованный, идентифицируемый объект реального мира, о котором в разговоре накапливаются факты. Сущности бывают нескольких типов:

Типы сущностей в разговоре агента
PERSON Иван Петров
роль Python-разработчик, 5 лет опыта
компания «Иннотех»
стек FastAPI, PostgreSQL, Docker
предпочтения строгая типизация, pydantic, async
PROJECT Трекер задач
стек FastAPI + PostgreSQL
дедлайн 30 марта 2026 ↑ обновлено
статус в разработке, MVP готов на 60%
ORG Иннотех
тип ИТ-компания, ~50 разработчиков
домен b2b SaaS

Entity memory — это слой памяти, который:

  1. Извлекает сущности и их атрибуты из каждого сообщения
  2. Обновляет хранилище (key-value или структурированное) новой информацией
  3. При каждом запросе загружает релевантные сущности и вставляет их в системный промпт

Это динамический контекст: он меняется по ходу разговора, растёт с каждым новым фактом и всегда доступен LLM, независимо от того, сколько сообщений прошло с момента упоминания.

Архитектура: четыре шага цикла

Entity memory встраивается в агентский цикл между получением сообщения и вызовом LLM. Каждое входящее сообщение проходит через четыре операции:

1
Извлечение (Extract)
Анализируем сообщение: какие сущности упомянуты, какие атрибуты указаны? Это делается либо через LLM-вызов с специальным промптом, либо через NER-модель.
extract_entities(message) → [{name, type, attrs}]
2
Обновление (Update)
Новые факты объединяются с существующими знаниями об этой сущности. Если сущность уже есть — атрибуты мержатся. Новые факты перезаписывают старые только если они более актуальны (например, новый дедлайн).
store.update(entity_name, new_attrs)
3
Загрузка (Load)
Перед генерацией ответа загружаем все сущности из хранилища. В сложных системах — только релевантные для текущего запроса.
entities = store.load_all() # или store.search(query)
4
Инъекция (Inject)
Сущности вставляются в системный промпт отдельным блоком «Известные сущности» — до истории сообщений. LLM видит их при каждом запросе.
system_prompt = base_prompt + format_entities(entities)
100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Сообщение пользователя "Я работаю на FastAPI, проект X" extract Экстрактор сущностей LLM-вызов с extraction промптом update Сборка контекста system_prompt + entities + history enriched prompt LLM (Claude) генерация финального ответа Ответ пользователю ENTITY STORE PERSON Иван Петров роль: Python-разработчик, 5 лет компания: «Иннотех» стек: FastAPI, PostgreSQL, Docker предпочт.: async, pydantic, строгие типы последнее упом.: сообщение #3 PROJECT Трекер задач стек: FastAPI + PostgreSQL дедлайн: 30 марта 2026 ↑ статус: MVP ~60% последнее упом.: сообщение #15 ORG Иннотех тип: ИТ-компания, b2b SaaS размер: ~50 разработчиков последнее упом.: сообщение #1 CONCEPT Оптимизация БД контекст: задача из проекта детали: slow queries > 500ms последнее упом.: сообщение #19 4 сущности · обновлено на сообщении #19 update load

Стрелка «update» идёт от экстрактора к хранилищу — туда записываются новые факты. Стрелка «load» идёт обратно — к сборщику контекста, который вставляет их в промпт перед LLM. Таким образом, каждое сообщение обновляет базу знаний, и каждый ответ генерируется с учётом всего накопленного знания.

Entity Store: структура данных

Самый простой entity store — это словарь, где ключ — имя сущности, а значение — объект с типом, описанием и атрибутами. Разберём структуру на Python-датаклассах.

from dataclasses import dataclass, field
from typing import Any
import json
import time


@dataclass
class Entity:
    """Одна именованная сущность в памяти агента."""
    name: str
    entity_type: str          # "person", "project", "org", "concept", "location"
    description: str = ""     # краткое описание одной строкой
    attributes: dict[str, Any] = field(default_factory=dict)
    first_seen: float = field(default_factory=time.time)
    last_seen: float = field(default_factory=time.time)
    mention_count: int = 1

    def update(self, new_attrs: dict[str, Any], new_description: str = "") -> None:
        """Мержим новые атрибуты с существующими."""
        self.attributes.update(new_attrs)
        if new_description:
            self.description = new_description
        self.last_seen = time.time()
        self.mention_count += 1

    def to_text(self) -> str:
        """Текстовое представление для вставки в промпт."""
        lines = [f"[{self.entity_type.upper()}] {self.name}"]
        if self.description:
            lines.append(f"  Описание: {self.description}")
        for k, v in self.attributes.items():
            lines.append(f"  {k}: {v}")
        return "\n".join(lines)


class EntityStore:
    """Хранилище сущностей для агента."""

    def __init__(self):
        self._entities: dict[str, Entity] = {}

    def upsert(self, name: str, entity_type: str,
               description: str = "",
               attributes: dict[str, Any] | None = None) -> Entity:
        """Создаёт или обновляет сущность."""
        key = name.lower().strip()  # нормализация ключа
        if key not in self._entities:
            self._entities[key] = Entity(
                name=name,
                entity_type=entity_type,
                description=description,
                attributes=attributes or {},
            )
        else:
            self._entities[key].update(attributes or {}, description)
        return self._entities[key]

    def get(self, name: str) -> Entity | None:
        return self._entities.get(name.lower().strip())

    def all(self) -> list[Entity]:
        return sorted(self._entities.values(), key=lambda e: -e.mention_count)

    def to_prompt_section(self) -> str:
        """Форматирует все сущности для вставки в системный промпт."""
        if not self._entities:
            return ""
        parts = ["## Известные сущности из разговора\n"]
        for entity in self.all():
            parts.append(entity.to_text())
        return "\n".join(parts)

    def save(self, path: str) -> None:
        """Сохраняем в JSON для персистентности между сессиями."""
        data = {}
        for key, e in self._entities.items():
            data[key] = {
                "name": e.name, "entity_type": e.entity_type,
                "description": e.description, "attributes": e.attributes,
                "first_seen": e.first_seen, "last_seen": e.last_seen,
                "mention_count": e.mention_count,
            }
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    @classmethod
    def load(cls, path: str) -> "EntityStore":
        """Загружаем из JSON."""
        store = cls()
        try:
            with open(path, encoding="utf-8") as f:
                data = json.load(f)
            for key, d in data.items():
                e = Entity(**d)
                store._entities[key] = e
        except FileNotFoundError:
            pass
        return store
Нормализация ключей. Сущности «Иван», «Иван Петров» и «иван» — это одна и та же персона. Приводи имя к нижнему регистру и убирай пробелы при записи в хранилище. Для отображения используй оригинальное имя из поля name.

LLM-driven извлечение сущностей

Как понять, что в сообщении «Я работаю в FastAPI над проектом Трекер задач» есть два атрибута одной персоны и одна новая сущность-проект? Надёжнее всего — попросить LLM. Задача формулируется как structured output: «Прочитай сообщение и верни список сущностей в JSON».

import anthropic
import json
import re

client = anthropic.Anthropic()

EXTRACTION_SYSTEM = """Ты — экстрактор сущностей. Анализируй сообщение пользователя
и возвращай JSON-список сущностей с их атрибутами.

Типы сущностей: person, project, org, concept, location, product

Формат ответа — только JSON, без пояснений:
[
  {
    "name": "Иван Петров",
    "type": "person",
    "description": "Python-разработчик",
    "attributes": {
      "роль": "Python-разработчик",
      "стек": "FastAPI"
    }
  }
]

Правила:
- Извлекай только явно упомянутые факты, не додумывай
- Если новых сущностей нет — верни пустой список []
- Один человек = одна запись, даже если у него несколько атрибутов
- "Я" / "меня" относится к пользователю (используй имя если оно известно)"""


def extract_entities(message: str, known_entities: list[str] = None) -> list[dict]:
    """
    Извлекает сущности из сообщения с помощью LLM.
    known_entities — список имён уже известных сущностей (для помощи с кореференцией).
    """
    context = ""
    if known_entities:
        context = f"\nУже известные сущности: {', '.join(known_entities)}\n"

    response = client.messages.create(
        model="claude-haiku-4-5-20251001",   # быстрая модель для extraction
        max_tokens=1024,
        system=EXTRACTION_SYSTEM,
        messages=[{
            "role": "user",
            "content": f"{context}Сообщение: {message}"
        }]
    )

    text = response.content[0].text.strip()

    # Вырезаем JSON даже если модель добавила пояснения
    match = re.search(r'\[.*\]', text, re.DOTALL)
    if not match:
        return []

    try:
        return json.loads(match.group())
    except json.JSONDecodeError:
        return []


# Пример использования
message = "Я работаю в компании Иннотех, веду проект 'Трекер задач' на FastAPI + PostgreSQL"
entities = extract_entities(message)

for e in entities:
    print(f"{e['type']}: {e['name']} — {e.get('description', '')}")
    for k, v in e.get('attributes', {}).items():
        print(f"  {k}: {v}")
Используй быструю модель для extraction. claude-haiku-4-5-20251001 справляется с извлечением сущностей значительно дешевле и быстрее, чем Sonnet или Opus. Это важно — extraction запускается на каждом сообщении пользователя.

Кореференция: «он», «Иван» и «Иван Петров» — одна сущность

В живом разговоре одна сущность называется по-разному: полным именем, сокращением, местоимением. Если агент не умеет связывать эти упоминания, он создаёт дублирующиеся записи в хранилище.

✗ Без кореференции
«Иван работает в Иннотех»
Entity: Иван (person)
«Он ведёт проект X»
Entity: Он (person) ← дубликат!
«Иван Петров прислал файл»
Entity: Иван Петров (person) ← ещё дубликат!
✓ С кореференцией
«Иван работает в Иннотех»
Entity: Иван Петров
«Он ведёт проект X»
«Он» → Иван Петров (update)
«Иван Петров прислал файл»
«Иван Петров» = Иван Петров (update)
1 entity · 3 attributes merged

Простой способ решить кореференцию — передавать список уже известных сущностей в extraction-промпт. LLM сам сопоставит «Он» с «Иваном» по контексту:

def process_message_with_coref(
    message: str,
    store: EntityStore
) -> list[dict]:
    """
    Извлекает сущности с учётом уже известных — для разрешения кореференции.
    """
    # Передаём имена известных сущностей как подсказку
    known = [e.name for e in store.all()]
    entities = extract_entities(message, known_entities=known)

    for e in entities:
        store.upsert(
            name=e["name"],
            entity_type=e["type"],
            description=e.get("description", ""),
            attributes=e.get("attributes", {}),
        )

    return entities
Когда этого недостаточно. Для длинных диалогов или многоязычных контекстов простой подсказки мало. Тогда используют специализированные NLP-библиотеки (spaCy, FastCoref) для выделения кореферентных цепочек до обращения к LLM.

Инъекция сущностей в системный промпт

После извлечения и обновления хранилища — вставляем сущности в системный промпт. Важно размещать их до истории сообщений и в структурированном формате, чтобы LLM легко их воспринял.

Пример системного промпта с entity context
# Базовые инструкции агента
Ты — помощник-разработчик. Отвечай точно и по делу.
Используй контекст о пользователе чтобы давать релевантные советы.

## Известные сущности из разговора

[PERSON] Иван Петров
  Описание: Python-разработчик, 5 лет опыта
  компания: «Иннотех»
  стек: FastAPI, PostgreSQL, Docker
  предпочтения: async, pydantic, строгие типы

[PROJECT] Трекер задач
  Описание: backend-сервис управления задачами
  стек: FastAPI + PostgreSQL
  дедлайн: 30 марта 2026
  статус: MVP ~60%

[ORG] Иннотех
  тип: b2b SaaS ИТ-компания
  размер: ~50 разработчиков
def build_system_prompt(base_prompt: str, store: EntityStore) -> str:
    """
    Собирает системный промпт: базовые инструкции + секция с сущностями.
    """
    entity_section = store.to_prompt_section()
    if entity_section:
        return f"{base_prompt}\n\n{entity_section}"
    return base_prompt
Токены растут с каждой сущностью. Если разговор длинный, хранилище может накопить десятки сущностей. Следи за размером: при более 10–15 сущностях рассмотри фильтрацию по релевантности (например, только те, что упоминались в последних N сообщениях) или суммаризацию редко используемых.

Полный агент с entity memory

Соберём всё вместе: entity store, extraction, кореференцию, инъекцию и персистентность.

import anthropic
from pathlib import Path


BASE_SYSTEM = """Ты — ассистент-разработчик. Помогаешь с архитектурой, кодом, отладкой.
Отвечай точно, конкретно, со ссылкой на технологии пользователя если они известны."""

ENTITY_STORE_PATH = "entity_store.json"


class EntityMemoryAgent:
    def __init__(self):
        self.client = anthropic.Anthropic()
        self.store = EntityStore.load(ENTITY_STORE_PATH)
        self.history: list[dict] = []

    def chat(self, user_message: str) -> str:
        # Шаг 1: Извлечь и обновить сущности
        process_message_with_coref(user_message, self.store)

        # Шаг 2: Сохранить обновлённый store (персистентность)
        self.store.save(ENTITY_STORE_PATH)

        # Шаг 3: Добавить сообщение в историю
        self.history.append({"role": "user", "content": user_message})

        # Шаг 4: Собрать контекст = base prompt + entities
        system = build_system_prompt(BASE_SYSTEM, self.store)

        # Шаг 5: Вызвать LLM
        response = self.client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=system,
            messages=self.history,
        )

        answer = response.content[0].text

        # Шаг 6: Добавить ответ в историю
        self.history.append({"role": "assistant", "content": answer})

        return answer

    def show_entities(self) -> None:
        """Отладка: показать текущее состояние хранилища."""
        for entity in self.store.all():
            print(entity.to_text())
            print()


# Использование
agent = EntityMemoryAgent()

# Первое сообщение — агент запоминает факты
r1 = agent.chat("Привет! Я Иван, пишу FastAPI-сервис для Иннотех.")
print(r1)

# Второе сообщение — новые факты
r2 = agent.chat("Проект называется 'Трекер задач', дедлайн 30 марта.")
print(r2)

# 20 сообщений спустя — агент всё ещё знает контекст
r3 = agent.chat("Какую базу данных посоветуешь для моего проекта?")
print(r3)
# → Ответ будет с учётом: "Трекер задач", FastAPI, Иннотех

# Смотрим что накопилось
agent.show_entities()

Персистентность: entity store между сессиями

В примере выше мы уже сохраняем store в JSON после каждого сообщения. Но для продакшна JSON-файл — не лучший вариант: нет транзакций, нет поиска. Вот три варианта хранения:

Хранилище Когда использовать Плюсы Минусы
dict (in-memory) Одна сессия, прототип Мгновенный доступ Теряется при рестарте
JSON-файл Локальный агент, один пользователь Простота, читаемость Нет поиска, нет транзакций
SQLite Один пользователь, продакшн SQL-запросы, надёжность Нет горизонтального масштаба
PostgreSQL / Redis Многопользовательский агент Масштабирование, TTL, поиск Зависимость от инфраструктуры

Пример с SQLite — достаточно для большинства реальных агентов-ассистентов:

import sqlite3
import json


class SQLiteEntityStore:
    """Entity store на базе SQLite с поддержкой нескольких пользователей."""

    def __init__(self, db_path: str = "entities.db"):
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self._init_db()

    def _init_db(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS entities (
                user_id     TEXT NOT NULL,
                name_key    TEXT NOT NULL,
                name        TEXT NOT NULL,
                entity_type TEXT NOT NULL,
                description TEXT DEFAULT '',
                attributes  TEXT DEFAULT '{}',
                mention_count INTEGER DEFAULT 1,
                last_seen   REAL,
                PRIMARY KEY (user_id, name_key)
            )
        """)
        self.conn.commit()

    def upsert(self, user_id: str, name: str, entity_type: str,
               description: str = "", attributes: dict | None = None) -> None:
        key = name.lower().strip()
        attrs_json = json.dumps(attributes or {}, ensure_ascii=False)

        self.conn.execute("""
            INSERT INTO entities (user_id, name_key, name, entity_type, description, attributes, last_seen)
            VALUES (?, ?, ?, ?, ?, ?, unixepoch())
            ON CONFLICT(user_id, name_key) DO UPDATE SET
                attributes    = json_patch(attributes, excluded.attributes),
                description   = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END,
                mention_count = mention_count + 1,
                last_seen     = unixepoch()
        """, (user_id, key, name, entity_type, description, attrs_json))
        self.conn.commit()

    def load_for_user(self, user_id: str) -> list[Entity]:
        cursor = self.conn.execute(
            "SELECT name, entity_type, description, attributes, mention_count, last_seen "
            "FROM entities WHERE user_id = ? ORDER BY mention_count DESC",
            (user_id,)
        )
        result = []
        for row in cursor:
            name, etype, desc, attrs_json, count, last_seen = row
            e = Entity(
                name=name, entity_type=etype, description=desc,
                attributes=json.loads(attrs_json),
                mention_count=count, last_seen=last_seen,
            )
            result.append(e)
        return result

Когда entity memory нужна, а когда нет

Entity memory — не серебряная пуля. Она добавляет сложность и стоимость (LLM-вызов на каждом сообщении для extraction). Используй её, когда:

  • Разговор длинный — 20+ сообщений, факты из начала нужны в конце
  • Разговор многосессионный — пользователь возвращается через день/неделю
  • Домен структурированный — агент работает с проектами, клиентами, задачами, где у объектов есть атрибуты
  • Персонализация важна — агент должен адаптировать ответы под конкретного человека

Не нужна, если:

  • Разговор короткий (5–10 сообщений) — всё влезает в контекст напрямую
  • Тематика разовая — пользователь задаёт вопрос и уходит
  • Нет повторяющихся объектов — каждое сообщение про новую тему

Типичные ошибки

1. Entity explosion — хранилище растёт бесконтрольно
Extraction-промпт не достаточно строг и извлекает всё подряд: каждый URL, каждое упомянутое слово. Через 50 сообщений в хранилище 200 сущностей, токены на инъекцию огромные, а качество ответов падает.
Ограничивай типы явно: «извлекай только person, project, org, product». Добавь TTL или лимит на количество сущностей — удаляй те, что не упоминались более N сообщений.
2. Инъекция без фильтрации — все сущности в каждый промпт
При длинных разговорах вставка всех 40+ сущностей в каждый запрос превращает системный промпт в огромный blob. LLM начинает игнорировать или путать информацию из-за «иголки в стоге сена».
Добавь релевантность: загружай только сущности, упомянутые в последних 5–7 сообщениях, или те, что семантически близки к текущему запросу (quick embedding search по имени/описанию).
3. Устаревшие атрибуты — «дедлайн 30 марта» после 1 апреля
Атрибуты хранятся вечно, но факты меняются. Пользователь перенёс дедлайн, поменял стек — а агент всё ещё говорит о старых данных, не обновляя их.
Храни last_seen для каждого атрибута. При обновлении — перезаписывай. Для временны́х атрибутов (дедлайны, статусы) добавляй TTL — автоматическое устаревание через N дней.
4. Пропуск кореференции — «Иван» и «Иван Петров» как разные люди
Без разрешения кореференции одна персона накапливает дублирующиеся записи с частично разными атрибутами. Агент путается, какой «Иван» имеется в виду.
Всегда передавай список известных имён в extraction-промпт. Добавь явное правило: «если имя похоже на уже известную сущность (подстрока или первое имя совпадает) — считай их одной сущностью».
5. Extraction на дорогой модели для каждого сообщения
Запуск Claude Sonnet для extraction на каждом сообщении удваивает стоимость разговора. Extraction — простая задача, не требующая мощной модели.
Используй claude-haiku-4-5-20251001 для extraction. Он справляется с этой задачей и в 10–15 раз дешевле Sonnet. Sonnet/Opus — только для финальной генерации ответа.

Шпаргалка

Entity Memory — краткая выжимка
  • Что такое: структурированные факты об именованных объектах (person, project, org, concept), обновляемые по ходу разговора
  • Зачем: агент помнит факты о пользователе/проектах через всю длину разговора и между сессиями
  • Цикл: Extract → Update Store → Load → Inject into system prompt
  • Extraction: LLM (Haiku) с structured output промптом или NER-модель
  • Кореференция: передавать известные имена в extraction промпт
  • Инъекция: блок «Известные сущности» в начале system prompt
  • Хранилище: dict (прототип) → JSON (один юзер) → SQLite/PostgreSQL (продакшн)
  • Модели: Haiku для extraction, Sonnet/Opus для генерации
  • Когда использовать: длинные или многосессионные разговоры, персонализация, структурированный домен
# Минимальный рабочий паттерн entity memory

store = EntityStore.load("store.json")           # загружаем между сессиями

def agent_turn(user_msg: str) -> str:
    # 1. Extract + Update
    process_message_with_coref(user_msg, store)
    store.save("store.json")                     # персистентность

    # 2. Build context
    system = build_system_prompt(BASE_SYSTEM, store)

    # 3. Generate
    return llm_call(system=system, messages=history)

Практика

Задание 1. Реализуй EntityStore.search(query: str) -> list[Entity] — метод, который возвращает только те сущности, чьи имя или описание содержат подстроку query (регистронезависимо). Используй его в build_system_prompt: вставляй только сущности, релевантные текущему сообщению пользователя.
Задание 2. Добавь поддержку TTL для атрибутов: вместо простых значений храни {"value": "...", "updated_at": timestamp}. Реализуй метод store.clean_stale(days: int), который удаляет атрибуты старше указанного числа дней. Это критично для атрибутов типа «статус», «дедлайн», «текущая задача».
Задание 3 (продвинутый). Реализуй EntityMemoryAgent с поддержкой нескольких пользователей через SQLiteEntityStore: каждый пользователь идентифицируется по user_id, его сущности изолированы от чужих. Добавь endpoint на FastAPI: POST /chat с полями user_id и message, который возвращает ответ агента с учётом entity memory конкретного пользователя.