全部 FastVLM 指南
操作步骤
- 1
检查 WebGPU 和 shader-f16,准备 Node.js 22+。
- 2
下载并解压项目,安装依赖,再启动 Vite。
- 3
选择收据样例并运行,检查 $15.00 答案。
- 4
阅读 Worker、验证取消与 JSON 导出,再构建到 HTTPS 站点。
示例
Browser
curl -fLO https://fastvlm.net/examples/fastvlm-webgpu.zip
unzip fastvlm-webgpu.zip
cd fastvlm-webgpu
npm install
npm run dev
# Open the localhost URL printed by Vite.
# Production: npm run build, then host all of dist/ over HTTPS. 构建可运行的图片问答应用
下载 Vite + TypeScript 完整项目,使用与本站相同的推理 Worker。包含收据样例、本地图片输入、流式回答、下载进度、取消和 JSON 导出。
使用 Node.js 22+,以及同时提供 WebGPU 和 shader-f16 的浏览器。本地使用 localhost,部署后需要 HTTPS。首次运行从 Hugging Face 下载约 1.1 GB 文件,还需足够的空闲 GPU 内存。
验证第一次运行
打开 Vite 输出的 localhost 地址,选择收据样例,再点击 Run。检查答案是否包含 $15.00;导出 JSON 查看原始答案和实测耗时。下载时点击 Cancel,再重新运行,检查恢复流程。
应用如何工作
- 主线程解码图片,将最长边限制为 1024 像素,再把像素和问题交给 Worker。模型运行时页面仍可响应操作。
- Worker 按需加载处理器和 ONNX 模型,逐步发送文字,最后返回完整答案与耗时。同一个 Worker 保留模型会话,供后续问题复用。
- 取消会终止 Worker。下次运行需要重建会话;浏览器模型缓存可能避免重新下载权重,是否可复用取决于来源站点和浏览器。
耗时如何定义
模型准备包含缓存读取、下载和会话初始化。“图片 → 回答”从图片处理前计时,到生成结束。首段文字截止于第一个非空流式文本片段,不等同于严格的首 token 基准。导出的结果不含图片像素。
阅读源码: src/main.ts
src/main.ts
import { checkBrowserCapability } from './capabilities';
import type { WorkerResponse } from './protocol';
const input = document.querySelector<HTMLInputElement>('#image')!;
const prompt = document.querySelector<HTMLTextAreaElement>('#prompt')!;
const preview = document.querySelector<HTMLImageElement>('#preview')!;
const status = document.querySelector<HTMLElement>('#status')!;
const answer = document.querySelector<HTMLElement>('#answer')!;
const run = document.querySelector<HTMLButtonElement>('#run')!;
const cancel = document.querySelector<HTMLButtonElement>('#cancel')!;
const sample = document.querySelector<HTMLButtonElement>('#sample')!;
const exportButton = document.querySelector<HTMLButtonElement>('#export')!;
let worker: Worker | null = null;
let image = '';
let busy = false;
let inputVersion = 0;
let result: Extract<WorkerResponse, { type: 'result' }> | null = null;
let resultPrompt = '';
function setBusy(value: boolean) {
busy = value;
input.disabled = prompt.disabled = sample.disabled = value;
run.disabled = value || !image || !prompt.value.trim();
cancel.hidden = !value;
exportButton.disabled = value || !result;
}
function stop() { worker?.terminate(); worker = null; setBusy(false); }
async function selectImage(blob: Blob) {
const version = ++inputVersion;
result = null; image = ''; preview.hidden = true;
answer.textContent = ''; status.textContent = '';
setBusy(true);
const url = URL.createObjectURL(blob);
try {
const decoded = new Image(); decoded.src = url; await decoded.decode();
if (version !== inputVersion) return;
const scale = Math.min(1, 1024 / Math.max(decoded.width, decoded.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(decoded.width * scale));
canvas.height = Math.max(1, Math.round(decoded.height * scale));
const context = canvas.getContext('2d')!;
context.fillStyle = 'white'; context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(decoded, 0, 0, canvas.width, canvas.height);
image = canvas.toDataURL('image/png'); preview.src = image; preview.hidden = false;
} catch { status.textContent = 'Cannot read this image. Choose a JPEG, PNG or WebP.'; }
finally { URL.revokeObjectURL(url); if (version === inputVersion) setBusy(false); }
}
input.onchange = () => {
const file = input.files?.[0]; input.value = '';
if (!file) return;
if (!['image/jpeg','image/png','image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
image = ''; preview.hidden = true; result = null; answer.textContent = ''; setBusy(false);
status.textContent = 'Use a JPEG, PNG or WebP of at most 10 MB.'; return;
}
void selectImage(file);
};
sample.onclick = async () => {
const version = ++inputVersion;
setBusy(true);
try {
const response = await fetch('/sample.png'); if (!response.ok) throw new Error('Sample unavailable');
const blob = await response.blob(); if (version !== inputVersion) return;
await selectImage(blob);
} catch { if (version === inputVersion) { setBusy(false); status.textContent = 'Sample unavailable. Choose a local image.'; } }
};
prompt.oninput = () => setBusy(busy);
cancel.onclick = () => { inputVersion++; stop(); status.textContent = 'Cancelled. Run again when ready.'; };
run.onclick = async () => {
if (busy || !image || !prompt.value.trim()) return;
result = null; answer.textContent = ''; setBusy(true);
status.textContent = 'Checking this browser…';
const version = ++inputVersion;
const capability = await checkBrowserCapability();
if (version !== inputVersion) return;
if (!capability.supported) { setBusy(false); status.textContent = 'WebGPU with shader-f16 is required. See the Python guide below.'; return; }
resultPrompt = prompt.value.trim();
try {
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }: MessageEvent<WorkerResponse>) => {
if (version !== inputVersion) return;
if (data.type === 'loading') status.textContent = 'Downloading/preparing model…';
if (data.type === 'progress') status.textContent = `Model files read: ${data.loadedMB} MB; preparing model…`;
if (data.type === 'running') status.textContent = 'Reading image…';
if (data.type === 'text') answer.textContent = data.text;
if (data.type === 'result') {
result = data; answer.textContent = data.text; setBusy(false);
status.textContent = `Preparation: ${data.timing.load_ms} ms · First text: ${data.timing.first_text_ms ?? '—'} ms · Image to answer: ${data.timing.inference_ms} ms`;
}
if (data.type === 'error') { status.textContent = `Failed during ${data.phase}: ${data.message}`; stop(); }
};
worker.onerror = () => { if (version === inputVersion) { status.textContent = 'Worker failed. Retry or use local Python.'; stop(); } };
worker.postMessage({ image, prompt: resultPrompt });
} catch { status.textContent = 'Could not start the worker.'; stop(); }
};
exportButton.onclick = () => {
if (!result) return;
const url = URL.createObjectURL(new Blob([JSON.stringify({ ...result, prompt: resultPrompt }, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'fastvlm-result.json'; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
void checkBrowserCapability().then(value => {
document.querySelector('#capability')!.textContent = value.supported ? 'WebGPU and shader-f16 available. Free GPU memory is also required.' : 'This browser does not meet the WebGPU / shader-f16 requirements. See the Python guide below.';
});
window.addEventListener('pagehide', () => { inputVersion++; stop(); });
阅读源码: src/worker.ts
src/worker.ts
import { AutoModelForImageTextToText, AutoProcessor, RawImage, TextStreamer, env } from '@huggingface/transformers';
import type { LlavaProcessor, PreTrainedModel, Tensor } from '@huggingface/transformers';
import type { RunRequest, WorkerResponse } from './protocol';
env.allowLocalModels = false;
const modelId = 'onnx-community/FastVLM-0.5B-ONNX';
let model: PreTrainedModel | null = null;
let processor: LlavaProcessor | null = null;
let busy = false;
const send = (message: WorkerResponse) => self.postMessage(message);
self.onmessage = async ({ data }: MessageEvent<RunRequest>) => {
if (busy) return;
busy = true;
let phase: 'load' | 'inference' = 'load';
const loadStart = performance.now();
try {
if (!model || !processor) {
send({ type: 'loading' });
const files = new Map<string, number>();
let lastMB = -1;
const progress_callback = (info: { status: string; file?: string; loaded?: number }) => {
if (info.status !== 'progress' || !info.file || typeof info.loaded !== 'number') return;
files.set(info.file, info.loaded);
const loadedMB = Math.floor([...files.values()].reduce((sum, bytes) => sum + bytes, 0) / 1_000_000);
if (loadedMB !== lastMB) { lastMB = loadedMB; send({ type: 'progress', loadedMB }); }
};
processor = await AutoProcessor.from_pretrained(modelId, { progress_callback }) as LlavaProcessor;
model = await AutoModelForImageTextToText.from_pretrained(modelId, {
progress_callback,
device: 'webgpu',
dtype: { embed_tokens: 'fp16', vision_encoder: 'q4', decoder_model_merged: 'q4' },
});
}
const loadMs = Math.round(performance.now() - loadStart);
send({ type: 'loaded', duration: loadMs });
phase = 'inference';
send({ type: 'running' });
const start = performance.now();
const image = await RawImage.fromURL(data.image);
const prompt = processor.apply_chat_template([
{ role: 'system', content: 'You are a helpful visual assistant. Answer the question accurately and concisely.' },
{ role: 'user', content: `<image>${data.prompt}` },
], { add_generation_prompt: true });
const inputs = await processor(image, prompt, { add_special_tokens: false });
let firstText: number | null = null;
let streamed = '';
const streamer = new TextStreamer(processor.tokenizer!, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (chunk: string) => {
if (chunk && firstText === null) firstText = performance.now() - start;
streamed += chunk;
send({ type: 'text', text: streamed });
},
});
const output = await model.generate({ ...inputs, max_new_tokens: 192, do_sample: false, repetition_penalty: 1.2, streamer }) as Tensor;
const inputLength = inputs.input_ids.dims.at(-1)!;
const generated = output.slice(null, [inputLength, null]);
const text = processor.batch_decode(generated, { skip_special_tokens: true })[0].trim();
send({ type: 'result', model: modelId, text, timing: {
load_ms: loadMs,
inference_ms: Math.round(performance.now() - start),
first_text_ms: firstText === null ? null : Math.round(firstText),
generated_tokens: generated.dims.at(-1) ?? null,
} });
} catch (error) {
// No input or raw exception text leaves the worker in telemetry.
send({ type: 'error', phase, message: error instanceof Error ? error.message : 'Inference failed' });
if (phase === 'load') { model = null; processor = null; }
} finally { busy = false; }
};
构建与部署
npm run build 输出 dist/,将完整目录部署到 HTTPS 站点,保留 JavaScript 与 WASM 文件。权重由访问者的浏览器下载,Worker 与配套资源路径需要一起保留。
运行不成功时
- WebGPU 可用但缺少 shader-f16:下载权重前就提供 Python 替代入口。
- 模型准备很慢:区分文件读取与会话初始化;未知总量时不要显示虚构的百分比。
- GPU 内存不足:关闭其他模型标签页。缩小上传图片不会缩小模型权重。
- 部署后 Worker 或 WASM 404:确认 dist 资源上传完整,并检查站点基础路径。
一手文档: Transformers.js WebGPU · ONNX model · Example source