中文 EN
// Engineering Notes

一个人 + 一支 Agent 车队:
这个 Hub 是怎么搭起来的

BigCat · 2026-07 · 技术博客
BigCat's Learning Hub 是我为自己学习而建的一组静态站:思维模型、AI/ML、System Design、论文精读、哲学、佛学、育儿、投资……三十来个主题,每个是一个独立的 GitHub Pages 仓库。它们每天自动更新、中英双语、带全站搜索、登录-free 评论、邮件订阅/投票和语音朗读,还能装成手机 App、离线看能离线听、划线做笔记——而日常运营几乎不需要我动手。这篇文章讲整条 pipeline 是怎么搭起来的。

0. 先看它长什么样

核心思路一句话:我只维护每个站的路线图(TOPICS.md),内容生产、发布、加工、聚合、巡检全部交给自动化——其中「写内容」这一步交给云端定时运行的 Claude agent。

1. 总体架构

人(我) 维护 TOPICS.md · 定封顶 · 审月度建议 生成层 · 云端 Claude routine ×20 cron 错峰 → 挂载 repo → 按 CLAUDE.md 写作 选题(文件系统幂等) → zh+en 页 → publish.sh git push 仓库层 · 20+ 内容 repo(GitHub Pages) TOPICS.md · CLAUDE.md · .maxchars publish.sh 发布闸门(8 项校验) push 触发 加工层 · 每仓 GitHub Actions 注入共享 JS · Azure TTS bake(音频存 R2) 每日定时 聚合层 · hub GitHub Actions refresh-hub 首页 · build-search 索引与语料(存 R2) GitHub Pages 发布 读者浏览器 · 静态站 搜索 / 阅读 / TTS / 语言切换 治理层 · 让系统自己管自己 • 封顶达标 → API 停机 (enabled=false) • *.en.html 中文泄漏指纹检测 • 月度前沿刷新 → ROADMAP- SUGGESTIONS.md(只建议不改) • ai-ml ⇄ super-individual cross-ref 同步防重复 每一层只信任下一层的产物, 不信任它的过程 —— 故层层设闸。 互动后端 · 唯一的一块后端 Cloudflare Worker:/subscribe · /vote · /comment … D1(SQLite):subscriptions · votes · comments 订阅双重确认 · 评论防刷 · CORS 锁站 GitHub Action 每 12h 把票/评论桥回仓库 JSON fetch

分层的原则是:每一层只信任下一层的产物,不信任它的过程。生成层可能写崩,所以仓库层有校验闸门;校验闸门可能漏,所以治理层有巡检。

2. 内容仓的解剖:四个文件定义一个站

每个内容 repo 除了 HTML 页面,只有四样东西,各司其职。

TOPICS.md —— 人类维护的路线图(对 bot 只读)

整个系统里唯一持续需要我投入的地方。它按顺序列出这个站要覆盖的全部主题——这份清单的长度本身就是封顶:写完即毕业(见 §9)。关键设计是权限单向:routine 只能读它,publish.sh 会直接拒绝任何修改了 TOPICS.md 的提交。选题耗尽时,routine 不许自己续写路线图,只能给我发一条推送通知请求补充。

这条线划在这里,是整个系统「不跑偏」的根本:AI 决定怎么写,人决定写什么。

CLAUDE.md —— 写作规范

需要精确控制版式和深度的站(system-design、论文精读、每日精读)有一份详细的执行规范:目标读者(资深工程师)、篇幅区间、必备章节结构(比如论文精读的「一句话 → Glossary → 坐标 → 问题动机 → 核心思想 → 关键结果 → 影响 → 局限与批评 → 要点收尾」九段式)、配色(每个站有独立的视觉签名:system-design 是青色暗色系,论文精读是琥珀铜色)、以及诚实性要求——引文拿不准要标「大意」,局限和反方观点必须写。

规范里最值得一提的是以已发布页面为基准:prompt 会指着某一篇(「以 read1 为深度、格式与腔调基准」)说照这个来。比起在规范里描述风格,直接给范例样本要稳定得多。

后来发现它还有个更重要的身份:CLAUDE.md 是「不动 prompt 就能改 routine 行为」的控制面。云端 routine 每次运行会自动加载仓根目录的 CLAUDE.md,而且它叠加在 prompt 之上——想改一条规则时,不必去动那份又长又自包含的 prompt(改 trigger 配置还有整体替换 job_config 的风险,见 §9),往 CLAUDE.md 里加一句就行。这条通路是拿探针实测过的:往某个仓的 CLAUDE.md 塞一句「运行开头写一个探针文件并 push」,只推该仓、手动触发一次,远端果然如期出现了那个文件——而且它照常写完了当期内容。

