Агенты

Как добавить постоянную память в ИИ-агенты через SDK OpenAI

Создаём агентов, которые запоминают предпочтения пользователей между сессиями. Пошаговое руководство по паттерну state-based memory от OpenAI.

Илья Новиков
Илья НовиковГлавный объяснитель
16 января 2026 г.10 мин чтения
Поделиться:
Круговая диаграмма четырёх фаз памяти ИИ-агента: инъекция, дистилляция, консолидация и персистентность

КРАТКАЯ ИНФОРМАЦИЯ

Сложность Средняя
Время 45-60 минут
Требования Python 3.9+, знание async/await, базовое понимание промптинга LLM
Инструменты API-ключ OpenAI, библиотека openai-agents (pip install openai-agents)

Чему научитесь:

  • Структурировать state-объект с разделением профиля и заметок
  • Захватывать предпочтения пользователя в реальном времени через специальный инструмент
  • Консолидировать сессионные заметки в долгосрочную память без дублей
  • Внедрять память в промпты с чёткими правилами приоритета

В начале января 2026-го OpenAI выложили кукбук по персонализации контекста. Подход использует структурированные state-объекты вместо поиска по памяти. Никаких эмбеддингов, никакого семантического поиска. Вы храните JSON-объект локально, подставляете нужные куски в системный промпт, и модель рассуждает над ними напрямую.

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

Чем это отличается от RAG-памяти

Большинство реализаций памяти для LLM устроены так: эмбеддим прошлые разговоры, складываем в векторную базу, достаём релевантные куски при запросе. Кукбук OpenAI идёт другим путём.

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

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

Базовая архитектура

Жизненный цикл памяти состоит из четырёх фаз, повторяющихся каждую сессию.

Инъекция происходит в начале сессии. State-объект рендерится в системный промпт: данные профиля форматируются как YAML-frontmatter, заметки памяти как Markdown-списки.

Дистилляция происходит во время разговора. Когда пользователь раскрывает предпочтение («я вегетарианец»), агент вызывает инструмент save_memory_note, чтобы захватить это как сессионную заметку.

Консолидация происходит после завершения сессии. Отдельный вызов LLM объединяет сессионные заметки с глобальной памятью, обрабатывая дедупликацию и разрешение конфликтов.

Персистентность на вашей совести. Кукбук хранит состояние локально. В продакшене вы бы записывали это в базу по user ID.

Шаг 1: определяем state-объект

State-объект представляет собой Python dataclass с четырьмя основными секциями. Profile хранит структурированные данные, которые редко меняются. Global memory хранит долгосрочные предпочтения. Session memory захватывает заметки из текущего разговора. Trip history (в примере с путешествиями из кукбука) даёт недавний поведенческий контекст.

from dataclasses import dataclass, field
from typing import Any, Dict, List

@dataclass
class MemoryNote:
    text: str
    last_update_date: str  # ISO формат: YYYY-MM-DD
    keywords: List[str]

@dataclass
class AgentState:
    profile: Dict[str, Any] = field(default_factory=dict)
    global_memory: Dict[str, Any] = field(default_factory=lambda: {"notes": []})
    session_memory: Dict[str, Any] = field(default_factory=lambda: {"notes": []})
    
    # Отрендеренные строки для инъекции (вычисляются каждый запуск)
    system_frontmatter: str = ""
    global_memories_md: str = ""
    session_memories_md: str = ""

Кукбук инициализирует это реалистичными данными путешественника: ID лояльности, предпочтения по местам, паттерны прошлых поездок. В своей реализации вы бы заполняли секцию profile из базы пользователей или CRM.

Одна вещь, которую я заметил при тестировании: поле last_update_date в заметках важно для разрешения конфликтов. Шаг консолидации использует его, чтобы решить, какая версия предпочтения побеждает, когда две заметки противоречат друг другу.

Шаг 2: создаём инструмент захвата памяти

Агенту нужен способ записывать предпочтения по мере их появления в разговоре. Кукбук реализует это как function tool, который пишет в session_memory.notes.

