Skip to content
EngineeringOctober 27, 2026

我们如何让 AnyRouter 变快

10 月初,一个已预热的请求可能要等待多达 1.7 秒,而模型服务商自己的计时器完全没有计入这段时间。这段时间几乎都不是在做事,而是一串小的读取,后一个等前一个。本文介绍我们如何找到它、改了什么、哪个修复不得不回滚,以及前后的数字。

每个请求里有两块时钟

通过 AnyRouter 调用模型时,等待时间分两部分。第一部分属于我们:检查密钥、选择上游、转发请求。第二部分属于模型:服务商排队、读取提示词、生成 token。

我们无法让服务商生成得更快。我们这部分不同。每个请求都要付出它,一个智能体循环要付出几百次,而它全部运行在我们的代码里。

Anthropic 的文章 How we made claude.ai faster 强调先测量、再优化。我们也按这个顺序来。本文讲网关里的工作,以及不再为每次访问运行代码的页面。每个数字都来自我们自己的测量,并说明了测量方法。

第一步:让我们的时间可见

在 10 月 2 日之前,一个已完成的请求只报告服务商的延迟。请求在发往上游之前在网关里花的时间,没有任何地方在测。所以第一个改动是加测量,而不是修复。

现在每个推理响应都带有标准的 Server-Timing 头。gw 是从请求到达到第一次调用上游的时间。upstream 是每次调用服务商的时间,每次一条。post 是从调用结束到响应组装完成的时间。total 是总和。gw 又拆分成多个阶段,例如 gw-auth(检查密钥)、gw-ctx(账户上下文)、gw-prep 和 gw-route(选择上游)。各阶段之和总是等于 gw。

只打印一次 chat completion 的 timing 头。
curl -s -o /dev/null -D - https://anyrouter.dev/api/v1/chat/completions \
  -H "Authorization: Bearer $ANYROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"anyrouter/free","messages":[{"role":"user","content":"hi"}]}' \
  | grep -i server-timing

撰写本文时的一次调用返回了下面这一行。我们去掉了按次调用的那一条,因为它包含后端名称。

server-timing: gw;dur=764, gw-auth;dur=222, gw-ctx;dur=238, gw-prep;dur=23, gw-route;dur=281, upstream;dur=5487, post;dur=10, total;dur=6261

这里模型用了 5.5 秒,我们用了 0.76 秒。这比后文预热时的数字高得多。这是隔了一段时间后的第一次调用,我们猜测 isolate 的缓存是冷的。222 毫秒和 238 毫秒这两个阶段与下文描述的冷启动模式一致。

第一次测量看到了什么

10 月 2 日,我们在已预热的 isolate 上,对单跳路由比较了客户端总耗时和服务商自己报告的延迟。在路由之前就失败的请求是对照组,它只付出一次网络往返。

路由客户端总耗时服务商延迟未归因
anyrouter/auto0.97 秒59 毫秒约 0.9 秒
anyrouter/agent1.81 秒50 毫秒约 1.7 秒
anyrouter/coding1.56 秒81 毫秒约 1.5 秒
404,未路由(对照)0.19 秒无约 0.19 秒(网络)

模型在 50 到 81 毫秒内就回答了。而我们在调用它之前花了将近一秒,有时接近两秒。

成本是等待,而不是工作

我们起初怀疑是响应之后的工作,比如计费和日志。并不是。那部分工作本来就在响应发出之后运行。

成本在发往上游之前。一个请求要依次走过一串读取:读取密钥记录,在数据库里预留额度,加载账户上下文,调用两个限流计数器,然后逐个运行路由自己的检查。每次读取都很快,加起来却是 1 到 2 秒的等待。

这些读取大多互不依赖。所以修复就是一条规则:互不依赖的两次读取,不能互相等待。

之后

请求已解析

密钥

账户

限流

路由检查

发往上游

之前

密钥

账户

限流

路由检查,逐个进行

发往上游

之前,每个独立读取都等待上一个。之后,独立读取一起发出,请求只为最慢的那个等待一次。

下面是我们找到并去掉的几种模式:

  • anyrouter/auto 这类路由模型逐个展开候选分支。一个请求最多可能连续跑 8 条完整的路由流水线,另外还有 8 条作为备用。各分支相互独立,所以现在以每批 8 个并行展开,再按原来的顺序合并。
  • 已禁用模型的集合在每次调用时都重新读取。路由每个分支问一次,所以一个请求最多会读 16 次。现在它按 isolate 缓存,写入时刷新缓存。
  • 一个本应只在单个请求内存在的缓存,被每个分支重新创建,于是每个分支都重新查询数据库。现在请求把同一个缓存传给所有分支。
  • 上游健康检查是顺序执行的,隔离标记每个候选最多要读 4 次。现在它们作为一批一起发出。
  • 两个限流计数器、服务商凭据以及账户身份检查,现在也都一起发出,而不是一个接一个。

这些改动都不会改变路由结果。测试会检查候选顺序和每条跳过记录保持不变。

数字的变化

我们使用冒烟测试密钥调用 anyrouter/free,从一台固定的测试机器测量 gw。预热表示 isolate 的缓存已经填好。空闲之后表示请求在大约 100 秒无流量之后到达,此时这些缓存已过期。