.maxchars —— 长度 ratchet

一个只有一行数字的文件(3500/4000/5000),publish.sh 会统计新页面的 CJK 字符数并卡在这个上限。这是踩过坑之后加的:LLM 生成内容有「长度棘轮」倾向——每篇都比上一篇略长一点,因为模型倾向参考最近的页面,几十天后页面就膨胀失控。解法是双向夹住:prompt 里给目标区间,闸门上给硬上限。

publish.sh —— 发布闸门

所有 repo 共用同一个 150 行的 bash 脚本,routine 写完页面后必须通过它发布。它做的检查:

最后这个格式约定不是小事——commit message 本身成了机器可读的发布记录,下游的完结检测、hub 徽章都靠解析它工作。

3. 生成引擎:云端 Claude routine

内容生产由 claude.ai 的云端定时任务(scheduled agent / routine)完成,目前有 24 个内容 trigger。每个 trigger 的 job 结构:

以「每日精读」为例,它的 prompt 就五步:

  1. 选题:从 TOPICS.md 挑编号最小、且还没做过的——「做没做」看 ls *-read*.html文件系统就是数据库:不需要任何状态存储,重复触发天然幂等,因为下一次运行看到的文件列表已经变了。
  2. 写作:按 CLAUDE.md 的段落结构写深读,明确「宁深勿浅」、术语首现补英文、真实不编造。
  3. 双语落盘{slug}-read{N}.html + {slug}-read{N}.en.html,两版都要求地道而非直译,互相有语言切换链接;同时更新两个语言的索引页。
  4. 发布:跑 ./publish.sh,过闸即上线。
  5. 通知:PushNotification 推一句「已更新 + 一句核心 + 链接」到我手机。

Prompt 最后一句是「务必自主完成、不等任何确认」——云端 routine 没有人在场,任何一步等确认就是死锁。

除了 20 个内容 routine,还有两个元 routine

4. 加工层:push 之后发生的事

routine push 完就下班了,但页面还没到最终形态。每个 repo 的 GitHub Actions 接手做两类后处理。

共享 JS 注入(所有仓)

评论区、搜索、双语 TTS、导航按钮、lightbox 这五个能力由 hub 仓托管的共享脚本提供(comments.jssearch.jsi18n-tts.jsindex-button.jslightbox.js)。内容页不允许硬编码这些 script 标签(publish.sh 会拦),而是 push 后由注入 Action 扫描 HTML、缺哪个补哪个,再以 Auto-inject shared scripts 自动提交。

为什么注入而不是让 routine 直接写进模板?因为基础设施要能独立于内容演进。20 多个仓、几百个页面,如果 script 标签写死在生成模板里,升级一个搜索脚本就要重训 20 个 prompt、重刷几百页。注入制下,共享脚本改一处,全站下一次注入即生效;旧页面也能追溯补齐。

Azure TTS bake(预烘焙音频)

中文页的每一节都能点击朗读,音频是预先「烘焙」好的:

hash 寻址让整条链路幂等:内容没变就不花一分钱 API 配额,改了一节只重烘那一节。hash 算在原文上,不是算在送给 Azure 之前的规范化结果上——朗读规则(符号替换、公式处理、emoji 剥离)一直在演进,若 hash 跟着规则走,任何一次微调都会让全站音频「失配」而被静默重烘几千段。旧音频里的小瑕疵,远比全站重烘划算。

音频不进 git,而是存在 Cloudflare R2(bucket 内按 <仓名>/<语言>/<hash>.mp3 组织),由一个 Worker 伺服。早先音频是提交进仓库、由 Pages 直接伺服的,代价是几 GB 二进制进了 git history,最大的几个站开始逼近 Pages 的 1 GB 限制。迁走之后 audio/ 在所有仓里都被 gitignore。

只有中文烘焙。英文页的朗读走浏览器自带的 Web Speech API——机器音质地不如 Azure,但英文的合成质量差距远比中文小,不值得为它花掉配额和存储。

5. 聚合层:Hub 怎么知道下面 20 个仓的动静

Hub 仓自己也是全自动的,两个每日 Action。

refresh-hub.yml —— 首页重渲染

generate_hub.py。这个脚本是典型的「单一数据源渲染双语」:卡片元数据(标题、双语简介、配色、分区)是脚本里一个 CARDS 数组,中文页和英文页从同一数组渲染,不存在两份要同步的 HTML。

动态部分靠 GitHub REST API:

时间上,这个 Action 排在所有内容 routine 之后约 45 分钟跑,保证当天新页面能反映到首页。