from agents import function_tool, RunContextWrapper
from datetime import datetime, timezone

@function_tool
def save_memory_note(
    ctx: RunContextWrapper[AgentState],
    text: str,
    keywords: List[str],
) -> dict:
    """
    Сохранить кандидата на заметку памяти в сессионное хранилище.
    
    Захватывать только устойчивые, actionable предпочтения, явно указанные пользователем.
    Не хранить спекуляции, чувствительные PII или временные детали конкретной поездки.
    """
    if ctx.context.session_memory.get("notes") is None:
        ctx.context.session_memory["notes"] = []
    
    clean_keywords = [k.strip().lower() for k in keywords if k.strip()][:3]
    
    ctx.context.session_memory["notes"].append({
        "text": text.strip(),
        "last_update_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
        "keywords": clean_keywords,
    })
    
    return {"ok": True}

Docstring инструмента делает основную работу. Он говорит модели, что считается хорошей памятью (устойчивая, actionable, явная) и что пропускать (спекуляции, чувствительные данные, временный контекст). Версия из кукбука подробнее, с конкретными примерами для каждой категории.

Ожидаемый результат: когда пользователь говорит «я вегетарианец», агент должен вызвать этот инструмент с чем-то вроде text="Предпочитает вегетарианские блюда" и keywords=["диета"].

Шаг 3: рендерим state для инъекции

Перед каждым запуском агента нужно конвертировать state-объект в строки для вставки в системный промпт. Кукбук использует YAML для структурированных данных профиля и Markdown-списки для заметок памяти.

import yaml

def render_frontmatter(profile: dict) -> str:
    payload = {"profile": profile}
    y = yaml.safe_dump(payload, sort_keys=False).strip()
    return f"---\n{y}\n---"

def render_memories_md(notes: list[dict], k: int = 6) -> str:
    if not notes:
        return "- (пусто)"
    # Сортируем по дате, свежие первыми
    notes_sorted = sorted(notes, key=lambda n: n.get("last_update_date", ""), reverse=True)
    return "\n".join([f"- {n['text']}" for n in notes_sorted[:k]])

Параметр k ограничивает количество инжектируемых заметок. Это ваш главный рычаг для контроля расхода токенов. Шесть заметок хорошо работали в travel-агенте из кукбука; вам может понадобиться больше или меньше в зависимости от задачи.

Шаг 4: настраиваем хук инъекции

OpenAI Agents SDK предоставляет lifecycle-хуки, которые срабатывают в определённые моменты выполнения агента. Хук on_start срабатывает перед началом обработки, и именно здесь вы инжектируете память в контекст.

from agents import AgentHooks, Agent

class MemoryHooks(AgentHooks[AgentState]):
    async def on_start(self, ctx: RunContextWrapper[AgentState], agent: Agent) -> None:
        ctx.context.system_frontmatter = render_frontmatter(ctx.context.profile)
        ctx.context.global_memories_md = render_memories_md(
            ctx.context.global_memory.get("notes", [])
        )

Кукбук также обрабатывает флаг inject_session_memories_next_turn, который устанавливается при обрезке контекста. Это гарантирует, что сессионные заметки выживут, если история разговора будет усечена.

Шаг 5: пишем промпт с политикой памяти

Здесь вы объясняете модели, как интерпретировать инжектированную память. Кукбук оборачивает это в XML-теги и включает явные правила приоритета.

MEMORY_POLICY = """
<memory_policy>
Вы можете получить два списка памяти:
- GLOBAL memory = долгосрочные умолчания ("обычно / в целом")
- SESSION memory = переопределения для конкретной поездки ("в этот раз / сейчас")

Приоритеты и конфликты:
1) Последнее сообщение пользователя перебивает всё
2) SESSION memory перебивает GLOBAL memory при конфликте
3) Внутри одного списка предпочитать более свежее по дате
4) Трактовать GLOBAL memory как умолчания, не жёсткие ограничения

Когда задавать уточняющий вопрос:
- Только если память существенно влияет на бронирование и намерение неоднозначно
- Задавать один конкретный вопрос, не несколько

Безопасность:
- Никогда не хранить и не озвучивать чувствительные PII
- Если память кажется устаревшей или конфликтует с намерением пользователя, отдать приоритет пользователю
</memory_policy>
"""

