多租户 AI 应用的隔离问题,通常不是某个查询漏写了条件,而是租户标识没有贯穿完整调用链。本文用 Node.js 的 AsyncLocalStorage 建立请求上下文,同时要求缓存、会话、检索和异步任务显式携带租户边界,并通过可运行测试验证并发场景下不会串数据。
先定义租户边界
多租户系统中的 tenantId 不是普通业务参数,而是访问控制的一部分。它应该来自已经完成认证的身份信息,例如 JWT 的可信声明、API Key 对应的租户记录,或者网关传入且经过签名验证的身份上下文。客户端自己提交的 tenantId、X-Tenant-Id 不能直接作为授权依据。
一次 AI 请求通常会经历以下链路:
| 环节 | 必须绑定的对象 | 常见错误 |
|---|---|---|
| 请求入口 | 用户身份与租户 | 直接信任请求头中的租户 ID |
| 会话记忆 | 会话、消息、摘要 | 只用 sessionId 查询 |
| 缓存 | 提示词、响应、工具结果 | 缓存键没有租户前缀 |
| 检索 | 文档、向量、元数据 | 只依赖向量相似度,不过滤租户 |
| 异步任务 | 任务、队列消息、重试数据 | 后台执行时依赖已结束的请求上下文 |
建议把租户上下文设计成不可变对象,至少包含 tenantId、userId 和请求标识。所有需要访问租户数据的服务都从这个上下文取值,而不是让调用方到处传入可被篡改的租户参数。对于跨租户的运营任务,则应使用单独的系统身份和显式的租户列表,不能偷偷绕过普通校验。
在请求入口建立上下文
Node.js 内置的 AsyncLocalStorage 可以把上下文关联到一次异步执行链。下面的示例使用 Node.js 内置模块,保存为 app.js 后可直接执行 node app.js。认证部分用固定 token 模拟真实身份解析,生产环境应替换为 JWT 或 API Key 校验,并验证签名、过期时间和撤销状态。
const http = require('node:http');
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const contextStorage = new AsyncLocalStorage();
function authenticate(request) {
const token = request.headers.authorization;
const identities = {
'Bearer tenant-a-token': { tenantId: 'tenant-a', userId: 'user-1' },
'Bearer tenant-b-token': { tenantId: 'tenant-b', userId: 'user-2' }
};
const identity = identities[token];
if (!identity) throw new Error('unauthorized');
return Object.freeze({ ...identity, requestId: randomUUID() });
}
function requireContext() {
const context = contextStorage.getStore();
if (!context) throw new Error('tenant context is missing');
return context;
}
function tenantKey(kind, id) {
const { tenantId } = requireContext();
return `${kind}:${tenantId}:${id}`;
}
const sessions = new Map([
['session:tenant-a:s1', { tenantId: 'tenant-a', messages: ['A 的历史消息'] }],
['session:tenant-b:s1', { tenantId: 'tenant-b', messages: ['B 的历史消息'] }]
]);
const responseCache = new Map();
const documents = [
{ tenantId: 'tenant-a', text: 'A 的产品文档' },
{ tenantId: 'tenant-b', text: 'B 的产品文档' }
];
function loadSession(sessionId) {
const session = sessions.get(tenantKey('session', sessionId));
if (!session) throw new Error('session not found');
if (session.tenantId !== requireContext().tenantId) throw new Error('tenant mismatch');
return session;
}
function retrieve(query) {
const { tenantId } = requireContext();
return documents.filter(doc => doc.tenantId === tenantId && doc.text.includes(query));
}
async function answer(sessionId, query) {
const session = loadSession(sessionId);
const key = tenantKey('llm', `${sessionId}:${query}`);
if (responseCache.has(key)) return responseCache.get(key);
const hits = retrieve(query);
const result = {
tenantId: requireContext().tenantId,
history: session.messages,
documents: hits.map(item => item.text),
answer: `处理 ${query}`
};
responseCache.set(key, result);
return result;
}
const server = http.createServer(async (request, response) => {
try {
const context = authenticate(request);
contextStorage.run(context, async () => {
const result = await answer('s1', '产品');
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(result));
});
} catch (error) {
const status = error.message === 'unauthorized' ? 401 : 500;
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({ error: error.message }));
}
});
server.listen(3000, () => console.log('http://localhost:3000'));
这里有三个关键点。第一,answer 没有接收 tenantId 参数,避免调用方伪造边界。第二,会话和缓存键都包含当前上下文中的租户。第三,检索结果仍然按 tenantId 过滤;缓存键正确并不能弥补检索层漏过滤的问题。
让每一层都做访问校验
上下文传播不是授权本身。AsyncLocalStorage 解决的是“当前调用属于谁”,不能保证某个底层仓库没有被错误参数调用。因此数据访问层仍应执行校验,最好让仓库方法只接受业务标识和当前上下文,不接受任意租户 ID。
会话表、缓存和向量库应采用同一套边界策略:关系数据库使用 WHERE tenant_id = ?,缓存键使用稳定的租户前缀,向量检索使用 metadata filter。数据库层可以进一步启用 PostgreSQL Row-Level Security,但应用层仍要保留测试和日志,因为配置错误可能让保护失效。
调用 LLM 时,不要把租户隔离寄托在 prompt 中。prompt 中的“不要读取其他客户资料”只是模型指令,不是安全边界。正确顺序应该是先完成授权、会话加载和检索过滤,再把已经筛选出的上下文交给模型。工具调用也一样:工具服务必须重新读取上下文并校验资源归属,不能因为调用来自“可信的 AI 服务”就跳过检查。
日志中可以记录 tenantId、userId、requestId、资源类型和资源 ID,避免记录完整 prompt、密钥或敏感文档。告警系统应关注租户上下文缺失、资源归属不匹配和跨租户访问拒绝。日志字段本身也需要脱敏,尤其是多租户环境下,排障人员不应通过普通日志获得其他租户的业务内容。
异步任务必须携带边界
请求结束后,AsyncLocalStorage 不应被当成队列消息。真实队列可能经过序列化、重试和另一台机器,消费者无法依赖生产者进程中的内存上下文。入队时要把最小必要的身份信息写进任务信封,消费时重新建立上下文,并在处理资源前再次校验。
const { AsyncLocalStorage } = require('node:async_hooks');
const tenantContext = new AsyncLocalStorage();
const jobs = [];
function currentContext() {
const value = tenantContext.getStore();
if (!value) throw new Error('tenant context is missing');
return value;
}
function enqueue(type, payload) {
const { tenantId, userId } = currentContext();
jobs.push({
id: `${type}-${jobs.length + 1}`,
type,
tenantId,
userId,
payload
});
}
async function consumeOne() {
const job = jobs.shift();
if (!job) return;
await tenantContext.run(
Object.freeze({ tenantId: job.tenantId, userId: job.userId, jobId: job.id }),
async () => {
const { tenantId } = currentContext();
if (job.payload.tenantId && job.payload.tenantId !== tenantId) {
throw new Error('job payload tenant mismatch');
}
console.log(`processing ${job.type} for ${tenantId}`);
}
);
}
tenantContext.run({ tenantId: 'tenant-a', userId: 'user-1' }, async () => {
enqueue('summarize', { sessionId: 's1' });
await consumeOne();
});
任务信封中只放处理所需的信息,不要把完整会话内容、访问令牌或未脱敏 prompt 放进队列。任务执行器应根据 tenantId 查询数据,而不是信任任务里附带的完整对象。重试、定时任务和死信转移都必须保留租户字段;监控指标也建议按租户或租户哈希维度关联请求,避免一个租户的失败被错误归因到另一个租户。
用测试验证并发和边界
至少要覆盖三类测试:不同租户同时请求相同的会话 ID;同一租户并发请求相同的 prompt;后台任务在原请求结束后执行。下面的测试使用 Node.js 内置 node:test,假设把前一个示例中的 answer、contextStorage 暴露为模块导出,或将核心函数拆到独立模块中。测试重点不是模型输出,而是每次结果中的租户、会话和文档归属。
const test = require('node:test');
const assert = require('node:assert/strict');
const { AsyncLocalStorage } = require('node:async_hooks');
const storage = new AsyncLocalStorage();
const data = {
'tenant-a:s1': { tenantId: 'tenant-a', text: 'A secret' },
'tenant-b:s1': { tenantId: 'tenant-b', text: 'B secret' }
};
async function readSession(sessionId) {
await new Promise(resolve => setImmediate(resolve));
const { tenantId } = storage.getStore();
const item = data[`${tenantId}:${sessionId}`];
if (!item) throw new Error('not found');
return item;
}
function request(tenantId, sessionId) {
return storage.run({ tenantId }, () => readSession(sessionId));
}
test('并发请求不会串租户', async () => {
const [a, b] = await Promise.all([
request('tenant-a', 's1'),
request('tenant-b', 's1')
]);
assert.equal(a.tenantId, 'tenant-a');
assert.equal(b.tenantId, 'tenant-b');
assert.notEqual(a.text, b.text);
});
test('不存在当前租户的会话必须拒绝', async () => {
await assert.rejects(request('tenant-c', 's1'), /not found/);
});
实际项目还应加入缓存命中测试:两个租户使用相同 sessionId 和 query,断言各自获得不同缓存条目;检索测试:构造相同文本但不同 tenantId 的文档,断言结果只包含当前租户;任务测试:篡改任务 payload 中的租户后,消费者必须拒绝。测试数据不要只使用全局唯一 ID,否则无法发现“查询只按 ID”这种典型缺陷。
总结
多租户 AI 应用的隔离边界应贯穿整条调用链:
- 在认证入口确定租户,不能信任客户端提交的租户 ID。
- 用
AsyncLocalStorage传播当前请求上下文,但把它视为上下文机制而不是完整授权系统。 - 会话、缓存、检索、工具和数据库查询都要绑定租户,并在数据层再次校验归属。
- LLM prompt 不能替代访问控制,模型只能看到已经授权和过滤后的数据。
- 异步任务必须显式携带租户信封,消费者重新建立上下文并执行校验。
- 用相同资源 ID、相同 query 和并发请求构造测试,才能有效发现跨租户串数据问题。
工程上可以把“所有数据访问必须有租户上下文”和“所有队列任务必须有租户字段”做成代码审查规则,再结合静态检查、集成测试和日志告警持续验证。隔离不是某一个中间件的特性,而是每一层都不允许越界的组合约束。
评论