一个 API Key 调用 19 个模型:我把家用 Hyper-V 虚拟机改成了私人 AI 网关

起因很简单:想在虚拟机里跑点自己的 AI 项目。
结果从”SSH 都连不上”开始,一路排到网络架构、磁盘分区、公钥权限、NAT 持久性、
OAuth 回调、路由分类器……最后落在一个 model=auto 上。

这篇不是教程,是一份排障账本——记录那些”看起来成功了”的假信号,
以及它们各自骗了我多久。

(全文已对公网 IP、内网地址、域名、账号、密钥、tailnet 名等做脱敏,仅保留技术结构。
文末附自查清单。)

TL;DR

一台跑在 Windows Hyper-V 里的 Ubuntu 26.04 虚拟机,最终变成:

1
2
3
4
5
6
7
8
9
10
11
                 ┌──────────────── Cloudflare Tunnel ────────────────┐
手机/任意网络 ────┤ aigw.example.com → localhost:8080 (AI 网关) │
│ panel.example.com → localhost:8443 (1Panel) │
│ dsh.example.com → localhost:10443 (DeepSeek Harness)
└───────────────────────────────────────────────────┘

家里设备 ── 192.168.x.7:8443 ──┐ │
宿主机 ── 192.168.x.10:8080 ─┼──► Ubuntu VM (2核/4G/76G)
│ ├─ AI 网关:5 个上游账号 / 3 个模型组
│ │ / 智能路由 auto / 1 个个人 key
└──────┴─ 1Panel + Docker + DeepSeek Harness
  • 一个 Base URL + 一个 API Key,背后 19 个模型,按任务复杂度自动分派
  • 路由器零端口转发,公网入口只有 Cloudflare
  • 从”虚拟机彻底失联”到全链路可用,中间踩了 14 个坑,其中 4 个是假成功信号

一、起点:一台连不上网的虚拟机

新装好的 Ubuntu Server 26.04,纯命令行,目标是搭 AI 开发环境。第一天就翻车:

宿主机 Windows 重启一次,SSH 再也连不上虚拟机。控制台里 ip a 长这样:

1
2: eth0:  mtu 1500 state DOWN

我的第一反应是”Hyper-V 的 Default Switch 网段随重启漂移,静态地址失效了”。

这个判断对了一半——网段确实每次宿主重启都换(我机器上历史出现过 5 个不同网段),但它不是主因。因为那个静态地址,从头到尾就没生效过。

二、真凶:配置没错,但网卡”没人管”

1
2
3
networkctl
# IDX LINK TYPE OPERATIONAL SETUP
# 2 eth0 ether routable unmanaged ← 关键在这一列

unmanaged 的意思是:没有任何网络管理器在管这块网卡。翻 netplan 配置:

1
2
3
4
ethernets:
eth0:
match:
macaddress: 00:15:5d:02:10:04 # 配置里匹配这个 MAC

而实际网卡 MAC 是 00:15:5d:02:10:05。连安装器留下的原始备份都是 :04——说明这块虚拟网卡在装完系统后被重建过,Hyper-V 重新分配了动态 MAC。

MAC 匹配不上 → 整份配置对这块网卡完全无效 → 既没有静态地址,也没有 DHCP 兜底。

可复用的判据:ip a 看不到地址时,先看 networkctl 的 SETUP 列,别急着改地址
unmanaged = 配置没匹配上;configuring / failed 才是配置本身有问题。

三、顺手挖出三个静默炸弹

隐患 为什么危险 处理
sshd 状态 active 但 enabled=disabled 下次重启 SSH 直接消失,而且”现在能用”会骗过你 systemctl enable ssh
AutomaticStopAction = Save 宿主机关机时 VM 被冻成休眠,开机带着旧网络配置”复活” ShutDown
AutomaticCheckpointsEnabled = True 每次开机长一个差分盘,越用越占空间 关掉 + 删存量

第二条有个反直觉的细节:AutomaticStopAction 只能在 VM 关机状态下修改。VM 运行中执行 Set-VM 会返回成功,但 Get-VM 读回来纹丝不动——你会以为命令没生效,反复试。

四、网络定型:双网卡分工(以及一个更深的坑)

