前言

这次给 Hexo Butterfly 博客增加了一个“博客助手”页面。它不是接入云端聊天接口,而是在访客浏览器中下载并运行 ONNX 模型,再从博客文章中检索相关段落,生成带原文引用的中文回答。

最终页面具备以下能力:

  • 从顶部菜单进入独立的 /chat/ 页面;
  • 只有进入对话页并主动点击启用后,才下载模型和启动 Worker;
  • 优先使用 WebGPU,不满足条件时由访客手动切换到 WASM 兼容模式;
  • 在浏览器中流式生成回答,可展开思考内容;
  • 从本地 RAG 索引检索文章,并在回答后显示原文链接;
  • 支持停止生成、重试、清空对话和本次会话恢复;
  • 将模型文件保存到当前站点来源的 Cache Storage,后续访问优先复用。

页面看起来只是一个对话框,实际实现中最难处理的部分是大文件缓存、浏览器配额、WebGPU 精度和异步状态。本文聚焦页面设计、接入、调试和验证过程。

技术方案

为什么使用独立页面

最初可选方案有两个:在所有页面显示悬浮入口,或者在顶部菜单增加独立页面。最终选择独立页面,原因很直接:

  • 模型首次下载约为数百 MB,需要完整展示下载、缓存和初始化状态;
  • 对话、思考过程和文章引用需要足够的纵向空间;
  • 手机端悬浮窗口容易遮挡正文,也不利于处理键盘弹出后的布局;
  • 独立 URL 方便刷新、排错和分享,同时可以关闭侧栏、评论和顶部大图。

Butterfly 菜单只需要增加一项:

1
2
menu:
博客助手: /chat/ || fas fa-comment-dots

对话页使用单独的页面类型,并关闭与聊天无关的主题元素:

1
2
3
4
5
6
7
8
9
---
title: 博客助手
type: blog-ai
aside: false
comments: false
top_img: false
---

<div id="blog-ai-page" aria-busy="true"><p>正在准备博客助手…</p></div>

延迟加载与页面生命周期

如果把完整对话程序直接注入主题,普通文章页也会下载大体积 JavaScript。为避免影响博客原有访问体验,主题只加载约 1.5 KB 的入口脚本:

1
2
3
inject:
bottom:
- <script type="module" src="/ai/blog-ai-loader.js"></script>

入口脚本检测到 #blog-ai-page 后,才继续加载对话样式和主程序。模型 Worker 则要等访客点击“下载并启用”后才创建。最终加载层次如下:

Butterfly 使用 PJAX 后,页面跳转不会每次都重新载入整个文档。因此入口还要处理重复进入和离开:进入时只挂载一次,离开对话页时终止 Worker、取消当前任务并释放状态,防止旧页面的回调继续修改新页面。

模型与运行模式

页面固定使用以下模型版本,避免仓库更新后文件结构或计算图变化导致线上行为漂移:

1
2
w10110/qwen3-0.6b-zh-blog-style-onnx
a41c0b941ee4ca797b1e750e683eb4ef36ea1992

文件统一从下面的版本化地址获取:

1
https://huggingface.co/w10110/qwen3-0.6b-zh-blog-style-onnx/resolve/a41c0b941ee4ca797b1e750e683eb4ef36ea1992/

页面提供两种运行方式:

模式主要权重首次下载估算适用条件
WebGPUonnx/model_q4f16.onnx 与 .onnx_data约 663 MB浏览器支持 WebGPU,显卡适配器支持 shader-f16
WASM 兼容模式onnx/model_quantized.onnx 与 .onnx_data约 765 MB无可用 WebGPU,或 WebGPU 初始化失败

检测顺序是 WebGPU API、可用适配器和 shader-f16。WebGPU 加载失败后不会自动开始下载另一套约 765 MB 的 WASM 权重,而是显示失败原因,由访客点击“切换兼容模式”。这样可以避免一次失败触发第二次大文件下载。

本地文章检索与对话状态

构建文章索引

Hexo 生成站点时会读取所有文章,移除 front matter、代码块、Hexo 标签和 Markdown 标记,再按标题与段落切分文本,生成 /ai/rag-index.json。每个检索单元保留文章标题、小节标题、URL 和正文。

浏览器取得问题后,先使用本地 BM25 检索最相关的文章片段,再把最多 3 条内容加入模型上下文。回答下方显示对应文章链接,访客可以回到原文核对。检索和生成都在浏览器中完成,问题不需要提交到博客服务端。

控制上下文与恢复会话

当前配置保留最近 4 轮上下文,单次输入最多 6000 个字符,最近 20 条界面消息写入 sessionStorage。刷新同一个标签页可以恢复本次会话;关闭标签页或清空对话后,这些消息不会继续保留。

重试不能简单使用“当前全部消息”。生成期间用户可能停止任务,页面状态也可能变化。因此每次请求都会保存当时的问题和上下文快照,重试时使用对应快照重新发起,避免把后续消息错误地拼入旧问题。

第一个坑:进度条不等于网络下载

Transformers.js 在读取文件时会发出进度事件,但缓存命中时同样可能经过读取和解析过程。如果界面把所有进度都显示为“正在下载”,访客会误以为刷新后又下载了数百 MB。

页面后来把状态拆成四个阶段:

  1. 正在从网络下载;
  2. 正在写入浏览器缓存;
  3. 文件已就绪,正在初始化模型;
  4. 模型已就绪。

判断是否真正重下不能只看进度条,应同时检查 DevTools 的 Network 面板和 Application → Cache Storage。若 .onnx_data 每次都有数百 MB 的实际传输,而且缓存里只有配置和分词器文件,这才是缓存写入失败。

第二个坑:大权重无法一次写入 Cache Storage

