十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OmniRoute `/v1/models` 目录缓存的 stale-while-revalidate 调度:让过期目录先于后台重建抵达客户端

OmniRoute `/v1/models` 目录缓存的 stale-while-revalidate 调度:让过期目录先于后台重建抵达客户端 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, RTKCaveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550 contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute本文聚焦 OmniRoute 统一网关/v1/modelsOpenAI 兼容模型列表接口上的一次关键修复后台重建调度重新接回 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-revalidateSWR#8728缓存过期后只要旧目录是成功的构建结果、且仍处于过期窗口内就立即把它返回给当前请求同时把一次目录重建调度到后台去刷新缓存——调用方不为重建买单。核心修复#11574重建再次经由 Nextafter()调度本次修复对应的变更说明changelog.d/fixes/11574-v1-models-after-scheduler.md只有一句话但信息量很大/v1/modelsschedules its stale-while-revalidate rebuild through Nextsafter()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 提供模块级默认调度器defaultBackgroundRefreshSchedulerexport function defaultBackgroundRefreshScheduler(task: () Promiseunknown): 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: () Promiseunknown) void;生产环境有两处接入路由层显式传(task) after(task)缓存模块默认调度器内部也优先走after()——两条路径语义一致。SWR 的完整判定流程与守护边界resolveCachedCatalogResponsecatalogCache.ts是整套逻辑的入口按如下顺序决策新鲜命中缓存未过期expiresAt now直接重放缓存的 body/status/headers不触碰构建器。Stale-while-revalidate缓存已过期但必须同时满足两个条件才允许先发旧再后台刷——缓存条目是成功的 200 构建。绝不能用缓存的错误结果充当 stale那会把一次偶发失败伪装成永久假成功。过期时长在窗口CATALOG_STALE_WHILE_REVALIDATE_MS 30_00030 秒之内。窗口必须有限——无界窗口会让反复失败的重建把远古目录永久钉在客户端面前。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把以下维度拼进 keyURL 上的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与 TTLv1-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.tsSWR过期 200 在 30 秒窗口内先发 stale、后台续期#8728 / #9199 语义调度排序重建经由 Nextafter()推迟到响应 flush 之后setTimeout(0)仅在请求作用域外兜底#11551 / #11574route 层与缓存模块双保险守卫世代版本使写入即时可见失败重建永不覆盖缓存错误响应永不伪装 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, RTKCaveman compression saves 15-95% tokens, MCP/A2A, Desktop/PWA. Built by 550 contributors项目地址: https://gitcode.com/GitHub_Trending/om/OmniRoute创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表