最终架构:

1
2
3
4
5
6
7
8
宿主机
├─ Default Switch(Hyper-V 自管 NAT,每次开机自动重建)
│ └─ VM eth0:DHCP,只要地址、不要默认路由 → 兜底 SSH 入口
└─ 自建内部交换机 VMIntNet(网段永久不变)
└─ VM eth1:静态 192.168.x.10,只负责入站 → 日常 SSH 入口

出网:eth0 → Default Switch NAT → 互联网
入站:宿主机 → eth1 静态地址(重启不变)

对应 netplan 的关键两行:

1
2
3
4
5
6
eth0:
dhcp4: true
dhcp4-overrides: { use-routes: false, use-dns: false } # 只要地址,不抢路由
eth1:
addresses: [ 192.168.x.10/24 ]
# 故意不写 routes:

那个更深的坑就藏在”故意不写 routes”上。

我最初的版本是给 eth1 配了默认路由、出网走自建内部交换机的 New-NetNat。看起来更”干净”——永久网段、自主可控。然后宿主机重启了一次:

  • portproxy 还在
  • 宿主侧静态 IP 还在
  • 防火墙规则还在
  • New-NetNat 建的 NAT 对象,没了

于是虚拟机一条默认路由都没有(eth0 被我设成不抢路由),出网 100% 断死,DNS 最先死。而当时 Tailscale 显示 logged out,我一度以为是 Tailscale 故障。

教训:内部交换机可以做”永久入站”,但不要拿它做出网主路径。
出网交给 Hyper-V 自己托管的 Default Switch NAT——它每次开机自动重建,不需要你养。

五、VS Code 反复要密码,而命令行免密正常

这个现象非常迷惑:命令行 ssh devbox 秒进,VS Code Remote-SSH 每次都弹密码框。

两个原因叠加。

其一,陈旧 .pub 造成的假指纹。 我把公钥装进虚拟机后仍被拒。查了半天才反应过来:

1
2
ssh-keygen -lf id_ed25519      # 报了个指纹
ssh-keygen -yf id_ed25519 # 反导出的却是另一个公钥

ssh-keygen -l 读私钥时会去拿同名的 .pub 文件,而那个 .pub 是上一把密钥的残留(私钥重新生成过,.pub 没跟着更新)。客户端拿 .pub 里的 blob 去 offer、却用不匹配的私钥签名 → 服务端必然拒绝。修复就是 -y 重新导出覆盖。

其二,私钥文件的 NTFS 权限太松。 Windows 自带的 OpenSSH(VS Code 用的就是它)会检查私钥 ACL,发现 BUILTIN\UsersEveryone 可读,就静默跳过密钥退化成密码认证。而 git-bash 里那个新版 OpenSSH 不做这个检查——所以”我能连、VS Code 不能连”。

1
2
icacls $key /inheritance:r /remove "BUILTIN\Users" "Everyone"
icacls $key /grant:r "${env:USERNAME}:F" /grant "NT AUTHORITY\SYSTEM:F"

顺带一个 shell 坑:PowerShell 里 "$env:USERNAME:F" 会被解析成带作用域的变量而变成空串,必须写 "${env:USERNAME}:F"

六、磁盘”只剩 19G”是个假象

根分区剩 8.5G,正准备停机扩虚拟磁盘,pvs 一看出问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PV         VG        PSize    PFree
/dev/sda3 ubuntu-vg 虚拟机磁盘不够,**先 `pvs` 看 PFree**,别一上来就动虚拟磁盘文件。

## 七、对外暴露:三条路全部撞墙

服务搭起来了,下一个需求是"手机在外面也能访问"。

### 尝试一:Tailscale 直连虚拟机 —— 架构上不可能

手机和虚拟机在同一个 tailnet,虚拟机有 `100.x` 地址,理论上直连就行。结果超时。

**而这里出现了第一个漂亮的假信号**:手机上 `tailscale ping 100.x` **是有 pong 的**。

但虚拟机上抓包,`tcpdump -nni any port 8443` —— **一个 SYN 都没收到**。

原因两层:

