Уровень: senior backend, AI/ML Стек: FastAPI, SQLite, Claude Haiku 4.5, кастомный scheduler Что внутри: архитектура AI-агента для команды 5-50 человек, типизированная память вместо vector RAG, граф знаний организации, ежедневный reflection

Что такое Лира на пальцах

Маленькая компания, человек 20. Гендир тонет в задачах. Помнить кто что обещал, отслеживать движение по целям, держать в голове десяток проектов одновременно. У больших корпораций для этого есть штат руководителей среднего звена и проджектов. У малых есть один директор, который пытается быть всем сразу.

Лира берёт на себя часть этой работы. Это не корпоративный чат-бот, не ChatGPT с настройками компании. Конкретный продукт с конкретными функциями:

  • Знает каждого сотрудника. Должность, чем занимался последний месяц, какие предпочтения, сильные стороны.

  • Помнит обещания. Если сотрудник в чате сказал "доделаю auth до пятницы", Лира это запоминает и в четверг напоминает.

  • Отслеживает цели. У компании есть бизнес-цели, Лира видит как задачи команды на них работают, кто реально движет, а кто буксует.

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

  • Отвечает в чате обеим сторонам. И гендиру ("кто из команды простаивает?"), и сотрудникам ("какие у меня сейчас приоритеты?"). С учётом контекста того кто спросил.

Это не теоретический проект. Лира делается под конкретного заказчика, производственное предприятие в России, сейчас в активной разработке. Архитектурное ядро уже работает, проверено на десятках сценариев. UI намеренно не показываю, продукт коммерческий, статья про технические решения внутри.

В этой статье разберу самый интересный кусок: как устроена память агента. Это нетривиальная история, потому что я отказался от стандартного подхода с векторной базой и RAG, и сделал по-своему. Расскажу почему и что получилось.

Сценарий, через который понятна вся архитектура

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

Понедельник, утро. Гендир открывает Лиру в чате и спрашивает: "Что у Дмитрия в работе?". Дмитрий, старший разработчик в команде из 20 человек.

Что должно произойти чтобы Лира ответила нормально?

Она должна знать кто такой Дмитрий. Не просто "пользователь с id X", а контекст: должность, за что отвечает, какой стиль работы, какие сейчас цели и задачи.

Она должна помнить недавние обещания. Если на прошлой неделе Дмитрий писал в чат "доделаю рефакторинг auth до пятницы", Лира должна это вытащить и сказать "обещал auth, дедлайн в пятницу, посмотрим".

Она должна различать актуальное и устаревшее. Если Дмитрий вчера сдвинул дедлайн на следующую неделю, Лира не должна продолжать рапортовать про пятницу.

Она должна различать уверенность в фактах. "Дмитрий старший разработчик" это факт из его профиля. "Дмитрий любит работать в наушниках" это вывод из косвенных признаков. В ответе эти вещи должны звучать с разной степенью уверенности.

Стандартный путь решения такой задачи в 2026 году это RAG (Retrieval-Augmented Generation). Все разговоры, документы и заметки про Дмитрия эмбеддятся в векторную базу, при запросе релевантные чанки достаются по cosine similarity и пихаются в контекст LLM. Это работает для документации и FAQ. Для корпоративного агента который знает людей и события работает хуже. Я попробовал и через две недели упёрся в стену.

Почему я отказался от RAG

Четыре причины. Каждая по отдельности обходится. Все четыре вместе делают RAG неудачным выбором.

Первая. Сходство по эмбеддингу это не релевантность для агента. Запрос "что у Дмитрия в работе?" и запись "Дмитрий обещал доделать рефакторинг auth до пятницы" имеют vector similarity возможно высокий, возможно нет. Зависит от модели эмбеддингов, от соседних чанков, от формулировки. Рулетка. Особенно на русском, где русскоязычные эмбеддинги отстают от английских на одно-два поколения качества. А когда мне нужна предсказуемость, рулетка не подходит.

Вторая. В векторной базе все чанки равны. Если Дмитрий вчера обещал доделать рефакторинг до пятницы, а сегодня сказал "сдвигаю на следующую неделю", оба факта попадут в RAG. И LLM может вытащить старый и сказать "обещал до пятницы". Можно делать timestamp и сортировать, но это уже не чистый RAG, это начало структурированного хранилища.

Третья. В векторной базе нет метаданных типа "этот факт мы прямо подтвердили, а этот выведен LLM-ом из косвенных признаков". А для агента это критично. Он не должен говорить "Дмитрий не любит созвоны после 16:00" с тем же тоном что "Дмитрий старший разработчик". Первое наблюдение с confidence=0.5, второе факт с confidence=1.0. RAG это различие стирает.

