Engineering

本地 GPU、OpenRouter,还是两者兼用:让每个推理角色独立路由

五种角色、四种策略、一份共享预算——以及一道兼容性关卡,它拒绝让云端嵌入模型悄无声息地污染本地 GPU 构建出来的索引。

Alper Üzmezler· Sep 18, 2026 · 13 分钟阅读

“加一个云端回退”听上去像是一个配置开关。但对检索系统而言,它更像是一个披着配置开关外衣的数据完整性问题。

文本生成是无状态的——某个提供方慢了,就换一个,事后谁也看不出来。嵌入则不然。嵌入会写入一张你将要查询数月的表,而只有当为查询做嵌入的编码器与为各行做嵌入的编码器彼此一致时,这次查询才有意义。

Fantom MCP Server 的云端集成,大部分就是为了让这件事变得安全而必需的那套机制。

五种角色,分别路由

这里没有一个统一的“使用云端”开关,因为这五种推理角色的需求确实各不相同。每一种都按自己的策略进行路由:

本地 GPU sidecar 主机 真实显存 OpenRouter 虚拟容器 无需 GPU code-embedding aggregate embedding backup reranker cloud code-assistant cloud rlm local 示例组合 —— 每个角色都独立设置,并可在运行时更改

四种策略:

策略 行为
local 仅使用本地 GPU 和 sidecar 主机。没有任何数据离开网络。
cloud 仅使用 OpenRouter。完全不需要 GPU。
aggregate 两者同时使用,组成一个扇出池,从同一队列中取任务。(默认)
backup 本地优先,云端作为储备。

aggregate 之所以是默认值,是因为它既能用上你已经付过钱的硬件,又能吸收突发流量。

让这一切变得安全的那道关卡

aggregate 模式下,本地与云端产生的向量会在同一次运行中交错落入同一张表。如果这两套服务栈产出的向量不可互换,余弦距离就失去了可比性,检索会悄无声息地退化——而且你无从判断哪些行来自何处。

所以兼容性不是一条警告,而是一个硬性前提:未通过校验的提供方,绝不允许服务哪怕一条文本。

探针文本 → 嵌入两次 → 逐对余弦 参考提供方 2560 维 候选提供方 必须完全一致 0.99 下限 0.95 1.00 0.976 本地 vs 云端 低于下限 —— 拒绝 ≥ 0.99 每一对探针 维度精确 —— 允许写入 查询编码器始终来自构建这些行的同一个池。

这项检查刻意做得毫不含蓄:用两条路径分别嵌入同一批探针文本,对每一组对应向量计算余弦相似度,要求其最小值超过 0.99,并要求返回的维度恰好等于配置的宽度。

有意思的是那个没通过的数字。本地模型与云端模型之间实测的余弦值是 0.976——抽查时看着还挺不错,却稳稳地低于下限,而这正是 cloud 策略存在的理由。这两个编码器不可互换,所以如果你想用云端那个,整个索引就必须由它来构建。这不是权宜之计,这是策略在履行职责。

这也意味着参考方并不总是本地的。在三种由 GPU 参与服务的策略下,参考方必须是本地的,因为被检验的属性恰恰是与表中已有各行的一致性。而在 cloud 策略下,和本地模型比较毫无意义——因为永远不会写入任何本地向量——因此参考方变成另一个云端提供方,关卡的其余部分不变。它从不会被禁用。

把限制说清楚,而不是藏起来

当只存在一个云端提供方时,就没有可对比的同侪,跨提供方一致性也就空洞了:一个提供方不会与自己不一致,而且所有行都由它独自写入。这时关卡只检查维度和上游固定项,除此之外什么都不查。

这确实是覆盖面的实质性缩减。一个被固定的上游,仍可能随时间在同一个固定标识背后更换其服务栈——比如某个主机为同一个模型 slug 重新部署了不同的量化版本——而在单一提供方的云端模式下,没有任何机制能发现它。如果你在跑这种配置,值得知道这一点。

为什么嵌入要固定上游,而重排不用

两个提供同一模型 slug 的 OpenRouter 上游并不能保证产出相同的向量。因此,未固定上游的嵌入路由可能在不同请求之间把一个向量空间劈成两半,这与前面说的污染是同一回事,只是路径更隐蔽。所以每条嵌入路由都必须指明其上游提供方,否则系统会直接失败关闭。

