在实际的 AI 应用开发里,大模型推理通常发生在服务器上,而 WebLLM Chat 代表的则是完全相反的一条路径:把模型推理直接放到用户浏览器里。WebLLM 是一个基于 WebGPU 与 WebAssembly 的开源端侧推理项目,浏览器加载 MLC 编译过的模型权重后,不再需要后端推理服务,对话生成全部在本地完成。这种形态也被称为浏览器端 AI 推理,它把隐私、成本、离线能力和系统架构这几件事同时改变了。
这篇文章会从原理讲到工程实现,最终在浏览器里跑通一个可复现的 WebLLM Chat 页面。内容覆盖 WebGPU 兼容性、模型选择、代码实现、流式对话、运行验证和常见问题排查。适合三类读者:想给产品加端侧 AI 能力的前端工程师,初次接触浏览器推理的算法同学,以及需要评估端侧大模型方案的 AI 应用负责人。读完你能得到一个最小可运行示例,也能判断 WebLLM 这条路在你的业务里到底值不值得走。
1. WebLLM 是什么:把大模型推理从服务器搬到浏览器
1.1 浏览器为什么能运行大语言模型
传统大语言模型推理依赖 GPU 计算,而浏览器本身没有 CUDA 这样的直接 GPU 编程接口。要让浏览器跑起来 Transformer 结构,必须解决两件事:第一,JavaScript 环境怎么执行高性能矩阵计算;第二,怎么访问设备上的 GPU 算力。
WebLLM 的组合方案是 WebAssembly 加 WebGPU。
- WebAssembly 负责把 C++、CUDA 之类的计算逻辑编译成浏览器可以执行的二进制指令。模型算子、量化计算、采样逻辑都在这一层运行。
- WebGPU 负责把矩阵乘法、注意力计算等核心操作提交给本地 GPU 执行。它相当于浏览器里的 GPU 编程接口,比旧的 WebGL 更适合通用计算。
- 模型权重由 MLC 编译成专门的资源包,放在静态资源服务器上。浏览器运行时按需下载 wasm 运行库、模型配置、tokenizer 和权重分片,在本地设备上完成前向推理。
MLC LLM 是机器学习编译方向上的开源项目,WebLLM 是它的浏览器端产物。你可以把 WebLLM 理解成一套编译链路加一套浏览器运行时:编译链路负责把模型变成浏览器友好的格式,运行时负责在浏览器里加载模型并执行生成。
1.2 WebLLM Chat 和传统 Chat 应用的本质差异
WebLLM Chat 从外表看是一个对话页面,但它的架构和传统云端 Chat 完全不同。下面这张表比较直接地反映了两者的区别。
| 对比维度 | 传统云端 Chat 应用 | WebLLM Chat |
|---|---|---|
| 推理位置 | 服务器、GPU 集群 | 用户本地浏览器 |
| 数据去向 | 用户输入会发送到服务端 | 输入内容留在本机 |
| 首次使用成本 | 服务端已部署好模型,用户可直接用 | 需要先下载数百 MB 到数 GB 模型权重 |
| 首字输出 | 取决于网络和服务端负载 | 取决于本地 GPU 算力和模型大小 |
| 模型更新 | 服务端升级即可 | 用户需要重新下载新权重 |
| 离线能力 | 必须联网 | 模型加载后可离线对话 |
| 内容可控性 | 服务端可集中审核、流控、审计 | 约束逻辑在客户端,需要单独设计 |
从这张表能看出,WebLLM Chat 并不是简单地替换请求地址,而是把“无状态前端 + 有状态服务端”的架构,改成了“完全在客户端运行的推理程序”。隐私和成本是它最大的收益:用户消息不出设备,也不需要为推理 GPU 付费。但代价也很明显,内容审核、权限控制、错误监控这些原本集中在服务端的能力,现在需要重新设计。
1.3 什么场景适合用 WebLLM Chat
参考实际项目的情况,以下几类场景更适合考虑 WebLLM:
- 数据敏感场景。客服助手、个人知识库、医疗或法律辅助工具,如果业务要求用户输入不能离开本机,端侧推理就是天然解。
- 演示与教育场景。上课、展会、离线演示时,不需要准备 GPU 服务器,一台支持 WebGPU 的笔记本就能跑通。
- 低频长尾请求。为了某个偶尔使用的功能长期维护一台 GPU 服务器成本太高,把推理下放到用户浏览器可以摊掉这部分开销。
- 弱网或边缘场景。模型提前加载后,即使网络波动,对话仍然可以继续。
反过来,如果你的业务需要最新千亿级模型、需要中心化内容审核、对首字延迟有严格 SLA,或者目标用户设备普遍老旧,WebLLM 目前并不合适。它适合的是“端侧可承载、数据要留本机、体验可接受秒级响应”这一类需求。
2. 跑通 WebLLM Chat 之前,先确认浏览器、硬件和依赖
2.1 浏览器与 WebGPU 兼容性
WebLLM 的核心依赖是 WebGPU,所以在写代码之前,先确认目标浏览器能拿到 GPU adapter。
以当前主流浏览器为例:
- Chrome 和 Edge 的较新稳定版本已默认开启 WebGPU,这是 WebLLM 的首选目标环境。
- Safari 在近几个大版本中逐步提供 WebGPU 支持,但不同操作系统的支持程度差异较大,必须实测。
- Firefox 仍处于实验支持阶段,需要打开相关实验特性,不建议作为默认目标。
不要默认所有环境都支持 WebGPU。在进入 WebLLM 开发前,可以先在目标浏览器控制台执行这段检测:
if (navigator.gpu) { const adapter = await navigator.gpu.requestAdapter(); console.log(adapter ? 'WebGPU available' : 'WebGPU no adapter'); } else { console.log('WebGPU not supported'); }这段代码的作用是提前发现环境问题。WebLLM 初始化时依赖 WebGPU adapter,如果adapter是null,后续所有推理代码都会失败。生产页面应该把这段检测放在页面最前面,失败时直接展示降级提示,而不是让用户点完“加载模型”后才看到报错。
2.2 模型体积与硬件资源匹配
模型选择直接决定 WebLLM Chat 能不能跑起来。浏览器推理可用的内存是有限的,模型越大,崩溃风险越高。下面这张表是一个粗略参考,不能当作精确规格。
| 模型规模(示例) | 常见量化 | 内存与显存参考 | 适合配置 |
|---|---|---|---|
| 0.5B | q4f16 | 约 1 到 2 GB | 核显或内存较小的设备,功能演示 |
| 1.5B | q4f16 | 约 3 到 4 GB | 常见开发机,入门首选 |
| 3.8B | q4f16 | 约 6 到 8 GB | 独立显卡或大内存设备 |
| 8B | q4f16 | 10 GB 以上 | 高配独立显卡设备 |
这些数字会随实际量化方式、上下文长度、设备内存架构变化。同一个模型,如果上下文窗口开得很大,KV cache 的占用会明显增加。落地前要用“目标用户最低配置的那台设备”做一次完整加载和对话测试,不能只看开发机的表现。
2.3 模型权重从哪来:模型 ID 与资源托管
WebLLM 使用的模型不是直接从 Hugging Face 拉一个 safetensors 文件就能加载。它需要 MLC 编译后的资源包,里面包含模型配置、tokenizer、权重分片和 wasm 运行库。对用户代码来说,只需要传一个模型 ID,例如:
Qwen2.5-1.5B-Instruct-q4f16_1-MLCWebLLM 会根据这个 ID 去内置的prebuiltAppConfig.model_list中找到模型仓库地址,然后逐个下载资源文件。模型 ID 的命名通常包含三部分:基础模型名、量化方式、MLC 编译标识。不同版本的 WebLLM 内置模型列表不同,不要凭记忆拼写模型 ID,应该从当前版本的model_list里复制。
如果因为网络或资源托管原因不能访问公共模型仓库,可以把模型资源目录复制到自己的对象存储或 CDN,然后通过自定义模型接口注册。示例逻辑如下:
const customModel = { model_id: 'My-Qwen-1.5B', model: 'https://cdn.example.com/webllm-models/Qwen2.5-1.5B-Instruct-q4f16_1-MLC/', model_lib: 'https://cdn.example.com/webllm-libs/Qwen2.5-1.5B-Instruct-q4f16_1-MLC-webgpu.wasm', // 完整字段以当前版本的类型定义为准 }; await engine.registerModel(customModel);这里要注意,不同版本的 WebLLM 对自定义模型的字段要求不一样,写代码前一定要查看当前安装版本的MLCEngineConfig和模型注册相关类型定义。模型资源放在自建对象存储时,还必须配置 CORS 响应头,否则浏览器会因为跨域限制拒绝读取模型文件。
3. 用 CDN 在几分钟内跑通最小 WebLLM Chat 页面
3.1 最小 HTML 页面:加载模型、显示进度、流式对话
先用一个最简单的 HTML 页面验证整套链路。下面是完整的示例,不需要 Node.js 和构建工具,保存成index.html用浏览器打开,或者放在任意静态服务器里访问就行。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>WebLLM Chat 最小示例</title> <style> body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 0 auto; padding: 24px; } #status { font-weight: 600; margin: 12px 0; } #log { white-space: pre-wrap; background: #f6f8fa; padding: 12px; min-height: 120px; border-radius: 8px; font-size: 13px; } #output { white-space: pre-wrap; margin-top: 16px; min-height: 120px; border: 1px solid #ddd; padding: 12px; } textarea { width: 100%; margin-top: 16px; } </style> </head> <body> <h1>WebLLM Chat 最小示例</h1> <div id="status">等待初始化,请点击“加载模型”</div> <button id="loadBtn">加载模型</button> <div id="log"></div> <textarea id="input" rows="3" placeholder="输入你的问题" disabled></textarea> <button id="sendBtn" disabled>发送</button> <div id="output"></div> <script type="module"> import { CreateMLCEngine } from 'https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm/+esm'; const statusEl = document.getElementById('status'); const logEl = document.getElementById('log'); const inputEl = document.getElementById('input'); const sendBtn = document.getElementById('sendBtn'); const outputEl = document.getElementById('output'); const selectedModel = 'Qwen2.5-1.5B-Instruct-q4f16_1-MLC'; async function loadModel() { statusEl.textContent = '模型加载中,请稍候...'; const engine = await CreateMLCEngine( selectedModel, { initProgressCallback: (report) => { logEl.textContent = `${(report.progress * 100).toFixed(2)}% - ${report.text}`; }, } ); window.engine = engine; statusEl.textContent = '模型已就绪,可以开始对话'; inputEl.disabled = false; sendBtn.disabled = false; } document.getElementById('loadBtn').addEventListener('click', loadModel); sendBtn.addEventListener('click', async () => { const prompt = inputEl.value.trim(); if (!prompt || !window.engine) return; outputEl.textContent = ''; const messages = [{ role: 'user', content: prompt }]; const reply = await window.engine.chat.completions.create({ messages, stream: true, }); for await (const chunk of reply) { const delta = chunk.choices[0]?.delta?.content || ''; outputEl.textContent += delta; } }); </script> </body> </html>这段代码是完整的 WebLLM Chat 最小闭环。页面打开后不会立即下载模型,点击“加载模型”后才开始初始化,这样避免页面刚打开就占用大量带宽。模型加载完成后输入问题,点击发送,回答会流式输出到页面上。
3.2 页面代码的关键执行顺序
理解这段代码的执行顺序,比直接复制更重要。
第一步,模块导入。CreateMLCEngine从 CDN 引入,浏览器在执行import时就会下载 WebLLM 的运行时脚本。这里使用 CDN 是为了快速验证,正式项目不建议长期依赖公共 CDN。
第二步,点击加载模型。CreateMLCEngine内部会做这些事:
- 解析模型 ID,查找模型仓库地址。
- 下载 wasm 运行库和模型配置文件。
- 下载模型权重分片,通常是几十个文件。
- 初始化 WebGPU 设备、编译 shader、加载 tokenizer。
- 返回可用的引擎实例。
initProgressCallback回调接收一个report对象,其中progress是 0 到 1 的浮点数,text是当前阶段的描述文字。进度条可以直接用report.progress渲染。
第三步,发送消息。chat.completions.create的入参结构兼容 OpenAI API,messages要传完整的对话历史。stream: true时返回异步生成器,用for await逐个消费输出 chunk,每来一块就把内容追加到页面上,这样用户能看到像打字机一样的效果。
3.3 CDN、npm 与本地打包的选择
CDN 方式适合学习环境和本地验证,但不是生产环境的首选。原因有三点:
- 公共 CDN 的可用性和加载速度不受你控制。
- WebLLM 包的版本更新后,CDN 路径可能变化。
- 大型静态资源走第三方 CDN,出现问题时不好排障。
工程化项目应该用 npm 安装:
npm install @mlc-ai/web-llm然后在业务代码里从工程目录引入:
import { CreateMLCEngine } from '@mlc-ai/web-llm';构建时由 Vite 或 webpack 处理打包,发布时把产物放到自己的静态资源服务器或 CDN。这样版本可控,也方便做模型资源与前端资源的统一发布。
4. 用 Vite 搭建工程化 WebLLM Chat,并把推理放到 Web Worker
4.1 初始化项目与 Vite 依赖优化配置
最小示例把推理放在主线程,页面在模型初始化和对话生成时会出现卡顿。更合理的做法是把推理逻辑放到 Web Worker 中,渲染主线程只负责展示进度和输出文字。
先初始化一个 Vite 项目:
npm create vite@latest webllm-chat -- --template vanilla-ts cd webllm-chat npm install npm install @mlc-ai/web-llm创建vite.config.ts:
import { defineConfig } from 'vite'; export default defineConfig({ optimizeDeps: { exclude: ['@mlc-ai/web-llm'], }, worker: { format: 'es', }, });optimizeDeps.exclude比较关键。WebLLM 包含顶层 await 和 wasm 相关逻辑,Vite 预构建有时会把它改写坏。遇到 “Top-level await is not available” 或 Vite 依赖优化报错时,优先检查这一项。worker.format: 'es'让 Worker 以 ES Module 方式加载,配合 WebLLM 的模块化代码更稳定。
项目目录结构:
webllm-chat/ ├── index.html ├── package.json ├── tsconfig.json ├── vite.config.ts └── src/ ├── main.ts └── worker.ts4.2 Worker 中的推理代码
创建src/worker.ts,把模型初始化和对话生成都放到 Worker 里:
import { CreateMLCEngine } from '@mlc-ai/web-llm'; let engine: Awaited<ReturnType<typeof CreateMLCEngine>> | null = null; self.onmessage = async (event) => { const msg = event.data; if (msg.type === 'load') { const callback = (report: { progress: number; text: string }) => { self.postMessage({ type: 'progress', payload: report }); }; engine = await CreateMLCEngine(msg.model, { initProgressCallback: callback, }); self.postMessage({ type: 'ready' }); } if (msg.type === 'chat' && engine) { const reply = await engine.chat.completions.create({ messages: msg.messages, stream: true, }); for await (const chunk of reply) { const content = chunk.choices[0]?.delta?.content || ''; self.postMessage({ type: 'delta', content }); } self.postMessage({ type: 'done' }); } };Worker 内部通过self.postMessage和主线程通信。加载进度、就绪状态、流式输出内容都通过消息类型区分。这样做的好处是模型下载、wasm 编译、对话生成都不阻塞页面渲染,用户点击按钮后页面仍然可以滚动和输入。
注意,如果使用 TypeScript,tsconfig.json需要包含 WebWorker 相关的 lib,否则self和postMessage的类型可能不匹配。实际项目里也可以在worker.ts顶部加/// <reference lib="webworker" />。
4.3 主线程通过消息与 Worker 协作
创建src/main.ts:
import './style.css'; const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); const statusEl = document.getElementById('status') as HTMLDivElement; const logEl = document.getElementById('log') as HTMLDivElement; const inputEl = document.getElementById('input') as HTMLTextAreaElement; const sendBtn = document.getElementById('send') as HTMLButtonElement; const outputEl = document.getElementById('output') as HTMLDivElement; const MODEL = 'Qwen2.5-1.5B-Instruct-q4f16_1-MLC'; worker.onmessage = (event) => { const { type, content, payload } = event.data; if (type === 'progress') { statusEl.textContent = `加载中 ${(payload.progress * 100).toFixed(2)}%`; logEl.textContent = payload.text; } else if (type === 'ready') { statusEl.textContent = '模型已就绪,可以开始对话'; sendBtn.disabled = false; } else if (type === 'delta') { outputEl.textContent += content; } else if (type === 'done') { statusEl.textContent = '本轮生成完成'; } }; document.getElementById('load')!.addEventListener('click', () => { statusEl.textContent = '开始加载模型'; worker.postMessage({ type: 'load', model: MODEL }); }); sendBtn.addEventListener('click', () => { const text = inputEl.value.trim(); if (!text) return; outputEl.textContent = ''; worker.postMessage({ type: 'chat', messages: [{ role: 'user', content: text }], }); });配套的index.html里需要有status、log、input、send、output这几个元素。整体消息流是:
- 主线程点击加载,向 Worker 发送
{ type: 'load' }。 - Worker 加载模型,边加载边回传
progress消息。 - 加载完成,Worker 回传
ready。 - 主线程发送问题,Worker 流式回传
delta。 - 生成结束,Worker 回传
done。
这种“主线程只管界面、Worker 只管推理”的结构,是生产环境 WebLLM Chat 的推荐起点。
5. 核心 API 与参数:CreateMLCEngine、chat.completions、流式输出
5.1 CreateMLCEngine 的本质
CreateMLCEngine是一个便捷工厂函数,它内部完成引擎创建和模型加载两步操作。典型调用如下:
const engine = await CreateMLCEngine(modelId, { initProgressCallback: (report) => { console.log(report.progress, report.text); }, context_window_size: 2048, });它和手动创建new MLCEngine()的区别在于:
- 工厂函数自动完成初始化流程,适合一次性加载一个模型。
- 手动创建适合需要管理多个模型、自定义加载流程、或加载后反复
reload的场景。
context_window_size决定模型能看到的上下文 token 数量上限。设置太小,长对话会被截断;设置太大,KV cache 占用的显存内存会显著上升。推荐先用模型默认值跑通,再根据实际内存情况调整。
5.2 chat.completions.create 的参数与调用方式
对话生成的核心接口如下:
const reply = await engine.chat.completions.create({ messages: [ { role: 'system', content: '你是一个运行在浏览器里的助手,回答要简洁。' }, { role: 'user', content: '用一句话解释 WebGPU。' }, ], temperature: 0.8, top_p: 0.9, max_tokens: 256, stream: true, });常用参数说明:
| 参数 | 含义 | 常见值 | 使用建议 |
|---|---|---|---|
temperature | 采样温度,越高越随机 | 0.7 到 0.9 | 需要稳定输出时降到 0.2 以下 |
top_p | 核采样概率阈值 | 0.9 | 与 temperature 配合使用,不必每次都调 |
max_tokens | 单次生成的最大 token 数 | 256 到 1024 | 过小会截断答案,过大会增加等待时间 |
stream | 是否流式返回 | true | 交互场景建议开启,能尽早展示首字 |
WebLLM 把 API 设计成 OpenAI 兼容格式,这是刻意的取舍。做过 OpenAI API 对接的团队迁移到 WebLLM 时,只需要把openai.chat.completions.create换成engine.chat.completions.create,messages结构完全一致。
5.3 对话历史与 token 控制
多轮对话时,必须把历史消息完整传给messages:
const messages = [ { role: 'system', content: '你是一个浏览器助手。' }, { role: 'user', content: '你好' }, { role: 'assistant', content: '你好,有什么可以帮你?' }, { role: 'user', content: '介绍一下 WebGPU' }, ];单轮对话看不出问题,但多轮之后,历史消息会不断增长,context_window_size很快被占满。一个简单的处理方法是限制传入的历史条数:
function trimHistory( history: Array<{ role: string; content: string }>, maxLen = 4 ) { return history.slice(-maxLen); }更复杂的项目可以做摘要压缩:把长历史交给模型总结成一段浓缩文本,再拼进下一轮对话。WebLLM 的上下文窗口是宝贵的资源,不要让它被无意义的重复内容占满。
6. 运行验证:怎么确认 AI 模型真的跑在浏览器里
6.1 启动项目并观察控制台
运行 Vite 项目:
npm run dev打开终端提示的本地地址,点击“加载模型”。在 DevTools 的 Console 面板可以看到进度回调输出的日志,例如:
0.00% - Loading model from URL ... 12.45% - Fetching param cache ... 78.20% - Loading model weights ... 100.00% - Finish loading model weights同一时间,Network 面板会显示大量对模型仓库的静态资源请求,包括 wasm 文件、权重分片、tokenizer.json、config.json等。整个过程不应该出现对任何/v1/chat/completions之类接口的网络请求。
6.2 用 Network 面板和任务管理器验证本地推理
验证推理是否真正发生在本地,有两种直观方式。
第一种,看 Network 面板。模型加载完成后,发送一条消息,然后观察页面是否发起模型推理接口请求。正常的 WebLLM Chat 页面只会读取已经下载好的本地资源,不会再向服务器发送需要生成回答的 API 请求。
第二种,打开浏览器自带的任务管理器。以 Chrome 为例,按Shift + Esc打开浏览器任务管理器,可以看到 GPU 进程的内存和 CPU 占用。推理期间 GPU 进程使用率上升,说明计算任务确实被提交到了本地 GPU。
如果你想做更严格的验证,模型加载完成后在 DevTools 里切换成 Offline 模式,再发送一条消息。如果还能得到回答,就证明推理完全不依赖网络。这个测试的结论要和浏览器缓存情况结合判断,因为模型资源可能被浏览器缓存,离线验证只能证明推理链路本身不需要网络。
6.3 预期输出与异常现象对照
一个正常的 WebLLM Chat 页面,时间线大概是这样的:
- 点击加载后 0 到 10 秒:下载运行时和权重。
- 随后 10 到 20 秒:wasm 编译、WebGPU 管线初始化。
- 加载完成后提问:1.5B 量级模型通常能在数秒内返回首字,8B 模型会更慢。
常见的异常现象和原因对照:
| 现象 | 常见原因 |
|---|---|
| 进度长期停在 0% | 模型仓库不可访问、CORS 配置错误、模型 ID 不存在 |
| 控制台报 WebGPU not supported | 浏览器不支持 WebGPU 或 GPU adapter 获取失败 |
| 标签页加载后崩溃 | 模型过大、内存不足、多标签页竞争 GPU |
| 回答被截断 | max_tokens太小或上下文窗口被占满 |
7. 常见问题排查:从现象到根因
7.1 WebGPU 初始化失败,出现 WebGL 或 WebGPU 报错
现象:控制台出现类似WebGPU is not supported、The browser supports WebGL, but initialization failed、Your browser does not support graphics API WebGL 2 which is required等错误。
原因:WebLLM 底层依赖 WebGPU,而 WebGPU 在部分浏览器、虚拟机、远程桌面环境下拿不到 GPU adapter。有些浏览器把 WebGL 和 WebGPU 的硬件加速开关放在同一个位置,关闭硬件加速后两者都会失效。
排查顺序:
- 在控制台运行
navigator.gpu检测脚本,确认navigator.gpu和requestAdapter()的结果。 - 打开
chrome://gpu,查看 Graphics Feature Status 中 WebGPU 是否可用。 - 确认浏览器已升级到最新稳定版。
- 确认操作系统和显卡驱动是较新版本。
解决方式:升级浏览器、开启硬件加速、更新显卡驱动。虚拟机里拿不到 GPU adapter 的情况很常见,不要在虚拟机环境里做 WebLLM 的性能测试。生产页面应该在前置检测失败时展示友好提示,而不是让用户看到控制台报错。
7.2 模型下载失败、进度卡住或出现 403
现象:点击加载后,进度一直停在 0% 或很小的百分比,Console 出现Failed to fetch,Network 面板里部分模型资源返回 403 或 404。
原因:
- 模型 ID 拼写错误,或者当前安装的 WebLLM 版本里根本没有这个模型。
- 公共模型仓库地址在目标网络下访问不稳定。
- 自建对象存储没有配置 CORS 响应头,浏览器读取不到响应。
排查顺序:
- 打印当前版本的模型列表,从列表里复制模型 ID,而不是手写。
- 打开 Network 面板,找到失败的请求,看 URL 是否是正确的模型仓库地址。
- 查看失败请求的响应头,确认是否有正确的
Access-Control-Allow-Origin。
解决方式:模型 ID 使用列表里的完整字符串;把模型资源复制到自己的对象存储或 CDN 并用registerModel指定自定义地址;对象存储配置Access-Control-Allow-Origin为你的站点域名或*。
预防建议:生产环境不要把外部公共仓库地址硬编码在代码里,自建资源托管是更可控的方案。
7.3 页面崩溃、标签页关闭或内存不足
现象:模型加载到一半,标签页直接崩溃;发送消息后浏览器提示内存不足;GPU 进程频繁重启。
原因:模型规模超过设备可用内存;上下文窗口开得过大导致 KV cache 暴涨;同一页面重复创建引擎实例没有释放;多个标签页同时跑大模型推理。
排查顺序:
- 打开浏览器任务管理器,查看当前标签页的内存和 GPU 进程占用。
- 关闭其他标签页后重试,确认是否资源竞争。
- 把模型换成更小的 0.5B 或 1.5B,确认是否模型过大。
- 检查代码是否在每次点击时都调用了
CreateMLCEngine,导致重复初始化。
解决方式:选择更小模型;调低context_window_size;避免重复创建引擎;加载新模型前先释放旧引擎。生产环境要在页面加载前做一次设备能力预估,不符合最低配置时直接提示用户,而不是让用户在崩溃中猜测原因。
7.4 模型加载完成但首次推理很慢
现象:模型已经 100% 加载,但发送第一条消息后等待很久才有输出。
原因:wasm 模块首次编译、GPU shader 编译、权重从磁盘或缓存读入、推理引擎预热都需要时间。
解决方式:在后台完成模型加载后,主动发送一次“你好”之类的预热对话,让引擎提前完成 shader 编译和缓存初始化。预热对话的结果不要展示给用户,只做内部调用。
预防建议:把推理放在 Web Worker 中,主线程展示加载文案和进度,避免用户以为页面卡死。给“模型准备中”的提示,而不是让用户面对一个空白按钮。
8. 生产环境建议与最佳实践
8.1 学习环境与生产环境的差异
学习 Demo 能跑通,和生产环境能稳定运行,中间还差很多环节。核心差异如下:
| 维度 | 学习 Demo | 生产环境 |
|---|---|---|
| 模型资源 | 直接访问公共仓库 | 自建 CDN 加对象存储,配置 CORS 和缓存 |
| 推理位置 | 主线程 | Web Worker |
| 错误处理 | 控制台打印 | 错误监控、上报、用户降级提示 |
| 内容安全 | 通常不考虑 | 提示词过滤、敏感策略、日志脱敏 |
| 网络稳定性 | 失败就失败 | 重试、进度展示、异常恢复 |
| 版本管理 | 固定一个模型 | 模型与前端版本绑定,灰度发布 |
生产项目里,模型资源必须自托管。公用仓库随时可能因为网络波动、仓库策略变化而不可用,业务不能把自己的核心体验建立在一个不受控的地址上。
8.2 加载体验与缓存策略
浏览器推理最大的体验瓶颈,就是首次加载的数 GB 权重。优化思路有三个方向:
第一,加载进度透明化。页面要展示模型大小、当前下载百分比、预计剩余时间,让用户知道需要等待。
第二,利用浏览器缓存。模型资源是稳定的静态文件,可以对稳定版本设置长期 HTTP 缓存。模型升级时更换资源路径,避免用户加载到新旧混合的文件。
第三,结合 Service Worker。Service Worker 可以把 wasm 和权重缓存到 Cache Storage,二次访问时几乎免下载。
不要做的操作是每次打开页面都重新下载模型权重。如果一个用户每周都访问你的页面,几 GB 的重复下载会很快消耗掉他的耐心和流量。
8.3 什么时候不要用 WebLLM
WebLLM 很有吸引力,但它不是万能方案。以下情况不建议使用:
- 需要中心化内容审核或强管控。纯端侧推理让内容审核失去了服务端这一道闸门。
- 需要最新超大模型。WebLLM 的模型生态有编译和发布周期,跟不上云端模型的新版本节奏。
- 目标用户设备老旧、浏览器版本杂、无法保证 WebGPU 支持。
- 业务对延迟和可靠性有严格 SLA,希望每次请求都在可预期时间内返回。
更稳妥的架构是混合模式:默认在本地跑一个小模型应对简单问题,探测到复杂问题或模型能力不足时,再请求云端大模型兜底。这样既节省了大部分推理成本,又保留服务端的控制能力和质量兜底。
8.4 可复用的上线前检查清单
WebLLM Chat 上线前,建议逐项确认以下内容:
- [ ] 在 Chrome、Edge 最新稳定版上验证 WebGPU 初始化成功。
- [ ] 页面启动时执行
navigator.gpu检测,失败时展示降级提示。 - [ ] 模型资源已上传到自建 CDN 或对象存储,CORS 响应头正确。
- [ ] 模型 ID 已从当前版本的模型列表中核对,不是手写拼凑。
- [ ] 模型尺寸与目标设备最低配置匹配,小内存设备有降级模型。
- [ ] 推理逻辑已放入 Web Worker,UI 在加载和生成期间不卡死。
- [ ] 网络中断导致加载失败时,页面能提示原因,而不是无限 loading。
- [ ] 错误监控已接入,能上报 WebGPU、下载、OOM 等关键错误。
- [ ] 多轮对话有长度上限,避免上下文无限增长导致内存膨胀。
- [ ] WebLLM 及模型版本锁定,升级时先在灰度环境验证。
浏览器里的 AI 推理不会取代数据中心里的大模型服务,但对特定场景来说,它是性价比和隐私友好度都相当高的一条路。WebLLM 的价值不在于给出一个炫酷的展示页,而在于验证了一条完全客户端推理的可行链路:WebGPU 提供算力,MLC 完成编译,权重分发复用静态资源体系,对话协议兼容 OpenAI 语义。
如果这篇文章的示例你还没有动手跑过,建议下一步按这个顺序练习:先跑通 CDN 最小页面,确认你的浏览器能加载模型;再把页面迁移到 Vite 工程,把推理放到 Web Worker;最后换一个更小的模型,对比不同模型的加载时间和回答质量。把这条链路完整走一遍之后,再回到参数调优和资源托管,会比直接看源码更有效。