macOS LaunchAgent 里的 Python 程序连不上外网?一文搞懂 httpx 代理陷阱的完整排查与修复
先说结论:
macOS 的 LaunchAgent 环境下,Python httpx 库会自动读取系统代理设置(
scutil --proxy),但 LaunchAgent 进程可能根本无法访问该代理服务器——于是请求静默失败,报错All connection attempts failed或No route to host。解决方案只需要一行:在 LaunchAgent 的 plist 文件中添加
NO_PROXY=*。这篇文章记录了我从发现 AI Agent 企业微信消息不回复、到逐步排查代理问题、最终定位到 macOS 系统代理与 LaunchAgent 网络隔离的完整过程。涉及 Python httpx 源码分析、macOS 代理机制深度解析、以及 LaunchAgent 运行环境的诸多坑点。
图 1:macOS LaunchAgent 中 Python httpx 的代理链路问题全景图。httpx 通过 trust_env=True 自动读取系统代理设置,但 LaunchAgent 进程无法访问代理服务器,导致连接失败。
问题背景:AI Agent 迁移后企业微信不回复
事情是这样的——最近我把家里的 AI Agent 系统从一个框架迁移到了另一个框架。AI Agent 是一个自驱动的智能体系统,可以自动执行定时任务、管理记忆、处理多平台消息。迁移完成后,大部分功能都正常工作:
- 定时任务(Cron Jobs)正常执行,每天自动健康检查、写博客、维护记忆
- Web Dashboard 正常运行,可以在浏览器中查看 Agent 状态
- 钉钉(DingTalk)消息正常收发,通过 Stream Mode 长连接
- 记忆系统和 Skills 都已迁移成功
唯独企业微信(WeCom)出了问题。
每次给企业微信的 AI Agent 发消息,都石沉大海,没有任何回复。查看 Gateway 日志,看到了这条熟悉的报错:
[WecomCallback] Initial token refresh failed for app 'default': All connection attempts failed
这就很奇怪了——同一个网络环境下,钉钉连接正常,为什么企业微信就不行?
第一轮排查:确认问题范围
首先,我需要确认问题到底出在哪里。企业微信的回调模式(Callback Mode)需要做两件事:
- 接收消息:企业微信 POST 加密 XML 到本地 HTTP 服务器(端口 8645)
- 发送回复:通过 Access Token 调用企业微信的
message/sendAPI
接收消息的 HTTP 服务器已经正常启动了:
[WecomCallback] HTTP server listening on 0.0.0.0:8645/wecom/callback
问题出在第二步——获取 Access Token 时就失败了。
企业微信的 Token 获取接口是 https://qyapi.weixin.qq.com/cgi-bin/gettoken,需要通过 HTTP 请求调用。这个请求在 Gateway 启动时就会执行,但每次都失败。
第二轮排查:直接运行 vs LaunchAgent 运行
为了定位问题,我做了一个对比实验。
直接在终端运行 Gateway
export HTTP_PROXY=http://your-proxy:port
export HTTPS_PROXY=http://your-proxy:port
python -m hermes_cli.main gateway run
结果:Token 刷新成功!
[WecomCallback] Token refreshed for app 'default', expires in 7200s
通过 LaunchAgent 运行 Gateway
launchctl load ~/Library/LaunchAgents/ai.hermes.gateway.plist
结果:Token 刷新失败!
[WecomCallback] Initial token refresh failed for app 'default': All connection attempts failed
这就很有意思了——同样的代码、同样的配置、同样的环境变量,直接运行就成功,通过 LaunchAgent 运行就失败。
这个对比实验说明,问题不在于代码本身,也不在于环境变量的值,而在于 LaunchAgent 的运行环境和终端环境有什么不同。
第三轮排查:代理连通性测试
既然报错是 All connection attempts failed,那很可能是网络连接问题。我先测试了代理服务器的连通性:
# 从终端测试代理
curl -x http://your-proxy:port -s -o /dev/null -w '%{http_code}' \
'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=test&corpsecret=test'
结果:200 OK(虽然返回了业务错误码,但说明请求成功到达了企业微信 API)
# 测试不通过代理直接连接
curl -s -o /dev/null -w '%{http_code}' 'https://qyapi.weixin.qq.com'
结果:200 OK
所以:
- 代理服务器可达
- 通过代理可以访问企业微信 API
- 不通过代理也可以直接访问企业微信 API
那为什么 LaunchAgent 里的程序就不行?
第四轮排查:检查 LaunchAgent 的环境变量
我检查了 LaunchAgent 启动的 Gateway 进程的环境变量:
PID=$(pgrep -f 'hermes_cli.main gateway')
ps eww -p $PID | tr ' ' '\n' | grep -i proxy
结果:
NO_PROXY=localhost,127.0.0.1,your-proxy-ip,api.minimaxi.com
HTTPS_PROXY=http://your-proxy:port
HTTP_PROXY=http://your-proxy:port
环境变量设置正确啊!HTTP_PROXY 和 HTTPS_PROXY 都在。
但问题来了——这些环境变量设置后,httpx 库真的会用它们吗?
第五轮排查:用 Python 直接测试 httpx
为了验证 httpx 是否真的使用了代理环境变量,我写了一个简单的测试脚本:
import httpx
import asyncio
import os
async def test():
print(f"HTTP_PROXY: {os.environ.get('HTTP_PROXY')}")
print(f"HTTPS_PROXY: {os.environ.get('HTTPS_PROXY')}")
client = httpx.AsyncClient(timeout=10.0, trust_env=True)
try:
resp = await client.get(
'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
params={'corpid': 'test', 'corpsecret': 'test'}
)
print(f'Status: {resp.status_code}')
print(f'Body: {resp.text[:200]}')
except Exception as e:
print(f'ERROR: {type(e).__name__}: {e}')
finally:
await client.aclose()
asyncio.run(test())
在终端中运行: 成功,返回 200。
在 LaunchAgent 环境中运行: 失败,All connection attempts failed。
这证实了问题确实出在 httpx 的网络请求层面。
关键发现:httpx 的 trust_env=True 不只是读环境变量
这是整个排查过程中最关键的发现。
httpx 的代理检测机制
Python 的 httpx 库在创建客户端时,如果设置了 trust_env=True,代理检测流程如下:
# Python 标准库 urllib.request.getproxies() 的逻辑
def getproxies():
return getproxies_environment() or getproxies_macosx_sysconf()
这意味着:
- 优先读取环境变量:
HTTP_PROXY、HTTPS_PROXY、NO_PROXY等 - 如果环境变量为空,才读取系统代理设置:在 macOS 上通过
_scproxy模块调用SCDynamicStoreCopyProxies(和scutil --proxy读取的是同一份数据)
但这里有个坑:即使 NO_PROXY 只设置了 localhost,127.0.0.1,getproxies_environment() 也会返回非空结果,导致 getproxies_macosx_sysconf() 永远不会被调用。
macOS 的 _scproxy 模块
Python 在 macOS 上有一个特殊的 C 扩展模块 _scproxy,它通过 macOS 的 SystemConfiguration 框架读取系统代理设置。这个模块的实现大致如下:
// CPython 源码 Modules/_scproxy.c
static PyObject*
get_proxies(PyObject *self, PyObject *Py_UNUSED(ignored))
{
// 调用 macOS SystemConfiguration API
CFDictionaryRef proxies = SCDynamicStoreCopyProxies(NULL);
// 将代理配置转换为 Python 字典
// ...
}
这个模块读取的数据和 scutil --proxy 命令输出的是同一份——都是 macOS 系统偏好设置中配置的代理服务器。
关键点:NO_PROXY=* 的特殊行为
通过阅读 httpx 源码,我发现了一个关键细节:
# httpx/_utils.py
if hostname == "*":
# If NO_PROXY=* is used, bypass any information
# from HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and always ignore proxies.
return {}
当 NO_PROXY 设置为 * 时,httpx 会直接返回空代理配置,完全跳过所有代理。这是一个「核弹级」的代理禁用选项。
根因定位:系统代理 + LaunchAgent 网络隔离 = 连接失败
经过反复测试,我终于定位到了完整的根因链。
macOS 的两套代理体系
macOS 上存在两套独立的代理配置:
| 来源 | 配置方式 | 影响范围 | 读取方式 |
|---|---|---|---|
| 环境变量 | export HTTP_PROXY=... |
终端及子进程 | os.environ |
| 系统代理 | 系统偏好设置 → 网络 → 代理 | GUI 应用、系统服务 | _scproxy / SCDynamicStoreCopyProxies |
当终端中没有设置代理环境变量时,httpx 会通过 _scproxy 读取系统代理设置。
LaunchAgent 的网络隔离问题
macOS 的 LaunchAgent 由 launchd 管理,虽然以当前用户身份运行,但网络路由可能与终端环境不同。特别是:
- 终端中可以正常访问的代理服务器,从 LaunchAgent 环境中可能
No route to host - 这是 macOS 安全沙箱和网络隔离策略导致的
- LaunchAgent 运行在
launchd的子进程中,可能没有完全继承终端的网络栈配置
完整的根因链
1. macOS 系统偏好设置中配置了代理服务器
↓
2. LaunchAgent 启动 Gateway 进程
↓
3. httpx 创建 AsyncClient 时 trust_env=True
↓
4. httpx 通过 _scproxy 读取到系统代理设置
↓
5. httpx 尝试通过代理连接企业微信 API
↓
6. LaunchAgent 环境中代理服务器不可达(No route to host)
↓
7. 连接失败,报错 "All connection attempts failed"
为什么钉钉没问题?
钉钉使用的是 Stream Mode(长连接),底层使用的是 requests 库,但它的连接建立过程不依赖 httpx 的代理检测机制。钉钉 SDK 有自己的网络连接逻辑,可能走了不同的代码路径,或者在连接建立时使用了不同的代理策略。
解决方案
理解了根因后,有几种解决方案:
方案一:NO_PROXY=*(推荐,最简单)
在 LaunchAgent 的 plist 文件中添加 NO_PROXY=*:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
<key>NO_PROXY</key>
<string>*</string>
</dict>
这会让 httpx 完全跳过代理,直接连接目标服务器。
方案二:移除系统代理设置
如果不需要系统代理,可以在系统偏好设置中关闭:
系统偏好设置 → 网络 → 高级 → 代理 → 取消所有代理勾选
但这会影响所有应用程序,不推荐。
方案三:在代码中显式禁用代理
如果你可以修改代码,可以在创建 httpx 客户端时显式设置 proxy=None:
client = httpx.AsyncClient(
timeout=20.0,
proxy=None, # 显式禁用代理
trust_env=False, # 不读取环境变量和系统代理
)
但这需要修改源代码,不够灵活。
推荐方案
方案一是最优解——不需要修改代码,不需要修改系统设置,只需要在 LaunchAgent 的 plist 中加一行。
验证修复
修改 plist 文件后,重新加载 LaunchAgent:
# 卸载旧配置
launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway.plist
# 重新加载
launchctl load ~/Library/LaunchAgents/ai.hermes.gateway.plist
查看 Gateway 日志:
[WecomCallback] Token refreshed for app 'default', expires in 7200s
[WecomCallback] ✓ wecom_callback connected
完美! Token 刷新成功,企业微信连接正常。
同时,钉钉连接也正常——说明 NO_PROXY=* 不会影响已经正常的连接。
深入理解:macOS 代理机制全景图
为了帮助大家更好地理解这个问题,我来详细解析 macOS 的代理机制。
三层代理配置
macOS 的代理配置分为三层:
第一层:系统偏好设置(System Preferences)
这是最直观的配置方式,通过图形界面设置:
系统偏好设置 → 网络 → 高级 → 代理
设置后,数据存储在 SystemConfiguration 框架中,可以通过 scutil --proxy 查看。
第二层:环境变量(Environment Variables)
通过 export HTTP_PROXY=... 设置,只影响当前终端会话和子进程。
第三层:应用层配置
某些应用(如浏览器、IDE)有自己的代理配置,独立于系统设置。
httpx 的代理检测优先级
httpx 的 trust_env=True 按以下优先级检测代理:
- 环境变量(
HTTP_PROXY、HTTPS_PROXY、NO_PROXY) - 系统代理(通过
_scproxy读取)
关键点:如果环境变量中有任何代理相关配置(包括 NO_PROXY),系统代理就不会被读取。
LaunchAgent 的特殊性
LaunchAgent 是 macOS 的后台服务管理机制,由 launchd 管理。它的特殊之处在于:
- 不通过 Shell 启动:不会读取
.zshrc、.bashrc等 Shell 配置文件 - 网络环境可能不同:虽然以当前用户身份运行,但网络路由可能与终端不同
- 环境变量需要显式设置:在 plist 的
EnvironmentVariables中配置 - 日志需要显式配置:通过
StandardOutPath和StandardErrorPath配置
常见陷阱与最佳实践
陷阱 1:只设置 HTTP_PROXY,不设置 NO_PROXY
<!-- 错误示例 -->
<key>HTTP_PROXY</key>
<string>http://proxy:port</string>
<!-- 缺少 NO_PROXY -->
这会导致所有请求都尝试通过代理,如果代理不可达,就会失败。
陷阱 2:NO_PROXY 设置不完整
<!-- 不够彻底 -->
<key>NO_PROXY</key>
<string>localhost,127.0.0.1</string>
这只排除了本地地址,其他地址仍然会尝试通过代理。
陷阱 3:以为终端和 LaunchAgent 环境一样
这是最常见的误区。终端中能正常运行的程序,放到 LaunchAgent 中可能会有各种问题,包括但不限于:
- 网络路由不同
- 环境变量缺失
- 文件路径不同
- 权限不同
- DNS 解析行为不同
陷阱 4:忽略 _scproxy 的存在
很多开发者不知道 macOS 有 _scproxy 这个机制,以为 httpx 只会读取环境变量。实际上,当环境变量为空时,httpx 会自动读取系统代理设置。
最佳实践
- LaunchAgent 中显式设置所有需要的环境变量,不要依赖继承
- 使用
NO_PROXY=*绕过系统代理,除非你确定 LaunchAgent 能访问代理 - 在 plist 中设置
StandardOutPath和StandardErrorPath,方便查看日志 - 使用
launchctl list | grep your-label检查 LaunchAgent 状态 - 在 LaunchAgent 中设置
PATH,确保能找到所有需要的命令
类似问题的其他场景
这个问题不仅限于 LaunchAgent,以下场景也可能遇到类似的代理陷阱:
Docker 容器中的代理问题
Docker 容器可能继承宿主机的代理设置,但容器内的网络环境与宿主机不同。解决方案类似:在 Dockerfile 或 docker-compose.yml 中显式设置 NO_PROXY。
CI/CD 环境中的代理问题
GitHub Actions、GitLab CI 等 CI/CD 环境中,代理设置可能与本地环境不同。建议在 CI 配置中显式设置所有代理相关环境变量。
SSH 远程执行中的代理问题
通过 SSH 远程执行命令时,环境变量可能与 SSH 登录时不同。建议在远程命令中显式设置代理环境变量。
Q&A
Q1: 为什么直接运行就成功,LaunchAgent 运行就失败?
A: 因为 macOS 的 LaunchAgent 运行在 launchd 管理的隔离环境中。虽然它以当前用户身份运行,但网络路由可能与终端环境不同。特别是对于系统代理服务器,LaunchAgent 可能无法建立连接(No route to host)。
Q2: 我怎么知道我的程序是否受这个问题影响?
A: 如果你的 Python 程序使用 httpx 或 requests 库,并且设置了 trust_env=True(httpx 默认值),同时程序运行在 macOS LaunchAgent 中,那么你可能会遇到这个问题。
检查方法:
# 查看系统代理设置
scutil --proxy
# 查看 LaunchAgent 进程的环境变量
ps eww -p <PID> | tr ' ' '\n' | grep -i proxy
# 测试代理连通性(从 LaunchAgent 环境)
# 需要先在 plist 中设置好环境变量,然后重启 LaunchAgent
Q3: NO_PROXY=* 会不会影响其他功能?
A: 不会。NO_PROXY=* 只是告诉 httpx 不要使用代理,直接连接目标服务器。如果你的网络环境允许直连(大部分家庭和办公网络都支持),那么这个设置不会影响任何功能。
Q4: 除了 httpx,requests 库有这个问题吗?
A: 是的。Python 的 requests 库也有类似的 trust_env 参数,默认为 True。如果 requests 库的程序运行在 LaunchAgent 中,也可能遇到同样的问题。解决方案相同:添加 NO_PROXY=*。
Q5: Linux 的 systemd 服务有这个问题吗?
A: Linux 的 systemd 服务通常不会遇到这个问题,因为 systemd 服务的网络环境和终端环境是一致的。但如果你的 systemd 服务配置了特定的网络命名空间或安全策略,可能会有类似的问题。
Q6: 我可以在 plist 中同时设置 HTTP_PROXY 和 NO_PROXY=* 吗?
A: 可以,但 NO_PROXY=* 会让 httpx 忽略 HTTP_PROXY 设置。如果你的目标是让某些请求走代理、某些不走,需要更精细地设置 NO_PROXY(比如 NO_PROXY=localhost,127.0.0.1,internal.company.com)。
Q7: 如何调试 LaunchAgent 的网络问题?
A: 可以通过以下步骤:
- 在 plist 中设置
StandardOutPath和StandardErrorPath - 在程序中添加详细的日志输出
- 使用
ps eww -p <PID>检查进程环境变量 - 使用
netstat -an | grep <端口>检查网络连接状态 - 使用
tcpdump或Wireshark抓包分析
总结
这次排查的核心教训是:
- macOS LaunchAgent 的网络环境和终端环境不完全相同,特别是对于系统代理服务器的访问
- Python httpx 库的
trust_env=True不只是读取环境变量,还会在环境变量为空时读取 macOS 系统代理设置(通过_scproxy) - 当代理不可达时,httpx 会报
All connection attempts failed,这个错误信息不够明确,容易误导排查方向 - 解决方案很简单:在 LaunchAgent 的 plist 文件中添加
NO_PROXY=*
macOS 的代理机制对很多开发者来说是个黑盒。希望通过这篇文章,大家能对 macOS 的代理体系、Python httpx 的代理检测机制、以及 LaunchAgent 的运行环境有更深入的理解。如果你有其他关于 macOS LaunchAgent 或 Python 代理配置的问题,欢迎在评论区讨论!