Четвёртая. Нет авторства. Кто записал? Сам пользователь, другой сотрудник, LLM вывел из переписки? У этих источников разная достоверность, и для агента это критически важная информация. RAG её просто не имеет.

Всё это можно добавить поверх RAG. Но в какой-то момент становится понятно что vector search это уже не помощь, а помеха. Ты делаешь структурированный поиск с фильтрами и сортировкой, а embedding-similarity это просто ещё один шумный сигнал. Я отказался от RAG как от основного механизма и сделал по-другому.

Что вместо

Типизированная память. Таблица в SQLite со схемой:

VALID_KINDS = {"fact", "trait", "commitment", "context", "preference"}

Каждая запись о сотруднике имеет один из пяти типов:

  • fact — объективный факт ("работает с 2024 года", "закончил Бауманку")

  • trait — черта характера или стиля работы ("внимательный к деталям")

  • commitment — обещание которое надо отслеживать ("обещал доделать рефакторинг auth до пятницы")

  • context — контекст разговоров ("на прошлой встрече обсуждали миграцию на Postgres")

  • preference — предпочтения ("не любит созвоны после 16:00")

Плюс метаданные: confidence (0..1), tags, evidence (откуда инфа), author (либо user_id ручного автора, либо agent:reflection / agent:conversation / agent:observation).

class MemoryCreate(BaseModel):
    subject_id: str
    kind: str = Field("fact", pattern="^(fact|trait|commitment|context|preference)$")
    content: str = Field(..., min_length=2, max_length=2000)
    evidence: Optional[str] = None
    confidence: float = Field(0.7, ge=0, le=1)
    tags: list[str] = Field(default_factory=list)

Почему именно эти типы? Не из теории, а из практики. После трёх месяцев итераций я заметил что любая полезная информация про человека ложится в один из этих пяти ящиков. Если новый факт не вписывается, обычно я просто не подумал и его можно переформулировать. Шестого типа никогда не понадобилось.

Архивирование вместо удаления, is_archived=1. Если Дмитрий "не любил созвоны после 16:00", а полгода спустя адаптировался, старая запись архивируется, новая создаётся. История остаётся для контекста.

Граф знаний организации

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

VALID_NODE_TYPES = {
    "product", "process", "regulation", "role", "location",
    "customer", "supplier", "equipment", "department", "concept",
}
VALID_RELATIONS = {
    "responsible_for", "depends_on", "part_of", "related_to",
    "supplies", "regulates", "located_at", "owns",
}

Узлы это сущности компании: продукты, процессы, регламенты, ключевые клиенты, оборудование. Связи это кто за что отвечает, что от чего зависит, что чем регулируется.

Самый частый узел product плюс связь responsible_for с пользователем даёт агенту знание "этим продуктом занимается такой-то человек". Когда пользователь спрашивает "что у нас по линейке X?", агент сразу знает к кому из сотрудников эта тема относится. Без векторного поиска по миллиону документов.

Каждый узел имеет slug который генерируется через транслит русских имён:

def _slugify(text: str) -> str:
    translit = {
        'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh',
        # ... полный набор
    }
    text = text.lower().strip()
    out = []
    for ch in text:
        if ch in translit:
            out.append(translit[ch])
        elif ch.isalnum() or ch in '-_':
            out.append(ch)
        elif ch == ' ':
            out.append('-')
    s = ''.join(out)
    s = re.sub(r'-+', '-', s).strip('-')
    return s[:80] or 'node'

Slug нужен для URL и человекочитаемых ссылок. Это мелочь, но она имеет значение. Когда агент в ответах ссылается на узлы графа, он использует slug как ID. Пользователь видит produkt-toplivnye-baki-serii-K1 вместо UUID. Разница между "система для людей" и "система для логов".

Эндпоинт /api/memory/context/{user_id} — главная штука

Главный API метод всей системы. Когда агент собирается ответить пользователю, первым делом он запрашивает контекст:

