Почему мультитенантность в Bedrock AgentCore — это головная боль (и как её лечить)
Вы построили крутого AI-агента на Amazon Bedrock AgentCore. Он отвечает на вопросы, тащит данные из вашей базы, вызывает внешние API. Теперь эту магию хотят купить сразу три крупных заказчика. Каждый — со своими данными, своей логикой и своими счетами. И тут начинается ад: как сделать так, чтобы агент клиента А не видел данные клиента Б, чтобы затраты клиента В не списывались на клиента Г, и чтобы каждый мог менять промпты не ломая соседа? Добро пожаловать в мир мультитенантности.
Из коробки Amazon Bedrock AgentCore не имеет встроенной поддержки tenant. Агент — это ресурс, живущий в вашем аккаунте AWS. Он может быть один для всех, но тогда изоляция ложится на вашу логику в лямбда-функциях и action groups. Или вы плодите копии агентов — по экземпляру на tenant. И тот, и другой путь — с подводными камнями. В этой статье я разложу по полочкам, как НЕ надо делать, а потом покажу рабочие паттерны с кодом. Поехали.
Дисклеймер: всё ниже — исключительно практический опыт, выстраданный на production-нагрузках. Никакой воды.
Паттерны изоляции: под какой соус резать арендаторов
В теории SaaS существует три основных паттерна: Silo (отдельный экземпляр), Pool (общий экземпляр), Bridge (гибрид). В контексте Bedrock AgentCore они выглядят так:
- Silo: на каждого tenant — свой AgentCore, свои action groups, своя база знаний. Максимальная изоляция, но кровавое управление и цена. Если у вас сотня клиентов, деплоить сотню агентов... только через CI/CD, как я описывал в гайде по деплою через GitHub Actions.
- Pool: один AgentCore на всех, в каждом вызове передаёте tenant_id, а action groups уже разрезают доступ по этому ID. Проще, но изоляция — на вас. Если в лямбде ошибка — все видят чужие данные.
- Bridge: общий AgentCore, но у каждого tenant своя база знаний (Knowledge Base) и подкапотные лямбды. Агент один, а хранилища — раздельные. Золотая середина.
Лично я — за Bridge. Он даёт гибкость Silo (можно выдать одному tenant Anthropic Claude Opus 4.5, другому — дешёвый Amazon Nova) без дублирования всего оркестрации. Но выбор зависит от вашего SLA и бюджета. Если tenant платят по-разному — Bridge позволяет нарезать tier'ы.
Архитектура «Как надо»: один агент, разные миры
Давайте спроектируем систему, где один AgentCore обслуживает сотню клиентов. Ключевая идея: action group (Lambda) получает tenant_id из сессии и решает, к каким данным дать доступ. Сам агент — просто роутер. Он не знает про tenant.
📌 Важно: tenant_id должен быть подписанным и проверяться в Lambda, чтобы клиент не мог подставить чужой ID. Используйте JWT или Cognito.
Зачем нам здесь CI/CD? Затем, что каждый раз менять одну лямбду для разных tenant — риск. Лучше иметь шаблон Agent (как FAST), который при деплое тянет конфигурацию tenant из Parameter Store или DynamoDB. Так вы меняете только конфиг, а не код.
1 Создание AgentCore с tenant-контекстом
Сам агент — обычный. Но в SessionAttributes мы передаём tenant_id на старте. Как это выглядит в boto3:
import boto3, json
client = boto3.client('bedrock-agent-runtime')
response = client.invoke_agent(
agentId='YOUR_AGENT_ID',
agentAliasId='YOUR_ALIAS_ID',
sessionId=f"tenant-{tenant_id}-{user_session}",
inputText=user_message,
sessionState={
'sessionAttributes': {
'tenant_id': tenant_id
},
'promptSessionAttributes': {
'current_tier': get_tenant_tier(tenant_id)
}
}
)Обратите внимание: sessionId я формирую с префиксом tenant. Это помогает в логировании и cost tracking (см. ниже). Но сам контекст tenant пробрасывается через sessionAttributes.
2 Action group: Lambda, которая знает про tenant
В action group мы поднимаем Lambda. Её первая задача — достать tenant_id из event['sessionState']['sessionAttributes']. Если его нет — бой! Опять же, проверяем валидность токена (я использую AWS Secrets Manager, как в гайде по Secrets Manager).
def lambda_handler(event, context):
tenant_id = event.get('sessionState', {}).get('sessionAttributes', {}).get('tenant_id')
if not tenant_id:
raise Exception('tenant_id required')
# Получаем динамическую конфигурацию для tenant
config_table = boto3.resource('dynamodb').Table('TenantConfig')
tenant_config = config_table.get_item(Key={'tenant_id': tenant_id})['Item']
# Теперь работаем с данными только этого tenant
db = boto3.client('rds-data')
# ... запросы с учётом схемы tenantЗдесь видна сила конфигурационной таблицы: вы храните ключи, endpoint'ы, model_id для этого tenant. Хотите дать одному клиенту модель Claude Haiku за 0.25$ за миллион токенов, а другому — Nova Pro за 0.80$? Просто меняете значение в DynamoDB!
3 Разграничение знаний: по одной базе на tenant
Если у вас Knowledge Base (RAG), то я рекомендую Bridge: создаём отдельный Data Source для каждого tenant, но все они подцеплены к одному Knowledge Base. Как? При создании Data Source указываете S3 prefix для tenant:
{
"knowledgeBaseId": "common-kb",
"dataSourceConfiguration": {
"type": "S3",
"s3Configuration": {
"bucketArn": "arn:aws:s3:::my-docs",
"inclusionPrefixes": ["tenants/{tenant_id}/"]
}
}
}Когда агент вызывает RetrieveAndGenerate, вы передаёте тот самый tenant_id в sessionAttributes, а в action group для Knowledge Base зашиваете фильтр по этому prefix. Так каждый tenant видит только свои документы. Безопасно и легко масштабируется.
Cost tracking: как понять, кто сколько съел токенов
Самый частый запрос от CTO: «Почему счёт растёт?» Без мультитенантности вы не ответите. С нашим подходом — легко. Есть два способа:
- Cost Allocation Tags. При создании AgentCore через CloudFormation навешиваете на него тег tenant_id. Но это только на уровне ресурса — не покроет вызовы.
- Custom logging в CloudWatch. В каждой Lambda пишем метрику: tenant_id, invocation_id, затраченные токены, стоимость. Можно рассчитать по формуле model pricing (на момент июня 2026 цены на Claude уже упали ещё на 15%, но всё равно считайте).
Вот фрагмент для записи метрик:
import boto3
cloudwatch = boto3.client('cloudwatch')
def log_tenant_cost(tenant_id, model_id, input_tokens, output_tokens):
cost_map = {
'anthropic.claude-3-haiku': (0.00025, 0.00125),
'amazon.nova-pro': (0.00080, 0.00240)
}
input_cost = input_tokens * cost_map[model_id][0] / 1000
output_cost = output_tokens * cost_map[model_id][1] / 1000
total_cost = input_cost + output_cost
cloudwatch.put_metric_data(
Namespace='AgentCosts',
MetricData=[{
'MetricName': 'TotalCost',
'Dimensions': [{'Name': 'TenantId', 'Value': tenant_id}],
'Value': total_cost,
'Unit': 'Count'
}]
)Дальше строим дашборду в Grafana или QuickSight — и видим, кто у нас «прожорливый клиент». Отличный повод перевести его на другой тариф.
Пример ошибки: как я чуть не слил данные всех tenant
Когда я впервые делал pool-паттерн, я допустил классическую ошибку: хранил tenant_id только в sessionId. Думал, это безопасно. Но клиент А переслал свой sessionId клиенту Б, и тот через веб-интерфейс получал данные от A. Потому что я не проверял tenant_id в action group! Кошмар.
Решение: tenant_id должен быть подписанным (JWT с вашим секретом) и проверяться в Lambda. И никакого доверия к sessionId.
Другая грабля — общая DynamoDB-таблица без partition key по tenant. Если забыли WHERE tenant_id = XXX в запросе, то один клиент может получить доступ ко всем данным. Заведите правило: каждая таблица с tenant_id как PK или GSI. И всегда используйте выражение ConditionExpression.
Интеграция с внешними сервисами: Slack, платежи и секреты
В мультитенантной архитектуре часто нужно, чтобы каждый tenant мог подключить свой Slack-канал или свои платёжные реквизиты. Тут пригождается наш гайд по интеграции со Slack — просто заведите для каждого tenant отдельную Slack-апп, а токены храните в Secrets Manager с меткой tenant_id.
И если вы хотите, чтобы агент оплачивал счета от имени tenant, используйте платёжный шлюз. Amazon запустил платежи через Bedrock AgentCore с Coinbase и Stripe — эти SDK поддерживают передачу tenant_id в metadata, так что вы сможете билить каждого арендатора отдельно.
CI/CD для мультитенантного мира: автоматизация без боли
Когда tenant растут (а они будут), вам нужно управлять деплоем агентов. Silo-паттерн требует механизма для создания новых инстансов. Bridge-паттерн — только обновления общей лямбды. Я рекомендую в каждом микросервисе (Lambda) использовать конфигурацию per-tenant из SSM Parameter Store или AppConfig. Тогда изменение одного параметра = изменение поведения для одного tenant, без полного деплоя.
Посмотрите на наш CI/CD пайплайн — он позволяет выкатывать изменения на staging, который видит только один tenant (ваш тестовый), а потом катить на всех. И не забудьте про Web Search on Amazon Bedrock AgentCore: если один tenant хочет веб-поиск, а другой нет, вы можете включать MCP-сервер выборочно через конфиг.
География и латенси: когда ваш агент живёт в Окленде, а клиент — в Токио
Если ваши tenant распределены по миру, используйте географическую маршрутизацию. Создайте по одному AgentCore в нескольких регионах и через Route53 направляйте tenant в ближайший. А key-value store для tenant_config храните глобально (DynamoDB Global Tables).
Итоговый совет: не пытайтесь сделать одну «серебряную пулю» для всех. Multi-tenancy — это trade-off между изоляцией, стоимостью и сложностью. На старте берите Pool + строгие action group check'и. Как только копнёте глубже — переходите на Bridge с отдельными Knowledge Bases и конфигами. А когда клиентов станет 100+ — подумайте о Silo, но только с автоматизированным деплоем.
Помните: лучшее — враг хорошего. Ваши арендаторы хотят, чтобы агент работал, а не чтобы его архитектура была идеальной. Дайте им это.