跳到主内容

{title} ​

结论先行:WebLLM 要懒加载,模型要走分片缓存 ​

我给组件文档站接入 AI 答疑后的核心结论只有两条:WebLLM 绝不能进 VitePress 首屏主包,模型权重必须交给浏览器 Cache Storage 分片缓存。做到这两点后,文档站首屏几乎不增加 JS 体积,用户点击「AI 答疑」时才初始化 WebGPU,二次访问模型准备时间从数分钟下载降到约 3 秒,答案还能以流式 Markdown 直接渲染在页面里。

这篇文章记录我在 VitePress + Vue3 文档站中接入 WebLLM 的完整方案,包括动态加载组件、WebGPU 能力检测、流式输出、Markdown 渲染、模型分片缓存,以及几个非常实际的坑。

VitePress 文档站内的端侧 AI 分层
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. 1
    文档站首屏直出
    VitePress 正常打包,不静态引入 WebLLM,首屏不下载模型。
  2. 2
    用户点击 AI 答疑
    动态 import @mlc-ai/web-llm,仅加载运行时胶水代码。
  3. 3
    检测 WebGPU
    先检查 navigator.gpu,再请求 adapter,失败时展示降级说明。
  4. 4
    拉取或命中分片
    模型权重按 shard 进入 Cache Storage,二次访问只做校验。
  5. 5
    流式生成答案
    onChunk 更新响应式文本,Markdown 增量渲染到对话面板。

模型分片缓存如何控制加载性能 ​

WebLLM 首次运行会下载模型配置、tokenizer、权重库和多个 .bin 分片。以 Llama-3.2-1B-Instruct-q4f16_1-MLC 为例,量化后仍有约 1.5GB。这个体积不能靠 VitePress 构建优化解决,关键是不要在首屏下载,并让分片可缓存、可复用、可观察进度。

浏览器端模型权重主要进入 Cache Storage。WebLLM 内部通过 fetch 拉取分片,浏览器根据 URL 和响应缓存复用文件。第二次打开时,它仍会读取配置并检查资源,但不会重复下载完整权重。实际体验上,首次瓶颈是网络和磁盘写入,二次瓶颈变成 GPU shader 编译与模型加载。

我采用了三条控制策略:

  1. 入口懒加载:只有点击按钮才 import("@mlc-ai/web-llm"),保证 VitePress 首屏不背运行时体积。
  2. 模型轻量优先:文档答疑选择 1B 级别量化模型,而不是追求 7B/8B。模型越大,首轮放弃率越高。
  3. 进度显性反馈:展示 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 写入前直接失败,报错往往只表现为模糊的网络错误。

接入前后关键指标对比(本机 Chrome 实测)
首屏额外 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,让答疑结果更贴合具体组件文档。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。