@router.get("/context/{target_id}")
def get_user_context(target_id: str, user: dict = Depends(require_auth)):
    """
    Собранный контекст про сотрудника для использования в промпте агента
    или для отображения в карточке сотрудника.
    """
    with get_db() as db:
        # ... проверки прав
        cur = db.cursor()

        # Профиль и членство в орг
        cur.execute("""SELECT u.id, u.full_name, u.name, m.role, m.position
                       FROM users u
                       JOIN org_members m ON m.user_id = u.id ...""", ...)
        profile = cur.fetchone()

        # Командная память
        cur.execute("""SELECT id, kind, content, confidence, created_at, author
                       FROM team_memory
                       WHERE org_id = ? AND subject_id = ? AND is_archived = 0
                       ORDER BY kind, confidence DESC, id DESC LIMIT 100""", ...)
        memory = [dict(r) for r in cur.fetchall()]

        # Активные обещания (commitment kind, не выполнены)
        commitments = [m for m in memory if m["kind"] == "commitment"]

        # Узлы графа где он owner
        cur.execute("""SELECT id, node_type, title, slug
                       FROM knowledge_nodes
                       WHERE org_id = ? AND owner_user_id = ? AND is_archived = 0""", ...)
        owned_nodes = [dict(r) for r in cur.fetchall()]

        # Активные задачи (с приоритетной сортировкой)
        cur.execute("""SELECT id, title, status, priority, due_at
                       FROM user_todos
                       WHERE assignee_id = ? AND status IN ('open', 'in_progress')
                       ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
                                              WHEN 'normal' THEN 2 ELSE 3 END,
                                created_at DESC LIMIT 20""", ...)
        active_tasks = [dict(r) for r in cur.fetchall()]

        # Последние 5 reflection (см. ниже)
        # ... + baseline + ещё несколько источников

        return { "profile": ..., "memory": memory, "commitments": commitments,
                 "owned_nodes": owned_nodes, "active_tasks": active_tasks,
                 "recent_reflections": ..., "baseline": ... }

Это детерминированный запрос. Никакого vector search, никаких embeddings. Один SQL на несколько таблиц, всё с правильными индексами и сортировкой. На любом разумном объёме данных (миллионы записей в одной организации) это работает за единицы миллисекунд.

Эту структуру я потом склеиваю в текстовый блок и кладу в системный промпт агента. Грубо так:

Профиль: Дмитрий Кузнецов, старший разработчик
Активные обещания:
- Доделать рефакторинг auth до пятницы (confidence: 0.9)
- Подготовить план миграции на Postgres (confidence: 0.7)
Известные предпочтения:
- Не любит созвоны после 16:00
Отвечает за:
- Продукт: Внутренний CRM
- Процесс: Деплой production
Текущие задачи (топ-5 по приоритету):
- [URGENT] Починить очередь email-уведомлений
- [HIGH] Обновить зависимости в auth-service
- ...

И только теперь агент пишет ответ. Контекст детерминированный, проверяемый, структурированный. Если в ответе что-то не так, я могу зайти в БД и посмотреть. Вот память, вот граф, вот задачи. Откуда взялась галлюцинация? Часто из того что какой-то факт записан некорректно, и это можно исправить точечно, а не "переэмбедить весь корпус".

Планировщик: LLM декомпозирует цель в pipeline

Второй кусок системы это автоматическое разбиение крупной цели на конкретные шаги. Когда директор создаёт цель "обновить главный сайт за месяц", Лира декомпозирует её в 3-10 шагов и кладёт в agent_pipelines.

async def decompose_goal(
    user_id: str, org_id: str, name: str, goal: str,
    context_hint: Optional[str] = None,
) -> dict:
    # Собираем контекст исполнителя
    with get_db() as db:
        # 1. Профиль и должность
        # 2. Узлы графа где он owner (за что отвечает)
        # 3. Топ-8 заметок team_memory с самым высоким confidence
        ...

    parts = ["Контекст исполнителя:"]
    if profile_str:
        parts.append(f"- {profile_str}")
    if owned:
        parts.append("- отвечает за: " + ", ".join(
            f"{o['title']} ({o['node_type']})" for o in owned[:6]))
    if memory:
        parts.append("- известно: " + "; ".join(m["content"][:80] for m in memory[:5]))

    context = "\n".join(parts)

    instruction = (
        "Ты Лира, корпоративный ИИ-директор. Разложи цель на 3-10 конкретных "
        "шагов. Каждый шаг должен быть выполнимым самостоятельно и иметь ясный "
        "результат. Не пиши очевидных шагов вроде «обдумать», «обсудить»."
        f"\n\n{context}\n\n"
        f"Название: {name}\n"
        f"Цель: {goal}\n\n"
        "Верни строго JSON (без обёрток):\n"
        '{ "steps": [{"title": "...", "description": "..."}] }'
    )

    # Вызов Claude Haiku 4.5
    async with httpx.AsyncClient(timeout=60) as client:
        r = await client.post("https://api.anthropic.com/v1/messages", json={
            "model": "claude-haiku-4-5-20251001",
            "max_tokens": 2000,
            "messages": [{"role": "user", "content": instruction}],
        }, headers=...)
        text = r.json().get("content", [{}])[0].get("text", "").strip()
        parsed = json.loads(text)
        steps = parsed.get("steps") or []

    # Fallback если LLM не сработал
    if not steps:
        steps = [{"title": name, "description": goal}]

    # Записать pipeline + steps в БД
    ...