1. `tailscale ping` 是 **disco 协议层应答**,由 `tailscaled` 进程直接回,**不经过内核网络栈、不产生 TCP 连接**。ping 通 ≠ 业务数据能传。
2. **方向不对称**。虚拟机藏在 Hyper-V 的 NAT 后面:虚拟机主动出去能通(NAT 记住回程映射),外部主动连入必然被丢;本该退化成 DERP 中继兜底,但国内访问官方 DERP 基本连不上。

更离谱的是:**同一个 WiFi 下的手机和宿主机,tailscale 也打洞失败**,只能绕旧金山中继:

pong from tailscale-termux via DERP(sfo) in 507ms
direct connection not established

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

### 尝试二:用 portproxy 桥宿主的 Tailscale IP —— 不接管 wintun

思路很漂亮:宿主机自己是正常的 tailscale 节点,又有直达虚拟机内网的路由,让它替虚拟机收连接。

**然后我犯了第二个假信号错误**:在宿主机上 `curl http://:8443` 返回 200,我当场宣布"桥通了"。

错。**本机访问自己的 IP 走回环,完全不经过入站防火墙和真实收包路径。** 换手机测,照样超时。最终确认 `netsh portproxy` 不接管 Tailscale 那张 wintun 网卡。

> 铁律:**验证"外部能否连入",必须换一台机器测。**

### 尝试三:公网域名 + 路由器端口转发 —— 两个假象叠加

域名在 Cloudflare 做灰云直指家宽公网 IP,路由器加端口转发。逻辑成立,实测超时。

**假象一:NAT 回环。** 在家里 WiFi 上访问自己的公网 IP,绝大多数家用路由器不支持回环,必然超时。判别方法:宿主机打自己公网 IP 超时、打自己局域网 IP 却是 200。所以**测公网链路必须关 WiFi 用移动数据**,否则你会把"回环不支持"误判成"公网不通"。

**假象二:规则填错。** 最后发现路由器规则里"局域网主机"字段填成了 `192.168.x.7:8443`(这个字段只能填纯 IP),而"局域网主机端口"填了外部端口 45432(应该填服务真实端口 8443)。也就是说——**我一度得出的"运营商封端口"结论,从头到尾没被真正验证过。**

## 八、终局:Cloudflare Tunnel,以及一张错误码阶梯

隧道是**虚拟机主动向外连 Cloudflare 边缘**,所以三个死结同时解开:不需要入站端口(不怕封)、不需要打洞(不受 NAT 方向限制)、不需要公网 IP。