日期代码状态预热时的 gw空闲之后的 gw
10 月 3 日并行读取的改动正在陆续合入364 到 697 毫秒2,254 到 2,306 毫秒
10 月 12 日并行读取已上线,下文的缓存预热之前68 到 85 毫秒(p50)1,269 到 1,652 毫秒

请把它看作趋势,而不是严格的 A/B 测试。两行来自不同的脚本,相隔九天,每行只有少量样本。10 月 3 日时部分并行读取可能已经上线。冒烟测试密钥还会跳过限流,所以真实用户的 gw 会比这些数字略高。

预热时的 gw 从 364 到 697 毫秒降到了 68 到 85 毫秒。空闲之后的路径仍然超过一秒,这是下一个问题。

冷启动的悬崖,以及不得不回滚的修复

空闲之后,每个路由阶段都要等一次已过期的缓存读取,大约 250 毫秒,然后下一个阶段再等一次。阶段拆分显示 gw-auth、gw-ctx 和 gw-prep 各约 250 毫秒,gw-route 为 600 到 940 毫秒。

修复方法是提前预热这些缓存。请求体一解析完,我们就把所有与用户无关的路由读取作为一批发出。它与密钥检查和限流同时进行。之后的检查要么直接拿到已加载的值,要么加入正在进行的读取。

我们在 10 月 12 日合入了这个改动,78 分钟后合入了回滚。部分非流式聊天请求和模型列表 API 卡住了 60 到 120 秒。

原因是一条我们没有考虑到的 Workers 规则。如果请求返回时没有用 waitUntil 登记尚未完成的工作,运行时会取消这些工作,而被取消的存储读取永远不会结束。它的 promise 既不会 resolve,也不会 reject。一个提前失败的请求,例如密钥错误,会留下被取消的预热读取。这些读取与同一 isolate 里后续的请求共享,于是后续每个请求都在等待一个永远不会完成的 promise。

我们在本地 Workers 运行时上复现了问题,然后带着两处修复重新合入。预热现在在 waitUntil 下运行,发起它的请求会一直存活到它完成。加入他人读取的请求现在最多等待 1.5 秒,之后放弃共享的槽位,自己去读。在旧代码上卡住的测试,在新代码上都通过了。

自那次改动以来,我们还没有重新测量空闲之后的路径,所以这里不给出数字。

不需要唤醒 Worker 的页面

第二项改动在网站上。模型目录、每个模型页面以及它们的 Markdown 数据表,不会因访问者而变化。唯一动态的部分,例如当前延迟或你自己的服务商密钥,在页面显示之后才从 API 加载。

所以我们不再为每个请求渲染这些页面。部署中的一个步骤把它们构建成静态文件,Cloudflare 直接在边缘提供,完全不运行我们的 Worker。文档站在自己的构建里做同样的事。它写出 210 个页面,据我们估计只给一次文档部署增加大约 10 秒。

10 月 26 日,我们用 curl 从一台机器测量首字节时间。每个页面先发一次请求,再连续发 12 次,取这 12 次的中位数。基线是 Cloudflare 的一个不运行任何 Worker 的端点,所以它只反映网络。

页面预热时首字节中位数
网络基线,无 Worker165 毫秒
文档首页,静态198 毫秒
/models,静态199 毫秒
一个模型页面,静态221 毫秒
登录页,Worker 每次请求都渲染211 毫秒
其他 Worker 上每次请求都渲染的两个页面292 和 341 毫秒

静态页面不运行任何 Worker 代码,所以每次访问不消耗 Worker 时间。不过从这台机器测量,它们的首字节时间与 Worker 渲染的页面很接近。登录页没有缓存,每次请求都渲染,测得 211 毫秒。两个较慢的页面运行在其他 Worker 上,这份报告没有查明它们为什么更慢。所以我们能证明的静态页面收益是每次访问的 Worker 工作量,而不是首字节时间的大幅下降。

下一步

实验室数字和服务端 timing 无法说明页面在手机上的真实感受。从 10 月 26 日起,我们开始记录真实用户的 timing:按页面类型分组的 Core Web Vitals,以及 playground 中从发送到第一个 token 的时间。我们保留 20% 的会话,把每个 URL 归并为页面分组,不记录提示词、响应或密钥。开启零数据保留的用户不会发送任何数据。我们需要积累满一周的数据再公布数字。

下面这些改动正在评审中,尚未上线。它们的数字来自实验室测试,而不是生产环境。

  • 把上游隔离标记读成每个后端一条记录。在冷 isolate 上,测试工具统计到一个有多个上游的模型在改动前需要 59 次存储读取,改动后为 26 次。
  • 拆分网站的 JavaScript,让一个页面不再下载其他页面的代码。在限速的移动端测试中,首页的阻塞时间从 1,017 毫秒降到 691 毫秒,JavaScript 从压缩后 1,411 KB 降到 1,192 KB。
  • 像模型页面一样,把用例页面作为静态文件提供。

如果通过 AnyRouter 的调用感觉慢,请查看 Server-Timing 头,把 gw 和 upstream 的值发给我们。它们能让我们立刻知道该查哪一边。

两分钟发第一笔请求

用自己的 key 免费开始,或充值按 token 计费。Go 每月 $4 额度 — $2/月,或捐一把 provider key 免费开通。

开始使用