logo

多租户 AI 应用:隔离数据、配额和检索权限

Published on

多租户 AI 应用:隔离数据、配额和检索权限

我见过一个很典型的 AI 项目:单个客户使用时一切正常,回答速度快,知识库也能搜到。后来产品改成 SaaS,要服务几十家公司,开发同学做的第一件事,是在表里加了一列 tenantId。查询时补上 where tenant_id = ?,向量入库时也加一个 metadata 字段,看上去万事大吉。

几周以后,问题来了。

有的客户发现问同一个问题,答案里出现了另一家公司的内部术语;有的客户上传了一批大文件,整个平台的 Embedding 任务排队;还有客户发现自己明明没有权限,却能通过旧缓存拿到已经被撤销的资料。

这不是某一个 SQL 漏写的问题,而是我们一开始把“租户”想得太简单了。多租户不是给每个请求贴一个标签,而是要保证一个租户的身份、数据、计算资源和账单,都不会意外穿过边界。

一、先定义清楚:什么叫隔离

在 AI 应用里,至少有四种边界:

  1. 租户边界:公司 A 永远看不到公司 B 的文档和对话。
  2. 用户边界:同一家公司里,普通员工不能看到管理员或其他部门的内容。
  3. 项目边界:同一个用户参与多个项目时,项目一的知识不能自动出现在项目二。
  4. 资源边界:一个租户不能因为大量上传文档或连续调用模型,拖垮其他租户。

所以,一次 RAG 请求真正需要的上下文,不只是一个 tenantId,而是一组经过服务端确认的身份信息:

type RequestScope = {
  tenantId: string
  userId: string
  roles: string[]
  projectIds: string[]
  documentScopes: string[]
}

最重要的一句话是:RequestScope 必须来自登录态和服务端授权结果,不能相信客户端传来的值。前端传来的 tenantId 只能当作一个请求参数,不能当作权限证明。否则用户把它改成别人的 ID,隔离就结束了。

二、数据隔离:不要只靠开发者记得加 where

1. 共享表是便宜而实用的起点

大多数 SaaS 产品不会一开始就给每个租户建一套数据库。更常见的做法是共享表、共享数据库,在每张业务表中保存 tenant_id

create table documents (
  id uuid primary key,
  tenant_id uuid not null,
  project_id uuid not null,
  owner_id uuid not null,
  title text not null,
  content_hash text not null,
  created_at timestamptz not null default now()
);

create index documents_tenant_project_idx
  on documents (tenant_id, project_id);

这个方案成本低、迁移简单,但它有一个致命依赖:每一个查询都不能漏掉租户条件。人会犯错,代码会被重构,未来还会有新同事写出一个“临时查询”。只靠大家小心一点,并不是安全策略。

2. 用数据库能力做第二道保险

如果使用 PostgreSQL,可以考虑 Row-Level Security(RLS),让数据库也参与隔离:

alter table documents enable row level security;

create policy documents_tenant_policy on documents
  using (tenant_id = current_setting('app.tenant_id')::uuid)
  with check (tenant_id = current_setting('app.tenant_id')::uuid);

请求开始时,服务端在事务里设置当前租户:

await db.transaction(async (tx) => {
  await tx.query('select set_config($1, $2, true)', [
    'app.tenant_id',
    scope.tenantId,
  ])

  return tx.documents.findMany({
    where: { projectId: projectId },
  })
})

RLS 不是万能的。使用超级用户连接、绕过 ORM 的管理脚本、跨租户报表任务,都可能绕过策略。所以生产环境要把普通业务连接和管理连接分开,并且对管理脚本做审计。安全设计不是找到一个神奇开关,而是让错误尽量难发生、发生后尽量能被发现。

三、向量检索:最容易被忽略的租户边界

关系数据库里漏一个条件,代码评审有机会发现;向量检索则更隐蔽。很多向量数据库允许在相似度搜索时传 metadata filter,但这个 filter 必须由服务端根据授权范围生成:

const filter = {
  tenantId: scope.tenantId,
  projectId: { $in: scope.projectIds },
  visibility: { $in: ['tenant', 'project'] },
}

const hits = await vectorStore.search({
  vector: queryEmbedding,
  topK: 20,
  filter,
})

不要把完整 filter 从前端传进来,也不要只过滤 tenantId 而忽略项目、部门和文档可见性。正确的权限判断应该在检索前完成,召回后还要再做一次服务端校验。这个“多做一次”看起来有点啰嗦,但 RAG 的上下文一旦交给模型,就已经很难补救了。

还有一个经常发生的错误:应用层做了过滤,但 Reranker 使用的是过滤前的结果,或者搜索服务先召回了跨租户内容再在返回前删除。这样虽然最终答案可能没有直接泄漏,但跨租户文档已经进入模型上下文或日志,边界仍然被破坏。

建议把 tenantIdprojectIddocumentIddocumentVersion 直接写进每个 chunk 的 metadata,并把“授权过滤”封装成唯一入口:

async function searchForScope(
  question: string,
  scope: RequestScope
) {
  const queryVector = await embed(question)
  const hits = await vectorStore.search({
    vector: queryVector,
    topK: 20,
    filter: buildSearchFilter(scope),
  })

  return hits.filter((hit) => canReadChunk(hit.metadata, scope))
}

