Agent 多租户隔离设计:不同客户的任务别混在同一个上下文里

一、"客户 A 的 Agent 调出了客户 B 的数据"

SaaS 平台的 AI 助手上线后,客户 A 的售后客服问 Agent"查看最近订单",Agent 返回了客户 B 的订单明细。排查发现 Agent 的知识库检索范围是全库而非租户级别的——不同客户的数据混在了同一个检索空间里。

Agent 多租户的本质不只是数据隔离,还有上下文隔离、任务隔离和成本隔离。租户 A 的对话上下文不能污染租户 B、租户 A 的任务失败不影响租户 B、租户 A 的成本需要独立核算。

二、多租户隔离的四个维度

flowchart TD
    subgraph Data[数据隔离]
        D1[向量库: 按 TenantID 分区]
        D2[数据库: Row-Level Security]
        D3[缓存: 按 TenantID 做 Key 前缀]
    end
    
    subgraph Context[上下文隔离]
        C1[会话上下文绑定 TenantID]
        C2[Agent 记忆按租户隔离]
        C3[工具调用权限按租户配置]
    end
    
    subgraph Task[任务隔离]
        T1[任务队列: 按租户的独立队列?]
        T2[并发限制: 每租户最大并发数]
        T3[失败重试: 租户粒度而非全局]
    end
    
    subgraph Cost[成本隔离]
        CO1[Token 使用量按租户归集]
        CO2[模型调用次数独立统计]
        CO3[成本账单按租户生成]
    end
    
    API[API 请求] --> Auth[认证: 提取 TenantID]
    Auth --> C1
    Auth --> D1
    Auth --> CO1

多租户隔离的核心原则:所有数据操作必须携带 TenantID,所有任务调度都要考虑租户的资源配额,所有成本统计都要归属到具体租户。

三、Go 实现:多租户 Agent 中间件

package multitenant

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// ========== 租户上下文 ==========

type contextKey string

const (
    KeyTenantID  contextKey = "tenant_id"
    KeyTenantTier contextKey = "tenant_tier" // free, pro, enterprise
)

// TenantInfo 租户信息
type TenantInfo struct {
    ID            string
    Name          string
    Tier          string    // free, pro, enterprise
    MaxConcurrent int       // 最大并发任务数
    MaxTokensPerDay int64   // 每日 Token 配额
    AllowedTools  []string  // 允许使用的工具列表
    RateLimitQPS  int       // 租户级别 QPS 限制
}

// WithTenant 给 context 注入租户信息
func WithTenant(ctx context.Context, info *TenantInfo) context.Context {
    ctx = context.WithValue(ctx, KeyTenantID, info.ID)
    ctx = context.WithValue(ctx, KeyTenantTier, info.Tier)
    return ctx
}

// TenantIDFromContext 从 context 中获取租户 ID
func TenantIDFromContext(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(KeyTenantID).(string)
    return id, ok
}

// ========== 租户管理器 ==========

// TenantManager 租户管理器
type TenantManager struct {
    tenants map[string]*TenantInfo
    mu      sync.RWMutex
}

func NewTenantManager() *TenantManager {
    return &TenantManager{
        tenants: make(map[string]*TenantInfo),
    }
}

func (tm *TenantManager) Register(info *TenantInfo) {
    tm.mu.Lock()
    defer tm.mu.Unlock()
    tm.tenants[info.ID] = info
}

// ========== 数据隔离中间件 ==========

// TenantDataFilter 数据层租户过滤器
type TenantDataFilter struct {
    db *mockDB // 实际项目中是真实的数据库连接
}

// QueryDocuments 查询文档时自动注入 TenantID 过滤
func (tf *TenantDataFilter) QueryDocuments(ctx context.Context, query string) ([]string, error) {
    tenantID, ok := TenantIDFromContext(ctx)
    if !ok {
        return nil, fmt.Errorf("未找到租户 ID,拒绝查询")
    }
    
    // 构造带租户过滤的查询
    filter := map[string]string{
        "tenant_id": tenantID,
        "query":     query,
    }
    
    // 实际查询
    _ = filter
    return nil, nil
}

