早上八点,本该准时推送的早报没有出现。九点之后陆续发了几条消息,界面上只返回一句很含糊的「请求未处理」。
第一反应是模型服务出了问题,但去查中转机器的状态,容器一直是健康的,负载 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.4s | 4.6s |
| 输出速度中位 | 79.7 tok/s | 94.5 tok/s |
| 首字延迟 | — | 1.04s |
顺带纠正一个误判:这几天总觉得「模型变慢了」,但把日志里的延迟和吞吐按天聚合出来,趋势是变快而不是变慢。体感上的慢,来自那天上午一个半小时完全没有响应,以及排查过程中一条回答要跑十几次工具调用。
留了两道兜底
一、开机自检脚本。 跑一次就检查 no_proxy 是否覆盖全部可直连目标,再真实打一次补全请求,结果写进日志。
二、隧道看门狗,只通知不重启。 每二十分钟探一次隧道,只在「正常转异常」和「异常转正常」两个跳变点发提醒,状态不变就静默。这里明确没有做成自动重启——重启代理会短暂切断全家网络,而且可能弹窗打断正在做的事,这种操作应该留给人决定。
几条教训
能用直连的,就不要让它经过代理。 每一层代理都是一个新的故障点,而且这些故障点往往表现为「超时」而不是「报错」,排查成本很高。
主备链路不要共用同一个故障域。 这次的备用模型有七八个,但全部依赖同一条隧道,等于只有一个。备用链的价值在于「主链出问题时它没事」,共用一个瓶颈就等于没有备用。
监控要看端到端,不能只看节点。 进程活着、端口在听、机器负载正常,这些指标在这次故障中全部是绿的。真正该测的是「一条请求从发出到拿到第一个字节要多久」。
失败要快速暴露,不要长时间重试。 重试八次、耗时二十分钟才放弃,这比失败本身更难受——它让人以为系统还在工作。
