【RAG安全隔离系列】第3讲:大模型把你的数据喂给竞争对手!多租户 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: 33.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_001和index_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 限流虽然麻烦,但能防止账单爆炸。
别总想着用逻辑判断去挑战人性。
把数据锁进不同的房间,把钱包放在不同的抽屉。
这才是企业级架构该有的样子。
至于怎么平衡成本和安全?
那是老板该操心的事,你的任务是保证数据不泄露。
代码写完了,去喝杯咖啡吧。