昨天才给博客做完大扫除,今天又没忍住继续装修。原计划只是把 GitHub Pages 同步到 Cloudflare Pages,结果一路从双轨部署折腾到 Workers AI,最后还在右下角雇了一只会疯狂拍键盘的抽象猫。
事情是怎么从“多部署一个 Pages”发展到“给博客养猫”的,流水账记录一下,哈哈。
先理清两个仓库
我的 Hexo 博客现在涉及两个 GitHub 仓库:
lenardar/blog:保存 Markdown、图片、主题和配置,也就是博客源码lenardar/lenardar.github.io:保存 Hexo 生成后的静态文件,用于 GitHub Pages
平时写文章和修改主题都在 blog 仓库。Hexo 负责把这些源码生成到 public/,lenardar.github.io 则只是发布目录,不适合直接编辑。
一开始,这套发布过程需要在本地手动运行:
1 | npm run clean |
GitHub Pages + Cloudflare Pages 双轨部署
Cloudflare Pages 直接连接 lenardar/blog 的 main 分支,配置也很简单:
1 | Build command: npm run build |
以后只要把源码推到 blog:
1 | git push origin main |
Cloudflare Pages 就会自动安装依赖、运行 Hexo、发布 public/。原来的 GitHub Pages 发布路线也继续保留。
后来我又把 GitHub Actions 接了进来,让 GitHub Pages 也不再依赖本地手动部署。现在每次向 blog/main 推送代码,工作流会自动安装依赖、运行 Hexo,再把生成的 public/ 推到 lenardar/lenardar.github.io:
1 | name: Deploy GitHub Pages |
这里使用 PAGES_DEPLOY_TOKEN,是因为工作流要从源码仓库跨仓库推送到发布仓库。Token 只授权目标仓库的 Contents 读写权限,再保存为源码仓库的 Actions Secret 即可。
所以准确地说,并不是“不用 Hexo 了”,而是“不用我亲自运行 Hexo 了”:Cloudflare Pages 和 GitHub Actions 都会在云端执行 npm run build,本地的 npm run deploy 只作为备用方案。
最终就变成了两条互不冲突的发布路线:
1 | push 到 blog/main |
现在博客可以从三个地址访问:
blog.ilovemilktea.top:自己的域名,目前指向 GitHub Pageslenardar.github.io:GitHub Pages 的原生地址lenardar.pages.dev:Cloudflare Pages 地址,支持完整的 AI 小猫功能
三个入口背后仍然是同一份博客源码,两条构建路线也没有所谓的“优先级”。好处是迁移和回退都轻松:Cloudflare 出问题还有 GitHub Pages,GitHub Pages 有问题也不影响 Cloudflare。
为什么只有 API 经过 Functions
Cloudflare Pages 不只可以放静态文件,还支持 Pages Functions。为了不让每一个 CSS、图片和文章请求都经过函数,我在 Hexo 的 source/_routes.json 里只开放了 API 路径:
1 | { |
这里还有一个小坑:Hexo 默认会忽略以下划线开头的文件,所以要在 _config.yml 里显式包含:
1 | include: |
否则本地明明有 _routes.json,构建后的 public/ 里却找不到,Cloudflare 自然也不会应用路由。
然后,博客开始养猫
最初的想法很简单:访客打开博客时,让右下角的小宠物随机说一句欢迎词。既然 Workers AI 每天有免费额度,而且博客访问量也不大,那就干脆每次实时生成。
后来又觉得,只会打招呼有点浪费,于是继续加了:
- 总结当前文章
- 用简单语言解释核心概念
- 从博客里推荐相关文章
- 接受访客自由提问
前端会读取当前页面正文和 Hexo 已有的 search.xml,在浏览器本地匹配最多三篇相关文章,再把截断后的上下文发给 /api/chat。这样不需要额外维护数据库、向量库或者搜索服务。
大概是这条链路:
1 | 当前文章 + search.xml |
Pages Functions 具体怎么写
Pages Functions 的约定很直白:项目根目录下的 functions/ 会按照文件路径自动变成接口。
1 | functions/ |
在 Cloudflare 控制台给 Pages 项目添加一个 Workers AI binding,变量名设为 AI。部署后,函数里就可以直接使用 context.env.AI,不需要把 API Key 放进仓库。
欢迎词接口
欢迎词只需要页面标题和页面类型,前端发一个很小的请求:
1 | fetch("/api/greeting", { |
Function 接收后调用 AI binding:
1 | const DEFAULT_MODEL = "@cf/qwen/qwen3-30b-a3b-fp8"; |
实际代码外面还包了一层 try/catch。绑定不存在、模型超额或者调用失败时,接口返回预先准备的本地问候,并标记 source: "fallback"。前端不需要区分错误类型,照常把文字放进气泡即可。
文章聊天接口
聊天的 Function 仍然只是一个 POST 接口,但输入多了三个部分:
1 | { |
服务端不会盲目信任这些内容,而是先清洗和截断:
1 | const question = cleanText(body?.question, 300); |
系统提示词里还明确告诉模型:文章正文属于不可信参考资料,不能执行正文里夹带的指令,资料不足就坦率说明。处理完以后才调用 Kimi:
1 | const result = await context.env.AI.run( |
这里关闭了长推理。博客问答更需要简洁、稳定和省额度,并不需要模型在后台写几百 token 的思考过程。
不上向量数据库,先用 search.xml
Hexo 已经通过 hexo-generator-search 生成了 search.xml,里面包含文章标题、链接和正文。前端第一次聊天时拉取它,之后复用同一个 Promise:
1 | function loadSearchEntries() { |
提问和当前文章会被切成英文单词、数字和中文二元词组。标题命中加 5 分,正文命中加 1 分,排序后取前三篇。当前页面正文最多取 5,000 字符,相关文章每篇最多取 1,800 字符,最终上下文再统一截到 9,000 字符。
这当然没有向量检索聪明,但对于目前不到十篇文章的小博客已经足够,而且零数据库、零索引任务、零额外费用。以后文章多起来,再把这一层换成 Vectorize 也不迟。
前后端都准备退路
整套功能刻意设计成“猫可以罢工,博客不能罢工”:
- GitHub Pages 上没有
/api/*:前端自动使用本地问候和关键词搜索 - Cloudflare 没有 AI binding:Function 返回 fallback
- Workers AI 超额或暂时失败:Function 捕获异常并返回 fallback
search.xml拉取失败:仍然可以使用当前文章正文- 请求超过 15~30 秒:浏览器用
AbortController主动取消
所以 AI 是一层渐进增强,而不是博客正常阅读的前置条件。
从 Emoji 猫到抽象键盘猫
第一版为了先跑通功能,右下角放的是一个 🐈。功能是能用了,但越看越像临时占位符。
后来参考“猫咪拍键盘”的动作,重新生成了一只原创角色:白色圆脑袋、几根黑线、表情有点傻,脖子上挂着一个橙色 <>。第一版生成得太精致,像儿童卡通;第二版把细节全部砍掉,反而一下对味了。

生成图先使用纯绿背景,再做本地色键抠除,得到透明 PNG。原本还想生成第二张动作帧,但生成模型很难保证两帧轮廓完全一致,播放时会突然“变脸”。最后用了更简单的办法:同一张图水平镜像,配合 CSS 交替显示。
1 | .blog-pet-mascot-frame-b { |
于是小猫平时安静趴着,生成欢迎词或者回答问题时就开始左右开弓疯狂敲键盘。还适配了手机尺寸和 prefers-reduced-motion,不喜欢动画的访客会看到静态版本。
免费额度够不够
Workers AI 免费计划目前每天提供 10,000 Neurons,UTC 00:00 重置。超出免费额度后请求会失败,而不是自动开始扣费,这一点对“坚决白嫖”的个人博客很重要。具体额度和模型单价以后可能变化,最新数字还是看 Workers AI Pricing。
上线当天查到的实际消耗是:
1 | Qwen3:586.34 Neurons |
于是最终采用双模型:
- 高频、简短的自动问候:
@cf/qwen/qwen3-30b-a3b-fp8 - 访客主动发起的文章聊天:
@cf/moonshotai/kimi-k2.6
欢迎词不需要动用昂贵模型,真正需要理解长文章时再让 Kimi 上场。这样既能提升回答质量,也不至于有人每刷新一次页面都让额度原地爆炸。
最后的样子
现在更新博客源码后,Cloudflare Pages 会自动重新构建;GitHub Pages 仍然可以独立发布。静态内容由 CDN 提供,只有两个 /api/* 接口会进入 Functions,聊天再按场景选择 Workers AI 模型。
最开始只是想多部署一份博客,最后收获了双轨发布、AI 文章助手,以及一只原创抽象键盘猫。
只能说,折腾博客最危险的一句话就是:“顺便再加一个小功能。”哈哈。