一个仓库,多个 Worker
AnyRouter 是一个 pnpm monorepo,发布为多个 Cloudflare Worker。一次完整部署包括 17 个主要 Worker 和两个小型代理 Worker。API、网站、控制台、文档、博客和 MCP 服务器都是独立的 Worker,其中大多数共享同一批包里的代码。
智能体全天合并改动,大约每小时部署一次。本系列第一篇介绍了这个循环。本文讲其中的一条命令 pnpm ops deploy,以及它每次运行都要回答的三个问题。
- 每个 Worker 现在运行的是什么?
- 这次改动可能影响哪些 Worker?
- 这个检出比生产环境新吗?
为什么我们不再全部部署
直到 10 月初,只要共享包有改动,一次部署就会发布整个 Worker 集群,而几乎每个改动都会碰到共享包。完整部署的中位数约为 1,130 秒,统计自部署日志中 10 月 6 日到 9 日最近 5 次成功的完整运行。
最大的阶段耗时 385 秒。它把模型目录爬取成静态 HTML,然后带着这些页面重新部署 web Worker。只有 web Worker 会发布这些 HTML。即使只改文档,也要付出这次爬取,耗时约 513 秒,其中 425 秒生成的页面从未被部署。
每个 Worker 记录自己的提交
第一个修复是不再猜测生产环境在运行什么。ops 执行的每次 wrangler deploy 都会传入 --message anyr-sha=<commit>。Cloudflare 把这条消息保存在 Worker 版本上,任何拥有 API 令牌的机器之后都能读取。它不花钱,也不需要新的数据库。
普通的 pnpm ops deploy 会并行读取每个 Worker 的线上版本。我们在 10 月 12 日测量时,这次读取耗时 1.8 秒。更新密钥会产生一个没有消息的新版本,所以读取程序会跳过这些版本,找到其下的代码版本。
然后每个 Worker 的 diff 是 <它的线上提交>..HEAD,再加上未提交的改动。我们以前与 origin/main 比较。合并一推送,那个 diff 就是空的;10 月 11 日,一次 --since HEAD~1 的部署漏掉了一个压在后续文档提交下面的合并。线上提交是唯一与实际运行内容一致的基准。
读不到提交的 Worker 一律部署。这包括从未打过标记的 Worker、从有未提交改动的工作区部署的版本,以及 API 错误。部署多了是安全的,部署少了不安全。
由导入图选出目标
接下来,scripts/lib/worker-graph.ts 把改动的文件映射到 Worker。对每个 Worker,它收集其依赖的 workspace 包和源码实际导入的 @anyrouter/* 包,再对每个包套用同样的规则。测试文件不会增加依赖边。
共享 UI 包按文件处理。对每个 Worker,规划器从该 Worker 自己的源码出发,沿着导入走进 packages/ui。改动一个 UI 文件,只会部署能到达它的 Worker,dry run 会写出导入它的文件。如果遇到无法追踪的导入,例如带模板字符串的动态 import(),就把该 Worker 视为能到达整个 UI 包。
有些输入可能改变所有 bundle,所以会强制走完整路径。包括 package.json、lockfile、根目录的 TypeScript 和 Vite 配置、部署脚本本身、数据库迁移,以及规划器不认识的任何路径。只改 docs/kb 下的 kb 笔记,本身不会触发部署。
pnpm ops deploy --dry-run 会打印计划:每个 Worker 一行、每个目标的原因,以及预计耗时。下面是规划器对几类改动的估算,基于部署日志中各阶段的中位数:
| 改动 | 部署 | 估算 | 之前 |
|---|---|---|---|
| 只改控制台 | dashboard | 约 65 秒 | 约 1,130 秒 |
| 只改文档应用 | docs | 约 95 秒 | 约 513 秒 |
| 首页区块 | web 及其运行时依赖 | 约 620 秒 | 约 1,130 秒 |
| 只改一条 kb 笔记 | 无 | 0 秒 | 0 秒 |
过时的检出无法让生产回退
10 月 12 日,两台机器同时运行了 pnpm ops deploy。其中一台在另一台运行中途部署了更新的提交,于是前者的最终检查失败。那次没有造成损害,因为更新的构建已经包含了较旧的提交。如果顺序相反,生产环境就会回到更旧的构建。
现在在第一次上传或迁移之前会运行两道防护。
- 部署租约。部署会向 Workers KV 写入一个租约键,内容包括机器、提交、开始时间和一个随机 nonce,有效期 25 分钟。KV 没有 compare-and-set,所以写入方等待 1 秒后再读回该键,只有存储的 nonce 属于自己的机器才拥有租约。其他机器会等待,每 15 秒检查一次,默认 15 分钟后放弃。构建期间,一个后台进程每分钟续一次租约。
- 回退防护。拿到租约后,部署会把 HEAD 与每个目标的线上
anyr-sha比较。如果 HEAD 是某个线上提交的严格祖先,就中止,并提示生产环境更新、请先 pull。git 无法解析的线上提交永远不会阻止部署。
第三项检查来自另一次事故。一次部署要在几分钟内持续读取共享检出,而有一次另一个会话在构建过程中在那里运行了 git pull。那次部署用更新的脚本爬取了基于更旧 bundle 的页面,导致 4 个模型页面退回到服务端渲染。现在 ops deploy 在开始时记录 HEAD 和受跟踪文件的状态,并在每次构建、上传和爬取之前重新检查两者。任何一个变化,它都会中止,并列出已经发布的 Worker。
爬取有了缓存,也更快了
每次 web 部署都会整体替换该 Worker 的静态文件,所以每次都必须带上预渲染页面。丢掉它们会让目录退回到服务端渲染。因此规划器从不跳过爬取,而是复用已有的爬取结果。
缓存键是构建产物的哈希,而不是源码的哈希,因为 HTML 中嵌入了构建的资源文件名。对同一构建重试失败的部署时,会复用爬取结果,完全跳过爬取。不涉及 web Worker 的部署则完全不爬取。
爬取本身也变快了。它以前一次爬一个页面,每次暂停 800 毫秒,这是为 Workers Free 计划选的参数。在付费计划上,它现在一次爬 2 个页面,暂停 100 毫秒。在一台机器记录的 10 月 25 日和 26 日的 18 次成功部署中,同一步骤(爬取加重新部署 web Worker)耗时 84 到 129 秒,中位数约 100 秒。以前是 385 秒。
现在的成本
下面是一台机器在 10 月 25 日和 26 日的部署日志。其他机器也在部署,所以这只是样本,不是总数。
- 按改动文件模式,成功 18 次,失败 3 次。
- 18 次从开始到结束的中位数为 522 秒;到生产环境提供新代码并通过 5 秒关键冒烟测试的中位数为 419 秒。完整爬取和检查在此之后运行。
- 18 次中有 11 次走了完整路径,中位数 530 秒;其余 7 次发布了 11 到 17 个 Worker,中位数 493 秒。
最后这个数字最重要。智能体自己经常修改部署脚本、lockfile 和共享包,每一项都会强制一次大范围部署。按 Worker 选择目标,在小而局部的改动上省得最多,比如一次控制台修复或一次文档编辑。所以完整路径部署从约 1,130 秒降到 530 秒,主要来自另外两项改动。爬取从 385 秒降到约 100 秒,上传现在也与下一次构建同时进行。这是我们对日志中各阶段耗时的解读,不是对照实验。
接下来,我们希望减少强制走完整路径的输入。控制台就是一个例子。10 月 10 日统计时,控制台 Worker 能到达的 126 个控制台 UI 文件中,有 102 个也通过一棵旧的路由树被打包进 web Worker,所以修改其中任何一个也会部署 web Worker。删除那棵旧树后,这些修改就只会部署控制台。
相关文章
Building an LLM gateway on Cloudflare Workers
AnyRouter runs as a set of Cloudflare Workers, not a long-running server. That constrains the design in specific ways — a 128MB memory ceiling that rules out buffering responses, a 3MiB script cap that forced a multi-Worker split, and a hard rule that every upstream call goes through Cloudflare's AI Gateway. Here's what that architecture actually looks like.
How automatic failover works in an LLM gateway
A single provider call has a single point of failure: one rate limit, one outage, one bad deploy on their end, and your request fails. Here's the actual mechanism AnyRouter uses to route around that — circuit breakers, escalating cooldowns, and a fallback chain — and why the extra hop barely registers next to model generation time.