【RAG安全隔离系列】第3讲:大模型把你的数据喂给竞争对手!多租户 RAG 安全隔离实录

别让大模型把你的数据喂给竞争对手!多租户 RAG 安全隔离实录

前言

去年有个做 SaaS 的客户找我哭诉。
他们公司买了套 RAG 系统给内部不同部门用。
结果财务部的员工问大模型“今年预算多少”。
结果研发部的员工也能搜到同样的答案。
这简直是灾难,数据像没锁门的保险柜。

很多人觉得,加个权限判断就行了。
if (user.role == "finance") 这种逻辑谁不会写?
大错特错。
在大模型语境下,这种逻辑防不住“提示词注入”。
黑客只要改改 Prompt,就能绕过你的 if 判断。

真正的安全,得靠“物理隔离”加“强控限流”。
今天我就把这套在银行级项目里跑通的方案,拆解给你看。
别等数据泄露了,才想起来加防火墙。

一、底层原理

1.1 核心机制

多租户 RAG 的核心,其实是“三把锁”。
第一把锁是“身份锁”,确认你是谁。
第二把锁是“数据锁”,确认你能看哪里的向量数据。
第三把锁是“资源锁”,确认你能花多少 Token。

这三把锁必须串联,不能并联。
任何一步失败,请求直接熔断。
我们设计了一套“租户网关”,所有请求先过这一关。
它就像小区的门卫,既查身份证,又查访客单。

下图展示了请求如何在不同租户间物理隔离:

graph TD
    Client["客户端请求"] --> Gateway["租户网关(身份校验)"]
    Gateway -->|校验失败 | Abort["直接拒绝"]
    Gateway -->|校验通过 | Quota["Token 配额检查"]
    Quota -->|超额 | Limit["触发限流"]
    Quota -->|正常 | VectorDB["向量数据库(物理分片)"]
    VectorDB -->|租户 A 数据 | ModelA["大模型(租户 A 上下文)"]
    VectorDB -->|租户 B 数据 | ModelB["大模型(租户 B 上下文)"]
    ModelA --> Response["返回结果"]
    ModelB --> Response

设计优势非常明显。
向量库按租户分片存储,物理上就不互通。
大模型推理时,只注入当前租户的上下文。
哪怕模型被攻击,它也“看”不到其他租户的数据。
这就好比把不同公司的档案,锁进了不同的保险柜。

1.2 与同类方案的对比

市面上常见的方案,大多只做了逻辑隔离。
逻辑隔离就像用胶带把柜子封起来,心理作用大于实际。
我们对比一下三种主流方案:

方案类型 隔离级别 安全性 成本 适用场景
纯逻辑过滤 代码层 WHERE 内部测试,非敏感数据
API 网关限流 网络层 IP 限制 简单的 SaaS 服务
物理分片 + 网关 存储与计算全隔离 金融、政务、多租户 SaaS

别为了省那点服务器成本,拿客户数据冒险。
一旦出事,赔偿款够你买十年服务器。

二、快速上手

别被架构图吓到了,上手其实很简单。
我们基于 Spring Boot 做了一个最小闭环。
核心就三步:定义租户、拦截请求、隔离存储。

先引入依赖,这里用的是自定义的 tenant-safety-starter

<dependency>
    <groupId>com.dali.safety</groupId>
    <artifactId>tenant-safety-starter</artifactId>
    <version>1.0.0</version>
</dependency>

创建一个配置类,告诉系统如何识别租户。

@Configuration
public class TenantSafetyConfig {

    // 定义租户识别器,从请求头获取
    @Bean
    public TenantResolver tenantResolver() {
        return new HeaderTenantResolver("X-Tenant-ID");
    }

    // 开启物理隔离模式
    @Bean
    public VectorStoreManager vectorStoreManager() {
        // 这里的分片策略决定了数据是否物理隔离
        return new ShardingVectorStoreManager(ShardingStrategy.PHYSICAL);
    }
}

就这么几行代码,系统就知道要查哪个保险柜了。
接下来,我们看看怎么控制 Token 的消耗。

三、核心 API / 深水区

3.1 核心方法速查

为了让你不用翻文档,我把核心接口列出来了。
这些接口都是生产环境经过千锤百炼的。

接口名称 功能描述 关键参数
checkQuota(tenantId) 检查租户剩余 Token 额度 tenantId: 租户唯一标识
deductToken(tenantId, cost) 预扣减 Token,防止超额 cost: 预估消耗量
rollbackToken(tenantId, cost) 请求失败后回滚额度 cost: 需返还的量
getTenantContext() 获取当前请求的租户上下文 无参,从 ThreadLocal 获取

3.2 生产级配置

生产环境最怕什么?怕“超卖”。
就像双十一抢票,不能卖了 101 张票,只有 100 个座位。
Token 计费也一样,必须保证原子性。
我们配置了 Redis 分布式锁来保障扣减安全。

# application.yml 配置
safety:
  rate-limit:
    enabled: true
    storage: redis
    redis-host: 192.168.1.100
    # 超时时间,防止死锁
    lock-timeout-ms: 5000
    # 重试次数,网络抖动时自动重试
    retry-count: 3

3.3 高级定制

有些客户需要“按量计费”,有些需要“包年包月”。
我们的 BillingStrategy 接口支持灵活扩展。
你可以写一个 MonthlyBillingStrategy 实现包月逻辑。
也可以写一个 PayAsYouGoStrategy 实现按 Token 计费。
系统会自动根据租户套餐,路由到不同的策略类。
这种设计,让你以后接新支付方式,不用改核心代码。