最初直接把完整 .onnx_data 响应写入 Cache Storage。小文件可以正常保存,大权重却在写入时抛出:

1
QuotaExceededError: Quota exceeded.

为减少单次写入压力,缓存层改为 16 MiB 分块:

  • 每个分块使用 ?__blog_ai_chunk=N 作为本地缓存键;
  • 下载流一边交给模型加载,一边按 16 MiB 写入 Cache Storage;
  • 所有分块成功后,才在原始 URL 下写入一份很小的清单;
  • 下次读取时先验证清单和全部分块,再把它们重新拼成响应流;
  • 缺少任意分块时都不把文件标记为完整。

“最后写清单”很关键。否则下载中断后,页面可能把残缺权重当成完整文件,直到 ONNX Runtime 初始化时才出现难以定位的错误。

分块只能降低单条缓存写入的压力,不能绕过浏览器对当前来源的总配额。若总配额只有约 281 MB,写到第 16 个 16 MiB 分块附近仍然会失败,这正是后续排查时遇到的现象。

第三个坑:navigator.storage.estimate() 不是写入保证

排查时,页面通过 navigator.storage.estimate() 得到大约 10.75 GB 总配额和 10.74 GB 剩余,看起来远大于模型文件。但 Chrome DevTools 的 Application → Storage 显示实际使用到约 281 MB 后就已满。

这两个数并不矛盾。Storage API 返回的是估算值,浏览器可能为了降低指纹识别风险而模糊结果;Chromium 对 session-only 或临时来源还有单独的额度计算和约 300 MiB 的上限。因而“估算剩余 10 GB”不能证明一次 663 MB 的 Cache Storage 写入一定成功。

实际排查应同时看三处:

  • Application → Storage 的当前来源用量与配额;
  • Application → Cache Storage → transformers-cache 的分块数量和完整清单;
  • Network 面板中 .onnx_data 的实际传输大小。

还要检查 Chrome 是否配置为关闭窗口时删除该站点数据。普通窗口也可能因此按临时站点存储处理。若 DevTools 勾选了 Simulate custom storage quota,Chrome 会强制执行填写的测试额度;额度小于模型所需空间时,分块缓存必然在中途失败。

第四个坑:ORT 警告不是实际报错

WebGPU 初始化时曾出现下面两组日志:

1
2
3
Some nodes were not assigned to the preferred execution providers...

Unexpected input data type. Actual: (tensor(float16)), expected: (tensor(float))

第一组是 ONNX Runtime 的执行提供器分配警告。形状计算等节点被放到 CPU 上不一定是故障,也不是这次加载失败的根因。真正终止运行的是第二组数据类型错误。

进一步检查两套 ONNX 图后可以确认:

  • q4f16 模型的 KV Cache 和 logits 是 float16;
  • q8 模型的 KV Cache 和 logits 是 float32;
  • 远程 config.json 对两种模式的 KV Cache 类型声明正确。

问题发生在采样阶段。Transformers.js 3.8.1 使用的 TopK ONNX 算子要求输入为 float32,而 q4f16 模型输出的 logits 是 float16。模型加载结束后的预热会立即调用 generate(),所以错误看起来像“模型加载失败”,实际是在第一次采样时失败。

解决方式不是把整套 q4f16 模型改成 float32,而是在 Qwen3 的 logits processor 链最前面加入一次局部转换,只把当前 token 参与采样的 logits 转成 float32,再交给温度、TopK、TopP 等后续处理器。这样保留了权重和 KV Cache 的半精度优势,也覆盖预热与正常生成两个入口。

第五个坑:停止任务后旧回调覆盖新状态

对话页面同时包含文章检索、Worker 加载、模型生成、流式消息和 PJAX 页面切换。只给按钮加一个 disabled 不能完整解决并发问题。

实现中为每次加载和生成分配版本标识,并在以下场景使旧版本失效:

  • 访客取消模型加载;
  • 停止当前生成;
  • 从 WebGPU 切换到 WASM;
  • 清空对话;
  • 离开 /chat/ 页面。

Worker 回传进度、结果或错误时,必须先确认版本仍属于当前任务。停止生成时保留已经输出的文字,但终止旧 Worker,避免 WASM 仍在计算时把后续 token 混入下一轮。Worker 自身异常也要主动结束等待状态,否则界面会一直停在“正在生成”。

文章检索期间同样需要锁定提交入口。否则连续点击发送会创建两组检索与生成任务,消息顺序和引用都会错位。

缓存的真实边界

模型最终保存在浏览器的 Cache Storage 中,可在当前博客页面按 F12 后进入 Application → Cache Storage → transformers-cache 查看。它不是 Windows 上由网页指定的固定文件夹,也不会写进 Hexo 项目目录。

缓存还有几个必须接受的边界:

  • 缓存按来源隔离,协议、域名或端口任一不同都属于另一个来源;
  • 用户清理站点数据、浏览器回收临时数据或更换浏览器后,需要重新下载;
  • 当前分块缓存不提供 HTTP Range 断点续传;缺少最终清单时,下次仍可能重新获取完整文件;
  • Network 显示下载达到 100% 后,还要等待缓存写入和模型初始化完成。

复盘

浏览器本地模型的接入难点并不只在推理 API。一个可用的博客对话页还要同时处理资源按需加载、数百 MB 文件缓存、来源隔离、配额误差、半精度算子兼容、Worker 生命周期和异步竞态。

这次最有价值的调试经验有三点:

  1. 先区分警告、表象和真正使流程停止的错误;
  2. 对大文件缓存同时观察 Network、Cache Storage 和实际配额,不能只相信一个估算值;
  3. 把“已下载”“已写入缓存”“正在初始化”“可以对话”设计成不同状态,界面才能如实反映系统正在做什么。

相关资料: