外观
{title}
结论先行:WebLLM 要懒加载,模型要走分片缓存
我给组件文档站接入 AI 答疑后的核心结论只有两条:WebLLM 绝不能进 VitePress 首屏主包,模型权重必须交给浏览器 Cache Storage 分片缓存。做到这两点后,文档站首屏几乎不增加 JS 体积,用户点击「AI 答疑」时才初始化 WebGPU,二次访问模型准备时间从数分钟下载降到约 3 秒,答案还能以流式 Markdown 直接渲染在页面里。
这篇文章记录我在 VitePress + Vue3 文档站中接入 WebLLM 的完整方案,包括动态加载组件、WebGPU 能力检测、流式输出、Markdown 渲染、模型分片缓存,以及几个非常实际的坑。
1
文档层 VitePress
Markdown 页面组件示例全局 Layout 插槽
2
交互层 Vue3
AiAssistant.vue流式消息状态Markdown 渲染
3
推理层 WebLLM
MLCEngineWebGPU ChatCompletion分片缓存
4
运行时层浏览器
Cache StorageGPU AdapterService Worker 可见存储
为什么选 WebLLM,而不是直接调云端 API
组件文档站通常部署在静态托管平台上,没有固定后端。最容易想到的方案是前端直接请求大模型 API,但这会带来三个问题:API Key 无法安全保存、用户代码片段会离开本地、站点还要承担调用费用。把 Key 写进环境变量这件事尤其危险,VITE_ 开头的变量会被打进浏览器产物,不是服务端密钥。
WebLLM 的思路不同。它基于 MLC LLM,在浏览器里通过 WebGPU 调用本地 GPU 推理,模型权重来自静态资源或 CDN,下载后缓存到浏览器。对文档答疑这种场景,小参数量化模型已经足够解释 props、事件、插槽和示例代码,而且整个过程不需要后端。
我对比过三种方案:
| 方案 | 优点 | 代价 | 我的判断 |
|---|---|---|---|
| 云端 LLM API | 回答质量高、无本地性能压力 | Key 泄露、隐私外传、持续计费 | 适合内部平台,不适合纯静态文档站 |
| WebLLM + WebGPU | 离线可用、无 API 成本、隐私留在本地 | 首轮模型下载大、依赖浏览器能力 | 最适合组件文档和代码示例答疑 |
| 后端代理云端模型 | 安全隐藏 Key、可做限流审计 | 需要维护服务和监控成本 | 团队有服务器时可选 |
最终我选 WebLLM,不是因为它最轻,而是因为它和 VitePress 的静态发布模型最匹配。
VitePress 中注册 Vue3 全局 AI 组件
VitePress 支持通过 .vitepress/theme/index.ts 扩展默认主题。我没有把 WebLLM 静态写在主题入口,而是在 Layout 插槽里挂一个 AiAssistant 组件。这样 VitePress 首屏仍然只加载组件壳,真正的 AI 运行时由组件内部动态导入。
typescript
// .vitepress/theme/index.ts
import DefaultTheme from "vitepress/theme"
import { h } from "vue"
import AiAssistant from "../components/AiAssistant.vue"
export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
// 放在页面底部,避免影响文档正文的 SSR 和 hydration
"layout-bottom": () => h(AiAssistant)
})
}
} satisfies DefaultTheme.Theme组件默认只显示一个浮动按钮。用户第一次点击时,才加载 WebLLM:
vue
<!-- .vitepress/components/AiAssistant.vue -->
<script setup lang="ts">
import { ref, shallowRef } from "vue"
type ChatMessage = {
role: "system" | "user" | "assistant"
content: string
}
const open = ref(false)
const loading = ref(false)
const answering = ref(false)
const error = ref("")
const question = ref("")
const messages = ref<ChatMessage[]>([])
// MLCEngine 内部包含复杂运行时对象,不建议深度响应式
const engine = shallowRef<any>(null)
const MODEL_ID = "Llama-3.2-1B-Instruct-q4f16_1-MLC"
async function createEngine() {
if (engine.value) return engine.value
if (!("gpu" in navigator)) {
throw new Error("当前浏览器不支持 WebGPU,请使用最新版 Chrome 或 Edge。")
}
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) {
throw new Error("没有检测到可用 GPU,可能是浏览器禁用了硬件加速。")
}
// 关键:动态导入,避免 WebLLM 进入首屏主包
const webllm = await import("@mlc-ai/web-llm")
loading.value = true
engine.value = await webllm.CreateMLCEngine(MODEL_ID, {
initProgressCallback: (progress: any) => {
error.value = `${progress.text} ${Math.round(progress.progress * 100)}%`
}
}, {
// 浏览器上下文使用 Cache Storage 保存权重分片
contextType: "web-worker"
})
return engine.value
}
async function ensureEngine() {
try {
await createEngine()
error.value = ""
} catch (e: any) {
error.value = e?.message || "WebLLM 初始化失败"
} finally {
loading.value = false
}
}
function togglePanel() {
open.value = !open.value
if (open.value) ensureEngine()
}
</script>这里有个细节很关键:engine 要用 shallowRef,不要用 ref。我一开始图省事用了 ref,Vue 会对这个巨大的运行时对象做依赖追踪,开发环境下控制台和内存表现都很差。这个坑不算致命,但排查时很容易误以为是 WebGPU 内存泄漏。
流式 ChatCompletion 与 Markdown 渲染
WebLLm 对齐了 OpenAI 风格的 ChatCompletion API,只要打开 stream: true,就能通过 AsyncGenerator 读取分片。我的做法是维护一条 assistant 消息,把每个 chunk 的增量文本拼进去。
typescript
async function ask() {
const currentEngine = await ensureEngine()
const text = question.value.trim()
if (!currentEngine || !text || answering.value) return
answering.value = true
question.value = ""
messages.value.push({ role: "user", content: text })
const answer: ChatMessage = {
role: "assistant",
content: ""
}
messages.value.push(answer)
try {
const stream = await currentEngine.chat.completions.create({
messages: [
{
role: "system",
content: "你是组件库文档助手。请用简洁中文解释 API,必要时给出 Vue3 代码示例。"
},
...messages.value.slice(0, -1).map(item => ({
role: item.role,
content: item.content
}))
],
stream: true,
temperature: 0.2,
max_tokens: 800
})
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta?.content || ""
answer.content += delta
}
} catch (e: any) {
answer.content = "生成失败,请稍后重试。"
error.value = e?.message || "流式生成失败"
} finally {
answering.value = false
}
}模板里我没有引入复杂聊天 UI,而是用 v-html 渲染 Markdown。为了避免 XSS,不建议手写正则转换,也不要直接渲染原始 HTML。可以使用 markdown-it,并显式关闭 HTML 标签:
typescript
// .vitepress/components/renderMarkdown.ts
import MarkdownIt from "markdown-it"
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true
})
// 新窗口打开外链,避免把用户从文档页带走
const defaultLinkRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet("target", "_blank")
tokens[idx].attrSet("rel", "noreferrer")
return defaultLinkRender(tokens, idx, options, env, self)
}
export function renderMarkdown(text: string) {
return md.render(text)
}vue
<template>
<div class="ai-fab">
<button class="ai-trigger" @click="togglePanel">
AI 答疑
</button>
<section v-if="open" class="ai-panel">
<header>
<strong>组件代码答疑</strong>
<button @click="open = false">×</button>
</header>
<div v-if="loading || error" class="ai-status">
{{ error || "正在准备本地模型,请稍候……" }}
</div>
<div class="ai-messages">
<article
v-for="(item, index) in messages"
:key="index"
:class="['ai-message', item.role]"
v-html="renderMarkdown(item.content)"
></article>
</div>
<form @submit.prevent="ask">
<textarea
v-model="question"
rows="3"
placeholder="例如:el-form 的 validate 方法返回 Promise 吗?给一个 Vue3 示例"
></textarea>
<button type="submit" :disabled="answering || loading">
{{ answering ? "生成中" : "发送" }}
</button>
</form>
</section>
</div>
</template>
<script setup lang="ts">
import { renderMarkdown } from "./renderMarkdown"
</script>流式输出时 Markdown 会经历「代码块还没闭合」的中间状态,这是正常现象。markdown-it 对不完整围栏代码块的容错还不错,视觉上最多闪一下。不要为了“完整”等全部 token 结束再渲染,否则流式体验就没了。
- 1文档站首屏直出VitePress 正常打包,不静态引入 WebLLM,首屏不下载模型。
- 2用户点击 AI 答疑动态 import @mlc-ai/web-llm,仅加载运行时胶水代码。
- 3检测 WebGPU先检查 navigator.gpu,再请求 adapter,失败时展示降级说明。
- 4拉取或命中分片模型权重按 shard 进入 Cache Storage,二次访问只做校验。
- 5流式生成答案onChunk 更新响应式文本,Markdown 增量渲染到对话面板。
模型分片缓存如何控制加载性能
WebLLM 首次运行会下载模型配置、tokenizer、权重库和多个 .bin 分片。以 Llama-3.2-1B-Instruct-q4f16_1-MLC 为例,量化后仍有约 1.5GB。这个体积不能靠 VitePress 构建优化解决,关键是不要在首屏下载,并让分片可缓存、可复用、可观察进度。
浏览器端模型权重主要进入 Cache Storage。WebLLM 内部通过 fetch 拉取分片,浏览器根据 URL 和响应缓存复用文件。第二次打开时,它仍会读取配置并检查资源,但不会重复下载完整权重。实际体验上,首次瓶颈是网络和磁盘写入,二次瓶颈变成 GPU shader 编译与模型加载。
我采用了三条控制策略:
- 入口懒加载:只有点击按钮才
import("@mlc-ai/web-llm"),保证 VitePress 首屏不背运行时体积。 - 模型轻量优先:文档答疑选择 1B 级别量化模型,而不是追求 7B/8B。模型越大,首轮放弃率越高。
- 进度显性反馈:展示
initProgressCallback的下载进度,否则用户会以为页面卡死。
如果模型文件托管在自己的 CDN 上,还可以通过 model_list 覆盖下载地址,并确保响应包含合适的缓存头:
typescript
const customModelRecord = {
model: MODEL_ID,
model_id: MODEL_ID,
model_lib:
"https://your-cdn.example.com/web-llm/libs/model_lib.wasm",
vram_required_MB: 900,
low_resource_required: false,
buffer_size_required_bytes: 1600000000
}
engine.value = await webllm.CreateMLCEngine(
MODEL_ID,
{
model_list: [
webllm.hasModelInModelList?.(MODEL_ID)
? webllm.findModelRecord(MODEL_ID)
: customModelRecord
],
initProgressCallback: (progress: any) => {
console.log(progress.text, progress.progress)
}
}
)生产里我更建议先用官方默认 CDN 验证链路,再迁移自有 CDN。跨域时必须返回 CORS 头,否则分片请求会在 Cache 写入前直接失败,报错往往只表现为模糊的网络错误。
首屏额外 JS 体积动态加载后不计入首屏主包,单位 MB
0
首轮模型下载量Llama-3.2-1B q4f16 分片,单位 MB,视模型而定
1500
二次启动模型准备缓存命中后约 3 秒,单位 s
3
流式首 token 延迟预热完成后约 800ms,单位 ms
800
样式与移动端适配
浮动面板需要控制层级和宽度,避免遮挡 VitePress 的搜索框和侧边栏。下面这组样式足够作为起点:
css
.ai-fab {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 999;
font-size: 14px;
}
.ai-trigger {
border: 0;
border-radius: 999px;
padding: 10px 16px;
color: #fff;
background: #6366f1;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.35);
cursor: pointer;
}
.ai-panel {
position: absolute;
right: 0;
bottom: 48px;
width: min(380px, calc(100vw - 32px));
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
background: var(--vp-c-bg);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
.ai-messages {
overflow: auto;
padding: 12px;
}
.ai-message {
margin-bottom: 12px;
line-height: 1.7;
}
.ai-message.user {
color: var(--vp-c-text-2);
}
.ai-message pre {
overflow: auto;
padding: 10px;
border-radius: 8px;
background: var(--vp-code-block-bg);
}移动端上要把面板高度限制好,不要使用 height: 100vh。移动浏览器的地址栏会伸缩,100vh 容易导致发送框被挡住,用 max-height + dvh 更稳:
css
@media (max-width: 640px) {
.ai-panel {
position: fixed;
left: 16px;
right: 16px;
bottom: 72px;
width: auto;
max-height: 70dvh;
}
}避坑总结:这些问题比写代码更耗时间
第一,不要把 API Key 当兜底方案写进前端。 我见过有人为了在不支持 WebGPU 的浏览器里降级,直接把云端 Key 放进 VITE_OPENAI_API_KEY。这等于把密钥发布给所有访客。正确做法是只展示能力提示,或者接一个自己控制权限和限流的后端代理。
第二,不要静态导入 WebLLM。 如果在 theme/index.ts 顶部直接 import { CreateMLCEngine } from "@mlc-ai/web-llm",VitePress 首屏 chunk 会明显膨胀,Lighthouse 分数也会掉。改成按钮触发后的动态 import(),主文档加载和 AI 能力初始化才能解耦。
第三,不要忽略浏览器和 GPU 差异。 Chrome/Edge 支持较好,Safari 和部分企业浏览器可能没有 navigator.gpu;即使存在,也可能拿不到 adapter。必须先检测能力,再给出明确文案。这个坑我排查了整整一下午,最后发现是测试机关闭了硬件加速。
第四,不要让历史消息无限增长。 端侧模型上下文有限,长对话会拖慢生成,甚至触发显存不足。文档答疑保留最近 6 到 10 轮即可,或者在每次提问时拼接当前组件页面的标题、API 名和关键代码,比堆完整聊天记录更有效。
第五,不要在 SSR 阶段访问 WebGPU。 VitePress 构建时会执行组件的部分代码,navigator、document 这类对象应放到事件回调或 onMounted 后访问。把初始化逻辑放在点击处理函数里,是最省心的做法。
第六,不要低估 Cache Storage 的失效策略。 用户清理站点数据、浏览器存储压力回收、模型 URL 变化都会导致重新下载。发布新模型时最好使用带版本号的路径,不要覆盖同名文件,否则可能出现配置已更新、旧分片仍命中的组合错误。
整体落地后,这套方案最适合组件 API 解释、示例代码改写和常见报错排查。它不会替代高质量文档本身,但能把“看不懂示例”的用户留在当前页面继续探索。本文首发于开发者日记,后续我还会补充如何把当前 Markdown 页面上下文自动注入 prompt,让答疑结果更贴合具体组件文档。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。