浏览器本地跑大模型:WebGPU与JavaScript库的LLM推理实战
2026/9/9 1:24:46 网站建设 项目流程

前阵子接了个挺有意思的需求:把团队内部的文档问答助手做成一个纯静态网页,用户打开浏览器就能用,但所有对话数据绝不能出内网。云端 API 这条路直接堵死,本地起 Python 服务又没法塞给每个不懂技术的同事。折腾了一圈,我把方案落在了浏览器端本地推理上——不仅跑通了,还顺手用 Three.js 把 token 生成过程做成了一段相当唬人的 3D 动画。整个过程用到的核心库就三个:WebLLM、Transformers.js、Three.js。这篇文章就把这段从“异想天开”到“真的能跑”的过程完整拆开来讲,顺便把坑也一并倒了。

先说结论:浏览器跑 LLM 这件事,两年前还只是个噱头,现在确实到了可以落地的阶段,尤其适合数据敏感、设备可控、预算有限的内部工具场景。它不是什么万能银弹,但在正确的场景里,体验能超出预期。

1. 浏览器跑 LLM 这事,到底靠谱吗

1.1 一条被低估的推理路线

很多人听到“浏览器里跑大模型”第一反应是:浏览器不是用来打开网页的吗?模型推理不是应该发生在服务器吗?其实这两年底层硬件能力已经悄悄发生了变化。

过去浏览器想调用 GPU,只有 WebGL 这一条路,而 WebGL 是给图形渲染设计的,做通用计算非常憋屈。模型稍大一点,就得退回到 JavaScript 虚拟机里硬算,那速度基本告别实用。但现在不一样了,WebGPU 这套规范成熟起来之后,浏览器终于有了面向现代 GPU 的通用计算接口,可以在着色器阶段直接操作缓冲区、跑 compute shader。你可以把 GPU 想象成一个超大的分拣流水线:CPU 是快递员,一件件处理;GPU 是几百条传送带同时运转。LLM 推理这种“大量简单计算并行执行”的任务,天生就该让 GPU 来干。

再加上 WebAssembly 这几年性能追赶得很快,内存管理、二进制指令集都比纯 JavaScript 高效太多。于是就有了一个看起来很自然的组合:用 JavaScript 做上层调度,用 WebGPU 做底层计算引擎,用 WASM 做算子和数据结构的承载。浏览器端推理,就是从这套组合里长出来的。

1.2 到底适合谁用,不适合谁用

我知道很多人关心的是:这玩意儿能替代云端 API 吗?答案是看场景。我把浏览器本地推理和传统服务端 API 推理做了个对比,特点非常明显:

对比维度浏览器本地推理云端 API 推理
数据隐私数据完全不离开设备需要把对话内容发送到服务端
联网要求模型加载后可完全离线必须保持在线
使用成本无 token 费用,纯本地算力按 token 计费,长期成本高
模型规模受设备显存和内存限制可运行百亿/千亿级大模型
响应速度无网络延迟,依赖本地算力受网络波动影响
首体验门槛需下载数百 MB 到数 GB 模型打开即用

适合的场景很清晰:第一是数据敏感的行业,比如金融、医疗、政府、企业内部知识库,对话内容不允许经第三方服务器;第二是离线环境,比如展厅演示、户外设备、军工涉密场所;第三是成本敏感的小团队,十几个人的内部问答工具,完全没必要为每次对话付 API 费用;第四是教育演示场景,学生打开网页就能看到模型推理过程,比放几张架构图直观得多。

不适合的场景也有:比如需要超大上下文窗口的代码仓库分析、需要调用大量外部工具的复杂 Agent、高并发对外服务的产品。这些需求在浏览器端硬扛,既不稳定也不经济。我的建议很朴素:能上服务端就上服务端,只有在“不方便用服务端”的场景里,浏览器端推理才值得作为第一选择。

2. 三个 JavaScript 库的分工与选型

2.1 WebLLM:走 WebGPU 的推理主力