// ========== 上下文隔离 ==========

// TenantContextStore 租户级别的上下文存储
type TenantContextStore struct {
    sessions map[string]map[string]interface{} // tenantID → sessionID → context
    mu       sync.RWMutex
}

func NewTenantContextStore() *TenantContextStore {
    return &TenantContextStore{
        sessions: make(map[string]map[string]interface{}),
    }
}

// GetContext 获取租户的会话上下文
func (cs *TenantContextStore) GetContext(
    ctx context.Context, sessionID string,
) (map[string]interface{}, error) {
    tenantID, ok := TenantIDFromContext(ctx)
    if !ok {
        return nil, fmt.Errorf("未找到租户 ID")
    }
    
    cs.mu.RLock()
    defer cs.mu.RUnlock()
    
    if sessions, ok := cs.sessions[tenantID]; ok {
        if sessionData, ok := sessions[sessionID]; ok {
            return sessionData.(map[string]interface{}), nil
        }
    }
    
    return make(map[string]interface{}), nil
}

// SaveContext 保存租户的会话上下文
func (cs *TenantContextStore) SaveContext(
    ctx context.Context, sessionID string, data map[string]interface{},
) error {
    tenantID, ok := TenantIDFromContext(ctx)
    if !ok {
        return fmt.Errorf("未找到租户 ID")
    }
    
    cs.mu.Lock()
    defer cs.mu.Unlock()
    
    if _, ok := cs.sessions[tenantID]; !ok {
        cs.sessions[tenantID] = make(map[string]interface{})
    }
    
    cs.sessions[tenantID][sessionID] = data
    return nil
}

// ========== 并发限制中间件 ==========

// TenantRateLimiter 租户级别并发限制
type TenantRateLimiter struct {
    tokens map[string]chan struct{} // 令牌桶
    mu     sync.Mutex
}

func NewTenantRateLimiter() *TenantRateLimiter {
    return &TenantRateLimiter{
        tokens: make(map[string]chan struct{}),
    }
}

