我 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 里)有两个为"一个人敲键盘"调校的默认值:
OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1—— 进程级只允许一个重量级请求在飞。我那群"并发"的智能体,其实是在彼此身后悄悄排队。OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=2000—— 排队请求在拿到可重试的 503 之前等待容量的时长。两秒听起来挺宽裕,直到突发流量到来;立刻返回的 503 会把客户端的重试预算,烧在本应只是稍等片刻的场景上。
把它们调大到 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_FLIGHT | 1 | 4 | 智能体扇出被进程级悄悄串行化 |
OMNIROUTE_CHAT_ADMISSION_QUEUE_MS | 2000 | 20000 | 突发被快速拒绝为可重试的 503,烧掉客户端重试预算 |
RATE_LIMIT_MAX_WAIT_MS | 15000 | 120000 | 长推理模型被杀死在生成中途(504 RATE_LIMIT_EXECUTION_TIMEOUT);需搭配 RATE_LIMIT_MAX_QUEUE_DEPTH: 100(默认 0 = 无界),让过载快速拒绝 |
验证它是否工作
我刻意没有用一次性的 curl 来验证这些修复。当故障模式是间歇性的时候,一次成功的请求什么也证明不了。验证标准是 OmniRoute 自己的健康数据:每个 provider 的 test_status 全绿,请求日志在持续的多智能体负载下保持干净 —— 是真实的 Claude Code 和 OpenCode 会话在猛轰它,而不是合成 ping 的循环。从那以后它一直保持这个状态,restart: unless-stopped 安静地干着它唯一的那份工作。