欢迎光临
我们一直在努力

WebAssembly AI 插件缓存策略:把模型权重缓存在 IndexedDB 里

WebAssembly AI 插件缓存策略:把模型权重缓存在 IndexedDB 里

一、每次刷新都要下载几百兆,这谁顶得住

最近在做一个浏览器端的 AI 插件,用 Rust 编译成 WASM 在浏览器里跑推理。想法很好——用户数据不出浏览器,隐私有保障。但很快就碰到了一个现实问题:模型权重文件动不动就是几十兆甚至几百兆,每次刷新页面都要重新下载。

对于用户来说,打开一个网页要等几十秒才能用,这个体验基本等于劝退。

解决思路其实很直接——浏览器不是有 IndexedDB 吗?把模型文件缓存到 IndexedDB 里,第一次加载后存起来,之后直接从本地读取。但这个方案在实际落地中有不少细节需要注意:WASM 怎么和 IndexedDB 打通?版本更新了怎么办?浏览器清理了缓存怎么处理?

这篇文章就聊聊我在这方面的实践。

二、缓存流程设计

先画一下整体的缓存流程,这样在写代码之前就知道每一步要做什么:

flowchart TD
A[用户打开页面] –> B{检查 IndexedDB}
B –>|缓存存在| C[读取缓存的模型版本号]
C –> D{版本号与服务端一致?}
D –>|是| E[从 IndexedDB 加载模型到内存]
E –> F[WASM 推理就绪]
D –>|否| G[清除旧缓存]
G –> H[从 CDN 下载新模型]
B –>|缓存不存在| H
H –> I[将模型存入 IndexedDB]
I –> J[记录版本号]
J –> F
F –> K[用户正常使用]

这个流程里有两个核心节点:版本校验和存储管理。版本校验确保用户不会用到一个过时的模型;存储管理确保不会无限膨胀占用磁盘空间。

三、代码实现:Rust 侧与 JS 侧的协作

WASM 不能直接操作浏览器的 IndexedDB API,所以需要 Rust 通过 wasm-bindgen 调用 JS 侧暴露的函数。我把缓存逻辑拆成两部分:JS 侧负责 IndexedDB 的读写,Rust 侧负责调用和整体流程控制。

3.1 JS 侧:封装 IndexedDB 操作

// db_cache.js —— 封装 IndexedDB 的读写操作

const DB_NAME = 'ai-model-cache';
const DB_VERSION = 1;
const STORE_NAME = 'models';

/**
* 打开数据库
* 如果数据库不存在会自动创建
*/
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建存储对象,用模型文件 URL 作为主键
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'url' });
}
};

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

/**
* 将模型数据存入 IndexedDB
* @param {string} url – 模型文件的 URL,作为唯一标识
* @param {ArrayBuffer} data – 模型文件的二进制数据
* @param {string} version – 模型的版本号
*/
export async function storeModel(url, data, version) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);

// put 方法:如果已存在则覆盖,不存在则新增
store.put({
url: url,
data: data,
version: version,
timestamp: Date.now(), // 记录存储时间,方便后续清理
});

return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}

/**
* 从 IndexedDB 读取缓存的模型数据
* @param {string} url – 模型文件 URL
* @returns {Object|null} 缓存的数据对象,不存在则返回 null
*/
export async function loadModel(url) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);

return new Promise((resolve, reject) => {
const request = store.get(url);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}

/**
* 删除指定模型的缓存
*/
export async function deleteModel(url) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.delete(url);

return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}

3.2 Rust 侧:调用 JS 函数并进行版本管理

use wasm_bindgen::prelude::*;
use web_sys::console;

// 声明 JS 侧暴露的函数
#[wasm_bindgen(module = "/js/db_cache.js")]
extern "C" {
// 存储模型到 IndexedDB
async fn storeModel(url: &str, data: &[u8], version: &str);

// 从 IndexedDB 加载模型
async fn loadModel(url: &str) -> JsValue;

// 删除模型缓存
async fn deleteModel(url: &str);
}

/// 模型加载器,负责缓存管理和版本校验
pub struct ModelLoader {
/// 模型文件的 CDN URL
model_url: String,
/// 期望的模型版本号(通常从服务端 API 获取)
expected_version: String,
}

