Hexo Butterfly 博客助手开发与调试复盘:浏览器本地模型、缓存与精度问题
前言
这次给 Hexo Butterfly 博客增加了一个“博客助手”页面。它不是接入云端聊天接口,而是在访客浏览器中下载并运行 ONNX 模型,再从博客文章中检索相关段落,生成带原文引用的中文回答。
最终页面具备以下能力:
- 从顶部菜单进入独立的
/chat/页面; - 只有进入对话页并主动点击启用后,才下载模型和启动 Worker;
- 优先使用 WebGPU,不满足条件时由访客手动切换到 WASM 兼容模式;
- 在浏览器中流式生成回答,可展开思考内容;
- 从本地 RAG 索引检索文章,并在回答后显示原文链接;
- 支持停止生成、重试、清空对话和本次会话恢复;
- 将模型文件保存到当前站点来源的 Cache Storage,后续访问优先复用。
页面看起来只是一个对话框,实际实现中最难处理的部分是大文件缓存、浏览器配额、WebGPU 精度和异步状态。本文聚焦页面设计、接入、调试和验证过程。
技术方案
为什么使用独立页面
最初可选方案有两个:在所有页面显示悬浮入口,或者在顶部菜单增加独立页面。最终选择独立页面,原因很直接:
- 模型首次下载约为数百 MB,需要完整展示下载、缓存和初始化状态;
- 对话、思考过程和文章引用需要足够的纵向空间;
- 手机端悬浮窗口容易遮挡正文,也不利于处理键盘弹出后的布局;
- 独立 URL 方便刷新、排错和分享,同时可以关闭侧栏、评论和顶部大图。
Butterfly 菜单只需要增加一项:
1 | menu: |
对话页使用单独的页面类型,并关闭与聊天无关的主题元素:
1 |
|
延迟加载与页面生命周期
如果把完整对话程序直接注入主题,普通文章页也会下载大体积 JavaScript。为避免影响博客原有访问体验,主题只加载约 1.5 KB 的入口脚本:
1 | inject: |
入口脚本检测到 #blog-ai-page 后,才继续加载对话样式和主程序。模型 Worker 则要等访客点击“下载并启用”后才创建。最终加载层次如下:
flowchart TD
A[访问普通文章] --> B[只加载轻量入口]
C[进入 /chat/] --> B
B --> D{存在 blog-ai-page}
D -->|否| E[结束]
D -->|是| F[加载对话样式和主程序]
F --> G[访客点击下载并启用]
G --> H[创建 Worker]
H --> I[检查缓存或下载模型]
I --> J[初始化推理会话]
Butterfly 使用 PJAX 后,页面跳转不会每次都重新载入整个文档。因此入口还要处理重复进入和离开:进入时只挂载一次,离开对话页时终止 Worker、取消当前任务并释放状态,防止旧页面的回调继续修改新页面。
模型与运行模式
页面固定使用以下模型版本,避免仓库更新后文件结构或计算图变化导致线上行为漂移:
1 | w10110/qwen3-0.6b-zh-blog-style-onnx |
文件统一从下面的版本化地址获取:
1 | https://huggingface.co/w10110/qwen3-0.6b-zh-blog-style-onnx/resolve/a41c0b941ee4ca797b1e750e683eb4ef36ea1992/ |
页面提供两种运行方式:
| 模式 | 主要权重 | 首次下载估算 | 适用条件 |
|---|---|---|---|
| WebGPU | onnx/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。
页面后来把状态拆成四个阶段:
- 正在从网络下载;
- 正在写入浏览器缓存;
- 文件已就绪,正在初始化模型;
- 模型已就绪。
判断是否真正重下不能只看进度条,应同时检查 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 | Some nodes were not assigned to the preferred execution providers... |
第一组是 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 生命周期和异步竞态。
这次最有价值的调试经验有三点:
- 先区分警告、表象和真正使流程停止的错误;
- 对大文件缓存同时观察 Network、Cache Storage 和实际配额,不能只相信一个估算值;
- 把“已下载”“已写入缓存”“正在初始化”“可以对话”设计成不同状态,界面才能如实反映系统正在做什么。
相关资料:
