브라우저에서 AI 모델 캐시

대부분의 AI 모델에는 인터넷을 통해 전송되는 리소스에 비해 상당히 크기하다는 공통점이 하나 이상 있습니다. 가장 작은 MediaPipe 객체 감지 모델(SSD MobileNetV2 float16)의 무게는 5.6MB이며 가장 큰 모델은 약 25MB입니다.

오픈소스 LLM gemma-2b-it-gpu-int4.bin은 1.35GB로, LLM에서 매우 작은 것으로 간주됩니다. 생성형 AI 모델은 엄청날 수 있습니다. 이것이 오늘날 많은 AI 사용이 클라우드에서 이루어지는 이유입니다 점점 더 많은 앱이 기기에서 직접 고도로 최적화된 모델을 실행하고 있습니다. 브라우저에서 실행되는 LLM의 데모가 있지만 브라우저에서 실행되는 다른 모델의 프로덕션 등급 예시는 다음과 같습니다.

웹용 Adobe Photoshop에서 AI 기반 개체 선택 도구가 열려 있고 3개의 개체(기린 두 개와 달)가 선택되어 있습니다.

향후 애플리케이션 실행 속도를 높이려면 암시적 HTTP 브라우저 캐시에 의존하지 말고 모델 데이터를 기기 내에 명시적으로 캐시해야 합니다.

이 가이드에서는 gemma-2b-it-gpu-int4.bin model를 사용하여 챗봇을 만들지만, 기기 내 다른 모델 및 기타 사용 사례에 맞게 이 접근 방식을 일반화할 수 있습니다. 앱을 모델에 연결하는 가장 일반적인 방법은 나머지 앱 리소스와 함께 모델을 제공하는 것입니다. 전달을 최적화하는 것이 중요합니다.

올바른 캐시 헤더 구성

서버에서 AI 모델을 제공하는 경우 올바른 Cache-Control 헤더를 구성하는 것이 중요합니다. 다음 예는 앱의 요구에 따라 빌드할 수 있는 확실한 기본 설정을 보여줍니다.

Cache-Control: public, max-age=31536000, immutable

AI 모델의 각 출시 버전은 정적 리소스입니다. 변경되지 않는 콘텐츠에는 요청 URL에서 캐시 무효화와 함께 긴 max-age를 제공해야 합니다. 모델을 업데이트해야 하는 경우 새 URL을 제공해야 합니다.

사용자가 페이지를 새로고침하면 서버가 콘텐츠가 안정적임을 알고 있더라도 클라이언트는 유효성 재검사 요청을 보냅니다. immutable 지시어는 콘텐츠가 변경되지 않으므로 재검증이 필요하지 않음을 명시적으로 나타냅니다. immutable 지시어는 브라우저와 중간 캐시 또는 프록시 서버에서 널리 지원되지 않지만 보편적으로 이해되는 max-age 지시어와 조합하면 호환성을 극대화할 수 있습니다. public 응답 지시문은 응답을 공유 캐시에 저장할 수 있음을 나타냅니다.

Chrome DevTools는 AI 모델을 요청할 때 Hugging Face에서 전송한 프로덕션 Cache-Control 헤더를 표시합니다. (출처)

클라이언트 측에서 AI 모델 캐시

AI 모델을 제공할 때는 브라우저에서 모델을 명시적으로 캐시하는 것이 중요합니다. 이렇게 하면 사용자가 앱을 새로고침한 후에도 모델 데이터를 즉시 사용할 수 있습니다.

이를 위해 사용할 수 있는 여러 가지 기법이 있습니다. 다음 코드 샘플에서는 각 모델 파일이 메모리에 blob라는 Blob 객체에 저장되어 있다고 가정합니다.

성능을 이해하기 위해 각 코드 샘플에는 performance.mark()performance.measure() 메서드로 주석이 달립니다. 이러한 측정값은 기기에 따라 다르며 일반화할 수 없습니다.

Chrome DevTools 애플리케이션 > 저장소에서 IndexedDB, Cache storage, File System의 세그먼트가 포함된 사용 다이어그램을 검토합니다. 각 세그먼트는 1,354MB의 데이터를 소비하는 것으로 표시되어 총 4,063MB가 됩니다.

Cache API, Origin Private File System API, IndexedDB API 중 하나를 사용하여 브라우저에서 AI 모델을 캐시할 수 있습니다. 일반적으로 Cache API를 사용하는 것이 좋지만 이 가이드에서는 모든 옵션의 장단점을 설명합니다.

캐시 API