```bash
# 虚拟机侧:token 从 Cloudflare Zero Trust → Tunnels → Connector 里复制
sudo tee /etc/cloudflared/cloudflared.env '
sudo systemctl enable --now cloudflared

配置过程不是一次到位,三个错误码恰好对应三个不同层次,值得存成查表:

现象 真实含义 我的实际问题
522 + 约 20 秒超时 流量没进隧道 DNS 还是灰云 A 记录,指向了路由器 IP
522 + 已解析到 CF 边缘 IP 进了 CF,但源站不可达 把 A 记录直接点成橙云;隧道要求 CNAME → .cfargotunnel.com
530 / 502(刚重启完) 假故障 cloudflared 重启后 QUIC 拨号有约 10 秒空窗,等日志出现 4 条 Registered tunnel connection 再测
502 + 1.4 秒返回 进了隧道,回源失败 Service URL 写成 https://localhost:8443,而面板是明文 HTTP

最后一条在日志里一目了然:

1
2
ERR Unable to reach the origin service:
tls: first record does not look like a TLS handshake originService=https://localhost:8443

判据:522 慢 → 查 DNS 是不是橙云 CNAME;502 快 → 查 cloudflared 日志的 originService。

配好之后把路由器所有端口转发关掉——公网入口只剩 Cloudflare 一条,被扫描爆破的面彻底收口。

九、AI 网关:把散落的 API Key 收成一个

基础设施稳了,回到真正目的。用 1Panel 装了 AI 网关,把各家模型聚合成一个入口。

账号池(5 个上游,全部健康):

账号 类型 内容
Agnes 文本 ×4 免费 beta 档 + 一个收费 pro 档
阿里云 Coding Plan 文本 ×9 coder 系列 + max + 多家第三方
硅基流动 文本 ×5 4B/8B 小模型 + R1 + GLM + OCR
Agnes 文生图 ×1 图像模型
硅基流动 向量 ×1 bge-m3,给智能路由用

模型组(关键认知:组内顺序是”故障转移链”,第一个模型吃 100% 流量,后面的只在它挂了才轮到):

1
2
3
4
light   : agnes-2.0-flash → qwen3-coder-next → agnes-2.5-flash → Qwen3.5-4B
complex : agnes-3.0-flash → qwen3-coder-plus → qwen3-max → qwen3.7-plus
→ kimi-k2.5 → DeepSeek-R1 → GLM-4-9B → agnes-2.5-pro-alpha
image : agnes-image-2.5-flash

智能路由:虚拟模型名 auto,阈值 0.80。这里有个必须踩一次才知道的坑——智能路由是基于”首条消息的向量相似度”分类的,不是内置复杂度判断,而且它依赖向量服务;更关键的是,种子样本的向量索引默认没建

1
188 条样本,vectorDim = 0   ← 索引没算,分类器等于空转

不触发一次全量向量计算,所有请求都会”无法可靠判定 → 按简单处理”。索引建完,分类立刻正常。

最终形态:一个 Base URL、一个 key,model=auto 走天下,需要特定时点名。

1
2
3
from openai import OpenAI
client = OpenAI(base_url="https://aigw.example.com/v1", api_key="")
client.chat.completions.create(model="auto", messages=[...])

十、实测数据把我的直觉全推翻了

我原本的设计前提是:”agnes 免费又快速,应该当第一优先级;硅基流动慢,放最后。”

于是给 6 个候选模型各跑了 3 次实测:

模型 延迟 成功率
qwen3-coder-next 1.2 / 1.5 / 2.0s 3/3
qwen3-coder-plus 1.9 / 2.2 / 3.0s 3/3
agnes-3.0-flash 2.2 / 5.1 / 35s 超时 2/3
agnes-2.0-flash 28s / 失败 / 失败 1/3
硅基 Qwen3.5-4B 41.6 / 45s 慢但成功
硅基 Qwen3-8B 18.6 / 45s 慢但成功

三个结论,每个都和直觉相反:

  1. “免费又快”不成立。 agnes 是免费 beta,代价是限流 + 长尾(35 秒超时、连续失败)。放链首会让所有 auto 请求先撞墙再降级,实测 auto 因此出现 40 秒延迟。
  2. 同账号内的多个模型互为备份毫无意义。 agnes 四个模型共用一个账号,账号级 cooldown 一来四个一起倒。真正的容灾必须跨供应商。
  3. 已经付费的 Coding Plan 反而是最好的主力。 1.2–3 秒、6/6 成功,而且额度是已经花出去的沉没成本。

最终我没有把 coder 提到首位(那会让额度消耗过度集中),而是选了折中方案:agnes 保持首位省额度,但把它的账号并发压到 1,并让 qwen3-coder-next 紧跟第二位——这样撞墙时 1~2 秒就降级到快模型,而不是掉进 40 秒的坑。

还有一个”第四个假信号”:我一度断言”cookie 的 SameSite=Strict 导致 OAuth 回调必挂”。
后来实测发现 state cookie 其实是 Lax,Strict 只在 csrf 上,真正的疑点是它只有 5 分钟有效期。
在没有对照实验之前,不要把推论当结论写进方案。

十一、接入端:两个小插曲

WorkBuddy 连不上。 报 404,且错误里目标显示为 https://aigw.example.com(没有 /v1)。真正原因有两层:Base URL 必须带 /v1;而且一旦勾选”工具调用/推理”,客户端会从 Chat Completions 切到 Responses API,而所有上游账号都只声明了 openaiChatCompletions 协议,Responses 路由无人可服务 → 404。所以那些开关就是不能勾,除非给账号补上 Responses 协议。

DeepSeek Harness。 1Panel 应用商店装的 deepseek-harness,Caddy 提供 HTTPS(自签本地 CA),配好模型后端指向网关的 auto 之后直接跑通:多轮 agent 任务、142 tok/s、77K token 上下文、缓存命中 19%。至此从”上游 API Key”到”能干活的应用”整条链路闭环。

十二、可复用的踩坑清单

症状 解法
netplan match.macaddress 与实际 MAC 不符 接口 unmanaged,连 UP 都没有 先看 networkctl 的 SETUP 列,再用真实 MAC 重写
AutomaticStopAction=Save 宿主重启后 VM 带旧网络配置复活 ShutDown,且只能在 VM 关机时改
New-NetNat 不跨宿主重启 出网全断、DNS 先死、Tailscale 显示 logged out 出网走 Default Switch 自管 NAT;内部交换机网卡不配默认路由
Ubuntu 装机只切 VG 的 64% df 显示根分区很小 pvs 看 PFree,lvextend + resize2fs 在线扩
.pub 陈旧而私钥已重建 公钥装上仍被拒 ssh-keygen -l 会读 .pub 报假指纹,用 -yf 反导
私钥 NTFS ACL 含 Users/Everyone VS Code 要密码但命令行免密 icacls /inheritance:r + 只留本人和 SYSTEM
本机 curl 自己的 IP 得 200 误判”外部也能连入” 必须换一台机器测
tailscale ping 有 pong 误判”隧道能传业务数据” 它是 disco 层应答,要抓包看 SYN
在家里 WiFi 测自己公网 IP 超时,误判公网不通 家宽多不支持 NAT 回环,用移动数据测
路由器”局域网主机”字段填了 IP:端口 端口转发全不通 主机字段只填 IP,内网端口填服务真实端口
Tunnel 的 Service URL 写 https:// 502,TLS handshake 失败 明文源站填 http://,且域名须是橙云 CNAME
智能路由种子样本 vectorDim=0 所有请求都判成”简单” 触发一次全量向量索引重建
Hyper-V 文本控制台手敲命令 字符被吞,命令变形 别在控制台配系统,先搞出 IP 走 SSH
客户端勾了工具调用/推理 404 它会切 Responses API,而上游账号没声明该协议

十三、一句话总结

这一路最贵的不是配置,而是那些”看起来成功了”的瞬间
有 pong 的 tailscale ping、返回 200 的本机自测、”所有设备都超时”(其实只是路由器不支持回环)、
“免费所以该优先”(其实限流长尾最致命)。

排障的真正门槛不是会配,而是知道每一个”成功”信号证明了什么、没证明什么
凡是只验证了一半的结论,都要给它留一个被推翻的位置。


配图建议:① 双网卡分工拓扑(出网走 Default Switch NAT / 入站走内部交换机静态 IP)
networkctl 显示 unmanaged 的截图 ③ 522 → 530 → 502 → 200 四次响应对照
④ 六个模型延迟实测柱状图 ⑤ 最终访问矩阵 + WorkBuddy / Harness 实跑截图

脱敏说明(发文前自查)

正文已做如下处理,发布前请再自查一遍:

  • 公网出口 IP → 未出现,仅表述为”家宽公网 IP”
  • 内网地址 192.168.x.7(宿主)、192.168.x.10(虚拟机)→ 已用 x 占位
  • Cloudflare 域名与隧道主机名 → 统一改为 aigw.example.com / panel.example.com / dsh.example.com,真实子域名与主域名不出现
  • Tailscale IP 与 tailnet 名 → 100.x.x.x / 不出现;peer 名改为通用描述
  • 所有账号密码、API Key、隧道 token → 一律 /,正文无任何真实凭据
  • 1Panel 面板安全入口码 → 表述为”入口码”
  • 飞书 / 钉钉的 Tenant ID、App ID → 未出现(该功能与主线无关,正文只保留结论)
  • 网卡 MAC → 保留 00:15:5d:02:10:04/05/07,这是 Hyper-V 本机动态分配值,不具公网可定位性;若介意可改为 00:15:5d:02:10:0x
  • 模型名与供应商 → 保留真实名称(属公开信息,且是本文技术价值所在)

如需保留真实域名作为示例,把 example.com 换回你自己的域名即可;
其余占位符已覆盖所有可直接定位到人的标识符。