OmniRoute/v1/models目录缓存的 stale-while-revalidate 调度:让过期目录先于后台重建抵达客户端
【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute
本文聚焦 OmniRoute 统一网关
/v1/models(OpenAI 兼容模型列表接口)上的一次关键修复:后台重建调度重新接回 Next.js 的after(),确保"过期目录(stale catalog)先到达客户端,再在响应 flush 之后执行阻塞事件循环的目录重建"。读完本文,你将掌握该接口三层缓存机制(请求合并、短 TTL 记忆化、SWR 过期续期)的实现脉络、after()与setTimeout(0)在响应排序语义上的本质差异,以及仓库中用于验证"先出 stale 再重建"契约的源码与测试证据。
背景:为什么/v1/models的一次重建如此昂贵
OmniRoute 是一个面向 Claude Code、Codex、Cursor、OpenCode、Cline 等客户端的统一网关,其/v1/models承担着向这些客户端暴露模型目录的职责。与一次普通请求不同,这个目录不是静态文件,而是由 catalog.ts 中的buildUnifiedModelsResponseCore现场组装而成:构建器会遍历多个注册表、读取 SQLite 中的连接(connections)、组合(combos)、自定义模型与别名数据,最终序列化成 OpenAIlist models格式的 JSON。
catalogCache.ts 源码注释里保留了该构建成本的实测记录:在生产 VPS 上,构建一个约 1.3 MB、包含 2645 个模型的目录大约耗时49 秒。而在 Next.js 单线程的 App Router 请求处理模型下,这么长的一段"几乎全同步"的构建代码一旦开始执行,就会钉死(pin)事件循环,期间任何其他请求都无法被处理。
如果每一次GET /v1/models都要等 49 秒的重建结束才能返回,接口对客户端而言就完全不可用。这正是本仓库围绕该接口建立多层缓存、并最终依赖after()做后台调度的根本原因。
三层缓存骨架:从 #6408 到 #8728
/v1/models的性能演进在源码注释中留下了一条清晰的脉络,可以概括为三层机制:
- 并发请求合并(request coalescing,#6408):目录构建器串行执行的年代,N 个并发请求会逐个排队,第 N 个请求要等 N × 单次延迟。现在相同 key 的并发请求会被合并到同一个 in-flight Promise 上。
- 短 TTL 响应记忆化(memoization):序列化后的 body 会被缓存一小段时间,在"没有任何写入"的情况下直接重放,避免重复构建。
- Stale-while-revalidate(SWR,#8728):缓存过期后,只要"旧目录"是成功的构建结果、且仍处于过期窗口内,就立即把它返回给当前请求,同时把一次目录重建调度到后台去刷新缓存——调用方不为重建买单。
核心修复(#11574):重建再次经由 Nextafter()调度
本次修复对应的变更说明(changelog.d/fixes/11574-v1-models-after-scheduler.md)只有一句话,但信息量很大:
/v1/modelsschedules its stale-while-revalidate rebuild through Next'safter()again, so a stale catalog reaches the clientbeforethe rebuild blocks the event loop.
拆解下来有三层含义:
- "again"(再次):这不是第一次引入
after()调度,而是把一条被回归破坏的链路重新接回。根据 CHANGELOG.md 与源码注释,路线大致是:#8728 首次提出 SWR 语义;#9199 把"无限期的 SWR 访问器 + 内部setTimeout(0)调度"改成固定的 30 秒窗口(详见下文);随后的一次重构中,getUnifiedModelsResponse()的参数签名里scheduleBackgroundRefresh选项被移除,而路由仍在传递它——于是该对象被静默丢弃,SWR 重建实际退回为裸的setTimeout(0);#11551 恢复注入点,让catalogCache默认经由after()调度;#11574 则是这一语义在路由层的最终落定与收口。 - "after()":指 Next.js 在 App Router 中提供的
after()API——把任务推迟到当前响应已 flush 给客户端之后再执行。 - "before the rebuild blocks the event loop":重建对事件循环的阻塞是绕不开的(构建器在单线程下近乎全同步),因此正确的顺序必须是:先把 stale body 发给客户端,再让重建开始阻塞。
路由层的接线
route.ts 中,GET处理器把调度器显式注入给目录构建入口:
export async function GET(request: Request) { return getUnifiedModelsResponse( request, {}, { scheduleBackgroundRefresh: (task) => after(task), } ); }after从next/server导入。这意味着:当某次请求命中 SWR 分支(缓存过期但在 stale 窗口内)时,重建任务会被交给after(),由 Next.js 保证在响应体真正写完、flush 到客户端之后再启动,而不是在响应头返回时就抢占事件循环。
该路由还额外实现了两个处理器,侧面反映接口的健壮性设计:
OPTIONS:处理 CORS 预检,声明GET, HEAD, OPTIONS。HEAD:显式返回空 200,避免 Next.js 自动派生的 HEAD 把完整 GET body 流式化——对 200+ provider 的按需枚举而言,这曾导致 OpenAI SDK 等以 HEAD 做健康探测的客户端挂起约 6 秒(源码注释引用 issue #6400)。
catalogCache 的默认调度器:after()+ 请求作用域外回退
为了让 SWR 行为在所有调用场景下一致,catalogCache.ts 提供模块级默认调度器defaultBackgroundRefreshScheduler:
export function defaultBackgroundRefreshScheduler(task: () => Promise<unknown>): void { try { after(task); } catch { setTimeout(() => { void task(); }, 0); } }关键设计是try/catch 回退:
- 在 Next.js 请求作用域内,
after(task)把重建挂在响应 flush 之后——这是"先 stale 后阻塞"契约的保证来源,也是 #8728/#11574 反复强调的语义核心。setTimeout(0)只能推迟一个宏任务,并不能等到 flush 完成,因此它"从未兑现过这条保证"(源码注释原话)。 - 在请求作用域之外(CLI/Electron 服务器、单元测试直连模块)调用时,
after()会抛出异常;此时退化为宏任务setTimeout(0)。这些调用方没有正在 flush 的响应,推迟一个宏任务已经足够。
该类型被抽象为注入点,便于测试替换:
export type BackgroundRefreshScheduler = (task: () => Promise<unknown>) => void;生产环境有两处接入:路由层显式传(task) => after(task),缓存模块默认调度器内部也优先走after()——两条路径语义一致。
SWR 的完整判定流程与守护边界
resolveCachedCatalogResponse(catalogCache.ts)是整套逻辑的入口,按如下顺序决策:
- 新鲜命中:缓存未过期(
expiresAt > now),直接重放缓存的 body/status/headers,不触碰构建器。 - Stale-while-revalidate:缓存已过期,但必须同时满足两个条件才允许"先发旧再后台刷"——
- 缓存条目是成功的 200 构建。绝不能用缓存的错误结果充当 stale:那会把一次偶发失败伪装成永久假成功。
- 过期时长在窗口
CATALOG_STALE_WHILE_REVALIDATE_MS = 30_000(30 秒)之内。窗口必须有限——无界窗口会让"反复失败的重建"把远古目录永久钉在客户端面前。getStaleWhileRevalidateMs作为可覆盖项保留,但没有任何生产调用方传入它,固定边界对每个真实请求都生效。 - 命中此分支后调用
startBackgroundRefresh,并把 stale body 立即返回。
- Cold path(冷启动 / 超出 stale 窗口):没有可用的 stale 条目时,请求必须等待一次真实的构建。这里会检查 in-flight 构建是否属于当前 catalog 世代(generation),是则加入合并,否则新起一次构建。
- 构建失败:in-flight 构建 Promise 会reject(而非用 stale 伪成功),错误响应形态交由调用方处理;后台刷新的失败被预先处理,绝不允许产生 unhandledRejection,且失败的重建永远不会覆盖缓存条目。
缓存失效与世代守卫
缓存并非只依赖 TTL。每当 settings / connections / combos / pricing 等发生写入,invalidateDbCache()(见 src/lib/db/readCache.ts)都会推进目录缓存版本modelCatalogCacheVersion。dropCatalogCacheIfStateChanged在每次缓存访问前比对版本,一旦发现版本前进就清空整个记忆化 Map——因此"写入在下次读取时立刻可见",TTL 只管"没有写入"时的重放窗口。
世代(generation)守卫则作用于两个关键点:
- in-flight 合并:只有与当前世代匹配的 in-flight 构建才允许被新请求加入;绑定旧世代的构建是为它最初的发起者存在的,新请求不会去等一份反映"写入前状态"的过期结果。
- storePayload 落盘:构建开始时记录的世代与落盘时的当前世代一致,才允许写入缓存;否则只把结果还给原始调用方,防止用旧状态污染新缓存。
TTL 上限与可配置项
- 模块默认
CATALOG_CACHE_TTL_MS_DEFAULT = 60_000。历史上它曾是 1500 ms——比一次构建(49 秒级)还短,导致间隔超过 1.5 秒的任意两个请求都会错过新鲜窗口掉进 SWR,而当时的 SWR 走setTimeout(0)会钉住事件循环,净效果是"几乎所有请求都要等约 50 秒"。60 秒的上限恰好与设置 schema 允许的配置上限一致。 - 数据库设置中的
modelCatalogCacheTtlMs可覆盖默认值。在 settingsSchemas.ts 中其约束为z.number().int().min(500).max(60000)——默认值永远不会超过运维人员被允许配置的上限。
缓存键的构成与鉴权边界
buildCatalogCacheKey把以下维度拼进 key:URL 上的prefix、是否为 Codex 模型目录客户端(isCodex)、API key 的 HMAC-SHA256 指纹(仅取 16 位十六进制,绝不存储明文密钥)、configuredOnly参数,以及hideAutoCombos/hideNoThinkVariants两个目录形态维度。路由在传给构建器时会把hideAutoCombos与"路由被禁用(autoRoutingEnabled === false)"折叠为同一维度——两者产出的目录完全一致,不需要两份缓存条目。
鉴权判断则刻意留在调用方(见 catalog.ts 的getModelCatalogAuthRejection):它依赖每个请求实时的 dashboard cookie / API key 状态,绝不能进入缓存;所以resolveCachedCatalogResponse把鉴权留给上游,自己只管可缓存的目录 payload。头信息合并则通过真实Headers实例执行(mergeCatalogHeaders),以规避对象展开造成的 Title-Case 与 lower-case 重复头。
测试契约:如何证明"stale 先于重建到达"
仓库对这条响应排序契约的验证值得单独展开,因为"后台刷新在响应 finish 之后才开始"这类时序难以用普通单元测试刻画。
集成级:真实外部 HTTP 客户端观测
tests/integration/v1-models-swr-response-flush-8728.test.ts 中,测试用http.createServer直连resolveCachedCatalogResponse,并用一个独立的子进程外部客户端通过 Unix socket 请求/v1/models,再注入一个模拟真实生产形态的同步构建(4000 个模型反复JSON.stringify,阻塞至少 300 ms)。断言包括:
- 第一次请求拿到的 body 是
"old"(新鲜缓存); - 手动把缓存标记为过期后,第二次请求拿到的依然是
"old",且scheduledCount === 1——说明 stale 分支只调度了一次后台刷新; refreshStartedAt >= responseFinishedAt——重建只能在响应 finish 之后启动;stale.receivedAt < refreshFinishedAt——外部客户端收到 stale body 的时刻严格早于同步重建结束的时刻。
另外同文件还有一个静态契约测试,直接读取 route.ts 与 catalogCache.ts 的源码文本,断言scheduleBackgroundRefresh: (task) => after(task)与defaultBackgroundRefreshScheduler内部确实调用了after(task)——防止后续重构再次让注入点"静默失效"。
单元级:SWR 边界行为
tests/unit/model-catalog-cache-swr-8728.test.ts 则覆盖了判定逻辑本身:
- SWR 窗口是有界常量(
30_000且Number.isFinite); - 新鲜条目直接重放,构建器计数不增长;
- 过期但成功的条目立即返回旧 body,后台刷新完成后下一次读取拿到新 body;
- 超过窗口的条目不得以 stale 形式返回,调用方必须等待重建;
- 缓存的非 200 错误永不被当作 stale 重放;
- 并发冷请求共享同一次构建(
__getCatalogBuilderRunsForTest() === 1); - 一次状态变更(
invalidateDbCache())让下次读取立即重建。
仓库中还围绕该模块沉淀了世代竞争(v1-models-catalog-generation-race.test.ts)、鉴权指纹(catalog-cache-auth-fingerprint.test.ts)与 TTL(v1-models-catalog-ttl.test.ts)等测试文件,可作进一步阅读入口。
服务启动预热:让真实流量避开首次构建
即使有了 SWR,第一次请求仍然要承担完整的冷构建。为此 instrumentation-node.ts 在服务启动阶段就主动调用一次getUnifiedModelsResponse(new Request("http://127.0.0.1/v1/models"))做目录预热,让后续任意客户端、任意 API key 的真实/v1/models流量都命中已填充的顶层响应缓存(该预热同时受益于构建器自身的顶层 Response cache)。这是"构建昂贵"这一前提下,保证网关开箱即用体验的最后一环。
小结:一行的修复,五层的设计
11574-v1-models-after-scheduler.md这行变更记录背后,实际是一条完整的防御链:
- 接口层:目录按需构建且成本高达数十秒(catalog.ts);
- 请求合并 + 短 TTL:并发去重、无写入时重放(#6408 语义,catalogCache.ts);
- SWR:过期 200 在 30 秒窗口内先发 stale、后台续期(#8728 / #9199 语义);
- 调度排序:重建经由 Next
after()推迟到响应 flush 之后,setTimeout(0)仅在请求作用域外兜底(#11551 / #11574,route 层与缓存模块双保险); - 守卫:世代版本使写入即时可见,失败重建永不覆盖缓存,错误响应永不伪装 stale,启动预热让首流量命中缓存。
对任何在 Next.js App Router 中维护"昂贵目录/聚合接口"的团队而言,/v1/models的这条演进路径都是一个可直接借鉴的范本:当后台任务可能长时间阻塞单线程事件循环时,选择正确的调度时机(flush 之后而非微/宏任务之后)与"旧数据先出、新数据后补"的响应策略,往往比单纯的缓存命中率优化更关键。
【免费下载链接】OmniRouteNever stop coding. Free MIT AI gateway: one endpoint, 352 providers (150+ free), 1200+ models Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax. Works with Claude Code, Codex, Cursor, OpenCode, Cline & Copilot. Quota-aware auto-fallback, RTK+Caveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550+ contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考