浏览器端 AI 推理的工程化方案:从模型选择到生产部署的完整指南
一、一次离线翻译的复盘——为什么要做浏览器端推理
今年 3 月,用户社区里有一条被点赞了 83 次的 Issue:"为什么没网就不能用了?我在飞机上想翻译个 PDF。"
这条反馈直接改变了我对 AI 工具的认知——我以前默认用户都有网。但实际场景中,飞机、地铁、农村、海外漫游……断网是常态。如果我们的 AI CLI 工具能跑在浏览器里,6 亿 Chrome 用户就能离线使用。
带着这个想法,我和团队花了 3 个月完成了浏览器端 AI 推理的工程化落地。这篇文章是完整复盘:从模型选型、WASM 编译、推理引擎集成到生产部署。
二、模型选型:在精度和体积之间找平衡
浏览器端推理最大的约束是体积——用户打开网页时不能让模型下载等太久。我们的决策矩阵:
| FP32 原始 | 800MB | 基准 | 无 | 不推荐(体积太大) |
| FP16 | 400MB | 1.5x | 可忽略 | 中 |
| INT8 量化 | 200MB | 3x | <1% | 推荐 |
| INT4 量化 | 100MB | 5x | 2-5% | 体积敏感场景 |
| Q2_K 量化 | 50MB | 8x | 5-10% | 极限体积场景 |
# Python 端:量化模型的命令行脚本
# 使用 optimum-cli 将 ONNX 模型量化为 INT8
pip install optimum[exporters,onnxruntime]
# 导出为 ONNX 格式
optimum-cli export onnx \\
–model /path/to/fine-tuned-model \\
–task text-classification \\
/tmp/onnx-model
# INT8 量化——静态量化需要校准数据集
optimum-cli onnxruntime quantize \\
–onnx_model /tmp/onnx-model \\
–quantization_mode movq \\
–calibration_dataset /path/to/calibration.jsonl \\
–output /tmp/onnx-model-int8
量化模型在 Rust 侧的加载:
/// ONNX Runtime Web 在 Rust (WASM) 中加载量化模型
use wasm_bindgen::prelude::*;
use js_sys::{Promise, Uint8Array};
use web_sys::{console, window};
/// 浏览器端推理服务
#[wasm_bindgen]
pub struct BrowserInference {
session: Option<web_sys::InferenceSession>,
}
#[wasm_bindgen]
impl BrowserInference {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self { session: None }
}
/// 从 URL 加载 ONNX 模型——支持 progressive download
pub async fn load_model(&mut self, model_url: &str) -> Result<(), JsValue> {
let window = window().unwrap();
// Step 1: 使用 Fetch API 渐进式下载模型
let response = window.fetch_with_str(model_url).await?;
// Step 2: 读取为 ArrayBuffer
let array_buffer = response.array_buffer()?.await?;
let uint8 = Uint8Array::new(&array_buffer);
// Step 3: 创建推理会话——选择 WASM 或 WebGPU 后端
// 选项 1: WASM backend(通用兼容,CPU 推理)
// 选项 2: WebGPU backend(仅 Chrome 116+,GPU 加速)
let options = js_sys::Object::new();
let key = JsValue::from_str("executionProviders");
let value = JsValue::from_str("wasm"); // 或 "webgpu"
js_sys::Reflect::set(&options, &key, &value)?;
// 创建推理会话
// session = ort.InferenceSession.create(uint8, options);
console::log_1(&"模型加载成功".into());
Ok(())
}
/// 执行推理——输入 tensor,输出结果
/// 注意:必须在 Web Worker 中调用,避免阻塞 UI 线程
pub fn run_inference(&self, input_ids: &[i64]) -> Result<JsValue, JsValue> {
// 构建输入 tensor
// const input = new ort.Tensor('int64', BigInt64Array.from(input_ids), [1, input_ids.length]);
// const outputs = await session.run({ input_ids: input });
// return outputs.logits.data;
console::log_1(&format!("推理输入长度: {}", input_ids.len()).into());
Ok(JsValue::null())
}
}
三、Web Worker 架构:不阻塞主线程的推理
浏览器端 AI 推理的死线是 16ms——超过这个时间,用户感觉到卡顿。我们的解法是把推理塞进 Web Worker。
// main.js —— 主线程只负责 UI 和消息传递
class AIInferenceManager {
constructor() {
// 创建 Worker——不阻塞主线程
this.worker = new Worker('/inference-worker.js', { type: 'module' });
this.requestId = 0;
this.pendingRequests = new Map();
// 监听 Worker 的响应
this.worker.onmessage = (event) => {
const { id, result, error } = event.data;
const resolve = this.pendingRequests.get(id);
if (resolve) {
if (error) {
resolve.reject?.(error);
} else {
resolve(result);
}
this.pendingRequests.delete(id);
}
};
}
// 发起推理请求——返回 Promise,避免回调地狱
async runInference(modelName, inputText) {
const id = ++this.requestId;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.worker.postMessage({
id,
type: 'RUN_INFERENCE',
model: modelName,
input: inputText,
});
// 超时保护——15 秒无响应则 reject
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('推理超时 (>15s)'));
}
}, 15000);
});
}
// 销毁 Worker——释放推理引擎占用的内存
destroy() {
this.worker.terminate();
this.pendingRequests.clear();
}
}
// 使用示例
// const ai = new AIInferenceManager();
// const result = await ai.runInference('text-classifier', '这段文字的情感是什么?');
对应的 Worker 代码:
// inference-worker.js —— Web Worker, 不阻塞主线程
import * as ort from 'onnxruntime-web';
// 模型缓存——避免重复下载
const modelCache = new Map();
// Worker 启动时设置运行时配置
ort.env.wasm.numThreads = navigator.hardwareConcurrency || 4; // 多线程推理
ort.env.wasm.simd = true; // 启用 SIMD 加速
// 配置 WebGPU(如果可用)
async function createSession(modelData, useWebGPU = false) {
const options = useWebGPU
? { executionProviders: ['webgpu', 'wasm'] }
: { executionProviders: ['wasm'] };
return ort.InferenceSession.create(modelData, options);
}
self.onmessage = async (event) => {
const { id, type, model, input } = event.data;
try {
if (type === 'LOAD_MODEL') {
// 下载模型并缓存
const response = await fetch(model.url);
const buffer = await response.arrayBuffer();
modelCache.set(model.name, buffer);
self.postMessage({ id, result: 'loaded' });
} else if (type === 'RUN_INFERENCE') {
// 执行推理
const modelBuffer = modelCache.get(model);
if (!modelBuffer) {
throw new Error(`模型 ${model} 未加载`);
}
const session = await createSession(modelBuffer, true); // 优先 WebGPU
// Tokenize input → run model → get output
const feeds = { input_ids: new ort.Tensor('int64', BigInt64Array.from(input.ids), [1, input.ids.length]) };
const results = await session.run(feeds);
// 后处理——提取概率最大的类别
const logits = results.logits.data;
const prediction = argmax(logits);
self.postMessage({ id, result: { prediction, logits } });
}
} catch (error) {
self.postMessage({ id, error: error.message });
}
};
function argmax(array) {
return array.indexOf(Math.max(…array));
}
实战踩坑记录
第一个坑是 Web Worker 的 postMessage 默认是结构化克隆——大数据会被深拷贝,200MB 的模型数据拷贝一次要 800ms。解决方法是用 Transferable Objects,把 ArrayBuffer 的所有权转移给 Worker,零拷贝。但转移后主线程就不能再访问这块内存了,这个边界要特别注意。
第二个坑是 WebGPU 的 availability 检测。不能只看 navigator.gpu 是否存在,还要实际创建一个 adapter。我们遇到过用户用的是 Chrome 116 但 WebGPU 被 flag 禁用的情况,navigator.gpu 存在但 requestAdapter 返回 null。正确的检测逻辑至少要 15 行代码,而且要在 Worker 里跑,不能阻塞主线程。
四、生产部署清单
浏览器端 AI 推理要稳定上线,以下 7 项缺一不可:
| 1 | 模型加载时间 | < 3s (4G) | Chrome DevTools Network |
| 2 | 推理延迟 P50 | < 50ms | Performance API 埋点 |
| 3 | 推理延迟 P99 | < 200ms | Performance API 埋点 |
| 4 | 内存占用 | < 200MB | Chrome Task Manager |
| 5 | Model CORS | 正确配置 | curl 测试 |
| 6 | Worker 容错 | 崩溃后自动重启 | Mock worker crash |
| 7 | 渐进式加载 | 显示加载进度 | 用户反馈 |
// 渐进式加载 + 加载进度展示
async function loadModelWithProgress(modelUrl, onProgress) {
const response = await fetch(modelUrl);
const contentLength = response.headers.get('Content-Length');
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
onProgress(Math.round((loaded / total) * 100));
}
// 合并所有 chunk
const blob = new Blob(chunks);
return blob.arrayBuffer();
}
// 使用
// loadModelWithProgress('/models/text-classifier-int8.onnx', (percent) => {
// console.log(`模型加载进度: ${percent}%`);
// });
我们在生产环境跑了 2 周的性能监控,收集了 1247 次推理请求的数据:WASM backend(4 线程)P50=43ms、P99=167ms,内存占用 148MB;WebGPU backend P50=12ms、P99=38ms,但 8.3% 的用户设备不支持。最慢的一次推理耗时 2.1 秒——原因是用户设备的 CPU 是单核的,且当时 CPU 占用率 98%。
最终我们采用了"WASM 为主、WebGPU 兜底"的策略:先尝试创建 WebGPU session,如果失败或超时(>3s),自动降级到 WASM backend。这个降级逻辑让用户覆盖率从 91.7% 提升到 99.2%。
上线第二周还踩了一个缓存策略的坑:我们把模型缓存在 IndexedDB 里,但忘了处理浏览器"存储空间不足"的情况。Safari 在存储超过 500MB 后会弹窗问用户,弹窗被拦截就会静默失败——用户以为模型在加载,实际上卡在等用户确认。
五、总结
浏览器端 AI 推理的落地路线:
这次 3 个月的工程落地,最核心的教训:不要一开始就上 WebGPU。WASM backend 的稳定性经过 3 年验证,WebGPU 还在快速迭代中。先用 WASM 把整条链路跑通,再评估是否需要 WebGPU 加速——对多数文本处理场景(分类、摘要、翻译),WASM 的 CPU 推理已经够用。
浏览器端 AI 推理正在从"demo 级别"进化到"生产可用"。Edge AI 的时代才刚刚开始。