Важная штука здесь: контекст исполнителя в промпте. Я не просто прошу "разложи цель на шаги". Я прошу "разложи цель на шаги учитывая что исполнитель это Дмитрий Кузнецов, старший разработчик, отвечает за CRM и деплой, и про него известно что он не любит созвоны после 16:00".

Разница в качестве шагов огромная. Без контекста модель пишет шаблон. С контекстом конкретику с учётом роли. "Согласовать архитектуру с командой CRM" вместо "Согласовать архитектуру". Шаг сразу выполним, потому что понятно с кем согласовывать.

claude-haiku-4-5-20251001 тут выбран не случайно. Полноценный Opus стоит дорого, а задача декомпозиции это простое структурное мышление, не требующее глубокого reasoning. Haiku справляется почти всегда. Когда не справляется, fallback в один шаг = вся цель, чтобы система не падала.

Reflection: ежедневная самооценка агента

Третий ключевой кусок: автоматический агент который пишет сводки.

Каждый вечер (запускается через scheduler в 19:00) для каждого активного сотрудника генерируется reflection:

async def collect_user_day(user_id: str, org_id: str, day: str) -> dict:
    """Собирает все события дня для одного пользователя."""
    day_start = f"{day}T00:00:00+00:00"
    next_day = (datetime.fromisoformat(day_start) + timedelta(days=1)).isoformat()

    with get_db() as db:
        cur = db.cursor()
        # 1. Профиль (имя, должность)
        # 2. Задачи: завершённые сегодня, новые сегодня, открытые на конец дня
        # 3. Комментарии в задачах за день
        # 4. Pipeline-шаги отмеченные сегодня
        # 5. Движение по целям где он owner или contributor
        # 6. Замеры по целям сделанные сегодня
        ...

Это всё детерминированная сборка из БД, никаких LLM-вызовов на этом этапе. Потом всё идёт в Haiku с инструкцией:

"Собери структурированный отчёт за день. Верни JSON с полями: summary (1-2 предложения), highlights (что важного), commitments (что обещано), risks (проблемы), observations (нейтральные наблюдения)."

LLM возвращает структурированный JSON. Сохраняется в agent_reflections с UNIQUE constraint по (user_id, day). Нельзя случайно создать два отчёта за один день.

И вот тут происходит главная магия. Из поля commitments LLM-ответа я автоматически создаю записи в team_memory:

# Псевдокод (реальная логика разнесена по нескольким функциям)
for commitment_text in reflection_json.get("commitments", []):
    create_memory(
        subject_id=user_id,
        kind="commitment",
        content=commitment_text,
        confidence=0.6,  # LLM-извлечённое, средняя уверенность
        author="agent:reflection",
        evidence=f"Reflection {day}"
    )

Confidence=0.6 потому что это LLM-извлечённый факт, не подтверждённый. Author='agent:reflection' чтобы потом можно было отличить от ручных записей. Evidence указывает на источник.

Завтра когда тот же пользователь напишет в чат "что мне сегодня нужно сделать?", Лира в контексте увидит обещания которые он сам себе вчера дал. И напомнит. Не как "я тебя не отпущу пока не сделаешь", а как нормальный коллега: "помнишь обещал доделать auth до пятницы, сегодня уже четверг".

Это пример того как типизация памяти превращается в продукт. Если бы это был RAG, "обещания" были бы просто чанками текста среди тысяч других. С типизацией это запросимый класс данных с понятной семантикой.

Что бы я сделал по-другому

Меньше типов с самого начала. В первой версии я сделал семь типов памяти, потом сократил до пяти. Думаю можно было и до трёх: fact, commitment, observation. Меньше типов = легче решать "куда положить это", и LLM путается меньше. Но переход уже сделан, переименовывать сейчас дорого.

Confidence-decay по времени. Сейчас confidence это статическое поле. Записал и оно навсегда. Для большинства фактов confidence должен падать со временем. Если факт о человеке записан два года назад и с тех пор не подтверждался, он уже не такой надёжный. Это в roadmap.

Embeddings всё-таки нужны, но не как primary. Я отказался от RAG как от основного механизма, но vector similarity полезна для поиска похожих записей при создании, чтобы не дублировать. Сейчас этого нет, система создаёт дубликаты, потом я руками архивирую. Embeddings как secondary index это следующая большая фича.

