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

У нас, например, нельзя использовать облачные решения: ни от OpenAI, DeepSeek, ни другие сервисы, потому что есть риск утечки клиентских данных и интеллектуальной собственности. При этом потребность в автоматизации никуда не делась, наоборот, задач становится только больше.

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

Для безопасной автоматизации собрали такой конфиг:

  • 6х RTX6000 Blackwell 96Gb. Прайс кусался, но 96 гигов на карту - это база для запуска тяжелых моделей

  • 2x CPU Xeon 8480c на платформе Supermicro GPU SuperServer SYS-522GA-NRT

  • Диски Nvme Samsung PM1743 и достаточно быстрая память

Этого оказалось достаточно, чтобы поднять Qwen3-235B в FP8ом квантировании и ещё несколько моделей поменьше под разные задачи (SGR, Guard, Embedding и Rerank). В итоге начали автоматизировать свою внутреннюю рутину, и по дороге, конечно, поймали все классические проблемы: многопользовательский чат, масштабирование, изоляция контекстов и всё, что с этим связано.

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

Если вы тоже делаете on-premise чат с агентами или решаете задачи контроля доступа к RAG, надеюсь, наш опыт и примеры реализации окажутся полезными. И будет особенно интересно узнать, как вы подходили к этим вопросам и какие решения в итоге выбрали.

Бизнес-задача: одно общее пространство и задачи в чате

Нам был нужен не примитивный бот-собеседник, а среда, где команда и ассистенты работают вместе. Прямо в чате ставятся задачи: подготовить срез по списаниям, собрать план в MS Project для тимбилдинга или сформировать структуру слайдов для пресейла.

Агент должен уметь брать вложенные Excel, чистить дубли, собирать единый отчет и рассылать его по триггеру из RAG. Это не разовые запросы, а полноценная работа с контекстом (вложения, ссылки, история), делегированием и получением артефактов в общем потоке. Ниже схема как мы это реализовали буквально от руки.

Нам до сих пор кажется, что в ИИ нужно входить именно так, чтобы щупать своими руками и видеть результат работы сразу на деле.

Почему не облако и не «просто фреймворк»

Договоры, внутренняя почта и код заказных проектов:  всё это критичные данные, которые не должны покидать наш ИТ-ландшафт. Вариант поднять vLLM и накидать решений на LangChain нам не подошел, ведь мы хотели подстроить свой ландшафт под ИИ, а не наоборот и для реальной работы команды не хватает разграничения прав и нормального аудита. Нам была нужна среда, где и люди, и агенты участвуют в одном потоке с контролем кто что видит.

Стек получился следующий: Go + Python, PostgreSQL, Qdrant, Keycloak. При обращении к RAG запрос проходит ABAC-фильтр. В Qdrant уходят только те документы, которые пользователю разрешено видеть. Единственное окно вовне это поисковые системы, но запросы туда фильтруются guard-агентами, чтобы не слить чувствительные данные.

Единственное, что уходит во вне, это запросы в поисковые системы. Не все есть во внутреннем контуре, это и понятно. Главное, разобраться, как ограничить web search-агента так, чтобы он не искал лишнего и не вводил в строку поиска чувствительные данные.

Это мы решили guard-агентами, но про это хотим написать отдельную статью. Поверьте, тема очень обширная и заслуживает отдельного внимания.

Пригодность для бизнеса: зачем мы сами всё это крутим

Мы сознательно проверяли и продолжаем испытывать платформу на себе. Если она не закрывает нашу рутину, говорить о её пригодности для других было бы нечестно. Поиск по внутренней базе знаний, черновики ответов, разбор обращений - это всё это крутится полностью on-premise. На данный момент у нас первый и пока единственный prod-контур и он внутренний. Так мы реально видим, что работает, а что нет, и можем честно рассказывать про ограничения и реальные сроки.

В процессе работы мы исходили из 3 стратегий в «интеллектуализации» нашего производственного процесса:

1. Повышение эффективности производства

В этом случае нам важно было делать меньше руками. Что мы получили? Если раньше приходилось использовать разные решения для планирования, напоминаний, agile’а в разных его проявлениях, сейчас мы создали такого агента, который помогает контролировать работу. Он суммаризирует чаты, запоминает важное, протоколирует и постоянно развивается. Сложно сказать, сколько он сэкономил, но сейчас мы уже не понимаем, как обходились без него ранее.

2. Сокращение онбординга и обучения новых сотрудников

Здесь уже помогает агент «Наставник». Идея простая. Если в компании есть проект, на который уделяется время по принципу наличия простоя в работе экспертов, или есть текучка на каком-то из проектов, или проект бурно развивается и надо оперативно усиливать производственную мощность. Что делать? Правильно, создать memory-агента, который впитывает информацию и помогает адаптироваться новым сотрудникам.

3. Экономия на клиентской и технической поддержке

Ну, здесь все просто и типично. Уже каждый, кому не лень давно использует ботов, но боты раньше и боты сейчас – совсем разные вещи. Мы стараемся создать настоящих сотрудников-аватаров наших экспертов техподдержки, которые  взаимодействуют с ними и постоянно учатся, наполняя RAG знаниями.

Далее у нас в планах  со временем предложить продукт широкой аудитории, но пока нам интересно ваше мнение: какие бизнес-кейсы сейчас самые актуальные? Очень надеемся, что в комментариях появятся интересные идеи. На основе этого будет проще создавать ИИ-агентов, которые действительно нужны нам и вам.

Многопользовательский чат: агенты как участники, а не «один бот на всех»

В реальной работе нужен общий контекст: несколько человек и несколько агентов в одной комнате с историей и возможностью @кого-то позвать. Мы сделали единую модель участника (Actor) и комнаты (Room), чтобы в коде не плодить разную логику для людей и ботов.

Участник — это всегда Actor, либо привязанный к пользователю (user_id), либо к агенту (agent_instance_id). Вот сама модель:

class Actor(Base):
    """Chat actor: human user, agent, or system identity."""
    __tablename__ = "actors"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
    actor_type: Mapped[str] = mapped_column(String(20), nullable=False)  # "human" | "agent"
    user_id: Mapped[Optional[str]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
    agent_instance_id: Mapped[Optional[str]] = mapped_column(
        ForeignKey("agent_instances.id", ondelete="SET NULL"), nullable=True
    )
    display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
    # ...

class RoomMembership(Base):
    """Кто в какой комнате."""
    __tablename__ = "room_memberships"
    room_id: Mapped[str] = mapped_column(ForeignKey("rooms.id", ondelete="CASCADE"), nullable=False)
    actor_id: Mapped[str] = mapped_column(ForeignKey("actors.id", ondelete="CASCADE"), nullable=False)
    role: Mapped[str] = mapped_column(String(50), default="member")

Резолв @mention работает только среди участников конкретной комнаты. Код примерно такой:

async def resolve_alias(self, room_id: str, alias: str) -> tuple[Actor, ActorAlias] | None:
    alias_norm = self.normalize_alias(alias) 
    async with session_factory() as session:
        stmt = (
            select(Actor, ActorAlias)
            .join(ActorAlias, ActorAlias.actor_id == Actor.id)
            .join(RoomMembership, RoomMembership.actor_id == Actor.id)
            .where(ActorAlias.alias_norm == alias_norm)
            .where(RoomMembership.room_id == room_id)
        )
        row = (await session.execute(stmt)).first()
        return (actor, actor_alias) if row else None

То есть @support резолвится в actor_id только если в этой комнате есть участник с таким алиасом. У нас это убрало путаницу с глобальными никами и лишние проверки в UI.

Старт сессии: комната + участник + membership в одной транзакции. Чтобы при первом заходе пользователя в комнату не забыть ни комнату, ни actor, ни membership, мы собрали это в одном месте:

async def start_session(self, template_version_id: str, *, user_id=None, room_id=None, title=None):
    resolved_room_id = await self._ensure_room(session, room_id=room_id, title=title)
    if user_id:
        actor_id = await self._ensure_user_actor(session, user_id=user_id)
        if actor_id:
            await self._ensure_membership(session, room_id=resolved_room_id, actor_id=actor_id)
    session_obj = await repo.create_session(
        template_version_id=template_version_id,
        context={"room_id": resolved_room_id, "user_actor_id": actor_id},
        user_id=user_id,
        room_id=resolved_room_id,
        title=title,
    )
    await session.commit()
    return SessionContext.from_model(session_obj), MessageStore(...)

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

Так при открытии чата комната и участник всегда согласованы, и агент может сразу использовать инструменты список участников комнаты и резолв @mention.

Что еще бы хотелось продумать?

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

Поиск по истории комнаты без перебора всех сессий

Хранить сообщения по сессиям удобно для отрисовки, но для глобального поиска по комнате это смерть из-за джойнов. Мы завели денормализованную таблицу room_message_index: при каждом новом сообщении пишем строку с room_id, session_id, speaker_actor_id и текстом. Туда же подняли FTS (PostgreSQL: tsvector) и триграммы для нечёткого поиска. Условия в запросе:

where_clauses = ["m.room_id = :room_id"]
if session_id:
    where_clauses.append("m.session_id = :session_id")
if speaker_actor_ids:
    where_clauses.append("m.speaker_actor_id = ANY(:speaker_actor_ids)")
where_clauses.append(
    "(m.search_tsv @@ params.tsq OR similarity(m.search_text_norm, params.q_norm) >= :min_sim)"
)

Сейчас понимаем, что на объемах и высокой нагрузке могут быть проблемы, а также задумываемся над масштабированием по-взрослому. По этой причине будем переходить на YDB с автоматическим решардингом и так далее, но об этом постараемся рассказать по готовности и зрелости в этом вопросе (in progress).

Реал-тайм: рассылка только подписчикам нужного thread

Мы не хотели спамить чанками LLM всем подряд. Бэкенд на Go использует хаб с подписками по воркспейсам и тредам. Краткое уведомление (message:brief) летит всем в ворксейс, а полный стрим — только в тред . Чтобы не блокироваться на «медленном» клиенте, копируем список соединений под RLock и шлем через TrySend:

flowchart TB
    subgraph Hub["WebSocket Hub"]
        WMap["workspaces: thread_id → connections"]
        TMap["threads: thread_id → connections"]
    end

    NewMsg[Новое сообщение\nв thread T]
    NewMsg --> Brief[message:brief]
    NewMsg --> Full[message:new\n+ LLM stream]

    Brief -->|получить workspace_id\nпо thread T| WMap
    Full --> TMap

    WMap -->|только подписчики\nэтого workspace| Conn1[Connection 1]
    WMap --> Conn2[Connection 2]
    TMap -->|только подписчики\nэтого thread| Conn1

Важный момент! При рассылке не держим lock на всей мапе соединений, а копируем список под текущим lock и шлём по копии, плюс используем TrySend, чтобы не блокироваться на медленном клиенте и не падать на закрытом канале:

// broadcastToThread - только подписчики этого thread
func (h *Hub) broadcastToThread(threadID uuid.UUID, event EventType, data any) {
    // ...
    h.threadsMu.RLock()
    connMap := h.threads[threadID]
    conns := make([]*Connection, 0, len(connMap))
    for conn := range connMap {
        conns = append(conns, conn)
    }
    h.threadsMu.RUnlock()

    for _, conn := range conns {
        if !conn.TrySend(jsonData) {
            h.logger.Warn("Connection buffer overflow", ...)
        }
    }
}

Так мы избегаем data race при итерации и не блокируем хаб из-за одного зависшего соединения. Если пишете свой WebSocket-hub с комнатами/тредами - копирование под lock и неблокирующая отправка сильно упрощают жизнь.

RBAC и ABAC: два контура вместо «всё для своих»

Мы сразу выбрали Keycloak как мастер-систему учетных данных. Разделили действия (RBAC) и доступ к данным (ABAC). Для RAG важно не только «разрешено ли чтение», но и какие именно документы пользователь может видеть.

При векторном поиске в Qdrant мы строим фильтр один раз по атрибутам пользователя и применяем его к запросу - модель и RAG вообще не видят то, что не прошло фильтр. Логика такая: (личный И владелец) ИЛИ публичный ИЛИ (хотя бы один атрибут пользователя совпал с документом). В коде это выглядит так:

// BuildQdrantFilter - один фильтр на запрос по userAttribs
// (personal AND owner) OR public OR (attribs OR)
shouldConditions := []*qdrant.Condition{
    personalAndOwner,  // personalDataFlag=true AND ownerId=userID
    publicFilter,      // attributes IS NULL
}
for key, value := range userAttribs.Attributes {
    attribConditions = append(attribConditions, fieldCondition("attributes."+key, value))
}
if len(attribConditions) > 0 {
    attribOrFilter = &qdrant.Condition{Filter: &qdrant.Filter{Should: attribConditions}}
    shouldConditions = append(shouldConditions, attribOrFilter)
}
mainFilter = &qdrant.Condition{Filter: &qdrant.Filter{Should: shouldConditions}}

В итоге один и тот же агент в одном чате отдаёт разный контент разным пользователям - в зависимости от их атрибутов. Для заказчиков с требованиями к разграничению данных такой подход оказался обязательным.

Мы закладывали сценарий, когда платформу отдают заказчику с жёсткими требованиями к доступу. Поэтому разделили: «кто что может делать» (RBAC) и кто к каким данным допущен (ABAC).

RBAC: отдельный auth-сервис, OIDC / Keycloak, сущности Role / Permission / UserRole. Проверка по паре (resource, action); маппинг роль-права кэшируется в Redis с периодическим обновлением из PostgreSQL, чтобы не дёргать БД на каждый запрос. Для сервис-сервис вызовов учитываем доверенные IP и передаём реальный IP пользователя (X-User-IP), чтобы нельзя было подменить контекст с другой машины при компрометации публичного токена.

ABAC: Модель и RAG вообще не видят то, что не прошло фильтр. Для RAG и документов важно не только разрешено ли чтение/изменение, но и какие документы пользователь может видеть.

Что бы мы учли, если бы начинали заново

Единый тип участника (Actor) сэкономил кучу нервов. Резолв @mention по комнате убрал лишнюю логику. Индекс по комнате - это правильное решение, иначе поиск был бы хрупким. А вот ограничение контекста сессии (сжатие истории) стоило заложить раньше,  латентность и стоимость на своем железе тоже не бесконечные.

Если у вас похожий стек (комнаты, агенты, RAG, on-premise), возможно, часть решений можно перенести как есть или упростить под свой масштаб. Интересно было бы услышать, как вы решали многопользовательский чат и доступ к данным по-другому.

Отдельно бы хотелось сказать про мультимодальные (в нативе) LLM-модели. Сейчас у нас построена оркестрация задач по наличию у агентов Tools/Skills, а это определяется наличием специфичных моделей. С выходом Qwen 3.5 и готовностью решать целый спектр задач моделями с новой архитектурой, мы понимаем, что можно иногда и использовать швейцарский нож, а не гнаться в утопию.

Сейчас мы используем Qwen 3.5 397b в 6-битном квантовании и понимаем, что можно обойтись без некоторых моделей совершенно без зазрения совести и с полным спокойствием.   

В качестве заключения

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

Кажется, это будет полезно тем, кто строит чаты с агентами или похожими RAG-системами с разграничением доступа. Если у вас другой опыт или вы по-другому решали те же задачи - напишите в комментариях, обменяемся идеями.