impl ModelLoader {
pub fn new(model_url: &str, expected_version: &str) -> Self {
Self {
model_url: model_url.to_string(),
expected_version: expected_version.to_string(),
}
}

/// 获取模型数据,优先从缓存读取
pub async fn load(&self) -> Result<Vec<u8>, String> {
// 第一步:尝试从 IndexedDB 读取缓存
let cached = loadModel(&self.model_url).await;

// 检查缓存是否存在且版本匹配
if !cached.is_null() && !cached.is_undefined() {
// 从返回的 JS 对象中提取 version 字段
let cached_version = js_sys::Reflect::get(&cached, &JsValue::from_str("version"))
.map_err(|_| "无法读取缓存版本".to_string())?
.as_string()
.unwrap_or_default();

if cached_version == self.expected_version {
// 缓存命中且版本匹配,直接使用
console::log_1(&"模型缓存命中,从 IndexedDB 加载".into());

let data = js_sys::Reflect::get(&cached, &JsValue::from_str("data"))
.map_err(|_| "无法读取缓存数据".to_string())?;

// 将 JS 的 ArrayBuffer 转换为 Rust 的 Vec<u8>
let uint8_array = js_sys::Uint8Array::new(&data);
let mut buffer = vec![0u8; uint8_array.length() as usize];
uint8_array.copy_to(&mut buffer);

return Ok(buffer);
} else {
// 版本不匹配,清除旧缓存
console::log_1(&"模型版本已更新,清除旧缓存".into());
deleteModel(&self.model_url).await;
}
}

// 第二步:从 CDN 下载模型
console::log_1(&"从 CDN 下载模型文件…".into());
let model_data = self.download_model().await?;

// 第三步:存入 IndexedDB 作为缓存
storeModel(&self.model_url, &model_data, &self.expected_version).await;
console::log_1(&"模型已缓存到 IndexedDB".into());

Ok(model_data)
}

/// 使用浏览器的 fetch API 下载模型文件
async fn download_model(&self) -> Result<Vec<u8>, String> {
let mut opts = web_sys::RequestInit::new();
opts.method("GET");
opts.mode(web_sys::RequestMode::Cors);

let request = web_sys::Request::new_with_str_and_init(&self.model_url, &opts)
.map_err(|_| "创建请求失败".to_string())?;

let window = web_sys::window().ok_or("无法获取 window 对象")?;
let resp_value = wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "下载请求失败".to_string())?;

let response: web_sys::Response = resp_value
.dyn_into()
.map_err(|_| "类型转换失败".to_string())?;

let buffer = wasm_bindgen_futures::JsFuture::from(
response.array_buffer().map_err(|_| "读取响应失败")?,
)
.await
.map_err(|_| "读取 ArrayBuffer 失败".to_string())?;

let uint8_array = js_sys::Uint8Array::new(&buffer);
let mut data = vec![0u8; uint8_array.length() as usize];
uint8_array.copy_to(&mut data);

Ok(data)
}
}

在实际使用时,ModelLoader 的调用非常简洁:

// 在 WASM 入口函数中使用
#[wasm_bindgen(start)]
pub async fn main() -> Result<(), JsValue> {
// 从服务端获取最新的模型版本号
let current_version = fetch_model_version().await?;

let loader = ModelLoader::new(
"https://cdn.example.com/models/embedding.onnx",
&current_version,
);

match loader.load().await {
Ok(model_data) => {
console::log_1(&format!("模型加载成功,大小: {} 字节", model_data.len()).into());
// 初始化推理引擎…
}
Err(e) => {
console::error_1(&format!("模型加载失败: {}", e).into());
}
}

Ok(())
}

四、边界与注意事项

IndexedDB 的存储上限。不同浏览器的限制不一样,但大体上可用空间在可用磁盘的 50%~60% 左右。对于几百兆的模型文件,在桌面端通常没问题,但在移动端需要注意。我建议在存储前调用 navigator.storage.estimate() 检查剩余空间。

浏览器可能清理 IndexedDB。用户手动清除浏览数据、浏览器在磁盘空间不足时,都可能删除 IndexedDB 中的数据。所以缓存逻辑必须假设"缓存可能不存在",随时准备好走下载流程。这也是为什么版本校验很重要——不能假设缓存一定是最新的。

版本管理策略。我用的是一个简单的字符串版本号(比如 "v2.3.1")。更稳妥的做法是同时存储文件的 hash 值,加载后用 hash 校验完整性。但如果模型文件很大,计算 hash 本身也有开销,需要权衡。

多次打开页面的并发问题。如果用户快速打开多个标签页,每个标签页可能同时尝试下载模型。可以通过一个共享的 Service Worker 或者 BroadcastChannel API 来协调,让其他标签页等待第一个标签页的下载完成。

IndexedDB 的写入也有性能细节。模型文件几百兆时,一次性 put 可能让浏览器 UI 卡顿。可以分块存储,每块 10-20MB,写入后记录分块元信息。读取时按块拼接,还能做断点续传和部分校验。存储不是一锤子买卖。

五、总结

把模型权重缓存到 IndexedDB 这个方案,实际落地后效果相当明显——首次加载需要十几秒(取决于网速),但之后刷新页面几乎是秒开。对于那些需要频繁打开关闭的浏览器端 AI 应用,这个优化是刚需。

Rust + WASM 的组合在处理这类场景时有个优势:Rust 侧可以保持清晰的业务逻辑,不需要在 JS 里写太多状态管理代码。wasm-bindgen 把两边的桥接做得足够顺滑。

当然,这个方案也有它的局限性。IndexedDB 的异步 API 决定了模型加载过程不能是同步的,所以你需要在应用初始化阶段就触发加载,而不是等用户操作时才临时去读缓存。做好预加载,体验就不错。

赞(0)
未经允许不得转载:171主机测评 » WebAssembly AI 插件缓存策略:把模型权重缓存在 IndexedDB 里
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址