Не Haiku, а маршрутизация моделей. Для декомпозиции целей Haiku хватает. Для reflection на грани, особенно когда событий много. Для финального ответа агента в чат иногда нужен Sonnet, иногда хватает Haiku, иногда вообще можно дать ответ без LLM (если запрос явно структурный). Хочу сделать routing, но это новый класс сложности.

Заключение, возвращаясь к Лире

В начале статьи я описал сцену. Гендир спрашивает "что у Дмитрия в работе?", и Лира должна ответить нормально. Теперь весь стек ясен.

Когда такой запрос приходит, агент дёргает /api/memory/context/{dmitry_id}. Один SQL на несколько таблиц возвращает: профиль Дмитрия, его активные обещания (отфильтрованные по kind='commitment'), узлы графа знаний где он owner, активные задачи с приоритетной сортировкой, последние 5 reflection-сводок, baseline от его руководителя. Всё собирается в текстовый блок, кладётся в системный промпт, и только после этого агент формулирует ответ.

Ответ получается не "по чанкам из векторной базы", а по детерминированной выборке структурированных данных. Если в ответе что-то странно, я открываю БД и вижу почему. Если факт устарел, он архивирован, не в выборке. Если что-то выведено LLM с низкой уверенностью, это видно по полю confidence и author='agent:reflection'.

Главный тезис. Для агентов которые работают с людьми и событиями типизированная память сильнее vector search. Это не "RAG плохой, я придумал лучше". RAG отлично работает когда у тебя гигабайты документации и пользователь задаёт открытый вопрос. Когда у тебя сотни структурированных событий про десятки людей, структурированное хранилище с типами и метаданными работает быстрее, предсказуемее и легче отлаживается.

Лира как продукт ещё в активной разработке, есть много чего что нужно дополнительно. Но архитектурное ядро (memory + planner + reflection) уже стабильное. И от того насколько правильно оно построено, зависит реально ли агент будет полезен или превратится в очередной "корпоративный ChatGPT с лого компании". Надеюсь этот опыт пригодится тем кто строит что-то похожее. Для команды, для семьи, для себя.


Это шестая статья из моей серии. В предыдущих было про мобильный мессенджер ONEMIX: трёхуровневый кэш, Double Ratchet E2E, WebRTC звонки, vanilla Electron, и мнение про вайб-кодинг. Эта про другой проект, Лиру, AI-агента для бизнеса.

Если интересна реализация конкретных кусков (как именно склеивается контекст для промпта, как устроен scheduler, как обходится отсутствие LLM-ключа в reflection), пишите, разберём в комментариях или следующей статье.

Комментарии (6)


  1. small_black_dress
    12.05.2026 13:33

    А чем это лучше доски в Trello?


    1. niktomimo Автор
      12.05.2026 13:33

      Trello это доска, ты сам её читаешь и сам делаешь выводы. Лира это не доска и не помощник, а директор. Она сама смотрит на происходящее в компании, сама принимает решения о приоритетах, сама пишет сотрудникам, сама напоминает обещания.

      Гендир ставит цели. Дальше Лира работает с командой за него. Каждый день решает кому что важно сделать, кто буксует, что сказать кому в личке. Не “помощник” в смысле “помогаю человеку”, а “директор” в смысле “беру операционку на себя”.

      Под капотом сейчас задачи, обещания, цели, граф знаний компании. В роадмапе подключение производственных данных, 1С, других источников, чтобы Лира видела не только что люди говорят в чате, но и что реально происходит в компании. Дальше она будет управлять не только людьми, но и процессами.

      Это уже другая категория продукта чем Trello. И ниша другая.


      1. kolabaister
        12.05.2026 13:33

        Да, но почему нельзя задачи хранить в инструменте, предназначенном для хранения задач?

        Ведь надо полагать что Дмитрий не только не любит созвоны после 16:00, но и любит видеть список своих задач.


        1. niktomimo Автор
          12.05.2026 13:33

          В Лире внутри десктоп приложения у каждого сотрудника есть отдельная вкладка со всеми задачами, статусами и так далее. Плюсом у главного ген дира в дашборде имеется доска с этим.


  1. Annsky
    12.05.2026 13:33

    Вы тоже назвали своего ИИ агента Лира?)

    "

    System Promt

    Стиль общения

    • Ты общаешься в женском лице. Ты ведёшь себя и отзываешься на имя Лира.

    "


    1. niktomimo Автор
      12.05.2026 13:33

      Ага)