四、实战演练

假设我们有个“企业知识库”系统。
租户 A 是“财务部”,租户 B 是“研发部”。
他们共用一套代码,但数据绝对不能串。

我们写一个 Controller,处理用户的提问。
注意看里面的异常处理和上下文传递。

@RestController
@RequestMapping("/api/chat")
public class RAGController {

    @Autowired
    private RAGService ragService;

    @Autowired
    private BillingService billingService;

    @PostMapping("/ask")
    public ResponseEntity<String> askQuestion(@RequestBody ChatRequest request) {
        // 1. 获取当前租户 ID,框架会自动从 Header 注入
        String tenantId = TenantContext.getCurrentTenantId();
        
        // 2. 校验租户是否存在,防止伪造 ID
        if (tenantId == null || tenantId.isEmpty()) {
            return ResponseEntity.status(401).body("未识别的租户,请检查密钥");
        }

        try {
            // 3. 预扣减 Token,防止恶意刷量
            // 这里预估了回答长度,实际以最终消耗为准
            long estimatedTokens = estimateTokens(request.getContent());
            boolean canProceed = billingService.deductQuota(tenantId, estimatedTokens);
            
            if (!canProceed) {
                return ResponseEntity.status(403).body("您的 Token 额度已用完,请联系管理员充值");
            }

            // 4. 执行 RAG 检索与生成
            // 内部会自动带上 tenantId,确保只搜该租户的数据
            String answer = ragService.generateAnswer(request.getContent(), tenantId);

            // 5. 结算真实消耗,多退少补
            long actualTokens = countTokens(answer);
            billingService.settleQuota(tenantId, estimatedTokens, actualTokens);

            return ResponseEntity.ok(answer);

        } catch (Exception e) {
            // 6. 出现异常必须回滚 Token,不能让客户吃亏
            billingService.rollbackQuota(tenantId, estimatedTokens);
            log.error("租户 {} 处理请求失败", tenantId, e);
            return ResponseEntity.status(500).body("系统繁忙,请稍后再试");
        }
    }
}

这段代码看起来简单,其实全是坑。
比如第 3 步,如果扣减成功了,但第 4 步报错了怎么办?
所以第 6 步的 catch 块里,必须做回滚。
这就是生产级代码和 Demo 代码的区别。

五、避坑指南与最佳实践

做了这么多项目,我总结了几条血泪经验。
希望能帮你少加几次班。

💡 技巧:Token 估算要留余量
大模型的输出长度很难精准预测。
估算时,建议按 输入长度 * 1.5 来预扣。
别卡着线算,万一模型话痨了,你就得去数据库改数据。

⚠️ 警告:别信任前端传来的租户 ID
永远从网关层或 Filter 层获取租户信息。
如果直接从 Body 里读 tenantId,黑客随便改个 ID 就能查别人数据。
这是最低级也最常见的漏洞。

推荐:向量库索引按租户前缀分离
在 Elasticsearch 或 Milvus 里,给索引加前缀。
比如 index_tenant_A_001index_tenant_B_001
这样即使代码逻辑出错,物理上也无法跨索引查询。

💡 技巧:设置“熔断阈值”
单个租户如果短时间内请求激增,可能是被攻击了。
配置一个阈值,比如 1 分钟 1000 次,超过直接熔断。
保护大模型实例不被打挂,比服务单个客户更重要。

六、综合实战演示

最后,我提供一套完整的配置代码。
这套代码可以直接复制到你的 Spring Boot 项目里。
它包含了限流、计费、权限校验的闭环逻辑。

@Component
public class TenantSecurityInterceptor implements HandlerInterceptor {

    @Autowired
    private TenantService tenantService;

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) throws Exception {
        
        // 1. 提取租户标识
        String tenantId = request.getHeader("X-Tenant-ID");
        
        // 2. 基础非空校验
        if (StringUtils.isEmpty(tenantId)) {
            response.sendError(400, "缺少租户标识");
            return false;
        }

        // 3. 查询租户状态,是否被封禁
        TenantInfo tenant = tenantService.getInfo(tenantId);
        if (tenant == null || !tenant.isActive()) {
            response.sendError(403, "租户账号已停用");
            return false;
        }

        // 4. 设置到 ThreadLocal,供后续业务使用
        // 注意:务必在 finally 块中清除,防止线程复用导致数据污染
        TenantContext.setTenant(tenant);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, 
                                HttpServletResponse response, 
                                Object handler, 
                                Exception ex) {
        // 5. 请求结束后清理上下文
        TenantContext.clear();
    }
}

这段拦截器代码,保证了每个请求都有合法的“身份证”。
afterCompletion 里的清理操作,千万别省。
在高并发下,线程池复用频繁,不清理就是埋雷。

七、总结

多租户 RAG 系统,安全是底线,不是加分项。
物理隔离虽然成本高,但能买一夜安眠。
Token 限流虽然麻烦,但能防止账单爆炸。

别总想着用逻辑判断去挑战人性。
把数据锁进不同的房间,把钱包放在不同的抽屉。
这才是企业级架构该有的样子。

至于怎么平衡成本和安全?
那是老板该操心的事,你的任务是保证数据不泄露。
代码写完了,去喝杯咖啡吧。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