Cache API는 수명이 긴 메모리에 캐시된 RequestResponse 객체 쌍을 위한 영구 저장소를 제공합니다. 서비스 워커 사양에 정의되어 있지만 기본 스레드 또는 일반 작업자에서 이 API를 사용할 수 있습니다. 서비스 워커 컨텍스트 외부에서 사용하려면 합성 Response 객체를 Request 객체 대신 합성 URL과 페어링하여 Cache.put() 메서드를 호출합니다.

이 가이드에서는 인메모리 blob를 사용한다고 가정합니다. 가짜 URL을 캐시 키로 사용하고 blob를 기반으로 하는 합성 Response를 사용합니다. 모델을 직접 다운로드하는 경우 fetch() 요청에서 얻은 Response를 사용합니다.

다음은 Cache API를 사용하여 모델 파일을 저장하고 복원하는 방법입니다.

const storeFileInSWCache = async (blob) => {
  try {
    performance.mark('start-sw-cache-cache');
    const modelCache = await caches.open('models');
    await modelCache.put('model.bin', new Response(blob));
    performance.mark('end-sw-cache-cache');

    const mark = performance.measure(
      'sw-cache-cache',
      'start-sw-cache-cache',
      'end-sw-cache-cache'
    );
    console.log('Model file cached in sw-cache.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromSWCache = async () => {
  try {
    performance.mark('start-sw-cache-restore');
    const modelCache = await caches.open('models');
    const response = await modelCache.match('model.bin');
    if (!response) {
      throw new Error(`File model.bin not found in sw-cache.`);
    }
    const file = await response.blob();
    performance.mark('end-sw-cache-restore');
    const mark = performance.measure(
      'sw-cache-restore',
      'start-sw-cache-restore',
      'end-sw-cache-restore'
    );
    console.log(mark.name, mark.duration.toFixed(2));
    console.log('Cached model file found in sw-cache.');
    return file;
  } catch (err) {    
    throw err;
  }
};

원본 비공개 파일 시스템 API

OPFS(Origin Private File System)는 스토리지 엔드포인트의 비교적 최신 표준입니다. 일반 파일 시스템과 달리 페이지 원본에는 비공개이므로 사용자에게 표시되지 않습니다. 이는 성능에 고도로 최적화된 특수 파일에 대한 액세스를 제공하고 해당 콘텐츠에 대한 쓰기 액세스 권한을 제공합니다.

예를 들어 다음은 OPFS에서 모델 파일을 저장하고 복원하는 방법입니다.

const storeFileInOPFS = async (blob) => {
  try {
    performance.mark('start-opfs-cache');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin', { create: true });
    const writable = await handle.createWritable();
    await blob.stream().pipeTo(writable);
    performance.mark('end-opfs-cache');
    const mark = performance.measure(
      'opfs-cache',
      'start-opfs-cache',
      'end-opfs-cache'
    );
    console.log('Model file cached in OPFS.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromOPFS = async () => {
  try {
    performance.mark('start-opfs-restore');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin');
    const file = await handle.getFile();
    performance.mark('end-opfs-restore');
    const mark = performance.measure(
      'opfs-restore',
      'start-opfs-restore',
      'end-opfs-restore'
    );
    console.log('Cached model file found in OPFS.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

색인화된 데이터베이스 API

IndexedDB는 브라우저에서 임의 데이터를 지속적으로 저장하기 위한 확립된 표준입니다. 다소 복잡한 API로 잘 알려져 있지만 idb-keyval과 같은 래퍼 라이브러리를 사용하면 IndexedDB를 기본 키-값 저장소처럼 취급할 수 있습니다.

예를 들면 다음과 같습니다.

import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

const storeFileInIDB = async (blob) => {
  try {
    performance.mark('start-idb-cache');
    await set('model.bin', blob);
    performance.mark('end-idb-cache');
    const mark = performance.measure(
      'idb-cache',
      'start-idb-cache',
      'end-idb-cache'
    );
    console.log('Model file cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromIDB = async () => {
  try {
    performance.mark('start-idb-restore');
    const file = await get('model.bin');
    if (!file) {
      throw new Error('File model.bin not found in IDB.');
    }
    performance.mark('end-idb-restore');
    const mark = performance.measure(
      'idb-restore',
      'start-idb-restore',
      'end-idb-restore'
    );
    console.log('Cached model file found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

스토리지를 영구 스토리지로 표시

이러한 캐싱 메서드 끝부분에서 navigator.storage.persist()를 호출하여 영구 저장소 사용 권한을 요청합니다. 이 메서드는 권한이 부여되면 true로 확인되고 권한이 부여되지 않으면 false로 확인되는 프로미스를 반환합니다. 브라우저는 브라우저별 규칙에 따라 요청을 이행하거나 적용하지 않을 수 있습니다.

if ('storage' in navigator && 'persist' in navigator.storage) {
  try {
    const persistent = await navigator.storage.persist();
    if (persistent) {
      console.log("Storage will not be cleared except by explicit user action.");
      return;
    }
    console.log("Storage may be cleared under storage pressure.");  
  } catch (err) {
    console.error(err.name, err.message);
  }
}

특수한 경우: 하드 디스크의 모델 사용

브라우저 스토리지 대신 사용자의 하드 디스크에서 AI 모델을 직접 참조할 수 있습니다. 이 기법을 사용하면 연구 중심의 앱이 브라우저에서 특정 모델을 실행할 수 있음을 보여주거나 아티스트가 전문 창작 앱에서 자체 학습된 모델을 사용할 수 있습니다.

File System Access API

File System Access API를 사용하면 하드 디스크에서 파일을 열고 IndexedDB에 유지할 수 있는 FileSystemFileHandle을 가져올 수 있습니다.

이 패턴을 사용하면 사용자가 모델 파일에 대한 액세스 권한을 한 번만 부여하면 됩니다. 지속된 권한 덕분에 사용자는 파일에 대한 액세스 권한을 영구적으로 부여하도록 선택할 수 있습니다. 앱과 필수 사용자 동작(예: 마우스 클릭)을 새로고침한 후 하드 디스크의 파일에 액세스할 수 있는 IndexedDB에서 FileSystemFileHandle를 복원할 수 있습니다.

필요한 경우 파일 액세스 권한이 쿼리되고 요청되므로 향후 새로고침에서 원활하게 수행할 수 있습니다. 다음 예는 하드 디스크에서 파일의 핸들을 가져온 다음 핸들을 저장 및 복원하는 방법을 보여줍니다.

import { fileOpen } from 'https://cdn.jsdelivr.net/npm/browser-fs-access@latest/dist/index.modern.js';
import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

button.addEventListener('click', async () => {
  try {
    const file = await fileOpen({
      extensions: ['.bin'],
      mimeTypes: ['application/octet-stream'],
      description: 'AI model files',
    });
    if (file.handle) {
      // It's an asynchronous method, but no need to await it.
      storeFileHandleInIDB(file.handle);
    }
    return file;
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error(err.name, err.message);
    }
  }
});

const storeFileHandleInIDB = async (handle) => {
  try {
    performance.mark('start-file-handle-cache');
    await set('model.bin.handle', handle);
    performance.mark('end-file-handle-cache');
    const mark = performance.measure(
      'file-handle-cache',
      'start-file-handle-cache',
      'end-file-handle-cache'
    );
    console.log('Model file handle cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromFileHandle = async () => {
  try {
    performance.mark('start-file-handle-restore');
    const handle = await get('model.bin.handle');
    if (!handle) {
      throw new Error('File handle model.bin.handle not found in IDB.');
    }
    if ((await handle.queryPermission()) !== 'granted') {
      const decision = await handle.requestPermission();
      if (decision === 'denied' || decision === 'prompt') {
        throw new Error(Access to file model.bin.handle not granted.');
      }
    }
    const file = await handle.getFile();
    performance.mark('end-file-handle-restore');
    const mark = performance.measure(
      'file-handle-restore',
      'start-file-handle-restore',
      'end-file-handle-restore'
    );
    console.log('Cached model file handle found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

이러한 메서드는 상호 배타적이지 않습니다. 브라우저에서 모델을 명시적으로 캐시하고 사용자 하드 디스크의 모델을 사용하는 경우가 있을 수 있습니다.

데모

MediaPipe LLM 데모에서 세 가지 일반 케이스 저장 방법과 구현된 하드 디스크 방법을 모두 확인할 수 있습니다.

보너스: 대용량 파일을 청크로 다운로드

인터넷에서 대규모 AI 모델을 다운로드해야 하는 경우 다운로드를 별도의 청크로 동시에 로드한 다음 클라이언트에서 다시 병합합니다.

다음은 코드에 사용할 수 있는 도우미 함수입니다. url만 전달하면 됩니다. chunkSize (기본값: 5MB), maxParallelRequests(기본값: 6), progressCallback 함수 (downloadedBytes 및 총 fileSize를 보고함), AbortSignal 신호의 signal은 모두 선택사항입니다.

프로젝트에 다음 함수를 복사하거나 npm에서 fetch-in-chunks 패키지를 설치할 수 있습니다.

async function fetchInChunks(
  url,
  chunkSize = 5 * 1024 * 1024,
  maxParallelRequests = 6,
  progressCallback = null,
  signal = null
) {
  // Helper function to get the size of the remote file using a HEAD request
  async function getFileSize(url, signal) {
    const response = await fetch(url, { method: 'HEAD', signal });
    if (!response.ok) {
      throw new Error('Failed to fetch the file size');
    }
    const contentLength = response.headers.get('content-length');
    if (!contentLength) {
      throw new Error('Content-Length header is missing');
    }
    return parseInt(contentLength, 10);
  }

  // Helper function to fetch a chunk of the file
  async function fetchChunk(url, start, end, signal) {
    const response = await fetch(url, {
      headers: { Range: `bytes=${start}-${end}` },
      signal,
    });
    if (!response.ok && response.status !== 206) {
      throw new Error('Failed to fetch chunk');
    }
    return await response.arrayBuffer();
  }

  // Helper function to download chunks with parallelism
  async function downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  ) {
    let chunks = [];
    let queue = [];
    let start = 0;
    let downloadedBytes = 0;

    // Function to process the queue
    async function processQueue() {
      while (start < fileSize) {
        if (queue.length < maxParallelRequests) {
          let end = Math.min(start + chunkSize - 1, fileSize - 1);
          let promise = fetchChunk(url, start, end, signal)
            .then((chunk) => {
              chunks.push({ start, chunk });
              downloadedBytes += chunk.byteLength;

              // Update progress if callback is provided
              if (progressCallback) {
                progressCallback(downloadedBytes, fileSize);
              }

              // Remove this promise from the queue when it resolves
              queue = queue.filter((p) => p !== promise);
            })
            .catch((err) => {              
              throw err;              
            });
          queue.push(promise);
          start += chunkSize;
        }
        // Wait for at least one promise to resolve before continuing
        if (queue.length >= maxParallelRequests) {
          await Promise.race(queue);
        }
      }

      // Wait for all remaining promises to resolve
      await Promise.all(queue);
    }

    await processQueue();

    return chunks.sort((a, b) => a.start - b.start).map((chunk) => chunk.chunk);
  }

  // Get the file size
  const fileSize = await getFileSize(url, signal);

  // Download the file in chunks
  const chunks = await downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  );

  // Stitch the chunks together
  const blob = new Blob(chunks);

  return blob;
}

export default fetchInChunks;

적합한 방법 선택

이 가이드에서는 브라우저에서 AI 모델을 효과적으로 캐시하는 다양한 방법을 살펴보았습니다. 이 작업은 사용자 경험과 앱 성능을 개선하는 데 중요한 작업입니다. Chrome 스토리지팀은 AI 모델에 대한 빠른 액세스를 보장하고 로드 시간을 줄이고 응답성을 개선하기 위해 최적의 성능을 제공하는 Cache API를 권장합니다.

OPFS 및 IndexedDB는 사용이 적은 옵션입니다. OPFS 및 IndexedDB API는 데이터를 저장하기 전에 직렬화해야 합니다. 또한 IndexedDB는 데이터를 가져올 때 데이터를 역직렬화해야 하므로 대규모 모델을 저장하기에 가장 적합한 위치입니다.

틈새 애플리케이션의 경우 File System Access API는 사용자 기기의 파일에 대한 직접 액세스를 제공하므로 자체 AI 모델을 관리하는 사용자에게 적합합니다.

AI 모델을 보호해야 하는 경우 서버에 보관하세요. 클라이언트에 저장한 후에는 DevTools 또는 OFPS DevTools 확장 프로그램을 사용하여 Cache와 IndexedDB 모두에서 데이터를 쉽게 추출할 수 있습니다. 이러한 Storage API의 보안은 본질적으로 동일합니다. 모델의 암호화된 버전을 저장하고 싶을 수 있지만 그런 다음 가로챌 수 있는 복호화 키를 클라이언트에 가져와야 합니다. 즉, 악의적인 행위자가 모델을 도용하려는 시도가 약간 더 어렵지만 불가능하지는 않습니다.

앱의 요구사항, 타겟층 행동, 사용되는 AI 모델의 특성에 맞는 캐싱 전략을 선택하는 것이 좋습니다. 이를 통해 다양한 네트워크 조건과 시스템 제약 조건에서도 애플리케이션이 반응하고 견고하게 작동합니다.


감사의 말씀

Joshua Bell, Reilly Grant, Evan Stade, Nathan Memmott, Austin Sullivan, Etienne Noël, André Bandaraa, Alexandra Klepper, François Beaufort, Paul Kinlan, Rachel Andrew가 이 내용을 검토했습니다.