调用方不应该自己拼过滤条件。把权限逻辑散落在十几个服务里,短期看是灵活,长期一定会出现一处忘记过滤。

四、缓存隔离:答案缓存也会泄漏

很多人只检查数据库和向量库,却忘了 Redis。假设缓存键是:

rag:answer:退款需要多久

那么第一个租户问出来的答案,可能被第二个租户直接复用。正确的缓存键至少要包含租户、用户权限版本、知识库版本和问题规范化结果:

const cacheKey = [
  'rag-answer',
  scope.tenantId,
  scope.userId,
  scope.permissionVersion,
  knowledgeBaseVersion,
  normalize(question),
].join(':')

是否需要放 userId,取决于答案是否包含用户专属权限。如果同一租户的答案对所有员工都一样,可以只放租户和权限集合的版本;如果不同部门能看到不同内容,就不能只按租户缓存。

缓存失效也必须纳入权限设计。员工离职、项目成员变化、文档撤回以后,旧缓存不能继续活着。宁可缓存时间短一点,也不要为了命中率保留无法解释的旧答案。

五、配额和限流:公平不是把所有人都拒之门外

多租户系统最难受的事故之一,是某个客户无意中把平台打满。比如用户一次上传五万份文档,后台同时发起五万次 Embedding;或者前端重试逻辑写错,一个按钮触发几十次模型调用。

配额至少分三层:

  • 请求配额:每分钟允许多少次对话、检索和上传。
  • 计算配额:每天或每月允许多少输入 Token、输出 Token 和 Embedding 数量。
  • 并发配额:同时运行多少个生成请求、索引任务和文件解析任务。

限流不能只在网关做。网关可以限制请求数量,但无法准确知道一次请求会消耗多少 Token。应用层应该在调用模型前做预算检查:

const estimate = estimateTokens(prompt, context)
const quota = await quotaService.get(scope.tenantId)

if (quota.inputTokens + estimate > quota.inputTokenLimit) {
  throw new QuotaExceededError('本月输入 Token 配额已用尽')
}

模型返回后,再用真实用量结算。预估值用于挡住明显超限,真实值用于计费和统计。两者不要混为一谈。

任务型操作最好进入按租户分组的队列,并设置公平调度。例如每个租户一个逻辑队列,单个租户最多占用固定并发数;高优先级客户可以有更高权重,但不能无限吞噬公共资源。否则“付费等级”最后会变成“谁先把机器打死”。

六、日志和计费:隔离不仅是看不见,还要算得清

每条日志都应该能回答三个问题:这是哪个租户的请求?消耗了哪些资源?发生了什么结果?建议记录:

type UsageEvent = {
  traceId: string
  tenantId: string
  userId: string
  model: string
  inputTokens: number
  outputTokens: number
  embeddingCount: number
  latencyMs: number
  status: 'success' | 'error' | 'quota_exceeded'
}

但是,tenantId 不代表可以把用户问题原文无限期写入日志。问题里可能有合同、病历、客户联系方式。日志要做脱敏、分级和留存期限控制;调试抽样也应该有权限,不能因为“内部系统”四个字就默认人人可看。

计费事件要具备幂等键。网络重试可能让同一次模型调用上报两次,如果没有 traceId + operationId 这样的唯一键,账单会重复。配额扣减和使用记录最好由一个明确的服务负责,不要让聊天服务、索引服务、后台脚本各自维护一套余额。

七、用测试证明隔离,而不是用信心证明

多租户功能必须有专门的隔离测试。最基本的测试矩阵是:

  1. A 租户的用户不能读取 B 租户的文档。
  2. A 租户的项目一不能检索到项目二的私有文档。
  3. 权限撤销后,旧缓存不能返回原答案。
  4. 向量检索、关键词检索和 Reranker 都使用同一权限范围。
  5. A 租户超额时,B 租户仍能正常请求。
  6. 删除文档后,旧索引和旧版本都不能继续召回它。
  7. 管理员跨租户查询有明确审计记录。

测试数据不要只用“公司 A”“公司 B”这种一眼假的名字。可以放入内容相同、关键词相近但答案不同的文档,验证系统是真的按权限过滤,而不是碰巧没有搜到错误内容。

总结:把租户当成系统边界,而不是业务字段

我最后总结出一个很朴素的判断方法:只要一个函数接收了 tenantId,就要问一句——这个 ID 是谁给的?有没有经过授权?它有没有继续传到数据库、向量库、缓存、队列、日志和账单?如果答案是“这里应该不会出问题”,那通常意味着问题还没有被真正设计出来。

多租户 AI 应用的本质,是把信任边界贯彻到底:身份决定范围,范围决定检索,检索决定上下文,上下文决定答案;资源配额则保证一个租户的浪潮不会淹没其他租户。数据隔离解决“不能看”,权限检索解决“不能搜”,配额管理解决“不能拖垮”,审计和计费解决“出了事能解释”。

当你把这些东西都补齐以后,系统可能没有 Demo 阶段那么轻巧,代码也不再只有几百行。但这不是复杂度失控,而是业务真实世界终于进入了系统。真正成熟的 AI 产品,不是模型回答得多么神奇,而是它知道谁可以知道什么、谁应该为多少资源付费,以及在发生错误时,团队能不能迅速把边界重新关上。

🤪 您也可以编辑此页: