现在做内部工具的人越来越多,都想把 AI 问答能力嵌进自己的页面里,但真动手时会发现几个绕不开的坎:数据不想出内网、调云端接口有成本、离线环境直接不可用。端侧 AI 就是冲着这几个问题来的。这篇笔记记录的是我用DeepSeek-R1蒸馏模型配合WebGPU,再套上React + TS + Tailwind这套前端栈,从零把一个能在浏览器里跑起来的对话应用搭出来的完整过程。中间包括技术选型怎么推、Web Worker 怎么隔离、模型下载缓存怎么处理、以及真实调试时踩到的那些坑。适合已经会写 React、对 TypeScript 不陌生,但还没碰过浏览器端模型推理的同学;如果你只是想找一个能抄的骨架,这篇里的配置和目录结构也可以直接拿去用。
1. 先想清楚端侧推理到底解决什么问题
动手之前我花了半天时间反问自己:为什么非要放浏览器里跑?这个判断直接决定了后面所有技术选型,跳过这一步很容易做出一个"能跑但没法用"的 Demo。
1.1 三类部署形态的真实取舍
把大模型接进一个前端应用,业内大概有三条路。第一条是调用云端 API,服务端推理,前端只管收发。第二条是本地桌面端程序,用户装个客户端,模型跑在用户机器上。第三条就是这篇要讲的,模型直接跑在浏览器里,靠 WebGPU 调显卡。
三者的差别不在"先进程度",而在约束条件。云端 API 最省事,但你的每一条对话都要离开用户设备,对于做企业内部工具的人来说这往往直接否掉;而且按量计费,用户量一上来成本曲线很难看。桌面端能保住数据,可分发成本高,一个内部小工具让人装客户端,阻力比想象中大得多,更新也是麻烦事。
浏览器端的好处是分发即链接,打开就能用。数据全程在本地显存和内存里,不经过任何服务器,这一点在做隐私敏感场景时说服力很强。代价也很明确:模型体积被用户的下载能力限制,参数量上不去,能力天花板比云端小模型还低一档。
1.2 WebGPU 在这条链路里做的到底是什么
很多人把 WebGPU 理解成"更快的 WebGL",其实它带来的关键变化是compute shader。WebGL 时代只能写图形渲染管线,想拿 GPU 做大矩阵乘法得绕很多弯,通常靠 fragment shader 的 hack 实现。WebGPU 直接暴露了通用计算能力,模型推理里最吃性能的那部分——矩阵乘法、注意力计算——终于能顺着正常路径丢给 GPU。
具体到我们的项目,WebGPU 负责的是每一层 Transformer 的权重矩阵运算。它同时管两件事:把量化后的权重从内存搬到显存(这一步耗时最久,也是首次加载卡顿的主因),以及每个 token 生成时的那一轮前向计算。你要写的业务代码完全不碰这些,它们被封装在推理引擎里,但理解这一层有助于你判断性能瓶颈出在哪。
注意:WebGPU 需要安全上下文,也就是 https 或者 localhost。用一个局域网 IP 直接访问是拿不到
navigator.gpu的,这个坑我在调试阶段撞过一次,排查了半小时才反应过来。
1.3 什么场景值得上端侧,什么场景别硬上
我的判断标准比较粗暴:如果你需要的是"总结一段三千字文档""回答几个常识问题""做一个结构化信息抽取",1.5B 到 3B 级别的蒸馏模型完全够用,端侧方案收益很明显。如果你要做复杂推理、长链条代码生成、需要严格事实准确度的任务,那还是老老实实走服务端。
另一个容易被忽略的点是设备分布。如果目标用户大量使用老旧设备或者集成显卡,端侧方案的失败率会很高——不是代码写错了,是显存真不够。所以后面第 6 章我会专门讲设备能力探测和降级,这个在设计初期就要留好口子,不要等到上线才发现一半用户打不开。
2. 选型推演:为什么最后落在 WebLLM 这套组合上
选型这一步我对比了四个方案,每个都实际跑过最小的 Demo,下面把结论和理由摊开说,你可以按自己的约束条件重新判断。
2.1 四个主流推理引擎的横向对比
浏览器里跑 LLM,常见的选择有 WebLLM、Transformers.js、ONNX Runtime Web 和 wllama。它们底层的编译器、支持的模型格式、对 WebGPU 的利用程度都不一样。
| 方案 | 底层技术 | WebGPU 支持 | 模型来源 | 适合场景 |
|---|---|---|---|---|
| WebLLM | MLC / TVM 编译 | 原生且深度优化 | 官方预编译模型库 | 对话类 LLM,追求开箱即用 |
| Transformers.js | ONNX Runtime Web | 支持,但覆盖模型有限 | HuggingFace ONNX 社区版 | 小模型、嵌入、分类任务 |
| ONNX Runtime Web | ONNX Runtime | 支持 | 自行导出 ONNX | 已有 ONNX 资产、需要定制 |
| wllama | llama.cpp WASM | 部分后端 | GGUF 量化模型 | 需要 GGUF 生态、离线包分发 |
我最后选 WebLLM,核心原因是它把"模型量化 + 编译 + 运行时 + 缓存"这条链路全包了,官方维护了一批预编译好的 MLC 格式模型,其中就包含 DeepSeek-R1 蒸馏系列的多个尺寸。Transformers.js 更轻量,但对话类模型的支持成熟度差一截,长文本生成的吞吐也不理想。wllama 的 GGUF 生态很香,但它对多线程 WASM 的依赖会让部署环境变复杂,需要额外的跨源隔离响应头配置,对内部工具的部署方式不太友好。
2.2 DeepSeek-R1 蒸馏模型的尺寸与量化档位
DeepSeek-R1 本身是个很大的模型,端侧肯定跑不动。但它开源了蒸馏系列,把推理能力蒸馏到小尺寸底座上,常见的有 1.5B、7B、8B 等规格。浏览器场景我建议从 1.5B 起步,理由不是能力,而是显存和下载体积。
量化档位决定了模型占用多少显存。q4f16_1 表示权重 4 位量化、激活和计算用 fp16,这是体验和体积比较平衡的一档。下面是粗略的量级参考,实际数字会因模型结构和引擎实现浮动:
| 量化档位 | 1.5B 大致显存占用 | 8B 大致显存占用 | 生成质量 |
|---|---|---|---|
| q4f16_1 | 约 1.2 GB 级 | 约 5 GB 级 | 日常问答够用,推荐起点 |
| q4f32_1 | 略高于上者 | 略高于上者 | 数值更稳,收益有限 |
| q0f16 | 翻倍以上 | 翻倍以上 | 接近未量化,端侧基本不用考虑 |
我实测下来,1.5B 的 q4f16_1 在一台带独显的笔记本上首字延迟可以压到两秒以内(不含模型下载),纯 CPU 环境则要十几秒甚至更久,这个差距后面第 5 章会展开。
2.3 React 和 TypeScript 在这里不是装饰
有人会问,一个聊天界面用得着上 TS 吗?我的答案是:这个项目里 TS 的价值比平时更高。原因在于推理引擎的输出是流式的、分片的,每一次回调的数据结构都可能有可选字段,用 JS 写很容易在某个边界情况里拿到undefined然后整个界面白屏。TS 能在编译期把这些路径逼出来。
React 这边,我主要用它的状态驱动模型。流式 token 不断进来,界面要跟着变,这种"数据推着 UI 走"的模式本来就是 React 的强项。但要注意别把每个 token 都当成一次setState,那样会把主线程拖垮,具体做法在第 4 章讲。
2.4 Tailwind 承担的其实是"快速试错"职责
这个项目我改了不下十版界面:消息气泡样式、流式光标、加载进度环、错误提示条。用传统 CSS 写这么多版,光命名和文件切换就够烦的。Tailwind 让我可以在 JSX 里直接调样式,改一版界面十几分钟就能看到效果。
这里有个版本相关的坑要提前说:Tailwind v4 的接入方式和 v3 差别很大,不再走tailwind.config.js那一套,而是直接在 CSS 里@import "tailwindcss";,配置用@theme指令内联。如果你是照着一篇两年前的教程搭的,很可能第一步就卡住,我把具体差异放在第 4.5 节。
3. 从零搭骨架:项目初始化和第一行推理代码
选型定了就开干。这一章按真实的搭建顺序走一遍,每一步都说明为什么这么做,不只是给命令。
3.1 项目初始化与 TypeScript 关键配置
我用 Vite 起项目,模板选 react-ts。这样出来的结构干净,没有多余的东西。初始化之后第一件事是装类型定义:
npm create vite@latest edge-ai-chat -- --template react-ts cd edge-ai-chat npm i @mlc-ai/web-llm npm i -D @webgpu/types npm i -D tailwindcss @tailwindcss/vite@webgpu/types这个包非常关键。TypeScript 标准库里没有 WebGPU 的类型声明,不加它,你写的navigator.gpu会直接报"属性不存在"。装完之后要在 tsconfig 里显式声明:
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client", "@webgpu/types"], "moduleResolution": "bundler", "strict": true } }target我定在 ES2022,因为推理引擎内部用到了一些较新的语法特性,编译目标太低会触发大量降级代码,体积和运行效率都会受影响。moduleResolution用 bundler 是为了让 Vite 的解析规则和 TS 保持一致,避免"编辑器不报错但构建失败"这种尴尬情况。
3.2 用 Web Worker 把推理隔出去
这是整个项目里我认为最重要的一个架构决策。模型推理是重计算任务,如果放在主线程,生成过程中页面会完全失去响应,滚动卡住、按钮点不动,用户体验直接归零。
做法是把引擎实例放在一个 Worker 里,主线程只负责收发消息。Vite 里写 Worker 有个细节:需要把 Worker 的打包格式设成 ES module,否则 Worker 内部import会失败。
// vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], worker: { format: "es" }, optimizeDeps: { exclude: ["@mlc-ai/web-llm"] }, });optimizeDeps.exclude这行是我调试了好一阵才加上的。推理引擎的包里有 WASM 产物和动态加载逻辑,让 Vite 的依赖预构建去处理它,容易出现路径错乱,直接排除掉更稳。
Worker 侧的代码结构大概是这样:
// src/worker/inference.worker.ts import * as webllm from "@mlc-ai/web-llm"; let engine: webllm.MLCEngineInterface | null = null; self.onmessage = async (event: MessageEvent) => { const { type, payload } = event.data; if (type === "init") { if (engine) { self.postMessage({ type: "ready" }); return; } engine = await webllm.CreateMLCEngine(payload.modelId, { initProgressCallback: (report) => { self.postMessage({ type: "progress", text: report.text, progress: report.progress, }); }, }); self.postMessage({ type: "ready" }); return; } if (type === "generate" && engine) { const stream = await engine.chat.completions.create({ messages: payload.messages, stream: true, temperature: payload.temperature ?? 0.6, max_tokens: payload.maxTokens ?? 1024, }); for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta?.content ?? ""; if (delta) self.postMessage({ type: "token", delta }); } self.postMessage({ type: "done" }); } };注意engine.chat.completions.create返回的是一个异步可迭代对象,for await会随着 token 逐个产出。这个设计很贴合流式场景,但你要注意它抛出异常的方式——生成中途出错会在迭代过程中抛出,所以这个循环要包在 try/catch 里,不然 Worker 里一个未捕获异常会让整个推理线程静默死掉,主线程还在傻等。
3.3 模型缓存与冷启动体验
模型文件动辄几百 MB 到几个 GB,绝对不能每次打开页面都重下。WebLLM 内部用浏览器的 Cache Storage 做缓存,同一个模型第二次加载会直接从本地读,速度差好几个数量级。
但这里有个体验设计问题:首次加载必然要下载,这个过程可能持续几分钟。如果界面上只有一个转圈动画,用户大概率会以为卡死了然后关掉页面。我的做法是做一个明确的进度条,把initProgressCallback回调里的文本和百分比都展示出来:
const [progress, setProgress] = useState(0); const [statusText, setStatusText] = useState("准备中"); useEffect(() => { const worker = new Worker( new URL("./worker/inference.worker.ts", import.meta.url), { type: "module" } ); worker.onmessage = (e) => { const { type, text, progress } = e.data; if (type === "progress") { setProgress(progress); setStatusText(text); } // 其余分支略 }; worker.postMessage({ type: "init", payload: { modelId: MODEL_ID }, }); return () => worker.terminate(); }, []);进度条文案我直接从引擎回调里取,因为它会告诉你当前是在加载权重、编译着色器还是做别的准备工作。让用户看到具体在做什么,比一个抽象的百分比更让人安心。
3.4 流式输出的数据通路
从 Worker 到界面,token 要经过三层:Worker 的 postMessage、主线程的消息处理、React 的状态更新。前两层没什么好说的,第三层有讲究。
如果每个 token 都调一次setState,一秒内可能触发几十次渲染,加上 Markdown 渲染和代码高亮的开销,长回答会把页面拖得很卡。我的处理是攒一小批再更新,用一个简单的缓冲配合requestAnimationFrame节流:
const bufferRef = useRef(""); const rafRef = useRef<number | null>(null); function appendDelta(delta: string) { bufferRef.current += delta; if (rafRef.current !== null) return; rafRef.current = requestAnimationFrame(() => { setAnswer(bufferRef.current); rafRef.current = null; }); }这样一帧最多更新一次,既保证了视觉上的"逐字出现"效果,又不会让渲染次数爆炸。实测在生成两千字回答时,帧率能稳定住,不会出现明显的掉帧。
4. 踩坑实录:那些让你怀疑人生的排查过程
下面这几个问题都是我真实撞过的,每一个都值得单列出来,因为它们的现象和根因之间往往隔得很远,不知道排查路径的话能卡一整天。
4.1navigator.gpu是 undefined 的完整排查链路
现象很直接:代码跑到navigator.gpu.requestAdapter()就抛错,提示gpu不存在。这个报错本身没什么信息量,得沿着链路一层层排。
第一步查浏览器版本和平台。WebGPU 在主流浏览器里的可用状态是分平台的,Windows 上依赖 DirectX 12 后端,Linux 上依赖 Vulkan,一些较老的显卡驱动版本会导致适配器请求失败但不报明确错误。我建议先在一个你确定支持的组合上验证代码本身没问题,再回到目标环境排查环境因素。
第二步查页面上下文。前面提过,必须是 https 或 localhost。用window.isSecureContext能直接确认。我第一次是把 dev server 绑到了0.0.0.0,然后用局域网 IP 在另一台机器上访问,结果死活拿不到gpu,换成 localhost 立刻正常。
第三步查适配器请求本身。requestAdapter()是可能返回 null 的,需要显式判断:
async function probeWebGPU() { if (!("gpu" in navigator)) { return { ok: false, reason: "浏览器未暴露 WebGPU 接口" }; } const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance", }); if (!adapter) { return { ok: false, reason: "未找到可用的 GPU 适配器" }; } const limits = adapter.limits; return { ok: true, maxBufferSize: limits.maxBufferSize, maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize, }; }第四步看适配器暴露的限制值。maxBufferSize和maxStorageBufferBindingSize这两个数字很关键,它们决定了你能加载多大的模型。有些集成显卡能拿到适配器,但限制值偏低,模型加载到一半直接失败,这时候报错信息和"显存不足"完全不搭边,只有把限制值打出来才能对上号。
提示:把探测结果做成一个开发者面板,在页面角落显示浏览器、适配器状态、限制值。上线前自己换几台设备过一遍,比等用户反馈快得多。
4.2 模型选大了会以什么方式失败
一开始我贪心,直接上了一个 8B 的量化模型,结果在本机上能跑,在同事的笔记本上表现非常诡异:进度条走到一半停住,控制台没有任何红色报错,页面也不崩,就是不往下走了。
这类问题的根因通常是显存或内存耗尽后,浏览器把分配失败转成了一个静默的等待或者回退路径,表现成"卡住"。排查方法是在加载前后读一下performance.memory(非标准但可用)和适配器的限制值,比对模型的理论占用。
我的经验是留出足够余量,不要把限制值用满。模型权重之外,KV cache、中间激活张量、浏览器自身的开销都要占地方。1.5B 的 q4f16_1 在多数近几年的设备上是安全的,8B 就需要用户有像样的独显。
降级策略我是这么设计的:先探测设备能力,根据maxStorageBufferBindingSize分档位给不同的模型 ID。探测失败或者用户拒绝加载大模型时,退到一个更小的档位,同时在界面上说清楚发生了什么,而不是默默降级让人以为模型变笨了。
4.3 缓存版本错配导致的诡异现象
这个问题最阴。表现是:昨天还能正常跑,今天打开直接报模型文件解析错误,或者进度卡在 99% 不动。
原因是引擎和模型之间存在版本对应关系。模型是预编译产物,格式和编译时的引擎版本绑定。如果你升级了@mlc-ai/web-llm的版本,本地 Cache Storage 里存的还是旧格式的模型文件,读出来就对不上。同理,换了一个构建版本但模型 ID 字符串没变,缓存也会撞车。
解决办法有两个层面。一个是清理缓存,在设置界面提供一个"清除本地模型缓存"的按钮,实现上就是遍历caches.keys()然后逐条caches.delete(),注意只删自己应用写入的那些。另一个是在模型 ID 上做版本标记,具体做法是维护一份自己的模型清单,把引擎版本号拼进缓存键里,升级时自然就走新缓存,不会读到旧文件。
async function clearModelCache() { const names = await caches.keys(); await Promise.all( names .filter((n) => n.includes("webllm") || n.includes("mlc")) .map((n) => caches.delete(n)) ); }另外提醒一句:首次下载体验依赖网络稳定性,一个 GB 级的文件下到中途断掉是很常见的事。好消息是 Cache Storage 的写入是分块的,断点续传在多数情况下能work,但我不建议把它当成可靠特性,界面上还是要提供"重新加载"的入口。
4.4 主线程假死与状态更新的连锁反应
前面提到用requestAnimationFrame节流,这是踩坑之后才加的。最初的版本我是每次收到 token 就setAnswer(prev => prev + delta),短回答看起来没问题,一旦模型开始输出长内容,页面就开始一顿一顿,最后整个标签页失去响应。
排查方式很土但有效:打开性能面板录一段,看主线程的时间都花在哪。结果很明显,大部分时间在 React 的渲染和 diff 上,而 Markdown 解析和代码高亮是重灾区。每来一个字就重新解析整篇 Markdown,复杂度是 O(n) 乘以 token 数量,长回答直接爆炸。
修法是两层的。第一层是节流,把状态更新压缩到每帧一次。第二层是拆分渲染,流式过程中的中间态先用纯文本展示,等生成结束之后再切换到完整的 Markdown 渲染。这样既保留了打字机效果,又把重活挪到了结尾,用户感知上反而更顺。
4.5 TypeScript 类型缺失和 Tailwind 版本差异
TypeScript 这边最大的问题就是类型声明缺失,前面说的@webgpu/types解决了 WebGPU 本身。但推理引擎的一些内部 API 类型不完整,遇到这种情况别急着写一堆any,用模块扩展声明补上更好:
// src/types/webllm.d.ts import "@mlc-ai/web-llm"; declare module "@mlc-ai/web-llm" { interface MLCEngineInterface { interruptGenerate(): Promise<void>; } }Tailwind 那边是版本问题。v4 之后接入方式变了,不再需要在 PostCSS 配置里挂插件,改成在 Vite 配置里加@tailwindcss/vite,CSS 文件里直接一行@import "tailwindcss";就行。主题定制从 JS 配置搬到了 CSS 的@theme块里:
@import "tailwindcss"; @theme { --color-brand-500: oklch(0.62 0.18 250); --radius-bubble: 1rem; }如果你按照旧教程在找content字段配置扫描路径,会发现根本没有这个东西了,v4 是自动检测的。我第一次照着旧文档配了半天没生效,还以为是路径写错了。
5. 性能调优:把首字延迟和生成速度拉到可接受区间
跑通只是及格线,能不能用取决于两个数字:首次出字要等多久,以及后面的字来得够不够快。这一章讲怎么拆解和优化这两个指标。
5.1 首字延迟到底由哪几段构成
我把整个过程拆成四段,每一段的优化手段完全不同:
| 阶段 | 耗时来源 | 优化方向 |
|---|---|---|
| 模型文件获取 | 网络下载或本地缓存读取 | 缓存命中、模型体积控制 |
| 引擎初始化 | WASM 加载、权重上传显存 | 提前初始化、常驻 Worker |
| Prefill | 输入 prompt 的前向计算 | 控制上下文长度、减少系统提示词 |
| 首 token 解码 | 第一次采样出字 | 影响很小,通常可忽略 |
最容易优化的是第一段和第二段。我的做法是在页面加载完成后就静默启动 Worker 并初始化引擎,用户还在读界面说明的时候,模型已经在后台准备了。等他真正输入第一个问题时,引擎已经就绪。这个改动能把感知延迟砍掉一大截。
Prefill 那一段常被忽视。系统提示词写得越长,每次对话都要重新算一遍,白烧时间。我把系统提示词压缩到必要程度,同时限制历史消息的保留条数,效果立竿见影。
5.2 生成参数怎么调才不难受
模型的生成参数对体验的影响比想象中大。temperature调太高,回答发散,用户觉得答非所问;调太低,同一句话反复说,显得呆板。对话场景我一般定在 0.6 到 0.7 之间,这个区间在稳定性和多样性之间比较平衡。
max_tokens需要设置上限。不设的话,模型可能在一个简单问题上啰嗦几百字,既浪费算力又让用户等。我按场景分档:快速问答给 512,文档处理给 1536,让用户也能在界面上自己调。
还有一个容易忽略的点是停止序列。如果模型的输出格式有自己的约定(比如带特殊标记),要正确配置停止条件,否则它输出完答案还会继续编,白白多等好几秒。
5.3 取消生成和中断机制
用户等不及要停掉生成,这个需求一定会出现。如果界面上没有取消按钮,用户唯一的办法就是刷新页面,那前面加载的模型缓存虽然还在,但引擎要重新初始化,体验很差。
推理引擎提供了中断接口,但要注意它和流式循环的配合方式。中断之后,for await循环会正常退出而不是抛异常,所以你需要一个状态标记来区分"正常生成结束"和"被用户中断":
if (type === "abort" && engine) { await engine.interruptGenerate(); self.postMessage({ type: "aborted" }); }界面上我把发送按钮在生成期间变成停止按钮,点击触发中断,同时保留已经生成的部分内容,不清空。这个细节很小,但用户感知很好,因为中断往往发生在"我已经看到想要的答案了,后面的不用看了"这种情况下。
6. 从能跑的 Demo 到敢用的工具还差什么
代码跑通到真正给别人用,中间还有一段距离。这一章聊几个我认为必须处理的问题。
6.1 上下文管理与历史裁剪
浏览器端模型的上下文窗口有限,而且每多一轮对话,Prefill 的成本都在叠加。全量保留历史,几轮之后就会明显变慢,十几轮之后基本不可用。
我的策略是分层处理:最近几轮完整保留,更早的对话做摘要压缩,只保留关键信息。摘要这一步可以让模型自己做——用一个极简的提示词让它总结前面的对话要点,输出控制在几十个字。实现在主线程还是 Worker 里都行,我放在 Worker 里,因为复用同一套引擎实例更省事。
另一个细节是消息列表的长度控制。即使做了摘要,也要设一个硬上限,比如最多保留 20 条消息,超出就丢弃最早的非摘要内容。这个上限和上下文窗口大小相关联,可以做成配置项。
6.2 错误处理和用户可理解的提示
端侧方案的失败模式比服务端多得多:显存不够、适配器拿不到、模型下载中断、缓存损坏。每一种都要有对应的提示,而且要用用户能理解的语言。
我整理了一份错误映射表,把底层的技术错误翻译成人话:
| 底层现象 | 用户看到的提示 | 建议动作 |
|---|---|---|
| 无 WebGPU 接口 | 当前浏览器不支持本地模型运行 | 建议更换新版浏览器 |
| 适配器请求返回 null | 未能访问显卡资源 | 检查显卡驱动或更换设备 |
| 加载中途停滞 | 模型加载未完成,可能是设备资源不足 | 提供重试和换小模型的入口 |
| 缓存解析失败 | 本地模型缓存异常 | 提供清除缓存按钮 |
这张表看起来简单,但它把一个"技术故障"转成了"可操作的选项"。用户看到"设备资源不足,可以切换到轻量模型"和看到一串英文报错,感受完全不同。
6.3 WebGPU 生态的延展可能
这套技术栈的复用性其实超出对话应用本身。WebGPU 的通用计算能力正在被用到更多地方,比如 3D 高斯泼溅的实时渲染,社区里有纯 JavaScript 加 WebGPU 的实现方案,能在浏览器里实时渲染出照片级的三维场景。这和我们做的文本推理底层是同一套能力,如果你的项目后续要做三维内容生成或者可视化,这条路径值得留意。
另一个方向是多模态。目前端侧的图像理解能力还在早期,模型体积和算力需求都比纯文本高,但思路是一样的:小尺寸模型、量化的权重、WebGPU 加速。等这个方向成熟,现在搭的这套 Worker 隔离、缓存管理、设备探测的框架基本可以直接复用。
6.4 部署时必须检查的几项
最后列一份上线前的检查清单,都是实际部署时会踩的地方。
第一,确认 https。内网部署如果没有证书,至少要把 localhost 访问打通,或者用支持安全上下文的方式暴露。
第二,确认响应头不会干扰 WASM 加载。有些反向代理会改Content-Type,导致 WASM 模块加载失败。
第三,确认模型文件的获取路径在生产构建里是对的。开发时 Vite 会做一堆路径重写,打包后可能全变,要在生产构建上实际打开页面验证一遍。
第四,确认缓存策略。如果你的静态资源有 CDN 缓存,模型文件最好单独处理,避免版本更新后用户拿到旧文件。
第五,做一次跨设备测试。至少覆盖一台独显笔记本、一台集显设备、一台移动端浏览器,把探测结果和实际表现对一遍,比看任何文档都有用。
我在实际部署时最大的体会是:端侧 AI 项目的复杂度不在模型本身,而在"设备差异"这四个字上。同一份代码在不同机器上的表现能差出一个数量级,所以设备探测、降级路径、错误提示这三件事的优先级,比优化生成速度还要高。先把这些兜底的东西做扎实,再谈性能。