这个组合里最核心的库是 WebLLM,来自 MLC-LLM 项目团队。它的思路是把 LLM 的整个推理栈——权重、算子、采样器、KV cache 管理——全部编译成可以在 WebGPU 上直接运行的版本。你在浏览器里调它,本质上跟调一个本地的推理服务差不多,只不过这个服务跑在你的显卡里。

WebLLM 最让我欣赏的一点是 API 设计,几乎照搬了 OpenAI 的客户端风格。写过 OpenAI SDK 的人,上手这个库几乎零学习成本。模型加载、流式输出、stop 条件、对话模板封装这些全都内置了。加载一个模型只需要指定模型标识,库会自动去 Hugging Face 上拉取对应的 MLC 预编译权重。它支持 Qwen、Llama、Phi、Gemma 这些主流系列的量化版本,比如 q4f32 这种常见的 4-bit 量化格式,在模型体积和推理质量之间取得了不错的平衡。

实际体验下来,WebLLM 的性能确实对得起“浏览器端推理主力”这个定位。在 Apple Silicon 和带独显的 Windows 机器上,1.5B 级别的量化模型能跑出十几到二十几 token 每秒,跟 Python 端 ONNX Runtime 的差距已经缩小到可以接受的程度。这也让后续所有功能都建立在“推理速度真的能用”这个前提上。

2.2 Transformers.js:兜底兼容的老朋友

WebLLM 很优秀,但它有个硬前提:浏览器必须支持 WebGPU。如果用户用的是老版本浏览器、Firefox,或者一堆企业定制的安全浏览器,WebLLM 直接就没辙了。所以我在架构里保留了第二套方案:Transformers.js。

Transformers.js 是 Hugging Face 团队出的 JavaScript 库,原理是把 PyTorch 模型转成 ONNX 格式,然后交给 ONNX Runtime Web 去执行。它的兼容性非常好,只要浏览器能跑 WebAssembly,基本就能跑 Transformers.js。虽然速度比不上 WebGPU 路线,对模型的量化要求也更苛刻,但它胜在“哪都能跑”和“生态庞大”。Hugging Face 上有大量已经转好格式的 ONNX 模型可以直接拉下来用,不需要自己处理权重转换。

我选择它的逻辑很简单:WebGPU 支持情况好时,用 WebLLM 追求性能;遇到不支持 WebGPU 的环境,就自动降级到 Transformers.js 跑一个更小的模型。两套路线互不冲突,反而形成了互补。这个降级策略一开始就写进了架构,后面实际使用中也救了我好几次,比如在一次现场演示时,演示机的浏览器恰好没开 WebGPU,靠 Transformers.js 扛住了场面。

2.3 Three.js:让推理过程可感知

第三位选手是 Three.js。说到 Three.js,多数人第一反应是“做 3D 网页游戏”,确实它最知名的应用场景是 WebGL 可视化。但在这个项目里,Three.js 承担的是一个很多人忽视的任务:让 LLM 的推理过程变得可见、可感知。

用过 LLM 的人都知道,模型生成是有延迟的,尤其是本地推理,可能要好几秒才能蹦出第一个 token。如果界面上没有任何反馈,用户第一反应是“网页卡死了”,然后就开始狂点刷新。这个问题靠普通 loading 转圈解决不了,因为 loading 无法传递“模型正在一段一段思考”的感觉。我用 Three.js 搭建了一个 3D 场景:模型加载阶段显示一个旋转的进度环,推理阶段每一个新的 token 都会变成一个从场景深处飞向前方的粒子,色彩和运动速度会随生成节奏变化。用户看到粒子不断涌出,就能直观感觉到模型在“输出内容”,焦虑感一下子就降低了。

选 Three.js 还有一个现实原因:它对开发者的友好度极高,文档齐全、示例库庞大,社区里能抄的现成代码比任何一个 WebGPU 原生方案都多。与其自己用原生 WebGL 调相机、调光照,不如直接站在 Three.js 的肩膀上。

3. 手把手搭一个本地 LLM 演示台

3.1 初始化工程与依赖

整个前端工程我用了 Vite 做脚手架,没有引入复杂框架,就是纯 JavaScript,方便让团队里任何前端都能快速接手。

npm create vite@latest browser-llm-demo -- --template vanilla cd browser-llm-demo npm install @mlc-ai/web-llm @huggingface/transformers three

安装完成后,目录结构保持默认即可。我习惯把页面入口保持在index.html,把逻辑拆成三个模块:llm-webllm.jsllm-wasm.jsvisualizer.js,分别负责 WebLLM 主线推理、Transformers.js 降级推理、Three.js 场景渲染。这样拆的好处是每个文件职责单一,调试的时候不用反复滚动页面看几百行代码。

需要特别提醒的是部署环境。浏览器对本地推理有一个安全上下文的要求:navigator.gpu和很多能力接口都必须在 HTTPS 或者localhost环境下才可用。如果你在局域网内用 IP 访问页面,浏览器默认是不给开 GPU 接口的,所以内部测试阶段最好先通过反向代理加一层 HTTPS,或者直接用localhost访问。

3.2 用 WebLLM 加载模型并流式对话

核心链路非常简单,先看一段完整代码:

import { CreateMLCEngine } from "@mlc-ai/web-llm"; const engine = await CreateMLCEngine("Qwen2.5-1.5B-Instruct-q4f32_1-MLC", { initProgressCallback: (report) => { const percent = Math.round(report.progress * 100); updateProgressBar(`正在加载模型:${percent}%`); }, }); async function sendMessage(content) { const messages = [ { role: "system", content: "你是一个严谨、简洁的文档问答助手。" }, { role: "user", content }, ]; const chunks = await engine.chat.completions.create({ messages, stream: true, temperature: 0.8, max_tokens: 1024, }); let answer = ""; for await (const chunk of chunks) { const delta = chunk.choices[0]?.delta?.content ?? ""; answer += delta; renderAnswer(answer); } }

为什么选 Qwen2.5-1.5B-Instruct-q4f32_1?两个原因:一是 Qwen 系列的中文能力在同类小模型里属于第一梯队,用于内部文档问答很合适;二是 q4f32 量化后的权重文件大约在 1GB 出头,加载时间和内存占用都在可接受范围内。如果想追求更快的速度,可以换 0.5B 版本,但回答质量会明显下降;想提升质量可以上 7B 版本,但普通办公笔记本就很难流畅跑了。

这里面有个细节必须说明:WebLLM 加载模型的过程是异步的,期间 GPU 会有大量编译和权重搬运,所以首次加载会有明显卡顿,页面一定要有进度反馈。另外,模型标识里的Instruct后缀不是随意的,它对应着特定的对话模板处理,如果不带 Instruct 后缀,模型会不知道什么时候该停止说话,容易出现胡言乱语。

3.3 加一层 Transformers.js 降级策略

有了 WebLLM 主线之后,降级方案就好写了。核心就是判断当前浏览器是否支持 WebGPU:

function isWebGPUSupported() { return typeof navigator !== "undefined" && "gpu" in navigator; }

如果支持,就走 WebLLM 逻辑;如果不支持,就用 Transformers.js 加载一个小号模型:

import { pipeline } from "@huggingface/transformers"; async function createWasmEngine() { const pipe = await pipeline("text-generation", "onnx-community/Qwen2.5-0.5B-Instruct"); return { async chat(messages) { const prompt = messages.map(m => `${m.role}: ${m.content}`).join("\n"); const output = await pipe(prompt, { max_new_tokens: 256, temperature: 0.7, do_sample: true, }); return output[0].generated_text; }, }; }

注意,Transformers.js 通常是一次性返回完整结果,不像 WebLLM 支持真正的流式。虽然新版本也在尝试支持流式,但考虑到兼容层本来就是“保底方案”,体验稍差一点可以接受。更稳妥的做法是给用户一个显性的“轻量模式”开关,让用户在老旧设备上主动选择,避免自动降级后模型能力不足导致误解。

3.4 用 Three.js 做 token 可视化

这部分是项目里最出彩的地方,代码本身并不复杂,核心思路是:拿 Three.js 搭一个场景,然后每次收到新的 token 增量时,往场景里发射一个粒子。

import * as THREE from "three"; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z = 8; const renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.getElementById("canvas-wrap").appendChild(renderer.domElement); const particles = []; function spawnParticle(token) { const geometry = new THREE.SphereGeometry(0.06, 8, 8); const material = new THREE.MeshBasicMaterial({ color: token.length > 2 ? 0xff6633 : 0x33aaff, }); const mesh = new THREE.Mesh(geometry, material); mesh.position.set((Math.random() - 0.5) * 2, -3, -2); particles.push(mesh); scene.add(mesh); } function animate() { requestAnimationFrame(animate); particles.forEach((mesh, i) => { mesh.position.y += 0.08; if (mesh.position.y > 4) { scene.remove(mesh); particles.splice(i, 1); } }); renderer.render(scene, camera); } animate();

在流式回调里面调用spawnParticle(delta)就行。我加了一点小设计:普通 token 用蓝色,中文 token 长度较长用橙色,这样画面会形成“中文密集处泛橙”的视觉规律,看起来很像模型在思考时点燃了一簇簇火花。

如果你之前见过网上很火的“Three.js 3D 火箭发射动画特效”,思路完全一致,都是粒子系统加补间动画的玩法。这套可视化不仅能取悦用户,对开发者自己监控推理状态也很有用:如果粒子生成速度明显放缓,基本可以判断是 GPU 资源被其他进程占用了。

3.5 页面部署与首屏优化

这个项目本质上还是一个纯静态页面,部署很简单,把dist目录丢到任何静态服务器即可。但首屏优化有几个点值得单独说。

第一,不要一打开页面就加载模型。模型文件动辄 1GB,在网速一般的内网环境里可能要等好几分钟。我的做法是默认页面只加载 UI 和 Three.js 场景,等用户点击“启动模型”按钮后才触发模型加载,同时显示下载进度。第二,用 Cache Storage 做模型文件缓存,第二次访问时直接从本地缓存读取,加载速度能快一个量级。具体实现可以监听 Service Worker 的fetch事件,把带特定前缀的模型请求优先拦截。第三,把模型资源放到独立的 CDN 路径下,不要跟页面静态资源混在一起,这样后续更新模型版本时,页面代码和权重文件可以独立回滚。

4. 踩坑记录与性能优化

4.1 模型下载:从磨蹭到秒开

这个项目第一个大坑就是首次模型下载。WebLLM 默认从 Hugging Face 的公共仓库拉模型,但在某些网络环境下,从公共模型仓库拉取 1GB 文件的过程非常痛苦,进度条几十 KB 地跳,看着能把人急死。

解决办法是把模型资源提前下载到自己的对象存储或者 CDN 上。WebLLM 支持通过初始化参数覆盖模型的 URL 前缀,你只需要把 MLC 预编译权重目录完整地传到自己服务器上,然后指定新的基础地址。传文件的时候一定要保持目录结构不变,MC 区块链文件、参数文件、tokenizer 配置缺一不可,少一个都会导致加载失败。

另外我强烈建议在发布前就提前把模型文件拉齐,而不是让用户现场等待。部署后可以写一个预热脚本,用无头浏览器访问页面触发加载流程,让 CDN 节点先把模型权重缓存到位。实测下来,预热后的内网访问速度能控制在几秒内加载完 1GB 文件,用户体验完全是两个级别。

4.2 内存溢出与 WebGPU 设备丢失

跑了一段时间后,我发现页面会随着对话轮数增加变得越来越卡,最后直接白屏报错。排查后发现问题出在两个地方:一是 LLM 推理时 KV cache 会持续占用显存,对话轮数多了,显存就会被逐渐吃满;二是某些版本的 WebGPU 实现在页面长时间运行后不会主动回收资源,导致内存只增不减。

对策也很直接:限制单次会话的对话轮数,比如最多 8 轮,达到上限后自动清空上下文并重建推理引擎。同时监听 WebGPU 设备的lost事件,一旦出现设备丢失就重新初始化,避免页面直接白屏。

const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); device.addEventListener("lost", (event) => { console.warn("WebGPU device lost:", event.message); resetEngine(); });