Правила приоритета важны. Без них агент может цепляться за старое предпочтение, даже когда пользователь явно просит что-то другое. «Я обычно хочу место у прохода» не должно перебивать «дайте мне место у окна в этот раз».

Шаг 6: собираем динамические инструкции

Инструкции агента строятся динамически при каждом запуске, подтягивая отрендеренное состояние и политику памяти.

async def instructions(ctx: RunContextWrapper[AgentState], agent: Agent) -> str:
    s = ctx.context
    
    base = """Вы - лаконичный, надёжный консьерж по путешествиям.
Помогаете пользователям планировать перелёты, отели и аренду авто.
Задавайте только один уточняющий вопрос за раз.
Никогда не выдумывайте цены или наличие - укажите неопределённость, если нужно."""
    
    return (
        base
        + "\n\n<user_profile>\n" + s.system_frontmatter + "\n</user_profile>"
        + "\n\n<memories>\nGLOBAL memory:\n" + s.global_memories_md + "\n</memories>"
        + "\n\n" + MEMORY_POLICY
    )

Шаг 7: реализуем консолидацию после сессии

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

Кукбук обрабатывает это через ещё один вызов LLM, который получает оба списка заметок и выдаёт объединённый результат.

import json

def consolidate_memory(state: AgentState, client, model: str = "gpt-4o-mini") -> None:
    session_notes = state.session_memory.get("notes", [])
    if not session_notes:
        return
    
    global_notes = state.global_memory.get("notes", [])
    
    prompt = f"""
    Консолидировать эти заметки памяти в долгосрочное хранилище.
    
    ПРАВИЛА:
    1) Оставлять только устойчивые предпочтения и ограничения
    2) Убирать заметки только для сессии (фразы вроде "в этот раз", "в этой поездке")
    3) Дедупликация: убрать точные и близкие дубликаты
    4) Конфликты: оставить более свежее по last_update_date
    5) НЕ выдумывать новые факты
    
    Вернуть ТОЛЬКО валидный JSON-массив с объектами:
    {{"text": string, "last_update_date": "YYYY-MM-DD", "keywords": [string]}}
    
    GLOBAL_NOTES: {json.dumps(global_notes)}
    SESSION_NOTES: {json.dumps(session_notes)}
    """
    
    resp = client.responses.create(model=model, input=prompt)
    
    try:
        consolidated = json.loads(resp.output_text.strip())
        if isinstance(consolidated, list):
            state.global_memory["notes"] = consolidated
    except Exception:
        # Fallback: простой append, если парсинг сломался
        state.global_memory["notes"] = global_notes + session_notes
    
    state.session_memory["notes"] = []

Fallback-поведение важно. Если модель консолидации вернёт кривой JSON (такое иногда случается), вы не хотите терять сессионные заметки совсем. Добавить их сырыми лучше, чем потерять.

Собираем всё вместе

Вот как выглядит типичная сессия:

from agents import Agent, Runner

# Инициализируем state (в продакшене загружаем из базы)
state = AgentState(
    profile={"name": "John", "loyalty_status": "Gold", "seat_preference": "aisle"},
    global_memory={"notes": [
        {"text": "Предпочитает места у прохода", "last_update_date": "2024-06-25", "keywords": ["место"]}
    ]}
)

agent = Agent(
    name="Travel Concierge",
    model="gpt-4o",
    instructions=instructions,
    hooks=MemoryHooks(),
    tools=[save_memory_note],
)

# Запускаем разговор
result = await Runner.run(agent, input="Забронируй мне рейс в Париж", context=state)

# После завершения сессии
consolidate_memory(state, client)

# Сохраняем state в базу
save_state_to_db(user_id, state)

Отладка

Агент не вызывает инструмент памяти, когда должен

