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

У нас, например, нельзя использовать облачные решения: ни от 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-системами с разграничением доступа. Если у вас другой опыт или вы по-другому решали те же задачи - напишите в комментариях, обменяемся идеями.