Когда агент «теряет» факты
Посмотрим на конкретный разговор. Пользователь называет факты о себе в первых сообщениях, а в 21-м — агент ведёт себя так, будто ничего этого не было.
Проблема в том, что контекстное окно ограничено. Старые сообщения либо выталкиваются, либо «тонут» в большом объёме текста — и LLM перестаёт обращать на них внимание. Суммаризация истории (краткосрочная память) помогает, но теряет конкретные факты. Векторный поиск (долгосрочная память) ищет по семантике, а не по структуре.
Что такое сущность и entity memory
Сущность (entity) — это именованный, идентифицируемый объект реального мира, о котором в разговоре накапливаются факты. Сущности бывают нескольких типов:
Entity memory — это слой памяти, который:
- Извлекает сущности и их атрибуты из каждого сообщения
- Обновляет хранилище (key-value или структурированное) новой информацией
- При каждом запросе загружает релевантные сущности и вставляет их в системный промпт
Это динамический контекст: он меняется по ходу разговора, растёт с каждым новым фактом и всегда доступен LLM, независимо от того, сколько сообщений прошло с момента упоминания.
Архитектура: четыре шага цикла
Entity memory встраивается в агентский цикл между получением сообщения и вызовом LLM. Каждое входящее сообщение проходит через четыре операции:
Стрелка «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}")
claude-haiku-4-5-20251001 справляется с извлечением сущностей значительно
дешевле и быстрее, чем Sonnet или Opus. Это важно — extraction запускается
на каждом сообщении пользователя.
Кореференция: «он», «Иван» и «Иван Петров» — одна сущность
В живом разговоре одна сущность называется по-разному: полным именем, сокращением, местоимением. Если агент не умеет связывать эти упоминания, он создаёт дублирующиеся записи в хранилище.
Простой способ решить кореференцию — передавать список уже известных сущностей в 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
Инъекция сущностей в системный промпт
После извлечения и обновления хранилища — вставляем сущности в системный промпт. Важно размещать их до истории сообщений и в структурированном формате, чтобы LLM легко их воспринял.
Ты — помощник-разработчик. Отвечай точно и по делу.
Используй контекст о пользователе чтобы давать релевантные советы.
## Известные сущности из разговора
[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
Полный агент с 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 сообщений) — всё влезает в контекст напрямую
- Тематика разовая — пользователь задаёт вопрос и уходит
- Нет повторяющихся объектов — каждое сообщение про новую тему
Типичные ошибки
last_seen для каждого атрибута. При обновлении — перезаписывай.
Для временны́х атрибутов (дедлайны, статусы) добавляй TTL — автоматическое
устаревание через N дней.
claude-haiku-4-5-20251001 для extraction.
Он справляется с этой задачей и в 10–15 раз дешевле Sonnet.
Sonnet/Opus — только для финальной генерации ответа.
Шпаргалка
- Что такое: структурированные факты об именованных объектах (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)
Практика
EntityStore.search(query: str) -> list[Entity] —
метод, который возвращает только те сущности, чьи имя или описание
содержат подстроку query (регистронезависимо).
Используй его в build_system_prompt: вставляй только
сущности, релевантные текущему сообщению пользователя.
{"value": "...", "updated_at": timestamp}.
Реализуй метод store.clean_stale(days: int),
который удаляет атрибуты старше указанного числа дней.
Это критично для атрибутов типа «статус», «дедлайн», «текущая задача».
EntityMemoryAgent с поддержкой нескольких пользователей
через SQLiteEntityStore: каждый пользователь идентифицируется по
user_id, его сущности изолированы от чужих.
Добавь endpoint на FastAPI: POST /chat с полями
user_id и message, который возвращает ответ агента
с учётом entity memory конкретного пользователя.