build-search.yml —— 全站搜索

静态站没有后端,全站搜索用 Pagefind:每天把所有内容仓 clone 到 _src/ 聚合成一棵树,跑 Pagefind 生成分片索引(中英文分开索引)。前端 search.js 提供一个悬浮搜索按钮,按页面语言弹相应语言的搜索层。索引时排除页脚、导航、评论容器这些噪音区块。

索引不进 git——跟音频是同一笔账。它有 3100 个文件、46 MB,每晚重建一次全量提交,.git 一路涨到 713 MB,而工作区本身只有 5 MB:这个仓库 99% 是历史包袱。现在索引传到 R2,由一个 Worker 按 /pagefind/* 伺服,URL 形状跟同源时代一模一样,前端只换掉一个 origin。

跨域这一步有个坑值得单独记:Pagefind 把结果路径按 bundle 所在的 origin 解析。索引搬走之后,每一条搜索结果都指向 Worker 域名,而那个 Worker 只伺服 /pagefind/*——全站搜索结果一度全是 404 死链,得在客户端把 bundle origin 剥回站点路径才修好。(同一批改动还有一次学费:Worker 把浏览器发来的条件请求头整个转给了 R2 的 onlyIf。浏览器重验缓存模块时发的是 If-Modified-Since,R2 用「没改」的空 body 对象回应,我的代码把它误判成前置条件失败、返回 412——于是 import() 整个失败,而我用 curl 怎么试都正常,因为 curl 不发校验头。能在 curl 里复现的 bug 是幸运的,只在浏览器里发生的才折磨人。

有一个特殊处理:思想家圆桌辩论站(thinker-arena)是客户端渲染的(内容在 JSON 里),爬不到文本。解法是 render_search_snapshots.py 在建索引前把 JSON 辩论渲染成纯 HTML 快照专供 Pagefind 消费——给爬虫做一份 SSR,只不过是每日批处理版。

中文搜索:同一个错误,我犯了三次

搜「现象学」,出来 1172 条——几乎是全站。而实际写到这个词的只有 27 篇。

根子不在排序,在索引端和查询端对「什么算一个词」的理解不一致。为了让「拓扑」能命中「拓扑直觉」,建索引时我在相邻汉字之间插了零宽空格,把中文强行切成单字;查询端却没动,仍然走 Pagefind 自己的分词器。于是「现象学」被切成词去查一个只有单字的索引——而 Pagefind 对查不到的词是静默丢弃,不是返回零结果。查询就这么退化成了单字「学」,把每一篇带「学」的文章都捞了上来。

修了两层。查询端改成精确短语优先、空了再退回:给中文词加引号强制短语匹配,「现象学」立刻从 1172 条收敛到 27 条,正好等于真实篇数。但引号不能无脑加——专名尤其容易踩,「海德格尔」加引号是 0 条、不加是 15 条,因为查询端和索引端对同一个字符串的分词结果不一致。所以每个词第一次出现时先探一次,空了就记下来走无引号。

索引端补子词召回:「拓扑」在正文里从不单独出现,全是「拓扑学」「拓扑指纹」「拓扑不变」,词典分词把它粘死了,全站 61 篇含这个词而搜索只给 1 条。加了一层隐藏的二元组影子文本(Lucene 那套无词典 CJK 索引的老办法)。

然后我在同一个坑里摔了第三次:影子文本我是直接写中文的「贝叶 叶斯」,以为空格分开就是两个 token。Pagefind 的分词器照样会再切一遍,把它们重新溶回单字——「贝叶斯」于是给出 75 条,而真实是 24 篇。这次骗过我的是「拓扑」恰好用字生僻、看起来一切正常;换个常用字组成的词才露馅。最后把每个二元组编码成 ASCII(bg8d1d53f6,两个码点的十六进制),分词器切不开,才真正成立:贝叶斯 24/24、拓扑 18/18、康德 12/12,跟 grep 数出来的真值一条不差。

教训:这三次是同一个错误换了三身衣服——索引端和查询端必须对「什么是一个 token」达成一致。任何一边单独「优化」,坏掉的方式都不会是报错,而是搜出一堆看起来煞有介事的结果。所以现在建索引时会往影子块里打一个版本标记,客户端探到标记才启用对应的查询策略:缓存的旧前端和新索引不可能「半同意」。

问一句:让站内内容回答问题

关键词搜索有个结构性的天花板:它只能找出含有某个词的页面。「哪几篇讲过决策疲劳」这种问题它答不了——讨论这件事的文章不一定用这个词。

所以在搜索框里加了第二个标签页「问一句」。检索用 Cloudflare AI Search(原 AutoRAG):切块、嵌入、向量库、生成、引用整条托管,嵌入模型 qwen3-embedding-0.6b,生成 qwen3-30b

它要的输入是干净文本,所以 build-search.yml 里多了一步:把聚合好的 HTML 剥成正文 Markdown 写进 R2。没有直接把 HTML 喂给它(它其实收 .html)——这些页面是一百多个 div 的排版裹着十来段正文,原样索引的话每个 chunk 里相当一部分是家具,切块边界还会落在标记里而不是意思之间。文件名镜像站点路径,于是它引用哪个文件,就能直接换算回哪篇文章的链接。

效果值得贴一下。问「哪几篇讲过决策疲劳」,回来的五篇分别来自思维模型、好书推荐、神经科学、思维模型索引、投资经典——五个不同的内容仓,其中几篇并不含这个词。问「意志力是有限的吗」,它会答「存在争议」,并且点出 2016 年 23 个实验室的预注册重复实验让自我损耗效应几乎归零——这是站内那篇文章的核心论点,不是模型自己的常识。

成本我实测了一遍,因为这是唯一一处按用量计费的东西:全量重建索引约 13,000 neurons(约 $0.14),一次提问约 28(约 $0.0003)。免费额度每天 10,000,所以大约是每天 350 次提问。两层都是增量的——语料同步只上传 MD5 变了的文件,AI Search 也只嵌入变更文件(实测某次同步只处理了 22 个、另一次 64 个,而不是全量 2690),所以稳态开销是每天新增的那几十篇,不是每 6 小时重来一遍。

问过的问题连同答案存在浏览器本地,点一下就能回看。回放完全不走网络——不消耗额度、不占那 350 次,也顺带避开了「一个公开站点把访客问了什么集中记下来」这个没人要求过的隐私决定。

这一层的学费集中在「文档说的和实际返回的不一样」:检索结果里 filename 是顶层字段而不是文档写的 metadata.filenamecontent 是分片数组而不是字符串——照文档写,每条出处链接都会是 undefined 拼出来的。另外,我把 Worker 起了跟 AI Search 实例一样的名字,请求还没进到我的代码就 404(Cloudflare 1042)。以及模型会把内部路径写进答案里给读者看((corpus/mental-models/energy-attention-day27.md))——提示词里禁止了,但提示词是请求不是保证,所以服务端还会再剥一遍。

6. 互动层:订阅、评论、投票——唯一的一块后端

前面五层没有一行服务器代码。但「订阅邮件、留言、投票」这三件事天然要写状态,纯静态做不到。这是整个系统里唯一一处后端,我把它压到最小:一个 Cloudflare Worker(worker.js)+ 一个 D1(SQLite)库,全在免费额度内(Worker 每天 10 万请求、D1 每天 500 万行读)。

一个 Worker,三件事

三个功能共用一个 Worker、一个库,靠 pathname 分流:

数据落在 D1 的三张表:subscriptions(邮箱+列表为主键,带 confirmed 标志与语言)、votes(题+投票人为主键,天然去重)、comments(页面路径、名字、正文、审核位、加盐 iphash)。前端只有 engage.js(订阅框+投票)和 comments.js 两个脚本,跟其他共享 JS 一样由注入层铺到全站;Worker 的 CORS 只放行白名单里的来源(现在是自定义域名 hub.cissychen.com,以及迁移前的 cissy0802.github.io)。

登录-free 的代价:三道反垃圾防线

不要求登录,门槛低了,但「账号」这道天然的反垃圾墙也没了——谁都能发,机器人尤其能发。所以自建版内置三道防线:蜜罐字段(隐藏的 website 输入,人不填、机器人会填,填了就静默丢弃)、按 IP 限流(60 秒最多 3 条,用加盐哈希记 IP、不存原始地址)、以及可选的 Cloudflare Turnstile(绑上 secret 后 Worker 自动强制,垃圾真出现再开)。评论正文一律用 textContent 渲染、绝不当 HTML,从根上堵死 XSS。

双重确认订阅 = 以后免密登录的地基

订阅走 double opt-in:/subscribe 先存一条未确认行、发一封带确认链接的邮件(用 Resend 免费层,每月 3000 封),点了链接触发 /confirm 才翻成 confirmed=1,只有确认过的才算真订户。这一步不只是防拼错的邮箱——「邮箱 → 验证」正是免密登录的地基:以后想给评论/投票加登录,只要签发一个会话 token、在 /comment/vote 校验它即可,数据库不用重构。(这句话后来真的兑现了——评论和投票现在都要登录,用的正是这套地基,见 §7。)

周报:把静态内容变成推送

收了邮箱就得能发。这条链路刻意不碰我的机器:

投票去重最初只靠前端一个随机 localStorage token——清掉就能重投。对一个随手玩的 hub 民调,诚实但不严防当时够用;后来真要严格时,也确实是照上面那句话升级的(投票改成绑账号、一人一票,见 §7)。这一层的取舍贯穿始终:能静态就静态;非有状态不可的那一小块,用最小的后端 + 免费额度兜住,并且每处都预留了「以后要更严怎么长上去」。

7. 从网站到 App:离线、笔记、账号

前六层讲的是「怎么把内容生产出来」。这一层是后来长出来的另一个方向:怎么让它更像一个 App——能装到手机主屏、能离线看能离线听、能划线做笔记、还能多人各用各的账号。关键约束没变:不新增服务器、不给每个内容仓改一行,全部挂在既有的注入层(§5)和那一个 Worker + D1(§6)上。

装成 App + 离线:一个 service worker 铺满全站

第一步是 PWA 化:加一个 manifest.webmanifest 和一个根作用域的 service worker(sw.js),Safari / Chrome 里「添加到主屏幕」后就是个全屏、带图标的 App。因为 SW 注册在域名根,它的作用域天然覆盖底下 30 多个内容仓——一次注册,全站离线,不必每个仓单独装。

离线策略是分层的:Hub 外壳(首页、共享 JS)自动进缓存;HTML / JS 走网络优先、断网回退缓存,保证联网时永远看到最新版;音频(预烘焙的 mp3,可能上百 MB)绝不自动缓存,只在用户对某篇文章点了「⤓ 离线」时,才把那一篇的 HTML + 对应 mp3 存进 Cache API。按篇下载,几 MB 到几十 MB,可控。

这个下载按钮我只想给自己看,于是撞上一个 iOS 的坑:装到主屏幕的 PWA 有一个和 Safari 完全隔离的存储空间,而「添加到主屏幕」又会丢掉 URL 上的查询串——所以最初想用 ?me=1 在本机解锁这个开关,永远传不进装好的 App。绕了一版应用内手势后,真正干净的解法要等到账号系统就位(见下):把「谁是主人」从一个存在设备本地的开关,改成绑定到我的账号——登录的邮箱是我,按钮才出现。于是装好的 App 里只要登录一次就行,不再依赖任何查询串或手势,换设备也只是再登录一次。这类「桌面 App 的存储 / URL 行为和浏览器不一样」的坑,都是实测换来的。

划线笔记:定位、离线、归类、批注

笔记是一个 notes.js,和离线脚本一样由注入层铺到全站(连客户端渲染、刻意不挂 Hub 导航的圆桌辩论页也覆盖到了——这里踩过一个坑:注入原本卡在「不显示导航按钮」的早退之后,等于把笔记也一起禁了,后来把注入提到早退之前才修好)。选中任意一段文字,冒出「+ 笔记」气泡,点一下就存。

难点在怎么把一句话重新定位回原文:保存时连同划线句、以及前后各约 40 个字的上下文一起存;重新打开时按「前文 + 原句 + 后文」在页面里查找,命中就滚动过去、黄色高亮闪一下。带上下文是因为同一句话在一页里可能出现多次,只有前后文能把那一处唯一定位出来。

这套定位后来还有第二个用途:把划过的句子在原文上重新画成下划线。这里栽了个不显眼的跟头——最初用 Range.surroundContents() 包裹,它一遇到跨元素的范围就抛异常,而我把异常静默跳过了。结果在行内标签密集的站上(神经科学 22 段里有 19 段句子中间嵌着 <strong>)几乎所有划线都画不出来,却什么错也不报。改成按文本节点分段包裹就好了:一条笔记可以拥有好几段 <mark>,视觉上连续,编辑和删除同时作用于所有片段。

划线本身也成了入口:点一下下划线,就地弹出编辑器写这一段的感想,写过的再点开是已有内容、可直接改。于是实线代表写过感想、虚线代表只划了线,扫一眼就知道哪几段自己动过脑子。除了这种「针对某一句」的感想,还有一层针对整篇的想法,文章末尾和笔记列表两处都能写、内容互通。

笔记是离线优先的:写下即进 localStorage 队列、先在本地生效,联网时自动补传到后端——飞机上、地铁里照划不误。(这里也埋过一个小 bug:推给服务器成功后我把它从队列删了、却忘了写进本地缓存,于是刚划完那一刻本地「查无此笔记」,下划线要刷新才出现。)列表页(/notes.html)分两层折叠:先按仓、再按文章,默认全收起,打开就是一张「我在哪些地方留过痕」的总览;搜索时自动展开命中项。文章的排序刻意跟着仓 index 的顺序(Day 01、02、03……)而不是按划线时间,这样重读时和站点本身的阅读顺序一致。

已读标记与离线书架

站点多了以后,最烦的不是没内容,而是忘了自己读到哪。所以每篇加了一个已读标记:文章末尾一个「标记已读」按钮,同时两种情况自动标记——滚到文章底部,或者把 TTS 听到最后一段(听完当然也算读完)。仓的文章列表里读过的打 ✓、顶部显示「已读 12 / 68」,Hub 首页每张卡片也带一个已读计数。

「听完算读完」这条有个约束:i18n-tts.js各仓各自一份、版本还不完全一致的(注入层铺的是脚本,不是同一个文件实例),改它就得改三十多个仓。所以没有去动它,而是从外部观测它本来就在维护的那个进度读数「当前段/总段数」,读到最后一段就算听完。跟第 7 节开头那个 iOS 坑是同一种手艺:不能改的东西就绕着它读状态。

最后是断网时的入口问题。原本 service worker 找不到页面就回退到 Hub 首页——可首页列的是 30 个站的卡片,而那些站的 index 页从没被缓存过,于是每个链接都弹回同一个首页,下载好的文章反而一个都进不去。现在回退到一个 /offline.html「离线书架」:它直接读 Cache API,列出这台设备真正下载过的东西,标题是从缓存的 HTML 里就地解析出来的(离线当然不能再去请求一次),并标出哪些带语音。整仓下载也顺手把该仓的 index 一起缓存,让下载过的站离线时保有自己的导航。

下载这件事本身也被真实使用磨过三轮,每一轮都是同一个教训的不同面:状态要存在能活过页面的地方。第一轮,整仓下载完成后按钮显示 ✓,重开一次却变回 ⤓——因为按钮每次加载都从零开始,从不问缓存里已经有什么;改成完成时往缓存写一个标记,加载时扫一遍还原。第二轮,下载到一半切走再回来不会继续——下载循环跑在页面的 JS 里,页面一卸载它就死了;于是开始下载时也写一个「正在下载」标记,回到首页看见它就接着下,已下好的直接跳过。第三轮不是 bug 而是设计错误:那一个按钮既是下载又是删除,同一个位置在不同状态下点下去会发生相反的事——现在拆成⤓ 和 🗑 两个各自只做一件事的按钮,后者只在有东西可删时才出现。

删除也比想象中容易做漏:只删 HTML 的话,占空间的大头(语音)会全留下来。所以删除时会从缓存的 HTML 里反查这一篇引用了哪些语音段落再一并清掉——按文件名匹配,于是同时覆盖旧的同源路径和迁到 R2 之后的地址。(这一段我自己也交了学费:改按钮时顺手删掉了两个删除函数,点下去就永远停在「…」——又是一次静默失败。)

多用户账号:把 §6 埋的地基兑现了

笔记一旦要跨设备、还要多人各用各的,就必须有真正的账号。而这正是 §6 结尾埋下的那句预言——「『邮箱 → 验证』正是免密登录的地基,以后签发一个会话 token 即可,数据库不用重构」——现在把它兑现了:还是同一个 Worker、同一个 D1,只多了 users / sessions 两张表和一组 POST /auth-* 端点。

安全上没有偷懒,因为这是公开注册:

整层加完,后端依然是那一个 Worker + 一个 D1、依然在免费额度内。这正好回扣了 §6 的取舍:那时把订阅刻意做成「邮箱 → 验证」,不是多此一举,而是提前给今天的账号系统埋好了地基——今天没有重构,只是把地基上的楼接着往上盖了一层。

顺手把 §6 那两处「诚实但不严防」补严了

账号系统一到位,§6 里那两处刻意留松的地方就顺理成章地收紧了——评论和投票现在都要登录

代价也如实记一笔:参与门槛确实抬高了,路过的读者想留一句话得先注册。对一个「给自己学习、顺便公开」的站,我认为可追溯的少量真实留言,胜过匿名但真假难辨的多。而这一步之所以能这么轻——加一个字段、两个端点,没碰任何一张已有表——完全是因为当初那个 double opt-in 决定。预留接口的价值,要到用上的那天才结算。

8. 两个偏离标准模板的站:Thinking Hub 与 Deep Research

前面讲的是「人定题、routine 写一页」的标准内容站。有两个站不走这个模板——它们更复杂,也最能说明这套架构能往哪儿伸。

Thinking Hub(思想家圆桌辩论)—— 观众投票决定写什么

标准站是「人维护 TOPICS.md、AI 照着写」;这个站把选题权交给了观众。它还是客户端渲染的:每场辩论是一个 debates/*.json,由浏览器脚本渲染成聊天流(所以全站搜索得靠 §5 的快照 SSR 才爬得到)。

难点在一道墙:云端 routine 跑在带出口代理的容器里,那个代理不放行自建后端(访问 bigcat-engage…workers.dev 直接 403),routine 自己读不到票、也读不到评论。解法是架一座桥:用一个 GitHub Action(它的 runner 不在墙后)每 12 小时把后端的净票和评论快照写回仓库的 ideas.json / audience_inbox.json。于是 routine 只读仓库文件,就拿到了后端数据——不必连后端。

这就闭成一个观众反馈回路:观众投票/提议 → Worker + D1 → Action 每 12h 桥接 → 仓库 JSON → routine 开辩时选「净票最高」的议题(或人工置顶队列),收尾时再从观众评论里挖出新候选。观众成了 roadmap 的一部分——这是标准站刻意不给 AI 的权限,在这里安全地还给了人群。

生成端也更重:从 150+ 人的人设名册里选 6–10 位思想家(选角前先跑 diversity_report.py 看分布统计、照聚合数字选,专门反「照抄最近几场形状」的过拟合),routine 亲自写三轮双语辩论、最后由三家 AI 收尾。连人物图鉴都要求「对本场议题盲」地写,防止介绍被当场话题污染。

Deep Research —— 多智能体调研 + 对抗式核查

标准站是一个 routine 一次写一页;这个站的内容是一套多智能体调研管线的产物。每条承重论断都被 3 个独立的验证 agent 拿着一手来源反复反驳过(对抗式事实核查)——通不过的要么补证据、要么砍掉。

它还是全系统里唯一有构建步骤的站:别的站 routine 直接吐 HTML,这里源文件是 markdown(放在 sources/,每篇分「通俗版 plain」和「深挖版 deep」、中英双语),由 build.py 按注册表渲染成 HTML——一篇 = 通俗 × 深挖 × 中英 = 4 个页面。改内容改 markdown、重跑 build.py

为什么单列:它把「生成」从「写一页」升级成「调研 → 对抗核查 → 带引用合成」,是这套系统里研究强度最高、也最贴合本文标题「一个人指挥一支 agent 车队」的一支。

9. 治理层:让系统自己管自己

跑起来只是开始,长期无人值守才是难的部分。几道防线:

封顶 + 自动停机

每个站的 roadmap 是有限的——学完就该毕业,而不是为了「日更」注水。但封顶不是一个写死的数字,而是浮动的:它就等于 TOPICS.md 里还剩几条没写。一个每天跑的巡检任务对每个站算两个数——已发布最高期数 N_pub 与 TOPICS 最大编号 N_top——据此双向同步云端 trigger 的开关:写到顶(剩余 ≤ 0)就 enabled=false 停掉;往 TOPICS 补了新主题(剩余 > 0)就自动重新开启。所以「补几行路线图」就是唯一的复活操作,我不需要碰任何 trigger。

停机操作有两道防呆,都是踩坑换来的:

  1. 停之前先 get 该 trigger,确认它挂载的仓确实是要停的那个仓——防止 trigger ID 对错位置,把别的站停了;
  2. update 请求体只发 {"enabled": false}。因为这个 API 的 update 对 job_config 是整体替换而非合并——带上不完整的 job_config 会把 prompt、挂载仓、模型配置整个洗掉。

部署看门狗

还有一层容易被忽略的失败:页面写对了、也 push 了,但 GitHub Pages 的部署本身失败了(偶发 503 / 构建抖动)。内容躺在仓库里,线上却还是旧版——这种失败最隐蔽,因为前面每一层校验都显示「通过」。

所以本地还挂了一个 launchd 看门狗,每 20 分钟用 gh 扫一遍各仓最近的 Pages 部署,发现失败的就自动重跑。它刻意跑在本地而不是云端——云端 sandbox 的出口代理挡着 api.github.com,反倒是我自己的机器能直连。

音频灾备:唯一一份 git 管不到的资产

整套系统里,几乎所有东西都能从 git 复原:文本、模板、脚本、workflow。唯独音频不能——它存在 R2,而 audio/ 在每个仓里都被 gitignore,所以git pull 永远拉不到音频。它又恰恰是唯一花过真金白银配额、且无法凭内容瞬间重建的资产。

所以另有一个 launchd job 每天把 R2 增量拉回本地,作为第二份副本。配套的一个小服务器能把本地仓库直接跑成可访问的站点:它在响应里把页面中指向 hub 的绝对 URL 改写成本地路径,磁盘上一个字节都不改——因为这些本地克隆是只读镜像,封顶守卫会用 git reset --hard 抹平任何偏离,真去改 HTML 只会被无声回滚。于是 R2 和 hub 同时消失时,本地的 clone 加音频仍能把整个站点原样跑起来。

这里埋着一个反直觉的坑:守卫脚本里绝不能出现 git clean -x-x 会连 gitignore 的文件一起删——也就是把整份音频备份静默清空,而其中有些片段连 R2 都没有。一个用来「保证工作树干净」的动作,会顺手毁掉唯一一份不可重建的资产。

内容层自治:把检查下放进各仓

早期这些内容层检查——写到顶就自停、把越界页回填进 TOPICS、英文页中文泄漏自查——都由外部那个巡检任务集中做。后来全部下放到各仓的 CLAUDE.md:既然 routine 每次运行都会加载它,那就让站点在生成当下自己把这三件事做掉,比事后由外部补更及时。外部巡检因此瘦成了纯粹的 trigger 开关。(回填是为了守住一条不变量:TOPICS 必须是已发布内容的超集。否则某天手动发了一篇 day57 却没记进 TOPICS,日后往 TOPICS 补的 Day 57 就会和它撞号、永远写不出来。)

其中英文页中文泄漏是双语生成最常见的退化:中文漏进英文页的模板槽(副标题、标签、人名栏)。各仓 CLAUDE.md 里写死一组指纹 grep(如 class="en">[一-鿿]),生成后、publish 前自查。指纹是精心挑的——佛经原文配英译、术语括注这类合法的中文不用这些 class,不会误报。

开关本身下放不了,这是条硬边界:绝大多数 routine 在云端只挂了 Google Drive,没有能写 trigger 的元工具,改不了自己的 enabled;更要命的是——一个已经被 disable 的 trigger 无法自我复活,重开天然只能由外部做。所以最终形态是:内容层自治,开关留在外面

内容质量的 ratchet 们

10. 沉淀下来的设计原则

静态优先,后端只留一小块。内容侧没有数据库、没有服务器、没有构建框架:HTML 就是产物,git 就是 CMS,GitHub Pages 就是 CDN,搜索(Pagefind)、TTS(预烘焙 mp3)都用静态方案解决了传统上「需要后端」的功能。唯一非有状态不可的订阅 / 评论 / 投票,收敛到一个 Cloudflare Worker + D1、全在免费额度内——静态优先不是教条,是把后端的面积压到最小。
文件系统就是状态。选题进度 = ls 的结果;发布记录 = commit message;完结状态 = 从 commit 解析。没有任何一处需要独立维护的状态存储,所以没有任何一处会和现实脱节。
每一步都幂等。routine 重复触发不会重写已有页面(文件已存在);TTS 重跑不会重复计费(hash 命中);注入重跑不会叠加标签(缺才补)。无人值守系统的重试是常态,幂等不是优化而是前提。
生成和校验分离。永远不假设 LLM 输出正确,publish.sh 用最笨的 bash 检查兜底。生成端越智能,校验端越要笨而确定。
人类只把守一个入口。我的全部日常投入收敛到 TOPICS.md 和封顶决策上,其余一切推导自它。AI 想扩大自己的工作范围(续写路线图)在机制上被禁止,只能申请。
约定优于协议。Add #N 的 commit 格式、{slug}-day{N}.html 的命名、.maxchars 单行文件——组件之间靠这些朴素约定通信,没有一处 JSON schema,但每处约定都有至少两个消费者。
能力长在注入层,不改内容仓。离线、划线笔记、账号——这些「App 化」的能力全部挂在那一个铺满全站的注入脚本上,30 多个内容仓一行都没改。想给所有站加一个新能力,改的是一处共享 JS,不是 30 个仓。
给未来留地基,而不是为未来过度设计。订阅当初刻意做成「邮箱 → 验证」,不是当时就要登录,而是把地基埋好;等真要账号系统时,同一个 Worker + D1 直接往上接,没有重构。预留接口,但不提前盖楼。
该毕业就毕业。每个站有封顶,写完自动停机、打完结徽章。这个系统是为我自己学习服务的,学完一个领域就该收口,而不是被「持续产出」绑架。

11. 成本

一个人的注意力是这个系统里最贵的资源。整条 pipeline 的设计目标从来不是「全自动」本身,而是把我的注意力从生产和运维里解放出来,全部花在唯一值得花的地方:决定接下来学什么。