Docstring инструмента, вероятно, недостаточно ясен насчёт того, когда захватывать память. Добавьте более явные примеры: «Когда пользователь говорит "я вегетарианец", сохранить заметку. Когда пользователь говорит "забронируй рейс в 15:00", НЕ сохранять заметку.»

Консолидация создаёт дубликаты

Промпт дедупликации нуждается в доработке. Попробуйте добавить few-shot примеры, показывающие входные заметки и ожидаемый дедуплицированный результат. Можно также добавить постобработку, которая проверяет точные совпадения строк.

Память перебивает текущий запрос пользователя

Проверьте правила приоритета в промпте политики памяти. Убедитесь, что «последнее сообщение пользователя побеждает» указано явно и в начале. Если агент всё ещё слишком активно применяет память, попробуйте смягчить формулировки: «рекомендательный» вместо «использовать эти предпочтения».

Контекстное окно забивается старыми заметками

Уменьшите параметр k в функции рендеринга. Можно также добавить TTL-поля (время жизни) к заметкам и фильтровать всё старше, скажем, 6 месяцев при инъекции.

Что дальше

Кукбук упоминает файн-тюнинг как естественное развитие, когда накопится достаточно данных. Маленькая модель, натренированная специально на извлечение и консолидацию памяти, была бы надёжнее, чем zero-shot промптинг.

Для более глубокого погружения в управление сессиями и обрезку контекста у OpenAI есть сопутствующий кукбук по краткосрочной памяти через объект Session: Session Memory Cookbook.


СОВЕТЫ

Поле keywords в заметках не просто метаданные. Его можно использовать для фильтрации того, какие заметки инжектировать в зависимости от текущей задачи. Запрос на бронирование рейса может нуждаться только в заметках с тегами «рейс» или «место», а не «отель» или «номер».

Храните сырую расшифровку разговора рядом с консолидированной памятью. Когда будете отлаживать, почему агент дал странную рекомендацию, захотите отследить исходное высказывание пользователя, из которого появилась заметка.

Кукбук использует ISO-даты (YYYY-MM-DD) для last_update_date. Это позволяет строковому сравнению корректно работать при сортировке. Не используйте локальные форматы дат.


FAQ

Почему не использовать векторную базу для памяти? Векторный поиск добавляет задержку и вносит сбои при поиске. Для агентов с предсказуемыми потребностями в памяти (предпочтения, ограничения, ID) state-based память надёжнее. Ограничение в том, что вы ограничены тем, что влезает в контекст, обычно несколько десятков заметок.

Можно использовать этот паттерн с Claude или другими моделями? Логика управления состоянием и инъекции работает с любой LLM. RunContextWrapper и хуки специфичны для OpenAI Agents SDK, но базовый паттерн переносим. Вам нужно будет реализовать эквивалентное управление жизненным циклом для других фреймворков.

Как обрабатывать память, которая должна истекать? Добавьте TTL-поле к заметкам и фильтруйте при инъекции. Кукбук не реализует это явно, но паттерн поддерживает. Что-то вроде created_at плюс проверка против текущей даты.

Что если модель консолидации галлюцинирует новое предпочтение? Это реальный риск. Промпт кукбука говорит «НЕ выдумывать новые факты», но zero-shot compliance не идеален. Рассмотрите добавление шага валидации, который проверяет, можно ли отследить каждую консолидированную заметку до исходной.


РЕСУРСЫ

Илья Новиков

Илья Новиков

Главный объяснитель

Илья тот самый человек, которому друзья пишут, когда ломается Wi-Fi, код не компилируется или инструкции к мебели выглядят как загадка. Теперь он направляет этот опыт в практичные гайды, которые помогают тысячам читателей решать проблемы спокойно и без паники.

Похожие статьи

Будьте впереди в мире ИИ

Получайте последние новости, обзоры и скидки ИИ прямо на почту. Присоединяйтесь к 100 000+ энтузиастов ИИ.

Подписываясь, вы соглашаетесь с нашей Политикой конфиденциальности. Отписаться можно в любое время.