这些逻辑一开始以为用不上,结果在实际使用中救了几次急。特别是开会演示的时候,如果演示机只有 8GB 内存,连续对话时间一长,很容易踩到这个雷。现在我会在演示前主动重启一次页面,把风险降到最低。

4.3 那些“看起来没问题”的兼容性坑

浏览器兼容性问题比想象中隐蔽得多。我整理了一张表,方便大家对照排查:

浏览器环境WebGPU 支持推荐策略
Chrome / Edge 桌面端默认支持,部分旧版本需开启 Flag优先 WebLLM
Safari 桌面端(macOS)近版本部分支持,不稳定因素多WebLLM + Transformers.js 双保险
iOS Safari较新版本支持 WebGPU内存受限,建议小模型或 WASM
Firefox 桌面/安卓不支持 WebGPUTransformers.js
企业定制浏览器视内核而定,普遍配置落后Transformers.js

除了 WebGPU 本身的兼容性,还有一个坑是安全上下文。页面必须跑在 HTTPS 或localhost下,否则连navigator.gpu都不存在。如果你在局域网里部署,最好给访问者配自签名证书,并一次性信任,否则每次打开页面都会被安全策略拦一道。

移动端方面更需要谨慎,我实测在手机上跑 1.5B 模型,发热非常明显,用几分钟就掉电厉害。如果目标平台包含手机,要么把模型降到 0.5B 以下,要么直接禁止在低内存设备上自动加载大模型。

4.4 真实性能数据参考

性能是所有人最关心的问题,但也是变化最多的数据。我在几台真实设备上跑过,列一个大致的参考区间:

设备模型实测速度
MacBook Pro M1 ProQwen2.5-1.5B-Instruct-q4f3215~25 token/s
RTX 3060 笔记本Llama-3-8B-Instruct-q4f328~15 token/s
Intel 核显轻薄本Qwen2.5-0.5B-Instruct5~10 token/s
iPhone 15 ProQwen2.5-1.5B-Instruct-q4f328~12 token/s

同样是 1.5B 模型,M 系列芯片设备上的 WebGPU 实现明显更成熟,速度波动也小。Intel 核显虽然也能跑,但速度上下起伏大,用户能明显感觉到“一会儿快一会儿慢”。用这些数据给需求方汇报时我会特意标注:这只是体感参考,WebGPU 驱动一直在更新,同型号设备不同系统版本跑出来的差异可能很大。

5. 后续还能怎么玩

5.1 纯浏览器 RAG

顺着这个项目往下走,我最看好的方向是纯浏览器 RAG。既然推理能留在本地,embedding 也能用 Transformers.js 在浏览器里算,那整个知识库链路就都不需要服务器了。用户可以本地上传文档,文档在浏览器内完成切块、向量化,然后用简单的余弦相似度检索,最后把检索结果喂给本地 LLM。

这个方案对中小企业特别有吸引力,等于把云端知识库搬到了桌面上,文档不外传,服务零成本。代码层面的难点主要是向量检索效率,但几千个片段以内的知识库,纯 JavaScript 的线性扫描也够用。

5.2 语音与多模态方向

另一个很自然的方向是把浏览器原生的 SpeechRecognition 和 SpeechSynthesis 接进来,做一个纯前端语音助手。用户对着浏览器说话,语音识别成文字,本地 LLM 生成回答,再用语音合成念出来,整个链路全程不走服务器。如果再配合 Three.js 做一个拟人化的 3D 角色,那就真的像个“虚拟助手”了。

这个组合我在本地已经跑通了原型,延迟主要出在语音识别环节,本地模型生成反倒不是瓶颈。如果后续浏览器端语音识别模型也能像 WebLLM 一样直接调 GPU,体验会再上一个台阶。

这套方案跑了小一个月,最大的感受是“能跑”和“好用”之间差着一堆细节。模型选型、资源预热、降级策略、可视化反馈,每一步都踩了不少坑。如果你也想做类似的东西,建议先从 1.5B 级别的模型起步,先把端到端流程跑通,再考虑怎么提升质量和体验。毕竟浏览器端推理的价值不在“替代服务器”,而在于给那些服务器到不了的地方,留了一扇门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询