单租户跑通、接第二个客户就出事,几乎都栽在同一处:**检索链路里的租户过滤写法,是从请求参数里取的。**开发时只有一份数据,tenantId 传什么都不影响结果,上线后才发现"过滤条件漏写"不会报错,只会把别人的文档答给用户。
多租户 AI 应用的隔离不是一道开关,是三层各管一件事的约束。
1. 三个隔离层级
| 层级 | 隔离对象 | 泄露后果 | 主要手段 |
|---|---|---|---|
| 数据 | 文档、向量、对话历史、缓存 | 直接泄露他人内容,最严重 | 索引层过滤 + 鉴权上下文注入 |
| 模型配额 | 并发、token、调用次数、费用 | 一个租户打满、全站降级 | 按租户限流与计费,独立 key |
| 提示资产 | 系统提示、工具定义、自定义模板 | 提示污染、越权调用工具 | 资产归属与作用域隔离 |
三层里最容易被忽略的是第三层:租户可以自定义提示,如果这份自定义提示被写进共享的模板表,它就可能覆盖到别人的请求上。
2. 向量库隔离的三种方案
| 方案 | 结构 | 优点 | 代价 |
|---|---|---|---|
| 每租户独立 collection | tenant_{id} 一个集合 |
隔离最硬,删除租户=删集合 | 集合数随租户线性增长,跨租户统计难 |
| 共享 collection + 元数据过滤 | 单集合,每条向量带 tenant_id |
管理简单,扩容平滑 | 过滤漏写就是数据泄露,依赖调用方纪律 |
| 独立实例 / 独立库 | 每租户一个库或集群 | 故障与性能都隔离 | 成本最高,只适合大客户 |
实际项目的分界线通常是:中小客户走共享集合加过滤,大客户走独立集合或独立库。取舍依据不是技术偏好,而是"漏一条会不会变成事故"。
3. 检索越权的真实风险:先过滤,再检索
两种写法的差别是安全与性能的分水岭:
// 错:先按语义检索 topK,再在应用层过滤
List<Document> hits = vectorStore.similaritySearch(req); // 全库 topK
List<Document> mine = hits.stream()
.filter(d -> d.getMetadata().get("tenantId").equals(ctx.tenantId()))
.toList(); // 可能剩 0 条
// 对:租户条件进入检索请求本身
SearchRequest scoped = SearchRequest.builder()
.query(req.text())
.topK(req.topK())
.filterExpression("tenant_id == '" + ctx.tenantId() + "'") // 索引层过滤
.build();
List<Document> hits = vectorStore.similaritySearch(scoped);
先检索再过滤有两个问题:topK 被别人的数据占满,你的结果可能一条不剩;而且过滤逻辑一旦写错(大小写、类型不一致、忘记加分支),泄露就是静默的。
过滤表达式不要拼字符串。Spring AI 的
FilterExpressionBuilder提供了类型安全的构造方式,按当前版本核对可用 API 后优先用它。
FilterExpression tenantFilter(TenantContext ctx) {
return new FilterExpressionBuilder()
.eq("tenant_id", ctx.tenantId())
.build();
}
4. 租户 id 只能来自鉴权上下文
这是整篇最重要的一条:**tenantId 绝不从请求体、查询参数或请求头里直接取。**它必须来自已经验证过的身份凭证(token 里的 claim、会话绑定、网关注入并签名的头)。
public final class TenantContext {
private static final ThreadLocal<Tenant> CURRENT = new ThreadLocal<>();
/** 只允许鉴权层调用,业务代码禁止 set */
static void bind(Authentication auth) {
String tenantId = ((JwtAuthenticationToken) auth).getTokenAttributes()
.get("tenant_id").toString(); // 来自已验证的 claim
CURRENT.set(new Tenant(tenantId, roles(auth)));
}
public static Tenant current() {
Tenant t = CURRENT.get();
if (t == null) throw new IllegalStateException("tenant not bound");
return t; // 缺失即失败,绝不回落到默认租户
}
}
两个细节:缺失就抛异常,不要回落到"默认租户"——默认值是多租户系统里最经典的越权源;请求结束必须 remove(),线程池复用会把上一个请求的租户带进下一个。
5. 模型侧隔离:限流、计费、key
| 维度 | 做法 | 为什么必须按租户 |
|---|---|---|
| 并发 | 每租户信号量或 Redis 计数器 | 一个租户的批量任务会挤掉所有人 |
| 速率 | 每租户 QPS / TPM 配额 | 需要按套餐区分 |
| 计费 | token 用量按租户累加 | 月末结算与超额提醒 |
| 凭证 | 大客户用租户专用 API key | 便于撤权与成本归户 |
# application.yaml:配额按套餐分层,具体数值按自己的成本模型核定
ai:
quota:
default:
max-concurrent: 2
tokens-per-minute: 20000
tokens-per-month: 2000000
tiers:
pro:
max-concurrent: 8
tokens-per-minute: 100000
tokens-per-month: 20000000
限流的桶必须带租户维度。全局桶会让"某租户刷爆"变成"全站 429",按租户桶则只影响它自己。
6. 提示与工具资产的可见性
租户自定义提示不能污染全局,规则就三条:**资产有归属(scope: global | tenant)、读取时按作用域解析、写入时校验归属。**优先级是"租户私有覆盖全局默认",反向不成立。
PromptAsset resolve(String key, TenantContext ctx) {
return repo.findByKeyAndScope(key, "tenant", ctx.tenantId())
.or(() -> repo.findByKeyAndScope(key, "global", null)) // 只降级到全局
.orElseThrow(() -> new IllegalStateException("no prompt: " + key));
}
工具定义同理:租户只能看到自己被授权的工具集合,工具执行前还要再校验一次——提示层不可信,执行层才是边界。
7. 缓存 key 必须带租户维度
| 缓存 | key 里必须有的维度 |
|---|---|
| 检索结果缓存 | 租户、查询、检索参数、索引版本 |
| 生成结果缓存 | 租户、提示版本、模型版本、输入摘要 |
| 权限/配置缓存 | 租户、用户、版本 |
漏掉租户维度就是"缓存穿透租户":A 的答案被 B 直接命中。这类事故在压测和单租户环境里永远发现不了。
String cacheKey(Tenant t, ChatRequest req) {
return String.join(":",
"ai.answer", t.id(), req.promptVersion(), req.modelVersion(),
sha256(req.normalizedInput())); // 租户在前,任何维度变更都换 key
}
8. 删租户:一条完整的清理链路
| 环节 | 清理动作 | 遗漏后果 |
|---|---|---|
| 向量 | 按 tenant_id 批量删除或删集合 |
残留文档仍可能被检索到 |
| 原始文档 | 对象存储前缀删除 + 元数据表删行 | 存储成本与合规风险 |
| 历史会话 | 会话表与消息表按租户删除 | 用户仍能看到旧会话 |
| 缓存 | 按租户前缀批量失效 | 旧答案继续命中 |
| 日志/追踪 | 标记为已删除或按保留策略过期 | 审计与隐私冲突 |
| 凭证 | 吊销租户 API key | 已撤销租户仍能调用 |
-- 先软删,再由异步任务分批硬删,避免长事务锁表
UPDATE documents SET deleted_at = now() WHERE tenant_id = :tid AND deleted_at IS NULL;
DELETE FROM messages WHERE tenant_id = :tid AND created_at < :cutoff LIMIT 5000;
清理必须是异步分批 + 可重入的:一次删几十万条向量会在事务里锁死,中途失败还得能续上。
9. 审计:谁在何时问了什么
CREATE TABLE ai_audit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id VARCHAR(32) NOT NULL,
user_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
question_hash CHAR(64) NOT NULL,
doc_ids JSON NOT NULL, -- 命中的文档 id
model VARCHAR(64) NOT NULL,
prompt_ver VARCHAR(32) NOT NULL,
created_at DATETIME(3) NOT NULL,
KEY idx_tenant_time (tenant_id, created_at),
KEY idx_request (request_id)
) ENGINE=InnoDB;
审计表要回答三件事:谁问的、问了什么(或其摘要)、答案依据了哪些文档。审计库与观测库分开存,保留策略不同、访问权限也不同。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| B 租户检索到 A 的文档 | 过滤写在应用层或条件漏写 | 过滤下推到索引层,用类型安全构造器 |
| 偶发越权,无法复现 | TenantContext 用 ThreadLocal 没清理 |
请求结束时 remove(),或用请求作用域 bean |
| 某租户刷爆全站 | 限流用全局桶 | 改为按租户桶,配额分层 |
| 删除租户后仍有旧答案 | 缓存 key 缺租户维度或没失效 | key 带租户前缀,删除时按前缀批量失效 |
| 日志里混着多个租户的记录 | 日志未带租户字段 | MDC 注入租户,检索按租户过滤 |
| 租户自定义提示影响他人 | 写入未校验 scope |
写入强制归属,读取只允许降级到全局 |
| 计费与实际不符 | 只记总数,没按租户打点 | token 计数打租户标签,日结对账 |
11. 边界
| 场景 | 更适合的手段 |
|---|---|
| 同一公司内不同部门 | 用户级 RBAC,不必上物理隔离 |
| 强监管行业要求物理隔离 | 独立实例或独立库,共享集合不满足 |
| 只做内部系统 | 单租户简化实现,过度设计成本更高 |
| 跨租户的数据分析 | 走脱敏聚合管道,不给业务账号直连 |
一句话:过滤下推到索引层、租户来自鉴权上下文、缓存带租户前缀——这三条守住,多租户 AI 应用就不会出现最贵的那个 bug。
12. 自测
- 为什么"先检索 topK 再在应用层过滤"既慢又不安全?
TenantContext缺失时回落到默认租户,会带来什么后果?- 共享集合方案里,防止越权的最后一道防线在哪一层?
- 缓存 key 漏掉租户维度,为什么在单租户环境测不出来?
- 删除租户时为什么要异步分批,而不是一条 DELETE 全清?
- 审计表至少要能回答哪三个问题?