中文 English

macOS LaunchAgent 里的 Python 程序连不上外网?一文搞懂 httpx 代理陷阱的完整排查与修复

发布时间: 2026-05-30
macOS Python httpx Proxy LaunchAgent Launchctl Networking AI Agent WeCom Debugging Sysadmin AI DevOps

先说结论:

macOS 的 LaunchAgent 环境下,Python httpx 库会自动读取系统代理设置(scutil --proxy),但 LaunchAgent 进程可能根本无法访问该代理服务器——于是请求静默失败,报错 All connection attempts failedNo route to host

解决方案只需要一行:在 LaunchAgent 的 plist 文件中添加 NO_PROXY=*

这篇文章记录了我从发现 AI Agent 企业微信消息不回复、到逐步排查代理问题、最终定位到 macOS 系统代理与 LaunchAgent 网络隔离的完整过程。涉及 Python httpx 源码分析、macOS 代理机制深度解析、以及 LaunchAgent 运行环境的诸多坑点。

macOS LaunchAgent + Python httpx 代理链路问题示意图

图 1:macOS LaunchAgent 中 Python httpx 的代理链路问题全景图。httpx 通过 trust_env=True 自动读取系统代理设置,但 LaunchAgent 进程无法访问代理服务器,导致连接失败。


问题背景:AI Agent 迁移后企业微信不回复

事情是这样的——最近我把家里的 AI Agent 系统从一个框架迁移到了另一个框架。AI Agent 是一个自驱动的智能体系统,可以自动执行定时任务、管理记忆、处理多平台消息。迁移完成后,大部分功能都正常工作:

唯独企业微信(WeCom)出了问题。

每次给企业微信的 AI Agent 发消息,都石沉大海,没有任何回复。查看 Gateway 日志,看到了这条熟悉的报错:

[WecomCallback] Initial token refresh failed for app 'default': All connection attempts failed

这就很奇怪了——同一个网络环境下,钉钉连接正常,为什么企业微信就不行?


第一轮排查:确认问题范围

首先,我需要确认问题到底出在哪里。企业微信的回调模式(Callback Mode)需要做两件事:

  1. 接收消息:企业微信 POST 加密 XML 到本地 HTTP 服务器(端口 8645)
  2. 发送回复:通过 Access Token 调用企业微信的 message/send API

接收消息的 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

所以:

那为什么 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_PROXYHTTPS_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()

这意味着:

  1. 优先读取环境变量HTTP_PROXYHTTPS_PROXYNO_PROXY
  2. 如果环境变量为空,才读取系统代理设置:在 macOS 上通过 _scproxy 模块调用 SCDynamicStoreCopyProxies(和 scutil --proxy 读取的是同一份数据)

但这里有个坑:即使 NO_PROXY 只设置了 localhost,127.0.0.1getproxies_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 管理,虽然以当前用户身份运行,但网络路由可能与终端环境不同。特别是:

完整的根因链

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 按以下优先级检测代理:

  1. 环境变量HTTP_PROXYHTTPS_PROXYNO_PROXY
  2. 系统代理(通过 _scproxy 读取)

关键点:如果环境变量中有任何代理相关配置(包括 NO_PROXY),系统代理就不会被读取。

LaunchAgent 的特殊性

LaunchAgent 是 macOS 的后台服务管理机制,由 launchd 管理。它的特殊之处在于:

  1. 不通过 Shell 启动:不会读取 .zshrc.bashrc 等 Shell 配置文件
  2. 网络环境可能不同:虽然以当前用户身份运行,但网络路由可能与终端不同
  3. 环境变量需要显式设置:在 plist 的 EnvironmentVariables 中配置
  4. 日志需要显式配置:通过 StandardOutPathStandardErrorPath 配置

常见陷阱与最佳实践

陷阱 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 中可能会有各种问题,包括但不限于:

陷阱 4:忽略 _scproxy 的存在

很多开发者不知道 macOS 有 _scproxy 这个机制,以为 httpx 只会读取环境变量。实际上,当环境变量为空时,httpx 会自动读取系统代理设置。

最佳实践

  1. LaunchAgent 中显式设置所有需要的环境变量,不要依赖继承
  2. 使用 NO_PROXY=* 绕过系统代理,除非你确定 LaunchAgent 能访问代理
  3. 在 plist 中设置 StandardOutPathStandardErrorPath,方便查看日志
  4. 使用 launchctl list | grep your-label 检查 LaunchAgent 状态
  5. 在 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: 可以通过以下步骤:

  1. 在 plist 中设置 StandardOutPathStandardErrorPath
  2. 在程序中添加详细的日志输出
  3. 使用 ps eww -p <PID> 检查进程环境变量
  4. 使用 netstat -an | grep <端口> 检查网络连接状态
  5. 使用 tcpdumpWireshark 抓包分析

总结

这次排查的核心教训是:

  1. macOS LaunchAgent 的网络环境和终端环境不完全相同,特别是对于系统代理服务器的访问
  2. Python httpx 库的 trust_env=True 不只是读取环境变量,还会在环境变量为空时读取 macOS 系统代理设置(通过 _scproxy
  3. 当代理不可达时,httpx 会报 All connection attempts failed,这个错误信息不够明确,容易误导排查方向
  4. 解决方案很简单:在 LaunchAgent 的 plist 文件中添加 NO_PROXY=*

macOS 的代理机制对很多开发者来说是个黑盒。希望通过这篇文章,大家能对 macOS 的代理体系、Python httpx 的代理检测机制、以及 LaunchAgent 的运行环境有更深入的理解。如果你有其他关于 macOS LaunchAgent 或 Python 代理配置的问题,欢迎在评论区讨论!


参考资料