重排是无状态的——它只为候选列表打分,不写入任何内容——所以不需要固定上游。

一份预算,凌驾于所有提供方之上

塑造整条云端路径的陷阱在这里:OpenRouter 按密钥限流,而不是按调用方限流。

每个 sidecar 拿到的都是同一个密钥。三个提供方各自限制八个在途请求,就是二十四个请求在争抢同一份共享预算。按提供方设置上限保护不了这个限额——上限必须设在它们之上。

全局许可池 —— 一个许可 = 一个并发云端请求 上限 429 → 减半 + 抖动 并发 ≈(每秒请求数)×(每请求平均秒数) —— 利特尔法则,随延迟漂移重新推导 什么都读不到时的默认值:4 个许可 · 防打字错误的硬上限:256 本地 GPU 从不触及该池 云端退避不应让硬件闲置 仍在拉取任务 项目之间公平 等待者按项目排队,轮询服务 单个大项目无法饿死其他项目

其中有几个细节值得单独拎出来说。

许可的单位是并发数,而不是每分钟请求数。 探测到的 rate_limit 是一个 RPM 数值,所以要用池自身实测的往返延迟来换算——也就是利特尔法则——这意味着它会随延迟漂移而重新推导,而不是被钉死在启动时的某个数字上。

被采信的 RPM 也会被直接强制执行,以滑动窗口闸门的形式,这样即便延迟估计短暂偏高,也不会超出账户额度。但这第二道闸门只在 RPM 值可信时才会启用。如果一个已充值账户的密钥报告出一个退化的限流值——或者干脆不报告,这才是如今的常态——那它会改为按信用余额来确定容量,并且不启用窗口闸门。在一个残留的“每分钟 1 次”上启用它只会更糟:那会把整个云端扇出压到每分钟一个请求。

退避是自适应的,绝不会立刻重试。 一次 429 会把有效预算减半,并启动带抖动的冷却;持续干净的时间窗口则会每次一个许可地把它逐步拉回上限。

默认值刻意保守。 什么都探测不到时给四个许可,因为假设自己拥有读不到的余量,正是引发 429 风暴和半成品表的方式。硬上限设为 256,因为覆盖框里的一个打字错误不应该把整个账户打满。

本地 GPU 完全豁免。 任务是按提供方拉取的,所以被阻塞的云端提供方只是停止拉取,本地那些照常运行。云端限流拖慢的是云端;它不会让你的硬件闲置。

密钥处理

从 Fantom MCP Server 的视角看,OpenRouter 密钥是只写的。它随一次管理请求到达,直接通过 WebSocket 帧发往将要使用它的 sidecar,从不写入配置、从不记录日志、从不被任何接口返回,也不会在调用结束后继续保留。

被持久化的只有可以安全持久化的内容:哪些角色映射了模型、固定了哪个上游、路由模式,以及“发生过一次推送”这个事实。不带密钥地重新推送是更改模式或模型时的常规做法——sidecar 会把部分推送合并到它已持有的条目上。

默认情况下各角色跑什么

  • 重排器qwen/qwen3-reranker-8b
  • 代码助手poolside/laguna-s-2.1
  • RLM 沙箱deepseek/deepseek-v4-flash,回退模式为仅本地

嵌入模型按部署逐一选择,因为正是这个选择决定了你的索引究竟是什么。代码向量表是一个 2560 维的单一空间;上面所有这些机制,都是为了让它保持如此。

要点

这些都不会让云端变快。它换来的是能说出一些具体的话:索引中的每一行,都是由一个已被证明可与将来为你的查询做嵌入的编码器互换的编码器写入的;没有任何单个项目能吃掉整个账户;以及当云端被限流时,GPU 仍在继续干活。

“加一个云端回退”确实只是一个配置开关。这篇文章里的全部内容,就是在你能安全地拨动那个开关之前,底下必须先成立的东西。


Fantom MCP Server 的源码可在 github.com/Project-SandStar/FantomMcpServer 获取。往期文章:Sidecar 集群与 RLM · 跨语言代码智能