Featured image of post 一次代理隧道僵死,为什么整条 AI 链路会静默失败

一次代理隧道僵死,为什么整条 AI 链路会静默失败

早报没发、消息不回,日志里全是读超时。复盘一次由代理隧道僵死引发的全链路故障,以及按目标精细分流后的修复方案

   
文章摘要
定时任务没出结果,发消息也没有回复。翻日志发现不是模型挂了,也不是网络断了,而是一个跑了几天的代理客户端把隧道挂在了半死状态,所有请求恰好都排在被它接管的路径上

早上八点,本该准时推送的早报没有出现。九点之后陆续发了几条消息,界面上只返回一句很含糊的「请求未处理」。

第一反应是模型服务出了问题,但去查中转机器的状态,容器一直是健康的,负载 0.11,内存用掉不到 5 GB。所以问题不在模型侧。

现象:只有超时,没有报错

翻开应用日志,从 08:04 开始,每三分钟一次失败,连续八次:

ERROR agent.chat_completion_helpers: Streaming failed before delivery: Request timed out.
ERROR agent.chat_completion_helpers: Streaming failed before delivery: Connection error.

最后定时任务被判死:

RuntimeError: No reply: every provider in the fallback chain kept failing over,
so the turn stopped instead of retrying forever.

有两个细节值得注意。一是失败都在「流开始之前」,说明连接建立了但数据没回来,不是模型返回错误、也不是鉴权失败。二是备用模型链也一起失败了,七八个不同厂商的接口同时不可用,这个概率基本为零。

再看堆栈,关键两行:

httpcore/_sync/http_proxy.py:206 in handle_request
httpcore/_sync/http11.py:177 in _receive_response_headers
httpcore.ReadTimeout: timed out

请求是经由 HTTP 代理发出去的,卡在读响应头。也就是说,流量根本没到模型服务,被代理吞了。

故障链路:可直连的目标被塞进僵死隧道,主备同源

根因:三层叠加

拆开看,这是三件事同时成立才导致的故障。

第一层,全局代理进了服务环境。 桌面环境里有一份 environment.d 配置,把 http_proxy 指向本地代理客户端。systemd 用户服务会继承它,于是本该直连的请求也被送去代理。

第二层,分流规则把这个目标判成了境外流量。 代理客户端的分流是「国内直连、其余走隧道」,而中转机器的 IP 属于海外机房,不在国内地址段里,因此被塞进了一条国际隧道。

第三层,那条隧道当时是坏的。 代理客户端进程还活着,端口也在监听,但隧道出口已经不通。表现是连接能建立、发出去的请求没有响应,一直等到读超时。

三层叠起来的结果就是:主模型走这条隧道,备用链里的境外服务也走这条隧道,主备同时失效。

按目标分流:能直连的绕过代理,必须走的才进隧道

为什么说它是静默失败

这次故障最难受的地方在于没有任何一处报错指向真正的原因。

  • 模型服务本身健康,监控不会告警
  • 本机网络正常,国内站点访问毫秒级
  • 代理客户端进程存活,端口在听
  • 应用层面只看到「超时」,看不出是代理还是服务端

如果只看单点状态,每一项都是绿的。故障藏在路径上,而不在节点上。

更糟的是定时任务的行为:它重试了八次、耗时二十多分钟才放弃,这期间所有入站消息都在排队,用户看到的就是「发了没反应」。

修复:把能直连的都排除出代理

原来的 no_proxy 只排除了回环和内网段。真要修,得先弄清哪些目标根本不需要代理。

于是逐个实测,全部用 --noproxy '*' 强制绕过代理:

目标直连结果结论
中转机器的模型网关401 / 0.12s直连可用
国内某大模型接口401 / 0.17s直连可用
某云厂商的推理服务401 / 0.09s直连可用
某个海外模型聚合站200 且能正常补全直连可用
即时通讯的收消息接口404 / 0.15s直连可用
另一个海外推理服务403 Forbidden必须代理
谷歌的接口直连超时必须代理

401 和 404 都算通——那只是缺凭据或路径不对,连接本身是成功的。判断通不通只看超时和 000。

结果比预期好:七个目标里五个可以直连,其中还包括一个一直以为必须走代理的海外聚合站,实测 1.5 秒返回完整结果。

改法是在两处保持一致:

# environment.d 里管全部用户服务
no_proxy=localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1,<中转机IP>,<国内接口域名>,<聚合站域名>

# 同一个 unit 的 drop-in 里再写一遍,管这个服务的进程
Environment="no_proxy=..."

写完 systemctl --user daemon-reload,再用 set-environment 让新起的服务立刻受益,最后重启一次服务。

修复后的实测

重启后逐项验证,顺带把这几天的性能数据也拉出来对比:

指标故障前修复后
单次调用延迟中位6.4s4.6s
输出速度中位79.7 tok/s94.5 tok/s
首字延迟—1.04s

顺带纠正一个误判:这几天总觉得「模型变慢了」,但把日志里的延迟和吞吐按天聚合出来,趋势是变快而不是变慢。体感上的慢,来自那天上午一个半小时完全没有响应,以及排查过程中一条回答要跑十几次工具调用。

留了两道兜底

一、开机自检脚本。 跑一次就检查 no_proxy 是否覆盖全部可直连目标,再真实打一次补全请求,结果写进日志。

二、隧道看门狗,只通知不重启。 每二十分钟探一次隧道,只在「正常转异常」和「异常转正常」两个跳变点发提醒,状态不变就静默。这里明确没有做成自动重启——重启代理会短暂切断全家网络,而且可能弹窗打断正在做的事,这种操作应该留给人决定。

几条教训

能用直连的,就不要让它经过代理。 每一层代理都是一个新的故障点,而且这些故障点往往表现为「超时」而不是「报错」,排查成本很高。

主备链路不要共用同一个故障域。 这次的备用模型有七八个,但全部依赖同一条隧道,等于只有一个。备用链的价值在于「主链出问题时它没事」,共用一个瓶颈就等于没有备用。

监控要看端到端,不能只看节点。 进程活着、端口在听、机器负载正常,这些指标在这次故障中全部是绿的。真正该测的是「一条请求从发出到拿到第一个字节要多久」。

失败要快速暴露,不要长时间重试。 重试八次、耗时二十分钟才放弃,这比失败本身更难受——它让人以为系统还在工作。

晚来天欲雪,能饮一杯无