// Acquire 获取租户的执行槽位
func (rl *TenantRateLimiter) Acquire(ctx context.Context, tenantID string, maxConcurrent int) error {
    rl.mu.Lock()
    ch, ok := rl.tokens[tenantID]
    if !ok {
        ch = make(chan struct{}, maxConcurrent)
        rl.tokens[tenantID] = ch
    }
    rl.mu.Unlock()
    
    select {
    case ch <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// Release 释放租户的执行槽位
func (rl *TenantRateLimiter) Release(tenantID string) {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    
    if ch, ok := rl.tokens[tenantID]; ok {
        <-ch
    }
}

// ========== 成本隔离 ==========

// TenantCostTracker 租户成本追踪
type TenantCostTracker struct {
    usage map[string]*TenantUsage // 租户 ID → 用量
    mu    sync.RWMutex
}

type TenantUsage struct {
    TenantID     string
    TotalTokens   int64
    TotalCost     float64
    DailyTokens   int64
    DailyCost     float64
    DailyDate     string
    MonthlyTokens int64
    MonthlyCost   float64
    MonthlyKey    string
}

func NewTenantCostTracker() *TenantCostTracker {
    return &TenantCostTracker{
        usage: make(map[string]*TenantUsage),
    }
}

// RecordUsage 记录租户的 Token 使用量
func (ct *TenantCostTracker) RecordUsage(tenantID string, inputTokens, outputTokens int64, cost float64) error {
    ct.mu.Lock()
    defer ct.mu.Unlock()
    
    if _, ok := ct.usage[tenantID]; !ok {
        ct.usage[tenantID] = &TenantUsage{TenantID: tenantID}
    }
    
    usage := ct.usage[tenantID]
    usage.TotalTokens += inputTokens + outputTokens
    usage.TotalCost += cost
    
    // 每日统计
    today := time.Now().Format("2006-01-02")
    if usage.DailyDate != today {
        usage.DailyDate = today
        usage.DailyTokens = 0
        usage.DailyCost = 0
    }
    usage.DailyTokens += inputTokens + outputTokens
    usage.DailyCost += cost
    
    return nil
}

// GenerateBill 生成租户账单
func (ct *TenantCostTracker) GenerateBill(tenantID string, startTime, endTime time.Time) *TenantBill {
    // 实际项目中从数据库查询区间用量
    return &TenantBill{
        TenantID:    tenantID,
        TotalCost:   0,
        ModelBreakdown: make(map[string]float64),
    }
}

type TenantBill struct {
    TenantID       string
    TotalCost      float64
    ModelBreakdown map[string]float64 // 各模型的费用明细
}

// ========== 工具权限控制 ==========

// ToolPermissionChecker 工具权限检查
type ToolPermissionChecker struct {
    tenantTools map[string][]string // 租户 ID → 允许的工具列表
    mu          sync.RWMutex
}

func (tc *ToolPermissionChecker) CanUseTool(ctx context.Context, toolName string) bool {
    tenantID, ok := TenantIDFromContext(ctx)
    if !ok {
        return false
    }
    
    tc.mu.RLock()
    defer tc.mu.RUnlock()
    
    allowed, ok := tc.tenantTools[tenantID]
    if !ok {
        return false
    }
    
    for _, t := range allowed {
        if t == toolName || t == "*" {
            return true
        }
    }
    
    return false
}

// ========== 接口抽象 ==========

type mockDB struct{}

// ========== 使用示例 ==========

func ProcessTenantRequest(ctx context.Context, tenantID, message string) (string, error) {
    // 1. 加载租户配置
    tenantInfo := &TenantInfo{
        ID:            tenantID,
        Tier:          "pro",
        MaxConcurrent: 5,
        AllowedTools:  []string{"query_order", "check_status"},
        RateLimitQPS:  10,
    }
    
    // 2. 注入租户上下文
    ctx = WithTenant(ctx, tenantInfo)
    
    // 3. 后续所有操作都自动携带租户 ID
    // 数据查询 → 自动过滤
    // 上下文存储 → 自动隔离
    // 成本记录 → 自动归集
    
    _ = ctx
    _ = message
    
    return "处理完成", nil
}

四、多租户隔离的边界与陷阱

不要用数据库字段隔离代替真正的主键隔离。 在 SQL 中加 WHERE tenant_id = ? 是对的,但不能只靠这一层。如果开发忘了加这个条件,数据就泄露了。更安全的做法是数据库层面的 Row-Level Security 策略。

共享知识的租户特殊处理。 有些场景下多个租户共享同一套知识库(如同一个行业的多租户共享法规知识)。这时需要区分"共享知识"和"私有数据",共享部分不需要租户过滤,私有数据严格要求。

租户级别的资源配额要留缓冲。 如果 10 个租户共享一个 Agent 集群,每个租户分到 10% 的资源配额,总共 100%。实际运行中资源利用率很少达到 100%,总会有一个租户的配额浪费。用弹性配额而非硬配额——未使用的配额可以被其他租户临时借用。

隔离带来的运维成本。 如果每个租户有独立的向量库索引、独立的 Agent 实例,隔离性最好但运维成本也最高。大多数场景下,轻量隔离(数据级 + 上下文级)已经足够,不需要做到实例级隔离。

五、总结

多租户隔离的四个核心:数据操作必须带 TenantID、上下文存储按租户分区、任务配额按租户限制、成本统计按租户独立。实施策略:先用 Context 传递 TenantID 的统一入口模式,再逐步加固数据层和资源层的隔离。隔离做得越早代价越小——等到数据混了再拆分,比脏了再洗难十倍。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