OmniRoute

自托管 OmniRoute 作为我的 LLM 网关

November 1, 2026 · 3 分钟阅读

我 Mac mini 上的每个智能体 —— Claude Code、OpenCode、以及这周正在评估的随便什么工具 —— 都需要访问一堆五花八门的 LLM provider,有国内的,也有国外的。与其给每个工具各发一套密钥和 base URL,我跑了一个 OmniRoute 作为统一网关:一个入口、一处密钥、provider 故障转移,以及真正能读的请求日志。它以单个 Docker 容器(diegosouzapw/omniroute:latest)运行在 20128 端口,通过 OrbStack,宿主机上同时跑着 TUN 模式的 Clash Verge 和 Tailscale。

上面最后一句话,正是我全部三个问题的根源。下面是配置,以及一路踩过的坑。

compose 文件

整个服务一个文件就装下了,注释里写着每个开关的原因:

services:
  omniroute:
    image: diegosouzapw/omniroute:latest
    container_name: omniroute
    restart: unless-stopped
    stop_grace_period: 40s
    ports:
      - "20128:20128"
    # DNS:宿主机解析路径(Tailscale MagicDNS 100.100.100.100)对国内
    # provider API 返回陈旧/被污染的 A 记录(open.bigmodel.cn ->
    # 8.208.31.28,api.minimaxi.com -> 47.79.117.67)。这些 IP 是死
    # 端点:TCP 能连通,然后 TLS 握手被 RST -> 502。
    # 让容器指向公共解析器后,Clash TUN(dns-hijack: any:53)会返回
    # fake-IP(198.18.0.0/16),并由 Clash 在连接时把 fake-IP 正确
    # 映射到真实 IP。
    dns:
      - 223.5.5.5
      - 119.29.29.29
    environment:
      HOSTNAME: 0.0.0.0
      PORT: 20128
      NEXT_PUBLIC_BASE_URL: http://192.168.1.11:20128
      # 聊天准入控制(见 chatBodyAdmission.ts):
      # - MAX_HEAVY_IN_FLIGHT:进程级允许并发的重量级请求数。
      #   默认 1 会把智能体扇出(OpenCode/Claude Code)串行化;4 让突发进入队列。
      # - ADMISSION_QUEUE_MS:重量级请求在拿到可重试的 503 之前等待容量的时长。
      #   默认 2000(立即 503 会烧掉客户端的重试预算)。
      OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT: 4
      OMNIROUTE_CHAT_ADMISSION_QUEUE_MS: 20000
      # provider 限流器(open-sse/services/rateLimitManager.ts)。
      # MAX_WAIT_MS 身兼两职:它被传给 Bottleneck 作为 `expiration`,
      # 即请求离开队列之后允许运行的截止时限。默认 15000ms(15 秒)
      # 会把长推理模型(glm-5.3、MiniMax-M3、k3)杀死在生成中途
      # -> 504 RATE_LIMIT_EXECUTION_TIMEOUT;它不是上游超时,也不约束排队等待。
      # 120000 给扩展思考模型留出产出首个 token 的空间。
      RATE_LIMIT_MAX_WAIT_MS: 120000
      # 由于 MAX_WAIT_MS 同时也是排队等待的额度,调大它要配一个深度上限,
      # 让突发快速拒绝(RATE_LIMIT_QUEUE_FULL),而不是让队列无界增长。
      # 默认 0 = 无界。
      RATE_LIMIT_MAX_QUEUE_DEPTH: 100
    volumes:
      - omniroute-data:/app/data

volumes:
  omniroute-data:

没什么花哨的:一个容器、一个命名卷、四个开关。每个开关的存在,都是因为默认值咬过我一口。

不是容器背锅的 502

第一个症状:随机 502,但只出现在国内 provider 的 API 上 —— open.bigmodel.cn 和 api.minimaxi.com。发往非国内 provider 的请求一切正常,这味道已经像网络问题,而不是应用问题。

我的第一反应是从容器里 nc -z open.bigmodel.cn 443。成功了。结案,网络没问题,肯定是 OmniRoute 的 bug —— 然而并不是。nc -z 只能证明 TCP 握手完成,仅此而已。等我把各层拆开看,画面就变了:TCP 能连上,然后 TLS 握手被 RST。一个还会应答 SYN 的死端点,正是 DNS 记录被污染的典型长相。

检查解析路径后,剩下的就都说得通了。宿主机经由 Tailscale MagicDNS(100.100.100.100)解析,对这两个域名返回的是陈旧记录 —— open.bigmodel.cn -> 8.208.31.28、api.minimaxi.com -> 47.79.117.67 —— 这些 IP 接受 TCP、死在 TLS。真正救了我的排查动作,是把宿主机解析器的答案与 DoH 真相对比,因为 443 端口不会被 Clash 劫持:

curl -s -H 'accept: application/dns-json' \
  "https://223.5.5.5/resolve?name=open.bigmodel.cn&type=A"

DoH 返回了不同的、可用的 IP。所以修复根本不在 OmniRoute —— 而在于决定容器向谁问 DNS。把 dns: 指向公共解析器(阿里 DNS 223.5.5.5、DNSPod 119.29.29.29)看起来像是绕开了 Clash,实际恰恰相反;要理解原因,得知道 TUN 的 fake-IP 模式是怎么工作的:

┌──────────────┐   DNS: open.bigmodel.cn?    ┌─────────────────────────┐
│  omniroute   │ ──────────────────────────▶ │ Clash TUN(宿主机)     │
│  (容器)      │   发往 223.5.5.5:53          │ dns-hijack: any:53      │
└──────┬───────┘ ◀────────────────────────── └───────────┬─────────────┘
       │              fake-IP 198.18.x.x                 │
       │                                                 │ 连接时把 fake-IP
       │ 向 198.18.x.x 发起 HTTPS                        │ 映射回真实 IP
       ▼                                                 ▼
┌──────────────────────────────────────────────────────────────────┐
│ Clash 路由:规则匹配(国内 provider 走直连)-> 真实 API            │
└──────────────────────────────────────────────────────────────────┘

dns-hijack: any:53 意味着 Clash 会劫持发往任意解析器的查询,UDP 和 TCP 都拦,并用 198.18.0.0/16 段里的 fake-IP 作答。当容器随后连接这个 fake-IP 时,连接落在 TUN 网卡上,Clash 把 fake-IP 映射回真实目的地,再按规则路由。容器正是因为 DNS 被劫持,才正确搭上了 Clash 的路由。而 MagicDNS 那条路是真实地应答了查询 —— 用的是被污染的记录 —— Clash 从头到尾没有发言权。

改完之后,502 消失了。不是减少 —— 是消失。

面向智能体扇出的准入控制

流量真正跑通之后的第二个问题:智能体们老是互相踩脚。Claude Code 和 OpenCode 都会扇出 —— 子智能体、并行工具调用 —— 而 OmniRoute 的聊天准入控制(在 chatBodyAdmission.ts 里)有两个为"一个人敲键盘"调校的默认值:

把它们调大到 4 和 20000,姿态就从"快速拒绝"变成了"让突发排队等容量"。扇出真正并行跑起来了,而那些原本要付出一次重试往返的突发,现在只是排队等轮到自己。

Bottleneck expiration 的坑

第三个问题,也是最微妙的一个:长推理模型 —— glm-5.3、MiniMax-M3、kimi k3 —— 总在生成中途死掉,报 504 RATE_LIMIT_EXECUTION_TIMEOUT。稳定地死在第 15 秒。上游没有任何超时;provider 们本来很乐意继续流式输出。

罪魁祸首是 rateLimitManager.ts 里的 RATE_LIMIT_MAX_WAIT_MS,它身兼两职。它被传给 Bottleneck 作为 expiration —— 请求离开队列之后允许运行的截止时限,既不是允许排队多久,也不是上游超时。默认值 15000ms。一个花 20 秒才产出首个 token 的扩展思考模型并不慢;那是常态,而默认值会在它思考到一半时处决它。

把它调到 120000,这些模型就有了喘息空间。但因为同一个值还兼任排队等待的额度,只调大它会让请求洪水各自堆上两分钟。所以它要搭配 RATE_LIMIT_MAX_QUEUE_DEPTH: 100 一起发布(默认 0,无界):真正的过载现在会以 RATE_LIMIT_QUEUE_FULL 快速拒绝,而不是让队列长到所有请求反正都要超时。

最终配置速查

开关默认值我的值默认值会付出的代价
dns宿主机解析器(MagicDNS)223.5.5.5、119.29.29.29国内 provider 的被污染 A 记录 -> TLS RST -> 随机 502
OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT14智能体扇出被进程级悄悄串行化
OMNIROUTE_CHAT_ADMISSION_QUEUE_MS200020000突发被快速拒绝为可重试的 503,烧掉客户端重试预算
RATE_LIMIT_MAX_WAIT_MS15000120000长推理模型被杀死在生成中途(504 RATE_LIMIT_EXECUTION_TIMEOUT);需搭配 RATE_LIMIT_MAX_QUEUE_DEPTH: 100(默认 0 = 无界),让过载快速拒绝

验证它是否工作

我刻意没有用一次性的 curl 来验证这些修复。当故障模式是间歇性的时候,一次成功的请求什么也证明不了。验证标准是 OmniRoute 自己的健康数据:每个 provider 的 test_status 全绿,请求日志在持续的多智能体负载下保持干净 —— 是真实的 Claude Code 和 OpenCode 会话在猛轰它,而不是合成 ping 的循环。从那以后它一直保持这个状态,restart: unless-stopped 安静地干着它唯一的那份工作。

← 返回所有文章